Deployment & VFS
Overview
modde never copies mods into your game directory and never edits the original
files. Instead it builds a symlink farm: a virtual filesystem (VFS) assembled
from symlinks that point back at a content-addressed store. The game — and any
external tool — sees a fully merged, modded Data/ (or ~mods/, or mods/,
depending on the game), while the real install on disk stays pristine.
This is the Linux-native equivalent of Mod Organizer 2’s USVFS, with one decisive
advantage: USVFS hooks file-system calls per-process, so only the hooked process
sees the merged tree. modde’s symlinks live in the real filesystem, so they are
globally visible to every process — the game, xEdit, a packer, a shell —
without any API hooking.
The VFS symlink farm is the deploy path for Nexus and Manual (store-backed) profiles. Wabbajack profiles take a different, hardlink/copy path — see Wabbajack profiles below.
The deployment pipeline
Deployment is a typestate pipeline enforced at compile time:
SymlinkFarm<Built> link map computed in memory (paths only, no I/O)
│ .materialize()
▼
SymlinkFarm<Materialized> symlinks written into the profile staging dir
│ .deploy_to() / deploy_to_install()
▼
(symlinks in the game's mod directory)
Each stage is a distinct Rust type. You cannot call deploy_to() on a Built
farm — the compiler rejects it — so it is impossible to deploy a farm that was
never written to disk. The state marker is a zero-sized PhantomData, so this
safety costs nothing at runtime.
1. Build
SymlinkFarm::build() computes a single HashMap<relative_path, source_path>
where source_path is the absolute path of the winning file inside the
store. No filesystem writes happen yet — this phase is pure path resolution.
The build takes:
| Input | Meaning |
|---|---|
profile_name | Selects the staging directory location |
resolved | The mods in final priority order (first = lowest priority) |
mod_files | Per-mod (relative_path, store_source_path) listings |
overrides | Optional profile-level override files (always win) |
hidden | Optional (mod_id, relative_path) pairs to exclude |
How files map to winners
The build walks resolved.order from lowest to highest priority and inserts
each (rel_path → source) into the map. Because later inserts overwrite earlier
ones for the same key, the highest-priority provider of each path wins.
The effective priority chain, highest first:
- Profile overrides — files in the profile’s
overridesdirectory are layered last and beat every mod. - Later mods in the load order — install priority, last-wins.
- Earlier mods in the load order.
A file listed in hidden as (mod_id, rel_path) is skipped while walking that
mod, so the next-highest provider of that path wins instead. This is how
per-file hiding takes effect — the equivalent
of MO2’s .mohidden. A mod that appears in resolved.order but has no entry in
mod_files (its store directory is missing) is silently skipped during build;
the deploy command logs the missing mods separately.
Install priority is not plugin load order. The symlink farm resolves which file wins on disk. Bethesda
.esp/.esm/.eslordering is a separate concern handled by LOOT andplugins.txt. See Conflicts & Load Order.
2. Materialize
materialize() writes the farm to the per-profile staging directory:
~/.local/share/modde/profiles/<profile>/staging/
It first removes any existing staging directory in full, recreates it, then for
each (rel_path → source) it creates the parent directories and writes a
symlink at staging/<rel_path> pointing at the absolute store source.
Because materialize wipes and rebuilds staging from scratch, the staging tree
always reflects exactly the current Built farm — there are no stale leftovers
from a previous deploy.
The content store these links point into lives at:
~/.local/share/modde/store/<mod_id>/...
3. Deploy
The materialized staging tree is projected into the game’s resolved mod directory. For each relative path the deploy step removes any pre-existing entry at the destination and creates a symlink pointing at the corresponding file inside the staging directory (again an absolute path). The result is a two-hop chain:
<game>/Data/textures/sky.dds ─sym→ staging/textures/sky.dds ─sym→ store/<mod_id>/textures/sky.dds
Final placement is delegated to the game plugin via deploy_to_install(), so a
game can apply its own deploy strategy (for example, UE4 titles route paks into
Content/Paks/~mods/). After deployment modde runs the game’s post_deploy
hook and configures Wine DLL overrides (WINEDLLOVERRIDES) for any proxy DLLs
the mods deploy, such as version.dll for CET or winmm.dll for ASI loaders.
Symlinks: absolute, not relative
Both hops use the symlink target exactly as passed — these are absolute
paths in modde, not relative ones. On Linux the call is a plain
symlink(2); on Windows modde inspects the target to choose symlink_file
vs symlink_dir.
If a store entry is missing, the symlink is still created but dangles. modde
does not error at deploy time for a broken link — the missing-mod case is caught
earlier during build (the mod is skipped and reported), and dangling links are
surfaced by modde verify, which flags any
broken symlink -> <target>.
Deploying
Manual deployment
# Deploy the active profile
modde deploy
# Deploy a specific profile
modde deploy --profile my-skyrim --game skyrim-se
Deploy via play
modde play deploys before launching the game:
modde play --game skyrim-se
Automatic deployment
After rebuilding your NixOS / home-manager configuration, modde deploys profiles
via an activation script when their prerequisites are present. Wabbajack
profiles can wait non-fatally for the game install: set
installMode = "await-game" or leave gameDir unset until Steam or Heroic has
installed the game, then set gameDir and rebuild. For Home Manager Wabbajack
profiles, the install runs before deployment only when the configured gameDir
exists and contains the expected game content directory (for example Data/ for
Skyrim SE); otherwise activation prints an awaiting message and continues.
The store link strategy
The symlink farm only ever creates symlinks. A second, distinct mechanism —
link_or_copy — is used wherever modde must materialise an actual file rather
than a link (notably the Wabbajack deploy path). It tries three strategies in
order and reports which one it used:
| Strategy | When it applies | Cost |
|---|---|---|
| Hardlink | Source and destination on the same filesystem | Free; shares inode/blocks |
| Reflink | Hardlink crossed a filesystem (EXDEV) but the FS supports copy-on-write (btrfs, XFS, ZFS) | Free until written; CoW |
| Copy | Neither of the above worked | Full byte copy |
The destination is removed first if it exists. A cross-device error
(EXDEV on Linux, ERROR_NOT_SAME_DEVICE on Windows) triggers the fall to
reflink; a reflink failure falls to a plain copy. This keeps deployments cheap
on a single CoW filesystem and correct everywhere else.
Rollback
modde rollback swaps the profile’s staging and staging.bak directories:
modde rollback --profile my-skyrim
The swap is performed with directory renames, which are atomic on a single filesystem:
- If a current
stagingexists, it is renamed aside tostaging.old,staging.bakis renamed into place asstaging, andstaging.oldis removed. - If no current
stagingexists,staging.bakis renamed straight into place.
If there is no staging.bak for the profile, rollback fails with
no backup staging found for profile '<name>'. modde does not silently create a
backup on every deploy — materialize() rebuilds staging in place — so
staging.bak is a deliberate restore point you (or higher-level tooling)
preserve before a risky change. After a successful rollback you must re-run
modde deploy to re-project the restored staging tree into the game directory.
Wabbajack profiles
Wabbajack-installed profiles do not use the symlink farm. A Wabbajack modlist
ships a pre-built, already-conflict-resolved file layout under
~/.local/share/modde/staging/<profile>/mods/<mod>/..., so there is nothing for
the VFS to resolve. Deploying one walks that mods/ tree and places each file
into the game directory with link_or_copy (hardlink → reflink → copy):
- MO2 metadata files (
meta.ini,meta.json) are skipped. - A file already hardlinked to its source (same inode and device) is skipped, so re-deploys are near-instant.
- Wine DLL overrides are configured afterwards, exactly as for store-backed profiles.
Hardlinking (rather than symlinking) is used here because the staging tree is the authoritative, fully-merged layout — the game sees real files, which is what Wabbajack lists and their tools expect. See the Wabbajack guide for the full install flow.
Verifying integrity
modde verify --profile my-skyrim
verify walks every enabled mod’s store directory, hashes each file with xxHash,
and checks symlink health. It reports:
- Total files checked
- Missing — enabled mods absent from the store, or files that vanished
- Mismatches/errors — files it could not hash, and any
broken symlink -> <target>where the link target no longer exists
A clean run prints All files OK. This is the fastest way to catch a store
entry that was deleted out from under a deployed profile.
Vanilla (stock) snapshots
A stock snapshot captures the original game installation so you can detect when something — a mod, a tool, a botched deploy — has modified files that should have stayed vanilla:
# Capture a snapshot of the detected install
modde stock snapshot skyrim-se
# Re-verify the install against the snapshot later
modde stock verify skyrim-se
snapshot detects the install directory via the game plugin, records the stock
file set, and prints the snapshot path and file count. verify re-checks the
current install against the recorded snapshot and prints either
Stock snapshot for <game>: OK or
Stock snapshot for <game>: MISMATCH (re-snapshot recommended). Take the
snapshot before you first deploy mods, while the game is genuinely vanilla.
See also
- Conflicts & Load Order — how the winning file is chosen and how Bethesda plugin order differs from install priority
- Wabbajack — the hardlink/copy deploy path for modlists
- Profiles — overrides, hidden files, and per-profile staging
- Playing —
modde playand launch integration - Troubleshooting — broken symlinks and missing-store fixes
- Parity reference — VFS feature coverage vs MO2