Commit graph

23 commits

Author SHA1 Message Date
Erik
cd6eefd0ba feat(plugins): enforce apiVersion; launcher plugins default ON with "none" opt-out
Two gaps from the MossTank shipment review.

**apiVersion was declared in every manifest and checked by nothing.** The
loader now refuses an unsupported contract BEFORE loading any code from the
plugin — checking after the fact is not equivalent, because by then the
assembly is in a collectible context and the mismatch surfaces as a type-load
or missing-member failure from inside the plugin, which reads like the plugin
is broken rather than built for a different host. PluginApi (Current /
MinimumSupported) lives in Plugin.Abstractions beside the contract it
versions, and the refusal is a distinct PluginApiVersionException so callers
can tell "update the client or the plugin" from "this plugin is broken". The
tests pin the ordering too: a manifest with a future apiVersion AND a missing
dll must fail on the version, a supported one on the dll.

**A launcher-launched client loaded no plugins until the user typed ids.**
LA5 distinguishes an omitted allow-list (load all) from an explicit empty one
(load none); a fresh character profile's list is empty, so it composed to
load-none. Direct launches pass null and load everything -- which is why the
gap never showed in development: the two launch paths disagreed and the
launcher was the one users get. This REVERSES the LA5 default deliberately:
"nothing configured" now composes to the omitted list, so plugins are on by
default, including ones installed later. The opt-out is kept -- losing it
would be a real regression for stripped sessions -- respelled as the literal
id "none", and the launcher's plugin box says so.

The cross-host shared fixture composes its explicit-load-none case through
the new spelling, keeping the reader-side contract tests (App and Headless
both preserve an explicit empty list) exactly as they were.

Complete Release suite: 14,469 tests pass on the standard hermetic lane
filter, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:28:04 +02:00
Erik
a1ffe77af4 feat: mosswart client icon and Asheron's Call-inspired launcher icon
acdream had no application icon on either executable. Two marks now ship,
built from the game's own material rather than drawn freehand:

* Client - the retail mosswart head. Not an illustration of one: the actual
  creature mesh (Setup 0x02000B4F part 14, skin atlas 0x05001E11,
  ClothingBase 0x10000344) read out of client_portal.dat through acdream's
  own GfxObjMesh/SetupMesh port, then smoothed, lit and graded. Palette
  values are sampled from that texture, including the mustard belly the
  Mosswart lore calls a "foul yellow".

* Launcher - a forged ring enclosing a barbed crescent, rebuilt from
  measurements of the retail wordmark and the acclient.exe icon resource.
  An original construction in the same visual language, not a copy of the
  trademarked logo. Its warm field matches the retail client icon.

Three techniques carry the render quality, all in tools/IconForge:

* PN-triangle tessellation (smooth.py). The retail head is 104 triangles
  and renders faceted. Each triangle becomes a cubic Bezier patch built
  from its own corner positions and normals, so the silhouette genuinely
  rounds rather than merely shading smoothly - and it needs no mesh
  connectivity, which matters because UV seams would otherwise pull apart.
  Normals are welded across coincident positions first, but only within a
  crease angle, so ear fins and tusk edges stay sharp.

* Matcaps (ring.py). A Lambert rasterizer cannot produce chrome, because
  chrome is almost entirely reflection and there is nothing here to
  reflect. Sampling a lit-sphere image by the camera-space normal is the
  standard stand-in for an environment map.

* Distance-transform bevelling (chisel.py). Flat shapes become chiselled
  metal by treating distance-to-edge as height. The height field is
  blurred before differentiating; without that the medial axis of each
  stroke shows through as a hatched ridge.

Two facts worth recording, both discovered the hard way. Creature Setups
define no upright pose in PlacementFrames, so the exporter must be handed
the weenie's MotionTable id or all 17 parts stack on the origin. And a
mosswart's eyes sit on the sides of the skull like a frog's, so a dead-on
frontal turns them edge-on and the face stops reading as a mosswart at all;
the hero angle is az 266 / el 32.

Wiring: <ApplicationIcon> gives each executable its PE icon. The client's
runtime window icon is embedded rather than copied beside the binary - a
window icon has no sensible fallback if the file goes missing, and
embedding survives single-file publish. WindowIconLoaderTests guards the
resource names, which are coupled to LogicalName in the csproj by string
alone and would otherwise fail only as a silently icon-less window.

