acdream/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md
Erik 0bcc7ba3a3 docs: Campaign LA — spec §11.4 corrected per LA7a review; LA7b hazards recorded
The spec seeded the wrong claim (restore extra strings = decompiler
artifact, no register row needed); the LA7a Opus review decoded the
PDB-paired binary and showed the two constant-string arguments are real,
making our guid-only request an adaptation — AD-97 filed on the LA7a
branch. Plan LA7 now carries the review-surfaced LA7b hazards (ACE
silent no-reply restore path, SendToLogon/SendToControl routing,
NumErrors sentinel).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 16:02:20 +02:00

20 KiB
Raw Blame History

Campaign LA — Launcher / Installer / Updater + character-select screen (design spec)

Date: 2026-08-14 Status: APPROVED design, pre-plan Plan doc (next step): docs/plans/2026-08-14-launcher-campaign.md (to be written) Prior decisions consumed: claude-memory/project_launcher_direction.md (2026-08-09)

Campaign LA is distinct from Modern Runtime Slice L (Linux graphical, parked at L1) — "LA" is a campaign identifier in the N/V/P/A/CH/OP/FA series, not a slice.

1. Goal

One external product — the acdream launcher — that is simultaneously the installer, the updater, and the multi-server / multi-account / multi-character session launcher (ThwargLauncher UX model), on Windows and Linux. Plus the one client-side feature the launcher flow exposes as missing: the retail character-selection screen.

Distribution model: alpha users receive ONLY the launcher (per-RID self-contained single-file publish). The launcher fetches the client from the release feed, locates retail DATs, runs the pak bake, and launches sessions.

2. Decisions log

Pinned 2026-08-09 (NOT re-litigated here):

Decision Value
UI stack Avalonia (Windows + Linux day one)
Project split AcDream.Launcher (thin Avalonia) + AcDream.Launcher.Core (BCL-only)
Credentials Plaintext file, user-decided. 0600 on Linux; never in logs/crash bundles
UX reference ThwargLauncher (servers × accounts × character pre-select)
Launch contract Reuse Slice K1's strict portable config shape
Paths Consume Slice L0's ApplicationPathSet XDG/Windows contract

Decided this session (2026-08-14):

Question Decision
Headless launch purpose Run plugins (VirindiTank-style) + login commands; same on GUI. Launcher selects which plugins per character
Character-select screen Retail screen, no Create. CORRECTED by 2026-08-14 recon: retail's gmCharacterManagementUI is a flat listbox + Enter Game / Delete / Restore buttons + dialogs — no 3D preview exists on retail's select screen (that machinery, gmCG3DView, is character-CREATION-only). We port what retail actually had; Create deferred to its own campaign
Campaign scope Everything now — launch flows + first-run install + update pipeline in one campaign
Launcher ↔ client coupling Approach A: file-contract orchestrator (config in, status events out; no game-protocol code in the launcher)
Update feed GitHub Releases (manifest.json + per-RID zips as release assets)
Profile editing Full CRUD in the launcher UI — add/edit/remove servers, accounts, passwords, per-character settings. The JSON file is storage (hand-editable as a bonus), never the required interface
Linux scope Full launcher stack Linux-tested in LA (launcher UI, install/update, headless + plugins + probe); GUI launches stay Windows-only until Slice L resumes later (user 2026-08-14). Launcher disables GUI modes on Linux with an explicit note
Character enumeration On-demand probe: launcher spawns the headless host in a probe mode (connect → CharacterList → status event → graceful disconnect BEFORE entering world → exit) and folds the roster into the profile store

Rejected: the launcher itself embedding Runtime/Core.Net to speak the game protocol — the character probe runs in the headless host via the normal launch contract, so the launcher stays protocol-free. The probe never enters the world; a graceful account-level disconnect at the character-list stage is the same dance every normal login performs, so the ACE stale-session landmine (hard-killed in-world sessions poisoning the account ~3 min) does not apply on the happy path. Deferred: live IPC fleet dashboard (the status-file format is its forward seam), Create Character, community server-list import.

3. Architecture — file-contract orchestrator

The launcher never speaks the game protocol and references nothing from the game solution except a new tiny platform assembly. Its contracts with the client are exactly three:

  1. Config in — a per-launch session config file (K1 shape, extended).
  2. Credential in — password piped to child stdin (K1 StandardInput provider).
  3. Status out — a per-session JSON-lines event file the launcher tails.

