Files
MHW-Mod-Manager/plan.md
T

230 lines
13 KiB
Markdown

# 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`
### `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 -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.