Both halves of the pipeline are deterministic and reproduce the committed
PNGs byte-for-byte, so an accidental edit shows up as a diff.

Solution builds clean; 14,378 tests pass on the standard hermetic lane
filter, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:42:10 +02:00
Erik
6ab5d8ce0f feat(launcher): LU9/LU10 — stop logs out for real, sessions read plainly, logout lands on character select
Some checks failed
CI / linux-portable (push) Failing after 2m23s
CI / windows-gate (push) Successful in 5m30s
CI / release (push) Has been skipped
Five things from the user's gate.

STOP NOW ACTUALLY LOGS OUT. The UI gave the client five seconds and then
killed it. That is not enough for a graphical client to send its logout, wait
for the server to acknowledge, and tear down a mapped 28 GB world — so Stop
routinely ended in a kill, which sends the server nothing, which is exactly
what leaves the account held. Thirty seconds now, with the kill still there as
a genuine last resort, and the status says "Logging out…".

THE SERVER-SIDE HOLD IS MODELLED INSTEAD OF DISCOVERED. A session that ends
without the host running its own teardown may leave the account logged in
server-side for minutes. Launching again inside that window does not queue or
retry — it fails with a bare "CharacterList not received", which reads as a
broken launcher rather than a busy server. The orchestrator now records whether
each session ended gracefully (the host reported its own exit AND exited zero —
a killed or crashed child can satisfy neither) and refuses that account for
three minutes afterwards, saying how many seconds are left. A graceful exit
never starts a hold.

READABLE TERMINAL TEXT. "Exited: connection-error (code 5)" becomes "Could not
reach the server — the server may hold this account for a few minutes";
"Exited: process-exit (code 0)" becomes "Exited gracefully — logged out
cleanly". Live sessions still show the host's own status line, which is the
most informative thing available while one is running.

COLUMN HEADERS on the sessions list — ACCOUNT / CHARACTER / STATUS / DETAIL,
sharing the row template's widths so they stay aligned.

LOGOUT LANDS ON THE CHARACTER SCREEN (LU10). The toolbar X was already wired
correctly: IndicatorBarController's EndCharacterSessionButtonId 0x100000FA runs
retail's EndCharacterSession, and LiveSessionController's logout transaction
already ends by resetting the world generation and calling
CharacterSelectionState.Begin. What was missing is where that lands: the
retained UI built its character-selection and character-creation bindings only
when NO character selector was supplied, so a launcher-started session logged
out into a client with no screen to return to. The selector decides how a
session STARTS; it must not decide whether the select screen EXISTS. Both
binding sets are now unconditional.

The composition test that pinned the old gate is updated to pin the new
contract — the retained UI must not branch on the selector at all — rather than
being deleted.

App 5380 passed, Launcher.Core 336, Launcher 76, Headless 169. Not pushed; the
user is testing locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:27:26 +02:00
Erik
18bbd37779 feat(launcher): LU8 — logging in IS the character refresh; put Play above the fold
Two things the user hit while gating LU7.

1. "Refresh characters" disconnected the session they were playing. It opened a
   SECOND connection to the same account purely to read the roster, which the
   server treats as a new login — so refreshing while logged in kicked them
   out. It was also redundant the whole time: every ordinary login already
   carries the roster in the host's own status stream, and the orchestrator
   already folds it into the profiles (ApplyRosterLocked runs for play sessions,
   not just probes). Removed, along with "Add cached character", which existed
   to paper over a roster the launcher can now always obtain by itself. The
   account page says what happens instead: characters appear after you log in.

   ProbeAsync stays in Core — headless bots and the CLI use it, and it has its
   own tests. What is gone is offering it to a player as a button whose only
   effect they could observe was being disconnected.

   AnOrdinaryLoginFoldsTheReportedRosterIntoTheStore pins the replacement,
   including that it persists so the tree is still populated after a restart.

2. "There is no headless or gui option" under a selected character. The buttons
   were there — below the fold. The character page led with a plugins/login-
   commands form whose two 96px text boxes pushed the Launch card past the
   bottom of the scroll area, so the primary action was invisible unless you
   scrolled. Launch now comes first and the settings form sits under it. A
   player should never have to scroll to find Play.

