13 KiB
Native Linux Mod Manager for Monster Hunter Wilds
Context
There is no native Linux mod manager for Monster Hunter Wilds. Fluffy Manager 5000 is the de-facto standard but is Windows-only, so Linux players run it under a second Wine prefix or hand-copy files into the game directory. This project builds a native GTK4 desktop app that reproduces Fluffy's workflow — a checkbox mod list with screenshots, categories, search, conflict detection and archive import — while handling the things Fluffy does not: Proton/Steam integration, and safe recovery when a title update shifts the vanilla PAK ceiling.
The project directory is empty apart from an IntelliJ GENERAL_MODULE stub (MHW-Mod-Manager.iml,
no SDK pinned), so this is greenfield.
Verified facts this design rests on
Gathered from the live system and from research this session:
| Fact | Source |
|---|---|
Installed game is Wilds, app 2246340, at ~/.steam/steam/steamapps/common/MonsterHunterWilds |
appmanifest_2246340.acf |
Two parallel PAK chains, both currently ending at 015: re_chunk_000.pak.patch_NNN.pak and re_chunk_000.pak.sub_000.pak.patch_NNN.pak |
directory listing |
| Higher patch number wins; mods slot above the vanilla ceiling, which rises with every title update | community guides |
Loose mods live in natives/STM/…, loaded by REFramework's loose file loader |
REFramework docs |
Loose file loader is toggled by LooseFileLoader_Enabled in re2_fw_config.txt in the game root — the filename keeps its RE2 origin across all RE Engine games — and must be set before launch |
REFramework issue tracker |
REFramework = dinput8.dll in game root; on Proton it needs WINEDLLOVERRIDES="dinput8.dll=n,b" %command% |
REFramework docs |
Lua mods → reframework/autorun/, DLL plugins → reframework/plugins/, reframework/data/ holds user settings and must be preserved |
cleanup guides |
No libraryfolders.vdf extra libraries, no REFramework, no natives/ present yet — clean slate |
system inspection |
Decisions taken with the user
- Target: Monster Hunter Wilds only (not MH:World).
- Stack: Python 3.14 + GTK4/libadwaita via system PyGObject.
- v1 extras: load order UI, mod loader setup, profiles,
nxm://integration.
Constraint: zero third-party runtime dependencies
Bazzite is an immutable distro, so the app must run without pip install. Everything needed is
already present and verified on this machine:
- UI — system PyGObject,
Gtk 4.0+Adw 1(/usr/lib64/python3.14/site-packages/gi) - zip — stdlib
zipfile; 7z —/usr/bin/7z(26.02); rar —/usr/bin/unrar - HTTP — stdlib
urllib.request; config — stdlibjson/configparser
pyproject.toml declares no runtime deps. A venv is only needed for dev tooling, and must be
created with --system-site-packages or gi disappears.
Architecture
src/mhwmm/
├── __main__.py entry point
├── app.py Adw.Application; HANDLES_COMMAND_LINE for nxm:// single-instance forwarding
├── core/ ← pure logic, no GTK imports, fully unit-testable
│ ├── paths.py XDG dirs + library layout
│ ├── config.py settings.json, atomic save
│ ├── steam.py VDF parser, app discovery, launch-option read/verify
│ ├── modinfo.py Fluffy-compatible modinfo.ini + screenshot discovery
│ ├── archive.py format sniffing, extraction, path-traversal rejection
│ ├── layout.py classify an extracted tree → deployment entries
│ ├── library.py Mod model; import / scan / rename / delete
│ ├── deploy.py link engine, manifest, conflicts, transactional apply/rollback
│ ├── paks.py vanilla baseline, slot allocation, title-update re-slotting
│ ├── profiles.py named enabled-sets + load order
│ ├── loader.py REFramework detect/install, re2_fw_config.txt, Proton wiring
│ └── nexus.py nxm:// parse, Nexus API client, downloads
└── ui/ window, mod_list, details, setup, conflicts, prefs
The core / ui split is deliberate: every risky behaviour (file deletion, PAK slotting, archive
extraction) lives in core with no GTK dependency, so it is testable headlessly.
On-disk state
~/.local/share/mhwilds-mod-manager/
├── mods/<mod-id>/ extracted mod trees (the library)
├── downloads/ nxm:// downloads
└── state/
├── deployed.json manifest: every path we created in the game dir
├── pak-baseline.json vanilla PAK snapshot (names + size + mtime_ns)
└── backup/ pre-existing real files we shadowed
~/.config/mhwilds-mod-manager/
├── settings.json
└── profiles.json
Core subsystems
layout.py — classifying a mod
Mods are packaged inconsistently, so the payload root must be discovered rather than assumed. Walk
the extracted tree and emit a list of (kind, source_abspath, dest_relpath) entries. A single mod
can produce several kinds at once. Detection, in priority order:
- dir named
natives(case-insensitive, any depth) → loose assets, payload root is its parent - dir named
reframework→ Lua/plugin mod, preservingautorun/andplugins/ - bare
STM/dir → wrap asnatives/STM *.pakat or near root → PAK mod (gets a slot, see below)- loose
*.luaat root →reframework/autorun/ - loose
*.dllat root → ambiguous; surface a prompt rather than guessing, since this could be a plugin or a root-leveldinput8.dll
deploy.py — the link engine
Deployment is a pure function from (enabled mods, load order) to a desired dest_relpath → source_abspath map, diffed against the last manifest.
- Later mod in load order wins a contested path; every contested path is recorded as a conflict with its winner and losers, which is what feeds the conflict UI.
- Link mode defaults to symlink — instant, no disk cost next to a ~100 GB install, works
across filesystems, and
ls -lmakes managed files self-evident during debugging. Wine resolves symlinks transparently on read. Hardlink and copy are available in preferences. - Shadowing: if an unmanaged real file already occupies a destination, move it to
state/backup/and record it; restore on undeploy. - Transactional: build plan → apply → write manifest via temp-file +
os.replace. Roll back on mid-apply failure.
Safety rules (non-negotiable — this writes into a ~100 GB game install)
- Never delete anything in the game directory that is not in our manifest and whose on-disk state still matches what we recorded.
- Never delete a PAK we did not create.
- Every destructive operation is preceded by a "Preview changes" plan the user can inspect.
reframework/data/is never touched — it holds user presets and script settings.
paks.py — surviving title updates
This is the standout feature and the main thing Fluffy handles badly. Leftover mod PAKs numbered
above the vanilla ceiling are the single most common cause of the community's spurious
"re_chunk_000.pak.patch_NNN.pak is corrupted" reports.
- Snapshot the vanilla ceiling per chain at setup (currently
015for both chains) along with each vanilla PAK's size and mtime. - Allocate mod slots from
ceiling + 1upward, in load order. - On every startup, re-scan. If a PAK appears inside our slot range that we do not own, Steam has patched the game: undeploy mod PAKs, re-derive the ceiling, re-slot, redeploy, and tell the user plainly ("Game updated — mod PAKs re-slotted to 017+").
Note that loose files and PAKs conflict if they supply the same asset, so the conflict checker spans both delivery mechanisms rather than treating them separately.
steam.py — library and launch options
A ~40-line text-VDF parser handles libraryfolders.vdf and localconfig.vdf. Launch options live
at userdata/<uid>/config/localconfig.vdf → …/apps/2246340/LaunchOptions.
Steam rewrites localconfig.vdf on exit, so writing it while Steam runs silently loses the
change. The app therefore reads and verifies by default, showing the exact required string with
a Copy button. Writing it is an explicit opt-in action, gated on Steam not running, and backs the
file up first.
loader.py — REFramework setup
Status panel with a per-check ✓/✗: dinput8.dll present · LooseFileLoader_Enabled=true ·
launch-option override present. One-click install fetches the latest MHWILDS.zip from the
praydog/REFramework-nightly GitHub releases API and extracts dinput8.dll to the game root.
re2_fw_config.txt is patched key-wise, preserving unrelated settings, and created if absent.
nexus.py — nxm:// handling
- Handler desktop file registers
x-scheme-handler/nxm;Gio.ApplicationFlags.HANDLES_COMMAND_LINEforwards the URL to the already-running instance over D-Bus. - Parse
nxm://monsterhunterwilds/mods/<mod_id>/files/<file_id>?key=…&expires=…, thenGET /v1/games/monsterhunterwilds/mods/{mod}/files/{file}/download_link.json?key=…&expires=…with anapikeyheader. Thekey/expirespair is what lets non-premium accounts download, so it must be passed through rather than dropped. - Also fetch
/v1/games/monsterhunterwilds/mods/{mod}.jsonto prefill name, version, summary and thumbnail. Surface the rate-limit response headers. - API key stored in libsecret when
gi.repository.Secretis importable, else a0600JSON file.
UI
Adw.NavigationSplitView. Sidebar: search, category filter, profile selector, and the mod list —
each row a Gtk.CheckButton, name, version, category badge and a conflict-warning icon. Load order
by drag-and-drop with up/down keyboard fallback. Content pane: auto-detected screenshot.png|jpg
(Fluffy-compatible), metadata, description, and Files / Conflicts tabs. Header: Import Mod, Deploy,
overflow menu. Archives can be dropped onto the window to import. Adw.StatusPage covers first-run
and game-not-found. Toasts report results.
Toggling a checkbox deploys immediately, matching Fluffy; a batched "pending changes" mode is a preference.
Implementation phases
Each phase ends in something runnable.
- Skeleton + game discovery —
pyproject.toml,paths,config,steam; window that finds the install and shows both PAK chains and the detected ceiling. - Library import —
archive,modinfo,layout,library; import archives, list mods with screenshots and metadata. No writes to the game dir yet. - Deploy engine —
deploy+paks; enable/disable, conflict detection, load order, preview-changes, undeploy round-trip. The correctness core. - Loader setup —
loader; REFramework install,re2_fw_config.txt, launch-option verification. - Profiles + polish —
profiles, preferences, search/categories, drag reorder, toasts. - Nexus —
nexus, desktop files, scheme registration, download queue.
Verification
Automated (stdlib unittest, no GTK needed, runs headless):
- Round-trip invariant — the key test: snapshot a synthetic game dir, deploy N mods, undeploy, assert the tree is byte-identical including backups restored and empty dirs pruned.
- Zip-slip and absolute-path entries are rejected by
archive. layoutclassification across the packaging shapes listed above.- Conflict winner follows load order; reordering flips the winner.
- PAK slot allocation, and re-slotting when a synthetic
patch_016appears from outside. - VDF parser against the real
libraryfolders.vdfandappmanifest_2246340.acfon this machine. nxm://URL parsing includingkey/expirespassthrough.
Manual, against the real install:
- Launch, confirm it finds
MonsterHunterWildsand reports ceiling015on both chains. - Install REFramework via the app; confirm
dinput8.dllandLooseFileLoader_Enabled=true, and that it reports the missingWINEDLLOVERRIDESlaunch option with the exact string to copy. - Import a real Wilds mod, enable it, and verify the symlinks under
natives/STM/resolve. - Enable two mods touching the same asset; confirm the conflict shows and reordering flips the winner.
- Disable everything and confirm the game directory returns to exactly its pre-mod state — compare
against a
find-based snapshot taken before step 3. - Launch the game through Steam and confirm the mod loads.
Step 5 is the acceptance gate: the manager must be able to fully undo itself.