acdream/docs/plans/2026-08-14-launcher-campaign.md
Erik 55fc51ed8c Merge campaign-cc2: CC2 CharacterCreate wire, review-closed
CC2 review PASS (checksum term set confirmed against the CG_Pack
accumulator; account-outside-body and GetPackSize=172 independently
proven), fix round e77ebf10 (F1 latch scope + pin test, AD-100, ACE
double-NameInUse note, creationFailed reason/name split, pointer fix,
retail-discriminator citations), narrow re-review CLOSED, residual
anchor fix 95e95bb6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 13:39:08 +02:00

52 KiB
Raw Permalink Blame History

Campaign LA — launcher / installer / updater + retail character-select

Status: ACTIVE (started 2026-08-14) Spec (approved): docs/superpowers/specs/2026-08-14-launcher-campaign-design.md Memory crib: claude-memory/project_launcher_direction.md Branch: claude/acdream-launcher-credentials-4d2f7c (merge to main at coherent checkpoints)

Campaign LA ships the alpha launcher (Avalonia, Windows + Linux): triple-duty launcher + installer + updater, ThwargLauncher-model profiles with full in-UI CRUD, plaintext credential file (user-decided), file-contract orchestration of AcDream.App and AcDream.Headless, plugins + login commands on both hosts, the headless character probe, and the retail character-select screen (no Create). All architectural decisions live in the spec — this plan sequences the work.

Process (binding)

  • Fable plans/sequences/integrates. Sonnet implements bounded slices. Opus reviews at every slice boundary, dual-lens: (a) architectural — ownership, layering, dependency-guard integrity, seams; (b) retail fidelity vs docs/research/named-retail/ wherever the slice touches retail behavior. Findings → fixes → narrow re-review.
  • Max 34 agents in parallel including children; subagents never spawn subagents; implementer prompts carry spec+plan paths, files-to-read, acceptance criteria, commit style.
  • dotnet build + dotnet test green before a slice is DONE; ≥1 commit per slice tagged Campaign LA; retail deviations add their docs/architecture/retail-divergence-register.md row in the same commit; no workarounds without explicit user approval.
  • Connected/visual gates are the ONLY stop-and-wait points; each gets an exact script under docs/research/ and non-blocked slices keep moving.

Slice map

Slice Deliverable Depends on
LA0 AcDream.Platform extraction (ApplicationPathSet) + guard amendments
LA1 Launch contract: App --session-config + stdin credential; status.jsonl writer both hosts; roster plumbing LA0
LA2 Headless probe mode + idle policy LA1
LA3 AcDream.Launcher.Core: profile store CRUD, config composition, spawn/supervise, status reader LA0 (LA1 contract shapes)
LA4 AcDream.Launcher Avalonia UI: CRUD views, per-char settings, sessions, probe action LA3
LA5 Plugin hosting: headless IPluginHost + capability flag; session-driven plugin set both hosts LA1
LA6 Login commands: parser-core extraction + execution on both hosts LA1, LA5
LA7 Character-select: Runtime selection state + wire (delete/restore/error) + no-selector flow LA1
LA8 Character-select authored retail screen (flat listbox — NO 3D preview, recon-corrected) LA7
LA9 Installer: first-run wizard (DAT locate/validate, bake w/ progress, SHA record) LA3, LA4
LA10 Updater: GitHub Releases manifest, download/verify/install/swap, self-update LA3, LA4
LA11 Closeout: connected-gate script, roadmap/CLAUDE.md/memory, program ledger all

Parallelism guide: LA3/LA4 (launcher side) proceed alongside LA5LA8 (client side) — different assemblies, no shared files. LA9/LA10 close the launcher side; LA11 closes the campaign.

Linux posture (binding — user decision 2026-08-14)

Everything the launcher does must WORK ON LINUX in this campaign, except GUI client launches: the Linux graphical client is Slice L, parked at L1, resuming later ("ok we will do it later"). Concretely:

  • Linux-shipping in LA: the Avalonia launcher UI, profile CRUD + 0600-permission file, installer (manual DAT picker — the auto-detect paths are Windows-only; acdream-bake is GL-free and runs on Linux), updater (staged swap; Linux can replace a running binary but keep the same staged-atomic flow), headless launches with plugins + login commands, and the character probe.
  • Launcher UX on Linux: the gui / guiSelect launch modes render disabled with an explicit "requires the Linux graphical client (Slice L)" note — never a silent failure.
  • Per-slice enforcement: every slice touching Launcher.Core, Headless, Runtime, Bake, or Platform runs its test projects on Linux (native Ubuntu or WSL, matching the K-slice practice) before the slice is DONE; LA4/LA9/LA10 additionally prove a real linux-x64 self-contained publish. LA11's connected-gate script gets a Linux section: launcher on Ubuntu doing CRUD, probe, headless launch with plugin + login commands, first-run install with a manual DAT path, and an update swap.
  • When Slice L later ships, the launcher's Linux GUI modes light up with NO launcher changes (the session-config contract is host-agnostic) — that expectation is part of LA's design acceptance.

LA0 — AcDream.Platform extraction

New BCL-only project src/AcDream.Platform/ holding ApplicationPathSet + IApplicationPathEnvironment (today src/AcDream.Runtime/Platform/ApplicationPathSet.cs — self-contained, no intra-Runtime dependencies; clean cut). Runtime/App/Headless reference it.

Recon facts (2026-08-14): blast radius is the definition, six source files (GraphicalHostPlatformServices.cs, GraphicalLegacyConfigurationMigrator.cs, App/Program.cs, GameWindow.cs:533, HeadlessPathSet.cs, HeadlessPlatformEnvironment.cs; two more files are doc-comment-only), two test files (ApplicationPathSetTests.cs moves to a new tests/AcDream.Platform.Tests/; GraphicalLegacyConfigurationMigratorTests.cs fixtures), and the dependency guards — CORRECTED post-review (the original recon here asserted the wrong guard, the C4-closeout failure mode): the K0 Headless guard (HeadlessAssemblyReferencesOnlyTheRuntimeProject) asserts HEADLESS's own csproj reference list, which this move does not touch — it stays UNCHANGED; the guard that actually needs amending is Runtime's own RuntimeDependencyBoundaryTests.RuntimeProjectDeclaresOnlyApprovedProjectDependencies (Runtime gains the AcDream.Platform reference), amended with a cited comment in the same commit. Namespace stays AcDream.Runtime.Platform? NO — rename to AcDream.Platform and fix the eight usings (clean naming beats avoiding a mechanical edit). Register new projects in AcDream.slnx.

