feat(relay-client): host the staged mods tree in the Darktide game directory - #229
Merged
Conversation
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.
…lify the external-hosting tooltip
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
launch stages the profile, then points
<game>/mods(an NTFS junction onWindows, 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 realgame-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.
(
.curator.json, rewritten every staging pass) plus a profiles-rootprefix check, never by the entry being a link. A foreign entry is never
deleted or modified: the launch returns
GameDirConflictand the UI askswhether to rename it aside (
mods_<timestamp>, collision-bumped, receiptrecorded in app-state, best-effort README inside) and retry once, or
cancel. A one-line notice reports the new folder name after the rename.
directory (experimental)" restores the previous launch shape (mods served
from staging, Curator-owned game-dir link removed best-effort).
RenamedModsFolders).reference docs: no patched game files, no copies, one self-identifying
link, vanilla Steam launches stay vanilla.
CI
so release-branch PRs get the same gate.
unconditional format step, and one remaining conditional
(workflow_dispatch runs format without committing).
Verification
dotnet build+dotnet testRelease green across all eight testprojects (2,067 tests);
dotnet format --verify-no-changesclean.Linux path translation covered by real-filesystem tests; junction-specific
behavior additionally exercised on Windows by the PR matrix.