Character enumeration has two feeds, both flowing through the same status-stream vocabulary:

  1. Cache-from-observation — hosts report the account's CharacterList in the status stream on every login; the launcher folds it into its profile store.
  2. On-demand probe — a "refresh characters" action per account spawns the headless host with a probe-mode session config: connect, receive CharacterList, emit the status event, gracefully disconnect without entering the world, exit. The launcher folds the roster in exactly as in (1). The launcher refuses to probe an account it is itself currently running a session for; an externally-active session makes the probe fail gracefully (reported on the status stream, never an exception in the launcher).

A never-seen account can therefore be enumerated before its first real launch, or simply launched in guiSelect mode and picked in-client.

4. Components

  • AcDream.Launcher.Core (new, BCL-only): profile store (load/save/validate/merge-charlist), session-config composition, process spawn + supervision + stdin credential feed, status-event reader, install engine (DAT locate/validate, bake-tool invocation, SHA verify), update engine (manifest client, download, SHA verify, versioned install, pointer swap), self-update stager. Fully unit-testable.
  • AcDream.Launcher (new, Avalonia): MVVM shell over Launcher.Core. Server list → accounts → characters tree with full CRUD in the UI: add/edit/remove servers (name/host/port), add/edit/remove accounts (account name + password entry), per-character settings editor (launch mode / plugins / login commands), a per-account "refresh characters" probe action, session status column, first-run install wizard, update prompts. Hand-editing the JSON is never required for any flow.
  • AcDream.Platform (new, tiny, BCL-only): ApplicationPathSet + IApplicationPathEnvironment move here from src/AcDream.Runtime/Platform/ApplicationPathSet.cs. Runtime, App, Headless, Launcher.Core reference it. Dependency guards (K0 family) amended deliberately in the same commit.
  • AcDream.App: gains --session-config <path> CLI ingestion into RuntimeOptions (env-var dev workflow untouched), the StandardInput credential resolver, the status-event writer, the launcher-selected plugin set, login-command execution, and the character-select screen.
  • AcDream.Headless: gains the two config fields (Plugins, LoginCommands), an idle consumer policy (enter world, run plugins/commands, stay until stopped), the probe mode (§3/§6), plugin hosting, and the same status-event writer.

5. Profile & credential store

One JSON file, created and maintained entirely by the launcher UI (the CRUD flows in §4): ConfigDirectory/launcher-profiles.json (%APPDATA%\acdream\ / ~/.config/acdream/), permissions 0600 on Linux. Hand-editability is a property of the format, not a required workflow.

{
  "version": 1,
  "servers": [
    {
      "name": "Local ACE",
      "host": "127.0.0.1",
      "port": 9000,
      "accounts": [
        {
          "account": "testaccount",
          "password": "testpassword",
          "characters": [
            {
              "name": "+Acdream",
              "id": "0x5000000A",
              "launchMode": "gui",
              "plugins": ["ExamplePlugin"],
              "loginCommands": ["/tell someone, hi", "/vt start"]
            }
          ]
        }
      ]
    }
  ]
}
  • characters[] = launcher-maintained cache (name/id, fed by status events) + user settings (launchMode, plugins, loginCommands).
  • launchMode: gui (straight to world), guiSelect (GUI, stop at character-select screen; default when no character chosen), headless.
  • Passwords live in this file and NOWHERE else: never in process arguments, never in session configs, never in logs (K1 redaction discipline extends to the launcher).
  • Server entries are manual-add (name/host/port). No published-list import this campaign.

6. Launch contract

Session config (written to CacheDirectory/launcher/sessions/<id>/session.json): the K1 HeadlessConfiguration shape extended with:

  • Plugins: string[] — plugin names to load from the standard PluginsDirectory; hosts load exactly this set.
  • LoginCommands: string[] — ordered chat-typed strings.
  • Graphical host: Character selector may be ABSENT → character-select screen instead of auto-enter.
  • Content descriptor (existing K1 field): DatDirectory + PreparedAssetPath, filled from the launcher's install records.

Spawn:

  • Headless: AcDream.Headless --config <path> (existing CLI).
  • GUI: AcDream.App --session-config <path> (new; parsed once in Program.cs into RuntimeOptions per code-structure rule 4).
  • Probe: AcDream.Headless --config <path> with a probe-mode session (connect → characterList status event → graceful disconnect before EnterWorld → exit). Today's config loader requires a character selector and a policy per session (JsonRequired); probe mode relaxes that for the probe shape only.
  • Credential: K1 StandardInput provider; launcher writes the password to child stdin then closes it. Headless supports this today; App gains the resolver.

Status stream (CacheDirectory/launcher/sessions/<id>/status.jsonl), appended by both hosts, one JSON object per line:

