Commit graph

3960 commits

Author SHA1 Message Date
Erik
7e75be23d1 fix(ui): effects list stayed pinned at its authored height in a taller window
Some checks failed
CI / linux-portable (push) Failing after 3m15s
CI / windows-gate (push) Successful in 6m19s
CI / release (push) Has been skipped
The "Beneficial Spells in Effect" window rendered its rows only in the top
249px and painted the rest of the list as empty black background, with a
scrollbar thumb sized for a viewport far smaller than the visible one. It did
not depend on window size, and the last visible row was sliced mid-height --
a clip boundary, not a missing row.

Root cause is the #412 class again. The authored list element (0x10000123) is
a UiTemplateListBox, not a UiItemList, so EffectsUiController creates the item
list itself and attaches it as a child with fill anchors. That baseline is
captured lazily on the child's first ApplyAnchor -- which lands AFTER the host
has already been resized to the restored window height in the same frame. The
capture then measures a bottom margin of (hostH - 249) and ComputeAnchoredRect
preserves it forever: h = hostH - (hostH - 249) = 249, at every subsequent
size. Rows past 249px fail LayoutCells' cull test and never draw.

Capturing the baseline at creation, while the list's extent still exactly
equals the host's, makes the margins (0,0,0,0) so it tracks the host from then
on. Identical fix and reason to UiTemplateListBox's own viewport seed. The
spellbook's component list is built by the same pattern and had the same
latent defect; it is fixed alongside.

Why it shipped: every existing test in EffectsUiControllerTests supplies a
synthetic UiItemList as the list element, so `host is UiItemList` is true and
the controller uses it directly -- the create-and-attach branch that actually
runs against real dat was never exercised. The new test binds the real
fixture, which builds the real UiTemplateListBox. Neutralising the fix makes
it fail with the exact production numbers (expected 547, actual 249).

Measured, not guessed. tools/LayoutDump grew --resize, which reproduces
retail's raw-edge policy (UIElement::UpdateForParentSizeChange @ 0x00462640)
offline, and it ruled out the authored geometry, the import, the layout policy
and the window frame in turn -- all four are faithful. The 4px gap between the
scrollbar and the window's inner edge is likewise authored: the user confirmed
retail shows the same gap, so it is deliberately left alone.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:00:16 +02:00
Erik
6db0d69816 tools(LayoutDump): print the BUILT widget tree beside the authored one
"This control is in the wrong place" has exactly two possible causes: the dat
authored it there, or our importer moved it. Printing only the authored tree
answers half the question.

--built runs LayoutImporter.Build over the same ElementInfo and prints the
resulting widget geometry underneath, so the two can be compared directly. On
the effects window they match exactly, which is how the "misaligned scrollbar"
report was ruled out as an import bug rather than assumed to be one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:46:34 +02:00
Erik
46ce6f238c feat: regen buffs, wand aura, spellbook assess, indicator press flash
Four reports from one gate round. Three were mine; the fourth I first
mis-explained, and the correction is the useful part.

**The vital regeneration rates were never cast.** Regeneration (health),
Rejuvenation (stamina) and Mana Renewal (mana) all landed in the catch-all
Other bucket, which is off by default. Retail words each of the three
differently and two of the six phrasings do not begin with "Increases the
caster's" at all:

    Increase caster's natural healing rate by 10%.                 <- and note "Increase"
    Increases your Health Regeneration Rate by 50%.                (Empyrean)
    Increases the rate at which the caster regains Stamina by 10%.
    Increases the caster's natural mana rate by 10%.

They are matched per vital, on by default, and ranked at the very tail of the
Life group so they finish the pass. The mana line had to be checked BEFORE the
generic "Increases the caster's X by N" match, which would otherwise read it as
a buff to a stat named "natural mana rate".

**Aura of Hermetic Link was the sixth aura line and the only one missed.**
"a magic casting implement's" is reached by none of the other alternatives, so
the wand's mana-conversion buff was silently in Other too.

**Right-clicking a spell in the spellbook did nothing.** I claimed this had
never worked; the user said it used to, and they were right -- I had checked
one file's history and concluded from it. The regression is 3e31b0ac, which
gave UiCatalogSlot its own RightClick case returning true unconditionally. On
any list that had not wired the examine seam -- the spellbook among them -- the
event was reported handled and UiRoot stopped bubbling. Two fixes: the row now
reports an unwired right-click UNHANDLED so bubbling continues, and the
spellbook wires the seam to the same appraisal window the spell bar uses.

Retail does this generically in the list rather than per window
(UIElement_ItemList::ListenToElementMessage @ 0x004E4F1F -> ExamineSpell
@ 0x00564A70), which is exactly why a per-controller seam could be forgotten
for one window and not another.

**No green flash when pressing an indicator.** Every indicator button authors
a full-size 0x100000F2 child whose DirectState is a draw-nothing File=0 image
and whose only other state, Normal_pressed, carries the green selector sprite
0x06004CE8 -- and the buttons author Normal_pressed with PassToChildren. But
UiButton.ConsumesDatChildren drops dat children at import, so the cascade had
nothing left to reach. The child is re-attached through the same repair the map
hotspot's rollover highlight already uses.

**tools/LayoutDump** is new, and is why the last two are diagnoses rather than
guesses: it prints an authored LayoutDesc tree -- geometry, edge modes, state
sets, PassToChildren, per-state media -- straight from the installed DATs.
"Does this button even have a pressed state?" was being answered by reading our
own importer and inferring; now it is read from the data.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:38:28 +02:00
Erik
fe1f124cd8 feat(mosstank): cast buffs in retail's dependency order
MossTank was casting cheapest-first. That is a reasonable rule for surviving
a mana shortfall and a bad one for everything else, because AC's buff order is
not a preference -- each group raises the skill the next group is cast with:

  1. Creature Enchantment, and inside it:
       - the Creature Enchantment skill itself, which every remaining creature
         buff is then cast with;
       - Focus, then Willpower, then Endurance -- Focus and Self are the
         attributes the Item Enchantment and Life Magic skills derive from, so
         raising them raises the skill groups 2 and 3 are cast with;
       - the rest of the creature spells.
  2. Item Enchantment -- the banes and weapon auras.
  3. Life Magic last -- the protections and Armor Self.

