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
40 changes: 32 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ Requirements, architecture, and technology choices are made fresh.
## Repository state

- **`main`** — production. Enginseer (the injected modding runtime + launcher) is
merged as the production seed; Magos Modificus is scaffolded (Phase 0:
.NET 10 + Avalonia 12 foundation — the backend libraries (Profiles, Steam,
Integrations, Enginseer-client, SharedMods) are implemented in Phases 1–2;
the UI is still the bare Phase-0 window, and the Launcher is a stub).
merged as the production seed; Magos Modificus has the backend libraries
implemented (Phases 1–2) and the Phase 3 Track A UI in progress: the app shell
+ profile management (dropdown switch, persisted active profile, create/rename
/delete dialog, switch-blocked-while-running) are wired (milestones 1–2);
mod-list UI (Track B) + Launch behavior (Track C) are still pending, and the
Launcher is a stub (Phase 5).
- **`poc`** — historical proof-of-concept, reference only. Not built upon.
- Development is branch + PR; no unreviewed merges to `main` (reviewed +
covered + qa'd + CI green).
Expand Down Expand Up @@ -64,8 +66,10 @@ magos-modificus/ Magos Modificus — the mod manager app (.NET 10 + Avalo
magos-modificus.sln solution root (classic .sln)
Directory.Build.props shared MSBuild props (net10.0, nullable, implicit usings)
ui/ Magos.Modificus.UI — the Avalonia executable + DI composition root
(Phase 3 Track A: shell + profile management — dropdown switch,
persisted active profile, create/rename/delete dialog)
general/ Magos.Modificus.General — cross-cutting infra (logging bootstrap,
config loader, AddGeneral() DI ext)
config loader, app-state store, AddGeneral() DI ext)
config/ Magos.Modificus.Config — the MagosConfig schema + defaults (POCO)
profiles/ Magos.Modificus.Profiles — profile data model, persistence,
shared-first staging (ProfileService.PrepareModRoot builds the
Expand Down Expand Up @@ -95,6 +99,10 @@ magos-modificus/ Magos Modificus — the mod manager app (.NET 10 + Avalo
Magos.Modificus.Steam.Tests/ xUnit tests for discovery + IsGameRunning
Magos.Modificus.EnginseerClient.Tests/ xUnit tests for the launch façade (dual-purpose:
`dotnet test` = xUnit; `dotnet run` = composition smoke harness)
Magos.Modificus.UI.Tests/ xUnit tests for the shell + manage-profiles
view models (profile CRUD/switch, active-profile
persist, switch-blocked-while-running; dialog via
an injectable IDialogService seam)
docs/ architecture/ + reference/ (darktide/, community-tools/, magos-modificus/)
.github/workflows/ CI: mingw-build + msvc-build (Enginseer) + magos-build (Magos Modificus)
.gitignore ignores enginseer/target, enginseer/bin, .NET bin/obj, build artifacts, _local/
Expand Down Expand Up @@ -145,7 +153,7 @@ Build + test the mod-manager app — run from the repo root (.NET 10 SDK require
```sh
dotnet build magos-modificus/magos-modificus.sln --configuration Release
dotnet test magos-modificus/magos-modificus.sln --configuration Release
dotnet run --project magos-modificus/ui --configuration Release # bare Avalonia window
dotnet run --project magos-modificus/ui --configuration Release # app shell window
```
- The composition root is `magos-modificus/ui/MagosComposition.cs` (loads
config → builds the Serilog logger → wires every `Add<Library>()`).
Expand All @@ -162,8 +170,15 @@ dotnet run --project magos-modificus/ui --configuration Release # bare Avalo
`LinuxProcessLookup` via `/proc` argv[0] under Proton), **Integrations**
(Phase 1: GitHub Releases client), **Enginseer-client** (Phase 1: the launch
façade), **SharedMods** (Phase 2: shared mod store + version-policy model +
allocation resolution). The **UI is still the bare Phase-0 window** and the
**Launcher** is a stub (Phase 5). See `docs/architecture/MAGOS-MODIFICUS.md`.
allocation resolution). **General** carries cross-cutting infra: logging,
`ConfigLoader`, and `AppStateStore` (runtime app-state: the active-profile id,
persisted to a separate `app-state.json`, not `MagosConfig`). **Phase 3 Track A
UI** (the shell + profile management: dropdown switch, persisted active profile,
create/rename/delete dialog) is wired, with an `IProfileSession` (ui/) as the
single authority for the active profile, the switch-block gate, and the live
running-state (polled). Next: Track D (Preferences + i18n), then B (mod-list)
and C (launch); the **Launcher** is a stub (Phase 5). See
`docs/architecture/MAGOS-MODIFICUS.md`.
- **CI** (`magos-build.yml`) is scoped to `magos-modificus/**` + the workflow
file, matrixed on Windows + Ubuntu; gates on build + tests.

Expand All @@ -182,6 +197,15 @@ dotnet run --project magos-modificus/ui --configuration Release # bare Avalo
- **Conventional Commits** (`type(scope): subject`); commit freely on feature
branches. Branch + PR flow; no unreviewed merges to `main`.
- Don't commit secrets, the game binary, or anything under `_local/`.
- **UI icons + decorative markers are drawn geometry, not Unicode glyphs.** In the
Avalonia UI, icons are `<Path Data="…">` (standard Material/Fluent-style path
data, dependency-free, themed via foreground) and dots/markers are `<Ellipse>`,
never `✏`/`🗑`/`⚙`/`●` symbol/emoji glyphs (which render unreliably across
fonts/platforms). Scoped to icons/markers; prose punctuation is covered by the
writing convention below.
- **No em-dashes in prose** (code comments, docs, commits, chat). Em-dashes read
as an AI-generated tell; use a comma, colon, parentheses, semicolon, or period
instead.

## Naming convention

Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ Magos has two components:
production seed of this repo. See
[`enginseer/README.md`](enginseer/README.md) for build + developer details.
- **Magos Modificus** — the mod manager app (UI, staging, load order, profiles,
dependency resolution). Phases 0–2 complete: the backend libraries (Profiles,
Steam, Integrations, Enginseer-client, SharedMods) are implemented; the UI is
still the bare Phase-0 window (no profile/mod-management UI yet) and the
Launcher is a stub (Phase 5). See
dependency resolution). Phases 0–2 complete; Phase 3 Track A in progress: the
backend libraries (Profiles, Steam, Integrations, Enginseer-client,
SharedMods) are implemented, and the UI shell + profile management (dropdown
switch, persisted active profile, create/rename/delete dialog) are wired.
Mod-list UI and Launch behavior are still pending, and the Launcher is a stub
(Phase 5). See
[`magos-modificus/README.md`](magos-modificus/README.md) for developer/build
details.

Expand Down
54 changes: 47 additions & 7 deletions docs/reference/magos-modificus/general.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# General (`Magos.Modificus.General`) — reference

> Cross-cutting infrastructure: structured logging, JSON config loading, and the
> DI registration that wires both into the container. Status: implemented (Phase 0).
> Cross-cutting infrastructure: structured logging, JSON config loading, runtime
> app-state persistence, and the DI registration that wires all three into the
> container. Status: implemented (Phase 0; app-state store added in Phase 3).

The composition root (`magos-modificus/ui/MagosComposition.cs`) calls into this
library first — before any domain library — to load `MagosConfig` and build the
Expand Down Expand Up @@ -65,6 +66,40 @@ public sealed class ConfigLoader : IConfigLoader
- `DefaultConfigPath()` — `<LocalApplicationData>/Magos Modificus/config.json`
(`%LOCALAPPDATA%` on Windows, `~/.local/share` on Linux).

### `IAppStateStore` / `AppStateStore`

Persists **runtime application state**: values that capture "where the app left
off" rather than user system settings. Kept deliberately narrow: the only state
today is the last-chosen active profile. A separate file (not `MagosConfig`)
holds it so the settings schema stays pure (system settings vs. runtime state).

```csharp
public interface IAppStateStore
{
Guid? ActiveProfileId { get; set; } // set persists immediately
}

public sealed class AppStateStore : IAppStateStore
{
public AppStateStore(string? path = null);
public string Path { get; }
public static string DefaultStatePath();
}
```

- File: `<LocalApplicationData>/Magos Modificus/app-state.json`
(`{ "ActiveProfileId": "<guid>" | null }`), derived the same way
`ConfigLoader` derives its config path.
- JSON is handled with `System.Text.Json` directly (read + write);
`Microsoft.Extensions.Configuration` is binding-oriented and read-only, the
wrong fit for a tiny writable state file.
- **First-run safe:** a missing or corrupt file never throws; `get` just
returns `null`. Writes are best-effort (runtime state is non-critical; a
persistence failure is swallowed rather than crashing the app).
- Used by `IProfileSession` (the active-profile authority) to restore the active
profile on construction and persist it on changes. The shell and the Manage
dialog read the active id through the session; they do not touch this store.

## DI registration

```csharp
Expand All @@ -83,10 +118,13 @@ DI itself needs them). It registers:
- `AddLogging()` — wires `ILogger<T>` resolution through the factory.
- `AddSingleton<IConfigLoader, ConfigLoader>()` — so a re-load is available if
ever needed (the path is re-resolved to the default location).
- `TryAddSingleton<IAppStateStore, AppStateStore>()`: the runtime app-state
store. `TryAdd` (not `Add`) so a test or host may pre-register an override
(e.g. an in-memory or temp-path store) before `AddGeneral` runs.

There are no `TryAdd` seams here: `config` and `loggerFactory` are constructed
objects passed in, not overridable from the container. Tests that want fakes
construct their own `ServiceCollection`.
`config` and `loggerFactory` are constructed objects passed in, not overridable
from the container; `IAppStateStore` is the one seam here (overridable via
pre-registration).

## Dependencies

Expand All @@ -99,8 +137,10 @@ construct their own `ServiceCollection`.
## Testing

`Magos.Modificus.General.Tests` covers `ConfigLoader` (first-run-safe + JSON
override binding), `LoggingBootstrap` (level parsing, truncation, file/dir
creation), and the `AddGeneral` DI wiring.
override binding), `AppStateStore` (round-trip + first-run + corrupt-file
safety + the app-data default path), `LoggingBootstrap` (level parsing,
truncation, file/dir creation), and the `AddGeneral` DI wiring (including the
`TryAdd` `IAppStateStore` override).

```sh
dotnet test magos-modificus/magos-modificus.sln -c Release
Expand Down
26 changes: 18 additions & 8 deletions magos-modificus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ top of the Enginseer runtime. It owns profiles, mod staging, load order,
dependency resolution, mod-source integrations (Nexus Mods, GitHub Releases,
Steam), and the "Launch Darktide" button that invokes the Enginseer launcher.

> **Status: Phases 0–2 complete.** The foundation + all backend libraries are
> implemented: Profiles, Steam, Integrations, Enginseer-client (Phase 1) +
> SharedMods (Phase 2). The UI is still the bare Phase-0 window (no
> profile/mod-management UI yet); the Launcher is a stub (Phase 5). Target
> architecture:
> **Status: Phases 0–2 complete; Phase 3 Track A in progress.** The foundation +
> all backend libraries are implemented: Profiles, Steam, Integrations,
> Enginseer-client (Phase 1) + SharedMods (Phase 2). Phase 3 Track A wires the
> UI: milestone 1 landed the app shell (top bar + status strip, live
> profile/game-running state) and milestone 2 makes the profile controls work
> (dropdown switch + persisted active profile + a "Manage profiles…" create /
> rename / delete dialog, switch-blocked-while-running). Mod-list UI (Track B)
> and Launch behavior (Track C) are still pending; the Launcher is a stub
> (Phase 5). Target architecture:
> [`../docs/architecture/MAGOS-MODIFICUS.md`](../docs/architecture/MAGOS-MODIFICUS.md).

## Tech stack
Expand All @@ -31,7 +35,9 @@ magos-modificus/
Directory.Build.props shared MSBuild properties (net10.0, nullable)
config.example.json sample global config (schema reference)
ui/ Magos.Modificus.UI Avalonia executable + DI composition root
general/ Magos.Modificus.General cross-cutting infra: logging, config loader, DI
(Phase 3 Track A: shell + profile management)
general/ Magos.Modificus.General cross-cutting infra: logging, config loader,
app-state store, DI
config/ Magos.Modificus.Config the MagosConfig schema + defaults (POCO)
profiles/ Magos.Modificus.Profiles implemented (Phase 1 + Phase 2 staging)
shared-mods/ Magos.Modificus.SharedMods implemented (Phase 2)
Expand All @@ -46,6 +52,7 @@ magos-modificus/
Magos.Modificus.Integrations.Tests/ xUnit tests for the GitHub Releases client
Magos.Modificus.Steam.Tests/ xUnit tests for discovery + IsGameRunning
Magos.Modificus.EnginseerClient.Tests/ xUnit tests for the launch façade (dual-purpose: dotnet test / dotnet run smoke harness)
Magos.Modificus.UI.Tests/ xUnit tests for the shell + manage-profiles view models
```

Each library exposes an `Add<Library>()` extension method on
Expand All @@ -66,8 +73,11 @@ dotnet build magos-modificus/magos-modificus.sln --configuration Release
dotnet run --project magos-modificus/ui --configuration Release
```

The bare Phase-0 window displays the loaded config values, and the startup log
lines (`Magos Modificus starting`, `Config loaded …`, `DI wired …`) go to the
The window shows the top bar (app title, profile dropdown + "Manage profiles…"
gear, Launch Darktide) and the status strip (Darktide running indicator). The
profile dropdown switches the active profile (persisted across restarts via
`IAppStateStore`); "Manage profiles…" opens the create / rename / delete dialog.
The startup log lines (`Magos Modificus starting`, `Config loaded …`) go to the
console and to the configured log file.

## Test
Expand Down
95 changes: 95 additions & 0 deletions magos-modificus/general/AppStateStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using System.Text.Json;

namespace Magos.Modificus.General;

/// <summary>
/// Default <see cref="IAppStateStore"/>. Loads + saves a single JSON file at
/// <c>&lt;app-data&gt;/Magos Modificus/app-state.json</c> (<c>{ "ActiveProfileId": "&lt;guid&gt;" | null }</c>).
/// The app-data dir is derived the same way <see cref="ConfigLoader"/> derives
/// its config path. JSON is handled with <see cref="JsonSerializer"/> (direct,
/// read+write) rather than <c>Microsoft.Extensions.Configuration</c>. The
/// latter is binding-oriented and read-only; a tiny writable state file is the
/// wrong fit for it.
/// </summary>
public sealed class AppStateStore : IAppStateStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
};

