|
| 1 | +import re |
| 2 | +from pathlib import Path |
| 3 | +from typing import Iterable |
| 4 | + |
| 5 | +from PyQt6.QtCore import QFileInfo |
| 6 | + |
| 7 | +import mobase |
| 8 | + |
| 9 | +from ..basic_features import BasicModDataChecker, GlobPatterns |
| 10 | +from ..basic_features.utils import is_directory |
| 11 | +from ..basic_game import BasicGame |
| 12 | + |
| 13 | + |
| 14 | +class ResidentEvilRequiemModDataChecker(BasicModDataChecker): |
| 15 | + """Mod data checker for Resident Evil Requiem (RE Engine). |
| 16 | +
|
| 17 | + Mods are loaded at runtime by REFramework, which supports: |
| 18 | +
|
| 19 | + - loose files under `natives/` (LooseFileLoader), |
| 20 | + - `.pak` mods placed in `pak_mods/` (IntegrityCheckBypass_LoadPakDirectory), |
| 21 | + - Lua scripts and native plugins under `reframework/`. |
| 22 | +
|
| 23 | + Most mods on Nexus are packaged for Fluffy Mod Manager: the payload is |
| 24 | + wrapped in one or more folders (usually the mod name) next to Fluffy |
| 25 | + metadata (modinfo.ini, screenshots, readmes). Single-option archives are |
| 26 | + unwrapped automatically. Multi-option archives are ambiguous, so they fall |
| 27 | + through to the manual installer - but re-rooting to one option there gives |
| 28 | + a layout that is valid without further fixing, because the manual |
| 29 | + installer never applies `fix()`: Fluffy metadata is merely ignored, and |
| 30 | + `.pak` files are valid at the mod root because the whole mod is cleaned |
| 31 | + up on disk right after installation - see |
| 32 | + `ResidentEvilRequiemGame._fix_installed_mod()`, which covers manual and |
| 33 | + BAIN installs as well. |
| 34 | + """ |
| 35 | + |
| 36 | + def __init__(self): |
| 37 | + super().__init__( |
| 38 | + GlobPatterns( |
| 39 | + valid=[ |
| 40 | + "natives", |
| 41 | + "reframework", |
| 42 | + "pak_mods", |
| 43 | + # Pak storage of installed mods: |
| 44 | + # ResidentEvilRequiemGame.mappings() maps its content |
| 45 | + # into pak_mods/ with a load-order prefix. |
| 46 | + "paks", |
| 47 | + "dinput8.dll", |
| 48 | + # Moved into paks/ after install, see |
| 49 | + # ResidentEvilRequiemGame._fix_installed_mod(). |
| 50 | + "*.pak", |
| 51 | + ], |
| 52 | + # Fluffy Mod Manager metadata: inert, so keep it - this way |
| 53 | + # manually (re-)rooted mods are valid without any fixing. |
| 54 | + ignore=[ |
| 55 | + "modinfo.ini", |
| 56 | + "screenshot.*", |
| 57 | + "*.txt", |
| 58 | + "*.md", |
| 59 | + "*.jpg", |
| 60 | + "*.jpeg", |
| 61 | + "*.png", |
| 62 | + "*.url", |
| 63 | + ], |
| 64 | + move={ |
| 65 | + # The archive was created from inside the natives folder. |
| 66 | + "stm": "natives/", |
| 67 | + }, |
| 68 | + ) |
| 69 | + ) |
| 70 | + |
| 71 | + def _wrapper_dir(self, filetree: mobase.IFileTree) -> mobase.IFileTree | None: |
| 72 | + """Find the single directory the mod payload is wrapped in. |
| 73 | +
|
| 74 | + Fluffy mod archives usually wrap the payload in a folder named after |
| 75 | + the mod. Returns the only directory of `filetree` that is neither |
| 76 | + valid, movable nor ignorable, provided everything next to it is junk |
| 77 | + (ignorable). Returns None otherwise (e.g. for multi-option Fluffy |
| 78 | + packs with several wrapper candidates, which need a manual install). |
| 79 | + """ |
| 80 | + rp = self._regex_patterns |
| 81 | + wrapper: mobase.IFileTree | None = None |
| 82 | + for entry in filetree: |
| 83 | + name = entry.name().casefold() |
| 84 | + if rp.ignore.match(name) or rp.delete.match(name): |
| 85 | + continue |
| 86 | + if rp.valid.match(name) or rp.move_match(name) is not None: |
| 87 | + # Meaningful content next to a potential wrapper: the base |
| 88 | + # checker already knows how to handle this level. |
| 89 | + return None |
| 90 | + if not is_directory(entry) or wrapper is not None: |
| 91 | + return None |
| 92 | + wrapper = entry |
| 93 | + return wrapper |
| 94 | + |
| 95 | + def dataLooksValid( |
| 96 | + self, filetree: mobase.IFileTree |
| 97 | + ) -> mobase.ModDataChecker.CheckReturn: |
| 98 | + status = super().dataLooksValid(filetree) |
| 99 | + if status is mobase.ModDataChecker.INVALID: |
| 100 | + wrapper = self._wrapper_dir(filetree) |
| 101 | + if wrapper is not None and ( |
| 102 | + self.dataLooksValid(wrapper) is not mobase.ModDataChecker.INVALID |
| 103 | + ): |
| 104 | + status = mobase.ModDataChecker.FIXABLE |
| 105 | + return status |
| 106 | + |
| 107 | + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: |
| 108 | + # Unwrap (possibly nested) Fluffy mod folders first. |
| 109 | + while ( |
| 110 | + super().dataLooksValid(filetree) is mobase.ModDataChecker.INVALID |
| 111 | + and (wrapper := self._wrapper_dir(filetree)) is not None |
| 112 | + ): |
| 113 | + # Everything next to the wrapper is junk (see _wrapper_dir), and |
| 114 | + # the wrapper level disappears, so drop the junk with it. |
| 115 | + for entry in list(filetree): |
| 116 | + if entry.name() != wrapper.name(): |
| 117 | + entry.detach() |
| 118 | + filetree.merge(wrapper) |
| 119 | + wrapper.detach() |
| 120 | + return super().fix(filetree) |
| 121 | + |
| 122 | + |
| 123 | +class ResidentEvilRequiemGame( |
| 124 | + BasicGame, mobase.IPluginFileMapper, mobase.IPluginDiagnose |
| 125 | +): |
| 126 | + def __init__(self): |
| 127 | + BasicGame.__init__(self) |
| 128 | + mobase.IPluginFileMapper.__init__(self) |
| 129 | + mobase.IPluginDiagnose.__init__(self) |
| 130 | + |
| 131 | + def init(self, organizer: mobase.IOrganizer) -> bool: |
| 132 | + super().init(organizer) |
| 133 | + self._register_feature(ResidentEvilRequiemModDataChecker()) |
| 134 | + organizer.modList().onModInstalled(self._fix_installed_mod) |
| 135 | + return True |
| 136 | + |
| 137 | + Name = "Resident Evil Requiem Support Plugin" |
| 138 | + Author = "HomerSimpleton Returns Again" |
| 139 | + Version = "1.0" |
| 140 | + |
| 141 | + GameName = "Resident Evil Requiem" |
| 142 | + GameShortName = "residentevilrequiem" |
| 143 | + GameNexusName = "residentevilrequiem" |
| 144 | + GameSteamId = 3764200 |
| 145 | + |
| 146 | + GameBinary = "re9.exe" |
| 147 | + GameDataPath = "%GAME_PATH%" |
| 148 | + |
| 149 | + # Where installed mods store their paks. The folder overlays into the |
| 150 | + # game directory as an inert "paks" dir the game never reads - only the |
| 151 | + # numbered virtual copies created by mappings() are loaded. (Hiding the |
| 152 | + # folder with a .mohidden suffix breaks the explicit file mappings with |
| 153 | + # usvfs 0.5.6, so the inert leak is deliberate.) |
| 154 | + _PAK_STORAGE_DIR = "paks" |
| 155 | + |
| 156 | + _PAK_NUMBER_PREFIX = re.compile(r"^\d{4} - ", re.ASCII) |
| 157 | + |
| 158 | + # Fluffy Mod Manager metadata: only read by Fluffy itself, and colliding |
| 159 | + # between mods, so not worth keeping in the installed mod. |
| 160 | + _FLUFFY_METADATA_GLOBS = ("modinfo.ini", "screenshot.*") |
| 161 | + |
| 162 | + @staticmethod |
| 163 | + def _parse_modinfo(path: Path) -> dict[str, str]: |
| 164 | + """Parse a Fluffy Mod Manager modinfo.ini (plain key=value lines, |
| 165 | + no sections). Returns an empty dict if the file cannot be read.""" |
| 166 | + info: dict[str, str] = {} |
| 167 | + try: |
| 168 | + text = path.read_text(encoding="utf-8-sig", errors="replace") |
| 169 | + except OSError: |
| 170 | + return info |
| 171 | + for line in text.splitlines(): |
| 172 | + key, sep, value = line.partition("=") |
| 173 | + if sep and key.strip() and value.strip(): |
| 174 | + info.setdefault(key.strip().casefold(), value.strip()) |
| 175 | + return info |
| 176 | + |
| 177 | + def _fix_installed_mod(self, mod: mobase.IModInterface) -> None: |
| 178 | + """Clean up a freshly installed mod on disk: salvage MO2 metadata |
| 179 | + from the Fluffy modinfo.ini, delete the Fluffy metadata files and |
| 180 | + collect .pak files (mod root and shipped pak_mods/) into the mod's |
| 181 | + pak storage folder, from where mappings() feeds them to |
| 182 | + REFramework. Files are never renamed after installation. |
| 183 | +
|
| 184 | + The ModDataChecker cannot do this: neither the manual installer nor |
| 185 | + the BAIN installer apply `fix()`, and multi-option Fluffy packs have |
| 186 | + to go through one of those. |
| 187 | + """ |
| 188 | + if self._organizer.managedGame() != self: |
| 189 | + return |
| 190 | + mod_path = Path(mod.absolutePath()) |
| 191 | + # Fill MO2's metadata from modinfo.ini before it is deleted - but |
| 192 | + # never override what MO2 already knows (e.g. from a Nexus download). |
| 193 | + modinfo_path = mod_path / "modinfo.ini" |
| 194 | + if modinfo_path.is_file(): |
| 195 | + modinfo = self._parse_modinfo(modinfo_path) |
| 196 | + version = modinfo.get("version") |
| 197 | + if version and not mod.version().isValid(): |
| 198 | + mod.setVersion(mobase.VersionInfo(version)) |
| 199 | + url = modinfo.get("homepage") or modinfo.get("website") |
| 200 | + if url and not mod.url(): |
| 201 | + mod.setUrl(url) |
| 202 | + for pattern in self._FLUFFY_METADATA_GLOBS: |
| 203 | + for junk in mod_path.glob(pattern): |
| 204 | + if junk.is_file(): |
| 205 | + junk.unlink() |
| 206 | + # Collect the paks into the hidden storage folder. |
| 207 | + shipped_pak_mods = mod_path / "pak_mods" |
| 208 | + paks = [ |
| 209 | + p |
| 210 | + for pak_dir in (mod_path, shipped_pak_mods) |
| 211 | + for p in pak_dir.glob("*.pak") |
| 212 | + if p.is_file() |
| 213 | + ] |
| 214 | + if paks: |
| 215 | + storage = mod_path / self._PAK_STORAGE_DIR |
| 216 | + storage.mkdir(exist_ok=True) |
| 217 | + for pak in paks: |
| 218 | + # Strip a leftover load-order prefix: load order is handled |
| 219 | + # by mappings() now. |
| 220 | + target = storage / self._PAK_NUMBER_PREFIX.sub("", pak.name) |
| 221 | + if not target.exists(): |
| 222 | + pak.rename(target) |
| 223 | + if shipped_pak_mods.is_dir() and not any(shipped_pak_mods.iterdir()): |
| 224 | + shipped_pak_mods.rmdir() |
| 225 | + |
| 226 | + # IPluginFileMapper: REFramework loads every *.pak found in pak_mods/ in |
| 227 | + # alphabetical order. The stored paks of all active mods are mapped into |
| 228 | + # pak_mods/ with a global sequence number prefix, so that the load order |
| 229 | + # follows MO2's mod order - the same scheme Fluffy Mod Manager uses with |
| 230 | + # its own number prefixes. The mapping is virtual and rebuilt on every |
| 231 | + # launch; nothing on disk changes. Within one mod, paks are ordered by |
| 232 | + # their case-insensitive file name, so renaming the files in the mod's |
| 233 | + # pak storage folder reorders them. |
| 234 | + |
| 235 | + def _active_mod_paths(self) -> Iterable[Path]: |
| 236 | + mods_parent_path = Path(self._organizer.modsPath()) |
| 237 | + modlist = self._organizer.modList() |
| 238 | + for mod_name in modlist.allModsByProfilePriority(): |
| 239 | + if modlist.state(mod_name) & mobase.ModState.ACTIVE: |
| 240 | + yield mods_parent_path / mod_name |
| 241 | + |
| 242 | + def mappings(self) -> list[mobase.Mapping]: |
| 243 | + pak_mods_dir = Path(self.gameDirectory().absolutePath()) / "pak_mods" |
| 244 | + # Without a directory link on pak_mods/, the virtual pak files below |
| 245 | + # are not enumerable - directory scans (like REFramework's) would |
| 246 | + # not see them (compare game_finalfantasy7remake.py). Anchor that |
| 247 | + # link with a dedicated, permanently empty plugin-data folder. No |
| 248 | + # createTarget: writes into the virtual pak_mods/ should still land |
| 249 | + # in overwrite via MO2's regular root mapping. |
| 250 | + anchor = ( |
| 251 | + Path(self._organizer.pluginDataPath()) / "residentevilrequiem" / "pak_mods" |
| 252 | + ) |
| 253 | + anchor.mkdir(parents=True, exist_ok=True) |
| 254 | + mappings: list[mobase.Mapping] = [ |
| 255 | + mobase.Mapping(str(anchor), str(pak_mods_dir), True, False) |
| 256 | + ] |
| 257 | + number = 0 |
| 258 | + for mod_path in self._active_mod_paths(): |
| 259 | + storage = mod_path / self._PAK_STORAGE_DIR |
| 260 | + if not storage.is_dir(): |
| 261 | + continue |
| 262 | + for pak in sorted( |
| 263 | + storage.glob("*.pak"), key=lambda pak: pak.name.casefold() |
| 264 | + ): |
| 265 | + if not pak.is_file(): |
| 266 | + continue |
| 267 | + number += 1 |
| 268 | + mappings.append( |
| 269 | + mobase.Mapping( |
| 270 | + str(pak), |
| 271 | + str(pak_mods_dir / f"{number:04d} - {pak.name}"), |
| 272 | + False, |
| 273 | + ) |
| 274 | + ) |
| 275 | + return mappings |
| 276 | + |
| 277 | + def _reframework_installed(self) -> bool: |
| 278 | + return QFileInfo(self.gameDirectory(), "dinput8.dll").exists() |
| 279 | + |
| 280 | + # IPluginDiagnose: mods only load through REFramework, so warn when it |
| 281 | + # is missing from the game directory. |
| 282 | + |
| 283 | + def activeProblems(self) -> list[int]: |
| 284 | + if self._organizer.managedGame() == self and not self._reframework_installed(): |
| 285 | + return [self._PROBLEM_NO_REFRAMEWORK] |
| 286 | + return [] |
| 287 | + |
| 288 | + _PROBLEM_NO_REFRAMEWORK = 0 |
| 289 | + |
| 290 | + def shortDescription(self, key: int) -> str: |
| 291 | + return "REFramework is not installed in the game directory." |
| 292 | + |
| 293 | + def fullDescription(self, key: int) -> str: |
| 294 | + return ( |
| 295 | + "REFramework provides the mod loaders this plugin relies on: loose files " |
| 296 | + "from natives/, .pak mods from pak_mods/ and scripts from reframework/. " |
| 297 | + "Without it, installed mods have no effect.\n\n" |
| 298 | + "Download REFramework from " |
| 299 | + "https://www.nexusmods.com/residentevilrequiem/mods/13 and extract it " |
| 300 | + "(dinput8.dll) directly into the game directory:\n" |
| 301 | + f"{self.gameDirectory().absolutePath()}\n\n" |
| 302 | + "Install it directly into the game directory rather than as a mod, so " |
| 303 | + "the loader DLL is always active regardless of the virtual file system." |
| 304 | + ) |
| 305 | + |
| 306 | + def hasGuidedFix(self, key: int) -> bool: |
| 307 | + return False |
| 308 | + |
| 309 | + def startGuidedFix(self, key: int) -> None: |
| 310 | + pass |
0 commit comments