Casting out of that order means casting at a lower skill than the character
could have had, which shows up as fizzles.

Grouping is by the spell's school rather than by name or category, so
protections land in group 3 because retail files them under Life Magic, not
because anything here says "protections go last".

Willpower is matched as "Self": retail's spell is named Willpower but its
description reads "Increases the caster's Self by 10 points", and MossTank
classifies from the description, so the name it matches is the one retail
actually writes. Verified against the spell table rather than assumed
(0x05A5 Willpower Self I).

Cheapest-first survives as the tiebreak inside a group, so a mana shortfall
still costs the cheapest of the last group instead of something the rest of
the pass depended on.

The two ordering tests were confirmed to fail when CastRank is neutralised --
the third guards the tiebreak and passes either way, by design.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:07:31 +02:00
Erik
81e6a48603 feat(mosstank): banes, protections and weapon auras
Three whole categories of buff were missing, for two different reasons, and
both were my errors.

**Protections and weapon auras were silently dropped by the description
parser.** They are self-targeted and were sitting in the spellbook the whole
time, but retail words them differently and the pattern only accepted
"Increases the caster's X by N":

    Fire Protection Self  -> "Reduces damage the caster takes from Fire by 9%."
    Armor Self            -> "Increases the caster's natural armor by 20 points."
    Aura of Blood Drinker -> "Increases a weapon's damage value by 2 points."

So the weapon and wand buffs do exist as self-cast "Aura of" lines and are now
cast. Each category has its own toggle, matching VTank's separate
BuffProfile_Prots and BuffProfile_Banes.

The underlying flaw mattered more than the two missing patterns: anything
unmatched was DISCARDED. It now falls into an Other bucket (off by default)
instead, so nothing self-targeted is lost without a word. A test caught a
second instance immediately -- regeneration spells say "Restores..." and were
vanishing the same way.

**Banes were excluded because I misread a flag.** I took IsSelfTargeted as
"can be cast on you". It means "needs no selection". Retail's own bane text
says exactly how they work:

    "Increases a shield or piece of armor's resistance to slashing damage by
     10%. Target yourself to cast this spell on all of your equipped armor."

So banes ARE cast on the person, and the catalogue now includes every
beneficial non-untargeted spell rather than only flagged self-casts, leaving
EvaluateGate to decide what a given target accepts. Before casting anything
without the self flag, MossTank selects the player -- and restores whatever
was selected before the pass, so targeting yourself does not quietly steal
the selection.

They are matched on retail's "Target yourself..." sentence rather than on the
word "Bane", so the classification comes from what the spell says it does.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:49:51 +02:00
Erik
5ca4a63272 feat(mosstank): the button is Force Buff — always recast everything
Virindi Tank's own term: Force Buff recasts the lot rather than only what has
lapsed. The button now does that, and says so.

BuffPlan gained a force flag that skips the already-in-force check. Forcing
means "ignore what is already up", NOT "ignore the settings" -- the trained-
skill filter, the attribute toggle and the difficulty margin all still apply,
and there is a test pinning that.

The loop had to change shape for this. It used to re-derive the plan every
tick and treat "plan is empty" as done, which works only because the ordinary
plan shrinks as buffs land. A forced plan never shrinks -- that is the point --
so the same loop would have cast forever. A pass now captures a queue at the
start and works through it by index, which is also cheaper: no rebuilding 80-odd
buff lines every frame.

A spell that will not go now advances the queue rather than blocking it. One
missing component used to mean everything behind it waited for the stall
timeout; now the status line names the refusal and the pass carries on.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:17:32 +02:00
Erik
54005a864c feat(mosstank): cast back-to-back, and a settings view for the thresholds
Three things from the first working buff pass.

**Pacing.** Casts were three seconds apart because of a fixed interval I added
before there was a real busy signal. There is one now -- the shared busy count,
incremented by the cast and decremented by the server's UseDone -- so the
interval is gone and the server is the only throttle. MossTank casts the moment
the previous action is acknowledged, which is the spam behaviour VTank has. The
stall timeout moved 25s -> 30s so a slow server does not read as a stall.

**Stamina to Mana was surprising.** VTank does convert vitals by default
(Recharge-*-Mana), so the behaviour is right, but a buff pass quietly spending
your stamina is not something to discover by watching. It is now a setting,
with its own thresholds, and can be turned off outright.

**Settings view.** Spell difficulty margin, rebuff time, the three vital
thresholds, and three toggles. Two details worth recording:

* The difficulty margin is SIGNED, per the VTank wiki: "a positive number
  raises the skill necessary to cast spells, a negative number lowers it. To
  attempt higher spells at a low level use a negative number." So the range
  spans -100..+100 rather than starting at zero.
* It is a second registered panel with a complementary visible binding rather
  than a tab control. Two panels and an Action need no new markup vocabulary,
  and only one is ever on screen.

Adjuster buttons rather than typed fields: buttons are proven in plugin markup,
while an editable UiField would need keyboard routing plumbed through to plugin
panels first. Worth doing, but not as a side quest inside this change.

Also fixed: the App copy target names plugin files explicitly, so the new
markup would have been left out of the plugin directory and the settings panel
would have failed to load at runtime with the build perfectly green. Caught by
listing the output rather than trusting the build.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:51:15 +02:00
Erik
1a7b0ed3d5 fix(mosstank): read the real busy signal, not the last-requested spell
A buff pass cast exactly one spell and then stalled at zero. The log timeline
made it unambiguous: one "casting Concentration", then every later pass queued
86 buffs and cast none until the 25s stall fired.

