Example #1
0
    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)
Example #2
0
    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
Example #3
0
    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)
Example #4
0
 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()
Example #5
0
 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()
Example #6
0
    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
Example #7
0
    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
Example #8
0
 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
Example #9
0
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
Example #10
0
 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])
Example #11
0
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()
Example #12
0
    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."
                )
Example #13
0
    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