Full solution 14,374 passed, 0 failed under the release-gate filter.

Not pushed — the user is testing locally first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:48:15 +02:00
Erik
2bff44a9fa fix: launcher-started headless sessions and character refresh never ran at all
The launcher spawned the headless host as

    acdream-headless --config <path>

but HeadlessCommandLine.Parse reads arguments[0] as the COMMAND and accepts
only "validate" or "run". So every launcher-started headless session and every
"Refresh characters" died on its first instruction with

    Invalid command. Run --help for usage.        (exit 64)

The user's own cache shows it six times over two days. It was invisible because
the failure is an exit code in a status file, not something the UI says out
loud — which is how it survived a whole campaign whose gates exercised the
headless host through its CLI directly, never through the launcher's spec.

The graphical host takes a bare "--session-config" and has no command word;
this sibling call was written to match it. Both headless call sites now pass
"run" first. A probe is an ordinary "run" whose session config carries
mode: "probe" — the difference is in the document, not the command line, so
one fix repairs refresh and headless play together.

LauncherHeadlessCommandLineContractTests is the connection that was missing:
it takes the argument vector the launcher will really use and hands it to the
parser the host will really use, for probe and for headless play, and pins that
the graphical arguments are deliberately NOT a headless command line. The two
sides cannot drift again without failing here. Headless.Tests already
referenced both assemblies, so this needed no new coupling.

Also LU7, at the user's direction: a selected character now offers only Play
and Headless. Choosing a character means choosing to play AS that character, so
"Character select" — which deliberately picks no character — belongs to the
account page alone, where it already lives. The per-character GuiSelect command
and its capability are removed rather than left as dead surface.

Full solution 14,374 passed, 0 failed under the release-gate filter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:33:51 +02:00
Erik
955c618013 fix: make locale-independence real, not assumed — parsing, casing, comparison
All checks were successful
CI / linux-portable (push) Successful in 3m46s
CI / windows-gate (push) Successful in 5m5s
CI / release (push) Successful in 1m56s
Follow-up to the retail-text fix. "Green under sv-SE" is not the same as "runs
on any locale", so this establishes the latter by running the suite under
cultures chosen to break different things, and fixing what they broke.

ar-SA found a genuine defect the Swedish runner cannot see: the resolution
parser read "1920x-1" through the ambient culture, and ar-SA's negative sign is
not ASCII '-', so the parse failed and the height silently became 0 instead of
-1. Both copies of that parser (App settings targets and the UI settings store)
now parse invariantly.

Audited every remaining culture-sensitive operation in src/ rather than fixing
only what a test happened to catch:

- Numeric Parse/TryParse with no IFormatProvider: 11 sites, all reading
  MACHINE-readable input — env vars (ACDREAM_LIGHT_DEBUG, ACDREAM_NET_DROP_*,
  streaming/quality knobs), CLI arguments, "1920x1080" settings keys, a chat
  command's price argument, and the launcher's bake thread count, which is
  handed straight to a child process command line. All pinned to
  InvariantCulture.
- ToUpper()/ToLower() with no culture: none. The Turkish-I class was already
  clean, and tr-TR confirms it.
- StartsWith/EndsWith/IndexOf(string) with no StringComparison: one —
  ChatInputParser's "@" prefix test, which is a culture-sensitive comparison
  for a single ASCII character. Now the ordinal char overload.

Verified: 13,958 tests pass identically under the machine default, sv-SE,
tr-TR, ar-SA, and de-DE. (The two launcher test assemblies are excluded from
this run only because a running acdream-launcher.exe holds its own binary; the
one launcher change here is the thread-count parse.)

