Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
919 changes: 839 additions & 80 deletions AGENTS.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ and it runs unmodified).
profiles, dependency resolution, mod-source integrations). The backend
libraries (Profiles, Mods, Steam, Integrations, Relay-client, General) and
the UI (a left navigation rail with five destinations: Profiles, Mods, Nexus
Integrations, Preferences, Settings; plus profile management and the Launch
Integrations, Preferences, Settings; plus profile management, including
cloning a profile into an independent copy (named "Copy N", with mod files
shared rather than duplicated), and the Launch
flow) are in place. The app is user-usable. The Mods list offers a persisted
Compact and Detailed row density; Detailed rows show a Nexus summary and a
cached thumbnail when one is available, with a neutral placeholder otherwise.
Expand Down
34 changes: 26 additions & 8 deletions docs/architecture/MODIFICUS-CURATOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,18 @@ for the full contract (env-var table, logging, the hook-ready handshake).
update of an entry already in the profile is a strict no-op that preserves
the user's current order, enabled state, policy, and lock. Existing profile
entries are not migrated.
- **Cloning:** the focused `IProfileCloner.CloneProfile` capability persists an
independent copy of a profile in one operation (never reconstructed through
repeated `AddMod`, which would replay fresh-add policy such as DMF
placement). The copy gets a new id + creation timestamp, a generated
` (Copy N)` family name (stable data, never localized; `Testing (Copy 1)`
belongs to the `Testing` family), and the source's description, complete mod
membership (enabled state, order, locks, policies incl. pinned version
ids), and launch settings. Mod files stay in the shared repository (the
clone references the same containers; nothing is duplicated), and the
staged tree is not copied: the clone receives an empty `staged/` scaffold
that ordinary launch staging rebuilds. Cloning raises no
`ProfileCreated`, so the DMF offer never fires for a clone.
- Mods are stored **once, in a unified repository** keyed by `(source, identity)`
per UUID container. Profiles reference a mod by `(containerId, policy)` and
store no mod files of their own. See [Mod repository](#mod-repository).
Expand All @@ -287,9 +299,12 @@ Each version is a subfolder with an **opaque unique ID**; the raw version tag
lives only in the manifest (for display + pin resolution), never as a folder
name. **`isLatest` is a flag on one version entry**, not a duplicate folder:
the repository re-evaluates the flag on every add/remove with the
**effective-timestamp key** (`remoteUploadedAt` when the remote source
published the file, else the import time, with the import time breaking exact
ties), so importing an older remote file never flips latest. Each version
**arrival rule** (the most recent arrival, i.e. the newest import time,
decides the clock: a manual import with the newest arrival is latest;
otherwise the newest downloaded version by `remoteUploadedAt`, with the
import time breaking exact ties), so importing an older remote file never
flips latest while a download arriving after a manual import takes the
flag. Each version
entry also records the remote file id it was acquired from (`FileId`, the
exact identity the download queue's repository hit check keys on; null for
manual imports, self-healing on re-acquisition). Moving latest is a one-field
Expand Down Expand Up @@ -367,7 +382,7 @@ contents; the archive is validated to have a single top-level folder before
extraction). Container dedup: Untracked by name, Nexus by mod id. Version dedup:
re-importing the same tag reuses its folder (refreshed); a new tag creates a new
version, and the repository re-evaluates `isLatest` over all versions with its
effective-timestamp key. The service returns `(containerId, versionId)`,
arrival rule. The service returns `(containerId, versionId)`,
where `versionId` is the imported version's opaque on-disk folder id (a
`ModVersion.Folder` value); the display tag (`ModVersion.VersionString`) is
recorded in the container manifest and is not returned. The caller then adds
Expand Down Expand Up @@ -647,10 +662,13 @@ is in [UI reference](../reference/ui.md).
drag-reorder grip, column 1 is the thumbnail/placeholder slot, column 2
holds the name + source badge (row 0) and a two-line summary (row 1), and
row 2 is the action strip. When the card
is wide (greater than 680 DIP) a 112-DIP thumbnail spans all three rows and
the action strip occupies only the content column; when constrained (at or
below 680 DIP) the thumbnail shrinks to 72 DIP spanning name + summary and
the action strip moves to a full-width row beneath all three columns. The action
is wide (greater than 680 DIP) a 192x108 DIP 16:9 thumbnail (stretched
`Uniform`, so the complete source image shows with the frame's neutral
background letterboxing/pillarboxing non-16:9 assets) spans all three rows
and the action strip occupies only the content column; when constrained (at
or below 680 DIP) the thumbnail shrinks to 128x72 DIP spanning name +
summary and the action strip moves to a full-width row beneath all three
columns. The action
strip is a right-aligned `WrapPanel` that wraps at the edge (no horizontal
scrolling); width, height, row span, and the action column/span are driven by
styles so the breakpoint changes them. The summary is plain text with `CharacterEllipsis` trimming and the full
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/mod-acquisition.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ and `IHttpClientFactory` (for the raw CDN download) from the container.
the archive structure (single base folder plus matching `<base>.mod`
descriptor; archive detection is content-based via SharpCompress), handles
find-or-create-container (dedup by `NexusSource.ModId`) plus add-version,
re-evaluates `IsLatest` with the repository's effective-timestamp key, records
re-evaluates `IsLatest` with the repository's arrival rule, records
the publish date and the file id on the entry (new or reused), replaces the
container's `DisplayMetadata` in the same
manifest update as the version mutation, and extracts into
Expand Down
50 changes: 44 additions & 6 deletions docs/architecture/ui-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ a UI-layer singleton that the shell (and other view models) inject:
│ │ strip; mirrors session state
│ │
│ ├── ProfilesViewModel ─── the active profile editor (name + description
│ │ │ + inline launch settings) + banner/picker
│ │ │ + inline launch settings) + banner/picker/clone
│ │ │
│ │ └── LaunchSettingsEditorViewModel the reusable inline launch-settings
│ │ rows (env vars + args + toggles)
Expand Down Expand Up @@ -195,6 +195,42 @@ running-state change does not reload the list (the list stays put while the
game runs; edits land on the profile the user will launch next). Active-id
changes rebuild the list from the new profile.

