def from_chrbnd(cls, chrbnd_source: GameFile.Typing) -> FLVER: """Open CHRBND from given `chrbnd_source` and load its `.flver` file (with or without DCX extension). Will raise an exception if no FLVER files or multiple FLVER files exist in the BND. """ chrbnd = Binder(chrbnd_source) flver_entry = chrbnd.find_entry_matching_name(r".*\.flver(\.dcx)?") return cls(flver_entry)
def validate_model_subtype(self, model_subtype, name, map_id): """Check appropriate game model files to confirm the given model name is valid. Note that Character and Object models don't actually need `map_id` to validate them. """ model_subtype = MSBModelList.resolve_entry_subtype(model_subtype) dcx = ".dcx" if self.project.GAME.uses_dcx else "" if model_subtype == MSBModelSubtype.Character: if (self.project.game_root / f"chr/{name}.chrbnd{dcx}").is_file(): return True elif model_subtype == MSBModelSubtype.Object: if (self.project.game_root / f"obj/{name}.objbnd{dcx}").is_file(): return True elif model_subtype == MSBModelSubtype.MapPiece: if (self.project.game_root / f"map/{map_id}/{name}A{map_id[1:3]}.flver{dcx}").is_file(): return True elif model_subtype == MSBModelSubtype.Collision: # TODO: Rough BHD string scan until I have that file format. hkxbhd_path = self.project.game_root / f"map/{map_id}/h{map_id}.hkxbhd" if hkxbhd_path.is_file(): with hkxbhd_path.open("r") as f: if name + "A10.hkx" in f.read(): return True elif model_subtype == MSBModelSubtype.Navmesh: nvmbnd_path = self.project.game_root / f"map/{map_id}/{map_id}.nvmbnd{dcx}" if nvmbnd_path.is_file(): navmesh_bnd = Binder(nvmbnd_path) if name + "A10.nvm" in navmesh_bnd.entries_by_basename.keys(): return True return False
def __init__(self, msg_directory=None, use_json=False): """Unpack all text data in given folder (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 game-specific 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 (such as 'ENGLISH' or 'engus') within the 'msg' directory in your game installation. use_json: if `True`, will assume the source is a path to a folder containing `item_manifest.json`, `menu_manifest.json`, and JSON file for each text category in those manifests' `entries`. """ self.directory = None self._is_menu = { } # Records whether each FMG belongs in 'item' or 'menu' MSGBND. self._original_names = { } # Records original names of FMG files in BNDs. self.categories = {} # type: dict[str, dict[int, str]] if msg_directory is None: self.item_msgbnd = None self.menu_msgbnd = None return self.directory = Path(msg_directory) if use_json: # BNDs will be generated from information in manifests. self.item_msgbnd = None self.menu_msgbnd = None self.load_json_dir(msg_directory, clear_old_data=False) return ext = "msgbnd.dcx" if self.IS_DCX else "msgbnd" self.item_msgbnd = Binder(self.directory / f"item.{ext}") self.menu_msgbnd = Binder(self.directory / f"menu.{ext}") 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.categories.items(): setattr(self, key, fmg_dict)
def _repack_binder(self): target = self.FileDialog.askdirectory( title="Choose Unpacked BND/BHD/BDT Directory to Repack", initialdir=str(self.project.game_root)) if target is None: return if not re.match(r".*\.[a-z]*(bnd|bhd|bdt).*", target): return self.CustomDialog( title="Invalid Directory", message= f"An unpacked BND/BHD/BDT directory (with a 'binder_manifest.json' file) must be selected.", ) Binder(target).write()
def _unpack_binder(self): target = self.FileDialog.askopenfilename( title="Choose BND/BHD/BDT File to Unpack", initialdir=str(self.project.game_root)) if target is None: return if not re.match(r".*\.[a-z]*(bnd|bhd|bdt)(\.dcx)?$", target): return self.CustomDialog( title="Invalid BND/BHD/BDT File", message= f"A BND/BHD/BDT file (with or without DCX) must be selected.", ) Binder(target).write_unpacked_dir()
def collect_tpfs(cls, tpfbhd_directory: tp.Union[str, Path]) -> dict[str, TPF]: """Build a dictionary mapping TGA texture names to TPF instances.""" from soulstruct.containers import Binder tpf_re = re.compile(rf"(.*)\.tpf(\.dcx)?") tpfbhd_directory = Path(tpfbhd_directory) tpf_sources = {} for bhd_path in tpfbhd_directory.glob("*.tpfbhd"): bxf = Binder(bhd_path, create_bak_if_missing=False) for entry in bxf.entries: match = tpf_re.match(entry.name) if match: tpf_sources[f"{match.group(1)}.tga"] = TPF(entry.data) return tpf_sources
def __init__(self, paramdef_bnd_source=None): """BND container with all the `ParamDef` definitions for a given game. The latest versions of these files are included with Soulstruct for some games, and can be loaded simply by passing the game name to this constructor. They will also be loaded automatically when needed by `Param` instances. If you want to modify a `ParamDefBND`, you are far too powerful a modder for Soulstruct, and I cannot make that journey with you at this time. """ if paramdef_bnd_source is None: paramdef_bnd_source = self.GAME elif isinstance(paramdef_bnd_source, str): try: paramdef_bnd_source = get_game(paramdef_bnd_source) except ValueError: # Will try to use as a path. pass if isinstance(paramdef_bnd_source, Game): # Load vanilla ParamDefBND bundled with Soulstruct (easiest). if not paramdef_bnd_source.bundled_paramdef_path: raise NotImplementedError( f"Soulstruct does not have a bundled `paramdefbnd` file for game {paramdef_bnd_source.name}." ) paramdef_bnd_source = PACKAGE_PATH( paramdef_bnd_source.bundled_paramdef_path) if not paramdef_bnd_source.is_file(): raise FileNotFoundError( f"Could not find bundled `paramdefbnd` file for game {paramdef_bnd_source.name} in Soulstruct.\n" "Update/reinstall Soulstruct or copy the ParamDef files in yourself." ) self._bnd = Binder(paramdef_bnd_source) self.paramdefs = {} # type: dict[str, ParamDef] for entry in self.bnd.entries: try: paramdef = self.PARAMDEF_CLASS(entry) except Exception: _LOGGER.error( f"Could not load ParamDefBND entry {entry.name}.") raise if paramdef.param_type in self.paramdefs: raise KeyError( f"ParamDef type {paramdef.param_type} was loaded more than once in ParamDefBND." ) self.paramdefs[paramdef.param_type] = paramdef
def find_all_tpfs(self, tpfbhd_directory: tp.Union[None, str, Path] = None) -> dict[Path, TPF]: if tpfbhd_directory is None: tpfbhd_directory = self.get_tpfbhd_directory_path() else: tpfbhd_directory = Path(tpfbhd_directory) tpf_paths = [(p, re.compile(rf"{p.stem}\.tpf(\.dcx)?")) for p in self.get_all_texture_paths()] tpf_sources = {} for bhd_path in tpfbhd_directory.glob("*.tpfbhd"): bxf = Binder(bhd_path, create_bak_if_missing=False) for entry in bxf.entries: for tpf_path, tpf_re in reversed(tpf_paths): if tpf_re.match(entry.name): tpf_paths.remove((tpf_path, tpf_re)) tpf_sources[tpf_path] = TPF(entry.data) break if not tpf_paths: break if not tpf_paths: break return tpf_sources
def soulstruct_main(ss_args) -> bool: 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.maps: game = GameSelector("darksouls1ptde", "darksouls1r", "bloodborne").go() global Maps Maps = game.import_game_submodule("maps").MapStudioDirectory(source) return ss_args.console if ss_args.params: game = GameSelector("darksouls1ptde", "darksouls1r", "bloodborne").go() global Params Params = game.import_game_submodule("params").GameParamBND(source) return ss_args.console if ss_args.lighting: game = GameSelector("darksouls1ptde", "darksouls1r").go() global Lighting Lighting = game.import_game_submodule("lighting").DrawParamDirectory( source) return ss_args.console if ss_args.text: game = GameSelector("darksouls1ptde", "darksouls1r", "bloodborne", "darksouls3").go() global Text Text = game.import_game_submodule("text").MSGDirectory(source) return ss_args.console if ss_args.binderpack is not None: from soulstruct.containers import Binder binder = Binder(ss_args.binderpack) binder.write() return False if ss_args.binderunpack is not None: from soulstruct.containers import Binder binder = Binder(ss_args.binderunpack) binder.write_unpacked_dir() return False if ss_args.tpfunpack is not None: from soulstruct.containers.tpf import TPF tpf = TPF(ss_args.tpfunpack) tpf.write_unpacked_dir() return False if ss_args.tpfpack is not None: print("ERROR: TPF pack not yet implemented.") return False # No specific type. Open entire Soulstruct Project. game = get_existing_project_game(source) if source else None if game is None: game = GameSelector("darksouls1ptde", "darksouls1r", "bloodborne").go() if ss_args.console: # Console only. global Project Project = game.import_game_submodule("project").GameDirectoryProject( source) if ss_args.show_console_startup: if colorama_init: colorama_init() print( "\n" f"Starting interactive console. You can modify your project data directly here through the\n" f"{GREEN}Project{RESET} attributes {GREEN}maps{RESET}, {GREEN}params{RESET}, " f"{GREEN}lighting{RESET}, and {GREEN}text{RESET}. For example:\n\n" f" {GREEN}Project.maps.parts.new_character(name=\"Pet Gaping Dragon\", model_name=\"c5260\")" f"{RESET}\n\n" "You can also access other common operations, such as:\n\n" f" {GREEN}Project.save(\"maps\"){RESET} # save current Maps data to project\n" f" {GREEN}Project.export_data(\"params\"){RESET} # export current Params data to game\n" f" {GREEN}Project.import_data(){RESET} # import ALL data types from game into project\n\n" f"See the Soulstruct Python package for other functions: " f"{MAGENTA}https://github.com/grimrukh/soulstruct{RESET}\n\n" f"Type {RED}exit{RESET} or close the window to terminate the console. Make sure to use " f"{GREEN}Project.save(){RESET}\n" f"to save your changes first, if desired!\n", ) return True # Window. window = game.import_game_submodule("project").ProjectWindow(source) window.wait_window() # MAIN LOOP return False
def from_binder(cls, binder_source, entry_id: int): """Open a file of this type from the given `entry_id` of the given `Binder` source.""" from soulstruct.containers import Binder binder = Binder(binder_source) return cls(binder[entry_id])
def add_draw_slot_1_to_drawparam(parambnd_path): """Add the second draw slot (slot 1) to the given `aXX_DrawParam.parambnd[.dcx]` file, if it doesn't already have a second slot (which only `a15_DrawParam.parambnd` does in vanilla). All draw parameters will be copied from slot 0. """ parambnd_path = Path(parambnd_path) if not parambnd_path.is_file(): raise FileNotFoundError( f"Could not locate DrawParam file: {str(parambnd_path)}") draw_param = Binder(parambnd_path) if len(draw_param) != 12: _LOGGER.info( f"DrawParam file {str(parambnd_path)} already has more than one slot." ) return try: area_id = parambnd_path.name.split("_")[0][1:] except IndexError: raise ValueError( f"Could not determine map area ID from DrawParam file name: {parambnd_path.name}" ) # slot 1 files ('mXX_1_LightBank') come before slot 0 files ('mXX_LightBank'), which are both before 'sXX_LightBank' s_ambient = draw_param[11] draw_param.remove_entry(11) for i in range(11): slot_0 = draw_param[i].copy() slot_0.id += 11 draw_param[i].path = draw_param[i].path.replace( f"m{area_id}_", f"m{area_id}_1_") draw_param.add_entry(slot_0) s_ambient_0 = s_ambient.copy() s_ambient_0.id = 23 s_ambient.path = s_ambient.path.replace(f"s{area_id}_", f"s{area_id}_1_") s_ambient.id = 22 draw_param.add_entry(s_ambient) draw_param.add_entry(s_ambient_0) draw_param.write()
def __init__(self, script_directory=None): """Unpack LuaBND scripts in a directory (usually `script`) 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 (currently does not work for PTDE or Bloodborne). TODO: Has Katalash upgraded this decompiler to support Bloodborne? 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 `AIDirectory.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.luabnds = {} self.event_common_bnd = None if script_directory is None: return self._directory = Path(script_directory) if not self._directory.is_dir(): raise ValueError( "`AIDirectory` should be initialized with the directory containing `LuaBND` files." ) for game_map in self.ALL_MAPS: if not game_map.ai_file_stem: continue # no AI script for this map luabnd_path = self._directory / (game_map.ai_file_stem + f".luabnd.dcx") if not luabnd_path.exists(): # Look for non-DCX version. luabnd_path = luabnd_path.with_name(luabnd_path.stem) try: self.luabnds[game_map.name] = LuaBND(luabnd_path) setattr(self, game_map.name, self.luabnds[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." ) if self.EVENT_COMMON_NAME: event_path = self._directory / f"{self.EVENT_COMMON_NAME}.luabnd.dcx" if not event_path.exists(): event_path = event_path.with_name(event_path.stem) try: self.event_common_bnd = Binder(event_path) except FileNotFoundError: raise FileNotFoundError( "Could not find `eventCommon.luabnd[.dcx]` file in given directory." )
def __init__(self, luabnd_path, sort_goals=True, require_luainfo=False, require_luagnl=False): self.bnd = Binder(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.is_lua_32 = False # as opposed to 64-bit (can't decompile 32-bit at present) try: gnl_entry = self.bnd.entries_by_id[1000000] except KeyError: if require_luagnl: raise LuaError( f"Could not find `.luagnl` file in LuaBND: {str(luabnd_path)}" ) else: self.global_names = LuaGNL(gnl_entry.get_uncompressed_data()).names try: info_entry = self.bnd.entries_by_id[1000001] except KeyError: if require_luainfo: # 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.get_uncompressed_data()).goals self.bnd_name = Path(info_entry.path).stem for entry in self.bnd: entry_path = Path(entry.path) entry_data = entry.get_uncompressed_data() 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: script = entry_data.decode("shift_jis_2004") bytecode = b"" except UnicodeDecodeError: script = "" bytecode = entry_data try: goal = self.get_goal(goal_id, goal_type) except KeyError: if not script: # Search compiled bytes for function name. if goal_type == LuaGoal.BATTLE_TYPE: search_bytes = f"\0([\\w\\d_]+{goal_id}Battle)_Activate\0".encode( ) else: # LuaGoal.LOGIC_TYPE search_bytes = f"\0([\\w\\d_]+{goal_id}_Logic)\0".encode( ) if (goal_name_match := re.search( search_bytes, bytecode)) is None: _LOGGER.warning( f"Lua file {entry_path.name} in {luabnd_path} has no corresponding `LuaInfo` entry and " f"its goal name could not be auto-detected from its bytecode, so it will not be loaded." ) continue goal_name = goal_name_match.group(1).decode() else: # Scan file for goal name in function definition. if goal_type == LuaGoal.BATTLE_TYPE: search_string = rf"^function ([\w\d_]+{goal_id}Battle)_Activate\(" else: # LuaGoal.LOGIC_TYPE search_string = rf"^function ([\w\d_]+{goal_id}_Logic)\(" if (goal_name_match := re.search( search_string, script, re.MULTILINE)) is None: _LOGGER.warning( f"Lua file {entry_path.name} in {luabnd_path} has no corresponding `LuaInfo` entry and " f"its goal name could not be auto-detected from its script, so it will not be loaded." ) continue goal_name = goal_name_match.group(1) goal = LuaGoal( goal_id=goal_id, goal_name=goal_name, goal_type=goal_type, script_name=entry_path.name, bytecode=bytecode, script=script, ) self.goals.append(goal) else: goal.script = script goal.bytecode = bytecode