User decision 2026-08-14: everything the launcher does ships Linux-tested in this campaign (launcher UI, install/update with manual DAT picker, headless launches with plugins + login commands, probe, per-slice Linux test runs, Linux connected-gate section at LA11). GUI client launches stay Windows-only until Slice L resumes later; the launcher renders GUI modes disabled on Linux with an explicit note, and the host-agnostic session-config contract means Slice L lights them up with no launcher changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
23 KiB
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 3–4 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 testgreen before a slice is DONE; ≥1 commit per slice taggedCampaign LA; retail deviations add theirdocs/architecture/retail-divergence-register.mdrow 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 LA5–LA8 (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-bakeis 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/guiSelectlaunch 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-x64self-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/ or stays keyed to the new assembly;
GraphicalLegacyConfigurationMigratorTests.cs fixtures), and ONE dependency
guard: tests/AcDream.Headless.Tests/HeadlessDependencyBoundaryTests.cs
HeadlessAssemblyReferencesOnlyTheRuntimeProject asserts Headless references
exactly [AcDream.Runtime.csproj] — amend to the exact new set in the same
commit (deliberate, never silent). 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)
Three pieces, one slice, because they share the session-config/status seam:
- App
--session-config <path>: parsed once inProgram.csintoRuntimeOptions(code-structure rule 4); carries endpoint, account, optional character selector,Plugins,LoginCommands,Content(DatDirectory/PreparedAssetPath), status-file path, credential reference. Recon:Program.cshas 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-1135→LiveSessionConnectOptions); the config path populates the sameRuntimeOptionsfields from a different source. Env-var dev flow untouched. App gains theStandardInputcredential read (mirroringHeadlessCredentialResolver.ResolveStandardInput— one line, immediately wrapped in an erasable secret, redactedToString; todayRuntimeOptions.LivePassis a bare string — the config path must not widen that exposure). - Status stream both hosts: per-session
status.jsonl(path given in config; absent → no writer constructed, zero cost). Versioned event vocabulary ("v":1):started,connected,characterList,enteredWorld,pluginLoaded/pluginFailed,disconnected,exited. Recon: today'sHeadlessDiagnosticWriteris 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). - Roster plumbing:
CharacterList.Parsedis consumed insideLiveSessionController.StartCore(LiveSessionController.cs:612) and never escapes — add a typed roster report on the lifecycle-host seam (ILiveSessionLifecycleHost) so hosts can emit thecharacterListstatus 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-622 → StopCore() → 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).
- Probe: a
Probeflag on the connect options short-circuitsStartCoreright afterGetCharacters(beforeTrySelectCharacter): report roster,StopCore(), return a NEWLiveSessionStartStatus.ProbeComplete.HeadlessProcessHostmaps it to exit code 0 with a finalcharacterList+exited(reason: "probe")status pair. Config:mode: "probe"on the session descriptor relaxes theJsonRequiredcharacter selector + policy for probe sessions ONLY (loader keeps strict validation otherwise — recon: violations currently surface as rawJsonException→ exit 3; probe relaxation must be shape-level in the loader, not attribute removal). idlepolicy: new consumerHeadlessBotPolicyid — 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 (foldcharacterListevents 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 (State →
WorldGameState, Events → WorldEvents, Selection → SelectionState)
are backed by Core-owned types already; only Ui (BufferedUiRegistry) is
genuinely App-only. Headless has zero plugin hosting today (confirmed).
- Session-config
Pluginsallow-list filters the discovery result on BOTH hosts (absent list = load all, preserving today's dev behavior). HeadlessPluginHost : IPluginHostin Headless over the same Core-ownedState/Events/Selection;Uiis an explicit no-op behind a new capability flag onIPluginHost(e.g.HasUi) so plugins can detect headless. Contract documented inPlugin.Abstractions.pluginLoaded/pluginFailedstatus 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.
- Extraction: move parser/router/catalog +
ICommandBus+ the four command records (+ sibling tables they require) into Runtime (AcDream.Runtime/Chat/...); the router'sChatVMparameter 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. - Headless dispatch: a Runtime/Headless
ICommandBusbinding the same session send delegates (SendTalk/SendTell/SendChannel/SendTurbineChat) + Runtime state that App's router binds — parallelingLiveSessionCommandRouter's registrations, feedback lands inRuntimeCommunicationState.AddText. - Execution: both hosts run
LoginCommandssequentially as-if-typed (default 500 ms inter-command delay, config-overridable) once entered-world; per-command failures → status stream, never abort. - 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.
- Wire messages (
AcDream.Core.Net/Messages/, retail citations in file docs per house style):CharacterDelete0xF655 — outbound account String16L + slot index (Proto_UI::SendDeleteCharacter @0x00546b30; NOT guid), inbound opcode-only ack followed by a fresh CharacterList;CharacterRestore0xF7D9 guid-only (ACE + holtburger consensus; the decomp's apparent extra strings are a decompiler artifact — spec §11.4), response 0xF643 (flag + guid + name + secondsDisabled);CharacterError0xF659 parser (new — today NO character-stage server error can be surfaced). - 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. - 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 atLiveSessionController.cs:848-851) remains ONLY for selector-carrying/headless sessions. Selection feeds the existingEnterWorldpath unchanged.
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).
- 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: listbox0x1000039d, create0x100003a0(present, disabled — Create is a future campaign), enter0x100003a2, delete0x1000039f, restore0x1000039e. - Dialogs: delete-confirm, please-wait, entering-world, error — the
retail dialog machinery from the OP8 WaitDialog work (
2a81e813mapped WaitDialog class type 0x19) is the base. - 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 CallandC:\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--outis INSIDE the DAT dir — the launcher always passes--outexplicitly. Progress: add--progress-jsontoAcDream.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 +BakeToolVersionin 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.jsonrelease 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>/, atomiccurrent.jsonpointer swap, refuse while any session runs, keep previous version for one-step rollback. - Launcher self-update: staged download + rename-dance 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.
LA11 — closeout
- One connected-gate script
docs/research/2026-XX-XX-campaign-la-test-script.mdcovering: 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 | — | |||
| LA1 | — | |||
| LA2 | — | |||
| LA3 | — | |||
| LA4 | — | |||
| LA5 | — | |||
| LA6 | — | |||
| LA7 | — | |||
| LA8 | — | |||
| LA9 | — | |||
| LA10 | — | |||
| LA11 | — |