## The Profiles destination (`ProfilesViewModel`)

The Profiles page edits the active profile only (name + description + the
inline launch-settings editor), hosts the persisted-profile banner + picker,
creates new drafts, and carries the Add / Clone / Delete action row for the
active persisted profile. Every voluntary active-profile change routes
through the session's `RequestActive` gate; the page never writes the
persisted profile outside the atomic `UpdateProfile` / `CreateProfile`
boundary.

**Cloning** (the Clone action, between Add and Delete) copies the active
persisted profile through the focused `IProfileCloner` capability:

- **Gating.** Clone is visible only for an active persisted profile and
disabled while a new draft is open, while a Save is in flight, and while
Darktide runs (the clone becomes active, which is a profile switch). All
gates are re-checked in the command body after the dirty-transition await;
the disabled button keeps its tooltip (`ToolTip.ShowOnDisabled`) so the
running-state reason stays available.
- **Dirty guard.** A dirty editor resolves through the same unsaved-changes
transition as navigation/switch/Add: Save persists the edits then clones
the saved profile, Don't save discards the edits then clones the previously
persisted profile, Cancel/ESC/X creates nothing and preserves the edits.
- **Activation.** On success the returned clone is requested active through
the session (with the page's own-reload suppression so the session event
handler does not treat it as an outside displacement), then one
authoritative reload opens the clone in a clean editor. Cloning is
immediate + non-destructive, so it needs no confirmation; the new banner
name is the success feedback.
- **DMF suppression.** `CloneProfile` raises no
`IProfileService.ProfileCreated` (the blank-profile signal behind the DMF
offer), so cloning a profile never queues the DMF prompt.
- **Failure.** An expected clone/read/write failure logs, keeps the source
active + unchanged, and surfaces the localized generic clone error in the
page's fixed error area; raw exception text is never shown.

## The shell (`ShellViewModel` + `MainWindow`)