started, connected, characterList (names + ids + slots), enteredWorld (id + name), pluginLoaded / pluginFailed (name + error), disconnected, exited (code + reason).

The launcher tails this for live per-session UI state and folds characterList into the profile store. This exact event vocabulary is the seam a future IPC channel (fleet dashboard) replaces — same events, different transport — so event names/payloads are versioned from day one ("v": 1 per line).

7. Character-select screen (client-side, retail)

A new pre-world session state between CharacterList receipt and EnterWorld. Today LiveSessionController (TrySelectCharacter, src/AcDream.Runtime/Session/LiveSessionController.cs) auto-selects and enters immediately. New behavior: no character selector in options → stop at the retail character-select screen. Selection there feeds the same EnterWorld path.

CORRECTED 2026-08-14 (named-retail recon): retail's screen is gmCharacterManagementUI (acclient.h:56545) — a UIElement_ListBox character list plus Create / Enter Game / Delete / Restore buttons and dialog contexts (delete-confirm, please-wait, entering-world, error). It has NO 3D preview — the rotating-model viewport (gmCG3DView / UIElement_Viewport / CreatureMode) exists only on character CREATION's appearance/heritage/profession pages. The earlier "3D preview on a pedestal" belief traced to one uncited line in docs/research/retail-ui/05-panels.md §13. We port the real screen; a preview would be a deliberate divergence we are NOT taking.

  • Ownership: J-owner pattern. A Runtime-owned selection state (roster incl. greyed/pending-delete seconds, highlighted entry, pending-delete confirmation) with typed commands (highlight / enter / delete-request / delete-confirm / restore); App projects the authored screen. Headless never uses it (config always carries a selector; the loader already requires one).
  • UI: imported retail screen. The root layout id is resolved indirectly — retail calls UIMainFramework::CreateAndAddRootElement(0x10000005, 0x1000039a) and resolves the concrete DataID via DBObj::GetDIDByEnum(..., 5) (the same GetDIDByEnum machinery OP8 already ported for key names, category 4). Child widget ids from the decomp: listbox 0x1000039d, create 0x100003a0 (hidden/no-op this campaign), enter 0x100003a2, delete 0x1000039f, restore 0x1000039e. Behavior oracles: RebuildCharacterList@0x004ec3a0, SelectCharacter@0x004ec160, UpdateButtons@0x004ec240 (Delete↔Restore visibility swap on greyed state), EnterGame@0x004ed440, MakeDeleteCharacterConfirmationDialog@0x004ecca0.
  • Wire (new messages): CharacterDelete 0xF655 (outbound: account String16L + slot index, per Proto_UI::SendDeleteCharacter@0x00546b30 — NOT the guid; inbound: opcode-only ack, then a fresh CharacterList), CharacterRestore 0xF7D9 (guid) with response 0xF643, and a CharacterError 0xF659 parser (currently absent — acdream cannot surface any character-stage server error today). Enter-world's two-phase handshake (0xF7C8 → 0xF7DF → 0xF657) is already implemented.
  • Open items carried to the plan: the concrete layout DataID (dump enum-table 5 from installed DATs), and whether retail rendered any render-loop-level background scene behind the UI (the pseudo-C only proves the UI class owns no viewport) — both resolved in the screen slice before the user visual gate.
  • Non-goals: Create Character (own future campaign; the Create button exists on the authored screen but is disabled); a login screen ("back" exits the client — credentials always arrive via config/env).
  • Retail-workflow rules apply: any behavioral deviation ships with its divergence-register row in the same commit.

8. Plugins & login commands on both hosts

  • Plugin loading mechanics already live in Core (src/AcDream.Core/Plugins/PluginLoader.cs, collectible ALC, IPluginHost from AcDream.Plugin.Abstractions). This campaign makes the loaded SET session-config-driven on both hosts.
  • Headless plugin host: an IPluginHost implementation over Runtime state. UI-only surfaces (IUiRegistry.AddMarkupPanel, etc.) become explicit no-ops behind a capability flag so plugins can detect headless. Contract documented in Plugin.Abstractions.
  • Login commands run as if typed into chat: sequentially, with a default 500 ms inter-command delay (config-overridable per session), starting at entered-world. Failures are logged to the status stream and do not abort the session.
  • The parser seam: chat-string parsing/dispatch (ChatInputParser, ChatCommandRouter, 152-verb RetailClientCommandCatalog) lives in AcDream.UI.Abstractions, unreachable from Headless under the K0 dependency guard. The campaign extracts the parsing/dispatch CORE to a location both hosts reach — Runtime vs a small shared assembly is decided in the plan after reading the CH seams. The K0 guard amendment is a deliberate, documented change in the same slice. GUI chat behavior must be bit-identical before/after the extraction (CH campaign is closed and user-accepted; this must not reopen it).