private readonly string _path;

/// <summary>
/// Creates a store for <paramref name="path"/>; <c>null</c> resolves to
/// <see cref="DefaultStatePath"/>.
/// </summary>
public AppStateStore(string? path = null)
{
_path = path ?? DefaultStatePath();
}

/// <summary>The state file this store reads + writes.</summary>
public string Path => _path;

/// <inheritdoc />
public Guid? ActiveProfileId
{
get => Load();
set => Save(value);
}

/// <summary>The conventional state-file location: <c>&lt;app-data&gt;/Magos Modificus/app-state.json</c>.</summary>
public static string DefaultStatePath() =>
System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Magos Modificus",
"app-state.json");

private Guid? Load()
{
// First-run safe: missing or corrupt file → null, never throws.
try
{
if (!File.Exists(_path))
{
return null;
}

var json = File.ReadAllText(_path);
var model = JsonSerializer.Deserialize<StateModel>(json, JsonOptions);
return model?.ActiveProfileId;
}
catch
{
// Missing/corrupt/permission-denied: treat as "no state recorded."
return null;
}
}

private void Save(Guid? value)
{
// Best-effort: runtime app-state is non-critical, so a persistence
// failure must not crash the app mid-interaction.
try
{
var dir = System.IO.Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}

var json = JsonSerializer.Serialize(new StateModel { ActiveProfileId = value }, JsonOptions);
File.WriteAllText(_path, json);
}
catch
{
// Swallow: the app keeps working without persisted state.
}
}

