Files
MHW-Mod-Manager/plan.md
T

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 — stdlib json / 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, preserving autorun/ and plugins/
  • bare STM/ dir → wrap as natives/STM
  • *.pak at or near root → PAK mod (gets a slot, see below)
  • loose *.lua at root → reframework/autorun/
  • loose *.dll at root → ambiguous; surface a prompt rather than guessing, since this could be a plugin or a root-level dinput8.dll

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 -l makes 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)

  1. Never delete anything in the game directory that is not in our manifest and whose on-disk state still matches what we recorded.
  2. Never delete a PAK we did not create.
  3. Every destructive operation is preceded by a "Preview changes" plan the user can inspect.
  4. 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 015 for both chains) along with each vanilla PAK's size and mtime.
  • Allocate mod slots from ceiling + 1 upward, 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_LINE forwards the URL to the already-running instance over D-Bus.
  • Parse nxm://monsterhunterwilds/mods/<mod_id>/files/<file_id>?key=…&expires=…, then GET /v1/games/monsterhunterwilds/mods/{mod}/files/{file}/download_link.json?key=…&expires=… with an apikey header. The key/expires pair is what lets non-premium accounts download, so it must be passed through rather than dropped.
  • Also fetch /v1/games/monsterhunterwilds/mods/{mod}.json to prefill name, version, summary and thumbnail. Surface the rate-limit response headers.
  • API key stored in libsecret when gi.repository.Secret is importable, else a 0600 JSON 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.

  1. Skeleton + game discovery — pyproject.toml, paths, config, steam; window that finds the install and shows both PAK chains and the detected ceiling.
  2. Library import — archive, modinfo, layout, library; import archives, list mods with screenshots and metadata. No writes to the game dir yet.
  3. Deploy engine — deploy + paks; enable/disable, conflict detection, load order, preview-changes, undeploy round-trip. The correctness core.
  4. Loader setup — loader; REFramework install, re2_fw_config.txt, launch-option verification.
  5. Profiles + polish — profiles, preferences, search/categories, drag reorder, toasts.
  6. 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.
  • layout classification 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_016 appears from outside.
  • VDF parser against the real libraryfolders.vdf and appmanifest_2246340.acf on this machine.
  • nxm:// URL parsing including key/expires passthrough.

Manual, against the real install:

  1. Launch, confirm it finds MonsterHunterWilds and reports ceiling 015 on both chains.
  2. Install REFramework via the app; confirm dinput8.dll and LooseFileLoader_Enabled=true, and that it reports the missing WINEDLLOVERRIDES launch option with the exact string to copy.
  3. Import a real Wilds mod, enable it, and verify the symlinks under natives/STM/ resolve.
  4. Enable two mods touching the same asset; confirm the conflict shows and reordering flips the winner.
  5. Disable everything and confirm the game directory returns to exactly its pre-mod state — compare against a find-based snapshot taken before step 3.
  6. 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.