Commit graph

932 commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
c5492984ef ci: tag DAT-dependent tests into the InstalledDat lane; drop invariant workaround
Some checks failed
CI / linux-portable (push) Successful in 3m26s
CI / windows-gate (push) Failing after 5m2s
CI / release (push) Has been skipped
Three tests reached the CI gate needing the installed retail DATs, which no
build machine has, and failed with FileNotFoundException on client_cell_1.dat:
  - Issue127FloodFlipReplayTests (both facts replay via ResolveDatDir)
  - FindCellListConformanceTests.FindCellList_DoorwayThreshold_IndoorPicks_
    MatchRetail, the one untagged method among already-tagged siblings
They now carry [Trait("Lane", "InstalledDat")] like every other DAT test, so
the gate filter excludes them and the local DAT lane still runs them.

Also reverts the DOTNET_SYSTEM_GLOBALIZATION_INVARIANT pin from the previous
commit. It was too blunt: it fixed the 40 decimal-comma failures but broke
ChatLogTests.FormatTimestampPrefix_UsesLiteralColons_RegardlessOfCurrentCulture,
which legitimately constructs a culture and cannot under invariant mode. The
runner's HKCU locale (LocaleName=en-SE, sDecimal=',') was corrected to en-US
instead, which is the actual defect.
2026-08-19 11:19:14 +02:00
Erik
14d371a05b test: replace create authority source pin 2026-08-18 16:52:22 +02:00
Erik
84034f732c test: replace gameplay owner source freezes 2026-08-18 16:49:38 +02:00
Erik
9b94050229 test: replace frame orchestration source freezes 2026-08-18 16:40:56 +02:00
Erik
9bd5d47c47 test: replace graphical host source freezes 2026-08-18 16:30:25 +02:00
Erik
5e56045077 test: replace render leaf source freezes 2026-08-18 16:12:50 +02:00
Erik
3c492aedc2 test: replace runtime root source freezes 2026-08-18 16:02:15 +02:00
Erik
80c7b44457 test: replace composition source freezes 2026-08-18 15:50:53 +02:00
Erik
caa5eb8b2b test: replace input and physics source freezes 2026-08-18 15:31:57 +02:00
Erik
0ad2ee1cdf test: replace streaming source freezes 2026-08-18 15:14:41 +02:00
Erik
5a33369074 test: replace render source freezes 2026-08-18 14:58:25 +02:00
Erik
53b6841c5a test: remove final campaign labels 2026-08-18 13:50:31 +02:00
Erik
631ecd24e3 test: finish diagnostic classification 2026-08-18 13:41:17 +02:00
Erik
ad7ebe9425 test: observe landblock worker joins 2026-08-18 13:03:30 +02:00
Erik
fa4bdfe89f test: observe monitor waits without delays 2026-08-18 12:56:25 +02:00
Erik
5fa9933636 test: remove exact duplicate coverage 2026-08-18 12:36:41 +02:00
Erik
9c6b143a03 test: replace campaign labels with behavior names 2026-08-18 12:25:00 +02:00
Erik
6faeb4a103 test: make prerequisite lanes fail honestly 2026-08-18 12:09:41 +02:00
Erik
dfc841b779 test: stabilize load-sensitive release contracts 2026-08-18 11:50:23 +02:00
Erik
3684e7b5e7 test: classify prerequisite lanes and own Avalonia sessions 2026-08-18 11:30:47 +02:00
Erik
c1a905004a test: separate diagnostic apparatus from release gates 2026-08-18 11:06:08 +02:00
Erik
8f490240d4 test: separate non-hermetic release lanes 2026-08-18 10:49:22 +02:00
Erik
52015f5052 test: remove known tautologies and scaffolds 2026-08-18 10:40:20 +02:00
Erik
c38f6b8852 build: make release restore reproducible 2026-08-18 10:29:00 +02:00
Erik
a1d15a82dd perf #418: widen the destination-lane budget only while the reveal hold is active
While a destination reservation hides the world behind the authored
tunnel, the streaming frame meter now runs a hold-widened profile
(StreamingWorkBudget.WidenForDestinationHold): the time ceiling rises
from the authored 2 ms to an absolute 8 ms default
(ACDREAM_STREAM_WORK_HOLD_DEST_MS is a measurement-only override), every
count/byte dimension scales by the same factor so elapsed time stays the
authoritative guard (the measured binder is Time at both ceilings), and
the reserve fraction is re-derived (0.75 -> 0.9375) so the
NON-destination lane's absolute per-frame caps are unchanged. The
widening keys off the existing BeginDestinationReservation/
EndDestinationReservation bracket only, is derived per-Tick from the
CURRENT budget (mid-hold quality swaps compose), and a frame with no
reservation uses the authored budget verbatim (test-pinned). Portal
holds ride the same bracket as login holds by construction - intended,
and pinned by a kind-parity test through the real coordinator plus a
live @telepoi portal hold (kind=portal gate-ready 3589 ms).