Dates remain on the current culture by intent, unchanged from the previous
commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:11:32 +02:00
Erik
09305be6c6 feat(launcher): LU5/LU6 — one Play button per character, and sessions say who is playing
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>
2026-08-19 18:54:37 +02:00
Erik
0a2defb618 feat(launcher): LU4 — first-run setup ends with "Setup complete" and an OK button
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>
2026-08-19 18:51:08 +02:00
Erik
a01ff42640 feat(launcher): LU2/LU3 — one update question at startup, and it restarts itself
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>
2026-08-19 18:49:19 +02:00
Erik
00d1278228 feat(launcher): LU1 — stop hashing 28 GB before the launcher window appears
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>
2026-08-19 18:40:34 +02:00
Erik
c38f6b8852 build: make release restore reproducible 2026-08-18 10:29:00 +02:00
Erik
981e168fb9 fix(launcher): Campaign LA gate-round-1 review findings F1-F6 + hardening
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>
2026-08-15 08:19:23 +02:00
Erik
e1e94697b2 fix(launcher): co-deploy acdream-bake on Build and write a crash report (#398)
Two gaps found launching the launcher for the LA11 gate.

1. acdream-bake was co-deployed only AfterTargets=Publish with a RID, so a
   plain `dotnet build` left the launcher with no bake tool beside it while
   App.OnFrameworkInitializationCompleted resolves it at
   AppContext.BaseDirectory/acdream-bake[.exe]. A developer-built launcher
   therefore reached the first-run wizard with an installer it could never
   run. CoDeployBakeToolToBuildOutput does for Build what the publish target
   does for Publish: still NO Launcher -> Bake project reference, still a
   self-contained single file so exactly one file lands beside the launcher
   rather than scattering Content/Chorizite assemblies into its output.
   Staged through obj/ because publishing straight into the launcher output
   makes the inner publish delete what the outer build just wrote.
   Inputs/Outputs keep it incremental - verified: 79.6 MB bake exe present,
   --help exits 0, and a second build skips the republish in ~1 s.

2. #398: the top-level guard printed only ex.Message, so the crash that
   preceded this commit surfaced with no file, line, or frame. The full
   exception now goes to a crash-reports file under the resolved data root
   and stderr names the path. The first implementation wrote to the machine
   real data root when option parsing itself failed, which broke LA11 process
   local roots during an isolated run; the reporter now reads --data-dir
   positionally for that fallback. Verified: report lands inside the isolated
   root and the real root stays empty.

The redaction comment states exactly what is guaranteed - args/environment
are never serialized, while exception text may quote an option name or path,
which is safe only because credentials never enter launcher state.

Launcher.Core 317/317 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 07:42:29 +02:00
Erik
d54b8a789e fix(launcher): Campaign LA — MainWindow must call InitializeComponent, not AvaloniaXamlLoader.Load
Every click in the launcher exited the process. MainWindow ctor called
AvaloniaXamlLoader.Load(this), which loads the XAML tree but never assigns
the generated x:Name backing fields, so ProfilesTree, ServerNameTextBox,
AccountNameTextBox, CharacterNameTextBox, EditorSubmitButton,
FirstRunDatDirectoryTextBox and UpdateCloseButton were all null. Opening or
closing any modal calls Focus() on one of them via
OnViewModelPropertyChanged, so the NullReferenceException escaped the
dispatcher and Program's top-level guard exited 74. A fresh isolated-root
start auto-opens the first-run wizard and hit the same line with no click
at all.

App.axaml.cs keeps AvaloniaXamlLoader.Load - that is the correct idiom for
Application.Initialize(), which has no named controls.

Verified live: the isolated-root launch that died instantly now stays up
with the first-run wizard open and the window responding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 07:31:34 +02:00
Erik
accd01a008 fix(launcher): harden Campaign LA11 gate evidence 2026-08-15 01:08:41 +02:00
Erik
134edabed2 feat(launcher): complete Campaign LA11 pre-gate support 2026-08-15 00:02:04 +02:00
Erik
f881e5b467 feat(launcher): prepare Campaign LA11 user gate 2026-08-14 23:42:30 +02:00
Erik
1955ca8ab5 fix(launcher): harden updater crash recovery 2026-08-14 23:12:15 +02:00
Erik
2d2a5b5046 feat(launcher): implement verified atomic updates 2026-08-14 22:09:34 +02:00
Erik
3f68895120 fix(launcher): harden installer transactions 2026-08-14 20:36:11 +02:00
Erik
ff6ebb6a6a feat(launcher): add verified first-run installer 2026-08-14 20:06:37 +02:00
Erik
10a712d66b fix(launcher): close LA4 review findings 2026-08-14 19:02:20 +02:00
Erik
d0a9c65d85 feat(launcher): Campaign LA add Avalonia desktop shell 2026-08-14 18:15:14 +02:00