IsCasting was bound to RuntimeSpellCastState.LastRequestedSpellId. That
property records the last spell REQUESTED and is cleared only by Reset() at
session teardown -- it is honestly named, and I read a busy flag into it that
was never there. So it latched true on the first successful cast and stayed
true for the rest of the session, and every tick returned early at the busy
check.

It now reads the shared busy count: incremented by the cast path
(FreeHandsAndCastSpell @0x00566EF0) and decremented by the server's UseDone,
which is the actual in-flight signal. EvaluateGate reports PluginCastGate.Busy
as well, so a genuinely wedged counter names itself in the status line instead
of presenting as silence.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:35:37 +02:00
Erik
b9674b1f1e fix(mosstank): unclickable button, empty skill list, dev font, and chat output
Four defects from the first in-world look, three of them with a definite root
cause rather than a plausible one.

**The Buff button did nothing.** Not a hit-testing problem -- the pointer found
the button perfectly. UiRoot's press handling asks the pressed widget whether
it owns the pointer; a widget that does not claim the press falls through to
"move the ancestor window", and a window drag returns early on release without
ever emitting a Click. UiButton and UiClickablePanel both override
HandlesClick for exactly this reason; UiSimpleButton never did. Latent since
that class was written, and invisible until it was put inside a draggable
window -- which is precisely what a markup plugin panel is.

Found by reproducing it headlessly through the real UiRoot dispatcher rather
than by reasoning about it: MarkupPanelClickTests drives press-and-release over
the button and asserts the bound action ran, with a separate test asserting the
pointer finds the button at all, so a future failure says which half broke.
My earlier guess -- that a modal at character select was swallowing the click
-- was wrong, and the screenshot of the panel live in world disproved it.

**"0 trained skills".** The skill-name table was read in OnLoad *before*
GameWindowCompositionPipeline.Run, which is what publishes the DAT collection,
so _dats was still null, the whole block was skipped, and the surface reported
an empty skill list with nothing to explain it. Bound in PublishDatCollection
instead -- the moment the data exists -- so it cannot run early again whatever
the phase ordering does, and a genuinely missing SkillTable now says so.

