Skip to content

feat(relay-client): host the staged mods tree in the Darktide game directory - #229

Merged
ModifAmorphic merged 17 commits into
release/1.1.0from
feat/game-dir-mod-hosting
Aug 19, 2026
Merged

feat(relay-client): host the staged mods tree in the Darktide game directory#229
ModifAmorphic merged 17 commits into
release/1.1.0from
feat/game-dir-mod-hosting

Conversation

@ModifAmorphic

@ModifAmorphic ModifAmorphic commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Why

Curator served mods from profile staging (<profiles>\<id>\staged\mods\)
outside the game directory. Mods that resolve game-directory-relative paths
break under that shape: Simple Assets and Simple Audio cannot load the DLL
they ship with and cannot resolve the darktide.exe binary. Fixes #230.

What

  • Mods now deploy into the Darktide game directory by default: each modded
    launch stages the profile, then points <game>/mods (an NTFS junction on
    Windows, a symlink on Linux) at the active profile's staged mods tree and
    hands Relay the game directory as --mod-path. The engine sees the real
    game-dir mods root, so mods that resolve game-directory-relative paths
    (for example SimpleAudio) work; vanilla launches from Steam remain
    untouched because nothing loads mods without Relay.
  • Ownership of the game-dir mods slot is decided by a staging marker
    (.curator.json, rewritten every staging pass) plus a profiles-root
    prefix check, never by the entry being a link. A foreign entry is never
    deleted or modified: the launch returns GameDirConflict and the UI asks
    whether to rename it aside (mods_<timestamp>, collision-bumped, receipt
    recorded in app-state, best-effort README inside) and retry once, or
    cancel. A one-line notice reports the new folder name after the rename.
  • New global experimental preference "Load mods from Curator's profile
    directory (experimental)" restores the previous launch shape (mods served
    from staging, Curator-owned game-dir link removed best-effort).
  • Renamed-folder receipts persist in app-state (RenamedModsFolders).
  • Docs updated across AGENTS.md, README, the architecture doc, and the
    reference docs: no patched game files, no copies, one self-identifying
    link, vanilla Steam launches stay vanilla.

CI

  • The PR gate runs on pull requests into any branch (previously main only),
    so release-branch PRs get the same gate.
  • The format job was simplified: a single checkout of the PR head, one
    unconditional format step, and one remaining conditional
    (workflow_dispatch runs format without committing).

Verification

  • dotnet build + dotnet test Release green across all eight test
    projects (2,067 tests); dotnet format --verify-no-changes clean.
  • Claim ladder, mod-path switch, external-mode removal, conflict shape, and
    Linux path translation covered by real-filesystem tests; junction-specific
    behavior additionally exercised on Windows by the PR matrix.

Append-only audit receipts for foreign game-dir mods entries renamed
aside with consent: one RenamedModsFolder record (original, renamed,
timestamp) persisted through a new IRenamedModsFoldersState role over
the shared AppStateStore, mirroring the KnownUpdates pattern incl.
old-file-without-field compatibility.
The experimental external mod-hosting toggle, default false (standard
game-dir hosting). Global, read live per launch like ShowRelayConsole;
an absent field on an older config file loads as the default.
…sRoot