private sealed class StateModel
{
public Guid? ActiveProfileId { get; set; }
}
}
26 changes: 26 additions & 0 deletions magos-modificus/general/IAppStateStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace Magos.Modificus.General;

/// <summary>
/// Persists non-critical **runtime application state**: values that capture
/// "where the app left off" (e.g. the last-selected profile) rather than user
/// system settings. Backed by a small JSON file kept under the app-data dir,
/// separate from <see cref="Config.MagosConfig"/> (which holds system settings
/// only). Kept deliberately narrow on purpose: when the only state is
/// <see cref="ActiveProfileId"/>, a tiny dedicated store is the honest model
/// and keeps the settings schema pure.
/// </summary>
/// <remarks>
/// <para><b>First-run safe:</b> a missing or corrupt state file never throws;
/// reads just return <c>null</c>. Writes are best-effort; runtime app-state is
/// non-critical, so a persistence failure (unwritable dir, full disk) is
/// swallowed rather than crashing the app mid-interaction.</para>
/// </remarks>
public interface IAppStateStore
{
/// <summary>
/// The last-chosen active profile id, or <c>null</c> when none is recorded.
/// Reading returns the persisted value (or <c>null</c> on first run /
/// corrupt file); assigning persists the value immediately.
/// </summary>
Guid? ActiveProfileId { get; set; }
}
10 changes: 8 additions & 2 deletions magos-modificus/general/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Magos.Modificus.Config;

Expand All @@ -13,8 +14,10 @@ public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers General services: the <paramref name="config"/> singleton,
/// the <paramref name="loggerFactory"/>, <c>AddLogging()</c>, and the
/// <see cref="IConfigLoader"/>.
/// the <paramref name="loggerFactory"/>, <c>AddLogging()</c>,
/// <see cref="IConfigLoader"/>, and <see cref="IAppStateStore"/> (runtime
/// app-state: the active-profile id, persisted separately from
/// <see cref="MagosConfig"/>).
/// </summary>
public static IServiceCollection AddGeneral(
this IServiceCollection services,
Expand All @@ -25,6 +28,9 @@ public static IServiceCollection AddGeneral(
services.AddSingleton(loggerFactory);
services.AddLogging();
services.AddSingleton<IConfigLoader, ConfigLoader>();
// TryAdd so a test/host may pre-register an override (e.g. an in-memory
// or temp-path state store) before AddGeneral runs.
services.TryAddSingleton<IAppStateStore, AppStateStore>();
return services;
}
}
Loading
Loading