Acceptance: build + full test suite green; guard test asserts the new exact reference set; launcher-side consumability proven by the LA3 project referencing only AcDream.Platform.

LA1 — launch contract (client side)

Pinned launch-contract schema (v1, BINDING — committed per LA3 review)

This text is the single source of truth for the launcher↔host file contract. Both host readers (LA1), the composer (LA3), and the probe loader (LA2) implement EXACTLY this; any change is an amendment to THIS section first, implementations second. The LA1+LA3 merge adds a cross-assembly test feeding a composer-produced document to both host loaders — that test is the seam's permanent enforcement.

Session-config document (System.Text.Json, camelCase, UnmappedMemberHandling.Disallow, camelCase string enums):

{
  "version": 1,
  "process": {
    "content": { "datDirectory": "...", "preparedAssetPath": "..." }
  },
  "sessions": [{
    "id": "sess-1",
    "endpoint": { "host": "127.0.0.1", "port": 9000 },
    "account": "testaccount",
    "mode": "probe",
    "character": { "id": 1342177290 },
    "policy": { "id": "idle" },
    "credential": { "provider": "standardInput", "reference": "session" },
    "plugins": ["ExamplePlugin"],
    "loginCommands": ["/vt start"],
    "loginCommandDelayMs": 500,
    "statusFile": ".../launcher/sessions/sess-1/status.jsonl"
  }]
}

Field rules:

  • process.paths is OMITTED unless a caller genuinely supplies overrides (never an empty object — the App reader has no paths member and strict parsing rejects unknown keys; LA3 review finding 1).
  • mode: ABSENT for normal play sessions; "probe" for the LA2 probe (connect → characterList → graceful disconnect, no EnterWorld). The headless loader accepts the field starting at LA2.
  • character: exactly ONE of index|id|name; OMITTED entirely (not null) for guiSelect and for probe sessions.
  • policy: { "id": "idle" } for headless play sessions ONLY; omitted for gui/guiSelect/probe.
  • credential: always { "provider": "standardInput", "reference": "session" } for launcher-composed configs.
  • plugins: absent/null means load all discovered plugins (preserving the developer flow); explicit [] means load none. Launcher-composed normal-empty and probe sessions emit [] so they cannot load arbitrary machine-local plugins.
  • loginCommands/loginCommandDelayMs/statusFile: optional, omitted-when-unset (never null, never [] for empty). Absent loginCommandDelayMs means 500.

Status stream (statusFile, one JSON object per line, writer flushes per line, writer opens FileShare.Read, tailer opens Read/FileShare.ReadWrite|Delete): events started, connected, characterList{accountName,slotCount,characters[{id,name,secondsGreyedOut}]}, enteredWorld{characterId,characterName}, pluginLoaded{plugin}, pluginFailed{plugin,error}, loginCommandFailed{commandIndex,command,error}, characterCreated{guid,name}, creationFailed{code,reason,name}, disconnected{reason}, exited{code,reason} — every line carries "v":1, "e", "t" (ISO-8601 UTC), "sessionId". secondsGreyedOut is a uint on BOTH sides. Unknown e values must parse to a typed Unknown event, never throw; a known e with a wrong payload shape should be distinguishable from an unknown e (LA3 review finding 12).

Campaign CC CC2 amendment (this section is the contract; the writer and tailer below implement it, in that order): characterCreated{guid,name} fires on the Ok reply to a CharacterCreate (opcode 0xF656) request — guid/name come straight off the shared 0xF643 CharGenVerificationResponse Ok identity payload (AcDream.Core.Net.Messages.CharGenVerificationResponse), deliberately named guid/name rather than characterId/characterName to mirror that payload's own field names and to read distinctly from enteredWorld — a freshly created character is logged straight in by retail without a fresh characterList (see that type's doc comment), so characterCreated can precede an enteredWorld for the same character rather than replacing it. creationFailed{code,reason,name} fires on any non-Ok reply: code is the raw wire CharGenVerificationResponse.Code value, reason is that code's enum member name (e.g. "NameInUse") so a reader gets a stable readable reason without hard-coding the numeric mapping itself, and name is the ATTEMPTED character name so a launcher can render "the name Bob is taken". (CC2 review F4: the enum member originally rode the name key, colliding in meaning with characterCreated.name; renamed before any consumer shipped.)

loginCommandFailed.commandIndex is the zero-based index in the configured loginCommands array. command is the exact configured line and error is the isolated parser/router/handler failure. The event is observational: the host continues with the next configured line and never converts the command failure into a login, plugin, session, or process failure.

Known LA1 status limitation: the stream has no independent mid-play wire-drop detector. If a transport becomes silent without raising through the host's tick/teardown path, no immediate disconnected line can be promised; the launcher must not treat the absence of that line as proof that the socket is healthy. Explicit reconnect is ordered and observable — it emits disconnected{reason:"reconnect"} before the replacement connection's second connected — and normal stop/process teardown closes any still-open connection before exited. A future transport-health signal may improve the timing without changing this pinned event vocabulary.

