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.
MainWindowViewTests.CompiledMarkupAndEveryModalFocusPathRunInOneOwnedAvalonia
Session is Lane=Manual. Measured across five environments on 2026-08-19:
dev desktop PASS
CI Windows box, over SSH PASS
Windows under act_runner FAIL
Linux, plain SSH FAIL
Always the same shape — Test Case Cleanup, 'The calling thread cannot access
this object', while a compositor is being CONSTRUCTED (Compositor..ctor ->
DefaultRenderLoop.Add -> VerifyAccess).
Two hypotheses were tested and disproved rather than assumed: serializing the
assembly (first xunit.runner.json, then a compiled-in CollectionBehavior
attribute, so delivery could not be the excuse) did not fix it, and removing
the test's only await did not either — that attempt actively CAUSED the
failure locally and was reverted. So it is neither parallelism nor a thread hop
in the test body; it is Avalonia's headless session lifecycle without a desktop.
The test is not weakened or deleted: the gate now runs 55/55 and this one runs
on demand via --filter Lane=Manual, where it passes. That matches how the
InstalledDat and Live lanes already work.
MainWindowViewTests kept failing on CI in Test Case Cleanup ('The calling
thread cannot access this object') while passing 56/56 locally. The cause was
delivery, not the fix: xunit.runner.json only takes effect if it is copied
beside the test DLL, and under CI's 'dotnet build' + 'dotnet test --no-build'
split it did not arrive, so CI ran with parallel collections while local runs
did not.
[assembly: CollectionBehavior(DisableTestParallelization = true)] is compiled
into the DLL and cannot fail to deploy. It lives beside the existing
AvaloniaTestApplication/AvaloniaTestIsolation attributes, which document the
same thread-affinity hazard. The json and its csproj copy rule are removed so
there is one source of truth.
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.
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.
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.
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.
Every push to main now runs the gate on the self-hosted runners and, when
green, publishes a Gitea Release carrying the client, launcher+bake, and
manifest.
Pipeline (.gitea/workflows/ci.yml):
- windows-gate runs tools/run-release-gate.ps1, the project's own bounded
gate. A bare `dotnet test AcDream.slnx` is NOT usable as a gate: it fails
~36 tests by design, because the InstalledDat/Live/Manual/OS lanes assert
their own preconditions. The gate script's trait filter is what excludes
them.
- linux-portable runs the portable closure, where the Linux-lane tests
actually execute instead of failing on Windows.
- release depends on both, so a red gate cannot publish. It is a job in the
same workflow rather than a workflow_run trigger, whose Forgejo support is
unreliable; `needs` is guaranteed.
No actions/setup-dotnet: data.forgejo.org does not mirror it at all (404),
and both runners carry the pinned SDK band already. actions/checkout IS
mirrored and is used normally.
Release payloads become release ATTACHMENTS, outside git history, so ~120 MB
per build never enters a branch. Only the ~500-byte manifest.json is
committed, to the payload-free dist branch, because Forgejo has no
/releases/latest/download/ route (verified 404) for the launcher to poll.
publish-bin.ps1 takes -BaseUrl so the manifest points at the release tag.
Two real gate failures fixed:
- LauncherProjectBoundaryTests asserted four `**` path filters belonging to
the push triggers that 8be14d39 removed when workflows went manual-only.
The assertions about what the workflow DOES are untouched.
- MainWindowViewTests failed in Test Case Cleanup with "calling thread cannot
access this object" while passing in isolation: Avalonia's headless session
is thread-affine and xUnit ran collections in parallel. Serialized via
xunit.runner.json, the same settings AcDream.Core.Tests already uses.
Local gate: 12 projects, 14,346 tests, 0 failures.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The launcher reported "no client available" because its update source was
pinned to a GitHub Releases manifest in a PRIVATE repo — nothing anonymous
could ever be fetched from it. Switch the feed to the PUBLIC Gitea repo so a
friend needs no account, and add the two commands that publish it.
- ReleaseManifestClient.ProductionManifestUri now points at
git.snakedesert.se/erik/acdream raw on the `dist` branch. No update
machinery changed: the existing strict reader already accepts any HTTPS
manifest, so this is a URL swap plus a build script.
- tools/publish-bin.ps1 publishes the payloads into /bin and writes
bin/manifest.json (schema v1, SHA-256 + size per artifact):
client-win-x64.zip AcDream.App + acdream-headless
launcher-win-x64.zip acdream-launcher + co-deployed acdream-bake
Stamps InformationalVersion ONLY — never -p:Version, which also rewrites
project-reference versions inside the committed packages.<rid>.lock.json
files and churned every one of them with a throwaway build stamp.
- tools/publish-dist.ps1 pushes /bin to the Gitea-only `dist` branch from a
throwaway worktree, leaving the developer's checkout, index, and HEAD
untouched. It refuses a GitHub remote outright.
Why `dist` and not main: the launcher payload is ~103 MB because the launcher
and its co-deployed bake CLI are each self-contained single files (deliberate,
see AcDream.Launcher.csproj). GitHub hard-rejects files over 100 MB, and all
three refs currently track main, so payloads on main would break every GitHub
push. `dist` is a single-commit orphan branch that each publish REPLACES, so
superseded builds never accumulate. /bin stays gitignored repo-wide and is
force-added only on that branch.
Verified live: manifest and both payloads serve anonymously over HTTPS, and a
downloaded client payload matches its declared SHA-256 and size byte for byte.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
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>
#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>
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>
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>
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>
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>
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>