Why: issue #418's next-hypothesis (1). Measured result: the ~5 s
publication drip collapsed to ~2 s (loaded 625/625 at ~3.0 s, tunnel at
64-66 fps), the portal-hold gate-ready fell to ~3.6 s - and login
gate-ready/total stayed at 8.4-8.8 s / 12.6-12.7 s, exposing the real
remaining pacer: the login-cold render-thread upload/registration
barrier behind GpuWorldState.IsRenderReady, which ran concurrently under
the old drip. Full attribution appended to docs/ISSUES.md #418; no
divergence-register row (the streamed result and reveal gate are
byte-identical; only the scheduling rate during a hidden hold changed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 20:55:43 +02:00
Erik
11106c70e7 perf #418: publish landblocks under the meter, not one per streaming tick
Phase-1 measurement (new [publish-timing]/[stream-tick] probe surfaces,
ACDREAM_PROBE_REVEAL_TIMING=1) refuted the ~31 ms-per-admission
hypothesis: the hold runs at ~64 fps with the streaming tick at ~32 Hz,
the whole 625-block window costs only ~500 ms of publication CPU (far
blocks ~0.17 ms, near 2-43 ms), and steady state showed ZERO meter
yields with ~0.22 ms of the 2 ms budget used - yet exactly one block
published per tick against a ~400-deep completion queue.

The real limiter: Runtime's collision-generation activation is a
deliberate two-poll transaction (the first
TryAcquireCollisionPrefixMutationPermission poll parks residents and
refuses by design), and LandblockPresentationPipeline.Advance's metered
arm returned Completed=false on ANY nonterminal commit, which
DrainAndApply treats as end-of-frame. One landblock per 32 Hz tick =
the flat 32/s, with the authored budget ~90% idle.

Fix: the metered arm now uses the same Runtime-owned gate the unmetered
arm and the synchronous CompletePublication API always used
(CanContinueMutationSynchronously). The second poll runs in the same
frame under the same meter, so the unchanged 2 ms elapsed-time ceiling
is now genuinely the authoritative per-frame bound; with any real debt
(live residents parked mid-game, pending withdrawals, dispatch backlog)
publication defers to the next frame exactly as before. No budget
values change, no reveal-gate/readiness change, and the streamed result
is byte-identical - only the frame scheduling of identical operations.

Measured A/B (this binary, two runs): totalMs 12689 / 12734 vs baseline
26728/27395/27503; loaded slope 32/s -> bursts of 100-360/s, 625/625 in
~6-7 s vs ~23 s. The remaining ~12.7 s floor is fully attributed in
docs/ISSUES.md: ~8 s of real budgeted readiness work plus retail's
authored tunnel exit (TunnelContinue 2-5 s + two 1 s fades, golden
constants), so the <12 s acceptance needs a lead decision on the
hold-time budget, not another hidden limiter.

New regression pin:
MeteredLoaded_NonterminalCommitWithoutDebt_CompletesInOneMeteredAdvance.
Gates: Release build 0 errors; App tests 5576/3 skips/0 failed;
Runtime tests 1756/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 20:11:35 +02:00
Erik
39967e78bd perf #418: parallelize landblock builds across a striped worker pool
Login publishes the 25x25 window at a flat 32 blocks/s (~27 s in the
tunnel). The reveal-timing probe A/B (695a27b4) showed the consumer
budget env ceilings change nothing, which was read as producer-limited:
one "acdream.streaming.worker" thread, ~31 ms/block. This replaces the
single worker with min(ProcessorCount-2, 8) workers, floor 1.

Design: striped/affinity dispatch. Each worker owns one unbounded lane
channel plus its own high/low priority queues; jobs route to
lane = ((id >> 16) * 2654435761) % N (the low word of a landblock id is
constant, so the id is mixed before reduction). Striping was chosen
over a shared queue + in-flight conflict tracker because it preserves
the per-landblock contract structurally rather than by bookkeeping:
every job for one id lives on one lane, so per-id enqueue order IS
execution and completion-arrival order, and the same-landblock
supersede rules (PromoteToNear removes queued LoadFar/Unload) keep
seeing every queued job for that id. Contract, point by point:

- Per-landblock ordering: same id -> same lane -> serial FIFO.
- ClearLoads: broadcast to every lane inside the same _inboxGate lock
  that serializes enqueues, so any load enqueued before
  ClearPendingLoads() returns sits ahead of its lane's ClearLoads copy
  in that lane's FIFO and is dropped at read time, exactly like the
  single-thread path. Already-dequeued builds still complete (now up
  to one per worker instead of one total); StreamingController's
  SweepCollapsed already unloads those uniformly.
- Priority: per-lane high/low split unchanged. Cross-lane, priority is
  not globally ordered (a lane cannot run another lane's job), which
  the contract permits; near-tier jobs hash-spread across lanes and
  are preferred within each.
- Outbox: SingleWriter flipped to false; nothing assumed single-writer
  (PublishResult already used TryWrite + an Interlocked backlog, and
  the consumer's peek->read head-stability holds because only the
  single reader ever moves the head). Cross-landblock arrival order
  was verified arbitrary-tolerant before relying on it:
  StreamingController.AdmitCompletions classifies each result
  independently into per-priority FIFOs (generation staleness +
  per-landblock retirement blocking); per-landblock arrival order is
  preserved by striping.
- Crash surface: per-worker. The first real crash publishes
  WorkerCrashed (prefixed "worker N:" in pools > 1), sets
  _workerFailure, completes every lane, and cancels the pool (a crash
  still ends all processing, as before); siblings that merely observe
  the closed lanes (ChannelClosedException) exit quietly instead of
  reporting spurious crashes; the outbox completes only when the LAST
  worker exits so no in-flight completions are dropped.
- Disposal: joins every worker under the same _disposeGate; Start
  stays idempotent and dispose-serialized.

Thread-safety audit of the production build closures
(SessionPlayerComposition), per shared object:

- DatCollection (every read in LandblockBuildFactory.BuildLocked:
  LandblockLoader.Load, SceneryGenerator.Generate, SetupMesh.Flatten,
  CellMesh.Build, GfxObjBounds.Get, GfxObjDegradeResolver): NOT
  thread-safe; already serialized under the shared _datLock, which
  BuildLocked holds for the whole read transaction. Unchanged; the
  probe run measured hold 0-13 ms / wait <= 12 ms during the login
  window, so the lock is not the new bottleneck and the build was NOT
  serialized beyond it.
- PakPreparedAssetSource / PakReader (BuildPreparedCollisionClosure,
  outside the lock): immutable TOC array + read-only
  MemoryMappedViewAccessor random-access reads + ConcurrentDictionary
  verdict caches - safe for N concurrent readers (Slice I3 design;
  the headless SharedPreparedCollisionCache wrapper is fully
  lock-protected).
- LandblockMesh.Build (outside the lock): pure math over the dat
  record + the composition-time height table + the immutable
  TerrainBlendingContext record; the shared SurfaceCache is a
  ConcurrentDictionary and BuildSurface is deterministic, so its
  lookup-or-build race is last-write-wins-benign (the code already
  documented exactly this).
- PhysicsDiagnostics probe statics: read-only bools + thread-safe
  Console writes.

MEASURED OUTCOME (gate 4): the timing acceptance did NOT pass, and per
the task contract that is reported, not tuned around. With 8 workers
on this 16-core machine all 625 builds complete in ~203 ms
(ACDREAM_PROBE_TELEPORT BUILD lines t=3475390..3475593) - the producer
is off the critical path - but loaded= still advances at exactly
+32/1000 ms and SUMMARY totalMs measured 27395 and 27503 across two
runs (baseline 26728). The 32/s pacer is in the consumer
admission/publication path and is not governed by the
StreamingWorkBudgetOptions env ceilings. #418 stays IN-PROGRESS on the
consumer side; see docs/ISSUES.md for the evidence chain.

Tests: per-landblock ordering under 4-worker contention, cross-lane
ClearLoads drop, per-lane near-before-far preference, pool-of-1 serial
equivalence, disposal joining every worker, lane-spread guard, and
worker-count validation (LandblockStreamerPoolTests). Two existing
tests asserted a GLOBAL cross-landblock execution order - a serial
implementation detail, not the contract - and now pin workerCount: 1
with justification comments (LoadNear_OvertakesQueuedFarLoads,
TwoQueuedLoads_RetainTheirDistinctOriginAndGeneration).

Gates: Release build 0 errors; App suite 5575 passed / 3 skipped
(5568 + 7 new); Runtime suite 1756/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 19:34:20 +02:00
Erik
0bb47f2711 fix #417: world ambience kept playing at character select after the in-world logoff
The character-session reset manifest had no audio step: retail's logoff
destroys the world's sound sources with the world, but our OpenAL world
pool and ambient scheduler are process-lifetime — the continuous ambient
beds played on at character select and the scheduler kept RE-FIRING
deadlines against the stale listener (Suspend/StopAll had zero callers;
WorldGenerationQuiescence only cycles around teleport-style generation
replaces).

New WorldAudioSessionGate: the reset manifest's 'world audio' step stops
all sixteen world-pool voices (SuspendWorldAudio) and drops every ambient
deadline (StopAll); the pool reopens at the entered-world edge through the
new default-null LiveSessionEnteredWorldBindings.ResumeWorldAudio binding,
invoked first in ApplyEnteredWorld. The ambient soundscape needs no
explicit resume — the next objcell observation rebuilds it exactly as a
cell change always did. Covers logout, reconnect, and full stop uniformly.
UI-pool sounds (interface bank, portal cues) untouched by design.

App tests 5568/3 skips, Runtime 1756/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 18:46:04 +02:00
Erik
91c1962b0d fix #416 #415: the retail button state/media machine — roster hover highlight clears; probe wait verbs bind without an artifact dir
#416 (char-select roster highlight never cleared on hover-leave): three
decomp-grounded mechanisms replace the media-keyed _availableStates
approximation.
- UIElement_Button::UpdateState_ @0x00471CF0: the button machine commits
  ONLY states authored on the button's OWN ElementDesc (AccessStateDesc
  gate); unauthored requests no-op, preserving custom semantic states.
- UIElement::SetState @0x00464E70: an unauthored state id is coerced to
  state 0 (the unnamed base state) and committed — ported into
  UiDatElement.TrySetRetailState with the base-descriptor PassToChildren
  cascade arm.
- The SetState media rule @0x004651c0: a committed state replaces the
  playing media ONLY when its media array is non-empty. UiButton now keeps
  per-face-segment media states under that rule (segments model retail's
  PassToChildren children), and LayoutImporter records the raw MediaCount
  including the File=0 draw-nothing images the drawable filter drops —
  the roster bar children's base state is exactly such an image, and it is
  what clears the bar.
The row template truth (probe, installed DAT): the row authors EMPTY
Normal/rollover/Highlight descriptors with PassToChildren; the three bar
children author rollover/Highlight media, NO Normal state, and a File=0
base image. An empty-media Normal_pressed still never blanks a Normal-art
button (the media rule keeps the previous art — the exact behavior the
old gate approximated), and the Appearance spins' property-only Highlight
now genuinely commits: label recolors, arrow art lingers — the retail
split AP-222 approximated with a requested-keyed label hack, now retired.
Live-verified at char select: hover +alex shows the grey bar, moving off
clears it, the selected row keeps its amber bar.

#415 (probe wait world-* verbs dead): the filed snapshot-reset diagnosis
was wrong — the automation bridge simply never bound without
ACDREAM_AUTOMATION_ARTIFACT_DIR. A facts-only
WorldRevealFactsAutomationRuntime now binds whenever the retained UI
exists; checkpoint/screenshot verbs still require the artifact directory
and now report that instead of a generic timeout.

App tests 5568/3 skips, Runtime 1756/0, UI.Abstractions 926/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 16:25:36 +02:00
Erik
7aa08045d8 fix #414: cursor disappears at character select after the in-world logoff
Session teardown (PlayerModeController.Exit/ResetSession ->
CameraController.ExitChaseMode) fell back to the dev free-fly camera, and
CameraPointerInputController.ApplyCursorForCameraMode faithfully applies
CursorMode.Raw (GLFW disabled cursor: hidden + captured) for fly mode —
so the character-select screen after an in-world logoff had no mouse.
Fresh boot starts in Orbit and never fires a mode change, which is why
only the post-logout path was affected.

Teardown now lands on Mode.Orbit — the exact state a fresh boot presents
at character select — and always notifies, so the pointer controller
restores CursorMode.Normal even when torn down from the dev fly camera.
The dev fly<->chase flow is untouched (it rides ToggleFly, never
ExitChaseMode).

Proven live both directions with a driven logout (UI probe 0x100000FA ->
dialog accept 0x17) under Win32 GetCursorInfo sampling: before, flags
flipped 1->0 exactly at the roster re-push that re-shows character select
and stayed hidden; after, zero hidden samples across the full timeline.
Files #415: the UI-probe 'wait world-visible' verb reads the reset
transit snapshot and is dead after reveal completion (test apparatus
only).

App tests 5564/3 skips (+3), Runtime 1756/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 15:17:50 +02:00
Erik
70f7f72d62 Merge campaign-newline-fix: retail source-level escape normalization
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	docs/architecture/retail-divergence-register.md
2026-08-17 14:21:10 +02:00
Erik
d233f81dce feat(session): the in-world logoff — LogOut animation, reverse wormhole, live return to character select
Retires AD-74 (Exit to Character Selection 'behaves as Exit Game') and
files AD-110 (the composed handoff edge) — register rows in this commit.

Retail derivation (named decomp):
- gmGamePlayUI::UseTime @0x004EA3A0: confirmed Yes drains into
  CPlayerSystem::LogOffCharacter(0) when grounded (transient_state &
  CONTACT); the grounded three-way branch now also covers the
  indicator-bar end-session control (it was Options-only).
- CPlayerSystem::LogOffCharacter @0x00563520: SaveToServer FIRST (the
  existing pre-logoff flush hook), then RequestLogOff @0x00562DD0:
  'Logging off...' chat (type 0), 0xF653 via Proto_UI::LogOffCharacter
  @0x00546A20, logOffRequestTime = now + 3.0 (+20.0 when
  IsPlayerKiller @0x0058C910 — PWD bits 0x20|0x2000000), and
  CommandInterpreter::HandleLogOff @0x006B3330 -> Disable.
- The log-off ANIMATION is server-driven: ACE broadcasts
  MotionCommand.LogOut (0x1000011E, Player.cs:596 SendMotionAsCommands)
  and it plays on the local player through the existing inbound
  unpack_movement funnel during the 3 s hold — retail plays nothing
  locally; Disable() is the whole client-side effect.
- gmSmartBoxUI::UseTime @0x004D6E64: hold elapsed ->
  BeginTeleportAnimation(TAS_WORLD_FADE_OUT) @0x004D6E83 (enter cue
  @0x004D638E, unconditional) -> TunnelFadeIn -> Tunnel. The tunnel
  plays the SAME forward 40 fps animation; nothing renders backwards,
  and NO exit cue ever fires on logout (the char-select swap preempts
  the TunnelContinue/FadeOut tail).
- Inbound 0xF653 echo (dispatch case 3 @0x0055C963) ->
  ExecuteLogOff @0x0055D780: world teardown with the LOGON CONNECTION
  KEPT (ExitWorldDisconnect @0x00541E00 removes every connection
  except logonRecID_ — one connection against ACE) and
  Proto_UI::SetEventCounter(0) @0x00541E79; the fresh CharacterList in
  the same batch re-shows character management (gmGamePlayUI::Update
  @0x004E9CD0 -> QueueUIMode(0x1000000a)). ACE mirrors it:
  SendFinalLogOffMessages (Session.cs:249) sends 0xF653 + CharacterList
  + ServerName >=6 s after the request and leaves the session
  AuthConnected — a second EnterWorld needs no re-handshake.

Implementation:
- RuntimeWorldTransitState: the canonical logout lifecycle
  (Requested/PresentationActive/Confirmed, retail 3 s/+20 s holds,
  cancel/reset/ownership convergence).
- WorldSession: RequestCharacterLogOff (non-blocking 0xF653),
  IsCharacterLogOffConfirmed, ReturnToCharacterSelect (InWorld ->
  InCharacterSelect + game-action sequence reset; transport untouched).
- LiveSessionController: BeginCharacterLogOff (flush-first request) and
  CompleteCharacterLogOff — the return-to-selection transaction
  (ReconnectCore minus the transport swap: retire the world
  generation's routes, host reset, state flip, fresh generation
  re-bind, roster re-applied from the pushed CharacterList; failures
  degrade to the full StopCore teardown).
- RuntimeLocalPlayerMovementState.DisableCommandInterpreter +
  DispatcherMovementInputSource gate: retail's Disable() — held keys
  produce no movement while the server LogOut motion plays; cleared by
  the generation reset.
- LocalPlayerTeleportController: the logout pump as the third arm of
  the one wormhole machine (request/hold/wormhole/confirmed handoff;
  teleport starts refused during logout; the handoff runs the session
  transaction whose world reset retires the tunnel as the fresh
  selection state re-shows the character screen).
- UI: both end-session surfaces share the retail three-way grounded
  gate and now run the REAL flow; Options' Exit Game keeps the app
  exit (window close -> the existing graceful-shutdown logoff).

Tests: +5 transit lifecycle, +4 session transaction, +7 logout pump.
Runtime 1756/0 (baseline 1747), App live-DAT 5523/3 (baseline 5512/3
+ 11 this round), Core.Net 1004/0, full solution green (0 failures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 14:02:40 +02:00
Erik
2bc81480d4 feat(ui): AD-109 — arm the login wormhole at the char-select Enter click
USER-DIRECTED deviation from retail (register row AD-109, same commit):
retail presents the empty pre-player gameplay screen — black behind the
retained UI — from the Enter click (CPlayerSystem::LogOnCharacter
@0x0055F890 -> CM_Login::SendNotice_BeginEnterWorld @0x006AD810, UI mode
0x10000008) until CreatePlayer raises SmartBox::teleport_in_progress
@0x00451C20 and gmSmartBoxUI::UseTime @0x004D6EAB begins TAS_TUNNEL. The
user prefers the tunnel to cover that whole wait.

- ILocalPlayerTeleportNetworkSink.ArmLoginTunnel: begins the login
  wormhole presentation at the Enter click, consuming the sequencer's
  begin-edge events SYNCHRONOUSLY (the Enter command blocks the update
  thread for the whole ServerReady round trip, so a deferred first tick
  would leave exactly the black window this deviation removes). The
  enter cue plays at the click: retail's own rule is cue-at-animation-
  begin (Sound_UI_EnterPortal @0x004D638E, unconditional inside
  BeginTeleportAnimation), and the animation begin moved to the click.
- Armed pre-reveal pump: tunnel animates across the round trip
  (worldReady pinned false, sequencer holds in Tunnel); the hold clock
  accumulates from the click.
- Adoption: the Runtime login reveal ADOPTS the running presentation
  (no re-Begin, no second cue); rejected EnterWorld (lifecycle back to
  AwaitingSelection) disarms and retires the tunnel.
- Wired at the ONE host edge every entry route shares:
  ILiveSessionLifecycleHost.ApplySelectedCharacter (direct connect,
  roster Enter, enter-after-create) via
  LiveSessionSelectionBindings.ArmLoginTunnel (default no-op keeps
  headless and every existing construction site unchanged).
- ILocalPlayerLoginLifecycleSource: typed seam (not a stored delegate —
  the frame-phase owner delegate-field guard) projecting the Runtime
  character-selection lifecycle for the disarm edge.
- Frame contract update: [login-frames] over a login is tunnel -> world
  from the click — no void, and no black between click and world.

Tests: 4 new armed-tunnel tests (arm/adopt/disarm/frame-shape); App
suite live-DAT 5516 passed / 3 skipped (baseline 5512/3 + 4 new).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 13:42:30 +02:00
Erik
967b9c57cf fix(ui): systemic escape normalization at the string source
The exit-world confirmation (ID_Client_EndCharacterSessionConfirm, table
0x23000001 key 0x0EB1C41D) rendered its literal two-character "\n" escapes
because escape decoding lived in individual consumers — Batch E centralized
it for authored captions only (DatWidgetFactory.ResolveAuthoredString), and
each new string surface had to remember its own copy. The installed DAT
carries the escape in 4,365 of 7,050 strings; per-consumer normalization
was structurally guaranteed to keep leaking.

Retail's placement is the SOURCE, not the widget: every public StringInfo
resolution ends in StringTableMetaLanguage::UnescapeString @ 0x0067BDC0
(StringInfo::InqString @ 0x0042E490, GetLiteralValue @ 0x0042CA50), the
write side escapes (SetLiteralValue @ 0x0042C980; AddVariable_String
@ 0x0042E6C0 for template variables), and widgets receive decoded text.
Ported exactly:

- NEW RetailStringEscapes: UnescapeString/EscapeString + the
  GetUnEscapedChar @ 0x0067B750 / GetEscapedChar @ 0x0067B6C0 tables
  (\n \t \r \q + the ten metalanguage self-escapes []!{}#\|^$,
  byte-verified against the PDB-paired 2013 binary at 0x3FE178;
  unrecognized pairs stay verbatim).
- DatStringResolver.Resolve/ResolveAll unescape at the source;
  ResolveTemplate escapes each variable on insert and unescapes the
  composed whole — retail's round trip, so variable content (player
  names) can never be corrupted by the final decode.
- RETIRED the consumer copies (double paths would corrupt an authored
  "\n" into a line break): DatWidgetFactory.NormalizeEscapes + BuildText's
  inline replace, RetailUiRuntime.NormalizeRetailNewlines + the
  OpenCaptureInstructions inline replace, DatRichText.Compose's replace,
  IndicatorDetailText.Shape's replace. ItemAppraisalTextLayout's replace
  stays — WIRE-domain (server strings never pass the DAT source; retail's
  ItemExamineUI::AddItemInfo @ 0x004AC050 appends wire text verbatim), now
  documented as such.
- Consumer CR-strips retired with them: the installed DATs contain ZERO
  real CR characters (sweep-measured) and UiText.WrapWords already drops
  strays.

Tests: RetailStringEscapes conformance (escape set, unknown pairs,
round trip), DatStringResolver source-decode pins (including the exact
user-reported exit-world text shape and a backslash-carrying variable),
the installed-DAT escape sweep (7,050 strings; every resolution must equal
the retail unescape of the raw entry; inventory printed), and the existing
caption/rich-text/live-DAT pins relocated to the source contract.

App 5550/3 (live-DAT), Runtime 1747/0, complete Release solution green
across all suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 13:26:25 +02:00
Erik
fdc4fd496d fix(ui): gate — no void frames around the login wormhole; vitals icons centered
The login tunnel now covers from the first world-facing frame (the
sky-void backdrop can never present pre-tunnel) and holds through an
atomic tunnel-to-world swap at reveal completion — the void is
structurally unreachable on both edges, pinned by frame-sequence tests
across WorldSceneRenderer/WorldRevealCoordinator/LocalPlayerTeleport-
Controller/RuntimeWorldTransitState. Vitals detail icons draw at their
authored centered offsets in both stacked and side-by-side layouts.
Implemented and live-probed by the fix agent; finalized by the lead
after the agent parked post-verification (gates re-run green:
App 5512/3, Runtime 1747/0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 12:30:51 +02:00
Erik
2f8c046aba Merge campaign-enter-portal into the round branch: login portal-space presentation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:21:46 +02:00
Erik
5ca1d47d7a feat(world): the login wormhole — every world entry runs retail's portal-space presentation with sound (TS-28 narrowed)
Retail runs the SAME TAS_TUNNEL wormhole at initial login as at an F751
teleport, with no F751 involved: SmartBox::teleport_in_progress
@0x00451C20 returns 1 the moment the login player exists with
position_update_complete == 0, gmSmartBoxUI::UseTime @0x004D6EAB
edge-detects it into BeginTeleportAnimation(TAS_TUNNEL) @0x004D6EC9
(playing Sound_UI_EnterPortal @0x004D638E), SmartBox::UseTime
@0x00455483 ends the hold once destination cells stop blocking,
Sound_UI_ExitPortal plays at the viewport swap @0x004D7405, and
LoginComplete goes out at the WorldFadeIn end @0x004D745D ->
CPlayerSystem::SendLoginCompleteNotification @0x00562E90 (ACE's own
GameActionLoginComplete comment names this contract: 'called when the
client player exits portal space. It includes initial login'). acdream
skipped all of it at login — every entry route (direct auto-select,
character-select Enter, enter-after-create) dropped onto the sky-only
'waiting for login' backdrop until the world reveal completed.

The fix engages the EXISTING F751 presentation machinery on Runtime's
login reveal — no duplicated presentation code, no timers:

- LocalPlayerTeleportController gains a login arm keyed off the
  Runtime-owned login reveal generation (RuntimeWorldTransitState
  .BeginLoginReveal, begun on the first accepted local-player position
  on every entry route). It drives the same TeleportAnimSequencer/
  PortalTunnelPresentation lifecycle and the same enter/exit cues; the
  Place edge is a no-op at login (the first-entry conductor already
  committed the canonical placement — retail's analogue only flips
  position_update_complete), and FireLoginComplete now performs
  EnterWorld + the single LoginComplete send + reveal Complete, exactly
  like the F751 pump. worldReady is latched on BOTH canonical first
  placement (OnLocalPlayerFirstEntryCompleted, the repointed
  GraphicalSessionEventRoute completion callback that used to send
  LoginComplete immediately) AND destination reveal readiness.
  ActiveDestinationCell now also reports the login destination so the
  render frame's reveal-preparation arm keeps running after portal-space
  entry flips ChaseModeEverEntered.
- PlayerModeController.TryEnterPortalSpaceForLogin performs the
  player-mode presentation attach (the same BuildControllerAndCamera the
  post-reveal auto-entry used to run) before flipping into portal space
  — at login no player-mode entry has happened yet. TryEnterPortalSpace
  itself now refuses (retryable) on a constructed-but-unpublished
  Runtime controller via the documented CanExecuteLiveMovement skip
  predicate instead of faulting — the first connected run crashed on
  exactly that pre-publication State write.
- HouseQuery stays at first-entry completion (retail: tail-called from
  CPlayerSystem::InitializePlayer @0x00563570, an object-arrival edge,
  not a tunnel edge).
- An F751 arriving mid-login-tunnel withdraws the login claim and hands
  the presentation to the portal pump, which owns the single
  LoginComplete — matching retail's one teleportInProgress flag.

TS-28 narrowed: the graphical host now runs the full login wormhole;
the residual is headless-only (no presentation; placement-edge send).

Live gates (testaccount2/+Horan vs local ACE, Release): the
character-select Enter route and the --session-config direct auto-select
route both play the wormhole with Sound_UI_EnterPortal at animation
begin, hold with retail's 'In Portal Space - Please Wait...' notice
until readiness, fade out with the view-plane warp, send LoginComplete
at the WorldFadeIn end, and materialize in Holtburg; ACE-confirmed
graceful logout. Tests: App 5493/3 skips (baseline 5490 + 3 new login
tests), Runtime 1744/0, full solution green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:51:33 +02:00
Erik
7fe83e2bb9 test(ui): vitals — toggle verified through the real UiRoot mouse path
Two end-to-end additions to the fixture toggle suite: a body press through
UiRoot.OnMouseDown's actual hit test + bubble (left toggles, right toggles),
and a press on the authored top drag bar (0x1000063C) arming the window
move WITHOUT toggling — the retail Dragbar-consumes-the-press semantics
proven against the real input path, not just injected OnEvent calls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:51:15 +02:00
Erik
db8fa328dc feat(ui): vitals — Side By Side Vitals swaps retail's two vitals windows
Port of retail's SideBySideVitals character option, derived end-to-end:
retail authors TWO complete vitals windows and swaps their VISIBILITY on
the option bit — nothing is rearranged in place.

- gmFloatySideVitalsUI (0x10000056, Register @0x004D0490) is a second
  full vitals window from LayoutDesc 0x21000075: 460x26, the same three
  meters (0x100000E6/EC/EE), cur/max labels (0x100000EB/ED/EF), and
  detail-icon overlays authored id-for-id with the stacked window — so
  the same VitalsController.Bind and the inherited UiVitalsRoot click
  toggle apply unchanged. Authored constraints ride the root's
  0x3C..0x3F (fixed 26 height, width 360..3000) through
  DatConstraintSource.
- Visibility ownership: gmFloatyVitalsUI::UpdateFromPlayerModule
  @0x004CF140 shows the stacked window iff PlayerModule::SideBySideVitals
  == 0; gmFloatySideVitalsUI::UpdateFromPlayerModule @0x004D0810 shows
  the side row iff set; gmGamePlayUI::RecvNotice_PlayerOptionChanged
  @0x004E9DA0 flips both live on option id 0x13.
- The bit: PlayerModule::SideBySideVitals @0x005D3070 =
  (options_ >> 0x15) & 1 — CharacterOptions1 0x00200000, ACE-confirmed;
  CharacterOptionTable already carried the exact row (PlayerModule-blob
  group, not a 0x0005 auto-save id).

acdream shape: MountSideVitals mounts the second window hidden;
VitalsSideBySideController polls the borrowed J4 option bit once per
frame from RetailUiRuntime.Tick and applies BOTH windows' visibility on
the edge — covering the mount default, the PlayerModule blob arriving
after mount, and the Character tab's live checkbox with one mechanism.
Both window names join stateManagedVisibilityWindows so the saved layout
never restores a visibility the option owns. The Character tab's
SideBySideVitals row un-dims (StoreOnly → Live) with a real reader —
33 dimmed / 17 live.

4 new controller tests (initial apply both directions, live edge swap
both directions, steady-bit non-reassertion). App suite Release live-DAT
5499 passed / 3 skips; Runtime 1744/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:40:49 +02:00
Erik
306a1670d3 feat(ui): vitals — click toggles retail's numeric/graphical detail modes
Port of gmVitalsUI's press toggle, derived end-to-end from the named
retail decomp + the authored DAT data (installed-DAT probe 2026-08-17):

- gmVitalsUI::ListenToElementMessage @0x004BFC00: mouse press (msg 0x1C,
  dwParam1 7=left or 0xA=right — the same param pair the spellbook's
  select/favorite handler @0x0048C033 disambiguates) flips
  SetState(m_state == HideDetail ? ShowDetail : HideDetail). Both floaty
  subclasses (gmFloatyVitalsUI 0x1000004D / gmFloatySideVitalsUI
  0x10000056) inherit it verbatim.
- UIElement::SetState @0x00464E70 cascades through the authored
  PassToChildren chain: root and meters author media-less
  HideDetail/ShowDetail StateDescs with PassToChildren=true.
- HideDetail (0x10000006) = the NUMERIC mode: the cur/max labels author
  {0x3B:false} (0x3B = invisible; UIElement::OnSetAttribute case 8
  @0x00462DAE is SetVisible(value == 0)), the 0x100004A9 overlays author
  File=0.
- ShowDetail (0x10000007) = the GRAPHICAL mode: labels author {0x3B:true}
  (numbers hidden); each bar shows its authored icon pair — dim back icon
  unclipped over the track, bright front icon clipped with the front
  container to the fill fraction (UIElement_Meter::DrawChildren
  @0x0046FBD0 clips the whole element-id-2 child; m_pcChildImage =
  GetChildRecursive(this, 2) @0x0046F7E3). Health heart 0x06007490/91
  (18x16 @66,0), stamina sword 0x06007492/93 (85x16 @32,0), mana scepter
  0x06007494/95 (100x16 @25,0) — identical authoring in both 0x2100006C
  and 0x21000075.
- Initial state is the authored Undef (numbers visible, no icons —
  visually HideDetail); retail's first press lands on HideDetail, then
  the pair toggles forever. NOT persisted: SaveScreenLayout @0x004EAD50
  writes window rects only, and no PlayerModule option is touched — the
  mode resets per session, per window.
- Presses on drag bars / resize grips do not toggle: retail's
  UIElement_Dragbar @0x0046C850 and UIElement_Resizebar @0x0046B930
  consume the press (return 2) before it can bubble to the root.

Implementation: new UiVitalsRoot behavioral widget registered for the
three gmVitals class ids (press handler + state flip over the existing
UiDatElement state machine); UiMeter absorbs the two 0x100004A9 overlays
(ConfigureDetailOverlay + ShowDetail-keyed draw, back unclipped / front
fill-clipped) and forwards the detail states to its absorbed text child;
UiText.ApplyDatState gains the same named-state-only 0x3B honor
UiDatElement already had (the DirectState 0x3B class stays gated — #408).

8 new fixture-driven conformance tests (toggle sequence, right-press,
label cascade, chrome exclusions, per-window independence, overlay
extraction). App suite Release live-DAT: 5495 passed / 3 skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:34:56 +02:00