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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,14 +120,15 @@ Frontend uses a single gRPC server-stream for all real-time state:
- **DaclOverwriter** - Changes DACL for elevated process access

### Data Layer
- **FileRepository<TItem, TList>** - Generic protobuf JSON file-backed repo using `JsonFormatter`/`JsonParser`. Stores data in `data/ng/` as single JSON documents (list-wrapper messages from `storage.proto`). Durable atomic saves via `Utilities/AtomicFile` (write `.tmp`, flush to disk, rename); unparseable files are quarantined to `.corrupt` and the repo starts empty. Reads and writes both take the SemaphoreSlim; use `MutateAllAsync()` for read-modify-write (GetAll → modify → ReplaceAll loses concurrent writes). Supports `ReloadAsync()` for base path changes.
- **FileRepository<TItem, TList>** - Generic protobuf JSON file-backed repo using `JsonFormatter`/`JsonParser`. Stores data in `data/ng/` as single JSON documents (list-wrapper messages from `storage.proto`). Durable atomic saves via `Utilities/AtomicFile` (write `.tmp`, flush to disk, rename); unparseable files are quarantined to `.corrupt` and the repo starts empty. Reads and writes both take the SemaphoreSlim; use `MutateAllAsync()` for read-modify-write (GetAll → modify → ReplaceAll loses concurrent writes). Supports `ReloadAsync()` for base path changes. Every save is gated on `DataWriteGate`, so a predecessor mid-handoff writes nothing.
- **ProfileRepository** - Extends `FileRepository<Profile, ProfileList>`, writes each framework's d2bs.ini via IniWriter inside `SaveAsync` (under the repo lock, ordering ini writes with profile saves); `RewriteInisAsync()` for framework-side callers
- **KeyListRepository** - Extends `FileRepository<KeyList, KeyListCollection>`, round-robin key selection, in-use/held state tracking (transient, not persisted)
- **FrameworkRepository** - Extends `FileRepository<Framework, FrameworkCollection>`. A framework bundles `game_directory`, `d2bs_path`, `dll_paths`, `game_version`; profiles reference one by name (`Profile.framework`) and supply the launched executable via `Profile.d2_path`. `FrameworkPaths` resolves the DLL/ini/mules paths from a framework.
- **FrameworkBootstrap** - Idempotent migration: ensures a `Default` framework exists and assigns it to any profile with no framework. Seeds the Default from the pre-frameworks config — `game_directory` from the old install-path setting (else the directory most profiles' `d2_path` live in, else the registry) with `d2bs_path` = `<base>/d2bs`, and game version + retention + health thresholds from `SettingsRepository.LegacySettings` (recovered by `SettingsMigrator`, since those keys were dropped from the `Settings` schema). Only a genuine first-run migration (no frameworks yet) adopts framework-less profiles; once frameworks exist, an empty `Profile.framework` (from a framework delete) is left for the user to reassign. Runs at startup and on base-path change.
- **FrameworkBootstrap** - Idempotent migration: ensures a `Default` framework exists and assigns it to any profile with no framework. Seeds the Default from the pre-frameworks config — `game_directory` from the old install-path setting (else the directory most profiles' `d2_path` live in, else the registry) with `d2bs_path` = `<base>/d2bs`, and game version + retention + health thresholds from `SettingsRepository.LegacySettings` (recovered by `SettingsMigrator`, since those keys were dropped from the `Settings` schema). Adopts framework-less profiles whenever there is nothing to choose — no frameworks yet (first-run migration) or exactly one — since the assignment it would make is the only one the user could make by hand. With two or more it declines and logs a warning naming the profiles: an empty `Profile.framework` there is the deliberate post-delete state and guessing could launch against the wrong game directory. The one-framework case is not a nicety: basic mode renders no framework control at all (nav hidden, route redirected, dropdown gated on `advanced_mode`), so an orphaned profile refused to start with no UI to repair it — `ProfileForm` now also forces the dropdown visible when the saved profile has no framework, in either mode. Runs at startup and on base-path change.
- **ItemRepository** - In-memory dictionary; aggregates and watches every framework's `kolbot/mules/`. `RefreshAsync()` rebuilds watchers when frameworks change.
- **SettingsRepository** - Singleton, protobuf JSON in `d2botng.json` next to the exe. On load, when the file's `schema_version` is behind, recovers pre-frameworks values into `LegacySettings` via `SettingsMigrator` but deliberately does NOT rewrite the file — leaving it at the old version keeps those values recoverable if startup fails before the framework migration completes; the file upgrades on the next save. A corrupt file is quarantined to `.corrupt` and the app boots with defaults. Stamps `schema_version` on every save
- **SettingsMigrator** (`Data/SettingsMigrator.cs`) - Versioned migration for `d2botng.json` (`schema_version`, absent = 0), applied on load up to `CurrentVersion`. Each breaking change archives the old settings shape as a **backend-only proto** in `src/D2BotNG/Legacy/Protos/` (kept out of `protos/`, so it's excluded from the frontend's buf generation) and parses the old file into it — typed and field-tolerant, not raw-JSON poking. v0→v1 recovers the removed `game`/`engine` values, exposed as `SettingsRepository.LegacySettings` for the framework migration. Only the settings file is versioned — the `repeated`-wrapper list files have no place for a version, so their one-off migrations stay in bootstraps
- **DataWriteGate** (`Data/DataWriteGate.cs`) - Process-wide switch that stops this instance persisting anything (every `FileRepository` save, the d2bs.ini writes, and `SettingsRepository`). Closed by `HandoffManager` *before* it spawns the successor, because the successor signals Adopted at the top of `Main` and only then runs `Migration`/`FrameworkBootstrap` — the predecessor is alive and message-driven throughout. A save rewrites its whole file from an in-memory list the OLD schema parsed, so one run counter arriving in that window silently drops every field the successor just added (this is how an update to the frameworks release could leave every profile with no `framework`, which then never self-healed because the successor's frameworks.json survived). Reopened only when no successor can still be migrating: it exited without signalling, or was never started. A successor that is alive but silent leaves the gate closed — read-only beats two writers on one directory.
- **Paths** (`Data/Paths.cs`) - Reactive path resolver, subscribes to `SettingsChanged` event. Exposes BasePath, DataDirectory, LegacyDataDirectory (d2bs/mules paths are per-framework now, via `FrameworkPaths`)
- **ScheduleRepository**, **PatchRepository** - Standard FileRepository implementations
- **Migration** (`Legacy/Models/Migration.cs`) - Static one-time migration from legacy JSONL files (`data/`) to modern protobuf JSON (`data/ng/`). Runs on startup and on base path change. Skips IRC profiles. Separate `MigrateLegacyApi` migrates `server.json` → `LegacyApiSettings`.
Expand Down
19 changes: 13 additions & 6 deletions src/D2BotNG.UI/src/features/profiles/ProfileForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ export function ProfileForm({
const profilesData = useProfiles();
const advancedMode = useSettings()?.advancedMode ?? false;

// Basic mode hides frameworks entirely, but a saved profile can still be left without
// one — deleting a framework clears every reference to it — and such a profile refuses to
// start. Hiding the only control that repairs it turns that into a dead end, so show
// the picker whenever the profile on disk has no framework, in either mode.
const frameworkOrphaned = !!profile && !profile.framework;
const showFramework = advancedMode || frameworkOrphaned;

// Build set of existing profile names for uniqueness validation
const existingNames = useMemo(() => {
return new Set(profilesData.map((p) => p.profile.name.toLowerCase()));
Expand Down Expand Up @@ -295,11 +302,11 @@ export function ProfileForm({
touched.d2Path && d2Path.trim() === ""
? "Diablo II path is required"
: undefined,
// Framework is only user-selectable in advanced mode; in basic mode it is
// auto-set to "Default" once the frameworks snapshot arrives, so the error is
// only ever visible in advanced mode (where the dropdown exists).
// Framework is only user-selectable where the dropdown is rendered; elsewhere it
// is auto-set to "Default" once the frameworks snapshot arrives, so the error can
// only ever be seen next to the control it refers to.
framework:
advancedMode && touched.framework && framework.trim() === ""
showFramework && touched.framework && framework.trim() === ""
? "Framework is required"
: undefined,
entryScript:
Expand All @@ -311,7 +318,7 @@ export function ProfileForm({
// Surfaced near the submit button in basic mode, where the framework dropdown
// isn't rendered: without this, a blocked save would be a silent no-op.
const basicModeFrameworkError =
!advancedMode && framework.trim() === "" && frameworksData.length === 0
!showFramework && framework.trim() === "" && frameworksData.length === 0
? "No frameworks are available. Restart D2BotNG to recreate the Default framework, or enable Advanced Mode in Settings to create one."
: undefined;

Expand Down Expand Up @@ -588,7 +595,7 @@ export function ProfileForm({
onChange={(e) => setProxy(e.target.value)}
options={proxyOptions}
/>
{advancedMode && (
{showFramework && (
<Select
id="framework"
label="Framework"
Expand Down
2 changes: 1 addition & 1 deletion src/D2BotNG/Data/CharacterRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace D2BotNG.Data;
/// </summary>
public class CharacterRepository : FileRepository<Character, CharacterList>
{
public CharacterRepository(Paths paths) : base(paths, "characters.json") { }
public CharacterRepository(Paths paths, DataWriteGate writeGate) : base(paths, writeGate, "characters.json") { }

protected override string GetKey(Character c) => c.Profile;

Expand Down
34 changes: 34 additions & 0 deletions src/D2BotNG/Data/DataWriteGate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace D2BotNG.Data;

/// <summary>
/// A process-wide switch that stops this instance persisting anything to the data
/// directory. Held open for the whole normal lifetime; closed only by a handoff.
///
/// During a handoff two D2BotNG processes are alive at once and they share one data
/// directory. The successor signals Adopted at the top of <c>Main</c> — before it runs
/// <see cref="Legacy.Models.Migration"/> and <see cref="FrameworkBootstrap"/> — so the
/// predecessor is still fully live, still receiving D2BS messages, while the successor
/// migrates. Every repository save rewrites its whole file from the in-memory list, and
/// the predecessor's list was parsed by the OLD schema: one run counter arriving in that
/// window rewrites the successor's freshly migrated file and silently drops every field
/// the old build didn't know about. That is how a v0.0.40 update could leave every
/// profile with no <c>framework</c>, which then never self-heals because frameworks.json
/// (written by the successor) survives.
///
/// So the predecessor closes the gate before it spawns the successor: from that moment
/// the data directory belongs to the successor and this process only reads. The gate
/// reopens if the handoff aborts, since then no successor ever took over.
/// </summary>
public sealed class DataWriteGate
{
private volatile bool _closed;

/// <summary>True when writes to the data directory must be skipped.</summary>
public bool IsClosed => _closed;

/// <summary>Hands the data directory to a successor process. Writes become no-ops.</summary>
public void Close() => _closed = true;

/// <summary>Takes the data directory back after a handoff that never completed.</summary>
public void Reopen() => _closed = false;
}
17 changes: 16 additions & 1 deletion src/D2BotNG/Data/FileRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,24 @@ public abstract class FileRepository<TItem, TList> : IDisposable
private volatile bool _loaded;

private readonly Paths _paths;
private readonly DataWriteGate _writeGate;
private ILogger? _logger;
private ILogger Logger => _logger ??= TrackingLoggerFactory.ForContext(GetType());

protected FileRepository(Paths paths, string fileName)
protected FileRepository(Paths paths, DataWriteGate writeGate, string fileName)
{
_paths = paths;
_writeGate = writeGate;
FilePath = fileName;
}

/// <summary>
/// Whether this process still owns the data directory. False once a handoff has
/// given it to a successor — see <see cref="DataWriteGate"/>. Overrides of
/// <see cref="SaveAsync"/> that write files of their own must check this too.
/// </summary>
protected bool CanWrite => !_writeGate.IsClosed;

private string FilePath => Path.Combine(_paths.DataDirectory, field);

/// <summary>
Expand Down Expand Up @@ -118,6 +127,12 @@ private void QuarantineCorruptFile(Exception ex)

protected virtual async Task SaveAsync()
{
if (!CanWrite)
{
Logger.Debug("Handoff in progress; not writing {FilePath} — the successor owns it now", FilePath);
return;
}

var directory = Path.GetDirectoryName(FilePath);
if (directory != null)
Directory.CreateDirectory(directory);
Expand Down
33 changes: 26 additions & 7 deletions src/D2BotNG/Data/FrameworkBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ namespace D2BotNG.Data;
/// A "Default" framework is synthesized from those settings and assigned to every
/// profile that has no framework yet. Existing per-profile <c>d2_path</c> values (the
/// launched executable) are preserved unchanged.
///
/// It also self-heals afterwards: while only one framework exists, a profile left
/// without one (a framework delete clears every reference to it) is rebound to it on
/// the next run. Basic mode exposes no framework control at all, so such a profile was
/// otherwise unstartable with no way in the UI to fix it.
/// </summary>
public class FrameworkBootstrap
{
Expand Down Expand Up @@ -52,23 +57,29 @@ public async Task<bool> EnsureDefaultAsync()
return false;
}

// Only a genuine first-run migration (no frameworks yet) adopts framework-less
// profiles. Once frameworks exist, an empty Framework means the user deleted one
// and must reassign explicitly — we must not silently rebind those profiles.
var frameworksExisted = frameworks.Count > 0;
var changed = false;

// A framework-less profile is only adopted when there is nothing to choose:
// either this is a genuine first-run migration (no frameworks yet), or exactly
// one framework exists — in which case the assignment we would make is the only
// one the user could have made by hand, so making it cannot be wrong.
//
// With two or more, an empty Framework is the deliberate post-delete state and
// the profile is awaiting reassignment; guessing could launch it against the
// wrong game directory. We decline, but say so — the profile is unstartable
// until someone acts, and silence made that look like a bug rather than a choice.
var canAdopt = frameworks.Count <= 1;

// The framework new/orphaned profiles will be attached to.
var targetName = frameworks.FirstOrDefault(f => f.Name == DefaultFrameworkName)?.Name
?? frameworks.FirstOrDefault()?.Name
?? DefaultFrameworkName;

// Adopt profiles BEFORE creating the framework: a crash between the two
// writes then leaves a state the next run completes (frameworks.Count is
// still 0 on retry). The reverse order would strand the profiles — an
// existing framework plus empty references is the post-delete state that
// must NOT be rebound.
if (missing.Count > 0 && !frameworksExisted)
// still 0 on retry).
if (missing.Count > 0 && canAdopt)
{
await _profileRepository.MutateAllAsync(list =>
{
Expand All @@ -87,6 +98,14 @@ await _profileRepository.MutateAllAsync(list =>
"Assigned framework '{Name}' to {Count} profile(s) with no framework",
targetName, missing.Count);
}
else if (missing.Count > 0)
{
_logger.LogWarning(
"{Count} profile(s) have no framework and {FrameworkCount} frameworks exist, so "
+ "none was assigned automatically: {Profiles}. Each must be assigned a framework "
+ "before it can start",
missing.Count, frameworks.Count, string.Join(", ", missing.Select(p => p.Name)));
}

if (!frameworksExisted)
{
Expand Down
2 changes: 1 addition & 1 deletion src/D2BotNG/Data/FrameworkRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace D2BotNG.Data;
/// </summary>
public class FrameworkRepository : FileRepository<Framework, FrameworkCollection>
{
public FrameworkRepository(Paths paths) : base(paths, "frameworks.json") { }
public FrameworkRepository(Paths paths, DataWriteGate writeGate) : base(paths, writeGate, "frameworks.json") { }

protected override string GetKey(Framework f) => f.Name;

Expand Down
2 changes: 1 addition & 1 deletion src/D2BotNG/Data/KeyListRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public class KeyListRepository : FileRepository<KeyList, KeyListCollection>, IHa
{
private readonly Dictionary<string, int> _currentIndex = new();

public KeyListRepository(Paths paths) : base(paths, "keylists.json") { }
public KeyListRepository(Paths paths, DataWriteGate writeGate) : base(paths, writeGate, "keylists.json") { }

protected override string GetKey(KeyList k) => k.Name;

Expand Down
2 changes: 1 addition & 1 deletion src/D2BotNG/Data/PatchRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class PatchRepository : FileRepository<Patch, PatchList>
"STORM.dll", "D2CMP.dll", "D2MULTI.dll", "D2MCPCLIENT.dll", "Game.exe"
];

public PatchRepository(Paths paths) : base(paths, "patches.json") { }
public PatchRepository(Paths paths, DataWriteGate writeGate) : base(paths, writeGate, "patches.json") { }

protected override string GetKey(Patch patch) => $"{patch.Name}{patch.Version}";

Expand Down
13 changes: 11 additions & 2 deletions src/D2BotNG/Data/ProfileRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ public class ProfileRepository : FileRepository<Profile, ProfileList>
private readonly IniWriter _iniWriter;
private readonly FrameworkRepository _frameworkRepository;

public ProfileRepository(Paths paths, IniWriter iniWriter, FrameworkRepository frameworkRepository)
: base(paths, "profiles.json")
public ProfileRepository(
Paths paths,
DataWriteGate writeGate,
IniWriter iniWriter,
FrameworkRepository frameworkRepository)
: base(paths, writeGate, "profiles.json")
{
_iniWriter = iniWriter;
_frameworkRepository = frameworkRepository;
Expand All @@ -29,6 +33,9 @@ protected override ProfileList CreateList(IEnumerable<Profile> items)
protected override async Task SaveAsync()
{
await base.SaveAsync();
// base.SaveAsync() no-ops during a handoff; the ini files are ours to write too.
if (!CanWrite) return;

// Rewrite each framework's d2bs.ini so it reflects only its assigned profiles.
// Runs under the repository lock (so use Items, not GetAllAsync, which would
// deadlock), which also orders ini writes with profile saves.
Expand All @@ -42,6 +49,8 @@ protected override async Task SaveAsync()
/// </summary>
public async Task RewriteInisAsync()
{
if (!CanWrite) return;

await EnsureLoadedAsync();

await Lock.WaitAsync();
Expand Down
2 changes: 1 addition & 1 deletion src/D2BotNG/Data/ProxyRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace D2BotNG.Data;
/// </summary>
public class ProxyRepository : FileRepository<Proxy, ProxyCollection>
{
public ProxyRepository(Paths paths) : base(paths, "proxies.json") { }
public ProxyRepository(Paths paths, DataWriteGate writeGate) : base(paths, writeGate, "proxies.json") { }

protected override string GetKey(Proxy p) => p.Address;

Expand Down
2 changes: 1 addition & 1 deletion src/D2BotNG/Data/ScheduleRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ namespace D2BotNG.Data;

public class ScheduleRepository : FileRepository<Schedule, ScheduleList>
{
public ScheduleRepository(Paths paths) : base(paths, "schedules.json") { }
public ScheduleRepository(Paths paths, DataWriteGate writeGate) : base(paths, writeGate, "schedules.json") { }

protected override string GetKey(Schedule s) => s.Name;

Expand Down
Loading
Loading