acdream/docs/plans/2026-08-19-launcher-usability-campaign.md
Erik 4d84456c21
Some checks failed
CI / linux-portable (push) Successful in 3m15s
CI / windows-gate (push) Failing after 6m44s
CI / release (push) Has been skipped
docs: close Campaign LU with its ledger and the four gate traps
Ten slices, six planned and four the gate rounds added, all shipped through CI
and accepted live: the update flow "works, it updates as it should", launcher
self-update "pass", the client's exit back to the character selector "pass".

The plan now records what the gate rounds found that the plan could not, since
every one of the four was invisible to the automated suite:

- headless play and character refresh had never run once — the launcher passed
  the graphical host's argument shape to the headless host, which reads
  arguments[0] as a command;
- refresh was harmful as well as broken, opening a second connection the server
  treats as a new login;
- Stop WAS the ungraceful exit, killing the client five seconds in;
- Play was below the fold behind the settings form.

And three findings worth keeping: the verification cache cannot see a same-size
same-timestamp change (measured — the CI runner's /tmp is ZFS, 141 of 200
same-size rewrites kept an identical mtime), testing the launcher exercises the
INSTALLED client rather than your source, and a locally built launcher stamps
1.0.0 and therefore can never be offered an update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 08:13:29 +02:00

334 lines
16 KiB
Markdown

