acdream/docs/plans/2026-08-14-launcher-campaign.md

39 KiB
Raw 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}, 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).

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, 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 2):

{
  "schemaVersion": 2,
  "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 },
  { "path": "obsolete.dll", "operation": "remove", "hadOriginal": true }
]

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 reverses the same operations atomically and is idempotent after a process/power loss. 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). 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 connected-gate script docs/research/2026-XX-XX-campaign-la-test-script.md 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.

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
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
LA9
LA10
LA11