From 2bc81480d4f8374cbaf237ca44dbf7cd1c99217f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 17 Aug 2026 13:42:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20AD-109=20=E2=80=94=20arm=20the=20lo?= =?UTF-8?q?gin=20wormhole=20at=20the=20char-select=20Enter=20click?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../retail-divergence-register.md | 3 +- .../Composition/SessionPlayerComposition.cs | 7 +- .../Net/LiveSessionRuntimeFactory.cs | 8 +- .../Rendering/LoginPresentationFrameProbe.cs | 11 +- .../RenderFrameResourceController.cs | 12 +- .../LocalPlayerTeleportController.cs | 226 +++++++++++++++++- .../Session/LiveSessionHost.cs | 14 +- ...ityNetworkOnPositionCollapseMatrixTests.cs | 2 + ...yNetworkRemoteTeleportPresentationTests.cs | 2 + .../LocalPlayerTeleportControllerTests.cs | 165 ++++++++++++- 10 files changed, 437 insertions(+), 13 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 59aff0dc..8d97c6af 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -64,7 +64,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 83 active rows (AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 84 active rows (AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -190,6 +190,7 @@ readiness/requeue adaptation. See | AD-90 | **Filed 2026-08-13 at the #389 mechanism-review fix round (finding M1).** Retail's smartbox divisor aspect is not raw width/height: `RenderDevice::ComputeAspectForViewport @0x0054f150` yields `(w/h) × m_DisplayAspectRatio × 0.75`, with `m_DisplayAspectRatio` fed by the registered `Render.AspectRatio` preference. At that preference's DEFAULT (4:3) the factor is exactly 1.0f and the expression collapses to raw w/h — which is what acdream uses. acdream carries no AspectRatio preference at all. Also folded in: retail's `SetFOVRad` gate arithmetic ACCEPTS NaN (x87 unordered-compare quirk) where acdream's port rejects it — unreachable in practice, deliberately not reproduced (mechanism review M3). | `src/AcDream.App/Rendering/RetailFieldOfView.cs` (class doc names this row) | Bit-exact at retail's registered default; the preference existed for 2003-era stretched-CRT correction with no modern counterpart. Reproducing it would add a user knob retail itself defaulted away. | A retail user who had changed `Render.AspectRatio` saw framing acdream cannot reproduce; anyone porting FOV behavior from a capture made with a non-default AspectRatio preference will measure a mismatch against our law. | `RenderDevice::ComputeAspectForViewport @0x0054f150`; `Render::SetFOVRad @0x0054b2d0`; consumer `D3DXMatrixPerspectiveFovLH @0x0059ab71`; docs/research/2026-08-13-389-fov-mechanism-review.md | | AD-91 | **Filed 2026-08-13 at the #390 port.** acdream's display-change clamp covers ALL registered floating windows; retail's does not — every retail floaty overrides `MoveTo` with the clamp `x = max(0, min(x, parentW − selfW))` EXCEPT `gmFloatyChatUI` (floating chats 2–4), which has no clamp and can genuinely strand off-screen on a resolution change (decomp finding, `docs/research/2026-08-13-retail-ui-display-change.md`). The display block's product requirement ("UI windows must stay reachable on resolution change", the 2026-08-13 /goal) overrides the exception. | `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs` (`ClampAllToScreen` — clamps every attached handle, floating chats included) | User-directed reachability beats reproducing a retail defect-shaped gap; the clamp math itself is retail's own, applied uniformly. | A retail-parity comparison that deliberately strands a floating chat window will find acdream rescuing it where retail leaves it lost. | `UIElementManager::RefreshEvent @0x0045C530`; `UIElement::UpdateForParentSizeChange @0x00462640`; the per-floaty `MoveTo` clamp overrides; docs/research/2026-08-13-retail-ui-display-change.md | | AD-92 | **Filed 2026-08-13 at the #376/#388 review fix round (blast M6 / mechanism M4).** Two switcher adaptations with no retail counterpart: (1) the fullscreen refresh rate is the monitor's HIGHEST for the picked WxH — retail passed the device mode's own refresh as-is (`Device::ForceDisplayResolution`); (2) an invalid/unsupported fullscreen request is a logged refusal that leaves the window unchanged — retail attempted the switch and surfaced the device error. The persisted-flag divergence a refusal leaves behind is ISSUES #392. | `src/AcDream.App/Settings/DisplayModeSwitching.cs` (`TryFindRefreshRate`, the refusal paths); `src/AcDream.App/Settings/RuntimeSettingsTargets.cs` (`Apply`'s refused-mode logging) | Highest-refresh is strictly better on modern variable-refresh panels (retail predates them); refuse-and-log is #388's own no-crash requirement. | A capture comparing retail's exact chosen refresh for a mode will differ; a server/tooling flow expecting an error dialog on an invalid mode sees a console line instead. | `Device::ForceDisplayResolution @gmClient::Init 0x004047af`; docs/research/2026-08-13-376-388-{mechanism,blast}-review.md | +| AD-109 | **Filed 2026-08-17 at the entry/exit presentation round (USER-DIRECTED).** Retail presents the empty pre-player gameplay screen — opaque black behind the retained UI — from the char-select 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` (`BeginTeleportAnimation @0x004D6300`, `Sound_UI_EnterPortal` at 0x004D638E). acdream instead ARMS the login wormhole presentation at the Enter click itself (`ILocalPlayerTeleportNetworkSink.ArmLoginTunnel`, invoked from the shared `ApplySelectedCharacter` host edge on all three entry routes: direct connect, roster Enter, enter-after-create) so the tunnel covers the whole EnterWorld round trip; the enter cue moves WITH the animation begin (retail's own cue-at-begin rule) and therefore plays at the click; the running presentation is ADOPTED (not restarted) when the Runtime login reveal begins, and DISARMED if the enter transaction returns to character select (rejected EnterWorld). | `src/AcDream.App/Streaming/LocalPlayerTeleportController.cs` (`ArmLoginTunnel`, `TickArmedLoginTunnel`, the adoption in `TryActivateLoginPresentation`); `src/AcDream.Runtime/Session/LiveSessionHost.cs` (`LiveSessionSelectionBindings.ArmLoginTunnel`) | User preference, 2026-08-17: retail's black CreatePlayer wait reads as a hang; the tunnel is already the login presentation, so covering the wait with it is strictly more continuous. Retiring this row = deleting the arm call and letting the reveal-driven activation begin the presentation, restoring retail's black window. | A retail side-by-side of the Enter edge shows acdream entering the tunnel roughly one server round-trip earlier than retail; any frame-sequence gate must expect tunnel (not black) between the click and the world. | `[login-frames]` probe (`LoginPresentationFrameProbe`); `LocalPlayerTeleportControllerTests` armed-tunnel tests; retail truth: the addresses in this row | | AD-94 | **Filed 2026-08-14 at the secure-trade feature.** Retail's `Event_AcceptTrade` payload (`Trade::Pack @0x005B9FF0`) appends two `PackableList` staged-item lists after the six fixed fields; acdream sends both as ZERO-COUNT lists. ACE parses and then discards the ENTIRE payload (`HandleActionAcceptTrade()` takes zero arguments — server trade state is fully self-derived; lane B §quirks), so the difference is unobservable against ACE; a byte-capture comparison against a real retail client would differ from offset 40. | `src/AcDream.Core.Net/Messages/TradeRequests.cs` (`BuildAcceptTrade`) | The `ContentProfile` pack layout was not byte-verified (ACE never reads it — no reader to check against), and guessing a wire struct violates the workflow; zero-count lists are well-formed `PackableList`s. | A future server that actually validates the accept echo would see empty item lists and could refuse or desync the accept. | `Trade::Pack @0x005B9FF0`; `GameActionAcceptTrade.cs:11-16`; `docs/research/2026-08-14-trade-laneB-wire.md` Table 1 | | AD-96 | **Filed 2026-08-14 at the OP8 re-gate fix round (key-name display).** Retail's `GetNameFromKey_Internal @0x00687800` falls back from the DAT string tables (key enum 4 → `0x2300000A`, meta enum 5 → `0x2300000B`) to the OS keyboard layout's own key name via DirectInput `IDirectInputDevice8::GetObjectInfo` (`tszName` — "SKIFT" on a Swedish layout). acdream reads the SAME layout-resident name data through Win32 `GetKeyNameTextW` instead (no DirectInput device exists in-process); on non-Windows hosts there is no OS lookup at all and the DIK-suffix spelling shows (un-localized English, e.g. "LSHIFT"). Mouse chords keep the pre-existing enum spelling — retail names them through the DirectInput mouse device. | `src/AcDream.App/Platform/PlatformKeyNameProvider.cs`; `src/AcDream.App/UI/Layout/RetailKeyNames.cs` (`Describe`, the mouse-device early-out) | GetKeyNameText and DirectInput's key names both come from the active keyboard-layout tables; adding a DirectInput device solely for name strings would be a heavyweight, dead-end dependency. Linux graphical work is parked at Slice L1. | A key whose GetKeyNameTextW name differs from DirectInput's `tszName` on some layout shows a slightly different caption than retail did; Linux graphical shows English DIK-suffix names where retail-on-Wine would localize; a mouse-chord caption reads as the Silk enum, not retail's device string. | `CInputManager_WIN32::GetNameFromKey_Internal @0x00687800`; `GetNameFromKey @0x00687F40`; `ControlSpecification::GetDIKName @0x0068ACB0`; `DBCache::GetDIDFromEnumStatic` category-4 probe 2026-08-14 (`KeyboardConfigLiveMountProbeTests.ProbeKeyboardFontsAndKeyNameStrings`) | | AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic sprite blit, `Graphic::Draw` (0x00693b20) dispatching to `Graphic::PutImage` (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode (confirmed against `BlitMode`, acclient.h ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. **COMPLETED 2026-08-15 (same gate round, misalignment follow-up):** the first substitution (resize the mounted root + stretch only its own background) stretched the ART but left the authored child widgets at 800x600 pixel positions — misaligned against a background whose painting CARRIES visual anchors (the World/Characters captions are art). The substitution now reproduces retail's whole-frame behavior: the root KEEPS its authored 800x600 extent, and while the screen is active `UiRoot.FixedCanvasSize` scales EVERY emitted quad (widgets, glyphs, art, dialogs) uniformly at `TextRenderer.AppendQuad`, with the exact inverse applied to mouse coordinates at the `UiRoot` entry points so hit-testing lives in canvas space. Non-uniform window/canvas stretch, retail-authentic (no letterbox). `UiDatElement` keeps retail's pure copy-or-tile blit; the interim `StretchOwnBackgroundToFill` flag is deleted. **Campaign CC CC4 review-fix round R1 (2026-08-15): `FixedCanvasSize` now has a single arbiter.** Character-creation can be simultaneously active on top of character-management (both author the same 800x600 canvas), so a raw property write from either controller was a last-writer-wins race with no owner — chargen's own Close() nulled the canvas out from under a still-active character-management screen underneath it. `UiRoot.DeclareFixedCanvas(object owner, Vector2 size)`/`RevokeFixedCanvas(object owner)` now own every production write: each screen declares on its activation edge and revokes on close/deactivate/dispose; the effective size is the current declaration set's value (asserted equal across every concurrent declarer — a future mismatched screen throws instead of silently winning), and it nulls only once EVERY declarer has revoked. The raw `FixedCanvasSize` setter stays public only for `UiRootFixedCanvasTests`' isolated scale-math coverage. | `src/AcDream.App/UI/UiRoot.cs` (`FixedCanvasSize`, `DeclareFixedCanvas`, `RevokeFixedCanvas`, `CanvasScale`, `MapWindowToCanvas`, `Draw`); `src/AcDream.App/Rendering/TextRenderer.cs` (`CanvasScale`, `AppendQuad`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` and `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (both declare/revoke through the arbiter on activate/close/deactivate/dispose) | Reproducing retail's literal mechanism (an offscreen fixed-resolution UI render target scaled at presentation) would add RHI surface area for an identical pixel result; scaling at the one quad-emission chokepoint with an inverse input mapping is the same math applied one stage earlier, and the world-space HUD stays native because the scale is scoped to `UiRoot.Draw`. | Glyphs stretch with the frame (retail-authentic blur at large windows). **Gate round 2 filtering follow-up (2026-08-15):** the stretch now filters bilinearly — `TextureCache.GetOrCreateLinearUiTwin` gives every nearest-sampled UI texture (dat-font glyphs, composited icons) a linear-sampled twin that `TextRenderer.DrawSprite` swaps to while `CanvasScale != One` — matching retail's own bilinear-filtered presentation blit instead of aliasing the point-sampled art. Any future fixed-canvas screen (login/disconnected/datapatch) DECLARES via `UiRoot.DeclareFixedCanvas` while active and REVOKES on close — per-screen opt-in through the arbiter, not automatic and not a raw write. If a genuine present-time frame-stretch pass ever lands, this collapses into it. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020; `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf`; `UiRootFixedCanvasTests`; `CharacterScreensFixedCanvasArbiterTests` (the two-controller arbiter gate); `UiDatElementTests.CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored`; the NON-UNIFORM (no-letterbox) aspect behaviour has no decomp citation of its own (batch review F7) — it is inferred from the mechanism chain and CONFIRMED by the user's live gate pass 2026-08-15 (stretched widescreen look accepted as matching retail memory) | diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index b4a214bd..e12cd58a 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -1020,7 +1020,12 @@ internal sealed class SessionPlayerCompositionPhase presentation, // C4 route 3: the portal arm shares route 2's Runtime // SetPosition drive controller. - acceptedPositionDrive); + acceptedPositionDrive, + // Enter-click round (2026-08-17): resolved per call — the + // armed pre-reveal tunnel projects the Runtime + // character-selection lifecycle to disarm on a rejected + // EnterWorld (see the controller's field doc). + new RuntimeLoginLifecycleSource(d.Runtime)); LocalPlayerTeleportController CreateLocalTeleportWithTunnel( PortalTunnelPresentation portalTunnel) diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 9791477e..a743c41d 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -188,7 +188,13 @@ internal sealed class LiveSessionRuntimeFactory SetChatIdentity: _domain.Communication.Chat.SetLocalPlayerGuid, MarkPersistent: _world.WorldState.MarkPersistent, SetVanishProbeIdentity: id => EntityVanishProbe.PlayerGuid = id, - ClearCombat: _domain.Actions.Combat.Clear), + ClearCombat: _domain.Actions.Combat.Clear, + // Enter-click round (2026-08-17): the login wormhole arms at + // the selected-character edge — before the EnterWorld wire + // send on every entry route — so the tunnel covers the whole + // server round-trip (registered deviation; retail shows + // black until CreatePlayer). + ArmLoginTunnel: _world.Teleport.ArmLoginTunnel), EnteredWorld: new( SetActiveCharacter: _interaction.Settings.SetActiveCharacter, RestoreLayout: () => diff --git a/src/AcDream.App/Rendering/LoginPresentationFrameProbe.cs b/src/AcDream.App/Rendering/LoginPresentationFrameProbe.cs index df2b35ec..a429cd3d 100644 --- a/src/AcDream.App/Rendering/LoginPresentationFrameProbe.cs +++ b/src/AcDream.App/Rendering/LoginPresentationFrameProbe.cs @@ -15,9 +15,14 @@ internal static class RenderPresentationDiagnostics /// classification for the login wormhole edges. When set, every completed /// render frame is classified by WHAT PRESENTED — world / /// tunnel / black / void — and a [login-frames] - /// line is written on every classification transition. The gate contract - /// is retail's: the sequence over a login must contain NO void - /// entry on either edge (black → tunnel → world, each swap atomic). + /// line is written on every classification transition. The gate contract: + /// the sequence over a login must contain NO void entry on either + /// edge, and — since the enter-click round's click-armed tunnel + /// (registered deviation from retail's pre-CreatePlayer black) — no + /// black entry between the Enter click and the world either: the + /// sequence from the click is tunnel → world, each swap atomic. + /// (black remains legal BEFORE the click — it is the + /// character-select screen's own backdrop.) /// Not a user setting; not in RuntimeOptions; not persisted. /// public static bool ProbeLoginFrames { get; } = diff --git a/src/AcDream.App/Rendering/RenderFrameResourceController.cs b/src/AcDream.App/Rendering/RenderFrameResourceController.cs index feef4867..f3d53ee1 100644 --- a/src/AcDream.App/Rendering/RenderFrameResourceController.cs +++ b/src/AcDream.App/Rendering/RenderFrameResourceController.cs @@ -160,8 +160,18 @@ internal sealed class LocalPlayerTeleportRenderStateSource /// backdrop had no retail counterpart and presented as the gate's /// entry-edge VOID (2026-08-17). Both flags flip on the update thread /// (the login activation tick flips ChaseModeEverEntered AND makes the - /// tunnel visible before the next render), so the black → tunnel → world + /// tunnel visible before the next render), so the cover → tunnel → world /// sequence swaps atomically per frame. + /// + /// + /// Enter-click round (2026-08-17): with the click-armed login tunnel + /// (registered user-directed deviation — see + /// ILocalPlayerTeleportNetworkSink.ArmLoginTunnel) the tunnel + /// scene becomes visible AT the Enter click, so retail's bare-black + /// CreatePlayer window normally never presents; the second arm remains + /// the char-select backdrop and the fallback for any unarmed pre-world + /// frame. + /// /// public bool IsPortalViewportVisible => _teleport.IsPortalViewportVisible || _login.IsWaitingForLogin; diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index 3d672184..7a941381 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -40,6 +40,24 @@ internal interface ILocalPlayerTeleportNetworkSink /// void OnLocalPlayerFirstEntryCompleted(); + /// + /// Enter-click round (2026-08-17): arm the login wormhole PRESENTATION at + /// the character-select Enter click (and its direct-connect / + /// enter-after-create equivalents), BEFORE the EnterWorld server + /// round-trip. This is a REGISTERED user-directed deviation from retail: + /// retail shows the empty pre-player gameplay screen (black behind the + /// UI) from CPlayerSystem::LogOnCharacter @ 0x0055F890 / + /// CM_Login::SendNotice_BeginEnterWorld @ 0x006AD810 until + /// CreatePlayer flips SmartBox::teleport_in_progress @ 0x00451C20 + /// and gmSmartBoxUI::UseTime @ 0x004D6EAB begins TAS_TUNNEL. The + /// user prefers the tunnel to cover that whole wait — see the divergence + /// register row added with this method. Invoked from the ONE host edge + /// every entry route shares: ILiveSessionLifecycleHost + /// .ApplySelectedCharacter, which runs immediately before the + /// EnterWorld wire send on all three routes. + /// + void ArmLoginTunnel(); + void ResetSession(); void ResetGenerationPresentation(); @@ -83,6 +101,8 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink public void OnLocalPlayerFirstEntryCompleted() => Required().OnLocalPlayerFirstEntryCompleted(); + public void ArmLoginTunnel() => Required().ArmLoginTunnel(); + public void ResetSession() => Required().ResetSession(); public void ResetGenerationPresentation() => @@ -141,6 +161,30 @@ internal interface ILocalPlayerTeleportAuthority bool IsFreshStart(ushort sequence); } +/// +/// Enter-click round (2026-08-17): the armed pre-reveal login tunnel's read +/// of the Runtime character-selection lifecycle (a typed seam, not a stored +/// delegate — frame-phase owners hold no delegate fields per the GameWindow +/// decomposition invariant). Resolved per call against the live owner. +/// +internal interface ILocalPlayerLoginLifecycleSource +{ + RuntimeCharacterSelectionLifecycle SelectionLifecycle { get; } +} + +/// Production adapter over the canonical GameRuntime owner. +internal sealed class RuntimeLoginLifecycleSource + : ILocalPlayerLoginLifecycleSource +{ + private readonly GameRuntime _runtime; + + public RuntimeLoginLifecycleSource(GameRuntime runtime) => + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + + public RuntimeCharacterSelectionLifecycle SelectionLifecycle => + _runtime.CharacterSelection.Snapshot.Lifecycle; +} + internal sealed class LiveLocalPlayerTeleportAuthority : ILocalPlayerTeleportAuthority { @@ -499,6 +543,18 @@ internal sealed class LocalPlayerTeleportController private long _loginRevealGeneration; private bool _loginPresentationActive; + /// + /// Enter-click round (2026-08-17): true while the login tunnel is armed + /// PRE-REVEAL — from the character-select Enter click (host edge + /// ApplySelectedCharacter, shared by direct connect, roster Enter, + /// and enter-after-create) until the Runtime login reveal adopts the + /// running presentation, or the enter transaction falls back to character + /// select (rejected EnterWorld), or a session/teleport reset withdraws + /// it. Registered deviation from retail's pre-CreatePlayer black — see + /// . + /// + private bool _loginTunnelArmed; + /// /// Latched by — the /// first-entry conductor's canonical initial placement committed. The @@ -511,6 +567,18 @@ internal sealed class LocalPlayerTeleportController private bool _loginPlacementCompleted; private float _loginHoldSeconds; + /// + /// Enter-click round (2026-08-17): resolved PER CALL (never captured — + /// claude-memory/feedback_resolve_deferred_funcs_per_call.md). The armed + /// pre-reveal tunnel projects the Runtime character-selection lifecycle: + /// EnteringWorld/InWorld keep it armed; a regression to + /// AwaitingSelection (rejected EnterWorld — + /// LiveSessionController.EnterHighlightedCore's + /// ReturnToSelection) disarms it so the user is not left staring + /// at a tunnel on the character-select screen. + /// + private readonly ILocalPlayerLoginLifecycleSource _loginLifecycle; + public LocalPlayerTeleportController( ILocalPlayerTeleportAuthority authority, ILocalPlayerTeleportInputLifetime input, @@ -521,7 +589,8 @@ internal sealed class LocalPlayerTeleportController ILocalPlayerTeleportPlacement placement, ILocalPlayerTeleportSession session, ILocalPlayerTeleportPresentation presentation, - RuntimeAcceptedPositionDriveController acceptedPositionDrive) + RuntimeAcceptedPositionDriveController acceptedPositionDrive, + ILocalPlayerLoginLifecycleSource loginLifecycle) { _authority = authority ?? throw new ArgumentNullException(nameof(authority)); _input = input ?? throw new ArgumentNullException(nameof(input)); @@ -534,6 +603,8 @@ internal sealed class LocalPlayerTeleportController _presentation = presentation ?? throw new ArgumentNullException(nameof(presentation)); _acceptedPositionDrive = acceptedPositionDrive ?? throw new ArgumentNullException(nameof(acceptedPositionDrive)); + _loginLifecycle = loginLifecycle + ?? throw new ArgumentNullException(nameof(loginLifecycle)); } public bool IsActive => _transit.IsTeleportActive; @@ -610,6 +681,93 @@ internal sealed class LocalPlayerTeleportController _loginPlacementCompleted = true; } + /// + /// Enter-click round (2026-08-17): begin the login tunnel PRESENTATION at + /// the Enter click, before the EnterWorld server round-trip (registered + /// user-directed deviation — retail presents black here; see + /// ). + /// + /// + /// The enter cue plays HERE, at the click: retail's rule is "cue at the + /// animation begin" (gmSmartBoxUI::BeginTeleportAnimation plays + /// Sound_UI_EnterPortal unconditionally at 0x004D638E), and + /// this deviation moves the animation begin to the click — so the cue + /// moves with it, keeping cue-and-tunnel continuous instead of splitting + /// them across the round-trip. + /// + /// + /// + /// The first sequencer tick is consumed SYNCHRONOUSLY (dt = 0) rather + /// than left to the frame pump: the Enter command that follows this call + /// blocks the update thread for the whole ServerReady round-trip + /// (WorldSession.EnterWorldCore), so a deferred first tick would + /// leave the frame black for exactly the wait this deviation exists to + /// cover. The render thread draws the tunnel scene independently every + /// frame once it is visible. + /// + /// + public void ArmLoginTunnel() + { + ThrowIfDisposed(); + if (_loginTunnelArmed + || _loginPresentationActive + || _transit.IsTeleportActive + || _transit.HasPendingTeleportStart) + { + return; + } + + long generation = _lifetimeGeneration; + _presentation.Begin(_mode.Projection); + if (_lifetimeGeneration != generation) + return; + + var (_, events) = _presentation.Tick(0f, worldReady: false); + if (_lifetimeGeneration != generation) + return; + if (!ProcessArmedLoginTunnelEvents(events, generation)) + return; + + _loginTunnelArmed = true; + _loginHoldSeconds = 0f; + Console.WriteLine("live: login tunnel armed at enter click"); + } + + /// + /// The armed pre-reveal tunnel's event consumer — only the two + /// begin-edge events can occur while worldReady is pinned false + /// (the sequencer holds in Tunnel); anything else is ignored. Returns + /// false when a nested callback retired this lifetime. + /// + private bool ProcessArmedLoginTunnelEvents( + IReadOnlyList events, + long generation) + { + foreach (TeleportAnimEvent teleportEvent in events) + { + switch (teleportEvent) + { + case TeleportAnimEvent.PlayEnterSound: + Console.WriteLine( + "live: login portal-space enter cue " + + "(Sound_UI_EnterPortal)"); + _presentation.PlayEnterCue(); + if (_lifetimeGeneration != generation) + return false; + break; + case TeleportAnimEvent.EnterTunnel: + _presentation.EnterTunnel(); + if (_lifetimeGeneration != generation) + return false; + break; + default: + break; + } + } + + return true; + } + public void Tick(float deltaSeconds) { ThrowIfDisposed(); @@ -1066,14 +1224,26 @@ internal sealed class LocalPlayerTeleportController return; } + // Enter-click round (2026-08-17): a click-armed tunnel is ADOPTED, + // not restarted — the presentation is already running (sequencer in + // its Tunnel hold, tunnel scene visible, enter cue already played at + // the click), so re-Begin here would restart the sequencer and + // double-fire the begin-edge events. The hold clock also carries + // over: the user's wait began at the click. + bool adoptedArmedTunnel = _loginTunnelArmed; + _loginTunnelArmed = false; _loginRevealGeneration = snapshot.Generation; _loginPresentationActive = true; - _loginHoldSeconds = 0f; - _presentation.Begin(_mode.Projection); + if (!adoptedArmedTunnel) + { + _loginHoldSeconds = 0f; + _presentation.Begin(_mode.Projection); + } Console.WriteLine( $"live: login portal-space presentation started " + $"(gen={snapshot.Generation} " - + $"cell=0x{snapshot.Readiness.DestinationCell:X8})"); + + $"cell=0x{snapshot.Readiness.DestinationCell:X8} " + + $"adoptedArmedTunnel={(adoptedArmedTunnel ? 1 : 0)})"); } /// @@ -1108,9 +1278,20 @@ internal sealed class LocalPlayerTeleportController // through TryActivateLoginPresentation above. _loginRevealGeneration = 0; _loginPresentationActive = false; + _loginTunnelArmed = false; _loginHoldSeconds = 0f; _presentation.Reset(); } + else if (_loginTunnelArmed) + { + // Enter-click round (2026-08-17): the pre-reveal armed + // window — from the Enter click until the Runtime login + // reveal begins (CreatePlayer + first accepted position) and + // the activation above claims it. Keeps the tunnel animating + // across the server round-trip; disarms if the enter + // transaction fell back to character select. + TickArmedLoginTunnel(deltaSeconds); + } return; } @@ -1216,6 +1397,42 @@ internal sealed class LocalPlayerTeleportController _presentation.TickTunnel(deltaSeconds); } + /// + /// Enter-click round (2026-08-17): the armed pre-reveal pump. The + /// sequencer holds in its Tunnel state (worldReady pinned false — no + /// reveal exists to be ready), the tunnel scene animates, and the hold + /// clock accumulates from the click. Disarms when the Runtime + /// character-selection lifecycle regresses out of the enter transaction + /// (rejected EnterWorld → AwaitingSelection, or a session teardown → + /// Inactive/Connecting): the character-select screen is in front again + /// and retail shows no tunnel there. + /// + private void TickArmedLoginTunnel(float deltaSeconds) + { + RuntimeCharacterSelectionLifecycle lifecycle = + _loginLifecycle.SelectionLifecycle; + if (lifecycle is not ( + RuntimeCharacterSelectionLifecycle.EnteringWorld + or RuntimeCharacterSelectionLifecycle.InWorld)) + { + _loginTunnelArmed = false; + _loginHoldSeconds = 0f; + _presentation.Reset(); + Console.WriteLine( + $"live: login tunnel disarmed (lifecycle={lifecycle})"); + return; + } + + long generation = _lifetimeGeneration; + _loginHoldSeconds += deltaSeconds; + var (_, events) = _presentation.Tick(deltaSeconds, worldReady: false); + if (_lifetimeGeneration != generation || !_loginTunnelArmed) + return; + if (!ProcessArmedLoginTunnelEvents(events, generation)) + return; + _presentation.TickTunnel(deltaSeconds); + } + /// /// The login pump's currency check — the login mirror of /// : same controller @@ -1368,6 +1585,7 @@ internal sealed class LocalPlayerTeleportController // teleport-scoped reset and clears only with the session. _loginRevealGeneration = 0; _loginPresentationActive = false; + _loginTunnelArmed = false; _loginHoldSeconds = 0f; if (clearSession) _loginPlacementCompleted = false; diff --git a/src/AcDream.Runtime/Session/LiveSessionHost.cs b/src/AcDream.Runtime/Session/LiveSessionHost.cs index 9d977419..66194e77 100644 --- a/src/AcDream.Runtime/Session/LiveSessionHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionHost.cs @@ -14,7 +14,16 @@ public sealed record LiveSessionSelectionBindings( Action SetChatIdentity, Action MarkPersistent, Action SetVanishProbeIdentity, - Action ClearCombat); + Action ClearCombat, + /// Enter-click round (2026-08-17): arms the graphical host's + /// login-wormhole presentation at the selected-character edge — the one + /// host callback every entry route (direct connect, roster Enter, + /// enter-after-create) fires immediately BEFORE the EnterWorld wire + /// send, so the tunnel covers the whole server round-trip (registered + /// user-directed deviation from retail's pre-CreatePlayer black). + /// Default no-op preserves headless and existing construction sites. + /// + Action? ArmLoginTunnel = null); public sealed record LiveSessionEnteredWorldBindings( Action SetActiveCharacter, @@ -252,6 +261,9 @@ public sealed class LiveSessionHost _selection.MarkPersistent(id); _selection.SetVanishProbeIdentity(id); _selection.ClearCombat(); + // Enter-click round (2026-08-17): LAST, after identity wiring — the + // armed tunnel's own logging can then already attribute the session. + _selection.ArmLoginTunnel?.Invoke(); } private void ApplyEnteredWorld(LiveSessionCharacterSelection selection) diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs index 8d8d99ff..61aa3da9 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs @@ -1827,6 +1827,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests public void OnLocalPlayerFirstEntryCompleted() { } + public void ArmLoginTunnel() { } + public void ResetSession() { } public void ResetGenerationPresentation() { } diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs index 1b5eaa34..734aa47e 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs @@ -1061,6 +1061,8 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests public void OnLocalPlayerFirstEntryCompleted() { } + public void ArmLoginTunnel() { } + public void ResetSession() { } public void ResetGenerationPresentation() { } diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs index 66bbd11d..a3ec0f36 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -920,6 +920,21 @@ public sealed class LocalPlayerTeleportControllerTests /// public bool WorldReady; + /// + /// Enter-click round (2026-08-17): the Runtime character-selection + /// lifecycle the click-armed tunnel projects, resolved per call. + /// Defaults to EnteringWorld (an enter transaction in flight — the + /// state at every real arm site); armed-tunnel tests regress it to + /// AwaitingSelection to drive the disarm. + /// + public RuntimeCharacterSelectionLifecycle SelectionLifecycle + { + get => LoginLifecycle.SelectionLifecycle; + set => LoginLifecycle.SelectionLifecycle = value; + } + + public readonly FakeLoginLifecycleSource LoginLifecycle = new(); + public Harness( int centerX = 0x20, int centerY = 0x21, @@ -1048,7 +1063,8 @@ public sealed class LocalPlayerTeleportControllerTests Placement, Session, Presentation, - AcceptedPositionDrive); + AcceptedPositionDrive, + LoginLifecycle); } /// @@ -1458,6 +1474,7 @@ public sealed class LocalPlayerTeleportControllerTests { public List Starts { get; } = []; public int FirstEntryCompletions; + public int LoginTunnelArms; public void OnTeleportStarted(uint sequence) => Starts.Add(sequence); public void OfferDestination( RuntimeTeleportDestination destination, @@ -1465,6 +1482,7 @@ public sealed class LocalPlayerTeleportControllerTests { } public void OnLocalPlayerFirstEntryCompleted() => FirstEntryCompletions++; + public void ArmLoginTunnel() => LoginTunnelArms++; public void ResetSession() { } @@ -1718,11 +1736,156 @@ public sealed class LocalPlayerTeleportControllerTests Assert.False(source.IsPortalViewportVisible); } + // ── Enter-click round (2026-08-17): the click-armed login tunnel ──── + // + // Registered deviation AD-109 (user-directed): retail shows BLACK from + // the char-select Enter click until CreatePlayer begins TAS_TUNNEL + // (gmSmartBoxUI::UseTime @ 0x004D6EAB); acdream arms the same wormhole + // presentation AT the click (ApplySelectedCharacter host edge — before + // the EnterWorld round trip) and the reveal later ADOPTS it. + + [Fact] + public void ArmLoginTunnel_ShowsTunnelAndPlaysCueSynchronously() + { + var order = new List(); + var harness = new Harness(worldReady: false, order: order); + + // The production sequencer queues PlayEnterSound + EnterTunnel at + // Begin; the arm consumes them in the SAME call (the Enter command + // blocks the update thread for the whole round trip afterwards). + harness.Presentation.Enqueue( + TeleportAnimEvent.PlayEnterSound, + TeleportAnimEvent.EnterTunnel); + harness.Controller.ArmLoginTunnel(); + + Assert.Contains("presentation-begin", order); + Assert.Equal(["enter"], harness.Presentation.Cues); + Assert.True(harness.Presentation.IsPortalViewportVisible); + + // Pre-reveal ticks keep the tunnel animating (worldReady pinned + // false — the sequencer holds in Tunnel) and never re-fire the cue. + harness.Controller.Tick(0.016f); + harness.Controller.Tick(0.016f); + Assert.Equal(["enter"], harness.Presentation.Cues); + Assert.True(harness.Presentation.IsPortalViewportVisible); + Assert.All( + harness.Presentation.WorldReadyValues, + value => Assert.False(value)); + Assert.Contains("tunnel-tick", order); + + // Idempotent: a second arm (double-click, re-entrant host edge) + // never restarts the presentation. + int begins = order.Count(entry => entry == "presentation-begin"); + harness.Controller.ArmLoginTunnel(); + Assert.Equal(begins, order.Count(entry => entry == "presentation-begin")); + } + + [Fact] + public void ArmedLoginTunnel_IsAdoptedByTheRevealWithoutRestartOrSecondCue() + { + var order = new List(); + var harness = new Harness(worldReady: false, order: order); + harness.Presentation.Enqueue( + TeleportAnimEvent.PlayEnterSound, + TeleportAnimEvent.EnterTunnel); + harness.Controller.ArmLoginTunnel(); + int beginsAtArm = order.Count(entry => entry == "presentation-begin"); + + // CreatePlayer's first accepted position begins the Runtime login + // reveal; the next tick ADOPTS the running presentation. + harness.Reveal.BeginLogin(0x20210001u); + harness.Controller.Tick(0.016f); + Assert.Equal(1, harness.Mode.EnterPortalCount); + Assert.Equal( + beginsAtArm, + order.Count(entry => entry == "presentation-begin")); + Assert.Equal(["enter"], harness.Presentation.Cues); + Assert.True(harness.Presentation.IsPortalViewportVisible); + Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell); + + // The adopted presentation completes exactly like the reveal-armed + // one: hold ends, viewport swap, one LoginComplete. + harness.WorldReady = true; + harness.Controller.OnLocalPlayerFirstEntryCompleted(); + harness.Controller.Tick(0.016f); + Assert.True(harness.Presentation.WorldReadyValues[^1]); + harness.Presentation.Enqueue(TeleportAnimEvent.Place); + harness.Controller.Tick(0.016f); + harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound); + harness.Controller.Tick(0.016f); + Assert.Equal(["enter", "exit"], harness.Presentation.Cues); + harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete); + harness.Controller.Tick(0.016f); + Assert.Equal(1, harness.Session.LoginCompleteCount); + Assert.True(harness.Reveal.Snapshot.Completed); + } + + [Fact] + public void ArmedLoginTunnel_DisarmsWhenTheEnterFallsBackToSelection() + { + var order = new List(); + var harness = new Harness(worldReady: false, order: order); + harness.Presentation.Enqueue( + TeleportAnimEvent.PlayEnterSound, + TeleportAnimEvent.EnterTunnel); + harness.Controller.ArmLoginTunnel(); + Assert.True(harness.Presentation.IsPortalViewportVisible); + + // Rejected EnterWorld: LiveSessionController.EnterHighlightedCore + // applies the error and returns the lifecycle to AwaitingSelection. + // The armed pump must retire the tunnel — the character-select + // screen is in front again and retail shows no tunnel there. + harness.SelectionLifecycle = + RuntimeCharacterSelectionLifecycle.AwaitingSelection; + harness.Controller.Tick(0.016f); + Assert.False(harness.Presentation.IsPortalViewportVisible); + Assert.Contains("presentation-reset", order); + + // A later successful Enter arms a fresh tunnel. + harness.SelectionLifecycle = + RuntimeCharacterSelectionLifecycle.EnteringWorld; + harness.Presentation.Enqueue( + TeleportAnimEvent.PlayEnterSound, + TeleportAnimEvent.EnterTunnel); + harness.Controller.ArmLoginTunnel(); + Assert.True(harness.Presentation.IsPortalViewportVisible); + Assert.Equal(["enter", "enter"], harness.Presentation.Cues); + } + + [Fact] + public void ArmedLoginTunnel_PresentsTunnelNotBlack_FromTheClickFrame() + { + // AD-109's frame contract: from the Enter click the composed render + // source presents the portal-viewport shape AND the tunnel scene is + // already visible — no bare-black CreatePlayer window. + var harness = new Harness(worldReady: false); + var login = new StubLoginState { IsWaitingForLogin = true }; + var source = new LocalPlayerTeleportRenderStateSource( + harness.Controller, login); + + harness.Presentation.Enqueue( + TeleportAnimEvent.PlayEnterSound, + TeleportAnimEvent.EnterTunnel); + harness.Controller.ArmLoginTunnel(); + Assert.True(source.IsPortalViewportVisible); + Assert.True(harness.Presentation.IsPortalViewportVisible); + } + private sealed class StubLoginState : IRenderLoginStateSource { public bool IsWaitingForLogin { get; set; } } + private sealed class FakeLoginLifecycleSource + : ILocalPlayerLoginLifecycleSource + { + public RuntimeCharacterSelectionLifecycle SelectionLifecycle + { + get; + set; + } = RuntimeCharacterSelectionLifecycle.EnteringWorld; + } + private sealed class FakePresentation : ILocalPlayerTeleportPresentation { private readonly List _order;