230 lines
13 KiB
Markdown
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.
|