Three pieces, one slice, because they share the session-config/status seam:

  1. App --session-config <path>: parsed once in Program.cs into RuntimeOptions (code-structure rule 4); carries endpoint, account, optional character selector, Plugins, LoginCommands, Content (DatDirectory/PreparedAssetPath), status-file path, credential reference. Recon: Program.cs has NO subcommand dispatch today — args handling is one positional DAT-dir (Program.cs:35), so the flag is purely additive (preserve the positional arg). The live-credential seam is a single call site (SessionPlayerComposition.cs:1128-1135LiveSessionConnectOptions); the config path populates the same RuntimeOptions fields from a different source. Env-var dev flow untouched. App gains the StandardInput credential read (mirroring HeadlessCredentialResolver.ResolveStandardInput — one line, immediately wrapped in an erasable secret, redacted ToString; today RuntimeOptions.LivePass is a bare string — the config path must not widen that exposure).
  2. Status stream both hosts: per-session status.jsonl (path given in config; absent → permanent no-op sink). Versioned event vocabulary ("v":1): started, connected, characterList, enteredWorld, pluginLoaded/pluginFailed, loginCommandFailed, characterCreated/creationFailed (Campaign CC CC2), disconnected, exited. Recon: today's HeadlessDiagnosticWriter is a single shared-stdout JSONL sink with four kinds (lifecycle/failure/event/resources) and NO per-session file — the status writer is a second, separate sink, not a rework of the diagnostics writer. App has no structured writer today; it gets the same shared implementation (lands in Runtime so both hosts borrow it).
  3. Roster plumbing: CharacterList.Parsed is consumed inside LiveSessionController.StartCore (LiveSessionController.cs:612) and never escapes — add a typed roster report on the lifecycle-host seam (ILiveSessionLifecycleHost) so hosts can emit the characterList status event and (later) the char-select screen can populate. No behavior change to selection itself in this slice.

Acceptance: round-trip tests (config → RuntimeOptions; stdin credential; status events in order with exact shapes; roster surfaced); App/Headless/ Runtime suites green; redaction test proves the password never appears in status/diagnostics output.

LA2 — headless probe mode + idle policy

