LU5. The per-character panel offered "GUI — enter world", "GUI — character
select" and "Headless" as three equal-looking buttons, above a "Default launch
mode" combo. It now leads with one primary **Play** that enters the world as
the selected character, with Character select and Headless kept as deliberate
secondary choices.
The combo is gone. It was never consulted by anything: every launch button
passes its own mode and LauncherOrchestrator.LaunchAsync overrides the
profile's stored mode with it (CloneCharacter(character, mode)). A setting that
changes nothing is worse than no setting, and this one made the three buttons
look like they obeyed it. The stored value is untouched.
Worth recording for whoever reads the LU5 acceptance: the launcher-side
plumbing was already correct end to end — orchestrator, selector composition,
and the client's own "skip character select when a selector is present" gate.
What actually made launching a character fail was #420, a client crash on the
character-select screen, fixed separately. Every play session in the user's
cache had no character selector, which is consistent with them only ever
reaching the select-screen paths.
LU6. Rows read `server / account / character`, then the launch mode
(Gui/GuiSelect/Headless/Probe), then the raw LauncherActivityState enum name,
then a status string. The launch mode is launcher bookkeeping — it says how the
process was started, which tells the person watching nothing and is meaningless
once the client is up.
Rows now show the account, the character (or "Character select" while one is
still being chosen, "Character refresh" for a roster probe), and one plain word
derived from the host's own status stream: Starting -> Character select ->
In game -> Stopping -> Stopped / Failed. A Play launch and a character-select
launch both read "In game" once the player is actually in it.
The orchestrator now KEEPS the identity from the host's enteredWorld event
instead of only formatting it into a status sentence, so a character-select
session stops being anonymous the moment someone enters the world.
Tests: LauncherSessionRowViewModelTests (16 — every state's wording, in-game
independent of launch mode, the character-select placeholder and its
replacement, probe labelling, stop gating). Full solution 14,370 passed,
0 failed, 0 skipped under the release-gate filter.
Campaign LU slices LU5 and LU6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Setup used to finish by leaving a full progress bar and a status line on
screen, with the same Validate / Cancel bake / Close / Build and install row
underneath. Nothing said "you are done" and nothing said what to press.
The wizard now swaps its whole form for a plain completion panel: "Setup
complete", one sentence saying the content was built and verified, and a single
OK that closes the dialog and returns to the launcher.
Raised at exactly one point — after _onInstalled publishes the record — so the
launcher behind the dialog is already in its launch-enabled state when OK is
pressed, and the "Client setup required" banner is gone the moment the user
gets back. The cancelled and failed branches deliberately never reach it and
keep their existing status/error reporting.
Tests: FirstRunSetupEndsWithACompletionPanelThatOkReturnsFrom (form hidden,
panel shown, record published before OK, wizard reopens as an ordinary form
afterwards) and AFailedFirstRunSetupNeverShowsTheCompletionPanel.
Launcher 61 passed.
Campaign LU slice LU4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The update surface was a panel the user had to reason about: Check again,
Rollback client, Stage launcher, Install client, Cancel, Close, plus an
installed/available version table, a minimum-launcher-version sentence, and a
"restart required" banner they had to act on. Reaching it meant knowing to
press "Check for updates" in the header.
Now: the feed is checked once at startup. If nothing is out of date, nothing
appears. If something is, one dialog says what is new and offers Update or
Not now.
Launcher before client, deliberately. A client release can declare a minimum
launcher version, so updating the launcher first is what makes the client
update installable at all — and it means nobody is ever shown "install
launcher X or newer before the client update", which is not a sentence a
player should have to read.
A launcher update now restarts into the new build by itself. That reuses the
existing, proven handoff rather than inventing a second one: LauncherSelfUpdate
Bootstrap.TryApplyStagedUpdateNowAsync starts the staged payload in helper mode
against the CURRENT process, exactly as ordinary startup does, and the launcher
then shuts down. Restarting by spawning a fresh copy of the current launcher and
letting its startup notice the staged plan would look simpler and be wrong: the
helper would wait on the new copy while the old one still held its own
executable mapped, so the file replacement could fail. The staged-helper launch
is extracted into one private method both paths call, so they cannot drift.
Deleted: the header "Check for updates" button, OpenCommand, CheckCommand,
InstallClientCommand, StageLauncherCommand, RollbackCommand, CloseCommand, the
version table, IsLauncherMinimumBlocked/MinimumLauncherStatus, the restart
banner, and LauncherUpdatePhase plumbing through the view model.
NOT deleted — none of the safety changed: manifest validation, bounded verified
download, safe ZIP extraction, versioned install with an atomic current.json
switch, the update session barrier, and rollback all still live in
AcDream.Launcher.Core/Updates. Rollback simply has no button; it remains
reachable as Core API with its own tests. The complexity the user objected to
was the panel, not the machinery underneath it.
An unreachable feed stays silent. A friend with no internet must still reach
their characters, so a failed startup check shows nothing at all rather than an
error to dismiss.
Tests: LauncherUpdateViewModelTests rewritten against the new surface (8 tests
— nothing-to-do stays silent, client update installs, launcher update stages
then restarts without touching the client, no-restart-seam fallback, silent
offline, Not now, refused while a session runs, failed install reports why).
Tests for the deleted commands are removed with them, not skipped.
Launcher 59 passed, Launcher.Core 335 passed.
Campaign LU slices LU2 and LU3, landed together because the new prompt replaces
the old one in the same files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured on the user's machine: %LOCALAPPDATA%\acdream\pak\acdream.pak is
29,908,271,024 bytes and SHA-256 over it takes 24.1 s at 1.16 GB/s. App
.OnFrameworkInitializationCompleted ran exactly that hash synchronously,
before constructing the window, and the digest came back identical to the one
install.json already recorded. So the launcher took roughly half a minute to
appear in order to re-confirm a fact that had not changed. A friend does not
see it only because they have no package installed yet — verification
short-circuits at "nothing installed" — so it would hit them the moment
first-run setup finished.
Startup now checks the cheap facts (size, last-write time) and skips only the
hash, and only when a previous FULL hash of that same file agreed with the
install record. Everything that should hash still does: install, update, the
crash-recovery backup path, and a new explicit "Verify files" button.
The remembered fact lives 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 property there would make an
older launcher build reject the record outright and demand a fresh ~28 GB bake
after a rollback. An unknown sidecar is simply ignored by builds that predate
it. The cache type never throws — it sits in front of a guarantee, so every
failure mode (missing, corrupt, unknown schema, unwritable) degrades to
"hash it again" rather than to a failed launch.
Two subtleties worth keeping:
- The write time is re-read after the hash and the entry is only written when
it is unchanged. A writer racing a multi-second hash would otherwise be
remembered under the OLD timestamp, and the next startup would trust a
digest that never covered those bytes.
- A hash that disagrees with the record invalidates the entry, so a stale
"verified" fact cannot outlive the evidence that produced it.
Tests: PreparedAssetVerificationCacheTests (10) counts hash invocations through
the store's injectable hasher and covers second-startup skip, forced full
verification, touched package, same-size silent corruption, resize, a cache
digest that disagrees with the record, three unreadable-cache shapes, and
backup recovery still hashing. Plus two LauncherWindowViewModel tests for the
Verify files command. Launcher.Core 335 passed, Launcher 57 passed.
Note for the first run after this ships: the very first startup still pays one
full hash to learn the digest for the installed file, and every startup after
that is instant.
Campaign LU slice LU1. Plan: docs/plans/2026-08-19-launcher-usability-campaign.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MainWindowViewTests.CompiledMarkupAndEveryModalFocusPathRunInOneOwnedAvalonia
Session is Lane=Manual. Measured across five environments on 2026-08-19:
dev desktop PASS
CI Windows box, over SSH PASS
Windows under act_runner FAIL
Linux, plain SSH FAIL
Always the same shape — Test Case Cleanup, 'The calling thread cannot access
this object', while a compositor is being CONSTRUCTED (Compositor..ctor ->
DefaultRenderLoop.Add -> VerifyAccess).
Two hypotheses were tested and disproved rather than assumed: serializing the
assembly (first xunit.runner.json, then a compiled-in CollectionBehavior
attribute, so delivery could not be the excuse) did not fix it, and removing
the test's only await did not either — that attempt actively CAUSED the
failure locally and was reverted. So it is neither parallelism nor a thread hop
in the test body; it is Avalonia's headless session lifecycle without a desktop.
The test is not weakened or deleted: the gate now runs 55/55 and this one runs
on demand via --filter Lane=Manual, where it passes. That matches how the
InstalledDat and Live lanes already work.
MainWindowViewTests kept failing on CI in Test Case Cleanup ('The calling
thread cannot access this object') while passing 56/56 locally. The cause was
delivery, not the fix: xunit.runner.json only takes effect if it is copied
beside the test DLL, and under CI's 'dotnet build' + 'dotnet test --no-build'
split it did not arrive, so CI ran with parallel collections while local runs
did not.
[assembly: CollectionBehavior(DisableTestParallelization = true)] is compiled
into the DLL and cannot fail to deploy. It lives beside the existing
AvaloniaTestApplication/AvaloniaTestIsolation attributes, which document the
same thread-affinity hazard. The json and its csproj copy rule are removed so
there is one source of truth.
Every push to main now runs the gate on the self-hosted runners and, when
green, publishes a Gitea Release carrying the client, launcher+bake, and
manifest.
Pipeline (.gitea/workflows/ci.yml):
- windows-gate runs tools/run-release-gate.ps1, the project's own bounded
gate. A bare `dotnet test AcDream.slnx` is NOT usable as a gate: it fails
~36 tests by design, because the InstalledDat/Live/Manual/OS lanes assert
their own preconditions. The gate script's trait filter is what excludes
them.
- linux-portable runs the portable closure, where the Linux-lane tests
actually execute instead of failing on Windows.
- release depends on both, so a red gate cannot publish. It is a job in the
same workflow rather than a workflow_run trigger, whose Forgejo support is
unreliable; `needs` is guaranteed.
No actions/setup-dotnet: data.forgejo.org does not mirror it at all (404),
and both runners carry the pinned SDK band already. actions/checkout IS
mirrored and is used normally.
Release payloads become release ATTACHMENTS, outside git history, so ~120 MB
per build never enters a branch. Only the ~500-byte manifest.json is
committed, to the payload-free dist branch, because Forgejo has no
/releases/latest/download/ route (verified 404) for the launcher to poll.
publish-bin.ps1 takes -BaseUrl so the manifest points at the release tag.
Two real gate failures fixed:
- LauncherProjectBoundaryTests asserted four `**` path filters belonging to
the push triggers that 8be14d39 removed when workflows went manual-only.
The assertions about what the workflow DOES are untouched.
- MainWindowViewTests failed in Test Case Cleanup with "calling thread cannot
access this object" while passing in isolation: Avalonia's headless session
is thread-affine and xUnit ran collections in parallel. Serialized via
xunit.runner.json, the same settings AcDream.Core.Tests already uses.
Local gate: 12 projects, 14,346 tests, 0 failures.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F1: the crash reporter comment claimed the launcher never holds a password
in any field - false (ProfileEditorDialogViewModel, AccountProfile.Password,
StartRequest.Password). Reworded to the true, narrower invariant (no throw
site interpolates a credential VALUE into an exception message) and pinned
it with CrashReportNeverContainsAStoredPassword: a real STJ failure over a
profiles document containing a known password, corrupted after the
credential, must yield a crash file with the stack and without the value.
F2: the co-deploy Inputs covered only Bake own sources; a Content edit
never refreshed the 83 MB exe. Now the full reference closure. Fixing it
surfaced two more incrementality traps, both fixed and comment-documented:
SkipUnchangedFiles left the output older than the triggering input (target
re-ran forever - added an explicit Touch), and %(Item.Metadata) in a plain
Include does not batch (the literal percent-text became a permanently
out-of-date phantom input - globs are now spelled per project). Verified:
Core edit retriggers, then two consecutive clean incremental builds.
F3: RID publishes ran BOTH co-deploy paths (two self-contained bake
publishes). Build-time target now guarded on _IsPublishing; verified a
real win-x64 publish runs zero build-target co-deploys and still ships
both exes.
F4: comment misattributed PublishBakeTool=false to CI lanes; it is
target-local recursion guarding. F5: the x:Name reflection sweep now walks
the markup as XML and tolerates template-scoped names (no generated field
exists for those). F6: dead using removed. Hardening: the crash reporter
positional --data-dir fallback requires a fully-qualified path so a
relative or flag-shaped value cannot create ./crash-reports at an
arbitrary CWD.
Launcher 67/67, Launcher.Core 317/317.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#398 was a crash on every modal open/close caused by MainWindow's
constructor calling AvaloniaXamlLoader.Load(this) instead of the
generated InitializeComponent() — only InitializeComponent assigns the
x:Name backing fields, so every named control was null and the first
Dispatcher.UIThread.Post callback in OnViewModelPropertyChanged threw
NullReferenceException, killing the process. It reached the user gate
because no test in tests/AcDream.Launcher.Tests (ViewModel-only) ever
constructed a MainWindow. #399 is the process gap that let that class of
defect through 14,012 green tests.
Adds Avalonia.Headless.XUnit 12.1.1 to the launcher test project. Its
net10.0 dependency group targets xunit v3, so the project migrates
xunit 2.9.3 -> xunit.v3 3.2.2 (drop-in: all 54 pre-existing tests compile
and pass unchanged under dotnet test via xunit.runner.visualstudio 3.1.4,
which already supported v1/v2/v3; two call sites needed
TestContext.Current.CancellationToken per the new xUnit1051 analyzer).
TestAppBuilder.cs wires [assembly: AvaloniaTestApplication] to a headless
AppBuilder.Configure<App>() so the real App.axaml FluentTheme is live in
tests.
MainWindowViewTests.cs adds 12 [AvaloniaFact]/[AvaloniaTheory] tests:
- an explicit non-null + type check of every x:Name field the
code-behind dereferences (ProfilesTree, ServerNameTextBox,
AccountNameTextBox, CharacterNameTextBox, EditorSubmitButton,
FirstRunDatDirectoryTextBox, FirstRunCloseButton, UpdateCloseButton)
- a reflection sweep over every x:Name found in MainWindow.axaml, so a
future named control without a matching non-null field fails loudly
- one open+close round trip per ProfileEditorKind (all seven, including
Remove), plus the first-run wizard and the update prompt, each pumping
Dispatcher.UIThread.RunJobs() so the queued focus callback actually
executes instead of just being asserted vacuously
- a dedicated test for the _focusBeforeModal-restore branch (not just
the ProfilesTree.Focus() fallback), anchored on a real focusable
button since ProfilesTree (TreeView) has Focusable="False" under
FluentTheme — its own tab stops are TreeViewItem rows, so the
close-path assertions check "no exception escaped the dispatcher"
rather than "focus landed on ProfilesTree"
Falsification (required evidence): reverting MainWindow's constructor to
AvaloniaXamlLoader.Load(this) and rerunning gives 12 failed / 0 passed —
10 tests throw NullReferenceException at MainWindow.FocusActiveModal,
propagating cleanly out of Dispatcher.UIThread.RunJobs() (confirming
dispatcher exceptions are not silently swallowed), and the 2 reflection
tests fail on an explicit "x:Name 'ProfilesTree' was null after
construction" message. Restoring InitializeComponent() gives 12 passed /
0 failed. Full launcher suite: 66 passed / 0 failed, reproduced on both
Windows and native Ubuntu (WSL, no display/Xvfb — Avalonia.Headless needs
none). AcDream.Launcher.Core.Tests: 317/317 unaffected.
No CI workflow change needed: .github/workflows/headless-portability.yml's
portable-launcher job already runs dotnet test on the launcher test
project on both windows-latest and ubuntu-latest with no display setup,
which is sufficient for Avalonia.Headless.
Closes#399.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>