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>
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>
The dist branch existed to carry ~120 MB payloads that could not go on main.
Once payloads became release attachments it held one 500-byte manifest.json,
so it was a whole branch for a reason that no longer applied.
The pointer is now a release asset too: each publish recreates a one-asset
release naming the versioned build. Forgejo has no
/releases/latest/download/ route (404), so a pointer is still required — but
keeping it in a release means nothing about distribution lives in git: no
payload branch, no bot commits on main, and no push that could retrigger the
pipeline (which is why writing the manifest to main was not the answer either).
Recreating the tag deletes the old release AND its tag; the tag outlives its
release and would otherwise block recreation.
Versioned releases are retained, so older builds stay downloadable.
tools/publish-dist.ps1 is removed — publishing is CI's job now.
The launcher reported "no client available" because its update source was
pinned to a GitHub Releases manifest in a PRIVATE repo — nothing anonymous
could ever be fetched from it. Switch the feed to the PUBLIC Gitea repo so a
friend needs no account, and add the two commands that publish it.
- ReleaseManifestClient.ProductionManifestUri now points at
git.snakedesert.se/erik/acdream raw on the `dist` branch. No update
machinery changed: the existing strict reader already accepts any HTTPS
manifest, so this is a URL swap plus a build script.
- tools/publish-bin.ps1 publishes the payloads into /bin and writes
bin/manifest.json (schema v1, SHA-256 + size per artifact):
client-win-x64.zip AcDream.App + acdream-headless
launcher-win-x64.zip acdream-launcher + co-deployed acdream-bake
Stamps InformationalVersion ONLY — never -p:Version, which also rewrites
project-reference versions inside the committed packages.<rid>.lock.json
files and churned every one of them with a throwaway build stamp.
- tools/publish-dist.ps1 pushes /bin to the Gitea-only `dist` branch from a
throwaway worktree, leaving the developer's checkout, index, and HEAD
untouched. It refuses a GitHub remote outright.
Why `dist` and not main: the launcher payload is ~103 MB because the launcher
and its co-deployed bake CLI are each self-contained single files (deliberate,
see AcDream.Launcher.csproj). GitHub hard-rejects files over 100 MB, and all
three refs currently track main, so payloads on main would break every GitHub
push. `dist` is a single-commit orphan branch that each publish REPLACES, so
superseded builds never accumulate. /bin stays gitignored repo-wide and is
force-added only on that branch.
Verified live: manifest and both payloads serve anonymously over HTTPS, and a
downloaded client payload matches its declared SHA-256 and size byte for byte.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The remaining code-bearing findings from the round review, F4-F16 minus
the doc-only items (batched separately):
- F4: three client-wide UiButton corpus sweeps (LabelBox path — exactly
the 4 Town buttons, confined to chargen; conflicting custom-selection-
pair + standard Normal/Highlight media — zero found, no gate
tightening needed; per-state label-color map — 209 matches beyond
chargen, confirming AP-222's mechanism has always been broadly active
since it shipped generically in DatWidgetFactory).
- F5/F6: LayoutImporter's Batch C un-consumed-children carve-out now
honors a child's own AuthoredInvisible flag (a narrow honor scoped to
exactly that carve-out, not the general #408 client-wide one) — the
chat transcript's new-text indicator (0x1000048C) was building as a
visible phantom element retail never shows; verified both directions
against the gold-frame pieces, which do not author Invisible.
- F7: BoundedProcessOutputCapture.AppendLine combines the line text and
its trailing newline into one buffer and one file open/write/close
instead of two.
- F9: corrected a stale comment in RuntimeSettingsTargets — #407 split
DisplayModeCatalog's Resolutions/WindowedResolutions in two, so the
fullscreen validator's own narrower list is now DELIBERATELY different
from the Config dropdown's fuller offering, not the "must match" bug
the comment described.
- F10: documented (not changed) why the LabelBox path's default 3px
inset and the face-relative +4px gap in DatWidgetFactory.BuildButton
are deliberately different numbers — neither carries a retail
citation, and moving either to match the other would be an unfounded
guess on a button that currently works correctly.
- F11: Heritage/Profession/Summary/Town description pages now compose
DatRichText.Compose's result ONCE inside their already revision-gated
Refresh, caching the built line list instead of re-wrapping on every
draw call.
- F14: documented (not changed) why PrivateEntityViewportRenderer's
_animatedIds set carrying a reserved-but-never-drawn backdrop id is
harmless — BuildDrawEntities already excludes a null/empty backdrop
from the actual draw list, so the id is never looked up.
- F16: the Summary preview now uses its own render-id pair
(SummaryPreviewRenderId/SummaryPreviewBackdropRenderId, 0xDA11D035/
0xDA11D036) instead of sharing the Appearance page's
(0xDA11D032/0xDA11D034) — confirmed by tracing
FixedEntityTextureOwnerLease through TextureCache to
CompositeTextureArrayCache's shared owner tracker that both pages'
previews share ONE process-wide TextureCache, so sharing render ids
was a real cross-page texture-release collision (either page's own
re-dress or disposal could release the OTHER page's still-active
textures), not a theoretical one.
F3's own register bookkeeping (AP-229 addendum) and F12's register/AD
header-count corrections land in the docs-only commit alongside F15.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GameWindow.Dispose() (via Program.cs's `using var window = ...`) runs
unconditionally even when invoked mid-unwind of an exception that escaped
Run()'s Silk.NET frame loop. Resource teardown itself can converge
cleanly regardless, so CompleteShutdown had no way to tell "normal Run()
return" from "a crash is propagating through me right now" and always
wrote the hardcoded exited{code:0,reason:"graceful"} — exactly the
symptom #406 observed against a real 0xE0434352 crash. Fixed by latching
_runFailure in Run()'s existing catch block (before the pre-existing
throw) and consulting it from a new ReportExited method, the one call
site for the terminal status write: crashed(1)/graceful(0)/
shutdown-incomplete(1) as appropriate. No wire-contract amendment needed
— §LA1 pins the exited event NAME, and reason is already free text that
StatusEventParser round-trips unchanged.
Sibling gap fixed in the same commit: the launcher discarded the child's
stdout/stderr entirely, which is why diagnosing this exact crash required
a manual console re-run. Added BoundedProcessOutputCapture, a 2 MiB-capped
sink mirroring SessionStatusWriter's open-append-flush-close-per-write
posture (a long-lived write handle is not actually concurrently readable
on Windows even with FileShare.Read — confirmed by isolated repro), wired
into both SystemChildProcess (ProcessStartInfo.RedirectStandardError;
Linux + Windows graphical children, i.e. this bug's own scenario) and
WindowsSystemChildProcess (a real native pipe via CreateChildOutputPipe,
mirroring the existing stdin pipe; Windows console-capable/Headless
children). Opt-in via LauncherProcessSpec.StderrLogPath (null = unchanged
behavior), threaded through SessionConfigComposer -> client.err.log
beside status.jsonl -> LauncherExecutableSet -> LauncherOrchestrator.
Tests: GameWindowCrashStatusTests (source-shape, matching the existing
GameWindow test pattern — the class cannot be constructed without a live
GPU/window), BoundedProcessOutputCaptureTests (10 unit tests), and three
new LauncherProcessSupervisorTests spawning real child processes through
both capture code paths.
Launcher.Core.Tests: 337/0 (was 324/0). Launcher.Tests: 67/0 (unchanged).
Full solution build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F1 (MEDIUM): the correlation-latch docs claimed replies are never
misattributed; in truth an overlapping send OVERWRITES the latch and the
first reply routes to the newest request's event. Narrowed all three doc
sites to the exact contract (single outstanding request; overlap refusal
is CC3's Runtime verification gate, retail's DoFinish UNDEF-state rule)
and pinned the overwrite behavior with
OverlappingSend_OverwritesTheLatch_ReplyRoutesToNewestRequest.
F2 (LOW): filed register AD-100 for the drop-unless-armed deviation —
retail's Handle_CharGenVerificationResponse@0x0055E8B0 has no armed gate
and processes whatever arrives against its persistent verification state.
F3 (LOW): doc note in CharacterCreate.cs — ACE double-sends NameInUse
(IsCharacterNameAvailable runs twice; the first callback's return exits
only the lambda), so the second reply hitting the drop path during a
connected gate is EXPECTED, not a defect.
F4 (LOW): creationFailed's enum-member key renamed name -> reason and the
ATTEMPTED character name added as name, before any consumer shipped —
one status vocabulary must not give the same key two meanings
(characterCreated.name is a character name). Contract, writer, tailer,
and shape-pinning tests updated in lockstep.
F5 (LOW): the thread-id probe-note pointer now cites
ProbeNetLogOutbound's doc comment, where the note actually lives.
Fidelity fold (reviewer's positive note): the latch is retail's OWN
discriminator one layer down — 0x0055E8B0 case 1 branches on
GetVerificationState()==PENDING (create) vs not (restore) — now cited in
both the latch doc and CharGenVerificationResponse.cs.
Core.Net 994, Runtime 1667, Launcher.Core 324, all green Release.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire (Core.Net):
- CharacterCreate.cs: outbound 0xF656 builder, byte-exact port of
Proto_UI::SendCharGenResult@0x00546a70 -> ACCharGenResult::Pack@0x005c7570
-> CG_Pack@0x005c7200. Account String16L first (packed outside CG_Pack),
then the constant-1 u32, heritage/gender, 14 appearance strip/style/color
u32s, 6 f64 shades (skin/hair/headgear/shirt/trousers/footwear, retail
order), template, 6 attributes, slot, classId, numSkills + exactly 55
u32 skill-advancement classes (ReadOnlySpan validated ==55, throws
ArgumentException otherwise — ACE terminates the session on any other
count via PlayerFactory.CreateResult.ClientServerSkillsMismatch), name
String16L, startArea, isAdmin, isEnvoy, and a trailing checksum whose
exact 19-term accumulation set (heritage+gender+3 strips+hairColor+
eyeColor+hairStyle+headgearStyle+shirtStyle+trousersStyle+footwearStyle+
template+6 attributes) is read byte-for-byte off CG_Pack's decompiled
accumulator (0x005c7213-0x005c74c3) — headgearColor/shirtColor/
trousersColor/footwearColor/shades/slot/classId are deliberately absent
from the sum despite sitting adjacent on the wire. Cross-checked against
ACE's CharacterCreateInfo.Unpack/Appearance.Unpack and holtburger's
CharacterCreateRequestData (types.rs:236-369), which agree on every
field and order. Retail routes via SendToLogon — the same queue
CharacterDelete already uses.
- CharGenVerificationResponse.cs (new): promotes the shared 0xF643 parse
out of CharacterRestore — full Code enum (Undef..AdminPrivilegeDenied=7,
ACE's CharacterGenerationVerificationResponse) plus the conditional
Ok-only identity payload (guid/String16L name/u32 secondsGreyedOut).
CharacterRestore.Parse now delegates to it; CharacterRestore's public
Parsed shape, Parse signature, and every existing test expectation are
UNCHANGED.
- PacketWriter.WriteDouble: f64 little-endian helper for the shade fields.
WorldSession dispatch (Core.Net):
- Added an awaiting-request latch (None/Restore/Create), armed by
SendRestoreCharacter/the new SendCharacterCreation immediately before
each send (SendCharacterCreation builds the body first so a skill-count
throw never arms the latch for a request that was never sent), cleared
the instant a matching 0xF643 is dispatched (success OR parse failure —
a malformed reply must never wedge the latch open) and on Dispose.
0xF643 now routes to CharacterRestoreReceived or the new
CharacterCreateResponseReceived (Action<CharGenVerificationResponse.Parsed>)
by that latch; an unexpected 0xF643 with nothing outstanding logs once
and is dropped, never misattributed. Fixed
WorldSessionCharacterSelectionTests' restore-dispatch test, which
previously fed a bare CharacterRestore response with no preceding
SendRestoreCharacter — that shape is now the "no outstanding request"
drop path by design.
Status events (Runtime + Launcher.Core, contract first):
- Amended docs/plans/2026-08-14-launcher-campaign.md §LA1's pinned status
vocabulary to add characterCreated{guid,name} (Ok reply identity, named
to mirror CharGenVerificationResponse's own fields and to read distinct
from enteredWorld — retail logs a freshly created character straight in
without a fresh characterList) and creationFailed{code,name} (raw Code
value + its enum member name).
- SessionStatusWriter.CharacterCreated/CreationFailed implement that
contract.
- Launcher.Core: CharacterCreatedStatusEvent/CreationFailedStatusEvent +
StatusEventParser cases, in lockstep.
Tests: CharacterCreateTests (byte-exact layout incl. checksum term-set,
55-slot fixture, wrong-count throws), CharGenVerificationResponseTests
(every Code value), WorldSessionCharacterCreationTests (create-then-
response routes correctly, restore unaffected, no-outstanding drop,
second-response-after-consumed drop, Dispose clears the latch, a builder
throw never arms it), SessionStatusWriterTests + Launcher.Core
StatusEventParserTests/StatusFileTailerTests (pinned shape + tailer
round-trip) for the two new events.
Verified: dotnet build AcDream.slnx -c Release — 0 errors. Full solution
test run green (Core.Net.Tests 993/993, Runtime.Tests 1667/1667,
Launcher.Core.Tests 323/323, plus every other project in the solution).
WSL Ubuntu: Core.Net.Tests 993/993, Runtime.Tests 1667/1667.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opus review of LA3 returned FIX FIRST; this addresses every finding in
scope (F1-F5, F7-F12; F6 CI-lane addition excluded per instructions):
- F1 (CRITICAL): SessionProcessSettings.Paths is now nullable and left
null by SessionConfigComposer unless a caller supplies overrides, so
the JSON key is entirely absent instead of "paths":{} — the App-side
loader's strict UnmappedMemberHandling.Disallow would otherwise reject
every gui/guiSelect session-config document at load.
- F2: added SessionConfigComposer.ComposeProbe and a nullable
SessionDescriptor.Mode field ("probe", omitted for normal play) per
the pinned contract — no character/policy/plugins/loginCommands.
- F3: LauncherProcessSupervisor.Stop now tries
ILauncherChildProcess.TryRequestGracefulStop (Linux: libc SIGINT via
LibraryImport, K4-proven graceful headless logout) before
CloseMainWindow. Windows has no reliable no-window-console equivalent
today; filed docs/ISSUES.md #397 with the CREATE_NEW_PROCESS_GROUP +
CTRL_BREAK fix direction. Stop()'s blocking-timeout contract is now
documented for LA4.
- F4: LauncherProfileStore.Save chmods the Linux temp file to 0600
immediately after creation, before any credential is serialized;
failure paths and Load() clean up a stale .tmp.
- F5: added LauncherCoreDependencyBoundaryTests asserting Launcher.Core
references exactly AcDream.Platform and no packages.
- F7: StatusEventParser.Parse no longer throws on a whitespace/null
line; StatusFileTailer.ReadNewEvents swallows the File.Exists/open
TOCTOU window (FileNotFoundException/DirectoryNotFoundException/
IOException) instead of throwing.
- F8: Start() now kills (entire process tree) and disposes a child that
started successfully but failed while being fed its stdin password,
instead of orphaning it.
- F9: SetState is monotonic — once Exited, no later transition applies
or fires StateChanged, closing a Start()-path race where a
synchronously-exiting child could be "resurrected" to Running.
- F10: CharacterIdFormat.TryParse now requires the "0x" prefix (an
unprefixed hand-typed decimal id is also valid hex and was silently
misread); a parsed id of 0 is treated as unusable and falls back to
the name selector; LauncherProfileStore.MergeRoster normalizes both
sides through TryParse/ToHexString instead of raw string equality, so
a legacy unprefixed-hex row self-heals via name match instead of
duplicating.
- F11: StatusCharacterEntry.SecondsGreyedOut is now uint, matching
CharacterRosterEntry and the host writer.
- F12: added MalformedStatusEvent, returned for a recognized `e` whose
payload doesn't match its shape, distinguished from UnknownStatusEvent
(an unrecognized `e`).
AllowUnsafeBlocks was added to AcDream.Launcher.Core.csproj — required
by the LibraryImport source generator's function-pointer marshalling
stub for F3's Linux SIGINT P/Invoke.
Verification: dotnet build AcDream.slnx -c Release green (0 errors);
dotnet test tests/AcDream.Launcher.Core.Tests -c Release green at 94/94
on native Windows and under WSL (Ubuntu, verified across multiple runs
for the timing-sensitive SIGINT/sharing-violation tests, no flakes
observed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New AcDream.Launcher.Core (BCL-only, ProjectReference: AcDream.Platform
ONLY) plus tests/AcDream.Launcher.Core.Tests, both registered in
AcDream.slnx. This is the file-contract orchestrator core the Avalonia
launcher (LA4) will bind to — the game solution (Core/Runtime/App/
Headless) stays entirely out of this dependency graph, so the launcher
can never accidentally grow a game-protocol coupling.
- Profiles/: LauncherProfileStore owns launcher-profiles.json (spec §5
schema: version 1, servers[]/accounts[]/characters[]), strict
camelCase System.Text.Json (UnmappedMemberHandling.Disallow), typed
CRUD (add/edit/remove server; add/edit/remove account; edit character
settings), and MergeRoster (fold a reported roster into an account's
characters[] while preserving user-owned launchMode/plugins/
loginCommands, adding new rows with default guiSelect, and retaining
rows absent from the roster — they may be pending-delete). 0600 on
Linux via File.SetUnixFileMode after save.
- Launching/: SessionConfigComposer builds the pinned session-config
contract (Headless K1 shape + plugins/loginCommands/
loginCommandDelayMs/statusFile) from a profile character + install
record — character selector omitted entirely for guiSelect, policy
{id:"idle"} only for headless, credential always standardInput/
session. Passwords never enter this document (proven by a dedicated
test). LauncherProcessSupervisor spawns a host, feeds the password to
stdin then closes it, and exposes Starting/Running/Exited lifecycle;
Stop calls CloseMainWindow falling back to Kill after a timeout, both
reachable through an injectable ILauncherChildProcess/factory seam so
the state machine is unit-testable without real OS process timing.
- Status/: StatusEventParser decodes the v1 status.jsonl vocabulary
(started/connected/characterList/enteredWorld/pluginLoaded/
pluginFailed/disconnected/exited); an unrecognized "e" or a malformed
line degrades to a typed Unknown event rather than throwing.
StatusFileTailer incrementally reads new lines, tolerating a
not-yet-existing file and a partial trailing line (only advances its
read position past confirmed '\n' boundaries; a truncated tail is
simply re-read next poll, never parsed early).
- Integrity/: streaming SHA-256 + hex verify for later pak/download
checks (LA9/LA10).
Tests: 71 passed (profile CRUD + roster-merge matrix + strict-schema
rejection; composer golden-shape tests for gui/guiSelect/headless +
password-absence; supervisor tests against both an injected fake child
(state-machine determinism) and a real spawned `dotnet --version`
child (genuine cross-platform stdin/exit-code proof); tailer tests
incl. partial-line and not-yet-existing-file; SHA-256 tests). Verified
green on Windows (Release) and native WSL/Linux (Release) — the Linux
0600 test executes its real assertion body under WSL rather than
early-returning.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>