The shell owns navigation across five hosted destinations (the
Expand Down Expand Up @@ -1085,11 +1121,13 @@ subscription re-hydrates from the store when the result lands.
holds the name + source badge (row 0) and a two-line plain-text summary
(row 1, `MaxLines=2`, `TextWrapping=Wrap`, `TextTrimming=CharacterEllipsis`,
full text in the tooltip and the automation name), and row 2 is the action
strip. When the card is wide (greater than 680 DIP) a 112-DIP
`UniformToFill` thumbnail spans all three rows and the action strip occupies
only the right column; when constrained (at or below 680 DIP) the thumbnail
shrinks to 72 DIP spanning name + summary and the action strip moves to a
full-width row beneath both columns. Width, height, row span, and action
strip. When the card is wide (greater than 680 DIP) a 192x108 DIP 16:9
`Uniform` thumbnail (the complete source image; the frame's neutral
background letterboxes/pillarboxes non-16:9 assets) spans all three rows and
the action strip occupies only the right column; when constrained (at or
below 680 DIP) the thumbnail shrinks to 128x72 DIP spanning name + summary
and the action strip moves to a full-width row beneath both columns. Width,
height, row span, and action
column/span that change at the breakpoint are driven by styles (not local
values, which would outrank styles); constant row/column positions stay
local. Both roots bind the exact same per-row state and route to the exact
Expand Down
Binary file added docs/images/Curator_Action_List.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/main.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/screenshots/mods_detailed_2k.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/screenshots/preferences_2k.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/screenshots/profile_active_2k.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 6 additions & 2 deletions docs/nexus/description.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ converter_ignore fences so it never reaches the page.
**Modificus Curator** is a mod manager for
**Warhammer 40,000: Darktide**, for Windows, Linux, and the Steam Deck.
Source code and releases: [Curator GitHub repository](https://github.com/ModifAmorphic/darktide-modificus-curator).
Guides and help: [Curator wiki](https://github.com/ModifAmorphic/darktide-modificus-curator/wiki).

Curator loads mods into Darktide via dll injection with [Mod Relay](https://github.com/ModifAmorphic/darktide-mod-relay).
It does this without altering any Darktide files. No patching or unpatching required. To run vanilla Darktide,
Expand Down Expand Up @@ -95,11 +96,14 @@ Hit **Launch Darktide** to inject mods and launch the game.

**Note:** If your game was already patched then you'll need to restore it to "Vanilla" prior to launching. See the next section.


## Migrating from Patched Darktide

If you previously patched Darktide to load the Darktide Mod Loader, then it needs to be restored to Vanilla for mods to work properly. The easiest is to simply "Verify integrity of game files" from within Steam's Darktide properties under "Installed Files". This can take a bit of time (few minutes usually). You can also unpatch your game the same way you originally patched it. Whatever method you choose, DML (Not to be confused with DMF - Darktide Mod Framework) and Curator are not compatible.

## Importing an Existing Load Order and Mods

On the **Mods** page, open the add button's drop-down menu and select **Import mod list**. Choose an existing `mod_load_order.txt` and select **Reorder mods** or **Reorder and import mods** to review what Curator will do before applying it.

## Vanilla

To play Darktide without mods, simply launch the game from Steam. Because Modificus Curator leverages Relay's dll injection to load mods at runtime there's no changes to revert.
Expand All @@ -115,4 +119,4 @@ To play Darktide without mods, simply launch the game from Steam. Because Modifi
- Updates: update checks across the list, flagged rows, Premium in-app
install + optional automatic update install
- Quality of life: DMF install prompt for new profiles, in-app self-update,
themes + localizations, Steam Deck Gaming Mode support
themes + localizations, Steam Deck Gaming Mode support
41 changes: 39 additions & 2 deletions docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public interface INexusClient
Task<Response<ModInfo>> GetModInfoAsync(string gameDomain, int modId, CancellationToken ct = default);
Task<Response<ModFile[]>> ListModFilesAsync(string gameDomain, int modId, CancellationToken ct = default);
Task<Response<ModUpdateStatus[]>> CheckUpdatesGraphQlAsync(int gameId, IReadOnlyList<int> modIds, CancellationToken ct = default); // v2 GraphQL
Task<Response<NexusSearchResult[]>> SearchModsAsync(string gameDomain, string terms, int count, CancellationToken ct = default); // anonymous v2 GraphQL
Task<Response<NexusSearchResult?>> GetModByIdAsync(string gameDomain, int modId, CancellationToken ct = default); // anonymous v2 GraphQL
}
```

Expand All @@ -39,11 +41,46 @@ public interface INexusClient
Returns `ModUpdateStatus[]` with the server-computed `viewerUpdateAvailable`
field per mod. Throws `NexusApiException` on GraphQL-level errors in a 200 OK
body (in addition to the standard HTTP error handling).
- `SearchModsAsync` -- the ANONYMOUS v2 GraphQL `mods` search, ONE request
in the Nexus website's own search shape (grounded against the captured live
website query): the supplied phrase goes out verbatim as
`name:[{op:WILDCARD,value:"terms"}]` (no literal asterisks; Nexus's
wildcard index owns the matching), scoped by
`gameDomainName:[{op:EQUALS,value:"warhammer40kdarktide"}]` (only the
Darktide domain resolves), with `viewUserBlockedContent:false`,
`sort:{relevance:{direction:DESC}}` (best match first; the result is
capped, so ordering decides which hits the page carries, and a
newest-first page can omit the exact title entirely), and the given
`count`, requesting only `nodes { modId name uid }`. Returns
`NexusSearchResult` (modId + name + uid). The request
is the one client call that carries NO credentials: it routes around the
auth factory entirely (a plain request with only the
app-identification headers; no auth gate, no 401-refresh), because the
endpoint is anonymous (verified live) and sits behind Cloudflare, not the
API key budget. Anonymous responses carry no `x-rl-*` headers; they are
parsed onto the `Response<T>` anyway if ever present. Throws
`NexusApiException` on a non-2xx or a GraphQL-level error in a 200 OK body.
Callers stay serial + human-paced (the
Cloudflare posture); the load-order resolver's search queue is the model.
- `GetModByIdAsync` -- the ANONYMOUS exact-identity counterpart to the fuzzy
search: looks ONE Darktide mod up by its numeric id through the v2 GraphQL
`modsByUid` query (`uid = game_id * 2^32 + mod_id`, identity fields only:
`nodes { uid name }`), and returns the mod's canonical identity
(`NexusSearchResult`), or null when the id resolves to no Darktide mod
(an empty node list is the documented not-found answer, not an error).
The same anonymous routing as the search (no auth factory involvement, no
credentials, works signed out). Throws `ArgumentException` for a
non-Darktide domain or a non-positive id; `NexusApiException` on a non-2xx
or a GraphQL-level error in a 200 OK body. Its caller (the load-order
workspace's manual id/URL verification) never treats a syntactically valid
id as verified identity without this call confirming it.

Every method throws `NexusApiException` on a non-2xx; `NexusRateLimitException`
on a rate-limit signal (429, or 403 with `x-rl-*-remaining: 0`);
`NexusNotAuthenticatedException` when `AuthMethod == None` or the selected
method has no usable credentials.
method has no usable credentials -- except `SearchModsAsync` and
`GetModByIdAsync`, which work signed out and never throw the
not-authenticated exception.

**401-reactive refresh + retry-once.** On a 401, the client asks the auth
factory to refresh (OAuth) or give up (API key, None). On a successful refresh
Expand Down Expand Up @@ -326,7 +363,7 @@ UI-layer download queue wires it to its per-row progress.
4. **Import** via `IModImportService.Import(tempPath, modName, new NexusSource
{ ModId = modId }, version, remoteUploadedAt, remoteFileId, displayMetadata)`. The import service handles
find-or-create-container (dedup by `NexusSource.ModId`) + add-version +
the effective-timestamp `IsLatest` re-evaluation + records
the arrival-rule `IsLatest` re-evaluation + records
`RemoteUploadedAt` and the Nexus `FileId` on the entry (new or reused), and
the `displayMetadata` argument replaces the container's `DisplayMetadata` in the
same manifest update (an acquisition that fetched newer text wins atomically
Expand Down
Loading