Exemple #1
0
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)
Exemple #4
0
 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()
Exemple #5
0
 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)
Exemple #8
0
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
Exemple #9
0
    "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()
Exemple #10
0
    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)
Exemple #11
0
    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))
Exemple #12
0
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}