PrepareModRoot rewrites .curator.json into the staged mods/ on every
pass (schema, profile id + name, projection timestamp) so a game-dir
hosting link aimed at the tree can prove Curator owns it; the filename
is the shared StagingOwnership.MarkerFileName contract. ProfilesRoot is
the new focused read the ownership prefix check consumes.
IGameDirModsHost owns the game-dir mods ladder: claims are proven by
the staging marker inside a link's target or a target under the
profiles root (never reparse-ness alone), foreign entries are reported
not mutated, re-pointing replaces the link only, and the consented
TakeOver renames a foreign entry aside with a README + an app-state
receipt. RelayLaunchService inserts the host step after staging +
game-binary resolution: hosting is the default (--mod-path = the
derived game dir, validated to exist), the external preference
restores the staged-root launch with best-effort owned-link removal,
and a foreign slot returns the new LaunchStatus.GameDirConflict
(message = detected path, GameDirPath = the dir) before any spawn.
Link IO failures map to Error with the exception's message.
The shell's GameDirConflict branch shows the three-choice modal through
IDialogService.ShowGameDirConflictAsync (GameDirConflictDialog, the
UnsavedChangesDialog pattern incl. EscapeClosesBehavior; Cancel is the
enum default so ESC/X/close abort). Proceed performs the consented
IGameDirModsHost.TakeOver + retries the launch once; Keep my current
setup persists Preferences.ExternalModHosting through a focused
read-modify-save + retries once; a second conflict in the chain
surfaces the standard error alert. The launch-attempt overlay state
holds through the modal + retry exactly like the failure dialogs. The
Preferences destination gains the experimental external-hosting toggle
with its known issue stated; the composition root registers
IGameDirModsHost over the Profiles link primitive + the receipts role.
…omposition

The composition smoke harness composes the real services like the UI
root; RelayLaunchService now resolves IGameDirModsHost, so mirror the
CuratorComposition registration (Profiles link primitive + receipts
role).
Replace the no-game-directory-footprint language with the one-link
story (no patched game files, no copies, one self-identifying opt-in
mods link at <game>/mods; vanilla Steam launches stay vanilla): AGENTS
directory-structure + ops rows, the root README tagline, the
architecture doc (Relay contract, staging marker, the Launch section's
game-dir hosting subsection incl. the consent + external opt-out), and
the profiles/relay-client/config/general/ui references.
… readme

The receipt is the user's audit trail for a rename that already
happened, so it is recorded immediately after the successful
rename-aside; the README.txt write becomes best-effort (a failure is
logged, never surfaced). The spec's Proceed bullet + the host contract
doc now state the order, and a new ladder test drives a README-write
failure (a directory occupying the README path) proving the takeover
succeeds + the receipt persists.

Also: name IGameDirModsHost in the AddRelayClient summary (it resolves
from the container, registered by the composition root), and strip
consumer identity + read-append-assign narration from the
IRenamedModsFoldersState / RenamedModsFolder contract docs (storage
semantics only).
…notice

Operator-directed shape: the dialog drops the Keep-my-current-setup
choice (and the preference-persisting branch in the shell; the
ExternalModHosting preference stays reachable only via Preferences) and
becomes Cancel / Rename with short copy (title 'Existing mods folder',
a two-sentence body, short button labels).

TakeOver now returns the renamed entry's path (null when nothing was
renamed); after a successful takeover the shell shows a one-line rename
notice carrying it BEFORE the single retry, so the information survives
a later launch failure. Cancel remains the enum default so ESC/X/window
close abort; EscapeClosesBehavior unchanged.

The Preferences checkbox copy is the operator's exact wording: label
'Load mods from Curator's profile directory (experimental)', hint 'May
experience issues with mods that require absolute paths'.

Tests: the keep-setup shell test is dropped; new coverage for the
notice + its before-the-retry ordering + the null-return skip + cancel
never touching the preference, and the host tests assert the return
value on every takeover path. Docs (spec, architecture, AGENTS, ui +
relay-client references) describe the two-choice flow + notice.
A dangling NTFS junction still reports Directory.Exists true on Windows
(the reparse point carries the directory attribute), so the setup
assertion after creating the dead link failed on the Windows CI leg
before the host was exercised. Assert the dangling state universally
instead: the slot is a reparse point + the stored target is absent.
@ModifAmorphic
ModifAmorphic merged commit b7b6d7f into release/1.1.0 Aug 19, 2026
7 of 8 checks passed
@ModifAmorphic
ModifAmorphic deleted the feat/game-dir-mod-hosting branch August 19, 2026 17:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant