Skip to content

Commit 3871cbe

Browse files
author
Jim Carroll
committed
Plugin for Resident Evil Requiem
1 parent 3bd9da9 commit 3871cbe

2 files changed

Lines changed: 311 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ You can rename `modorganizer-basic_games-xxx` to whatever you want (e.g., `basic
6464
| Mount & Blade II: Bannerlord — [GOG](https://www.gog.com/game/mount_blade_ii_bannerlord) / [STEAM](https://store.steampowered.com/app/261550/Mount__Blade_II_Bannerlord/) | [Holt59](https://github.com/holt59/) | [game_mountandblade2.py](games/game_mountandblade2.py) | <ul><li>mod data checker</li></ul> |
6565
| Need for Speed: High Stakes | [uwx](https://github.com/uwx) | [game_nfshs.py](games/game_nfshs.py) | |
6666
| No Man's Sky - [GOG](https://www.gog.com/game/no_mans_sky) / [Steam](https://store.steampowered.com/app/275850/No_Mans_Sky/)|[EzioTheDeadPoet](https://eziothedeadpoet.github.io/AboutMe/)|[game_nomanssky.py](games/game_nomanssky.py)| |
67+
| Resident Evil Requiem — [STEAM](https://store.steampowered.com/app/3764200/Resident_Evil_Requiem/) | jfcarroll | [game_residentevilrequiem.py](games/game_residentevilrequiem.py) | <ul><li>mod data checker</li></ul> |
6768
| Slay the Spire 2 — [STEAM](s.team/a/2868840) | [Azlle](https://github.com/Azlle) | [game_sts2.py](games/game_sts2.py) | <ul><li>mod data checker</li></ul> |
6869
| S.T.A.L.K.E.R. Anomaly — [MOD](https://www.stalker-anomaly.com/) | [Qudix](https://github.com/Qudix) | [game_stalkeranomaly.py](games/game_stalkeranomaly.py) | <ul><li>mod data checker</li></ul> |
6970
| Stardew Valley — [GOG](https://www.gog.com/game/stardew_valley) / [STEAM](https://store.steampowered.com/app/413150/Stardew_Valley/) | [Syer10](https://github.com/Syer10), [Holt59](https://github.com/holt59/) | [game_stardewvalley.py](games/game_stardewvalley.py) | <ul><li>mod data checker</li></ul> |

games/game_residentevilrequiem.py

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
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

Comments
 (0)