# 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 ` 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. ```json { "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//session.json`): the K1 `HeadlessConfiguration` shape extended with: - `Plugins: string[]` — plugin names to load from the standard `PluginsDirectory`; absent/null loads all discovered plugins, while an explicit empty array loads none. Launcher-composed normal-empty and probe sessions emit the empty array. - `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 ` (existing CLI). - GUI: `AcDream.App --session-config ` (new; parsed once in `Program.cs` into `RuntimeOptions` per code-structure rule 4). - Probe: `AcDream.Headless --config ` 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//status.jsonl`), appended by both hosts, one JSON object per line: `started`, `connected`, `characterList` (names + ids + slots), `enteredWorld` (id + name), `pluginLoaded` / `pluginFailed` (name + error), `loginCommandFailed` (zero-based command index + exact configured line + isolated 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//`; 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; target-local atomic replacement on next start after the running process exits. - **Feed hosting:** GitHub Releases (user-confirmed). Manifest and zips are release assets; the launcher pins the repo/owner in its config. The exact v1 manifest, extracted-version record, `current.json` activation pointer and launcher ownership record, shared-session/exclusive-update OS lease, and durable self-update plan schema 3 are pinned in `docs/plans/2026-08-14-launcher-campaign.md` under **Pinned updater contracts (v1, BINDING)**. That section is normative: implementations reject unknown/duplicate fields and unsupported versions, use strict SemVer 2.0 precedence, verify bounded streamed downloads before safe ZIP extraction, and use per-hop redirect validation plus same-filesystem atomic replacement. The LA9 DAT/pak install record remains the sole content descriptor fed to session configs; LA10 changes only which verified `app/current.json` client binaries the process supervisor executes. ## 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-config` → `RuntimeOptions`, 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.