All six slices are committed locally with the full solution green at 14,370 tests. Records the one thing a gate tester will hit that is NOT a Campaign LU defect: the installed client predates the #420 character-select crash fix, so Play still dies until a release carrying it is published. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
282 lines
12 KiB
Markdown
282 lines
12 KiB
Markdown
# Campaign LU — launcher usability
|
|
|
|
**Status:** CODE-COMPLETE 2026-08-19. All six slices implemented, tested, and
|
|
committed locally (not pushed — the user asked for local verification first).
|
|
The connected user gate is the remaining boundary.
|
|
|
|
| slice | commit | tests |
|
|
|---|---|---|
|
|
| (blocker) #420 client crash | `a34e8f2a` | reverting test reproduces the live stack |
|
|
| LU1 instant startup | `00d12782` | 10 cache tests + 2 view-model tests |
|
|
| LU2/LU3 one update question | `a01ff426` | 8 rewritten update tests |
|
|
| LU4 Setup complete | `0a2defb6` | 2 tests (success + failure never claims success) |
|
|
| LU5/LU6 Play + sessions | `09305be6` | 16 session-row tests |
|
|
|
|
Full solution under the release-gate filter: **14,370 passed, 0 failed,
|
|
0 skipped.**
|
|
|
|
**Known gate limitation.** The client installed under
|
|
`%LOCALAPPDATA%\acdream\app\` is the pre-#420 build, so pressing **Play** still
|
|
crashes on the character-select screen until a release carrying the fix is
|
|
published and the launcher installs it. That is itself the natural next test:
|
|
push → CI publishes → the new startup prompt offers the update → taking it
|
|
exercises LU2 end to end and replaces the crashing client.
|
|
|
|
**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.
|