9. Installer / updater

  • First-run wizard: auto-detect DAT directories (%USERPROFILE%\Documents\Asheron's Call, C:\Turbine\Asheron's Call)
    • manual picker; validate the four DAT files; run bake tool 4 with a real progress UI (~30 GB read); SHA-verify the pak; record DatDirectory + PreparedAssetPath for session configs.
  • Client install/update: poll the GitHub Releases feed's manifest.json (version, per-RID zip URL, SHA-256); download; verify; install to DataDirectory/app/<version>/; atomic pointer swap (current.json); never while any session is running; keep the previous version for one-step rollback.
  • Launcher self-update: same feed; staged download; rename-dance swap on next start (a running exe can't replace itself on Windows).
  • Feed hosting: GitHub Releases (user-confirmed). Manifest and zips are release assets; the launcher pins the repo/owner in its config.

10. Testing

  • Launcher.Core unit tests (new test project, registered in AcDream.slnx): profile round-trip + merge, full CRUD operations (add/edit/remove servers/accounts/characters surviving save/load), session-config composition (including the probe shape), manifest/SHA against a local HTTP fixture, process supervision + stdin feed against a fake child, self-update staging.
  • Launch contract: App/Headless suite round-trips — --session-configRuntimeOptions, stdin credential resolution, status-event writer output shape, plugin-set narrowing, login-command execution order.
  • Character-select: Runtime selection-state tests; authored screen exercised by focused App tests (UI Studio no longer exists — deleted at Campaign V); visuals settle at the user gate.
  • Headless plugin host: fixture plugin in the Headless suite (load, capability flag, teardown).
  • Connected gates (user-driven): every launch mode against local ACE (gui / guiSelect / headless), the character probe (fresh account → refresh → roster appears, and repeated probes leaving no stale ACE session), clean-profile first-run wizard end-to-end, staged-manifest update swap, character-select visual matrix, delete flow, login-commands + plugin observable behavior on both hosts, and add-server/add-account flows done purely through the UI.

11. Risks / open items for the plan phase

  1. Parser-extraction landing spot (Runtime vs shared assembly) — decide after reading CH seams; do not regress CH-accepted chat behavior.
  2. Current App plugin-loading behavior (what loads today, when) — read before wiring the session-driven set.
  3. RESOLVED by recon except the concrete root-layout DataID: widget tree + behavior symbols are in §7; the DataID hides behind GetDIDByEnum enum-table 5 (enum 0x10000005) — dump the table from installed DATs in the screen slice.
  4. RESOLVED by recon, CORRECTED by the LA7a Opus review (2026-08-14): delete = 0xF655 account+slot (ack opcode-only, then fresh CharacterList); restore = 0xF7D9 → 0xF643 response; ACE's secondsSincePendingDelete computes to a constant 1 during the grace window (ACE quirk — treat any non-zero as "pending delete", don't display it as a countdown). The restore "two extra strings" question resolved AGAINST the earlier reading: the PDB-paired binary shows two REAL constant-string arguments (the decompiler mangled their rendering, not their existence), so retail's request is ≥16 bytes and our guid-only 8-byte form is an ADAPTATION — register row AD-97, filed with the LA7a fix round. ACE reads only the guid; holtburger ships guid-only successfully.
  5. Whether retail rendered a render-loop-level background scene behind the character-management UI — pseudo-C only proves the UI class owns no viewport. Resolve in the screen slice (user memory of retail + the visual gate settle it).
  6. Bake tool 4 invocation surface from Launcher.Core (in-process reference vs child process) — child process preferred to keep Launcher.Core free of game-solution references; confirm the tool's CLI is sufficient.
  7. AcDream.Platform extraction touches K0-family dependency guards — amend the guard assertions in the same commit, never loosen silently.
  8. Windows profile-file permissions: 0600 is Linux hygiene; Windows keeps default user-profile ACLs (no extra hardening — accepted plaintext posture).
  9. Probe semantics: verify against ACE source (and one live check) that a graceful disconnect at the character-list stage leaves no lingering account session — the design assumes the landmine is exclusive to hard-killed in-world sessions. Also verify ACE's behavior when a probe hits an account with an externally-active session (reject vs boot), and make the probe's failure path graceful either way.
  10. Probe config shape: the headless loader's JsonRequired character selector + policy need a deliberate relaxation for probe sessions only — normal sessions keep strict validation.