def test_dsr(): from soulstruct import DSR_PATH item_msgbnd = BND(DSR_PATH / 'msg/ENGLISH/item.msgbnd.dcx') print("item.msgbnd.dcx:") for entry_id, entry in item_msgbnd.entries_by_id.items(): print(f' {entry_id}: {entry.path}') menu_msgbnd = BND(DSR_PATH / 'msg/ENGLISH/menu.msgbnd.dcx') print("menu.msgbnd.dcx:") for entry_id, entry in menu_msgbnd.entries_by_id.items(): print(f' {entry_id}: {entry.path}') dsr_text = DarkSoulsText(DSR_PATH / 'msg' / 'ENGLISH') print(dsr_text.WeaponNames[9014000]) print(('WeaponNames', 9014000) in dsr_text._is_patch) dsr_text.save('test_dsr_text', separate_patch=True) item_msgbnd = BND('test_dsr_text/item.msgbnd.dcx') print("item.msgbnd.dcx:") for entry_id, entry in item_msgbnd.entries_by_id.items(): print(f' {entry_id}: {entry.path}') menu_msgbnd = BND('test_dsr_text/menu.msgbnd.dcx') print("menu.msgbnd.dcx:") for entry_id, entry in menu_msgbnd.entries_by_id.items(): print(f' {entry_id}: {entry.path}') new_text = DarkSoulsText('test_dsr_text') print(new_text.WeaponNames[9014000]) print(('WeaponNames', 9014000) in new_text._is_patch) print(new_text._is_patch)
def __init__(self, script_directory=None): """Unpack Dark Souls AI LuaBND scripts into one single modifiable structure. Note that the vanilla game uses pre-compiled Lua bytecode for a minor efficiency upgrade, but uncompiled Lua scripts work just as well. Use the `.decompile_all()` method to generate them from any compiled scripts. The same script will work in both PTDE and DSR, but decompiling is only offered (with Katalash's 'DSLuaDecompiler' tool) for DSR. PTDE files have the variable names stripped anyway, which makes them much harder to edit even when decompiled. If you want to customize PTDE scripts, I recommend doing all the work in DSR and then just copying the scripts over. `aiCommon.luabnd` ('Common') contains scripts that are loaded in all maps, which includes internal scripts with global functions that are accessed in every script. If you want to decompile these internal scripts, you must call `DarkSoulsAIScripts().Common.decompile(battle_or_logic_only=False)`, and similar to recompile them (for testing) or save the decompiled scripts into the LuaBND. `eventCommon.luabnd` contains scripts that are solely internal. This BND is unpacked in `.event_common_bnd` for manual inspection and modification, if you really want, but this shouldn't be necessary. It is written automatically by `.save()`. Note that you only need to reload the map (e.g. by saving and quitting, or getting sufficiently far away from the map) to see changes in these files while playing the game. You do NOT need to fully restart the game, unlike with text and parameter/lighting changes, *UNLESS* you need to reload a script in Common. Args: script_directory: Directory where all the `.luabnd` files are stored. This will be inside 'script' in your game directory (either version). """ self._directory = None self._data = {} self.event_common_bnd = None if script_directory is None: return self._directory = Path(script_directory) if not self._directory.is_dir(): raise ValueError( "DarkSoulsAIScripts should be initialized with the directory containing LuaBND files." ) for game_map in ALL_MAPS: luabnd_path = self._directory / (game_map.ai_file_stem + '.luabnd') try: self._data[game_map.name] = LuaBND(luabnd_path) setattr(self, game_map.name, self._data[game_map.name]) except FileNotFoundError: raise FileNotFoundError( f"Could not find LuaBND file {repr(game_map.ai_file_stem)} ({game_map.name}) in given directory." ) event_path = self._directory / "eventCommon.luabnd" try: self.event_common_bnd = BND(event_path) except FileNotFoundError: raise FileNotFoundError( "Could not find `eventCommon.luabnd[.dcx]` file in given directory." )
def __init__(self, talkesdbnd_source, game_version=None): if game_version not in (None, "ptde", "dsr"): raise ValueError( f"`game_version` should be 'ptde', 'dsr', or None (to auto-detect), not {game_version}." ) if game_version: self.esd_class = ESD_DSR if game_version == "dsr" else ESD_PTDE self.talk = OrderedDict() self.game_version = game_version if isinstance(talkesdbnd_source, (str, Path)): if talkesdbnd_source.is_file(): # Single `.talkesdbnd` files. Game version can be detected automatically in this case. self.bnd = BND(talkesdbnd_source) # path is remembered in BND self.bnd_name = Path(talkesdbnd_source).name self.unpack_from_bnd() elif talkesdbnd_source.is_dir(): # Directory of individual ESP files/folders. self.bnd_name = "<from ESP>" if not self.game_version: raise ValueError( "`game_version` must be specified ('ptde' or 'dsr') when loading TalkESDBND from " "ESP files/folders.") self.bnd = self.get_empty_talkesdbnd() self.bnd_name = talkesdbnd_source.name self.bnd.dcx = self.DSR_DCX_MAGIC if self.game_version == "dsr" else ( ) self.reload_all_esp(talkesdbnd_source, allow_new=True) self.update_bnd() elif isinstance(talkesdbnd_source, dict): # Note that `bnd_name` cannot be detected and must be passed to `write()` manually. self.bnd_name = "<from dict>" if not self.game_version: raise ValueError( "`game_version` must be specified ('ptde' or 'dsr') when loading TalkESDBND from " "dictionary.") self.bnd = self.get_empty_talkesdbnd() self.bnd.dcx = self.DSR_DCX_MAGIC if self.game_version == "dsr" else ( ) self.unpack_from_dict(talkesdbnd_source)
def _unpack_bnd(self): target = self.FileDialog.askopenfilename( title="Choose BND File to Unpack", initialdir=str(self.project.game_root) ) if target is None: return if not re.match(r".*\.[a-z]*bnd(\.dcx)?$", target): return self.CustomDialog( title="Invalid BND File", message=f"A valid BND file (with or without DCX) must be selected." ) BND(target).write_unpacked_dir()
def _repack_bnd(self): target = self.FileDialog.askdirectory( title="Choose Unpacked BND Directory to Repack", initialdir=str(self.project.game_root) ) if target is None: return if not re.match(r".*\.[a-z]*bnd", target): return self.CustomDialog( title="Invalid Directory", message=f"A valid unpacked BND directory (with a 'bnd_manifest.txt' file) must be selected.", ) BND(target).write()
class TalkESDBND: """Automatically loads all talk ESDs contained inside given path, or constructs BND from scratch using dictionary mapping talk IDs to valid ESD instance sources. By default, game version is automatically detected using BND paths. Currently only supported for DS1, hence its placement in this module. """ DSR_DCX_MAGIC = (36, 44) DS1_BND_PATH_FMT = "N:\\FRPG\\data\\INTERROOT_{version}\\script\\talk\\t{talk_id}.esd" bnd: BaseBND def __init__(self, talkesdbnd_source, game_version=None): if game_version not in (None, "ptde", "dsr"): raise ValueError( f"`game_version` should be 'ptde', 'dsr', or None (to auto-detect), not {game_version}." ) if game_version: self.esd_class = ESD_DSR if game_version == "dsr" else ESD_PTDE self.talk = OrderedDict() self.game_version = game_version if isinstance(talkesdbnd_source, (str, Path)): if talkesdbnd_source.is_file(): # Single `.talkesdbnd` files. Game version can be detected automatically in this case. self.bnd = BND(talkesdbnd_source) # path is remembered in BND self.bnd_name = Path(talkesdbnd_source).name self.unpack_from_bnd() elif talkesdbnd_source.is_dir(): # Directory of individual ESP files/folders. self.bnd_name = "<from ESP>" if not self.game_version: raise ValueError( "`game_version` must be specified ('ptde' or 'dsr') when loading TalkESDBND from " "ESP files/folders.") self.bnd = self.get_empty_talkesdbnd() self.bnd_name = talkesdbnd_source.name self.bnd.dcx = self.DSR_DCX_MAGIC if self.game_version == "dsr" else ( ) self.reload_all_esp(talkesdbnd_source, allow_new=True) self.update_bnd() elif isinstance(talkesdbnd_source, dict): # Note that `bnd_name` cannot be detected and must be passed to `write()` manually. self.bnd_name = "<from dict>" if not self.game_version: raise ValueError( "`game_version` must be specified ('ptde' or 'dsr') when loading TalkESDBND from " "dictionary.") self.bnd = self.get_empty_talkesdbnd() self.bnd.dcx = self.DSR_DCX_MAGIC if self.game_version == "dsr" else ( ) self.unpack_from_dict(talkesdbnd_source) def unpack_from_bnd(self): self.talk = OrderedDict() for entry in self.bnd: entry_path = Path(entry.path) if self.game_version is None: if "INTERROOT_x64" in entry.path: self.game_version = "dsr" elif "INTERROOT_win32" in entry.path: self.game_version = "ptde" else: raise ValueError( f"Could not detect DS1 version from path: {entry.path}" ) self.esd_class = ESD_DSR if self.game_version == "dsr" else ESD_PTDE talk_match = _TALK_ESD_RE.match(entry_path.name) if talk_match: talk_id = int(talk_match.group(1)) try: self.talk[talk_id] = self.esd_class(entry.data, "talk") except Exception as e: _LOGGER.error( f"Encountered error when trying to load talk ESD {talk_id}: {e}" ) raise else: _LOGGER.warning( f"Unexpected file in TalkESDBND: {entry_path.name}") def unpack_from_dict(self, talk_dict): i = 1 for talk_id, esd_source in talk_dict.items(): if not isinstance(talk_id, int): raise ValueError( "Keys of `talkesdbnd_source` dict must be integer talk IDs." ) try: esd = self.esd_class(esd_source) except Exception as e: _LOGGER.error( f"Could not interpret ESD source with talk ID {talk_id}. Error: {str(e)}" ) raise bnd_path = self.DS1_BND_PATH_FMT.format( version="x64" if self.game_version == "dsr" else "win32", talk_id=talk_id) self.talk[talk_id] = esd self.bnd.add_entry( BNDEntry(data=esd.pack(), entry_id=i, path=bnd_path)) i += 1 def __getitem__(self, talk_id): return self.talk[talk_id] def __iter__(self): return iter(self.talk) def __repr__(self): return f"TalkESDBND({repr(self.bnd_name)}): {list(self.talk)}" def write_all_esp(self, directory): directory = Path(directory) directory.mkdir(parents=True, exist_ok=True) for talk_id, talk_esd in self.talk.items(): talk_esd.write_esp(directory / f"t{talk_id}.esp") def reload_all_esp(self, directory, allow_new=True): directory = Path(directory) for esp_source in directory.glob("*.esp*"): talk_match = _TALK_ESP_RE.match(esp_source.name) if talk_match: talk_id = int(talk_match.group(1)) if not allow_new and talk_id not in self.talk: _LOGGER.warning( f"# WARNING: `allow_new=False` and no talk ID found for ESP source: {esp_source.name}. " f"Ignoring it.") continue try: self.talk[talk_id] = self.esd_class(esp_source) except Exception as e: _LOGGER.error( f"Could not load talk ESD 't{talk_id}' from ESP source {esp_source.name}. Error: {e}" ) raise def update_bnd(self): for talk_id, talk_entry in self.talk.items(): bnd_path = self.DS1_BND_PATH_FMT.format( version="x64" if self.game_version == "dsr" else "win32", talk_id=talk_id) if bnd_path in self.bnd.entries_by_path: self.bnd.entries_by_path[bnd_path].data = talk_entry.pack() else: new_id = max([entry.id for entry in self.bnd.entries ]) + 1 if self.bnd.entries else 1 new_entry = BNDEntry(data=talk_entry.pack(), entry_id=new_id, path=bnd_path) self.bnd.add_entry(new_entry) _LOGGER.debug( f"New ESD entry added to TalkESDBND: t{talk_id}.esd") def write(self, talkesdbnd_path=None): self.update_bnd() self.bnd.write(talkesdbnd_path) @classmethod def write_from_dict(cls, talk_dict, game_version, talkesdbnd_path): """Shortcut to immediately load given dictionary and write to given path without pointless BND update.""" talkesdbnd = cls(talk_dict, game_version=game_version) talkesdbnd.bnd.write(talkesdbnd_path) @staticmethod def get_empty_talkesdbnd(): """Get empty pickled `.talkesdbnd` file for Dark Souls 1 (either version).""" with PACKAGE_PATH("project/resources/empty_talkesdbnd.ds1").open( "rb") as f: return pickle.load(f)
class DarkSoulsAIScripts(object): Common: LuaBND # scripts loaded in all maps; also contains internal functions that should not be edited Event: LuaBND # internal functions; never processed automatically Depths: LuaBND UndeadBurg: LuaBND # and Undead Parish FirelinkShrine: LuaBND PaintedWorld: LuaBND DarkrootGarden: LuaBND # and Darkroot Basin Oolacile: LuaBND # and all DLC Catacombs: LuaBND TombOfTheGiants: LuaBND AshLake: LuaBND # and Great Hollow Blighttown: LuaBND LostIzalith: LuaBND # and Demon Ruins SensFortress: LuaBND AnorLondo: LuaBND NewLondoRuins: LuaBND # and Valley of Drakes DukesArchives: LuaBND KilnOfTheFirstFlame: LuaBND UndeadAsylum: LuaBND def __init__(self, script_directory=None): """Unpack Dark Souls AI LuaBND scripts into one single modifiable structure. Note that the vanilla game uses pre-compiled Lua bytecode for a minor efficiency upgrade, but uncompiled Lua scripts work just as well. Use the `.decompile_all()` method to generate them from any compiled scripts. The same script will work in both PTDE and DSR, but decompiling is only offered (with Katalash's 'DSLuaDecompiler' tool) for DSR. PTDE files have the variable names stripped anyway, which makes them much harder to edit even when decompiled. If you want to customize PTDE scripts, I recommend doing all the work in DSR and then just copying the scripts over. `aiCommon.luabnd` ('Common') contains scripts that are loaded in all maps, which includes internal scripts with global functions that are accessed in every script. If you want to decompile these internal scripts, you must call `DarkSoulsAIScripts().Common.decompile(battle_or_logic_only=False)`, and similar to recompile them (for testing) or save the decompiled scripts into the LuaBND. `eventCommon.luabnd` contains scripts that are solely internal. This BND is unpacked in `.event_common_bnd` for manual inspection and modification, if you really want, but this shouldn't be necessary. It is written automatically by `.save()`. Note that you only need to reload the map (e.g. by saving and quitting, or getting sufficiently far away from the map) to see changes in these files while playing the game. You do NOT need to fully restart the game, unlike with text and parameter/lighting changes, *UNLESS* you need to reload a script in Common. Args: script_directory: Directory where all the `.luabnd` files are stored. This will be inside 'script' in your game directory (either version). """ self._directory = None self._data = {} self.event_common_bnd = None if script_directory is None: return self._directory = Path(script_directory) if not self._directory.is_dir(): raise ValueError( "DarkSoulsAIScripts should be initialized with the directory containing LuaBND files." ) for game_map in ALL_MAPS: luabnd_path = self._directory / (game_map.ai_file_stem + '.luabnd') try: self._data[game_map.name] = LuaBND(luabnd_path) setattr(self, game_map.name, self._data[game_map.name]) except FileNotFoundError: raise FileNotFoundError( f"Could not find LuaBND file {repr(game_map.ai_file_stem)} ({game_map.name}) in given directory." ) event_path = self._directory / "eventCommon.luabnd" try: self.event_common_bnd = BND(event_path) except FileNotFoundError: raise FileNotFoundError( "Could not find `eventCommon.luabnd[.dcx]` file in given directory." ) def __getitem__(self, map_source): game_map = get_map(map_source) return self._data[game_map.name] def save(self, script_directory=None): if script_directory is None: script_directory = self._directory script_directory = Path(script_directory) for luabnd in self._data.values(): luabnd_path = script_directory / luabnd.bnd.bnd_path.name luabnd.write(luabnd_path) event_common_path = script_directory / self.event_common_bnd.bnd_path.name self.event_common_bnd.write(event_common_path) _LOGGER.info( "Dark Souls AI script files (LuaBND) written successfully.") def compile_all(self, output_directory=None, including_other=False): for bnd_name, luabnd in self._data.items(): _LOGGER.info(f"Compiling Lua scripts in {bnd_name}...") luabnd.compile_all(output_directory=output_directory, including_other=including_other) def decompile_all(self, output_directory=None, including_other=False): for bnd_name, luabnd in self._data.items(): _LOGGER.info(f"Decompiling Lua scripts in {bnd_name}...") luabnd.decompile_all(output_directory=output_directory, including_other=including_other)
def soulstruct_main(ss_args): try: console_log_level = int(ss_args.consoleLogLevel) except ValueError: if ss_args.consoleLogLevel.lower() not in LOG_LEVELS: raise argparse.ArgumentError( ss_args.consoleLogLevel, f"Log level must be one of: {LOG_LEVELS}") console_log_level = getattr(logging, ss_args.consoleLogLevel.upper()) CONSOLE_HANDLER.setLevel(console_log_level) try: file_log_level = int(ss_args.fileLogLevel) except ValueError: if ss_args.fileLogLevel.lower() not in LOG_LEVELS: raise argparse.ArgumentError( ss_args.fileLogLevel, f"Log level must be one of: {LOG_LEVELS}") file_log_level = getattr(logging, ss_args.fileLogLevel.upper()) FILE_HANDLER.setLevel(file_log_level) source = None if not ss_args.source else ss_args.source if ss_args.modmanager: from soulstruct.utilities.mod_manager import ModManagerWindow ModManagerWindow().wait_window() return ss_args.console if ss_args.text: from soulstruct.maps import DarkSoulsMaps global Maps Maps = DarkSoulsMaps(source) return ss_args.console if ss_args.text: from soulstruct.text import DarkSoulsText global Text Text = DarkSoulsText(source) return ss_args.console if ss_args.params: from soulstruct.params import DarkSoulsGameParameters global Params Params = DarkSoulsGameParameters(source) return ss_args.console if ss_args.lighting: from soulstruct.params import DarkSoulsLightingParameters global Lighting Lighting = DarkSoulsLightingParameters(source) return ss_args.console if ss_args.bndpack is not None: from soulstruct.bnd import BND bnd = BND(ss_args.bndpack) bnd.write() return if ss_args.bndunpack is not None: from soulstruct.bnd import BND bnd = BND(ss_args.bndunpack) bnd.write_unpacked_dir() return if ss_args.ai: from soulstruct.ai import DarkSoulsAIScripts global AI AI = DarkSoulsAIScripts(source) return ss_args.console # No specific type. Open entire Soulstruct Project. if ss_args.console: from soulstruct.project import SoulstructProject global Project Project = SoulstructProject(source) else: from soulstruct.project import SoulstructProjectWindow window = SoulstructProjectWindow(source) window.wait_window() # MAIN LOOP return ss_args.console
"gaurd_break_attack", "gaurd_break_tunable", "goal_list", "goal_list_dlc", "keep_dist_yaxis", "logic_list", "nonspinning_attack", "npc_subgoals", "obj_act", "specialTurn", "top_goal", # known decompile error "turnaround", ] if __name__ == '__main__': aiCommon = BND(AI_COMMON_PATH) for entry in AI_COMMON_LIST: try: compiled_lua = aiCommon.entries_by_basename[entry + '.lua'] except KeyError: print(f"Could not find {entry} in aiCommon.luabnd.dcx") continue try: output_path = Path( f"ai_scripts/aiCommon_Funcs/{entry}.lua").resolve() decompile_lua(compiled_lua.data, script_name=entry + ".lua", output_path=output_path) except Exception as e: import traceback traceback.print_exc()
def __init__(self, msg_directory=None): """Unpack all Dark Souls 1 text data (from both 'item' and 'menu' MSGBND files) into one unified structure. You can access and modify the `entries` attributes of each loaded `FMG` instance using the names of the FMGs, which I have translated into a standard list (see above attribute hints). The source (item/menu) of the FMG and its type (standard/Patch) are handled internally, though the latter does not matter in either version of DS1 (and neither do the names of the FMG files in the MSGBND archives). Args: msg_directory: Directory containing 'item.msgbnd[.dcx]' and 'menu.msgbnd[.dcx]', in any of the language folders within the 'msg' directory in the Dark Souls data files. """ self._bnd_dir = '' self._directory = None self._original_names = {} # The actual within-BND FMG names are completely up to us. My names are nicer. self._is_menu = {} # Records whether each FMG belongs in 'item' or 'menu' MSGBND. self._data = {} self._dcx = False # Patch and non-Patch resources get put into the same attribute here so they can be edited together. # This set contains tuple pairs (fmg_name, entry_index) that came from Patch resources. self._is_patch = set() if msg_directory is None: self.item_msgbnd = None self.menu_msgbnd = None return self._directory = Path(msg_directory) try: self.item_msgbnd = BND(self._directory / 'item.msgbnd.dcx', optional_dcx=False) except FileNotFoundError: self.item_msgbnd = BND(self._directory / 'item.msgbnd', optional_dcx=False) self._dcx = False else: self._dcx = True if (self._directory / 'item.msgbnd').is_file(): _LOGGER.warning( "Both DCX and non-DCX 'item.msgbnd' resources were found. Reading only the DCX file, " "and will compress with DCX by default when `.write()` is called.") try: self.menu_msgbnd = BND(self._directory / 'menu.msgbnd.dcx', optional_dcx=False) except FileNotFoundError: self.menu_msgbnd = BND(self._directory / 'menu.msgbnd', optional_dcx=False) if self._dcx: raise ValueError("Found DCX-compressed 'item.msgbnd.dcx', but not 'menu.msgbnd.dcx'.") else: if not self._dcx: raise ValueError("Found DCX-compressed 'menu.msgbnd.dcx', but not 'item.msgbnd.dcx'.") if (self._directory / 'menu.msgbnd').is_file(): _LOGGER.warning( "Both DCX and non-DCX 'menu.msgbnd' resources were found. Reading only the DCX file, " "and will compress with DCX by default when `.write()` is called.") self.load_fmg_entries_from_bnd(self.item_msgbnd, is_menu=False) self.load_fmg_entries_from_bnd(self.menu_msgbnd, is_menu=True) for key, fmg_dict in self._data.items(): setattr(self, key, fmg_dict)
def __init__(self, luabnd_path, sort_goals=True): self.bnd = BND(luabnd_path) # path is remembered in BND self.goals = [] # type: List[LuaGoal] self.other = [] # type: List[LuaOther] self.global_names = [] self.gnl = LuaGNL() self.bnd_name = "" self._bnd_path_parent = None self.is_lua_64 = False try: gnl_entry = self.bnd.entries_by_id[1000000] except KeyError: raise LuaError(f"Could not find `.luagnl` file in LuaBND: {str(luabnd_path)}") self.global_names = LuaGNL(gnl_entry.data).names try: info_entry = self.bnd.entries_by_id[1000001] except KeyError: # TODO: 'eventCommon.luabnd' has no '.luainfo' file. Not handling this yet. raise LuaError(f"Could not find `.luainfo` file in LuaBND: {str(luabnd_path)}") else: self.goals = LuaInfo(info_entry.data).goals self.bnd_name = Path(info_entry.path).stem for entry in self.bnd: entry_path = Path(entry.path) goal_match = _GOAL_SCRIPT_RE.match(entry_path.name) if goal_match: goal_id, goal_type = goal_match.group(1, 2) goal_id = int(goal_id) try: goal = self.get_goal(goal_id, goal_type) except KeyError: _LOGGER.warning( f"Lua file {entry_path.name} has no corresponding goal and will not be loaded.\n" f"(Future versions of Soulstruct may guess the goal name.)") continue try: goal.script = entry.data.decode("shift-jis") except UnicodeDecodeError: goal.bytecode = entry.data if self._bnd_path_parent is None: self._bnd_path_parent = str(Path(entry.path).parent) self.is_lua_64 = "INTERROOT_x64" in self._bnd_path_parent elif entry.id not in {1000000, 1000001}: lua_match = _LUA_SCRIPT_RE.match(entry_path.name) if not lua_match: _LOGGER.warning(f"Found non-Lua file with BND path: '{entry.path}'. File will be ignored.") continue for goal in self.goals: snake_name = _SNAKE_CASE_RE.sub("_", goal.goal_name).lower() if lua_match.group(1) == snake_name: goal.bytecode = entry.data goal.script_name = entry_path.name break else: self.other.append(LuaOther(entry_path.stem, bytecode=entry.data)) if sort_goals: self.goals = sorted(self.goals, key=lambda g: (g.goal_id, g.goal_type))
class LuaBND(object): """Automatically loads all scripts, LuaInfo, and LuaGNL objects.""" def __init__(self, luabnd_path, sort_goals=True): self.bnd = BND(luabnd_path) # path is remembered in BND self.goals = [] # type: List[LuaGoal] self.other = [] # type: List[LuaOther] self.global_names = [] self.gnl = LuaGNL() self.bnd_name = "" self._bnd_path_parent = None self.is_lua_64 = False try: gnl_entry = self.bnd.entries_by_id[1000000] except KeyError: raise LuaError(f"Could not find `.luagnl` file in LuaBND: {str(luabnd_path)}") self.global_names = LuaGNL(gnl_entry.data).names try: info_entry = self.bnd.entries_by_id[1000001] except KeyError: # TODO: 'eventCommon.luabnd' has no '.luainfo' file. Not handling this yet. raise LuaError(f"Could not find `.luainfo` file in LuaBND: {str(luabnd_path)}") else: self.goals = LuaInfo(info_entry.data).goals self.bnd_name = Path(info_entry.path).stem for entry in self.bnd: entry_path = Path(entry.path) goal_match = _GOAL_SCRIPT_RE.match(entry_path.name) if goal_match: goal_id, goal_type = goal_match.group(1, 2) goal_id = int(goal_id) try: goal = self.get_goal(goal_id, goal_type) except KeyError: _LOGGER.warning( f"Lua file {entry_path.name} has no corresponding goal and will not be loaded.\n" f"(Future versions of Soulstruct may guess the goal name.)") continue try: goal.script = entry.data.decode("shift-jis") except UnicodeDecodeError: goal.bytecode = entry.data if self._bnd_path_parent is None: self._bnd_path_parent = str(Path(entry.path).parent) self.is_lua_64 = "INTERROOT_x64" in self._bnd_path_parent elif entry.id not in {1000000, 1000001}: lua_match = _LUA_SCRIPT_RE.match(entry_path.name) if not lua_match: _LOGGER.warning(f"Found non-Lua file with BND path: '{entry.path}'. File will be ignored.") continue for goal in self.goals: snake_name = _SNAKE_CASE_RE.sub("_", goal.goal_name).lower() if lua_match.group(1) == snake_name: goal.bytecode = entry.data goal.script_name = entry_path.name break else: self.other.append(LuaOther(entry_path.stem, bytecode=entry.data)) if sort_goals: self.goals = sorted(self.goals, key=lambda g: (g.goal_id, g.goal_type)) def compile_all(self, output_directory=None, including_other=False): """Compile all goals (and optionally, other Lua scripts) to Lua bytecode. Not necessary for writing game files, but useful to test for script syntax errors. Any files that produce errors will be reported, but will not halt the function. """ for goal in self.goals: output_path = Path(output_directory) / goal.script_name if output_directory else None try: goal.compile(output_path=output_path) except LuaDecompileError as e: _LOGGER.error(f"Could not compile Lua goal script '{goal.script_name}'. Error: {str(e)}") if including_other: for other in self.other: output_path = Path(output_directory) / other.script_name if output_directory else None try: other.compile(output_path=output_path) except LuaError as e: _LOGGER.error(f"Could not compile Lua non-goal script '{other.script_name}'. Error: {str(e)}") def decompile_all(self, output_directory=None, including_other=False): """Decompile all goals (and optionally, other Lua scripts). Any files that produce errors will be reported, but will not halt the function. """ if not self.is_lua_64: raise ValueError("Cannot decompile PTDE Lua scripts. Decompile the DSR scripts, then copy those " "into your PTDE Soulstruct project's 'ai' folder or call `.load_decompiled()`.") for goal in self.goals: output_path = Path(output_directory) / goal.script_name if output_directory else None try: goal.decompile(output_path=output_path) except LuaDecompileError as e: _LOGGER.error(f"Could not decompile Lua goal script '{goal.script_name}'. Error: {str(e)}") if including_other: for other in self.other: output_path = Path(output_directory) / other.script_name if output_directory else None try: other.decompile(output_path=output_path) except LuaError as e: _LOGGER.error(f"Could not decompile Lua non-goal script '{other.script_name}'. Error: {str(e)}") def update_bnd_from_decompiled(self, lua_file: LuaScriptBase, compile_script=True, x64=None): """Insert decompiled script into the BND. This will overwrite any existing scripts (compiled or decompiled) and create a new one if the BND entry is absent. """ if not lua_file.script: raise LuaError(f"No decompiled Lua script exists for '{lua_file.script_name}'.") if compile_script: if x64 is None: raise ValueError("`x64` must be specified to test if script compiles.") lua_file.compile(x64=x64) bnd_path = self._bnd_path_parent + f"\\{lua_file.script_name}" if bnd_path in self.bnd.entries_by_path: self.bnd.entries_by_path[bnd_path].data = lua_file.script.encode("shift-jis") else: # Get next ID below 1000000 (GNL). new_id = max([entry.id for entry in self.bnd.entries if entry.id < 1000000]) + 1 new_entry = BNDEntry(data=lua_file.script.encode("shift-jis"), entry_id=new_id, path=bnd_path) self.bnd.add_entry(new_entry) _LOGGER.info(f"New decompiled script added to LuaBND[{new_id}]: {lua_file.script_name}") def load_decompiled(self, directory, including_other=False): """Load decompiled scripts from an arbitrary directory (e.g. to get DSR scripts into PTDE).""" directory = Path(directory) for lua_script in directory.glob("*.lua"): goal_match = _GOAL_SCRIPT_RE.match(lua_script.name) if goal_match: goal_id, goal_type = goal_match.group(1, 2) goal_id = int(goal_id) try: goal = self.get_goal(goal_id, goal_type) except KeyError: # TODO: parse script to guess what goal name should be (from Activate function name). _LOGGER.warning( f"# WARNING: No goal found for script {lua_script.name}. Ignoring file.\n" f"# (Future versions of Soulstruct may automatically create the goal.)") continue try: with lua_script.open("r", encoding="shift-jis") as f: goal.script = f.read() except UnicodeDecodeError: raise LuaError(f"Could not read Lua script '{lua_script.name}'. Are you sure it's not compiled?") elif including_other: lua_match = _LUA_SCRIPT_RE.match(lua_script.name) if not lua_match: continue try: with lua_script.open("r", encoding="shift-jis") as f: other_script = f.read() except UnicodeDecodeError: raise LuaError(f"Could not read Lua script '{lua_script.name}'. Are you sure it's not compiled?") matching_scripts = [g for g in self.other if g.script_name == lua_script.name] if not matching_scripts: # Create new LuaOther. self.other.append(LuaOther(lua_script.stem, script=other_script)) elif len(matching_scripts) >= 2: raise LuaError(f"Lua script {lua_script.name} has been loaded from LuaBND multiple times. You must " f"have done this intentionally, but it's not valid; you should delete one of them.") else: other = matching_scripts[0] other.script = other_script def write_all_decompiled_scripts(self, directory, including_other=True): directory = Path(directory) directory.mkdir(parents=True, exist_ok=True) for goal in self.goals: goal.write_decompiled(directory / goal.script_name) if including_other: for other in self.other: other.write_decompiled(directory / other.script_name) def update_bnd(self, use_decompiled_goals=True, use_decompiled_other=False, compile_scripts=True): self.bnd.entries_by_id[1000000].data = LuaGNL(self.global_names).pack() self.bnd.entries_by_id[1000001].data = LuaInfo(self.goals).pack() lua_list = [] if use_decompiled_goals: lua_list += self.goals if use_decompiled_other: lua_list += self.other for lua_list in (self.goals, self.other): for lua_entry in lua_list: if lua_entry.script: try: self.update_bnd_from_decompiled(lua_entry, compile_script=compile_scripts, x64=self.is_lua_64) except LuaCompileError: if lua_entry.bytecode: _LOGGER.error(f"Could not compile script {lua_entry.script_name}. Using existing bytecode.") else: raise LuaError(f"Could not compile script {lua_entry.script_name} and no bytecode exists " f"to use instead. BND update aborted (it may have been partially updated).") else: continue elif lua_entry.bytecode: # Fall back to compiled bytecode if it exists; otherwise, skip (e.g. 'Runaway' and 'Hide' goals). bnd_path = self._bnd_path_parent + f"\\{lua_entry.script_name}" if bnd_path in self.bnd.entries_by_path: self.bnd.entries_by_path[bnd_path].data = lua_entry.bytecode else: # Get next ID below 1000000 (GNL). new_id = max([entry.id for entry in self.bnd.entries if entry.id < 1000000]) + 1 new_entry = BNDEntry(data=lua_entry.bytecode, entry_id=new_id, path=bnd_path) self.bnd.add_entry(new_entry) _LOGGER.info(f"New compiled bytecode added to LuaBND[{new_id}]: {lua_entry.script_name}") elif (not lua_entry.goal_name.endswith("Runaway") and not lua_entry.goal_name.endswith("Hide") and lua_entry.goal_name not in DS1_GOALS_WITH_NO_SCRIPT): _LOGGER.warning(f"Skipping unexpected goal with no script or bytecode: {lua_entry.goal_name}") def write(self, luabnd_path=None, use_decompiled_goals=True, use_decompiled_other=False): self.update_bnd(use_decompiled_goals=use_decompiled_goals, use_decompiled_other=use_decompiled_other) self.bnd.write(luabnd_path) def get_goal(self, goal_id, goal_type) -> LuaGoal: if goal_type not in {LuaGoal.BATTLE_TYPE, LuaGoal.LOGIC_TYPE, LuaGoal.NEITHER_TYPE}: raise ValueError("goal_type must be 'battle', 'logic', or 'neither'.") goals = [g for g in self.goals if g.goal_id == goal_id and g.goal_type == goal_type] if not goals: raise KeyError(f"No goal in LuaBND with ID {goal_id} and type {repr(goal_type)}.") elif len(goals) >= 2: raise LuaError(f"Multiple {repr(goal_type)} goals with ID {goal_id}. This shouldn't happen.") return goals[0] def get_goal_index(self, goal_id, goal_type) -> int: goal = self.get_goal(goal_id, goal_type) return self.goals.index(goal) def get_goal_dict(self): return {(g.goal_id, g.goal_type): g for g in self.goals}