# Campaign LU — launcher usability
**Status: CLOSED USER-ACCEPTED 2026-08-19/20.** Ten slices — the six planned
plus four the gate rounds added — shipped through CI and accepted live.
**Gate results, in the user's words:** the update flow "works, it updates as it
should"; the launcher self-update round "pass"; the client's exit back to the
character selector "pass".
| slice | commit | what it fixed |
|---|---|---|
| (blocker) #420 client crash | `a34e8f2a` | character select killed the client mid-paint |
| LU1 instant startup | `00d12782` | 29.9 s → 0.89 s, measured on the real 27.9 GiB pak |
| LU2/LU3 one update question | `a01ff426` | six buttons → Update / Not now, self-restarting |
| LU4 Setup complete | `0a2defb6` | setup ends with a dialog, not a finished progress bar |
| LU5/LU6 Play + sessions | `09305be6` | one Play per character; rows say who is playing |
| (cross-cutting) locale | `6a15dd06`, `955c6180` | retail text stopped following the machine's locale |
| headless CLI + LU7 | `2bff44a9` | headless and character refresh had never run at all |
| LU8 roster + fold | `18bbd377` | logging in IS the refresh; Play above the fold |
| LU9/LU10 stop + logout | `6ab5d8ce` | 30 s graceful stop, ACE hold, logout lands on select |
| verification-cache limit | `7037681a` | the ZFS finding below |
Full solution under the release-gate filter: **14,375 passed, 0 failed,
0 skipped**, and identical under `sv-SE`, `tr-TR`, `ar-SA` and `de-DE`.
---
## What the gate rounds found that the plan did not
Four of the ten slices did not exist when this plan was written. Each came from
the user running the thing, and each was a defect the automated suite could not
have surfaced:
**Headless and character refresh had never worked, once.** The launcher spawned
`acdream-headless --config <path>`; the host reads `arguments[0]` as its command
and accepts only `validate` or `run`. Every launcher-started headless session
and every roster refresh died on its first instruction with "Invalid command"
and exit 64 — visible only as a code in a status file. A whole campaign's gates
missed it because they drove the headless host through its CLI directly, never
through the launcher's spec. `LauncherHeadlessCommandLineContractTests` now
feeds the launcher's real argument vector to the host's real parser.
**Refresh was harmful as well as broken.** It opened a second connection to an
account purely to read the roster, which the server treats as a new login — so
using it while playing disconnected you. It was also redundant: every ordinary
login already carries the roster, and the orchestrator already folds it in.
**Stop was the crash.** The UI gave the client five seconds before killing it,
which is not enough to send a logout, await the acknowledgement, and tear down a
mapped 28 GB world. So Stop routinely produced exactly the ungraceful exit that
leaves the server holding the account.
**Play was below the fold.** The buttons existed; the plugins/login-commands
form pushed them past the bottom of the scroll area. Reported, correctly, as
"there is no headless or gui option".
## Findings worth keeping
**The verification cache cannot see a same-size, same-timestamp change.** Run
174 failed on a test asserting it could. Measured on the runner: `/tmp` is ZFS,
and 141 of 200 same-size rewrites produced an identical mtime. NTFS's 100 ns
resolution is the only reason it never showed on Windows. The contract is now
two true statements — startup catches a corruption whose write time moves, and
a forced full verification catches one that preserves both — instead of one
that is false on some filesystems. Verify files is the forced path.
**Testing the launcher does not test your source.** The launcher runs the
INSTALLED client from the version store, so a client-side fix cannot be gated
until CI publishes it. A void-world screenshot was read as "the fix failed" when
the installed build was 63 minutes older than the fix.
**A locally built launcher cannot test self-update.** Its stamped version is
`1.0.0`, which sorts above every `0.1.0-build.*` the feed publishes, so it is
never offered an update. Publishing one with a deliberately low
`InformationalVersion` is what made that path testable at all.
**Goal**
> The launcher opens without a long wait. On startup it asks whether to
> update the launcher or the client, and restarts itself after a launcher
> update; the old update flow is gone. First-run setup ends with a success
> popup that returns you to the launcher on OK. A selected character
> launches directly. The sessions frame shows account, character (or Char
> Select) and whether they are in game — not the launch mode.
**Why now.** Campaign LA shipped a launcher that is *correct* — atomic
installs, verified artifacts, session barriers, rollback — and *not
usable*. The user's verdict, twice: "way too complex", "too complex for
sending it to my friends". This campaign changes the surface a person
touches. It does not weaken what happens underneath.
**Acceptance for the whole campaign** is the user's own walkthrough:
download `launcher-win-x64.zip` from the `latest` release, unzip, run,
install, play — without being told anything.
---
## LU1 — the launcher opens immediately
**Measured problem.** [App.axaml.cs:57](../../src/AcDream.Launcher/App.axaml.cs)
blocks the UI thread on `installer.LoadExistingAsync().GetAwaiter().GetResult()`
before the window is constructed. That reaches
`LauncherInstallRecordStore.VerifyFileAsync`, which computes a full SHA-256
of the installed package.
Measured on the user's machine 2026-08-19:
| fact | value |
|---|---|
| `%LOCALAPPDATA%\acdream\pak\acdream.pak` | 29,908,271,024 bytes (27.9 GiB) |
| full SHA-256 | **24.1 s** at 1.16 GB/s |
| digest vs `install.json` | identical (`fee8595d…`) |
So the startup cost is 24 s of disk read to re-confirm something that was
already true. A friend does not see it only because they have no package
installed yet — verification short-circuits at "nothing installed". It
will hit them the moment first-run setup finishes.
**Change.** Startup verification becomes size + last-write-time against
the record. The full hash keeps running where it is cheap and meaningful:
at install, after an update installs a new package, and behind an explicit
**Verify files** button (the Steam shape).
The cheap facts live in a **sidecar** (`install.verification.json`), not as a
new field on the install record. `LauncherInstallRecordStore` reads
`install.json` with `JsonUnmappedMemberHandling.Disallow`, so a new field
there would make an *older* launcher build reject the record outright and
demand a 28 GB re-bake after a rollback. An unknown sidecar file is simply
ignored by older builds, so the change is compatible in both directions.
An install with no sidecar yet pays one full hash and then writes it.
**Acceptance**
- Window visible in under 2 s with the 27.9 GiB package installed.
- Truncating or touching the package still blocks launch with a clear reason.
- **Verify files** reproduces the full check and reports pass/fail.
- The install and update paths still hash in full — unchanged.
---
## LU2 — one update question, asked once, at startup
**Change.** On start the launcher checks the feed once. If the launcher or
the client is behind, it shows **one** dialog naming what is out of date and
offering **Update** / **Not now**. Nothing else.
- Launcher first when the feed's `minimumLauncherVersion` demands it, or
when only the launcher is behind: install, then **restart into the new
version** (`LauncherSelfUpdateBootstrap` already owns this handoff).
- Client otherwise: install, close the dialog, back at the launcher.
- Nothing to do: no dialog at all. The launcher just opens.
**Acceptance** — three observed cases: up to date (silent), client behind
(one dialog → play), launcher behind (one dialog → relaunched on the new
version, confirmed by the version it reports).
---
## LU3 — delete the old update surface
The current prompt offers six buttons — Check again, Rollback client,
Stage launcher, Install client, Cancel, Close — plus a version table and a
restart-required banner. That is the flow being removed, along with the
"Check for updates" header button and the `LauncherUpdateViewModel` paths
only it reached.
**What stays:** everything in `AcDream.Launcher.Core/Updates/` that makes
an update safe — manifest validation, bounded verified download, safe ZIP
extraction, versioned install with an atomic `current.json` switch, the
session barrier, and rollback as a *capability*. The complexity the user
objects to is the panel, not the safety beneath it.
**Open — needs one confirmation before code is deleted:** rollback has no
place in the new single-question flow. It can move behind a small
"Advanced" affordance or leave the UI entirely (staying available as Core
API + tests). I will show the exact deletion list and ask before removing
it.
**Acceptance** — exactly one update entry point in the UI; tests covering
deleted view-model behavior are removed with the code, never skipped.
---
## LU4 — "Setup complete" ends first-run setup
**Change.** When the bake publishes and the install record verifies, the
wizard shows a modal: setup succeeded, what was built, **OK**. OK closes
the wizard and returns to the launcher with the "Client setup required"
banner gone and launching enabled.
**Acceptance** — a real first-run bake shows it exactly once on success;
cancellation and failure paths keep their existing error/status reporting
and must **not** show it.
---
## LU5 — pressing Play on a character launches that character
**Reproduce before changing anything.** The plumbing already exists end to
end: `LauncherOrchestrator.LaunchAsync` clones the character with the
*requested* mode (`CloneCharacter(character, mode)`),
`SessionConfigComposer.BuildSelector` emits an id selector (falling back to
name), and `RuntimeOptions.MapCharacterSelector` maps it into the App host.
A defect somewhere in a chain that reads correct is exactly the case this
project has repeatedly lost time to by guessing.
Two candidates to separate by observation, not argument:
1. The launch button is gated off by a capability reason, so the click
never becomes a session.
2. The selector reaches the client but the roster match fails, so character
select stays on screen — which is what "you can just select different
chars" describes.
**Change.** One obvious **Play** per character that enters the world as
that character, plus the deliberate "Character select" path kept separate.
Three near-identical launch buttons is itself part of the complaint.
**Acceptance** — select a character, press Play, arrive in the world as
that character with no character-select screen in between.
---
## LU6 — the sessions frame says who is playing
Today each row reads `server / account / character`, then `Mode`
(Gui/GuiSelect/Headless/Probe), then `State`, then a raw status string.
The launch mode is launcher bookkeeping and means nothing to a player.
**Change.** Each row shows the account, the character — or **Character
select** when no character was chosen — and one plain status word derived
from the host's own status stream:
`Starting``Character select``In game``Stopped` / `Failed`
Errors keep their own line. Stop keeps its button. Character-refresh
(probe) rows stay distinguishable from play sessions.
**Acceptance** — launching a character shows account + name + **In game**
once in world; a character-select launch shows **Character select** until a
character is entered.
---
## Non-goals
- No change to download verification, atomic install, or the session barrier.
- No change to credential handling (plaintext profile remains the user's decision).
- No change to Linux graphical gating (Slice L stays parked).
## Working rules for this campaign
- One slice per commit, `dotnet build` + `dotnet test` green before each.
- Push to main; CI gates on both runners and publishes the release the
launcher itself updates from — so every slice is testable by the user
through the shipped path within a few minutes.
- LU3's deletions and LU5's root cause get shown to the user before they
land.
---
# Implementation notes (recon 2026-08-19, before any code)
These were read out of the tree, not assumed. They exist so each slice
starts from the mechanism that is already there instead of re-deriving it.
## The self-update restart chain already exists end to end (LU2)
`LauncherUpdater.StageLauncherAsync` stages a verified payload and writes a
plan. On the next ordinary startup `LauncherSelfUpdateBootstrap.HandleAsync`
takes the exclusive lease, sees `SelfUpdatePlanState.Staged`, and spawns the
STAGED launcher in helper mode. `RunHelperAsync` waits for the parent PID to
exit, applies the replacement, starts the updated launcher with
`--acdream-self-update-confirm-v1`, and waits for the confirmation receipt.
So "restart after a launcher update" needs no new update machinery. What it
needs is one seam: after staging succeeds, start the staged helper against
the CURRENT process and shut down. Extract the existing staged-plan branch of
`HandleAsync` into a callable entry point and reuse it — do not duplicate it,
and do not restart by launching a second copy of the launcher and hoping the
bootstrap picks the plan up, which races the exclusive lease against the
process that is still shutting down.
## The orchestrator already knows "in game" (LU6)
`LauncherActivityState` has `InWorld`, and the orchestrator already sets it
from `EnteredWorldStatusEvent`, which carries the real `CharacterId` and
`CharacterName` from the host. Today that identity is written into a status
STRING (`"In world as X."`) and thrown away.
LU6 promotes it: the entered-world event updates the activity's character
name so a character-select launch can show who is actually being played, and
the row renders one word derived from `LauncherActivityState` rather than the
raw enum plus the launch mode:
| state | row shows |
|---|---|
| `Starting`, `Running` | Starting |
| `Connected` | Character select |
| `InWorld` | In game |
| `Disconnected`, `Stopping` | Stopping |
| `Exited`, `Cancelled` | Stopped |
| `Failed` | Failed |
`LauncherActivityKind.Probe` rows stay visually distinct (they are a
character refresh, not a play session).
## First-run completion has an exact point (LU4)
`FirstRunInstallerViewModel.StartAsync` succeeds at the line that calls
`_onInstalled(result.Record)` and sets `Phase = LauncherInstallPhase.Completed`.
That is where the success dialog belongs — after the record is published, so
the launcher behind it is already in its launch-enabled state when the user
presses OK. The cancelled and failed branches immediately below it must not
reach it.
## The launcher side of "launch this character" reads correct (LU5)
Confirmed by reading, so the live repro can skip re-checking these:
- `LauncherOrchestrator.LaunchAsync` -> `CloneCharacter(character, mode)`
overrides the profile's saved `LaunchMode` with the mode the button asked
for, so the stored default cannot leak into an explicit launch.
- `SessionConfigComposer.Compose` builds a selector for every mode except
`GuiSelect`, preferring a parsed non-zero id over the name.
- `SessionPlayerComposition` passes the selector into
`LiveSessionConnectOptions` with `AwaitCharacterSelection: selector is null`,
and `InteractionRetainedUiComposition` binds the character-selection UI only
when the selector is null.
The user's stored profiles all carry `launchMode: "guiSelect"` (the default),
and every cached character has a real id. So the defect is NOT a missing id
and NOT the saved default overriding the click. Reproduce live before
changing anything.