def __patch_engine(original_file: Path, file: Path, engine: str, hex_patches: dict[str, HexPatch]) -> bool: """Return True if patch went as expected, False if any warnings happened.""" data = original_file.read_bytes() all_as_expected = True for hex_patch_name, hex_patch in hex_patches.items(): replacement = hex_patch['replacement'] % hex_patch['replacement_args'] pattern = hex_patch['pattern'] (data, sub_count) = pattern.subn(replacement, data) LOGGER.debug( f"Replaced {sub_count} occurrences of '{hex_patch_name}' pattern {pattern.pattern} with {replacement} in '{file}'" ) expected = hex_patch['expected_subs'] if sub_count == 0 and expected != 0: raise LookupError( f"Failed to apply '{hex_patch_name}' patch in '{file}' (no occurrences found)" ) elif sub_count != expected: LOGGER.warning( f"Expected {expected} matches for '{hex_patch_name}' patch in '{file}', found {sub_count}" ) all_as_expected = False file.write_bytes(data) LOGGER.info(f"Patched '{file}'") return all_as_expected
def status() -> None: if config.HASH_DIR.exists() and any(config.HASH_DIR.iterdir()): LOGGER.info(f"Found hashes at '{config.HASH_DIR}'") return True else: LOGGER.info(f"No hashes found at '{config.HASH_DIR}'") return False
def status() -> None: if config.SJSON_DATA_DIR.exists() and any(config.SJSON_DATA_DIR.iterdir()): LOGGER.info(f"Found SJSON data at '{config.SJSON_DATA_DIR}'") return True else: LOGGER.info(f"No SJSON data found at '{config.SJSON_DATA_DIR}'") return False
def uninstall() -> None: mod_dir = config.content_dir.joinpath(MOD_TARGET_DIR) if mod_dir.exists(): dir_util.remove_tree(str(mod_dir)) LOGGER.info(f"Uninstalled Lua mod from '{mod_dir}'") else: LOGGER.info(f"No Lua mod to uninstall from '{mod_dir}'")
def try_get_modimporter() -> Path: """Check if modimporter is available in the Content directory.""" for mod_importer in MOD_IMPORTERS: modimporter = config.content_dir.joinpath(mod_importer) if modimporter.exists(): LOGGER.info(f"'modimporter' detected at '{modimporter}'") return modimporter return None
def __patch_hook_file(original_file: Path, file: Path, import_statement: str) -> None: source_text = original_file.read_text() source_text += f""" -- Hephaistos hook {import_statement} """ file.write_text(source_text) LOGGER.info(f"Patched '{file}' with hook '{import_statement}'")
def patch_lua_status(lua_scripts_dir: Path, import_statement: str) -> None: hook_file = lua_scripts_dir.joinpath(HOOK_FILE) LOGGER.debug(f"Checking patch status of Lua hook file at '{hook_file}'") text = hook_file.read_text() if import_statement in text: LOGGER.info(f"Found hook '{import_statement}' in '{hook_file}'") return True else: LOGGER.info(f"No hook '{import_statement}' found in '{hook_file}'") return False
def __handle_global_args(self, args: list[str]) -> None: # logging verbosity level level = ParserBase.VERBOSE_TO_LOG_LEVEL[min(args.verbose, 2)] LOGGER.setLevel(level) # hades_dir self.__configure_hades_dir(args.hades_dir) # modimporter if args.modimporter: config.modimporter = helpers.try_get_modimporter() else: LOGGER.info("Using '--no-modimporter': will not run 'modimporter', even if available")
def status() -> None: mod_dir = config.content_dir.joinpath(MOD_TARGET_DIR) if mod_dir.exists() and any(mod_dir.iterdir()): LOGGER.info(f"Found Lua mod at '{mod_dir}'") (mod_dir, lua_scripts_dir, relative_path_to_mod, _) = __prepare_variables() return patchers.patch_lua_status( lua_scripts_dir, relative_path_to_mod + MOD_ENTRY_POINT) else: LOGGER.info(f"No Lua mod found at '{mod_dir}'") return False
def patch_sjsons() -> None: LOGGER.info( "Reading SJSON data (this operation can take time, please be patient)") sjson_dir = config.content_dir.joinpath(SJSON_DIR) for dirname, files in SJON_PATCHES.items(): sub_dir = sjson_dir.joinpath(dirname) for filename, patches in files.items(): file = sub_dir.joinpath(filename) LOGGER.debug(f"Patching SJSON file at '{file}'") with safe_patch_file(file) as (source_sjson, file): __patch_sjson_file(source_sjson, file, patches)
def install() -> None: LOGGER.debug(f"Installing Lua mod from '{config.MOD_SOURCE_DIR}'") (mod_dir, lua_scripts_dir, relative_path_to_mod, import_statement) = __prepare_variables() dir_util.copy_tree(str(config.MOD_SOURCE_DIR), str(mod_dir)) LOGGER.debug(f"Copied '{config.MOD_SOURCE_DIR}' to '{mod_dir}'") __configure(mod_dir, relative_path_to_mod) LOGGER.info(f"Installed Lua mod to '{mod_dir}'") # run modimporter (if available) to register Hephaistos if config.modimporter: LOGGER.info(f"Running 'modimporter' to register Hephaistos") helpers.run_modimporter(config.modimporter) # otherwise register manually else: patchers.patch_lua(lua_scripts_dir, import_statement)
def patch_profile_sjsons() -> None: if config.custom_resolution: profile_sjsons = helpers.try_get_profile_sjson_files() if not profile_sjsons: msg = """Cannot patch custom resolution to 'ProfileX.sjson'. This is a non-blocking issue but might prevent you from running Hades at the resolution of your choice.""" LOGGER.warning(msg) return edited_list = [] for file in profile_sjsons: LOGGER.debug(f"Analyzing '{file}'") data = sjson.loads(file.read_text()) for key in ['X', 'WindowWidth']: data[key] = config.resolution.width for key in ['Y', 'WindowHeight']: data[key] = config.resolution.height # we manually set WindowX/Y in ProfileX.sjson configuration files as a # safeguard against WindowX/Y values overflowing when switching to # windowed mode while using a custom resolution larger than officially # supported by the main monitor, ensuring Hades will not be drawn # offscreen and can then be repositioned by the user for key in ['WindowX', 'WindowY']: if not key in data: data[key] = WINDOW_XY_DEFAULT_OFFSET LOGGER.debug( f"'{key}' not found in '{file.name}', inserted '{key} = {WINDOW_XY_DEFAULT_OFFSET}'" ) elif data[key] >= WINDOW_XY_OVERFLOW_THRESHOLD: data[key] = WINDOW_XY_DEFAULT_OFFSET LOGGER.debug( f"'{key}' found in '{file.name}' but with overflowed value, reset to '{key} = {WINDOW_XY_DEFAULT_OFFSET}'" ) file.write_text(sjson.dumps(data)) edited_list.append(file) if edited_list: edited_list = '\n'.join(f" - {file}" for file in edited_list) msg = f"""Applied custom resolution to: {edited_list}""" LOGGER.info(msg)
def handler(self, **kwargs) -> None: """Restore backups, discard hashes and SJSON data, uninstall Lua mod.""" # run 'modimporter --clean' (if available) to unregister Hephaistos if config.modimporter: LOGGER.info(f"Running 'modimporter --clean' to unregister Hephaistos") helpers.run_modimporter(config.modimporter, clean_only=True) backups.restore() hashes.discard() sjson_data.discard() lua_mod.uninstall() # clean up Hephaistos data dir if empty (using standalone executable) if not any(config.HEPHAISTOS_DATA_DIR.iterdir()): dir_util.remove_tree(str(config.HEPHAISTOS_DATA_DIR)) LOGGER.info(f"Cleaned up empty directory '{config.HEPHAISTOS_DATA_DIR}'") # re-run modimporter (if available) to re-register other mods if config.modimporter: LOGGER.info(f"Running 'modimporter' to re-register other mods") helpers.run_modimporter(config.modimporter)
def patch_engines_status() -> None: status = True for engine, filepath in ENGINES[config.platform].items(): file = config.hades_dir.joinpath(filepath) LOGGER.debug( f"Checking patch status of '{engine}' backend at '{file}'") try: with safe_patch_file(file, store_backup=False) as (original_file, file): if original_file is not None: LOGGER.info(f"'{file}' looks patched") else: status = False LOGGER.info(f"'{file}' is not patched") except hashes.HashMismatch: status = False LOGGER.info( f"'{file}' has been modified since last backup: probably not patched" ) return status
def handler(self, width: int, height: int, scaling: Scaling, hud: HUD, custom_resolution: bool, force: bool, **kwargs) -> None: """Compute viewport depending on arguments, then patch all needed files and install Lua mod. If using '--force', discard backups, hashes and SJSON data, and uninstall Lua mod.""" helpers.configure_screen_variables(width, height, scaling) LOGGER.info(f"Using resolution: {config.resolution.width, config.resolution.height}") LOGGER.info(f"Using '--scaling={scaling}': computed patch viewport {config.new_screen.width, config.new_screen.height}") config.center_hud = True if hud == HUD.CENTER else False msg = f"Using '--hud={hud}': HUD will be kept in the center of the screen" if config.center_hud else f"Using '--hud={hud}': HUD will be expanded horizontally" LOGGER.info(msg) if not custom_resolution: LOGGER.info("Using '--no-custom-resolution': will not bypass monitor resolution detection") config.custom_resolution = custom_resolution if force: LOGGER.info("Using '--force': will repatch on top of existing files in case of hash mismatch and store new backups / hashes") config.force = True # run 'modimporter --clean' (if available) to restore everything before patching if config.modimporter: LOGGER.info(f"Running 'modimporter --clean' to restore original state before patching") helpers.run_modimporter(config.modimporter, clean_only=True) try: patchers.patch_engines() patchers.patch_sjsons() patchers.patch_profile_sjsons() lua_mod.install() except hashes.HashMismatch as e: LOGGER.error(e) if config.interactive_mode: LOGGER.error("It looks like the game was updated. Do you wish to discard previous backups and re-patch Hades from its current state?") choice = interactive.pick(options=['Yes', 'No',], add_option=None) if choice == 'Yes': self.handler(width, height, scaling, hud, custom_resolution, force=True) else: LOGGER.error("Was the game updated? Re-run with '--force' to discard previous backups and re-patch Hades from its current state.") except (LookupError, FileExistsError) as e: LOGGER.error(e)
def discard() -> None: if config.HASH_DIR.exists(): dir_util.remove_tree(str(config.HASH_DIR)) LOGGER.info(f"Discarded hashes at '{config.HASH_DIR}'")
def discard() -> None: if config.SJSON_DATA_DIR.exists(): dir_util.remove_tree(str(config.SJSON_DATA_DIR)) LOGGER.info(f"Discarded SJSON data at '{config.SJSON_DATA_DIR}'")
def __patch_sjson_file(source_sjson: SJSON, file: Path, patches: SJSONPatch) -> None: patched_sjson = __patch_sjson_data(source_sjson, patches) file.write_text(sjson.dumps(patched_sjson)) LOGGER.info(f"Patched '{file}'")