**Plugin text used the development bitmap font.** UiLabel and UiSimpleButton
gained a DatFont, and MarkupDocument now takes the retail interface font from
the host, so plugin panels render through the same glyph path (including
retail's two-plane outline) as authored panels.

**MossTank now writes to chat.** New BCL-only IPluginChat routes to retail's
ClientLocal log type (0x1A) -- the channel the client uses for its own notices,
local to this client, so a plugin cannot speak in the player's name. MossTank
announces the start, the finish with a cast count, and a stall.

Not addressed here: the cursor showing blue rather than amber. Traced but not
fixed -- CursorFeedbackController picks the cursor family from combat mode, and
CombatMode.Magic selects the blue Magic cursor where Default is amber. That is
a combat-mode question, unrelated to this change, and worth its own look rather
than a speculative fix folded in here.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:27:28 +02:00
Erik
17ebfc434d feat(mosstank): buff trained skills and attributes, pick tiers by skill, manage mana
Reworks MossTank against user feedback and the Virindi Tank feature docs
(virindi.net is reachable again over https with a self-signed cert; the
research doc's "unreachable" note is stale).

VTank's stated default is the spec: "automatically buffs every Attribute and
Skill you have trained", and "all buff spells are recast when they go below 5
minutes". The previous pass buffed the whole spellbook and refreshed at 60s;
both are corrected.

The hard problem was working out WHICH stat each buff raises. The client's
spell table has no such link -- it arrives from the server with the
enchantment -- and the naming is too irregular to infer: Invulnerability
raises Melee Defense, Impregnability raises Missile Defense, Fealty raises
Loyalty, Sprint raises Run, Arcane Enlightenment raises Arcane Lore, and the
line called Willpower raises the attribute named Self. Any name-matching
scheme dies on that last one.

Retail states it outright in each spell's own description ("Increases the
caster's Life Magic skill by 10 points"), so BuffProfile derives the whole
mapping from shipped data at runtime. It also carries the one alias the data
needs: the spell text says "Assess Monster" where the skill table says "Assess
Creature", and without that the skill silently never matches.

Two data facts that would each have caused a real bug, found by dumping the
spell table rather than assuming:

* Family is NOT a spell-line identity in general. Retail groups the
  instantaneous vital transfers by SOURCE vital, so family 89 holds both
  "Stamina to Health" and "Stamina to Mana". Picking the strongest tier in a
  family would convert into the wrong vital about half the time. Buff lines
  group by family (correct for duration buffs, which is retail's own stacking
  bucket); the conversions are found by name stem instead.
* Instantaneous spells have no duration and must be excluded from buff lines
  entirely, or they are treated as buffs that never appear to land.

Tier selection now follows the character's skill in the casting school against
the spell's difficulty (VTank's SpellDiffExcessThreshold-Buff), which is why
PluginSpellInfo gained School as a SKILL id -- MagicSchool is retail's 1-5
school enum, not something a character trains.

Mana upkeep is the loop asked for: convert stamina to mana when mana is low,
Revitalize when that leaves stamina too low to convert, and refuse to drain
stamina past a floor. Unknown vitals read as zero and are treated as "no
information" rather than "empty", so it will not cast on a healthy character.

Panel no longer shows at character select. IsAvailable is now the runtime's
own lifecycle state rather than a proxy, and markup gained visible="{Binding}"
plus UiElement.VisibleSource -- evaluated before the visible gate, because
TickSelfAndChildren returns early when hidden and an element could otherwise
never un-hide itself.

Also: a generated SpellId enum of all 6,266 spells (tools/SpellDump --enum),
generated from portal.dat rather than copied, so it cannot drift and carries
no third-party licence; skill and spell names now come from the retail tables
for display; and the Buff click logs unconditionally, so "nothing happened"
can be told apart from "the click never arrived".

Solution builds clean; 14,433 tests pass on the standard hermetic lane filter,
0 failures, including 21 covering the buff profile, tier selection and mana
loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:09:02 +02:00
Erik
9d1117b923 feat(plugins): MossTank — a self-buffing plugin, and the automation surface it needed
First consumer of acdream's plugin automation surface, and the first slice of
the VTank-class plugin milestone
(docs/research/2026-07-29-vtank-plugin-automation-requirements.md).

MossTank shows a panel with a Buff button; clicking it casts every self-buff
the character is missing, skips what is already in force at an equal or higher
tier, and refreshes what is nearly expired.

The host/plugin line is the load-bearing decision here. The host publishes
spell DATA -- family, tier, difficulty, mana, duration -- plus a cast
primitive with a preflight gate. The plugin owns the POLICY. That is the
architectural conclusion the requirements research reached: VTank's engine
lived in plugin-land, built on Decal's primitives, and baking "best buff for
skill X" into the host would start pulling the engine inward one convenience
at a time.

Why the plan is driven off the spellbook rather than off trained skills, which
is the obvious reading of "buff every trained and specialised skill": the
client cannot honestly make that mapping. The link between a spell and the
stat it modifies arrives from the SERVER in the enchantment message and is
absent from the client's own spell table. What the client does know is which
spells the character has learned -- and a character only learns buffs for the
skills they use, so the spellbook reaches the same set without inventing a
mapping the client has no grounds for.

Surface added, all BCL-only so Plugin.Abstractions keeps its zero project
references:

* ICharacterInfo, ISpellCatalog, IMagicCommands, grouped behind one
  IAutomationSurface so IPluginHost grows by one member rather than three.
* IEvents.Tick. Automation is sequences, not single calls -- a buff pass casts
  several spells and must wait between them. Without a host tick a plugin
  would need its own timer thread re-entering the host off its update thread.
* NoOpAutomationSurface for hosts with no live session, so a plugin keeps one
  code path and checks IsAvailable.

Markup gained <button> and <label>; it previously supported only <meter>, with
a comment promising the rest. Buttons bind onclick to an Action property and
FAIL THE PANEL LOAD if it does not resolve -- a silently dead button is worse
than a panel that refuses to load, because the user clicks and there is
nothing to diagnose. Labels bind through a Func so a status line tracks its
binding instead of freezing at build time.

Enchantment reads use EnchantmentsInEffectSnapshot rather than the raw active
set: retail leaves a weaker same-family enchantment in the registry while a
stronger one is in force, and a plugin asking "am I buffed?" means in force.

BuffPlan is a pure function of (known buffs, active enchantments) precisely so
it can be tested without a session; 9 tests cover tier supersede, the
family-0 no-stack bucket that must not be de-duplicated, expiry refresh, and
plan stability across the rebuilds the tick loop performs.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:22:28 +02:00
Erik
690f21889e docs: handoff for the VTank-class plugin automation milestone
The requirements research from 2026-07-29 was nearly lost: it lives in a
session titled "graphics" because it was one strand of a session about
something else, and could not be found again by any search. The research
itself was committed (6077ce4d) and survived; this makes it findable and
usable by a session picking the work up cold.

Contents: reading order, the architectural conclusion not to relitigate
(VTank's meta FSM, expressions and loot engine are plugin-land, not
host-land; the K2 headless triad is already the right substrate), the
five dependency-ordered steps, every relevant file path, and the project
rules that bind the work.

Two things the handoff adds beyond relaying the research. First, a
changed-since section: three of the research's "gap" rows have closed —
vendor landed 2026-08-08, fellowship 2026-08-12, secure trade 2026-08-14 —
so step 4's substrate is materially stronger than when the plan was
written, and §3's gap table is the part that has aged. Second, every path
is verified against the live tree as of today rather than copied forward:
WorldEntitySnapshot is still exactly four fields, IPluginHost is unchanged,
there is still no enchantment-enumerating view and no point-goal movement
primitive, and IHeadlessBotPolicy is internal to AcDream.Headless so step 1
mirrors its shape rather than re-exporting the type.

All 30 path references were checked programmatically. Also records that
claude-memory/ is a junction that resolves in the main checkout but not
inside a worktree, which would otherwise read as a broken pointer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:50:58 +02:00
Erik
c254fea83d fix(app): apply the window icon from Load, not beside Window.Create
All checks were successful
CI / linux-portable (push) Successful in 3m20s
CI / windows-gate (push) Successful in 5m40s
CI / release (push) Successful in 2m9s
The client shipped with a PE icon Explorer showed and a window that did not:
launched from the launcher it still drew the stock Windows application icon.

Silk's Window.Create only builds the managed object. IWindow.Initialize is
what, in Silk's own words, "creates the window on the underlying platform".
Applying an icon before that throws:

    after Window.Create : IsInitialized = False
    SetWindowIcon BEFORE Initialize : THREW InvalidOperationException:
                                      Window should be initialized.
    after Initialize    : IsInitialized = True
    SetWindowIcon AFTER  Initialize : returned without throwing

What made this quiet rather than obvious is the fallback. GLFW registers its
window class against a resource named GLFW_ICON and, not finding one, uses
IDI_APPLICATION - the generic Windows icon - rather than the executable's own.
So the PE icon kept showing on the file while the live window lost it, which
reads as a packaging problem and is nothing of the kind. The launcher was
unaffected because Avalonia takes a different path entirely, and that
asymmetry was the tell.

Apply now happens in OnLoad, beside the other window-dependent startup work,
and refuses with a message naming the ordering requirement if it is ever
called on an uninitialized window - the previous generic catch reported
"Window should be initialized" to a stderr nobody reads, which said nothing
about icons.

The regression guard reads the compiled call graph, because this is an
ordering edge with no observable return value: OnLoad must call Apply, and no
method that calls Window.Create may. Verified by reintroducing the bug and
watching it fail, then restoring the fix and watching it pass.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:13:29 +02:00
Erik
400e7c766f Merge main into acdream-mosswart-icon
All checks were successful
CI / linux-portable (push) Successful in 3m20s
CI / windows-gate (push) Successful in 6m1s
CI / release (push) Successful in 2m7s
2026-08-20 14:46:26 +02:00
Erik
48c44abd4a fix(IconForge): point the texture path at the real dump directory
render.py was lifted from the scratch pipeline with its default TEXDIR still
aimed at tools/IconExtract's build output — a tool that is not in the repo.
forge.py overrides the value, so the icons built correctly and the staleness
was invisible; anyone importing render.py directly would have been sent to a
path that never existed.

Default now matches where tools/MosswartArt actually writes, and a missing
texture prints a warning instead of silently dropping out: a partial texture
set renders some parts flat grey, which reads as a lighting bug rather than a
missing extraction step.

Both icons still reproduce byte-for-byte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:43:37 +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
1bd2b30291 fix(ui): restore retail vitals and window interactions
All checks were successful
CI / linux-portable (push) Successful in 3m16s
CI / windows-gate (push) Successful in 5m41s
CI / release (push) Successful in 2m5s
2026-08-20 13:26:35 +02:00
Erik
4d84456c21 docs: close Campaign LU with its ledger and the four gate traps
Some checks failed
CI / linux-portable (push) Successful in 3m15s
CI / windows-gate (push) Failing after 6m44s
CI / release (push) Has been skipped
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
Erik
7037681a1f fix: state the verification cache's real limit instead of a claim that is false on ZFS
All checks were successful
CI / linux-portable (push) Successful in 2m56s
CI / windows-gate (push) Successful in 4m53s
CI / release (push) Successful in 2m5s
Run 174's Linux job failed on
ASilentlyCorruptedPackageOfTheSameSizeStillFailsAndDropsTheCache. It passed in
isolation on that same machine, and passed under full-suite load there too, so
it looked like a flake. It is not.

Measured on the runner:

    same-mtime collisions: 141 / 200
    fs type: zfs

Its /tmp is ZFS, whose timestamp granularity is coarse enough that a same-size
rewrite usually lands on the SAME last-write time. So the startup fast path —
size plus write time — cannot see that modification, and the test was right to
fail. LU1's commit message claimed "truncating or touching the package still
blocks launch"; on a coarse-timestamp filesystem the second half of that is
false. NTFS's 100 ns resolution is why it never showed on Windows.

Rather than relax the test until it passes, the contract is now stated as two
facts that are true everywhere instead of one that is not:

- A same-size corruption whose write time moves is caught at startup. The test
  moves the timestamp explicitly instead of trusting the clock, so it asserts
  the mechanism rather than the filesystem's resolution.
- A corruption preserving BOTH size and write time is NOT caught at startup and
  IS caught by a forced full verification — which is exactly what the
  launcher's Verify files button runs. New test, so the escape hatch is
  covered rather than merely mentioned.

PreparedAssetVerificationCache now documents the limit with the measurement, so
the next reader does not have to rediscover it from a red pipeline.

Verified on Windows (11 passed) and five consecutive runs on the ZFS runner
itself (11 passed each). Full solution 14,375 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:03:52 +02:00
Erik
d233538f2c fix(tests): the launcher/headless command-line contract needs executable stubs on Linux
Some checks failed
CI / linux-portable (push) Failing after 3m0s
CI / windows-gate (push) Failing after 5m0s
CI / release (push) Has been skipped
Run 173's Linux job failed on the contract test added one commit earlier — my
test, not the product.

LauncherExecutableSet refuses a host that exists but has no execute bit on
Linux (HasUnixExecutePermission), which is a real and useful check: an update
whose extraction lost its permissions would otherwise fail deep inside process
start instead of at the launch gate. The stubs were written with
File.WriteAllText, which is 0644, so on Linux every one of the four tests died
at that gate before reaching the command-line contract they exist to pin.

Windows never sees this — the predicate short-circuits to true off Linux — so
the test passed locally and could only fail on the runner.

Stubs are now created through a helper that chmods them executable on non-
Windows. Verified on Windows (4 passed); the Linux half is what run 174 checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:53:19 +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
6a15dd063c fix: retail text and golden-string tests must not follow the machine's locale
All checks were successful
CI / linux-portable (push) Successful in 3m13s
CI / windows-gate (push) Successful in 5m15s
CI / release (push) Successful in 2m2s
Run 170's Windows gate went red on 37 tests across four assemblies while the
same commit passed 14,370/0 locally. The failures were all one family:

  Expected: "You have 1 500p"     <- built with the machine's culture
  Actual:   "You have 1,500p"     <- production, correctly invariant

The runner is Swedish; this dev box is not. These tests had been passing on CI
only because that machine's registry locale had been pinned by hand — machine
state, which came undone (almost certainly the reboot after today's hang).
Re-pinning it would be a workaround on one machine for a defect in the repo,
so this fixes the repo instead.

Two genuinely different bugs were hiding in that one symptom.

1. TESTS that build an expected string with the ambient culture and compare it
   to invariant production output, and test-side recording sinks whose traces
   are compared against literal golden strings. Those only ever passed on a
   machine that happens to format like the invariant culture. Pinned to
   InvariantCulture: the vendor purse/cost expectations, and the motion-funnel,
   animation-sequencer, framebuffer-resize, resource-slot, and runtime-attack
   trace sinks.

2. PRODUCTION that formats player-visible retail text with the ambient culture.
   This one matters beyond CI: retail is a US client, so it shows "2.50",
   "1,500p" and "(-20)" to everyone. On a Swedish machine acdream was showing
   "2,50", "1 500p" and "(-20)" with U+2212 MINUS SIGN — the audience for this
   alpha is literally Swedish. Converted 76 sites to InvariantCulture across the
   item/creature appraisal formatters, the character stat panel's buff and vitae
   parentheticals, the appraisal and link-status controllers, the chat
   /framerate and /location output, the camera sensitivity toast, the
   time-override toast, the F3 dump, the sky diagnostics, and the world-frame
   invariant-failure message.

   DATES are deliberately left on the current culture (CharacterController's
   birth/login stamp, RuntimeHouseState's purchase expiry). Retail has no answer
   for a non-US player's date format, and forcing "08/19/2026 7:00:00 PM" on
   them is a UX decision, not a retail-fidelity one.

Apparatus, so the next occurrence is reproducible instead of mysterious:
tests/TestCultureInitializer.cs adds an opt-in ACDREAM_TEST_CULTURE knob to
every test assembly, linked in through a new tests/Directory.Build.props.
Unset — what CI and everyone runs — it changes nothing.

  ACDREAM_TEST_CULTURE=sv-SE dotnet test ...

reproduced all 37 CI failures on this machine plus 6 more the runner's own
locale does not surface (the Unicode-minus family), and drove the fix.

Verified both ways on the full solution under the release-gate filter:
default culture 14,370 passed / 0 failed, and ACDREAM_TEST_CULTURE=sv-SE
14,370 passed / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:56:09 +02:00
Erik
6e6e6f8de4 docs: Campaign LU is code-complete; record the gate limitation
Some checks failed
CI / linux-portable (push) Successful in 3m14s
CI / windows-gate (push) Failing after 6m27s
CI / release (push) Has been skipped
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>
2026-08-19 18:55:27 +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
a34e8f2a17 fix #420: seed face-segment media states so character select stops crashing the client
Every launcher-started play session on 2026-08-19 died a few seconds after
login. The user's own session evidence shows it three times in a row:
started -> connected -> characterList -> exited code 1 "crashed", with
client.err.log carrying

  System.ArgumentNullException: Value cannot be null. (Parameter 'key')
     at System.Collections.Generic.Dictionary`2.FindValue(TKey key)
     at AcDream.App.UI.UiButton.OnDraw(UiRenderContext ctx)

UiButton allocated its per-face-segment media-state array as `new string[n]`,
leaving every element null, while the single-face sibling _faceMediaState was
correctly seeded to "" (DirectState). NextMediaState returns `current`
unchanged on three of its four arms — including retail's own "committed state
authored with an empty media array keeps the previous media playing" rule — so
on a multi-segment button whose committed state carries no media the null
survived the first SyncMediaStates and reached
ElementInfo.StateMedia.TryGetValue(null), throwing mid-paint and taking the
process down.

Seed the array with "" at construction. That is what the constructor's
existing comment already claimed the media machine did ("the media machine
begins on the element's BASE media"); only the segment array was left out.

Verified by reverting the one-line fix: the new regression test throws
ArgumentNullException from UiButton.ActiveFile, the same frame as the live
crash. AcDream.App.Tests UiButton filter: 41 passed, 3 skipped.

Found while investigating Campaign LU item 4 ("launching the selected
character doesn't work") — this is why nothing worked. Also lands the Campaign
LU plan doc, whose recon section records the mechanisms the remaining slices
build on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:32:28 +02:00
Erik
a3b0455f59 ci: put the downloads in the 'latest' release, not just the pointer
All checks were successful
CI / linux-portable (push) Successful in 3m14s
CI / windows-gate (push) Successful in 4m52s
CI / release (push) Successful in 1m56s
'latest' held only manifest.json, which serves the launcher but not a person:
it sits at the top of the Releases page with nothing to click, so a friend has
to know to scroll past it to a build tagged with a timestamp. That is the exact
friction this feed exists to remove.

It now carries the client and launcher zips as well, and its body says plainly
which file to download and what to do with it. Storage cost is flat, not per
build: 'latest' is deleted and recreated each publish, so it is one extra copy
at any time rather than one per release.
2026-08-19 16:25:48 +02:00
Erik
172bcc2183 docs: note that docs-only pushes skip the pipeline 2026-08-19 16:15:04 +02:00
Erik
988d242ad9 ci: skip the pipeline for docs-only pushes
All checks were successful
CI / linux-portable (push) Successful in 3m11s
CI / windows-gate (push) Successful in 4m49s
CI / release (push) Successful in 1m48s
Half of today's pushes were documentation, and each cost a full ~7-minute
clean build, 14k tests, and a 121 MB release for changes no test can fail on.
Pushes touching only docs/, the memory trees, or markdown no longer trigger
the gate; any code path still runs the full uncached pipeline, and manual
dispatch is always available.

Deliberately NOT build caching instead: the gate's value is proving a
from-nothing build (what a friend's machine does), and stale bin/obj serving
deleted code is a documented past failure class in this repo.
2026-08-19 16:14:48 +02:00
Erik
7428dda715 docs: route CLAUDE.md's canonical-state list to the CI/release SSOT
All checks were successful
CI / linux-portable (push) Successful in 3m13s
CI / windows-gate (push) Successful in 4m51s
CI / release (push) Successful in 1m49s
Sessions start from CLAUDE.md's reading list; the Gitea pipeline, the
self-hosted runners, the release/pointer layout, and the Lane=Timing rule now
have a one-line entry there so the next session finds docs/ci-and-releases.md
before touching CI.
2026-08-19 16:04:25 +02:00
Erik
20905e16fc docs: warn against leaving load on a runner
All checks were successful
CI / linux-portable (push) Successful in 3m10s
CI / windows-gate (push) Successful in 4m52s
CI / release (push) Successful in 1m52s
A stress run left going on the Windows runner kept 17 dotnet processes alive
and competed with CI for the same machine for roughly half an hour, slowing
every job and making the load-sensitive failures it was meant to diagnose more
likely. Records the cleanup commands and the caveat that the runner agent
itself should be left alone.
2026-08-19 15:53:23 +02:00
Erik
3dd1af5ec1 ci: run Core.Net single-threaded on Linux, as its own step
All checks were successful
CI / linux-portable (push) Successful in 3m10s
CI / windows-gate (push) Successful in 4m48s
CI / release (push) Successful in 1m53s
Push 2 went red on a SIXTH member of the load-sensitive family
(PrunedId_RejectAtFreshSequence_ReclaimKeepsTheStreamAligned), with the
assembly taking 59 s on Linux against ~7 s on Windows. Laning members one at a
time was the pattern that already failed to converge, so this addresses the
assembly instead.

The split is measured, not defensive: on this 6-core container Core.Net FAILS
in 40 s with default parallelism and PASSES in 10 s single-threaded. Its
sessions do real socket work on background threads, so contention both breaks
and slows them. Windows keeps default parallelism — 18 cores, ~7 s, and it
REGRESSED when the same assembly was serialized there.

An earlier attempt passed the flag through a bash array inside the shared loop
and never reached dotnet. This gives the project its own explicit invocation so
the flag cannot be swallowed.
2026-08-19 15:43:41 +02:00
Erik
315d4f7aad docs: point the documentation map at the Timing lane
Some checks failed
CI / linux-portable (push) Failing after 2m38s
CI / windows-gate (push) Successful in 4m53s
CI / release (push) Has been skipped
The CI entry now names Lane=Timing and routes to release-gate.md, which carries
the evidence and the bar for adding a test to it. Memory (project_launcher_
direction) records the same, including the fix that regressed the other
platform, so the next session does not repeat the one-at-a-time chase.
2026-08-19 15:37:44 +02:00
Erik
c155db74d1 test: introduce Lane=Timing for load-sensitive tests, and stop chasing them individually
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 5m34s
CI / release (push) Successful in 1m54s
Four separate fixes each surfaced a different member of the same family, and
one of them (serializing Core.Net.Tests to fix Linux) REGRESSED Windows from
1000 passed in 7 s to 999/1000 in 17 s. That is not converging, so the family
gets a lane instead — the same treatment InstalledDat, Live and Manual already
have.

Lane=Timing means the outcome depends on real elapsed time or OS scheduling
rather than on logic. Membership is evidence-based, from three stress rounds of
the full suite on the runners themselves:

  GracefulStopSignalSendsSigintToARealChildOnLinux   3/3 failed under load,
                                                    passes in ~47 ms alone
  LossSoak_TwoPercentBidirectional_...              1/3, plus on Windows the
                                                    moment its assembly was serialized
  S2CLoss_LaterPacketsStillDecode_...               1/3
  PausedSelector_SeededDroppedServerReady_...       failed CI repeatedly; did not
                                                    recover even with 60 s patience
  OrphanBakeCanNeverPublishAfterRestartRecovery     observed on Windows, run 162

Nothing is weakened or deleted: 997 Core.Net tests still gate every push, the 3
laned ones still run and pass on demand, and release-gate.md documents how to
run the lane plus the bar for adding to it (fails under load, passes isolated —
a consistent failure is a bug, not a lane member).

Also removes the ad-hoc Core.Net parallelism special-case from the Linux job,
which this supersedes.
2026-08-19 15:29:10 +02:00
Erik
6923ca02bd ci: prune old releases, keeping the newest five
Some checks failed
CI / linux-portable (push) Failing after 2m3s
CI / windows-gate (push) Failing after 4m52s
CI / release (push) Has been skipped
Releases were retained forever. Each build is ~121 MB of attachments, so the
Gitea server grew by that much on every push to main — five builds had already
reached 606 MB, and nothing would have stopped it.

The release job now deletes versioned releases beyond the newest five, and
their tags with them (a tag survives its release and would otherwise pile up).
Five keeps a previous build available for a friend or a bisect while staying
well under a gigabyte. The 'latest' pointer is explicitly excluded from
pruning: it is the launcher's feed, not a build.
2026-08-19 14:59:28 +02:00
Erik
1a07f3e7f4 ci: run Core.Net.Tests single-threaded on the Linux runner only
All checks were successful
CI / linux-portable (push) Successful in 3m4s
CI / windows-gate (push) Successful in 4m59s
CI / release (push) Successful in 1m51s
PausedSelector_SeededDroppedServerReady_RecoversOnIdleSweep kept failing on the
Linux runner even after its wall-clock patience was widened to 60 s — the
assembly ran 1 m 39 s and recovery still never completed, so this is real
starvation of the session's background threads, not a tight timeout.

Measured on the runner itself:
  default parallelism      FAILED  in 40 s
  MaxParallelThreads=1     PASSED  in 10 s

Serial is both correct and four times faster there, because the contention was
also costing wall-clock. Scoped to this one project on Linux: Windows has 18
cores, passes with default parallelism in ~7 s, and serializing this assembly
for everyone previously REGRESSED it (1000 passed in 7 s -> 999/1000 in 17 s).

Replaces the earlier '-- xUnit.MaxParallelThreads=2' that was applied to every
Linux project: too weak to help and too broad to be safe.
2026-08-19 14:49:28 +02:00
Erik
ff01423f3f ci: put the launcher's update pointer in a release, and delete the dist branch
Some checks failed
CI / linux-portable (push) Failing after 3m22s
CI / windows-gate (push) Successful in 5m27s
CI / release (push) Has been skipped
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.
2026-08-19 14:40:41 +02:00
Erik
353231fe6e docs: correct two landmine rows that my own later fixes disproved
All checks were successful
CI / linux-portable (push) Successful in 2m56s
CI / windows-gate (push) Successful in 4m56s
CI / release (push) Successful in 1m51s
The transport row said to cap xUnit.MaxParallelThreads; serializing that
assembly actually REGRESSED Windows (1000 passed in 7 s -> 999/1000 in 17 s)
and the real fix was widening the virtual-clock harness's wall-clock patience.

The Avalonia row said to serialize via xunit.runner.json; that does not fix it
either — proven twice, including with a compiled-in attribute so delivery could
not be blamed. It needs a real desktop and is now Lane=Manual.

Both rows now record what was tried and disproved, which is the part worth
keeping.
2026-08-19 14:23:31 +02:00
Erik
311c8bd3df test: lane the one Avalonia test that needs a real desktop session
All checks were successful
CI / linux-portable (push) Successful in 2m53s
CI / windows-gate (push) Successful in 5m6s
CI / release (push) Successful in 1m52s
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.
2026-08-19 14:15:21 +02:00
Erik
45f88d2d18 test: serialize Launcher.Tests via an assembly attribute, not xunit.runner.json
Some checks failed
CI / linux-portable (push) Successful in 3m10s
CI / windows-gate (push) Failing after 4m52s
CI / release (push) Has been skipped
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.
2026-08-19 14:06:24 +02:00
Erik
daf28bfec5 test: revert Core.Net serialization; widen the virtual-clock harness patience instead
Some checks failed
CI / linux-portable (push) Successful in 3m21s
CI / windows-gate (push) Failing after 4m57s
CI / release (push) Has been skipped
Serializing AcDream.Core.Net.Tests to fix a Linux starvation REGRESSED Windows,
which had been green: Core.Net went from 1000 passed in 7 s (run 154) to
999/1000 in 17 s (run 155), taking down LossSoak_TwoPercentBidirectional_
ZeroMessageLoss_LedgersConverge, a test that had never failed. That trade trans-
ferred the flake between platforms rather than fixing anything, so it is
reverted: no xunit.runner.json, no csproj change.

The actual fragility is narrower than it looked — exactly ONE test uses
real-time waits (PausedSelector_SeededDroppedServerReady_RecoversOnIdleSweep),
and its harness drives a VIRTUAL clock while asserting on 2 s wall-clock
windows. Those windows are patience for background work, not part of the
assertion, and 2 s only ever encoded 'the machine is idle'. They now share a
60 s HarnessPatience constant.

Nothing about what the test verifies changes: recovery must still occur, a
genuine failure to NAK still fails, and a real hang is still bounded. Campaign N
transport code is untouched.

Local: 1000/1000 in 6 s under the gate filter.
2026-08-19 13:59:42 +02:00
Erik
03bcc1a41b test: serialize AcDream.Core.Net.Tests so real-time transport waits are not starved
Some checks failed
CI / linux-portable (push) Successful in 3m22s
CI / windows-gate (push) Failing after 5m15s
CI / release (push) Has been skipped
PausedSelector_SeededDroppedServerReady_RecoversOnIdleSweep failed twice in CI
on the Linux runner, taking 37 s and 42 s, while passing 5/5 in ~350 ms in
isolation on that same machine. The test drives a virtual clock but asserts on
real-time 2 s SpinUntil windows, so full-assembly parallelism on a 6-core
container starves it.

Passing -- xUnit.MaxParallelThreads=2 through dotnet test did not take effect.
A xunit.runner.json is read by xUnit directly and is the convention already
used by AcDream.Core.Tests and AcDream.Launcher.Tests.
2026-08-19 13:51:07 +02:00
Erik
6ef82934dc docs: SSOT for the Gitea CI pipeline and automated alpha releases
Some checks failed
CI / linux-portable (push) Failing after 2m10s
CI / windows-gate (push) Successful in 4m59s
CI / release (push) Has been skipped
docs/ci-and-releases.md documents what happens on a push to main, why Gitea
rather than GitHub (billing-blocked, private repo, and Forgejo ships no hosted
runners), both runners and their prerequisites, the release/manifest layout,
and how to verify a release with the Lane=Live install test.

Its landmine table is the part worth keeping: every row cost a red pipeline —
Node for JS actions, setup-dotnet unmirrored on data.forgejo.org, the
zombie-task timeout caused by run-release-gate.ps1 redirecting child output,
an en-SE runner locale breaking 40 tests on decimal commas, DAT tests missing
their InstalledDat lane tag, parallel-load timing flakes, and the Avalonia
compositor threading failure that must NOT be 'fixed' by de-async-ing the test.

Also records the culture finding: config, parsing and the wire are all
invariant-safe, so US and European installs behave identically; only
diagnostic strings follow the current culture.

Cross-linked from docs/README.md and release-gate.md, which keeps ownership of
the local bounded gate.
2026-08-19 13:44:55 +02:00
Erik
657ac6baca test: prove a launcher installs the client from the live Gitea release
Some checks failed
CI / windows-gate (push) Has been cancelled
CI / linux-portable (push) Has been cancelled
CI / release (push) Has been cancelled
Lane=Live end-to-end verification against the real feed, using the production
updater the GUI button calls: ReleaseManifestClient.ProductionManifestUri, real
network, real SHA-256/size verification, real ZIP extraction, real atomic
activation. Asserts what the launcher actually does next — that
LauncherExecutableSet can resolve BOTH hosts out of the activated directory,
and that current.json names the installed version — rather than merely that
files exist.

Excluded from the bounded gate (Lane=Live needs the public feed reachable);
run deliberately after a release lands. Writes only under an isolated
temporary path set, never the caller's real launcher data directory.
2026-08-19 13:43:12 +02:00
Erik
aac29e359d ci: retire the smoke workflow now that the real pipeline is green
All checks were successful
CI / linux-portable (push) Successful in 3m23s
CI / windows-gate (push) Successful in 5m40s
CI / release (push) Successful in 1m55s
smoke.yml was scaffolding to prove the self-hosted runners could execute
anything at all, back when checkout and the SDK were still unresolved. ci.yml
now builds, gates and releases on both runners, so the smoke jobs only
duplicate its environment checks. Leaving dead workflows around is exactly the
debt that confuses the next reader.

This commit also serves as the pipeline's first real push-triggered run: every
green run so far was a workflow_dispatch.
2026-08-19 13:32:19 +02:00