Recon facts: the probe's shape already exists as the NoCharacters early-exit (LiveSessionController.cs:613-622StopCore() → 4-stage SessionScope.DrainTeardown, graceful, _inWorld == false so no pre-logoff flush) — but it fires only on selection FAILURE and maps to exit code 5 (HeadlessProcessHost.RunOnUpdateThread:203-212 treats any non-Connected start as ConnectionError).

  1. Probe: a Probe flag on the connect options short-circuits StartCore right after GetCharacters (before TrySelectCharacter): report roster, StopCore(), return a NEW LiveSessionStartStatus.ProbeComplete. HeadlessProcessHost maps it to exit code 0 with a final characterList + exited(reason: "probe") status pair. Config: mode: "probe" on the session descriptor relaxes the JsonRequired character selector + policy for probe sessions ONLY (loader keeps strict validation otherwise — recon: violations currently surface as raw JsonException → exit 3; probe relaxation must be shape-level in the loader, not attribute removal).
  2. idle policy: new consumer HeadlessBotPolicy id — enter world, run plugins/login-commands (arrive in LA5/LA6), stay until stopped, clean SIGINT teardown (K4's graceful-logout path already proves the mechanism).

Acceptance: probe test (fixture session → roster event → graceful teardown receipt → exit 0, no EnterWorld on the wire); loader tests for probe-shape relaxation + strict normal validation; idle-policy lifecycle test; suites green. Connected verification (user gate, LA11 batch): live probe against ACE twice in a row with no lingering session (spec §11.9).

LA3 — AcDream.Launcher.Core

New BCL-only project + tests/AcDream.Launcher.Core.Tests/. References AcDream.Platform ONLY.

  • Profile store: launcher-profiles.json (spec §5 schema) — load/save/ validate, full CRUD operations, roster merge (fold characterList events in, preserving per-character user settings), 0600 on Linux.
  • Session-config composition: profile + install records → the LA1 config shape (typed writer; probe shape included). Passwords excluded — stdin only.
  • Process orchestration: spawn App/Headless per launch mode, feed password to child stdin then close, supervise lifetime, tail status.jsonl (share-tolerant reads), surface typed session state.
  • SHA-256 utility (pak record + download verify — consumed by LA9/LA10).

Acceptance: CRUD/round-trip/merge tests; composition tests (all three modes + probe); supervision tests against a fake child process (echo script); status-tail tests including partial-line handling; suites green.

LA4 — AcDream.Launcher (Avalonia)

New Avalonia project (Windows + Linux). MVVM over Launcher.Core; no game solution references beyond AcDream.Platform transitively.

  • Views: server list → accounts → characters tree; add/edit/remove dialogs for servers (name/host/port) and accounts (account + password entry); per-character settings editor (launch mode, plugin set, login commands); per-account "refresh characters" (probe); running-sessions status column.
  • Launch actions per mode (gui / guiSelect / headless); probe disabled while the launcher runs a session for that account.
  • First-run wizard shell + update prompt shell (bodies land in LA9/LA10).

Acceptance: ViewModel tests in Launcher.Core.Tests patterns (VMs live in the Avalonia project but stay logic-thin; anything testable pushes down); build green on Windows; linux-x64 publish compiles. Visual polish is gated at LA11 (user).

LA5 — plugin hosting on both hosts

Recon facts (2026-08-14): PluginLoader/PluginDiscovery/PluginManifest already live in AcDream.Core (Headless-reachable). App's single load loop (App/Program.cs:110-121) loads ALL discovered plugins from two roots (AppContext.BaseDirectory/plugins + ApplicationPathSet.PluginsDirectory, dup-id skip) — no allow-list exists on either host. AppPluginHost is a 26-line pass-through; three of four IPluginHost surfaces (StateWorldGameState, EventsWorldEvents, SelectionSelectionState) are backed by Core-owned types already; only Ui (BufferedUiRegistry) is genuinely App-only. Headless has zero plugin hosting today (confirmed).

  1. Session-config Plugins allow-list filters the discovery result on BOTH hosts (absent/null list = load all, preserving today's dev behavior; explicit [] = load none). Launcher-composed normal-empty and probe sessions emit [].
  2. HeadlessPluginHost : IPluginHost in Headless over the same Core-owned State/Events/Selection; Ui is an explicit no-op behind a new capability flag on IPluginHost (e.g. HasUi) so plugins can detect headless. Contract documented in Plugin.Abstractions.
  3. pluginLoaded/pluginFailed status events from both hosts' load loops.

Acceptance: fixture plugin in Headless suite (load, capability flag, markup no-op, teardown via collectible ALC); allow-list filter tests both hosts; status events asserted; suites green.

LA6 — login commands on both hosts

Recon facts (2026-08-14): the command core is dependency-CLEAN — ChatInputParser (zero usings), ChatCommandRouter (BCL + AcDream.Core.Chat), RetailClientCommandCatalog (FrozenDictionary), ChatVM (Core.Chat/Combat + System.Numerics only), ICommandBus + the four command records (BCL-only). The block is assembly identity, not coupling. ChatCommandRouter.Submit's two entanglements: a hard ChatVM parameter (uses only ShowInterfaceText/ShowSystemMessage/ LastIncomingTellSender/LastOutgoingTellTarget) and the ICommandBus, whose production implementation (LiveSessionCommandRouter, App/Net/LiveSessionCommandRouter.cs) is App-only and wraps wire-send delegates from the live session. GUI already has a login-command analog: RetailUiAutomationScriptRunner feeds ChatCommandRouter.Submit at RetailUiRuntime.cs:523-527.

  1. Extraction: move parser/router/catalog + ICommandBus + the four command records (+ sibling tables they require) into Runtime (AcDream.Runtime/Chat/...); the router's ChatVM parameter becomes a narrow feedback interface defined beside it (exactly the four members used); ChatVM (stays in UI.Abstractions) implements it. GUI path stays bit-identical — same call sites (ChatWindowController.cs:326, FloatingChatWindowController.cs:157), same routing, CH-accepted behavior regression-checked by the existing chat suites.
  2. Headless dispatch: a Runtime/Headless ICommandBus binding the same session send delegates (SendTalk/SendTell/SendChannel/ SendTurbineChat) + Runtime state that App's router binds — paralleling LiveSessionCommandRouter's registrations, feedback lands in RuntimeCommunicationState.AddText.
  3. Execution: both hosts run LoginCommands sequentially as-if-typed (default 500 ms inter-command delay, config-overridable) once entered-world; per-command failures → status stream, never abort.
  4. K0 guard: if the code folds into Runtime, the single-reference assertion stands untouched; the forbidden-prefix closure tests keep passing. Any guard text change is deliberate and documented.

Acceptance: extraction lands with zero GUI chat test regressions (UI.Abstractions + App chat suites bit-green); headless executes a login-command script against a fixture session with ordered wire sends; delay + failure-tolerance tests; suites green.

LA7 — character-select: state + wire

Recon facts (2026-08-14): retail's screen is gmCharacterManagementUI (acclient.h:56545) — flat listbox + Create/Enter/Delete/Restore buttons + dialog contexts. No 3D preview exists on retail's select screen (the gmCG3DView/CreatureMode viewport is chargen-only; the old "rotating pedestal" line in retail-ui/05-panels.md §13 is uncited and wrong). Our CharacterList parse already matches ACE's serializer exactly (two-array shape, status/deleted always zero from ACE) and the two-phase enter-world (0xF7C8 → 0xF7DF → 0xF657) is implemented. Missing wire: delete/restore/error.

  1. Wire messages (AcDream.Core.Net/Messages/, retail citations in file docs per house style): CharacterDelete 0xF655 — outbound account String16L + slot index (Proto_UI::SendDeleteCharacter @0x00546b30; NOT guid), inbound opcode-only ack followed by a fresh CharacterList; CharacterRestore 0xF7D9 guid-only (ACE + holtburger consensus; the decomp's apparent extra strings are a decompiler artifact — spec §11.4), response 0xF643 (flag + guid + name + secondsDisabled); CharacterError 0xF659 parser (new — today NO character-stage server error can be surfaced).
  2. Runtime selection state (J-owner pattern): roster with per-entry greyed/pending-delete state (SecondsGreyedOut != 0 ⇒ pending; ACE sends a constant 1 during the grace window — treat as boolean, never a countdown), highlight, pending-delete dialog state, typed commands (highlight / enter / delete-request / delete-confirm / restore). Retail behavior oracles: RebuildCharacterList@0x004ec3a0, SelectCharacter@0x004ec160, UpdateButtons@0x004ec240 (Delete↔Restore swap on greyed state), EnterGame@0x004ed440.
  3. No-selector flow: a graphical session config without a character selector stops at selection state instead of auto-enter; the first-available fallback (CharacterList.TrySelectFirstAvailable, used at LiveSessionController.cs:848-851) remains ONLY for selector-carrying/headless sessions. Selection feeds the existing EnterWorld path unchanged.

LA7b hazards carried from the LA7a review (2026-08-14): ACE's restore handler has a SILENT no-reply path (unknown guid → return;, no 0xF643, no 0xF659) — selection state must never block awaiting a restore reply; outbound routing is delete via retail's SendToLogon, restore via SendToControl, ACE replies on UIQueue; charError.NumErrors (0x19) is an enum-range sentinel and must never render as a user-facing message. Register row AD-97 (guid-only restore request, an adaptation) rides the LA7a branch.

Acceptance: message round-trip tests against ACE's serializer shapes; selection-state tests (greyed transitions, delete→list-refresh, restore, error surfacing); no-selector stop + enter flow tests; suites green.

LA8 — character-select: authored retail screen

Scope: project LA7's state through the REAL retail screen. No 3D preview (recon-corrected; a preview would be an unapproved divergence).

  1. Layout resolution: retail resolves the root via UIMainFramework::CreateAndAddRootElement(0x10000005, 0x1000039a) + DBObj::GetDIDByEnum(..., 5) — reuse OP8's ported GetDIDByEnum machinery (category 4 precedent) for enum-table 5; slice starts by dumping that table from installed DATs to pin the concrete DataID. Child ids: listbox 0x1000039d, create 0x100003a0 (present, disabled — Create is a future campaign), enter 0x100003a2, delete 0x1000039f, restore 0x1000039e.
  2. Dialogs: delete-confirm, please-wait, entering-world, error — the retail dialog machinery from the OP8 WaitDialog work (2a81e813 mapped WaitDialog class type 0x19) is the base.
  3. Open item resolved here: whether retail draws a render-loop background scene behind the UI (pseudo-C proves only that the UI class owns no viewport) — settle via user recollection + the visual gate before polishing.

Acceptance: authored screen builds from DAT assets; button-state matrix matches UpdateButtons oracle (incl. Delete↔Restore swap); enter/delete/restore/error flows drive LA7 state end-to-end; suites green. User visual gate at LA11 (screen look, dialog flows, delete + restore against local ACE).

LA9 — installer (first-run)

  • DAT locate: auto-detect %USERPROFILE%\Documents\Asheron's Call and C:\Turbine\Asheron's Call + manual picker; validate the four DATs.
  • Bake: spawn acdream-bake --dat-dir <dats> --out <DataDirectory>/pak/acdream.pak --threads N. Recon: default --out is INSIDE the DAT dir — the launcher always passes --out explicitly. Progress: add --progress-json to AcDream.Bake (JSONL progress lines alongside the existing 5-second human text, which stays default) — scraping human text is fragile and we own the tool. Recon: the bake has NO whole-file SHA — after a successful bake the LAUNCHER computes and records SHA-256 + size + BakeToolVersion in its install record, and re-verifies on subsequent startups (fast corruption check trades a few seconds of hashing for never launching against a half-written pak).
  • Install record feeds LA3's session-config composition (DatDirectory/PreparedAssetPath).

Acceptance: wizard flow tests over Launcher.Core (fake bake child emitting --progress-json lines); bake-tool progress flag tests in tests/AcDream.Bake.Tests; SHA record/verify tests; suites green. Connected gate (user): clean-profile first-run against real DATs.

LA10 — updater

  • Manifest: GitHub Releases; manifest.json release asset — version, per-RID client zip URL + SHA-256 + size, minimum-launcher version. Launcher pins owner/repo in its config.
  • Client update: poll on launch (+ manual check), download to staging, SHA verify, unpack to DataDirectory/app/<version>/, atomic current.json pointer swap, refuse while any session runs, keep previous version for one-step rollback.
  • Launcher self-update: staged download + target-local atomic replacement on next start.
  • Session-config composition targets app/current's binaries.

Acceptance: manifest/download/verify/swap tests against a local HTTP fixture; rollback test; refusal-while-running test; self-update staging test; suites green. Connected gate (user): staged-manifest update swap end-to-end.

Pinned updater contracts (v1, BINDING)

This section is the single source of truth for every LA10 feed and on-disk shape. Readers use strict, case-sensitive System.Text.Json parsing, reject unknown or duplicate properties, and reject unsupported schema versions before doing network, extraction, or activation work.

The production feed is pinned to GitHub owner/repository eriknihlen/acdream; the launcher reads https://github.com/eriknihlen/acdream/releases/latest/download/manifest.json. Tests use a separate internal fixture constructor that may admit loopback HTTP; that allowance never propagates to the production feed. Production manifest and artifact URIs use HTTPS. Automatic redirects are disabled and every redirect hop is validated before it is requested; redirect loops, a chain over five hops, and any HTTPS-to-HTTP downgrade are rejected. manifest.json is:

{
  "schemaVersion": 1,
  "version": "1.2.3",
  "minimumLauncherVersion": "1.1.0",
  "clients": {
    "win-x64": {
      "url": "https://github.com/eriknihlen/acdream/releases/download/v1.2.3/acdream-client-win-x64.zip",
      "sha256": "<64 hex characters>",
      "size": 123
    }
  },
  "launchers": {
    "win-x64": {
      "url": "https://github.com/eriknihlen/acdream/releases/download/v1.2.3/acdream-launcher-win-x64.zip",
      "sha256": "<64 hex characters>",
      "size": 123
    }
  }
}

version and minimumLauncherVersion are strict SemVer 2.0 strings. Build metadata is ignored for precedence; numeric identifiers are compared without fixed-width integer overflow. RID keys are exact lowercase portable RIDs. Both dictionaries are required and the running RID must have a client and a launcher row. Artifact sizes are positive and capped by the launcher's download limit; SHA-256 is exactly 64 hex characters. ZIP URLs are absolute. Client ZIPs have the two host executables at their root (AcDream.App[.exe], acdream-headless[.exe]); launcher ZIPs have acdream-launcher[.exe] at their root. No implicit wrapper directory exists.

Every extracted client version has DataDirectory/app/<version>/install.json:

{
  "schemaVersion": 1,
  "version": "1.2.3",
  "rid": "win-x64",
  "archiveSha256": "<64 hex characters>",
  "archiveSize": 123,
  "files": [
    { "path": "AcDream.App.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 }
  ]
}

Paths use /, are relative, normalized, unique under ordinal-ignore-case, and sorted ordinally. unixMode contains only the portable permission bits captured from the ZIP entry. Startup verifies every recorded regular file by size/SHA, rejects unrecorded files/reparse points, and requires the two host executables before admitting a version. Extraction uses a random sibling directory under DataDirectory/app/; promotion to <version>/ is one same-volume directory rename.

DataDirectory/app/current.json is the only activation authority:

{ "schemaVersion": 1, "currentVersion": "1.2.3", "previousVersion": "1.1.0" }

previousVersion is omitted for the first activation. Pointer writes are write-through temporary-file + same-directory atomic rename. The last valid pointer is also atomically preserved as current.previous.json; startup may restore that exact backup only when current.json is missing/malformed and the referenced version verifies. Orphan LA10 staging directories, download archives, corrupt-version quarantine directories, and pointer temporaries are transaction-owned by exact lowercase GUID names and are removed only under the exclusive update lease; near-matching user names are preserved. A corrupt installed version is never silently selected; the explicit one-step rollback swaps the two verified pointer versions.

DataDirectory/app/.update-session.lock is the cross-process barrier. Each supervised launcher activity holds a shared OS handle from before executable resolution until terminal process observation; launcher disposal requests child termination and does not release that handle until the child is actually observed terminal. An update/rollback holds the exclusive handle for its entire recovery/download/extract/promote/pointer transaction. Failure to acquire the exclusive handle is an immediate refusal, not a wait behind a running session. The open handle, not lock-file contents, owns the lease and therefore releases after process death.

Launcher self-update staging lives at DataDirectory/launcher-update/transactions/<transactionId>/ and the sole durable authority is DataDirectory/launcher-update/pending.json (schema 3):

{
  "schemaVersion": 3,
  "transactionId": "0123456789abcdef0123456789abcdef",
  "state": "staged",
  "version": "1.2.3",
  "rid": "win-x64",
  "targetDirectory": "<absolute current launcher directory>",
  "archiveSha256": "<64 hex characters>",
  "archiveSize": 123,
  "files": [
    { "path": "acdream-launcher.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 }
  ],
  "apply": null
}

Before mutation the verified staged launcher becomes the next-start helper and waits for the initiating launcher PID without invoking a shell. It first copies the complete verified payload into the target-local .acdream-self-update-<transactionId>/incoming/ tree. The plan then advances to applying; apply is an ordinally sorted union of new payload paths, the owned metadata path, and obsolete paths from the previous ownership record:

[
  {
    "path": "acdream-launcher.exe",
    "operation": "install",
    "hadOriginal": true,
    "priorSha256": "<64 hex characters>",
    "priorSize": 123,
    "priorUnixMode": 0,
    "replacementSha256": "<64 hex characters>",
    "replacementSize": 456,
    "replacementUnixMode": 0
  },
  {
    "path": "new-support.dat",
    "operation": "install",
    "hadOriginal": false,
    "priorSha256": null,
    "priorSize": null,
    "priorUnixMode": null,
    "replacementSha256": "<64 hex characters>",
    "replacementSize": 456,
    "replacementUnixMode": 0
  }
]

Every hadOriginal entry persists the exact pre-mutation SHA-256, length, and Linux mode bits; a no-original entry has all three prior fields null. Every install entry likewise persists the verified replacement metadata, while a remove entry has all three replacement fields null. The journal is invalid unless those fields agree with hadOriginal and operation.

Existing targets are replaced with one same-filesystem atomic replace whose backup is also target-local. Previously absent noncanonical files use one same-filesystem rename; obsolete owned files use one rename into backup. The canonical launcher path therefore contains either the complete old file or the complete new file at every durable crash boundary. Rollback first performs a zero-mutation preflight of the complete target-local transaction and every journal entry. It rejects reparse points, unsafe parents, unrecorded paths, ambiguous file layouts, and any SHA-256/length/mode mismatch in a prior, incoming, or discard file. Only a fully preflighted rollback may atomically restore backups; newly created files move to target-local discard rather than being deleted. The complete prior target set is then reverified before the plan enters durable rolledBack state while retaining the journal. Retry is allowed only after that prior set is reverified again and the plan returns to staged. Thus rollback is atomic per file and idempotent after a process/power loss. Any ambiguity preserves the applying plan and transaction evidence and forbids launching the canonical path for manual recovery. Linux mode bits come from the verified incoming file. A helper that cannot immediately acquire the exclusive update lease defers the staged plan and exits without restarting the old launcher, preventing restart loops.

Successful application writes strict target ownership metadata at <launcher directory>/launcher.install.json:

{
  "schemaVersion": 1,
  "version": "1.2.3",
  "rid": "win-x64",
  "files": [
    { "path": "acdream-launcher.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 }
  ]
}

The archive may not supply that reserved metadata path. A prior valid record is the only authority for obsolete-file removal; the first managed update does not infer ownership of unrelated legacy files. On success the plan becomes awaitingConfirmation; the new launcher confirms at its first managed instruction, after which the helper releases its lease and the confirmed launcher reclaims plan, data-transaction, and target-local residue. An applying plan is rolled back before retry, and failure to start/confirm the new launcher restores every original (and removes every no-original target). The helper restarts the restored canonical launcher only after a fresh complete verification of the retained rolledBack journal; rollback corruption or an unsafe backup/discard tree exits without starting either launcher. Reading pending.json never performs cleanup. Ordinary startup attempts the exclusive lease without waiting and skips update cleanup entirely when another session/staging transaction owns it. All plan paths are re-derived/contained under pinned roots; the target directory must equal the actual launcher base directory.

Every portable archive and persisted relative path rejects Windows device segments on every host: CON, PRN, AUX, NUL, CLOCK$, CONIN$, CONOUT$, COM1-COM9, LPT1-LPT9, and the Windows-equivalent superscript forms COM¹/COM²/COM³ and LPT¹/LPT²/LPT³, including extensions.

LA11 — closeout

  • One exact operator script docs/research/2026-08-14-campaign-la-test-script.md, fronted by the connection-free tools/run-campaign-la-preflight.ps1 and followed by serial user rows, covering: all three launch modes vs local ACE, probe round-trip ×2 (no lingering session), char-select visual matrix + delete flow, login-commands
    • plugin behavior on both hosts, add-server/add-account purely in UI, clean-profile first-run wizard, staged update swap.
  • Roadmap shipped-table entry, CLAUDE.md Current-state flip, memory distill, ledger below completed, program closeout section.

Review protocol

Per slice: implementer commit(s) → Opus dual-lens review (architectural + retail-where-applicable) → fix round → narrow re-review of fixes → slice DONE in ledger. Reviews name blast radius explicitly (claude-memory/feedback_blast_radius_single_lens.md). Slices LA7/LA8 add the retail-fidelity lens against named-retail symbols cited in the slice body; LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny.

Gate round 1 — 2026-08-15 (first live launch by the user)

The user's first hands-on launch found the launcher exiting on every click. Root cause (d54b8a78): MainWindow's constructor called AvaloniaXamlLoader.Load(this) instead of the generated InitializeComponent(), so every x:Name backing field was null and any modal open/close threw out of the dispatcher into Program's exit-74 guard. It reached the gate because NO test constructed MainWindow — filed and closed as #399 (2b439cc1, merged): Avalonia.Headless.XUnit view tests with falsification evidence (12/12 fail against the old code, 12/12 pass against the fix; launcher suite 66/66 Windows + native Ubuntu; xunit→xunit.v3 in that test project). Same round (e1e94697): #398 closed — fatal exceptions now write a full-stack crash report under the data root (isolated-roots-safe; the first cut leaked to the real data root when parsing failed, caught live and fixed) — and acdream-bake.exe is now co-deployed on plain Build, not just Publish, so a developer-built launcher can actually run its first-run wizard (79.6 MB single file beside the launcher, incremental, --help verified). One transient 65/66 on the first post-merge test run did not reproduce across a clean rebuild + six repeats — consistent with stale-artifact mixing, but if it EVER recurs, capture the failing test name before anything else. Merged slice worktrees/branches (la2/la3/la7a/la-uitest) removed. Opus batch review: PASS (HIGH confidence) with 6 findings, all landed same-day: F1 the crash reporter's by-construction claim was FALSE (the launcher holds passwords in three fields; the true invariant — no throw site interpolates a credential value — is now pinned by a forced-failure test), F2 the co-deploy's Inputs covered only Bake's own sources, not its Content/Platform/Core/ Plugin.Abstractions closure (the stale-artifact class again; fixing it exposed and fixed two more incrementality traps: SkipUnchangedFiles leaving outputs older than inputs, and %(Item.Metadata) in a plain Include not batching — a literal '%(...)' input is permanently out-of-date), F3 dual bake publish on RID publishes (guarded by _IsPublishing; verified 0 build-target co-deploys during a real publish), F4 misattributed comment, F5 template-scoped x:Name false-fail (sweep now walks the XML with template-ancestor tolerance), F6 dead using, plus the optional Path.IsPathFullyQualified hardening on the crash reporter's --data-dir fallback. Launcher 67/67, Launcher.Core 317/317. The §AI connected script remains the open user gate.

Gate round 2 — 2026-08-15 (first live launcher→client flow) — char-select matrix USER-PASSED

USER-PASSED 2026-08-15 (end of round): the character-select visual/ interaction matrix — stretched-canvas look with bilinear filtering, aligned widgets, left-justified roster, World box reading the live server name ("sawato"), and the centered exit confirmation — all accepted on the live launcher→client flow. Round-2 commits after the round-1 batch: 6e1c0967 (session-config launches force the retail UI), 9ce72925 (PFID_CUSTOM_RAW_JPEG decode + resolution guards), 73041d70 (whole-canvas AD-98 scale + inverse input), 308f40a3 (linear-twin bilinear stretch), ef96c554 (exit confirmation + authored justify + world name, AD-99), 2e6d69dd (#400), 0a7dc7d6 (durable world-name read + canvas-centered dialogs). Remaining before shipment: the formal §AI script rows (probe ×2, headless+plugins+login commands, delete/ restore, A→B update swap, row I Linux), and the final-HEAD preflight re-run.

Round REVIEW-CLOSED 2026-08-15: the owed Opus dual-lens batch review of the six round-2 commits returned PASS with 8 findings; the fix round (0baebce2 — headline: RetailWaitDialogView was the ONE dialog view the EffectiveCanvasSize sweep missed, firing on ENTER; plus the two stale deleted-mechanism doc assertions, the Confirmation 0xAC property, truncating input mapping, the IsCurrent world-name gate, the AD-98 evidence note) closed all seven in the narrow re-review; F2 filed as #401 (invert RetailUi to opt-out). The review also proved the DatWidgetFactory justify widening has ZERO regressions across all 35 layout fixtures (303 buttons swept; the 16 authored-Left all already left-aligned via their face-child branch) and is a move TOWARD retail (CalcJustification @0x00467260 has no lifted-from-child condition).

Two real defects, both root-caused and fixed:

  1. 6e1c0967 — launcher-spawned clients had NO interface at all. RetailUi rode the dev env var ACDREAM_RETAIL_UI; FromSessionConfig inherited the env parse; the launcher strips ACDREAM_* from children (LA11 isolation). Product launches therefore got the dev default: world rendering, zero UI — character screen included. Session-config launches now force RetailUi = true (a session-config launch IS a product launch); the env flag remains the dev-launch opt-in. Test-pinned with a null env.
  2. 9ce72925 — character-select screen rendered magenta background/ fills. The screen's 800×600 root background (0x06007576) is PFID_CUSTOM_RAW_JPEG — a complete JFIF stream retail hands to the Intel JPEG Library (RenderSurface::CreateFromSourceData @0x004440a0), with Width/Height legitimately 0 on disk. SurfaceDecoder had no JPEG case AND a non-positive-dimension guard, so it fell silently to the magenta placeholder; the listbox/ENTER fills are transparent, so one broken background bled through as three symptoms. Fixed via StbImageSharp (managed, Linux-safe; codec-library substitution per the BCnEncoder precedent — no register row). BOTH silent traps now log once per id (id-resolves-but-undecodable in SurfaceDecoder; id-missing-from-DATs in TextureCache) — the existing magenta guard only covered id-0. New installed-DAT sweep asserts every char-select media id decodes non-magenta. Full suite 14,034 green.

Session-orchestration facts this round: the machine gained PowerShell 7 (winget, user-approved — the LA fixture tooling hard-requires it); an orphan feed server from the earlier session held port 43119 with stale fixture data (stopped); the launcher self-update bootstrap restart on a dev binary is EXPECTED (staged launcher update → exit → respawn). Observations still open for this round: the duplicated "versioned client is unavailable" status line (cosmetic), and verifying Create Character is disabled on the live screen.

Ledger

Slice Status Commits Review Notes
LA0 DONE 2026-08-14 cb6502c8, a49e92df Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched
LA1 DONE 2026-08-14 db9ad53c (mixed — see e1322a06), 75a6724d (recovery WIP), d511e4c3, ledger 890cf267 Initial review FIX FIRST; F1F8 CLOSED; narrow dual-lens re-review PASS Release build green (0 errors / 18 warnings). Windows: Runtime 1634 / Headless 127 / App 5038+3skip. WSL: Runtime 1634 / Headless 127. Known mid-play silent-wire-drop limitation recorded above. The LA1+LA3 composer-to-both-hosts contract gate and portable CI lane landed at 8a03a25f.
LA2 DONE + MERGED 2026-08-14 c6019424 (recovery WIP), 000ea979, 1c5e66c0, merge e01b2cd1 Dual-lens review FIX FIRST; all 3 findings CLOSED; final narrow re-review PASS Probe success requires a reported roster and remains before selection/EnterWorld; terminal status derives from the actual start outcome; conditional fields distinguish omission from explicit null without weakening strict JSON. Branch gates: Runtime 1,632/1,632 and Headless 149/149 on both Windows and Ubuntu/WSL. Integrated gates: Release solution build green; Windows Runtime 1,636/1,636, Headless 151/151, App 5,039+3 skip, Launcher.Core 114/114; WSL Runtime 1,636/1,636, Headless 151/151, Launcher.Core 114/114. Repeated live ACE probe remains the LA11 user gate.
LA3 DONE + MERGED 2026-08-14 37d74e44, 26feba81, 347a1a5d, merge 7749545d, seam 8a03a25f Initial 12 findings CLOSED; four-gap narrow review FIX FIRST; final narrow re-review PASS AcDream.Launcher.Core remains BCL + Platform only. Windows/WSL Core 114/114; full Release build green. Composer output is parsed by BOTH real host loaders from one linked fixture; Launcher.Core build/tests run in the portable Windows+Ubuntu lane. Windows graceful-stop gap remains tracked as #397.
LA4 DONE + MERGED 2026-08-14 d0a9c65d, 10a712d6, ae2cbbee, merge 60f62799 Initial dual-lens review found 10 issues; fix re-review left one Linux execute-bit gap; final narrow re-review PASS Avalonia 12.1.1 launcher remains thin over one BCL-only Core orchestrator. Windows/WSL Launcher.Core 162/162 and Launcher 17/17. Native linux-x64 publish evaluates self-contained + single-file, runs without a discoverable runtime, and CI verifies executable launcher/App/Headless artifacts. LA9/LA10 bodies and LA11 visual/accessibility confirmation remain intentionally later.
LA5 DONE + MERGED 2026-08-14 95f4be94, fbe9c8a2, f820eb25, merge 5535d0ad Initial review found 5 issues; first narrow re-review found 4 ownership/race gaps; final narrow re-review PASS Both hosts share exact absent/null=all, []=none allow-listing; transactional scoped UI/entity/selection rollback precedes unload; graphical/headless status and teardown ordering match; headless replay is exact-once under Runtime's borrowed membership lease. Branch complete suite 13,679+4 skip; portable WSL closure green.
LA6 DONE + MERGED 2026-08-14 41b15efd, 259f0e5a, merge 2bb8ccb6 Dual-lens/CH regression review found one Headless wire-parity gap; narrow re-review PASS Runtime owns the sole parser/router/catalog and shared four-route live binding. Both hosts run generation-scoped login commands after world entry with strict monotonic delay and nonterminal v1 failure status. Headless permit/chat/notell semantics match App. Branch complete suite 13,787+4 skip; WSL Runtime 1,662, Headless 165, Launcher.Core 167, UI/chat 922.
LA7 DONE + MERGED 2026-08-14 LA7a 6a32f375, 4338b1c1, 0c8643a7, merge fa2de1c4; LA7b 0e82cbf7, 1b9e7e41, ff406562, merge 7691cf75 LA7a retail-lens PASS; LA7b review found 4 issues, first narrow pass left one restore/delete interleave, final narrow re-review PASS; AD-97 filed Runtime owns the sole generation-scoped pre-world selection graph. Exact retail roster/grey/button/delete/restore behavior and queue routing are preserved; NumErrors is a sentinel, paused selection retains reliable transport sweeping, silent restore cannot block, and App has no mirror. Windows Runtime 1,653, Core.Net 958, App 5,042+3 skip; WSL Runtime/Core.Net green.
LA8 DONE + MERGED 2026-08-14 6cfab727, aeac874d, 1dd5706e, merge fe63ce18 Initial retail/architecture review found 4 issues; first narrow re-review left 2 retry-transaction/order gaps; final narrow re-review PASS Installed DAT enum table 5 proves 0x10000005 -> 0x21000004, root 0x1000039A, exact flat list/buttons/templates/dialog assets, and no viewport. Runtime remains the only selection owner; row sizing, modal priority/retry, restore ordering, reset/disposal, and explicit live-DAT skip/probe are covered. Branch full suite 13,796+5 skip; LA11 owns physical visual/live-ACE acceptance.
LA9 DONE + MERGED 2026-08-14 ff6ebb6a, 3f688951, 208a70ac, merge 2198a0cc Initial integrity review found 5 issues; narrow re-review left one orphan-child publication race; final narrow re-review PASS First-run installer validates four DATs, consumes strict v1 Bake JSONL, preserves/reverifies SHA+size+tool-version records, and co-publishes self-contained launcher+Bake. Cross-process install/publish locks plus durable nonce prevent post-recovery mutation across real parent-only hard kills on Windows/Linux. Branch full suite 13,799+4 skip; real retail-DAT bake remains LA11.
LA10 DONE + MERGED 2026-08-14 2d2a5b50, 1955ca8a, 09d84387, merge da4fb3de Initial architecture/security review found 10 crash, trust, integrity, cleanup, and lifecycle issues; first narrow re-review left one rollback-source P1; final narrow re-review PASS Production feeds and redirects are HTTPS-only, fixture loopback trust is explicit, downloads and archives are bounded and verified, version activation and rollback are atomic, active sessions hold the cross-process update lease, and schema-v3 self-update recovery verifies every prior/replacement file before apply, rollback, or restart. Real Windows/Linux process tests cover kill boundaries, staging races, lease deferral, corrupt backups, junctions/symlinks, and fail-closed recovery. Branch gates: Core 302/302 and Launcher 29/29 on Windows/WSL, full Release 13,945+4 skip, win/linux self-contained publishes. Integrated LA0LA10 gate: 13,972+5 skip.
LA11 AUTOMATED CLOSEOUT REVIEW-CLOSED + MERGED 2026-08-15 — USER GATE PENDING f881e5b4, 134edabe, accd01a0, 9f9c1167, merge d39f3098 Initial dual-lens review found 7 startup/evidence/safety issues; first narrow re-review left 2 PID-reuse/ZIP-mode gaps; final narrow re-review PASS Strict isolated roots and process-local feed override compose one exact launcher path graph. Windows targeted CTRL_BREAK is group-isolated and preserves stdin; exact-PID/start-identity status validation, credential-value scanning, deterministic Unix-mode A/B fixtures, Windows/native-Linux helper safety, and the exact AI operator script are implemented. Clean branch preflight passed 32/32 with 13,985 tests + 4 skips. Integrated clean-head preflight at a22f5411 passed 32/32 with 14,012 tests + 5 skips and report SHA-256 49f225bc6043b9256f17b7bf0f29df919c894b8355633077751fd279756470df. No connected/UI/real-DAT row has run; campaign shipment and #397 closure remain pending the user gate.