diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 59aff0dc..c10d51cb 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -64,7 +64,8 @@ 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) — 85 active rows (AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; 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; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- +-to-line-break shaping, which retail's `ItemExamineUI::AddItemInfo @0x004AC050` does not do (wire text appends verbatim; the escape decode retail runs at `StringInfo` resolution now lives at our string source, `DatStringResolver` → `RetailStringEscapes`); 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 @@ -107,6 +108,9 @@ readiness/requeue adaptation. See | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AD-111 | **Filed 2026-08-17 at the systemic escape-normalization round (commit 967b9c57).** The appraisal report's WIRE-string shaping (`ItemAppraisalTextLayout.Shape`) converts a literal two-character ` +` in server-sent fragment text (long description, use text) into a real line break. Retail does NOT: `ItemExamineUI::AddItemInfo @ 0x004AC050` hands wire text straight to `UIElement_Text::AppendTextWithFont` with no `StringTableMetaLanguage::UnescapeString` pass (that decode belongs to `StringInfo` resolution — DAT/authored strings — which the same round ported to `DatStringResolver`/`RetailStringEscapes` as the single source decode), so retail renders a wire backslash-n literally. Pre-existing behavior documented as wire-domain at the same round (it shipped inside the user-accepted Slice-3 assessment surface); the sibling inscription path (`IndicatorDetailText.Shape`) was returned to retail-verbatim in the same commit. | `src/AcDream.App/UI/Layout/ItemAppraisalReport.cs` (`ItemAppraisalTextLayout.Shape`'s domain-commented replace) | Accommodates literal " +" sequences appearing in ACE database strings; server strings carrying REAL line-break characters flow through the same split either way, so the replace only ever fires on content retail would render with a visible backslash-n. | A wire string legitimately containing the two characters backslash+n (a file path, ASCII art in a description) renders with a spurious line break where retail shows it literally. | `ItemExamineUI::AddItemInfo @ 0x004AC050`; `UIElement_Text::AppendTextWithFont` (direct append, no unescape); `StringTableMetaLanguage::UnescapeString @ 0x0067BDC0` (the decode retail applies ONLY at StringInfo resolution) | | AD-108 | **Filed 2026-08-17 at the night-round review fix round (F9); MECHANISM REPLACED the same day at the overnight round's final fix, after live verification found the row's original standalone re-import resolving NOTHING.** Retail authors the Map tab's player-location and house-location icons (`0x100001ED`/`0x100001EE`) as ordinary nested dat children of `m_pMap` (`0x100001EC`) — itself a Type-1 `UIElement_Button`, the GM click-to-teleport feature `gmMapUI::ListenToElementMessage @0x004a2350` idMessage `0x1c` reads — and `gmMapUI::PostInit @0x004a1c70` resolves them as ordinary live child elements. acdream's `UiButton.ConsumesDatChildren` swallows a button's dat children as skin/label parts during the normal import walk, so the two icons never exist in the built tree and `UiElement.FindDescendant` against the page root returns null for them. **The shipped adaptation:** `MapPageController.Bind` finds each icon's `ElementInfo` under `m_pMap`'s own ALREADY-RESOLVED info subtree — `pageInfo`, a subtree of the panel-slot resolve `ImportInfos(dats, 0x2100006E, 0x1000018C)`, the ONLY pathway that materializes these infos at all — and BUILDS it through the new `Bindings.IconBuilder` seam (production: `LayoutImporter.Build(info, ...)` under the DAT lock — the build half of `RowTemplateResolver`'s shape, no import half), attaching the result as a runtime child of the built `m_pMap`. Live-DAT-pinned structural facts (`MapHousePanelLiveDatMountTests`, the pin the original gap proved missing): a cold `ImportInfos(dats, hostLayoutId, iconElementId)` returns null for BOTH icons — its `FindDesc` walks the LayoutDesc's raw top-level `Elements` table (exactly ONE entry for host layout `0x2100006E`) recursing through `ElementDesc.Children`, a purely structural walk with no tab-page/state-descriptor resolution — while the full panel-slot resolve materializes both icons nested under `m_pMap` with real authored extents. (The town-hotspot template `0x100001F0` is different in kind: a genuine standalone catalog entry addressable by `(templateLayoutId, templateElementId)`, whose import-then-build resolution is correct and unchanged.) `ResolveSwallowedIcon` also prefers an icon the normal build walk DID produce (`FindDescendant` under `m_pMap` first), so a future `ConsumesDatChildren` policy change cannot leave a second, permanently-static copy behind the live marker. **Second mechanism half (found by this fix's own F1 live verification):** both icons are detached from the per-frame authored layout pass (`PrepareIcon` sets `Anchors = AnchorEdges.None`, which also clears any imported `LayoutPolicy`) because `PlaceMarker` owns their position outright (retail's `gmMapUI::Update` re-places both markers every tick, and retail's `UpdateForParentSizeChange` runs only on actual parent resize) — acdream re-runs `ApplyAnchor` per frame, and the icon's compatibility anchor had captured the authored `(0,0)` rect while the panel window was still hidden, re-asserting it every frame over PlaceMarker's writes: a live-observed visible green ring pinned to `m_pMap`'s top-left corner regardless of player position, with only the coordinate text correct. | `src/AcDream.App/UI/Layout/MapPageController.cs` (`Bind`'s two `ResolveSwallowedIcon` call sites, `ResolveSwallowedIcon`'s body, `Bindings.IconBuilder`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`MountMapHousePanel`'s `BuildSwallowedIcon`) | The rebuilt icons carry their authored ids and extents — `PlaceMarkerOnMap`'s centering divides the icon's own Width/Height, and the pin test asserts non-degenerate extents on the installed DAT. Their authored local position is irrelevant: `PlaceMarker` overwrites `Left`/`Top` on every 5 s `Refresh`, and each icon starts hidden until the first refresh decides real visibility — same net presentation as retail's find-the-child. | A future DAT regeneration that reauthors the icons OUTSIDE `m_pMap`'s subtree would leave `FindInfo(mapInfo, iconId)` null again — the same silent "[D.2b] … not authored under m_pMap" log-and-hide failure mode this row's original defect had, but now caught by `MapHousePanelLiveDatMountTests` failing on the next suite run instead of only at a connected gate. | `gmMapUI::PostInit @0x004a1c70` (child resolution); `gmMapUI::ListenToElementMessage @0x004a2350` idMessage `0x1c` (confirms `m_pMap` IS a button, not a passive container); `gmMapUI::PlaceMarkerOnMap @0x004a18b0` (the marker math consuming the rebuilt icons) | | AD-106 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system).** Retail's tooltip popup is a separate always-on-top presentation surface — `UIElementManager::StartTooltip @0x00459700` positions and latches it into `m_pTooltipElement`, drawn independently of the ordinary `UIElement` sibling tree (the SAME class of separation the AP-229 register row already establishes for retail's dialogs vs acdream's flat sibling list under one `Host.Root`). `RetailTooltipPresenter` instead mounts the popup as an ordinary `UiRoot` child sibling (`_host.AddChild(root)`) and keeps it topmost by calling `BringToFront` from its OWN `Tick()`, which `RetailUiRuntime.Tick` schedules AFTER both `RetailDialogFactory.Tick()` and `Host.Tick()` in the same frame — guaranteeing the tooltip wins whatever z-order race those two just ran, every frame, regardless of which dialog/screen last called its own `BringToFront`. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`Tick`, `OnTooltipShow`'s `AddChild`/`BringToFront`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`Tick`'s three-call ordering, `MountTooltipPresenter`) | Reproduces the one observable invariant a user can check (tooltips always draw on top of dialogs and screens) without porting retail's literal separate-layer architecture (no second draw pass, no dedicated presentation root) — the SAME tradeoff AP-229 already accepted for dialogs, extended one layer further. The ordering is enforced structurally (three sequential calls in one method), not by convention, so it cannot silently regress from an unrelated edit reordering unrelated `Tick` calls elsewhere. **F10 correction (2026-08-16 review round), two honest additions:** (1) the guarantee is versus dialogs/screens ONLY — `UiRoot.DrawCore`'s own second pass (`ctx.BeginOverlayLayer(); DrawOverlays(ctx); DrawDragGhost(ctx);`) routes open dropdown/menu popups and the drag ghost to a renderer overlay layer that paints over the WHOLE sibling tree unconditionally, so both still paint above a shown tooltip regardless of any `BringToFront` ordering — no z-order fix in the sibling tree can reach that layer. (2) counting the full chain by its own actual participants (not just the three calls local to `RetailUiRuntime.Tick`'s tooltip-adjacent lines), the per-tick `BringToFront` ratchet has FOUR rungs in frame order: `CharacterManagementUiController.Tick`, `CharacterCreationUiController.Tick` (both named in `RetailDialogFactory`'s own GF-15 doc comment as the screens it re-asserts over), `RetailDialogFactory.Tick`, then `RetailTooltipPresenter.Tick`. Four independent per-tick self-reraises stacked by tick ORDER is a design smell — a correct z-order model would need at most one authoritative comparison, not N racing assertions — but is bounded and enumerable in practice (no unbounded surface list, the order is fixed source, not runtime-discovered) so it is left as observed rather than restructured this round. | A FUTURE always-on-top UI surface that calls its own unconditional per-tick `BringToFront` AFTER `TooltipPresenter?.Tick()` in `RetailUiRuntime.Tick`'s ordering could bury a currently-shown tooltip — the exact failure class AP-229 already named for dialogs-vs-screens, now with four layers instead of two. | `UIElementManager::StartTooltip @0x00459700` (`m_pTooltipElement` ownership); AP-229's own dialog/screen precedent | | AD-73 | Filed 2026-08-11 at the Campaign OP OP2 rework (fix round after a double REJECT). `UiTabPanel` (dat Type 8, formerly `UiTabControl`) does NOT perform retail's automatic tab-table wiring / default-page activation at construction. Retail `UIElement_Panel::SetupTabPageHash @0x0046C2E0` + `::Update @0x0046BD00` unconditionally activate the authored default page for ANY instance that carries a tab table. `UiTabPanel` instead stays DORMANT — no click binding, no page-visibility flip, no tab Open/Closed write — until a controller explicitly calls `ActivateTabBehavior()`. | `src/AcDream.App/UI/UiTabPanel.cs` (`ActivateTabBehavior`); factory site `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (Type-8 arm) | Four already-shipped Type-8 hosts author a tab table today — character sheet root `0x10000227`, spellbook root `0x100002A8`, and vendor `0x100000B8` already implement this exact switching in their own C# controllers (`CharacterStatController`/`SpellbookWindowController`/`VendorUiController`); activating `UiTabPanel`'s own copy unconditionally would double-drive the same page-visibility/tab-state writes those controllers already own. Combat `0x100000A2` has no controller at all and is INTENTIONALLY left inert (its 8 stance pages have no switching UI yet) rather than have `UiTabPanel` silently take ownership. Only newly-authored hosts opt in (Options panel, Campaign OP slice OP3+; Configure Keyboard, OP8). This is what let the unconditional Type-8 factory mapping become safe after the OP2 REJECT (`docs/research/2026-08-11-op2-review-blast.md`, `docs/research/2026-08-11-op2-review-mechanism.md`). | A future panel that authors a Type-8 tab table but never gets a controller call to `ActivateTabBehavior()` renders with every tab button at its authored default (Closed) and every page slot at its default `Visible=true` — i.e. every page overlapping, no single active page — instead of retail's exactly-one-visible-page behavior. This is silent unless the diagnostic `UnresolvedEntries`/`BehaviorActive` surface is checked; a controller author who forgets the activation call will see a visually broken tab host, not a crash. | `UIElement_Panel::SetupTabPageHash @0x0046C2E0`; `UIElement_Panel::Update @0x0046BD00`; `UIElement_Panel::OpenTab @0x0046BE20`. ADDENDUM (2026-08-11, re-review closure): `UiTemplateListBox` additionally reports `ConsumesDatChildren = true` where the pre-rework fallback did not — inert against every shipped layout because no Type-5 element in any of the 32 fixtures authors children (now conformance-PINNED in `OP2ReworkBlastRadiusConformanceTests`, so an authored child appearing in a future DAT regeneration fails the build instead of silently vanishing) | @@ -172,7 +176,7 @@ readiness/requeue adaptation. See | ~~AD-70~~ | **RETIRED 2026-08-08 (same day, round-2 cdb capture): the row described retail behavior, not a divergence.** Retail's glide alternates exactly as ours does — the capture measured ~1.5 edge_slide entries per find_transitional_position during the glide (the alternation's exact signature: 3 on the arming tick, 0 on the moving tick), lockstep cliff_slide, step_down at 2.5x, and identical stack paths; cliff_slide's bytes match our port and ACE's. The 'retail redirects within the tick' inference misread round-1's set_sliding_normal cadence (per-event, not per-tick). Issue #347 closed without a code change. **Filed 2026-08-08 with the #345 fix.** Our steep-slope glide alternates: the edge-family arming tick absorbs the request (zero yield) and only the next tick's `AdjustOffset` pre-projection moves, then the clean move clears the sliding normal — a strict two-tick cycle. Retail redirects WITHIN the tick (`edge_slide`/`cliff_slide` 594 each over a ~15 s live glide — every 30 Hz tick, lockstep with `set_sliding_normal` 538) and yields motion every tick. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed` + the insert's post-constraint continuation) | The #345 landing deliberately touched only `validate_walkable`'s return scoping; the response bodies were freshly user-gated (Campaign S) and AD-66 had just relanded in the same block. | Gliding along a too-steep face at ~half retail's lateral speed; direction and angle-scaling correct. Visible as "slides but slower than retail" in a side-by-side. | `345-retail-glide.cdb.log` counters; `Issue345SteepSlopeGlideTests` tick trace; issue #347 | | AD-71 | **Filed 2026-08-08 (reviewer finding on the #345 fix).** `ValidateWalkable`'s walkable test uses the MUTABLE `sp.WalkableAllowance` where retail's `validate_walkable` calls `CPhysicsObj::is_valid_walkable` @0x0050f530 — a FIXED global threshold (N.z >= [0x8ede5c], the walkable constant; the function reads no object state). Several code paths write `WalkableAllowance = LandingZ` (0.0871557 — TransitionTypes.cs:1688,2264, BSPQuery.cs:2330, FlatBspQuery.cs:2085) and `ClearWalkable()` does not restore it, so a stale-permissive value entering a grounded `!StepDown && OnWalkable` validate makes the guard PASS where retail's fails. Every override is permissive, so the #345 fix cannot REGRESS through this path — but for planes with N.z in (0.0872, 0.6642) a stale allowance leaves the old Adjusted-without-push dead loop reachable. The #345 landing GREW this row's blast radius: the operand now gates the return value (OK vs Adjusted), not merely the push (reviewer B, 2026-08-08). Also folds in: our `FloorZ = 0.6642f` vs ACE's 0.66417414f flips OK/Adjusted in a ~0.002-degree band. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ValidateWalkable`, the `walkable` guard operand) | Deliberately not folded into the #345 landing: the allowance plumbing is shared with the step-down family and needs its own conformance pass over every WalkableAllowance write/restore site. | A too-steep plane between LandingZ and FloorZ validated right after a placement/landing path that left the allowance permissive: the guard pushes+Adjusts where retail returns OK — the #345 stop, in a narrower band. | capstone decode of 0x0050f530 (reviewer A, 2026-08-08); `docs/research/2026-08-08-345-d0-branch-pin.md` flagged-secondary section | | AD-72 | **Filed 2026-08-07, Slice 5.3 review corrections (fix 6).** `VendorPricing.BuyPrice`/`SellPrice` compute `rate * perUnitValue * quantity` at C# `double` (64-bit); retail's `ShopSystem::BuyPrice`/`SellPrice` (`0x006B6120`/`0x006B6180`) run the same multiply at x87 `long double` (80-bit extended) — the same narrowing class AD-33 already recorded for `CSequence.FrameNumber`. | `src/AcDream.Core/Items/VendorPricing.cs` (`BuyPrice`/`SellPrice`, the `double raw = (double)rate * perUnitValue * quantity;` line) | `double` is the widest floating-point type available in C# (no 80-bit extended type exists in .NET). The port keeps retail's literal `± 0.1` margin ahead of the floor()/ceil() (see the type's own doc comment) — many orders of magnitude larger than any float/double precision gap at realistic AC item-value magnitudes (rate/value/quantity products in the tens-of-thousands range at most), so the margin absorbs the narrowing before it can move the floor()/ceil() result. | A price computed at a pathological value/rate/quantity combination landing within a double-ULP of the 0.1 margin boundary could floor/ceil to a different integer than retail's 80-bit compute would. No known installed vendor data approaches this boundary. | `ShopSystem::BuyPrice`/`SellPrice` `docs/research/named-retail/acclient_2013_pseudo_c.txt:702082-702128`, `0x006B6120`/`0x006B6180`; AD-33 (same narrowing class, `CSequence.FrameNumber`) | -| AD-74 | **Filed 2026-08-11 at Campaign OP slice OP3 (D6).** The Options panel's "Exit to Character Selection" button (element `0x10000203`) behaves exactly like "Exit Game" (element `0x10000617`) after its own confirmation dialog + mid-air refusal, instead of retail's real behavior — logging the character off and returning to a pre-world character-select screen while keeping the login connection alive. | `src/AcDream.App/UI/RetailUiRuntime.cs` (`RequestExitToCharacterSelection`) | acdream has no pre-world character-select UI and `WorldSession` has no path back to `InCharacterSelect` from in-world — `Dispose()` tears down the entire session/socket (research doc `2026-08-10-keyboard-config-and-gameplay-tab.md` §2.4). Retail's own confirmation dialog (`ID_Client_EndCharacterSessionConfirm`) and mid-air refusal (`ClientTextRefusals.CantLogOffMidAir`) DO port exactly — only the post-confirmation destination differs. | A user clicking "Exit to Character Selection" expecting to pick a different character instead exits the client entirely, same as Exit Game. | `CM_UI::SendNotice_EndCharacterSession`; `gmGamePlayUI::RecvNotice_EndCharacterSession @0x004EBEA0`; `gmGamePlayUI::UseTime @0x004EA3A0` | +| AD-110 | **Filed 2026-08-17 at the entry/exit presentation round (the in-world logoff port).** Retail's return-to-character-select runs on TWO independent inbound edges: the opcode-only `0xF653` echo runs `CPlayerSystem::ExecuteLogOff @0x0055D780` (world teardown, logon connection kept, `Proto_UI::SetEventCounter(0) @0x00541E79`), and the fresh CharacterList in the same server batch drives the UI-mode swap (`gmGamePlayUI::Update @0x004E9CD0` → `QueueUIMode(0x1000000a)`); retail's wormhole would also advance Tunnel → TunnelContinue via `EndTeleportAnimation @0x004D65A0` when the SmartBox loses its player. acdream composes both edges into ONE atomic handoff transaction keyed on the observed `0xF653` confirmation (`LocalPlayerTeleportController.CompleteLogoutHandoff` → `LiveSessionController.CompleteCharacterLogOff`: routes disposed, world generation reset, `WorldSession.ReturnToCharacterSelect`, fresh generation re-bound, roster re-applied from the SAME pushed CharacterList), and its sequencer HOLDS in Tunnel (`worldReady` pinned false) instead of modeling the TunnelContinue advance. Presentationally identical on ACE's timing (confirmation ≥6 s > the 5 s tunnel arrival; the swap always preempts retail's TunnelContinue window and its exit cue on both clients). | `src/AcDream.App/Streaming/LocalPlayerTeleportController.cs` (`TickLogout`, `CompleteLogoutHandoff`); `src/AcDream.Runtime/Session/LiveSessionController.cs` (`CompleteCharacterLogOffCore`) | A split two-edge port would let the UI swap race the world teardown across two frames with no owner; the composed transaction preserves the observable ordering (tunnel up → char select shows → world gone) atomically under the session gate, and ACE's fixed ≥6 s confirmation floor makes the TunnelContinue window unreachable anyway. | If a server ever confirmed the logoff in under ~5 s, retail would swap during WorldFadeOut/TunnelFadeIn while acdream would too (immediate handoff) — same visible result; a server that DELAYED the CharacterList long after the 0xF653 echo would show retail sitting on an empty world and acdream sitting in the tunnel until the composed handoff (which waits for neither — it uses the batch-pushed roster cache). | `gmSmartBoxUI::UseTime @0x004D6E30` (logout begin @0x004D6E83, enter cue @0x004D638E); `CPlayerSystem::RequestLogOff @0x00562DD0`; `ExecuteLogOff @0x0055D780`; ACE `Session.cs:249-278` (`SendFinalLogOffMessages`); `LocalPlayerTeleportControllerTests` logout tests | | AD-75 | **Filed 2026-08-11 at Campaign OP slice OP3 (D5).** Urgent Assistance (`0x10000206`) and Report Abuse (`0x10000207`) never call `ShellExecuteA` against `http://support.turbine.com/ics/support/ticketnewwizard.asp?style=classic` — the endpoint is dead in 2026. Each button instead ALWAYS emits its own byte-verified retail failure body (the `ShellExecuteA`-failure `MessageBoxA` text, `(Error code %d)` dropped since no real Win32 error ever occurs, the URL kept verbatim) through the interface-text seam (`RetailLogTextType.ClientLocal`) instead of a native `MessageBoxA` popup. | `src/AcDream.Core/Chat/OptionsPanelText.cs` (`UrgentAssistanceUnavailable`/`ReportAbuseUnavailable`); `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (button wiring) | The URL genuinely does not resolve to a live Turbine support endpoint; attempting `ShellExecuteA` would open a browser to a dead page rather than usefully fail. The retained failure TEXT is retail's own (byte-verified), just always shown instead of conditionally on a real launch failure, and routed to acdream's existing interface-text channel rather than a modal OS dialog (retail's own EoR-era mechanism has no acdream analogue for a one-off native `MessageBoxA`). | If Turbine ever revives the endpoint, both buttons would still short-circuit instead of opening it — a silent staleness, not a crash. | `gmGameplayOptionsUI::ListenToElementMessage @0x0049E110`; `ShellExecuteA` call sites `0x0049E154`/`0x0049E1F0`; research doc `2026-08-10-keyboard-config-and-gameplay-tab.md` §4.1/§4.2 | | AD-76 | **Filed 2026-08-11 at Campaign OP slice OP3 (D5).** In-Game Help Files (`0x10000205`) is authored and clickable but has no handler — clicking it does nothing visible. | `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (button wiring — no callback bound) | Retail's own `KeyStone::OpenHelp` loads a third-party embedded help viewer (`plugins\ACHelpPlugin.dll` via `keystone.dll`) that acdream does not have and cannot port (no DAT-resident help content, no source). Retail ITSELF fails silently with the plugin absent (`KeyStone::m_fnAC2HelpPluginExecute` unresolved) — mirroring that as an inert button is the faithful behavior for "the asset is missing", not an invented stub screen. | A user clicking In-Game Help Files gets no feedback at all, same as retail with the plugin missing — indistinguishable from a dead button unless they already expect the asset-missing case. | `KeyStone::OpenHelp @0x00557010`; `KeyStone::Init @0x00556CF0` (the unresolved plugin function pointer); research doc `2026-08-10-keyboard-config-and-gameplay-tab.md` §4.5 | | AD-77 | **Filed 2026-08-11 at the Campaign OP OP3 review-fix round (dual-review S4/MUST-FIX 2 — the plan's §5 "out of scope" list explicitly delegated this ruling to the OP3 review).** Retail exposes TWO `gmPanelUI` host variants for the same panel stack — a floating host (`0x2100006E`, `gmFloatyPanelUI`) and a docked host (`0x21000017`) — so a retail user can dock the Options panel (and every other `gmPanelUI` sibling) into a fixed screen position instead of leaving it freely floating. acdream mounts every main panel through `RetailWindowFrame.Mount` + `RetailPanelUiController.RegisterMainPanel` against the floating host ONLY; no code path resolves or mounts `0x21000017` at all. | `src/AcDream.App/UI/RetailUiRuntime.cs` (every `Mount*`/`RegisterMainPanel` call site for a `gmPanelUI` sibling — Character/Inventory/Spellbook/Effects/the four indicator-detail panels/Options); `src/AcDream.App/UI/Layout/RetailWindowFrame.cs` | This predates OP3 — every `gmPanelUI` sibling has shipped floating-only since its own slice landed; OP3 did not introduce the gap, it just added a tenth panel to an already-floating-only cohort. The plan explicitly scoped filing the row to "whichever slice's review deems it a divergence" rather than blocking any one panel's slice on building a docked-host variant no prior panel has either. | A user who expects to dock the Options panel (or any other main panel) the way retail allows cannot — every `gmPanelUI` sibling is floating-only in acdream, client-wide, not an Options-specific gap. | research doc `2026-08-10-options-panel-structure.md` §10.1 (docked/floating host pair); `docs/plans/2026-08-10-options-panel-campaign.md` §5 | @@ -190,6 +194,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/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index ae49bfc2..3a298710 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -56,6 +56,11 @@ internal sealed record InteractionRetainedUiDependencies( HostQuiescenceGate HostQuiescence, RetainedUiInputCaptureSlot RetainedInputCapture, InputDispatcher? InputDispatcher, + /// Logout round (2026-08-17): the construction-order teleport + /// bridge — the retained UI is composed before the teleport controller, + /// so the end-character-session binding reaches the wormhole owner + /// through this deferred sink. + AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink TeleportSink, // Campaign OP slice OP8: the portable keybinds.json path // (ApplicationPathSet.KeyBindingsFile) — the Configure Keyboard screen's // Save button writes here, same file GameWindow's startup load reads. @@ -751,7 +756,14 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory () => late.Session.LinkStatus, d.ClientTime, () => late.Session.CurrentSession?.RequestLinkStatusPing(), - d.Window.Close), + // Logout round (2026-08-17): the in-world logoff flow + // (retail EndCharacterSession — animation + reverse + // wormhole + return to character select), forwarded + // through the construction-order teleport-sink bridge. + EndCharacterSession: d.TeleportSink.RequestLogout, + // Exit Game keeps the app-exit: window close runs the + // graceful-shutdown logoff in WorldSession.Dispose. + ExitGame: d.Window.Close), Toolbar: new ToolbarRuntimeBindings( d.Inventory.Objects, d.Inventory.Shortcuts, diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index b4a214bd..48cc2cfe 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -1020,7 +1020,21 @@ 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), + // Logout round (2026-08-17): the logout arm's Runtime seams + // (wire begin, confirmation, return-to-selection + // transaction, PK hold fact, interpreter disable). + new RuntimeLocalPlayerLogoutOperations( + d.Runtime, + d.PlayerController, + liveSessionSource, + d.Inventory.Objects, + d.PlayerIdentity)); LocalPlayerTeleportController CreateLocalTeleportWithTunnel( PortalTunnelPresentation portalTunnel) diff --git a/src/AcDream.App/Input/DispatcherMovementInputSource.cs b/src/AcDream.App/Input/DispatcherMovementInputSource.cs index 4e196129..876ac7ad 100644 --- a/src/AcDream.App/Input/DispatcherMovementInputSource.cs +++ b/src/AcDream.App/Input/DispatcherMovementInputSource.cs @@ -47,6 +47,15 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource public MovementInput Capture() { + // Logout round (2026-08-17): retail disables the command interpreter + // the moment the logoff request goes on the wire + // (CPlayerSystem::RequestLogOff @ 0x00562E6D -> + // CommandInterpreter::HandleLogOff @ 0x006B3330 -> Disable) — held + // keys stop producing movement while the server-broadcast LogOut + // motion plays. + if (_movement.CommandInterpreterDisabled) + return default; + // Devtools owns the whole gameplay keyboard while active, including // a latched autorun. Retained chat owns physical key state only; // retail's autorun latch continues until an explicit cancel action. 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/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1be03727..41c74483 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1397,6 +1397,7 @@ public sealed class GameWindow : _hostQuiescence, _retainedInputCapture, hostInputCamera.InputDispatcher, + _localPlayerTeleportSink, _applicationPaths.KeyBindingsFile, _runtimeSettings, _runtime, 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..1e0554a9 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -40,6 +40,36 @@ 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(); + + /// + /// Logout round (2026-08-17): the confirmed exit-to-character-select + /// click — retail's gmGamePlayUI::UseTime @ 0x004EA454 → + /// CPlayerSystem::LogOffCharacter(0) drain, forwarded through + /// this construction-order bridge so the retained UI (built before the + /// controller) can reach the one wormhole owner. Refusals are logged by + /// the controller; the grounded gate runs upstream in + /// RetailUiRuntime (the transient_state & CONTACT + /// branch @ 0x004EA445). + /// + void RequestLogout(); + void ResetSession(); void ResetGenerationPresentation(); @@ -83,6 +113,10 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink public void OnLocalPlayerFirstEntryCompleted() => Required().OnLocalPlayerFirstEntryCompleted(); + public void ArmLoginTunnel() => Required().ArmLoginTunnel(); + + public void RequestLogout() => Required().RequestLogout(); + public void ResetSession() => Required().ResetSession(); public void ResetGenerationPresentation() => @@ -141,6 +175,129 @@ 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; +} + +/// +/// Logout round (2026-08-17): the logout pump's typed seams — the wire/session +/// transaction pair on the canonical Runtime session owner, the local-player +/// PK fact, and the retail side effects of the request itself (chat line + +/// command-interpreter disable). See CPlayerSystem::RequestLogOff +/// @ 0x00562DD0 for the retail body these mirror. +/// +internal interface ILocalPlayerLogoutOperations +{ + /// + /// Retail ACCWeenieObject::IsPlayerKiller @ 0x0058C910: PWD + /// bitfield 0x20 (PK) or 0x2000000 (PKLite). Drives the + /// +20 s logoff hold (RequestLogOff @ 0x00562E4E-0x00562E67). + /// + bool IsLocalPlayerKiller { get; } + + /// + /// Retail CPlayerSystem::LogOffCharacter(0) @ 0x00563520 + + /// RequestLogOff @ 0x00562DD0: options flush, "Logging off..." + /// chat line (type 0, AddTextToScroll @ 0x00562DF2), the 0xF653 + /// wire send, and the command-interpreter disable + /// (HandleLogOff @ 0x006B3330). + /// + bool BeginCharacterLogOff(); + + /// The server's opcode-only 0xF653 echo has landed. + bool IsCharacterLogOffConfirmed { get; } + + /// + /// The return-to-character-select session transaction + /// (). + /// + bool CompleteCharacterLogOff(); +} + +/// Production adapter over the canonical Runtime owners. +internal sealed class RuntimeLocalPlayerLogoutOperations + : ILocalPlayerLogoutOperations +{ + private readonly GameRuntime _runtime; + private readonly RuntimeLocalPlayerMovementState _movement; + private readonly ILiveWorldSessionSource _session; + private readonly AcDream.Core.Items.ClientObjectTable _objects; + private readonly ILocalPlayerIdentitySource _identity; + + public RuntimeLocalPlayerLogoutOperations( + GameRuntime runtime, + RuntimeLocalPlayerMovementState movement, + ILiveWorldSessionSource session, + AcDream.Core.Items.ClientObjectTable objects, + ILocalPlayerIdentitySource identity) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _movement = movement ?? throw new ArgumentNullException(nameof(movement)); + _session = session ?? throw new ArgumentNullException(nameof(session)); + _objects = objects ?? throw new ArgumentNullException(nameof(objects)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + } + + public bool IsLocalPlayerKiller + { + get + { + uint bitfield = _objects.Get(_identity.ServerGuid) + ?.PublicWeenieBitfield ?? 0u; + // IsPlayerKiller @ 0x0058C910: (bitfield & 0x20) | (bitfield & + // 0x2000000) — the PK and PKLite PWD bits. + return (bitfield & 0x20u) != 0u || (bitfield & 0x2000000u) != 0u; + } + } + + public bool BeginCharacterLogOff() + { + // The Runtime command runs retail's SaveToServer-first ordering + // (the pre-logoff flush) then sends 0xF653. + if (!_runtime.Session.BeginCharacterLogOff(_runtime.Generation) + .Accepted) + { + return false; + } + + // Retail RequestLogOff's own side effects, in its order: the chat + // line (@ 0x00562DF2, AddTextToScroll(str, 0, 1, 0)) and the + // command-interpreter disable (@ 0x00562E6D). + _runtime.CommunicationOwner.AddText( + "Logging off...", + AcDream.Core.Chat.RetailLogTextType.Default); + _movement.DisableCommandInterpreter(); + return true; + } + + public bool IsCharacterLogOffConfirmed => + _session.CurrentSession?.IsCharacterLogOffConfirmed == true; + + public bool CompleteCharacterLogOff() => + _runtime.Session.CompleteCharacterLogOff(_runtime.Generation) + .Accepted; +} + internal sealed class LiveLocalPlayerTeleportAuthority : ILocalPlayerTeleportAuthority { @@ -337,6 +494,21 @@ internal interface ILocalPlayerTeleportPresentation : IDisposable bool IsPortalViewportVisible { get; } int CurrentTunnelFrame { get; } void Begin(Matrix4x4 projection); + + /// + /// Logout round (2026-08-17): the wormhole run in REVERSE ORDER — + /// retail's BeginTeleportAnimation(TAS_WORLD_FADE_OUT) + /// @ 0x004D6E83. The sequencer enters at WorldFadeOut (the world + /// stays drawn while the view plane pulls in), then TunnelFadeIn → + /// Tunnel; the tunnel scene itself plays the SAME forward 40 fps + /// animation (set_sequence_animation @ 0x004D6F70 runs + /// identically for every tunnel-family entry — nothing renders + /// backwards), and the enter cue plays at this begin + /// (Sound_UI_EnterPortal @ 0x004D638E, unconditional). No exit + /// cue ever plays on logout: the character-select swap preempts + /// retail's TunnelContinue/FadeOut tail. + /// + void BeginLogout(Matrix4x4 projection); (TeleportAnimSnapshot Snapshot, IReadOnlyList Events) Tick(float deltaSeconds, bool worldReady); void TickTunnel(float deltaSeconds); @@ -370,6 +542,12 @@ internal sealed class LocalPlayerTeleportPresentation _animation.Begin(TeleportEntryKind.Portal); } + public void BeginLogout(Matrix4x4 projection) + { + _viewPlane.Begin(projection); + _animation.Begin(TeleportEntryKind.Logout); + } + public (TeleportAnimSnapshot Snapshot, IReadOnlyList Events) Tick(float deltaSeconds, bool worldReady) { @@ -499,6 +677,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 +701,27 @@ 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; + + /// + /// Logout round (2026-08-17): the logout pump's Runtime seams — see + /// . The pump itself is the + /// third arm of retail's ONE wormhole machine + /// (gmSmartBoxUI::UseTime @ 0x004D6E30 drives login, teleport, + /// and logout from the same function). + /// + private readonly ILocalPlayerLogoutOperations _logout; + public LocalPlayerTeleportController( ILocalPlayerTeleportAuthority authority, ILocalPlayerTeleportInputLifetime input, @@ -521,7 +732,9 @@ internal sealed class LocalPlayerTeleportController ILocalPlayerTeleportPlacement placement, ILocalPlayerTeleportSession session, ILocalPlayerTeleportPresentation presentation, - RuntimeAcceptedPositionDriveController acceptedPositionDrive) + RuntimeAcceptedPositionDriveController acceptedPositionDrive, + ILocalPlayerLoginLifecycleSource loginLifecycle, + ILocalPlayerLogoutOperations logout) { _authority = authority ?? throw new ArgumentNullException(nameof(authority)); _input = input ?? throw new ArgumentNullException(nameof(input)); @@ -534,6 +747,9 @@ 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)); + _logout = logout ?? throw new ArgumentNullException(nameof(logout)); } public bool IsActive => _transit.IsTeleportActive; @@ -571,6 +787,16 @@ internal sealed class LocalPlayerTeleportController public void OnTeleportStarted(uint sequence) { ThrowIfDisposed(); + // Logout round (2026-08-17): a logoff in flight owns the wormhole; + // the character is leaving the world and no F751 may supersede the + // logout presentation (ACE does not teleport a logging-off player — + // Player.LogOut sets IsBusy/IsLoggingOut before any motion runs). + if (_transit.IsLogoutActive) + { + Console.WriteLine( + $"live: teleport start ignored during logout (seq={sequence})"); + return; + } ushort teleportSequence = (ushort)sequence; if (!_authority.IsFreshStart(teleportSequence) || !_transit.CanQueueTeleportStart(teleportSequence)) @@ -610,9 +836,286 @@ 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; + } + + // ── Logout round (2026-08-17): the logout arm of retail's ONE wormhole + // machine. Retail derivation: + // gmGamePlayUI::UseTime @ 0x004EA3A0 — confirmed Yes drains into + // CPlayerSystem::LogOffCharacter(0) when the player is grounded + // (transient_state & CONTACT — the upstream RetailUiRuntime gate). + // CPlayerSystem::LogOffCharacter @ 0x00563520 — SaveToServer first. + // CPlayerSystem::RequestLogOff @ 0x00562DD0 — "Logging off..." chat, + // 0xF653 send, logOffRequestTime = now + 3.0 (+20.0 PK), + // CommandInterpreter::HandleLogOff @ 0x006B3330 → Disable. The + // SERVER then broadcasts the LogOut motion (ACE Player.cs:596 → + // SendMotionAsCommands), which plays on the local player through + // the ordinary inbound movement funnel during this hold. + // gmSmartBoxUI::UseTime @ 0x004D6E64 — hold elapsed → + // BeginTeleportAnimation(TAS_WORLD_FADE_OUT) (enter cue) → + // TunnelFadeIn → Tunnel. + // Inbound 0xF653 echo (dispatch case @ 0x0055C963) → + // CPlayerSystem::ExecuteLogOff @ 0x0055D780 — world teardown with + // the logon connection kept; the fresh CharacterList in the same + // server batch re-shows character management + // (gmGamePlayUI::Update @ 0x004E9CD0 → QueueUIMode(0x1000000a)). + // No exit cue: the swap preempts the TunnelContinue/FadeOut tail. + + /// The sink-forwarded UI entry — see + /// . + public void RequestLogout() + { + if (!TryRequestLogout()) + Console.WriteLine("live: character logoff request refused"); + } + + /// + /// The confirmed exit-to-character-select click. Returns false when a + /// logout, teleport, or login presentation already owns the machine or + /// the wire request refused. + /// + public bool TryRequestLogout() + { + ThrowIfDisposed(); + if (_transit.IsLogoutActive + || _transit.IsTeleportActive + || _transit.HasPendingTeleportStart + || _loginPresentationActive + || _loginTunnelArmed) + { + return false; + } + + if (!_transit.TryBeginLogoutRequest(_logout.IsLocalPlayerKiller)) + return false; + + long generation = _lifetimeGeneration; + if (!_logout.BeginCharacterLogOff()) + { + // Nothing went on the wire — roll the request back rather than + // running a wormhole for a logoff the server never heard. + if (_lifetimeGeneration == generation) + _transit.CancelLogoutRequest(); + return false; + } + if (_lifetimeGeneration != generation) + return true; + + // Retail's Disable() also ends any mouse-driven turning; the same + // input-lifetime call every teleport start already makes. + _input.EndMouseLook(); + Console.WriteLine("live: character logoff requested"); + return true; + } + + /// + /// Per-frame logout pump — the third arm of the wormhole machine (see + /// the derivation block above). Confirmation is polled every tick in + /// every pre-confirmed stage; on the Confirmed edge the handoff runs + /// IMMEDIATELY, exactly like retail's ExecuteLogOff-on-echo — the + /// character-select swap tears down whatever presentation state exists + /// (normally the held tunnel; on a fast confirmation, less). + /// + private void TickLogout(float deltaSeconds) + { + long generation = _lifetimeGeneration; + + if (_transit.LogoutStage is RuntimeLogoutStage.Requested + or RuntimeLogoutStage.PresentationActive + && _logout.IsCharacterLogOffConfirmed) + { + _transit.AcknowledgeLogoutConfirmed(); + } + + switch (_transit.LogoutStage) + { + case RuntimeLogoutStage.Requested: + // The 3 s (23 s PK) hold: the server-broadcast LogOut + // motion is playing on the player in-world. + if (_transit.AdvanceLogoutHold(deltaSeconds)) + { + _presentation.BeginLogout(_mode.Projection); + if (_lifetimeGeneration != generation) + return; + PumpLogoutPresentation(0f, generation); + } + return; + case RuntimeLogoutStage.PresentationActive: + PumpLogoutPresentation(deltaSeconds, generation); + return; + case RuntimeLogoutStage.Confirmed: + CompleteLogoutHandoff(generation); + return; + default: + return; + } + } + + private void PumpLogoutPresentation(float deltaSeconds, long generation) + { + var (_, events) = _presentation.Tick(deltaSeconds, worldReady: false); + if (_lifetimeGeneration != generation) + return; + + foreach (TeleportAnimEvent teleportEvent in events) + { + switch (teleportEvent) + { + case TeleportAnimEvent.PlayEnterSound: + // Sound_UI_EnterPortal @ 0x004D638E — unconditional at + // BeginTeleportAnimation, INCLUDING the logout's + // TAS_WORLD_FADE_OUT entry. + Console.WriteLine( + "live: logout portal-space enter cue " + + "(Sound_UI_EnterPortal)"); + _presentation.PlayEnterCue(); + if (_lifetimeGeneration != generation) + return; + break; + case TeleportAnimEvent.EnterTunnel: + _presentation.EnterTunnel(); + if (_lifetimeGeneration != generation) + return; + break; + default: + // worldReady is pinned false: Place / PlayExitSound / + // FireLoginComplete cannot fire (the sequencer holds in + // Tunnel), matching retail's preempted logout tail. + break; + } + } + + _presentation.TickTunnel(deltaSeconds); + } + + private void CompleteLogoutHandoff(long generation) + { + if (!_transit.CompleteLogout() || _lifetimeGeneration != generation) + return; + + Console.WriteLine( + "live: logout confirmed — returning to character select"); + if (_logout.CompleteCharacterLogOff()) + { + // The transaction's world reset already ran this controller's + // ResetGenerationPresentation (retiring the tunnel) and the + // fresh selection state re-shows the character-management + // screen — retail's QueueUIMode(0x1000000a) analogue. + return; + } + + // The transaction refused or degraded to a full stop. If a reset + // reached this controller the lifetime moved and everything is + // already clean; otherwise retire the presentation here so a + // refused transaction can never leave a stranded tunnel over a + // still-running world. + if (_lifetimeGeneration == generation) + { + Console.Error.WriteLine( + "live: return-to-character-select refused — retiring the " + + "logout presentation"); + _presentation.Reset(); + } + } + public void Tick(float deltaSeconds) { ThrowIfDisposed(); + // Logout round (2026-08-17): an active logout owns the whole + // wormhole machine, exactly as retail's one teleportInProgress flag + // does (SetTeleportInProgress(1) at the logout begin, + // gmSmartBoxUI::UseTime @ 0x004D6E8C). Teleport starts are refused + // while it runs (OnTeleportStarted's own guard). + if (_transit.IsLogoutActive) + { + TickLogout(deltaSeconds); + return; + } + TryActivatePendingPresentation(); TryAimAcceptedDestination(); if (!_transit.IsTeleportActive) @@ -1066,14 +1569,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 +1623,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 +1742,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 +1930,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.App/UI/Layout/DatRichText.cs b/src/AcDream.App/UI/Layout/DatRichText.cs index 60b10bc3..b1b8e333 100644 --- a/src/AcDream.App/UI/Layout/DatRichText.cs +++ b/src/AcDream.App/UI/Layout/DatRichText.cs @@ -19,17 +19,16 @@ namespace AcDream.App.UI.Layout; /// /// The description pages used to bypass this entirely: they assigned a raw /// LinesProvider lambda returning ONE unwrapped -/// per composed string, with no escape-normalize and no word-wrap. Two -/// concrete symptoms this caused: literal two-character "\n" -/// escapes rendered as backslash-n instead of a real line break (the DAT -/// stores that literal escape — DatWidgetFactory.BuildText's own -/// authored-string path already normalizes it for single-element authored -/// captions; this helper reproduces the SAME normalize for -/// runtime-composed multi-segment text), and — for the Town page -/// specifically — an unwrapped single line meant the town-specific SUFFIX -/// of the composed string rendered far outside the box's clipped viewport, -/// so switching towns looked like "the text never changes" even though the -/// underlying string genuinely did (only its INVISIBLE tail differed). +/// per composed string, with no word-wrap. Historical symptom (Batch C): +/// for the Town page an unwrapped single line meant the town-specific +/// SUFFIX of the composed string rendered far outside the box's clipped +/// viewport, so switching towns looked like "the text never changes" even +/// though the underlying string genuinely did (only its INVISIBLE tail +/// differed). Escape decoding (the DAT's literal two-character "\n", +/// Batch C's other symptom) has since moved to the string source +/// (, +/// the 2026-08-17 systemic round) — segments reach this composer with real +/// line breaks already in place. /// /// internal static class DatRichText @@ -41,7 +40,7 @@ internal static class DatRichText public readonly record struct Segment(string? Text, Vector4 Color); /// - /// Escape-normalizes and word-wraps every segment (independently, so + /// Word-wraps every segment (independently, so /// each segment's wrapped lines keep ITS OWN color), then concatenates /// the results in order. No separator is inserted between segments — /// retail's own composition calls concatenate directly @@ -76,15 +75,12 @@ internal static class DatRichText if (string.IsNullOrEmpty(segment.Text)) continue; - // The installed DAT stores the LITERAL two-character escape - // "\n" (0x5C 0x6E), not a real line break — same normalize - // DatWidgetFactory.BuildText's authored-string path already - // applies for single-element authored captions. - string normalized = segment.Text - .Replace("\\n", "\n") - .Replace("\r", string.Empty); - - foreach (string wrapped in UiText.WrapWords(normalized, measure, maximumWidth)) + // Escape decoding (the DAT's literal two-character "\n") happens + // at the string source (DatStringResolver → RetailStringEscapes, + // 2026-08-17 systemic round — retail's own placement), so + // segments arrive with real line breaks; WrapWords preserves + // them and drops any stray CR itself. + foreach (string wrapped in UiText.WrapWords(segment.Text, measure, maximumWidth)) lines.Add(new UiText.Line(wrapped, segment.Color)); } diff --git a/src/AcDream.App/UI/Layout/DatStringResolver.cs b/src/AcDream.App/UI/Layout/DatStringResolver.cs index 4932c47a..70f3df7f 100644 --- a/src/AcDream.App/UI/Layout/DatStringResolver.cs +++ b/src/AcDream.App/UI/Layout/DatStringResolver.cs @@ -9,9 +9,23 @@ namespace AcDream.App.UI.Layout; /// The caller owns synchronization around reads. /// /// +/// /// Retail reference: StringInfo::GetString and /// compute_str_hash @ 0x00413110. A StringInfo's token selects one /// localized string variant; ordinary UI labels use token zero. +/// +/// +/// Every resolution decodes the DAT's two-character escapes +/// (\n, \t, \r, \q, and the metalanguage +/// self-escapes) HERE, at the source — retail's own placement: every public +/// StringInfo resolution ends in +/// StringTableMetaLanguage::UnescapeString @ 0x0067BDC0 +/// (StringInfo::InqString @ 0x0042E490, +/// StringInfo::GetLiteralValue @ 0x0042CA50). Consumers receive +/// already-decoded text and must not re-decode — see +/// ' remarks for the double-decode hazard +/// (the 2026-08-17 systemic round that retired the per-consumer copies). +/// /// public sealed class DatStringResolver { @@ -41,7 +55,9 @@ public sealed class DatStringResolver return null; int index = token >= 0 && token < entry.Strings.Count ? token : 0; - return entry.Strings[index].Value; + // StringInfo::InqString @ 0x0042E490's unconditional tail: the stored + // string is escaped; the resolved string is decoded. + return RetailStringEscapes.Unescape(entry.Strings[index].Value); } /// Returns every literal token for one retail StringInfo entry. @@ -57,7 +73,9 @@ public sealed class DatStringResolver return table is not null && table.Strings.TryGetValue(stringId, out var entry) && entry.Strings.Count != 0 - ? entry.Strings.Select(value => value.Value).ToArray() + ? entry.Strings + .Select(value => RetailStringEscapes.Unescape(value.Value)) + .ToArray() : null; } @@ -105,14 +123,21 @@ public sealed class DatStringResolver { composed.Append(entry.Strings[i].Value); // Variables are stored as the pre-computed name hashes (the same - // compute_str_hash space PlayerVariable lives in). + // compute_str_hash space PlayerVariable lives in). Each value is + // escaped on insert — retail's AddVariable_String @ 0x0042E6C0 + // stores every variable through SetLiteralValue(escape=1) + // @ 0x0042C980 → EscapeString — so the final whole-string + // unescape below returns variable content verbatim while + // decoding the authored fragments' escapes. if (i < entry.Variables.Count && variables.TryGetValue(entry.Variables[i], out string? value)) { - composed.Append(value); + composed.Append(RetailStringEscapes.Escape(value)); } } - return composed.ToString(); + // StringInfo::InqString @ 0x0042E490's unconditional tail, same as + // Resolve above: composed text decodes its escapes at the source. + return RetailStringEscapes.Unescape(composed.ToString()); } /// diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 29b71eeb..9713c159 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -856,9 +856,11 @@ public static class DatWidgetFactory { // 2026-08-13 social gate: authored strings can carry embedded // newlines (the fellowship empty-state is three sentences over - // '\n's). Gate round 2: the DAT stores the LITERAL two-character - // escape "\n" (0x5C 0x6E — probe-verified: the dump printed - // backslash-n, not a line break), so normalize the escape first. + // '\n's). The DAT stores those as the LITERAL two-character + // escape "\n" (0x5C 0x6E — probe-verified), decoded at the + // string SOURCE since the 2026-08-17 systemic round + // (DatStringResolver → RetailStringEscapes; retail's own + // placement) — `authored` arrives with REAL line breaks here. // Gate round 3: retail additionally WORD-WRAPS each authored line // within the element extent (its GlyphList draw — the same wrap // the confirmation dialog view already uses), so a multiline @@ -868,10 +870,7 @@ public static class DatWidgetFactory // re-wrapping them is a client-wide behavior change no gate has // asked for). Providers re-read DefaultColor/width/font per call // (NOT captured eagerly) so state-driven changes keep tracking. - string normalized = authored - .Replace("\\n", "\n") - .Replace("\r", string.Empty); - if (normalized.Contains('\n')) + if (authored.Contains('\n')) { float cachedWidth = float.NaN; UiDatFont? cachedFont = null; @@ -897,7 +896,7 @@ public static class DatWidgetFactory ? font.MeasureWidth : static value => value.Length * 8f; cachedLines = [.. UiText - .WrapWords(normalized, measure, maximumWidth) + .WrapWords(authored, measure, maximumWidth) .Select(line => new UiText.Line(line, t.DefaultColor))]; } return cachedLines; @@ -906,7 +905,7 @@ public static class DatWidgetFactory else { t.LinesProvider = () => - [new UiText.Line(normalized, t.DefaultColor)]; + [new UiText.Line(authored, t.DefaultColor)]; } } @@ -921,7 +920,7 @@ public static class DatWidgetFactory || !state.Properties.Values.TryGetValue(0x17u, out var stateCaption) || stateCaption.Kind != UiPropertyKind.StringInfo) continue; - if (NormalizeEscapes(stringResolve?.Invoke(stateCaption.StringInfoValue)) + if (stringResolve?.Invoke(stateCaption.StringInfoValue) is { Length: > 0 } text) (stateStrings ??= new Dictionary())[stateId] = text; } @@ -1220,6 +1219,20 @@ public static class DatWidgetFactory .OrderBy(child => child.ReadOrder) .ToArray(); + /// + /// Resolves the effective authored caption (dat property 0x17) + /// for a widget. Escape decoding is NOT done here: since the 2026-08-17 + /// systemic round the string SOURCE ( → + /// , retail's own placement — every + /// StringInfo resolution ends in + /// StringTableMetaLanguage::UnescapeString @ 0x0067BDC0) hands + /// every consumer already-decoded text. That supersedes R2-2 (Campaign + /// CC gate round 1 Batch E)'s consumer-level normalize, which covered + /// only the P0x17 resolutions in THIS file and missed sibling consumers + /// (the exit-world confirmation dialog, gate round 2) — the exact class + /// of bug source placement closes. Re-decoding here would corrupt an + /// authored \\n (escaped backslash then 'n') into a line break. + /// private static string? ResolveAuthoredString( ElementInfo info, Func? stringResolve) @@ -1228,42 +1241,16 @@ public static class DatWidgetFactory || !info.TryGetEffectiveProperty(0x17u, out var property) || property.Kind != UiPropertyKind.StringInfo) return null; - string? resolved = stringResolve(property.StringInfoValue); - // R2-2 (Campaign CC gate round 1 Batch E): the DAT stores the LITERAL - // two-character escape "\n" (0x5C 0x6E), not a real line break — same - // fact BuildText's own authored-string path already normalized for - // (see that call site's own comment). Centralizing the normalize - // HERE, at the single choke point every P0x17 caption resolution in - // this file goes through (BuildText, BuildButton's own caption AND - // its lifted-child caption, BuildButton's coexisting ValueLabel, - // BuildCheckbox), closes the exact class of bug R2-2 found: a caption - // like the Profession credits button's own "Attribute\n Credits" - // rendered the literal backslash-n because BuildButton never - // normalized while BuildText did. BuildText's own subsequent - // Replace("\\n","\n") is now a harmless no-op (idempotent) — left in - // place rather than removed, since it costs nothing and documents the - // same fact locally. - return NormalizeEscapes(resolved); + return stringResolve(property.StringInfoValue); } - /// - /// R2-2 (Campaign CC gate round 1 Batch E): the shared escape-normalize - /// applies, pulled out so the - /// per-STATE authored-caption loop below (which resolves a state's own - /// 0x17 directly, bypassing the effective-property resolution - /// wraps) gets the SAME normalize - /// instead of a second, easily-forgotten copy. - /// - private static string? NormalizeEscapes(string? raw) => - raw?.Replace("\\n", "\n").Replace("\r", string.Empty); - /// /// #409 (client-wide retail tooltip system): resolves the already- /// extracted (dat property - /// 0x49) through , applying the - /// SAME escape normalization every other authored StringInfo - /// (captions, 0x17) gets at this one choke point. Null when the - /// element authors no tooltip text or no resolver is available. + /// 0x49) through . Arrives + /// escape-decoded from the string source, like every authored + /// StringInfo (see ). Null + /// when the element authors no tooltip text or no resolver is available. /// internal static string? ResolveTooltipText( ElementInfo info, @@ -1271,6 +1258,6 @@ public static class DatWidgetFactory { if (stringResolve is null || info.TooltipText is not { } tooltipText) return null; - return NormalizeEscapes(stringResolve(tooltipText)); + return stringResolve(tooltipText); } } diff --git a/src/AcDream.App/UI/Layout/IndicatorDetailText.cs b/src/AcDream.App/UI/Layout/IndicatorDetailText.cs index baf4b5b2..f47d0af8 100644 --- a/src/AcDream.App/UI/Layout/IndicatorDetailText.cs +++ b/src/AcDream.App/UI/Layout/IndicatorDetailText.cs @@ -23,8 +23,13 @@ internal static class IndicatorDetailText ?? value.Length * 8f; var lines = new List(); - string normalized = text.Replace("\\n", "\n", StringComparison.Ordinal); - foreach (string paragraph in normalized.Split('\n')) + // DAT-resolved bodies (vitae, link status, effects) arrive with + // real line breaks — escapes decode at the string source + // (DatStringResolver → RetailStringEscapes, 2026-08-17 systemic + // round). Wire-sourced text (the appraisal inscription) renders + // verbatim, exactly like retail's ItemExamineUI::AddItemInfo + // @ 0x004AC050 → UIElement_Text::AppendTextWithFont direct append. + foreach (string paragraph in text.Split('\n')) { if (paragraph.Length == 0) { diff --git a/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs b/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs index 5f062813..aa3094bc 100644 --- a/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs +++ b/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs @@ -157,6 +157,14 @@ internal static class ItemAppraisalTextLayout } Vector4 color = ResolveColor(target, fragment.Style); + // WIRE-domain normalize — deliberately NOT the DAT source + // decode (DatStringResolver → RetailStringEscapes, 2026-08-17 + // systemic round): appraisal fragments are server strings + // (long description, use text, inscription), which never pass + // the DAT string source, so this is not a duplicate path. It + // accommodates literal "\n" sequences in ACE's database + // strings; server strings with REAL line breaks flow through + // the Split below either way. string normalized = fragment.Text.Replace( "\\n", "\n", diff --git a/src/AcDream.App/UI/Layout/RetailStringEscapes.cs b/src/AcDream.App/UI/Layout/RetailStringEscapes.cs new file mode 100644 index 00000000..1a239f9d --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailStringEscapes.cs @@ -0,0 +1,140 @@ +using System.Text; + +namespace AcDream.App.UI.Layout; + +/// +/// Exact port of retail's string-table escape codec +/// (StringTableMetaLanguage::UnescapeString @ 0x0067BDC0 / +/// EscapeString @ 0x0067BBC0 and their character tables +/// GetUnEscapedChar @ 0x0067B750 / GetEscapedChar @ 0x0067B6C0). +/// +/// +/// +/// PLACEMENT (the systemic 2026-08-17 normalization round): retail decodes +/// escapes at the string SOURCE, not per-widget. Every public +/// StringInfo resolution runs the unescape unconditionally before any +/// consumer sees the text — StringInfo::InqString @ 0x0042E490 tail +/// and StringInfo::GetLiteralValue @ 0x0042CA50 both end in +/// UnescapeString. The write side is the inverse: +/// StringInfo::SetLiteralValue @ 0x0042C980 runs EscapeString +/// when storing plain text (and StringInfo::AddVariable_String +/// @ 0x0042E6C0 always stores variables that way), so stored text is +/// escaped, resolved text is decoded, and variable content round-trips +/// verbatim. acdream's equivalent source is ; +/// widgets and controllers receive already-decoded strings and must not +/// re-decode (a second pass corrupts an authored \\n — escaped +/// backslash then 'n' — into a line break). +/// +/// +/// The escape set (byte-verified against the PDB-paired 2013 binary; the +/// metalanguage character-set literal at file offset 0x3FE178 is the ten +/// characters []!{}#\|^$): +/// \n → LF (0x0A), \t → TAB (0x09), \r → CR (0x0D), +/// \q → '"' (0x22), and a backslash before any of the ten +/// metalanguage characters yields that character itself. A backslash before +/// anything else is NOT an escape — retail copies it through verbatim +/// (GetUnEscapedChar returns 0 and UnescapeString's +/// else-branch keeps the current character). +/// +/// +public static class RetailStringEscapes +{ + /// The ten metalanguage-significant characters that escape to + /// themselves. Byte-decoded from the retail binary (see class remarks) — + /// the same literal both character tables test with wcschr. + private const string MetaCharacters = "[]!{}#\\|^$"; + + /// + /// StringTableMetaLanguage::GetUnEscapedChar @ 0x0067B750: the + /// character an escape pair \+ decodes + /// to, or '\0' when the pair is not an escape. + /// + internal static char GetUnEscapedChar(char value) => value switch + { + 'n' => '\n', + 'q' => '"', + 'r' => '\r', + 't' => '\t', + not '\0' when MetaCharacters.Contains(value) => value, + _ => '\0', + }; + + /// + /// StringTableMetaLanguage::GetEscapedChar @ 0x0067B6C0: the + /// character that follows the backslash when + /// must be stored escaped, or '\0' when it is stored verbatim. + /// + internal static char GetEscapedChar(char value) => value switch + { + '\t' => 't', + '\n' => 'n', + '\r' => 'r', + '"' => 'q', + not '\0' when MetaCharacters.Contains(value) => value, + _ => '\0', + }; + + /// + /// StringTableMetaLanguage::UnescapeString @ 0x0067BDC0: decodes + /// every two-character escape pair; all other characters (including a + /// backslash that does not start a recognized pair, and a trailing + /// backslash) copy through verbatim. + /// + public static string Unescape(string value) + { + ArgumentNullException.ThrowIfNull(value); + // Fast path: a string with no backslash cannot contain an escape. + int first = value.IndexOf('\\'); + if (first < 0) + return value; + + var result = new StringBuilder(value.Length); + for (int i = 0; i < value.Length; i++) + { + char current = value[i]; + // Retail reads the character AFTER the candidate backslash (the + // terminator — never an escape — when at the end of the buffer). + char next = i + 1 < value.Length ? value[i + 1] : '\0'; + char unescaped = GetUnEscapedChar(next); + if (current == '\\' && unescaped != '\0') + { + result.Append(unescaped); + i++; // consume the pair + } + else if (current != '\0') + { + result.Append(current); + } + } + return result.ToString(); + } + + /// + /// StringTableMetaLanguage::EscapeString @ 0x0067BBC0: the exact + /// inverse — every character with a mapping + /// is stored as \ + that mapping; everything else verbatim. + /// Unescape(Escape(x)) == x for every — + /// the round-trip retail relies on for template variables. + /// + public static string Escape(string value) + { + ArgumentNullException.ThrowIfNull(value); + StringBuilder? result = null; + for (int i = 0; i < value.Length; i++) + { + char current = value[i]; + char escaped = GetEscapedChar(current); + if (escaped != '\0') + { + result ??= new StringBuilder(value.Length + 4) + .Append(value, 0, i); + result.Append('\\').Append(escaped); + } + else if (current != '\0') + { + result?.Append(current); + } + } + return result?.ToString() ?? value; + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index dd52aada..22a915a8 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -117,7 +117,15 @@ public sealed record IndicatorRuntimeBindings( Func LinkStatus, Func CurrentTime, Action RequestLinkStatusPing, - Action EndCharacterSession); + /// Logout round (2026-08-17): the IN-WORLD character logoff — + /// retail's end-character-session flow (log-off animation, reverse + /// wormhole, return to character select on the live connection). Was + /// the window-close action before the logout flow existed. + Action EndCharacterSession, + /// The app-exit action (window close → the graceful-shutdown + /// logoff in WorldSession.Dispose) — the Options panel's Exit Game + /// button, retail's m_shouldQuitOnLogout arm. + Action ExitGame); public sealed record ToolbarRuntimeBindings( ClientObjectTable Objects, @@ -2461,18 +2469,52 @@ public sealed class RetailUiRuntime : IDisposable private void RequestEndCharacterSession() { - ShowConfirmation(ResolveEndCharacterSessionConfirmMessage(), accepted => + // Logout round (2026-08-17): retail funnels BOTH end-session + // surfaces (the indicator bar's control and the Options panel's + // Exit to Character Selection) into gmGamePlayUI's ONE + // m_doEndSession drain, so the grounded three-way branch applies + // here too — the original OP3 port carried it only on the Options + // button. + ShowConfirmation( + ResolveEndCharacterSessionConfirmMessage(), + accepted => + { + if (accepted) + EndCharacterSessionWithRetailGates(); + }); + } + + /// + /// The shared confirmed-Yes drain — gmGamePlayUI::UseTime + /// @ 0x004EA3A0's exact three-way branch (see + /// 's doc for the + /// pseudocode): grounded → CPlayerSystem::LogOffCharacter(0); + /// airborne → the mid-air refusal; no player → silent no-op. + /// + private void EndCharacterSessionWithRetailGates() + { + switch (_bindings.Options.IsGrounded()) { - if (accepted) + case true: _bindings.Indicators.EndCharacterSession(); - }); + break; + case false: + _bindings.Options.DisplaySystemMessage( + ClientTextRefusals.CantLogOffMidAir); + break; + case null: + break; + } } /// /// Campaign OP slice OP3: the Options panel's Exit to Character - /// Selection button (element 0x10000203) — D6's "behaves as Exit - /// Game" adaptation (register row, same commit), PLUS retail's - /// confirmation dialog and mid-air refusal, which DO port exactly. + /// Selection button (element 0x10000203), with retail's + /// confirmation dialog and mid-air refusal. Logout round (2026-08-17): + /// the D6 "behaves as Exit Game" adaptation (AD-76) is RETIRED — the + /// grounded confirmed exit now runs the real in-world logoff flow + /// (log-off animation, reverse wormhole, return to character select on + /// the live connection). /// gmGamePlayUI::UseTime @0x004EA3A0's drain, its EXACT three-way /// branch (review-fix round, 2026-08-11 — the original port collapsed /// this to a two-way `if/else` that fired the refusal outside player @@ -2490,26 +2532,18 @@ public sealed class RetailUiRuntime : IDisposable /// private void RequestExitToCharacterSelection() { - ShowConfirmation(ResolveEndCharacterSessionConfirmMessage(), accepted => - { - if (!accepted) return; - - switch (_bindings.Options.IsGrounded()) + // Logout round (2026-08-17): the D6 "behaves as Exit Game" + // adaptation (AD-76, retired with this change) is gone — the + // confirmed, grounded exit now runs the REAL retail flow through + // EndCharacterSessionWithRetailGates: log-off animation, reverse + // wormhole, return to character select on the live connection. + ShowConfirmation( + ResolveEndCharacterSessionConfirmMessage(), + accepted => { - case true: - _bindings.Indicators.EndCharacterSession(); - break; - case false: - _bindings.Options.DisplaySystemMessage(ClientTextRefusals.CantLogOffMidAir); - break; - case null: - // Retail's `else if (smartbox->player)` gate: outside - // player mode (or with no live controller) there is no - // player object for UseTime to test at all, so neither - // the airborne refusal nor the logoff itself ever runs. - break; - } - }); + if (accepted) + EndCharacterSessionWithRetailGates(); + }); } /// @@ -2575,7 +2609,10 @@ public sealed class RetailUiRuntime : IDisposable var callbacks = new Layout.OptionsPanelController.Callbacks( Toggle: () => ToggleWindow(WindowNames.Options), RequestExitToCharacterSelection: RequestExitToCharacterSelection, - ExitGame: _bindings.Indicators.EndCharacterSession, + // Logout round (2026-08-17): Exit Game keeps the app-exit + // (window close → graceful-shutdown logoff); the in-world + // return-to-charselect flow lives on EndCharacterSession. + ExitGame: _bindings.Indicators.ExitGame, UseMouseTurningSettings: ApplyMouseTurningSettingsMacro, DisplaySystemMessage: _bindings.Options.DisplaySystemMessage, AfterApply: () => _bindings.Options.CommandBus().Publish( @@ -2973,10 +3010,10 @@ public sealed class RetailUiRuntime : IDisposable }); if (text is null) return 0u; // no invented English // The authored text stores its blank line as a literal - // "\n\n" two-character escape (live-probed) — same - // convention DatWidgetFactory/IndicatorDetailText already - // unescape for other DAT-authored strings. - text = text.Replace("\\n", "\n", StringComparison.Ordinal); + // "\n\n" two-character escape (live-probed), decoded at + // the string source (DatStringResolver → + // RetailStringEscapes, 2026-08-17 systemic round) — + // `text` arrives with real line breaks. try { return DialogFactory.MakeWait(text, queueKey: 0x10000001u); @@ -4331,13 +4368,13 @@ public sealed class RetailUiRuntime : IDisposable { lock (_bindings.Assets.DatLock) { - return NormalizeRetailNewlines(strings.ResolveTemplate( - stringTableId, - "ID_CharacterManagement_DeleteCharacterConfirmation", - new Dictionary - { - [DatStringResolver.PlayerVariable] = characterName, - })!); + return strings.ResolveTemplate( + stringTableId, + "ID_CharacterManagement_DeleteCharacterConfirmation", + new Dictionary + { + [DatStringResolver.PlayerVariable] = characterName, + })!; } } @@ -4353,16 +4390,16 @@ public sealed class RetailUiRuntime : IDisposable confirmExit)); } + // Escape decoding (the DAT's literal two-character "\n" and friends) + // happens at the string source since the 2026-08-17 systemic round — + // DatStringResolver → RetailStringEscapes, retail's own placement — so + // this is a plain key-hash resolve. The former NormalizeRetailNewlines + // consumer copy is retired (double-decoding corrupts an authored "\\n"). private static string? ResolveCharacterManagementString( DatStringResolver strings, uint tableId, string key) => - strings.Resolve(tableId, DatStringResolver.ComputeHash(key)) is { } value - ? NormalizeRetailNewlines(value) - : null; - - private static string NormalizeRetailNewlines(string value) => - value.Replace("\\n", "\n", StringComparison.Ordinal); + strings.Resolve(tableId, DatStringResolver.ComputeHash(key)); private void ConfigureCharacterCreation() { diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 357e9069..4801149b 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1139,6 +1139,77 @@ public sealed class WorldSession : IDisposable EnsureNetReceiveLoopStarted(); } + /// + /// The server's opcode-only 0xF653 logoff confirmation has been + /// observed since the last . Latched + /// by the inbound datagram path regardless of world-event dispatch, so + /// both the graceful-shutdown wait and the in-world logoff presentation + /// read the same fact. + /// + public bool IsCharacterLogOffConfirmed => + Volatile.Read(ref _characterLogOffConfirmed) != 0; + + /// + /// Logout round (2026-08-17): the IN-WORLD character-logoff request — + /// retail's CPlayerSystem::RequestLogOff @ 0x00562DD0 → + /// Proto_UI::LogOffCharacter @ 0x00546A20 (opcode 0xF653 + + /// active character id), sent the moment the exit confirmation is + /// accepted, ~3 s BEFORE the client's own wormhole presentation begins. + /// Non-blocking: the server's confirmation arrives through the ordinary + /// pump and is observed via + /// (retail's inbound dispatch + /// case for the echo runs CPlayerSystem::ExecuteLogOff @ 0x0055D780). + /// The graceful-shutdown path in is unchanged and + /// independent; after its + /// BuildShutdownPlan no longer requests a second logoff (state is + /// not ). + /// + public void RequestCharacterLogOff() + { + if (CurrentState != State.InWorld || _activeCharacterId == 0) + { + throw new InvalidOperationException( + "character logoff requires an in-world session with an " + + "active character"); + } + + Interlocked.Exchange(ref _characterLogOffConfirmed, 0); + SendGameMessage(CharacterLogOff.BuildRequestBody(_activeCharacterId)); + } + + /// + /// Logout round (2026-08-17): the world half of retail's + /// CPlayerSystem::ExecuteLogOff @ 0x0055D780 → + /// ClientNet::ExitWorldDisconnect @ 0x00541E00 — return this LIVE + /// session to character select WITHOUT touching the transport. Retail + /// keeps the logon connection (ExitWorldDisconnect removes every + /// connection EXCEPT logonRecID_; against ACE the logon and world + /// connection are the same one) and resets the outbound event counter + /// (Proto_UI::SetEventCounter(0) @ 0x00541E79); ACE mirrors it + /// server-side — Session.SendFinalLogOffMessages leaves the + /// session in AuthConnected and InitSessionForWorldLogin + /// resets GameEventSequence on the next world entry + /// (ACE Session.cs:249-278, CharacterHandler.cs:258). A second + /// then runs the same + /// InCharacterSelect → EnteringWorld transition the enter-rejection + /// retry path already exercises. + /// + public void ReturnToCharacterSelect() + { + if (CurrentState is not (State.InWorld or State.EnteringWorld)) + { + throw new InvalidOperationException( + "return-to-character-select requires an in-world session"); + } + + _activeCharacterId = 0; + // Proto_UI::SetEventCounter(0) @ 0x00541E79: the client's outbound + // game-action sequence restarts for the next world session. + _gameActionSequence = 0; + Interlocked.Exchange(ref _characterLogOffConfirmed, 0); + Transition(State.InCharacterSelect); + } + /// /// Send CharacterEnterWorldRequest and CharacterEnterWorld for /// []. diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs index 9c39d299..991204e2 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs @@ -95,6 +95,7 @@ public sealed class RuntimeLocalPlayerMovementState private RuntimeLocalPlayerPhysicsPublicationState? _physicsPublication; private bool _autoRunActive; private bool _hasCommandInput; + private bool _commandInterpreterDisabled; private MovementInput _commandInput; private bool _disposed; private long _revision; @@ -378,13 +379,39 @@ public sealed class RuntimeLocalPlayerMovementState return _controller?.PrepareForAttackRequest() == true; } + /// + /// Logout round (2026-08-17): retail's + /// CommandInterpreter::HandleLogOff @ 0x006B3330 → + /// Disable(), fired from CPlayerSystem::RequestLogOff + /// @ 0x00562E6D the moment the logoff request goes on the wire. + /// While disabled the graphical input source produces NO movement + /// intent (the server-broadcast LogOut motion animates the player) and + /// the autorun latch is cancelled. Session-scoped: cleared by + /// / (the + /// generation reset the return-to-character-select transaction runs). + /// + public bool CommandInterpreterDisabled => _commandInterpreterDisabled; + + public void DisableCommandInterpreter() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_commandInterpreterDisabled) + return; + _commandInterpreterDisabled = true; + _autoRunActive = false; + _hasCommandInput = false; + _commandInput = default; + Interlocked.Increment(ref _revision); + } + public void ResetInputIntent() { ObjectDisposedException.ThrowIf(_disposed, this); - if (!_autoRunActive && !_hasCommandInput) + if (!_autoRunActive && !_hasCommandInput && !_commandInterpreterDisabled) return; _autoRunActive = false; _hasCommandInput = false; + _commandInterpreterDisabled = false; _commandInput = default; Interlocked.Increment(ref _revision); } @@ -402,10 +429,12 @@ public sealed class RuntimeLocalPlayerMovementState bool changed = _autoRunActive || _hasCommandInput + || _commandInterpreterDisabled || _controller is not null || _preparingMotionOwner is not null; _autoRunActive = false; _hasCommandInput = false; + _commandInterpreterDisabled = false; _commandInput = default; if (_controller is not null) { diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index 756bc85f..81729bcf 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -235,6 +235,17 @@ public interface ILiveSessionOperations session.SendCharacterCreation(accountName, request, skillAdvancementClasses); void Tick(WorldSession session); void DisposeSession(WorldSession session); + + /// Logout round (2026-08-17): the in-world 0xF653 request — + /// . + void RequestCharacterLogOff(WorldSession session) => + session.RequestCharacterLogOff(); + + /// Logout round (2026-08-17): the live-connection return to + /// character select — . + /// + void ReturnToCharacterSelect(WorldSession session) => + session.ReturnToCharacterSelect(); } internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations @@ -1155,6 +1166,198 @@ public sealed class LiveSessionController } } + /// + /// Logout round (2026-08-17): retail's + /// CPlayerSystem::LogOffCharacter(force=0) @ 0x00563520 — + /// CPlayerModule::SaveToServer FIRST (the pre-logoff flush hook, + /// @ 0x00563528), then RequestLogOff @ 0x00562DD0's 0xF653 wire + /// send. No teardown happens here: the ~3 s hold, the wormhole + /// presentation, and the confirmation ride + /// RuntimeWorldTransitState's logout lifecycle App-side; the + /// world teardown is . + /// + public RuntimeCommandResult BeginCharacterLogOff( + RuntimeGenerationToken expectedGeneration) + { + lock (_gate) + { + RuntimeGenerationToken current = new(_generation); + if (expectedGeneration != current) + { + return new RuntimeCommandResult( + RuntimeCommandStatus.StaleGeneration, + current); + } + if (_disposed + || _disposeRequested + || _scope is null + || !_inWorld + || _operationDepth != 0) + { + return new RuntimeCommandResult( + RuntimeCommandStatus.Inactive, + current); + } + + SessionScope scope = _scope; + InvokePreLogoffFlush(scope.Session); + try + { + _operations.RequestCharacterLogOff(scope.Session); + } + catch (Exception error) + { + Console.Error.WriteLine( + $"live: character-logoff request failed: {error.Message}"); + return new RuntimeCommandResult( + RuntimeCommandStatus.Rejected, + current); + } + + return new RuntimeCommandResult( + RuntimeCommandStatus.Accepted, + current); + } + } + + /// + /// Logout round (2026-08-17): the return-to-character-select transaction + /// — retail's ExecuteLogOff @ 0x0055D780 (world teardown, logon + /// connection kept, event counter reset via + /// Proto_UI::SetEventCounter(0) @ 0x00541E79) composed with the + /// character-select re-show its fresh CharacterList drives + /// (gmGamePlayUI::Update @ 0x004E9CD0 → + /// QueueUIMode(0x1000000a)). Invoked by the graphical host AFTER + /// the logout presentation retired and the server's 0xF653 echo landed. + /// Structurally it is ReconnectCore minus the transport swap: the + /// retiring world generation's routes are disposed, the host resets that + /// generation, the SAME live flips back to + /// character select, and a fresh generation re-binds routes and re-applies + /// the roster ACE pushed alongside the logoff echo + /// (Session.SendFinalLogOffMessages — 0xF653 + CharacterList + + /// ServerName; the session stays AuthConnected). Any failure + /// degrades to the full StopCore teardown rather than leaving a + /// half-reset session. + /// + public RuntimeCommandResult CompleteCharacterLogOff( + RuntimeGenerationToken expectedGeneration) + { + lock (_gate) + { + RuntimeGenerationToken current = new(_generation); + if (expectedGeneration != current) + { + return new RuntimeCommandResult( + RuntimeCommandStatus.StaleGeneration, + current); + } + if (_disposed + || _disposeRequested + || _scope is null + || _retiredScope is not null + || !_inWorld) + { + return new RuntimeCommandResult( + RuntimeCommandStatus.Inactive, + current); + } + if (_operationDepth != 0) + { + return new RuntimeCommandResult( + RuntimeCommandStatus.Rejected, + current); + } + + return RunTopLevel(CompleteCharacterLogOffCore); + } + } + + private RuntimeCommandResult CompleteCharacterLogOffCore() + { + SessionScope scope = _scope!; + ILiveSessionLifecycleHost host = scope.Host; + WorldSession session = scope.Session; + RuntimeGenerationToken retiring = scope.Generation; + try + { + // 1. Retire the world generation's routes: outbound commands + // become inert before inbound subscriptions detach (the same + // ordering LiveSessionBinding's own teardown guarantees). The + // transport is deliberately untouched — retail keeps the + // logon connection at character select. + scope.Binding?.Dispose(); + scope.CharacterSelectionBinding?.Dispose(); + + // 2. Detach, then reset the retiring world generation (the + // DrainTeardown stage 2→3 ordering, without stage 1's + // transport disposal). + if (scope.HostAttached) + { + host.DetachSession(session); + scope.HostAttached = false; + } + host.ResetSessionState(retiring); + + // 3. Core.Net: InWorld → InCharacterSelect + retail's outbound + // event-counter reset. + _operations.ReturnToCharacterSelect(session); + + // 4. Fresh generation for character select and the next world. + ulong generation = ++_generation; + var activeGeneration = new RuntimeGenerationToken(generation); + _inWorld = false; + _activeSelection = null; + CharacterSelectionState.Reset(activeGeneration); + CharacterCreationState.Reset(activeGeneration); + _createsSinceCharacterList = 0; + CharacterSelectionState.Begin(activeGeneration); + CharacterCreationState.Begin(activeGeneration); + + // 5. New scope over the SAME session and host; commands stay + // inert until the next EnterWorld activates them, exactly + // like the awaiting-selection connect flow. + var newScope = new SessionScope(session, host, activeGeneration); + _scope = newScope; + LiveSessionBinding binding = host.BindSession(session); + newScope.Binding = binding; + newScope.HostAttached = true; + newScope.CharacterSelectionBinding = BindCharacterSelection( + newScope, + generation); + + // 6. Roster + world name from the session's post-logoff caches + // (ACE pushed both in the SAME batch as the 0xF653 echo). + CharacterList.Parsed? characters = + _operations.GetCharacters(session); + if (characters is not null) + { + _createsSinceCharacterList = 0; + LiveSessionRosterReport roster = BuildRosterReport(characters); + CharacterSelectionState.ApplyRoster(roster); + host.ReportRoster(roster); + } + if (_operations.GetServerInfo(session) is { } serverInfo) + CharacterSelectionState.ApplyWorldName(serverInfo.WorldName); + + Console.WriteLine( + "live: character logoff complete — returned to character " + + "select (session connected)"); + return new RuntimeCommandResult( + RuntimeCommandStatus.Accepted, + activeGeneration); + } + catch (Exception error) + { + Console.Error.WriteLine( + "live: return-to-character-select failed; stopping session: " + + error.Message); + _ = StopAfterFailure(error); + return new RuntimeCommandResult( + RuntimeCommandStatus.Rejected, + new RuntimeGenerationToken(_generation)); + } + } + private RuntimeCommandResult EnterSelectedCore() => EnterHighlightedCore(static (operations, session, character, _) => operations.EnterWorld(session, character.ActiveIndex)); 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/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs b/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs index cb531b66..b098036a 100644 --- a/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs +++ b/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs @@ -11,7 +11,12 @@ public readonly record struct RuntimeWorldTransitOwnershipSnapshot( int ActiveRevealCount, int PendingDestinationReadinessCount, int HostProjectionCount, - int PendingHostAcknowledgementCount) + int PendingHostAcknowledgementCount, + /// Logout round (2026-08-17): 1 while a character-logoff + /// lifecycle (request/hold/presentation/confirmation) is in flight. + /// Defaulted so pre-existing positional constructions read + /// identically. + int ActiveLogoutCount = 0) { public bool IsSessionIdle => BufferedTeleportDestinationCount == 0 @@ -21,7 +26,45 @@ public readonly record struct RuntimeWorldTransitOwnershipSnapshot( && ActiveRevealCount == 0 && PendingDestinationReadinessCount == 0 && HostProjectionCount == 0 - && PendingHostAcknowledgementCount == 0; + && PendingHostAcknowledgementCount == 0 + && ActiveLogoutCount == 0; +} + +/// +/// Logout round (2026-08-17): the canonical stages of retail's ONE +/// character-logoff flow, owned Runtime-side exactly like the login/portal +/// reveal lifecycles (the App host projects presentation only). +/// +/// +/// Retail: +/// +/// — +/// CPlayerSystem::RequestLogOff @ 0x00562DD0: the 0xF653 request is +/// on the wire, logOffRequested = 1, logOffRequestTime = +/// now + 3.0 (+20.0 more when IsPlayerKiller() — 0x00562E3E / +/// 0x00562E67), and the command interpreter is disabled +/// (CommandInterpreter::HandleLogOff @ 0x006B3330 → Disable). The +/// server-broadcast LogOut motion plays during this hold. +/// — +/// gmSmartBoxUI::UseTime @ 0x004D6E7D: the hold elapsed; +/// BeginTeleportAnimation(TAS_WORLD_FADE_OUT) @ 0x004D6E83 (which +/// plays the enter cue @ 0x004D638E), SetTeleportInProgress(1), +/// SetLogOffStarted. The wormhole then runs WorldFadeOut → +/// TunnelFadeIn → Tunnel and HOLDS (the char-select swap preempts retail's +/// TunnelContinue/FadeOut tail, so no exit cue plays on logout). +/// — the server's opcode-only +/// 0xF653 echo (ACE SendFinalLogOffMessages, Session.cs:249): +/// retail's inbound dispatch case 3 (@ 0x0055C963) runs +/// CPlayerSystem::ExecuteLogOff @ 0x0055D780. The fresh +/// CharacterList in the same batch drives the character-select return. +/// +/// +public enum RuntimeLogoutStage +{ + None, + Requested, + PresentationActive, + Confirmed, } /// @@ -66,6 +109,22 @@ public sealed class RuntimeWorldTransitState public static readonly TimeSpan RetailWaitCueDelay = TimeSpan.FromSeconds(5); + /// + /// Retail's logoff presentation hold: logOffRequestTime = + /// Timer::cur_time + 3.0 (CPlayerSystem::RequestLogOff + /// @ 0x00562E3E). The server-broadcast LogOut motion plays on the + /// player during this window; the wormhole begins when it elapses. + /// + public const double RetailLogoutHoldSeconds = 3.0; + + /// + /// The additional player-killer hold: + 20.0 when the local + /// player's weenie reports IsPlayerKiller() + /// (CPlayerSystem::RequestLogOff @ 0x00562E4E-0x00562E67), + /// mirroring ACE's own server-side pk_timer logoff queue. + /// + public const double RetailPlayerKillerAdditionalHoldSeconds = 20.0; + private readonly Action _log; private readonly Dictionary _bufferedDestinations = []; @@ -82,6 +141,9 @@ public sealed class RuntimeWorldTransitState private bool _destinationAccepted; private bool _hasAcceptedDestination; private RuntimeTeleportDestination _acceptedDestination; + private RuntimeLogoutStage _logoutStage; + private double _logoutHoldElapsedSeconds; + private double _logoutHoldRequiredSeconds; public RuntimeWorldTransitState(Action? log = null) { @@ -124,7 +186,124 @@ public sealed class RuntimeWorldTransitState revealActive ? 1 : 0, revealActive && !_snapshot.IsReady ? 1 : 0, _hostProjections.Count, - pendingHostAcknowledgements); + pendingHostAcknowledgements, + _logoutStage != RuntimeLogoutStage.None ? 1 : 0); + } + + // ── Logout lifecycle (2026-08-17) — see RuntimeLogoutStage's remarks + // for the retail derivation. ───────────────────────────────────────── + + public RuntimeLogoutStage LogoutStage => _logoutStage; + public bool IsLogoutActive => _logoutStage != RuntimeLogoutStage.None; + + /// + /// The Yes-click edge: latches retail's logOffRequested + + /// logOffRequestTime pair (CPlayerSystem::RequestLogOff + /// @ 0x00562DD0). Refuses while a teleport or logout lifecycle is + /// already in flight — the upstream grounded gate + /// (gmGamePlayUI::UseTime @ 0x004EA445) already refuses mid-air, + /// which covers portal transit for the player-driven path; this guard + /// keeps the invariant structural. + /// + public bool TryBeginLogoutRequest(bool isPlayerKiller) + { + if (_logoutStage != RuntimeLogoutStage.None + || _teleportActive + || _hasPendingTeleportStart) + { + LogRejected( + "logout-request-refused", + $"stage={_logoutStage} teleportActive={_teleportActive} " + + $"pendingStart={_hasPendingTeleportStart}"); + return false; + } + + _logoutStage = RuntimeLogoutStage.Requested; + _logoutHoldElapsedSeconds = 0d; + _logoutHoldRequiredSeconds = RetailLogoutHoldSeconds + + (isPlayerKiller ? RetailPlayerKillerAdditionalHoldSeconds : 0d); + SafeLog( + $"[world-reveal] event=logout-requested " + + $"holdSeconds={_logoutHoldRequiredSeconds:F1} " + + $"pk={(isPlayerKiller ? 1 : 0)}"); + return true; + } + + /// + /// Rolls back a request whose 0xF653 wire send refused — nothing is on + /// the wire, so no logout lifecycle may remain armed. Only legal from + /// . + /// + public bool CancelLogoutRequest() + { + if (_logoutStage != RuntimeLogoutStage.Requested) + return false; + + _logoutStage = RuntimeLogoutStage.None; + _logoutHoldElapsedSeconds = 0d; + _logoutHoldRequiredSeconds = 0d; + SafeLog("[world-reveal] event=logout-request-cancelled"); + return true; + } + + /// + /// Advances the request hold (retail's logOffRequestTime compare + /// at gmSmartBoxUI::UseTime @ 0x004D6E6E). Returns true exactly + /// once — on the tick the hold elapses — moving the lifecycle to + /// ; the host begins + /// the wormhole (WorldFadeOut entry, enter cue) on that edge. + /// + public bool AdvanceLogoutHold(double deltaSeconds) + { + if (_logoutStage != RuntimeLogoutStage.Requested || deltaSeconds < 0d) + return false; + + _logoutHoldElapsedSeconds += deltaSeconds; + if (_logoutHoldElapsedSeconds < _logoutHoldRequiredSeconds) + return false; + + _logoutStage = RuntimeLogoutStage.PresentationActive; + SafeLog("[world-reveal] event=logout-presentation-begin"); + return true; + } + + /// + /// The server's opcode-only 0xF653 echo landed — retail's + /// ExecuteLogOff edge. Legal from either pre-confirmation stage: + /// ACE's confirmation timing (≥6 s after the request, and only once the + /// player left the landblock) normally lands mid-tunnel, but nothing + /// forbids it landing during the hold. + /// + public bool AcknowledgeLogoutConfirmed() + { + if (_logoutStage is not ( + RuntimeLogoutStage.Requested + or RuntimeLogoutStage.PresentationActive)) + { + return false; + } + + _logoutStage = RuntimeLogoutStage.Confirmed; + SafeLog("[world-reveal] event=logout-confirmed"); + return true; + } + + /// + /// The character-select handoff: the host retired the presentation and + /// is about to run the return-to-selection session transaction. Clears + /// the lifecycle so the world generation reset inside that transaction + /// sees a converged transit owner. + /// + public bool CompleteLogout() + { + if (_logoutStage != RuntimeLogoutStage.Confirmed) + return false; + + _logoutStage = RuntimeLogoutStage.None; + _logoutHoldElapsedSeconds = 0d; + _logoutHoldRequiredSeconds = 0d; + SafeLog("[world-reveal] event=logout-complete"); + return true; } /// @@ -836,6 +1015,9 @@ public sealed class RuntimeWorldTransitState _hasLastTeleportStart = false; _lastTeleportStartSequence = 0; _bufferedDestinations.Clear(); + _logoutStage = RuntimeLogoutStage.None; + _logoutHoldElapsedSeconds = 0d; + _logoutHoldRequiredSeconds = 0d; } private bool TryGetHostRecord( diff --git a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs index 119b997a..abdda089 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs @@ -238,6 +238,8 @@ public sealed class InteractionRetainedUiCompositionTests HostQuiescence: null!, RetainedInputCapture: null!, InputDispatcher: null, + TeleportSink: + new AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink(), KeyBindingsFilePath: "keybinds.json", Settings: null!, Runtime: runtime, diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs index 8d8d99ff..2e6e8b2b 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs @@ -1827,6 +1827,10 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests public void OnLocalPlayerFirstEntryCompleted() { } + public void ArmLoginTunnel() { } + + public void RequestLogout() { } + 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..3ff9ba57 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs @@ -1061,6 +1061,10 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests public void OnLocalPlayerFirstEntryCompleted() { } + public void ArmLoginTunnel() { } + + public void RequestLogout() { } + 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..c1e8f5d1 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -920,6 +920,28 @@ 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(); + + /// + /// Logout round (2026-08-17): the logout pump's Runtime seams — + /// mutable wire/confirmation/transaction outcomes so tests drive the + /// full request → hold → wormhole → confirmation → handoff flow. + /// + public readonly FakeLogoutOperations Logout = new(); + public Harness( int centerX = 0x20, int centerY = 0x21, @@ -1048,7 +1070,9 @@ public sealed class LocalPlayerTeleportControllerTests Placement, Session, Presentation, - AcceptedPositionDrive); + AcceptedPositionDrive, + LoginLifecycle, + Logout); } /// @@ -1458,6 +1482,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 +1490,9 @@ public sealed class LocalPlayerTeleportControllerTests { } public void OnLocalPlayerFirstEntryCompleted() => FirstEntryCompletions++; + public void ArmLoginTunnel() => LoginTunnelArms++; + public int LogoutRequests; + public void RequestLogout() => LogoutRequests++; public void ResetSession() { } @@ -1718,11 +1746,338 @@ 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); + } + + // ── Logout round (2026-08-17): the logout arm of the wormhole machine. + // + // Retail: CPlayerSystem::RequestLogOff @ 0x00562DD0 (chat + 0xF653 + + // 3 s hold (+20 PK) + interpreter Disable) → + // gmSmartBoxUI::UseTime @ 0x004D6E64 hold-elapsed → + // BeginTeleportAnimation(TAS_WORLD_FADE_OUT) @ 0x004D6E83 (enter cue, + // @ 0x004D638E) → TunnelFadeIn → Tunnel hold → inbound 0xF653 echo → + // ExecuteLogOff @ 0x0055D780 + CharacterList-driven char-select swap. + // No exit cue on logout (the swap preempts the tail) — AD-110. + + [Fact] + public void LogoutRequest_SendsWireThenHoldsThreeSeconds_ThenBeginsWormhole() + { + var order = new List(); + var harness = new Harness(worldReady: true, order: order); + + Assert.True(harness.Controller.TryRequestLogout()); + Assert.Equal(1, harness.Logout.BeginCalls); + Assert.Equal(RuntimeLogoutStage.Requested, harness.Transit.LogoutStage); + Assert.Equal(1, harness.Input.EndCount); + + // The 3 s hold: no presentation, the server-broadcast LogOut motion + // is playing in-world. + harness.Controller.Tick(1.0f); + harness.Controller.Tick(1.0f); + Assert.DoesNotContain("presentation-begin-logout", order); + Assert.Equal(RuntimeLogoutStage.Requested, harness.Transit.LogoutStage); + + // Hold elapses → the wormhole begins at WorldFadeOut with the enter + // cue (BeginTeleportAnimation plays it unconditionally). + harness.Presentation.Enqueue(TeleportAnimEvent.PlayEnterSound); + harness.Controller.Tick(1.05f); + Assert.Contains("presentation-begin-logout", order); + Assert.Equal( + RuntimeLogoutStage.PresentationActive, + harness.Transit.LogoutStage); + Assert.Equal(["enter"], harness.Presentation.Cues); + + // The tunnel edge arrives on its own sequencer event. + harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel); + harness.Controller.Tick(0.016f); + Assert.True(harness.Presentation.IsPortalViewportVisible); + Assert.All( + harness.Presentation.WorldReadyValues, + value => Assert.False(value)); + } + + [Fact] + public void LogoutConfirmation_RunsTheHandoffOnceAndCompletesTheLifecycle() + { + var order = new List(); + var harness = new Harness(worldReady: true, order: order); + Assert.True(harness.Controller.TryRequestLogout()); + harness.Presentation.Enqueue( + TeleportAnimEvent.PlayEnterSound, + TeleportAnimEvent.EnterTunnel); + harness.Controller.Tick(3.05f); + Assert.True(harness.Presentation.IsPortalViewportVisible); + + // The server's opcode-only 0xF653 echo lands (ACE sends it >= 6 s + // after the request, tunnel always up by then): the next tick + // acknowledges and runs the handoff IMMEDIATELY — retail's + // ExecuteLogOff-on-echo (AD-110's composed edge). + harness.Logout.IsCharacterLogOffConfirmed = true; + harness.Logout.OnComplete = () => + harness.Controller.ResetGenerationPresentation(); + harness.Controller.Tick(0.016f); + Assert.Equal(1, harness.Logout.CompleteCalls); + Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage); + // The transaction's world reset retired the presentation. + Assert.Contains("presentation-reset", order); + Assert.False(harness.Presentation.IsPortalViewportVisible); + // No exit cue on logout — the swap preempts the tail. + Assert.Equal(["enter"], harness.Presentation.Cues); + + // The retired lifecycle stays quiet. + harness.Controller.Tick(0.016f); + Assert.Equal(1, harness.Logout.CompleteCalls); + } + + [Fact] + public void LogoutConfirmationBeforeHoldEnd_SkipsTheWormholeEntirely() + { + // Retail's ExecuteLogOff clears logOffRequested — a confirmation + // landing before the hold elapses cancels the pending wormhole and + // the CharacterList swap happens directly (unreachable against + // ACE's >= 6 s floor, but the machine is total). + var order = new List(); + var harness = new Harness(worldReady: true, order: order); + Assert.True(harness.Controller.TryRequestLogout()); + + harness.Logout.IsCharacterLogOffConfirmed = true; + harness.Logout.OnComplete = () => + harness.Controller.ResetGenerationPresentation(); + harness.Controller.Tick(0.016f); + Assert.Equal(1, harness.Logout.CompleteCalls); + Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage); + Assert.DoesNotContain("presentation-begin-logout", order); + Assert.Empty(harness.Presentation.Cues); + } + + [Fact] + public void LogoutRequest_RefusedWireRollsTheLifecycleBack() + { + var harness = new Harness(worldReady: true); + harness.Logout.BeginResult = false; + + Assert.False(harness.Controller.TryRequestLogout()); + Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage); + Assert.Equal(1, harness.Logout.BeginCalls); + } + + [Fact] + public void LogoutRequest_UsesThePlayerKillerHold() + { + var harness = new Harness(worldReady: true); + harness.Logout.IsLocalPlayerKiller = true; + Assert.True(harness.Controller.TryRequestLogout()); + + // 3 s is NOT enough for a PK — retail adds +20 s + // (RequestLogOff @ 0x00562E67). + harness.Controller.Tick(3.5f); + Assert.Equal(RuntimeLogoutStage.Requested, harness.Transit.LogoutStage); + harness.Controller.Tick(19.6f); + Assert.Equal( + RuntimeLogoutStage.PresentationActive, + harness.Transit.LogoutStage); + } + + [Fact] + public void TeleportStart_IsIgnoredWhileLogoutIsActive() + { + var harness = new Harness(worldReady: true); + Assert.True(harness.Controller.TryRequestLogout()); + + harness.Controller.OnTeleportStarted(7); + Assert.False(harness.Controller.IsActive); + Assert.False(harness.Transit.HasPendingTeleportStart); + Assert.Equal(RuntimeLogoutStage.Requested, harness.Transit.LogoutStage); + } + + [Fact] + public void LogoutRequest_RefusedDuringTeleportOrLogin() + { + var harness = new Harness(worldReady: true); + harness.Controller.OnTeleportStarted(3); + Assert.False(harness.Controller.TryRequestLogout()); + Assert.Equal(0, harness.Logout.BeginCalls); + + var loginHarness = new Harness(worldReady: false); + loginHarness.Presentation.Enqueue( + TeleportAnimEvent.PlayEnterSound, + TeleportAnimEvent.EnterTunnel); + loginHarness.Controller.ArmLoginTunnel(); + Assert.False(loginHarness.Controller.TryRequestLogout()); + Assert.Equal(0, loginHarness.Logout.BeginCalls); + } + 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 FakeLogoutOperations : ILocalPlayerLogoutOperations + { + public bool IsLocalPlayerKiller { get; set; } + public bool BeginResult = true; + public int BeginCalls; + public bool IsCharacterLogOffConfirmed { get; set; } + public bool CompleteResult = true; + public int CompleteCalls; + public Action? OnComplete; + + public bool BeginCharacterLogOff() + { + BeginCalls++; + return BeginResult; + } + + public bool CompleteCharacterLogOff() + { + CompleteCalls++; + OnComplete?.Invoke(); + return CompleteResult; + } + } + private sealed class FakePresentation : ILocalPlayerTeleportPresentation { private readonly List _order; @@ -1747,6 +2102,12 @@ public sealed class LocalPlayerTeleportControllerTests _order.Add("presentation-begin"); } + public void BeginLogout(Matrix4x4 projection) + { + BeginProjection = projection; + _order.Add("presentation-begin-logout"); + } + public (TeleportAnimSnapshot Snapshot, IReadOnlyList Events) Tick(float deltaSeconds, bool worldReady) { diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 30de665e..859ed34d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -1904,18 +1904,21 @@ public sealed class CharacterCreationUiControllerTests /// /// GF-2: the composed description routes through the shared rich-text /// helper — header segments (palette index 1) render in a DIFFERENT - /// color than body segments (index 0), and each segment's own escape - /// sequence is normalized. The fixture's description element carries - /// no authored FontColorPalette, so this also exercises - /// 's fallback (green header / - /// white body). + /// color than body segments (index 0), and an authored line break + /// splits into stacked lines. The harness resolver models + /// DatStringResolver's post-decode output (the DAT's literal + /// "\n" escape decodes AT THE SOURCE since the 2026-08-17 systemic + /// round), so the fixture feeds a REAL '\n'. The fixture's description + /// element carries no authored FontColorPalette, so this also + /// exercises 's fallback (green + /// header / white body). /// [Fact] public void HeritageDescription_ComposesGreenHeaderAndWhiteBodySegments() { using var environment = new EnvironmentHarness(); environment.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills_Header"] = "Trained Starting Skills:"; - environment.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills"] = "Line one\\nLine two"; + environment.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills"] = "Line one\nLine two"; environment.Controller.Open(); environment.Runtime.SelectHeritageDirect(AluvianId); BumpRevisionAndTick(environment); @@ -1924,7 +1927,7 @@ public sealed class CharacterCreationUiControllerTests var lines = description.LinesProvider().ToList(); Assert.Contains(lines, l => l.Text == "Trained Starting Skills:" && l.Color == new Vector4(0f, 1f, 0f, 1f)); - // The literal "\n" escape in the body segment must become TWO + // The source-decoded line break in the body segment must become TWO // separate lines, not render as a literal backslash-n. Assert.Contains(lines, l => l.Text == "Line one" && l.Color == Vector4.One); Assert.Contains(lines, l => l.Text == "Line two" && l.Color == Vector4.One); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs index 6c01c054..5b685043 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs @@ -138,10 +138,12 @@ public sealed class CharacterManagementLiveDatTests // Finding 1: MakeConfirmExitDialog@0x004ed250's text // (compute_str_hash("ID_CharacterManagement_ConfirmExit"), table // enum 0x10000002 -> 0x23000002). The raw DAT string carries a - // literal two-character "\n" escape (this test's Resolve() helper - // does not normalize it — RetailUiRuntime does, via - // NormalizeRetailNewlines, before handing it to the controller). - Assert.Equal("Are you sure you want to leave?\\n", Resolve(strings, table, + // literal two-character "\n" escape; since the 2026-08-17 systemic + // round DatStringResolver decodes it AT THE SOURCE (retail's own + // placement — StringInfo::InqString @ 0x0042E490's UnescapeString + // tail), so Resolve returns a REAL line break and no consumer + // normalizes again. + Assert.Equal("Are you sure you want to leave?\n", Resolve(strings, table, "ID_CharacterManagement_ConfirmExit")); string confirmation = Assert.IsType(strings.ResolveTemplate( table, diff --git a/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs index 2ae0193e..bb7dc226 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs @@ -5,8 +5,10 @@ namespace AcDream.App.Tests.UI.Layout; /// /// Campaign CC gate round 1 Batch C: unit tests for the shared -/// escape-normalize + word-wrap + per-segment-color helper feeding -/// GF-2/GF-3/GF-11a and the Summary how-to text (Commit 3). +/// word-wrap + per-segment-color helper feeding GF-2/GF-3/GF-11a and the +/// Summary how-to text (Commit 3). Escape decoding moved to the string +/// source in the 2026-08-17 systemic round (DatStringResolver → +/// RetailStringEscapes) — segments reach Compose with real line breaks. /// public class DatRichTextTests { @@ -16,17 +18,24 @@ public class DatRichTextTests private static UiText MakeTarget(float width) => new() { Width = width, Height = 200f }; + /// Segments arrive source-decoded (real '\n'); Compose keeps + /// the authored break as a line split. A literal backslash-n pair in a + /// segment must stay VERBATIM — re-decoding here is the double-decode + /// hazard the 2026-08-17 round retired. [Fact] - public void Compose_NormalizesLiteralBackslashNEscape() + public void Compose_SplitsOnRealNewlines_AndKeepsLiteralPairsVerbatim() { UiText target = MakeTarget(1000f); // wide enough that nothing wraps - var segments = new[] { new DatRichText.Segment("line one\\nline two", White) }; + var segments = new[] + { + new DatRichText.Segment("line one\nliteral \\n stays", White), + }; var lines = DatRichText.Compose(target, segments); Assert.Equal(2, lines.Count); Assert.Equal("line one", lines[0].Text); - Assert.Equal("line two", lines[1].Text); + Assert.Equal("literal \\n stays", lines[1].Text); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/DatStringEscapeSweepTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatStringEscapeSweepTests.cs new file mode 100644 index 00000000..915c39e1 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/DatStringEscapeSweepTests.cs @@ -0,0 +1,136 @@ +using System.IO; +using System.Text; +using AcDream.App.UI.Layout; +using AcDream.Content; +using DatReaderWriter.Options; +using StringTable = DatReaderWriter.DBObjs.StringTable; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// 2026-08-17 systemic escape round: installed-DAT sweep of EVERY string +/// table for literal escape content, plus the source-normalization contract +/// ( resolutions must equal +/// of the raw stored text — +/// retail's StringInfo::InqString @ 0x0042E490 placement). This is +/// the measurement companion to the per-consumer normalize retirement: it +/// proves the escape class genuinely exists in shipping data and prints +/// which tables carry it. +/// +public sealed class DatStringEscapeSweepTests +{ + [InstalledDatFact] + public void EveryInstalledStringResolvesSourceDecoded() + { + string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + using var dats = new DatCollection(datDirectory, DatAccessType.Read); + var resolver = new DatStringResolver(dats); + + int tables = 0; + int strings = 0; + int withNewlineEscape = 0; + int withTabEscape = 0; + int withCrEscape = 0; + int withQuoteEscape = 0; + int withMetaSelfEscape = 0; + int withUnknownPair = 0; + int withRealCr = 0; + var perTableNewlines = new SortedDictionary(); + var examples = new List(); + string? userReportedExitText = null; + + foreach (uint tableId in dats.GetAllIdsOfType().Order()) + { + StringTable? table = dats.Get(tableId); + if (table is null) + continue; + tables++; + + foreach ((uint stringId, var entry) in table.Strings) + { + for (int token = 0; token < entry.Strings.Count; token++) + { + string raw = entry.Strings[token].Value; + strings++; + + bool newline = false, unknown = false, meta = false; + for (int i = 0; i < raw.Length - 1; i++) + { + if (raw[i] != '\\') + continue; + char next = raw[i + 1]; + char decoded = RetailStringEscapes.GetUnEscapedChar(next); + switch (decoded) + { + case '\n': newline = true; break; + case '\t': withTabEscape++; break; + case '\r': withCrEscape++; break; + case '"': withQuoteEscape++; break; + case '\0': unknown = true; break; + default: meta = true; break; + } + i++; // the pair is consumed either way it decodes + } + if (newline) + { + withNewlineEscape++; + perTableNewlines[tableId] = + perTableNewlines.GetValueOrDefault(tableId) + 1; + if (examples.Count < 12) + examples.Add( + $"0x{tableId:X8}/0x{stringId:X8}: \"{Truncate(raw)}\""); + } + if (meta) withMetaSelfEscape++; + if (unknown) withUnknownPair++; + if (raw.Contains('\r')) withRealCr++; + + // The source contract: what consumers receive from the + // resolver is EXACTLY the retail unescape of the stored + // text — nothing more (no consumer re-decode is owed), + // nothing less (no escape survives to render literally). + Assert.Equal( + RetailStringEscapes.Unescape(raw), + resolver.Resolve(tableId, stringId, token)); + + if (raw.Contains("exit your character", StringComparison.OrdinalIgnoreCase)) + userReportedExitText = + $"0x{tableId:X8}/0x{stringId:X8}: \"{raw}\""; + } + } + } + + var summary = new StringBuilder() + .AppendLine("[escape-sweep] installed-DAT string-table inventory:") + .AppendLine($" tables={tables} strings={strings}") + .AppendLine($" strings with literal \\n escape: {withNewlineEscape}") + .AppendLine($" \\t pairs: {withTabEscape}; \\r pairs: {withCrEscape}; \\q pairs: {withQuoteEscape}") + .AppendLine($" strings with metalanguage self-escapes: {withMetaSelfEscape}") + .AppendLine($" strings with unrecognized backslash pairs (kept verbatim): {withUnknownPair}") + .AppendLine($" strings containing a REAL CR character: {withRealCr}") + .AppendLine(" \\n-escape counts per table: " + + string.Join(", ", perTableNewlines.Select( + static pair => $"0x{pair.Key:X8}={pair.Value}"))) + .AppendLine(" examples:"); + foreach (string example in examples) + summary.AppendLine($" {example}"); + summary.AppendLine(userReportedExitText is null + ? " user-reported exit-world text: NOT found by content scan" + : $" user-reported exit-world text: {userReportedExitText}"); + Console.WriteLine(summary.ToString()); + + // The escape class must genuinely exist in shipping data — if this + // ever goes to zero the sweep (and the source decode) is measuring + // nothing and needs re-examination, not silent success. + Assert.True( + withNewlineEscape > 0, + "expected at least one installed string carrying the literal \\n escape"); + } + + private static string Truncate(string value) => + (value.Length <= 90 ? value : value[..90] + "…") + .Replace("\r", "").Replace("\n", ""); +} diff --git a/tests/AcDream.App.Tests/UI/Layout/DatStringResolverTemplateTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatStringResolverTemplateTests.cs index 9aefda93..f590f962 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatStringResolverTemplateTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatStringResolverTemplateTests.cs @@ -85,6 +85,72 @@ public sealed class DatStringResolverTemplateTests new Dictionary())); } + /// + /// The 2026-08-17 systemic escape round: resolution decodes the DAT's + /// literal two-character escapes AT THE SOURCE — retail's own placement + /// (StringInfo::InqString @ 0x0042E490's unconditional + /// UnescapeString tail). Consumers receive real line breaks; no + /// per-consumer normalize remains. + /// + [Fact] + public void ResolveDecodesEscapesAtTheSource() + { + var resolver = MakeResolver( + "ID_Confirm_Exit", + fragments: [ + "This will exit your character from the game world.\\n\\nAre you sure?", + ], + variables: []); + + Assert.Equal( + "This will exit your character from the game world.\n\nAre you sure?", + resolver.Resolve( + TableId, DatStringResolver.ComputeHash("ID_Confirm_Exit"))); + } + + [Fact] + public void ResolveAllDecodesEveryVariant() + { + var resolver = MakeResolver( + "ID_Variants", + fragments: ["one\\nline", "two\\tcol"], + variables: []); + + Assert.Equal( + ["one\nline", "two\tcol"], + resolver.ResolveAll( + TableId, DatStringResolver.ComputeHash("ID_Variants"))); + } + + /// + /// Template composition decodes the authored fragments' escapes while + /// variable content round-trips VERBATIM — retail escapes each variable + /// on insert (AddVariable_String @ 0x0042E6C0 → + /// SetLiteralValue(escape=1) @ 0x0042C980) and unescapes the + /// composed whole once, so a player name containing escape-significant + /// characters can never be corrupted by the final decode. + /// + [Fact] + public void ResolveTemplateDecodesFragmentsAndKeepsVariablesVerbatim() + { + var resolver = MakeResolver( + "ID_Delete_Confirmation", + fragments: ["Delete ", "?\\nType 'DELETE' to confirm."], + variables: [DatStringResolver.PlayerVariable]); + + Assert.Equal( + "Delete Odd\\nName?\nType 'DELETE' to confirm.", + resolver.ResolveTemplate( + TableId, + "ID_Delete_Confirmation", + new Dictionary + { + // A pathological name carrying a REAL backslash before + // an 'n' — must come out verbatim, not as a line break. + [DatStringResolver.PlayerVariable] = "Odd\\nName", + })); + } + [Fact] public void UnknownKeyResolvesNull() { diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index e218d444..1ca6c0c2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -1144,8 +1144,10 @@ public class DatWidgetFactoryTests } /// - /// 2026-08-13 social gate round 3: a MULTILINE authored string (literal - /// backslash-n escapes in the DAT) word-wraps each authored line to the + /// 2026-08-13 social gate round 3: a MULTILINE authored string (a real + /// '\n' — the DAT's literal backslash-n escape decodes at the string + /// source since the 2026-08-17 systemic round, so the resolver seam + /// hands this factory decoded text) word-wraps each authored line to the /// widget's live width — retail's GlyphList draw, the same wrap the /// confirmation dialog view uses. The fellowship empty-state was /// rendering its three authored lines as three clipped runs. @@ -1165,7 +1167,7 @@ public class DatWidgetFactoryTests // Width=100 fits 12 characters per wrapped line. var text = Assert.IsType(DatWidgetFactory.Create( info, NoTex, null, - stringResolve: _ => "one two three four five\\nsix")); + stringResolve: _ => "one two three four five\nsix")); var lines = text.LinesProvider!(); Assert.True(lines.Count >= 3); diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailStringEscapesTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailStringEscapesTests.cs new file mode 100644 index 00000000..c30387ac --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/RetailStringEscapesTests.cs @@ -0,0 +1,103 @@ +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Conformance for — the exact port of +/// retail's string-table escape codec +/// (StringTableMetaLanguage::UnescapeString @ 0x0067BDC0 / +/// EscapeString @ 0x0067BBC0, character tables +/// GetUnEscapedChar @ 0x0067B750 / GetEscapedChar @ 0x0067B6C0). +/// The metalanguage character set is byte-verified against the PDB-paired +/// 2013 binary (file offset 0x3FE178: []!{}#\|^$). +/// +public sealed class RetailStringEscapesTests +{ + [Theory] + [InlineData("line one\\nline two", "line one\nline two")] + [InlineData("a\\tb", "a\tb")] + [InlineData("a\\rb", "a\rb")] + [InlineData("say \\qhi\\q", "say \"hi\"")] + public void Unescape_DecodesTheFourCharacterEscapes( + string raw, string expected) + => Assert.Equal(expected, RetailStringEscapes.Unescape(raw)); + + [Theory] + [InlineData("\\[", "[")] + [InlineData("\\]", "]")] + [InlineData("\\!", "!")] + [InlineData("\\{", "{")] + [InlineData("\\}", "}")] + [InlineData("\\#", "#")] + [InlineData("\\\\", "\\")] + [InlineData("\\|", "|")] + [InlineData("\\^", "^")] + [InlineData("\\$", "$")] + public void Unescape_DecodesEveryMetalanguageSelfEscape( + string raw, string expected) + => Assert.Equal(expected, RetailStringEscapes.Unescape(raw)); + + /// + /// GetUnEscapedChar returns 0 for anything else — retail keeps the + /// backslash verbatim (UnescapeString's else-branch), including a + /// trailing backslash whose "next" character is the terminator. + /// + [Theory] + [InlineData("\\z", "\\z")] + [InlineData("C:\\path\\dir", "C:\\path\\dir")] + [InlineData("ends with \\", "ends with \\")] + [InlineData("\\N upper is not an escape", "\\N upper is not an escape")] + public void Unescape_KeepsUnrecognizedPairsVerbatim( + string raw, string expected) + => Assert.Equal(expected, RetailStringEscapes.Unescape(raw)); + + /// + /// The double-decode hazard the 2026-08-17 systemic round exists to + /// close: an authored escaped backslash before an 'n' decodes ONCE to + /// the literal two characters backslash+n — a second decode pass (the + /// retired per-consumer copies) would corrupt it into a line break. + /// + [Fact] + public void Unescape_EscapedBackslashBeforeN_YieldsLiteralPair() + => Assert.Equal("\\n", RetailStringEscapes.Unescape("\\\\n")); + + [Fact] + public void Unescape_EmptyString_IsEmpty() + => Assert.Equal(string.Empty, RetailStringEscapes.Unescape(string.Empty)); + + /// No backslash → no allocation: the same instance returns. + [Fact] + public void Unescape_NoEscapes_ReturnsTheSameInstance() + { + const string plain = "Please Wait"; + Assert.Same(plain, RetailStringEscapes.Unescape(plain)); + } + + [Theory] + [InlineData("line one\nline two", "line one\\nline two")] + [InlineData("a\tb", "a\\tb")] + [InlineData("a\rb", "a\\rb")] + [InlineData("say \"hi\"", "say \\qhi\\q")] + [InlineData("[x]", "\\[x\\]")] + [InlineData("back\\slash", "back\\\\slash")] + [InlineData("plain", "plain")] + public void Escape_IsTheStorageInverse(string plain, string expected) + => Assert.Equal(expected, RetailStringEscapes.Escape(plain)); + + /// + /// Retail's template-variable round trip + /// (AddVariable_String @ 0x0042E6C0 escapes on insert; + /// InqString @ 0x0042E490 unescapes the composed whole): variable + /// content must come out verbatim. + /// + [Theory] + [InlineData("plain name")] + [InlineData("Odd\\Name")] + [InlineData("multi\nline")] + [InlineData("tabs\tand \"quotes\"")] + [InlineData("[]!{}#\\|^$")] + [InlineData("")] + public void UnescapeOfEscape_RoundTripsVerbatim(string value) + => Assert.Equal(value, RetailStringEscapes.Unescape( + RetailStringEscapes.Escape(value))); +} diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs index 5ecb0cd1..48172af5 100644 --- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs +++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs @@ -553,16 +553,20 @@ public class UiButtonTests Assert.True(confinedWidth < button.Width, "the confined width must be narrower than the full button"); } - // ── R2-2 escape-normalize ──────────────────────────────────────────── + // ── R2-2 authored caption (source-decoded) ─────────────────────────── /// - /// R2-2: BuildButton's own P0x17 caption escape-normalizes the same way - /// BuildText's authored-string path always has — the DAT stores the - /// LITERAL two-character escape "\n" (0x5C 0x6E), and the Profession - /// credits button's own authored caption is exactly this shape. + /// R2-2's successor contract (2026-08-17 systemic round): the DAT's + /// LITERAL two-character escape "\n" (0x5C 0x6E — the Profession + /// credits button's own authored caption is exactly this shape) decodes + /// at the string SOURCE (DatStringResolver → RetailStringEscapes, + /// retail's own placement), so the resolver seam hands BuildButton a + /// caption with a REAL line break — and the factory passes it through + /// verbatim, with no second decode that would corrupt an authored + /// backslash pair. /// [Fact] - public void BuildButton_OwnCaption_NormalizesLiteralBackslashNEscape() + public void BuildButton_OwnCaption_PassesSourceDecodedTextThrough() { uint stringId = 444u; var info = new ElementInfo { Type = 1, Width = 150, Height = 50 }; @@ -575,11 +579,14 @@ public class UiButtonTests var button = Assert.IsType(DatWidgetFactory.Create( info, NoTex, null, - // The raw resolved string carries the LITERAL two characters - // '\' and 'n', matching what the installed DAT actually stores. - stringResolve: value => value.StringId == stringId ? "Attribute\\n Credits" : null)); + // The resolver seam models DatStringResolver's post-decode + // output: a REAL '\n', plus a literal backslash pair that a + // stray second decode would corrupt into a line break. + stringResolve: value => value.StringId == stringId + ? "Attribute\n Credits \\not-an-escape" + : null)); - Assert.Equal("Attribute\n Credits", button.Label); + Assert.Equal("Attribute\n Credits \\not-an-escape", button.Label); } private static UiButton ButtonWithStates(params string[] states) diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index a4495cb3..80b25378 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -169,6 +169,30 @@ public sealed class LiveSessionControllerTests } DisposeCounts[session] = DisposeCounts.GetValueOrDefault(session) + 1; } + + // Logout round (2026-08-17): the in-world logoff pair — recorded + // here so the return-to-selection transaction is testable without a + // live in-world WorldSession state machine. + public int RequestCharacterLogOffCount { get; private set; } + public int ReturnToCharacterSelectCount { get; private set; } + public bool ThrowOnRequestCharacterLogOff { get; set; } + public bool ThrowOnReturnToCharacterSelect { get; set; } + + public void RequestCharacterLogOff(WorldSession session) + { + calls.Add("request-character-logoff"); + RequestCharacterLogOffCount++; + if (ThrowOnRequestCharacterLogOff) + throw new InvalidOperationException("logoff request failure"); + } + + public void ReturnToCharacterSelect(WorldSession session) + { + calls.Add("return-to-character-select"); + ReturnToCharacterSelectCount++; + if (ThrowOnReturnToCharacterSelect) + throw new InvalidOperationException("return failure"); + } } private sealed class TestHost(List calls) : ILiveSessionLifecycleHost @@ -1619,6 +1643,144 @@ public sealed class LiveSessionControllerTests Assert.Single(operations.DisposeCounts); } + // ── Logout round (2026-08-17): the in-world logoff transaction pair — + // retail CPlayerSystem::LogOffCharacter(0) @ 0x00563520 (flush-first + // 0xF653 request) and ExecuteLogOff @ 0x0055D780 composed with the + // CharacterList-driven character-select re-show (AD-110). ───────────── + + [Fact] + public void BeginCharacterLogOff_FlushesFirstThenSendsTheRequest() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + controller.ConfigurePreLogoffFlush(_ => calls.Add("flush")); + Assert.Equal( + LiveSessionStartStatus.Connected, + controller.Start(LiveOptions(), host).Status); + + RuntimeCommandResult result = + controller.BeginCharacterLogOff(controller.Generation); + + Assert.True(result.Accepted); + Assert.Equal(1, operations.RequestCharacterLogOffCount); + // SaveToServer BEFORE the wire request — LogOffCharacter @ 0x00563528. + Assert.True( + calls.IndexOf("flush") < calls.IndexOf("request-character-logoff")); + // No teardown of any kind at request time (the single reset on + // record is Start's own initial host reset). + Assert.True(controller.IsInWorld); + Assert.Equal(1, host.ResetCount); + } + + [Fact] + public void BeginCharacterLogOff_RefusesOutsideTheWorld() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + Assert.Equal( + RuntimeCommandStatus.Inactive, + controller.BeginCharacterLogOff(controller.Generation).Status); + Assert.Equal(0, operations.RequestCharacterLogOffCount); + + Assert.Equal( + LiveSessionStartStatus.Connected, + controller.Start(LiveOptions(), host).Status); + Assert.Equal( + RuntimeCommandStatus.StaleGeneration, + controller.BeginCharacterLogOff(default).Status); + } + + [Fact] + public void CompleteCharacterLogOff_ReturnsToSelectionOnTheLiveSession() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + Assert.Equal( + LiveSessionStartStatus.Connected, + controller.Start(LiveOptions(), host).Status); + WorldSession session = operations.Sessions[0]; + RuntimeGenerationToken worldGeneration = controller.Generation; + calls.Clear(); + + RuntimeCommandResult result = + controller.CompleteCharacterLogOff(worldGeneration); + + Assert.True(result.Accepted); + // The retiring world generation's routes died first, then the host + // reset THAT generation, then the same live session flipped back — + // no transport disposal anywhere. + Assert.Equal( + [ + "deactivate", "detach-events", "detach-session", "reset", + "return-to-character-select", "bind", "roster", + ], + calls); + // ResetGenerations[0] is Start's own initial host reset; the + // transaction's reset targets exactly the retiring world generation. + Assert.Equal(2, host.ResetCount); + Assert.Equal(worldGeneration, host.ResetGenerations[^1]); + Assert.Empty(operations.DisposeCounts); + Assert.False(controller.IsInWorld); + Assert.Same(session, controller.CurrentSession); + Assert.NotEqual(worldGeneration, controller.Generation); + Assert.Equal(result.Generation, controller.Generation); + + // The fresh generation owns an AwaitingSelection roster re-applied + // from the session cache ACE's post-logoff CharacterList filled. + RuntimeCharacterSelectionSnapshot snapshot = + controller.CharacterSelectionState.View.Snapshot; + Assert.Equal( + RuntimeCharacterSelectionLifecycle.AwaitingSelection, + snapshot.Lifecycle); + Assert.Equal(controller.Generation, snapshot.Generation); + Assert.Equal(2, host.Rosters.Count); + + // The round trip: a second Enter works on the SAME session. + RuntimeCommandResult enter = controller.Enter(controller.Generation); + Assert.True(enter.Accepted); + Assert.True(controller.IsInWorld); + Assert.Equal(2, operations.EnterWorldCount); + Assert.True(host.CommandBuses[^1].Active); + } + + [Fact] + public void CompleteCharacterLogOff_RefusalsAndFailureDegradeToStop() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + Assert.Equal( + RuntimeCommandStatus.Inactive, + controller.CompleteCharacterLogOff(controller.Generation).Status); + + Assert.Equal( + LiveSessionStartStatus.Connected, + controller.Start(LiveOptions(), host).Status); + Assert.Equal( + RuntimeCommandStatus.StaleGeneration, + controller.CompleteCharacterLogOff(default).Status); + + // A mid-transaction failure must not leave a half-reset session: + // the transaction degrades to the full StopCore teardown. + operations.ThrowOnReturnToCharacterSelect = true; + WorldSession session = operations.Sessions[0]; + RuntimeCommandResult result = + controller.CompleteCharacterLogOff(controller.Generation); + Assert.Equal(RuntimeCommandStatus.Rejected, result.Status); + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + Assert.Equal(1, operations.DisposeCounts[session]); + } + private static LiveSessionConnectOptions LiveOptions( bool live = true, string? user = "user", diff --git a/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs b/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs index 790ae838..a419177a 100644 --- a/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs +++ b/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs @@ -1050,6 +1050,104 @@ public sealed class RuntimeWorldTransitStateTests state.ResetSession(); } + // ── Logout round (2026-08-17): the character-logoff lifecycle — + // retail CPlayerSystem::RequestLogOff @ 0x00562DD0 (3 s hold, +20 PK), + // gmSmartBoxUI::UseTime @ 0x004D6E64 (hold-elapsed presentation begin), + // ExecuteLogOff @ 0x0055D780 (the 0xF653 echo), and the char-select + // handoff. ──────────────────────────────────────────────────────────── + + [Fact] + public void LogoutLifecycle_RequestHoldPresentationConfirmComplete() + { + var state = new RuntimeWorldTransitState(); + Assert.Equal(RuntimeLogoutStage.None, state.LogoutStage); + + Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: false)); + Assert.Equal(RuntimeLogoutStage.Requested, state.LogoutStage); + Assert.Equal(1, state.CaptureOwnership().ActiveLogoutCount); + Assert.False(state.CaptureOwnership().IsSessionIdle); + + // A second request while one is in flight refuses. + Assert.False(state.TryBeginLogoutRequest(isPlayerKiller: false)); + + // The retail 3.0 s hold: not elapsed at 2.9, elapsed at 3.0+. + Assert.False(state.AdvanceLogoutHold(2.9d)); + Assert.Equal(RuntimeLogoutStage.Requested, state.LogoutStage); + Assert.True(state.AdvanceLogoutHold(0.2d)); + Assert.Equal( + RuntimeLogoutStage.PresentationActive, + state.LogoutStage); + // The begin edge fires exactly once. + Assert.False(state.AdvanceLogoutHold(1.0d)); + + Assert.True(state.AcknowledgeLogoutConfirmed()); + Assert.Equal(RuntimeLogoutStage.Confirmed, state.LogoutStage); + Assert.False(state.AcknowledgeLogoutConfirmed()); + + Assert.True(state.CompleteLogout()); + Assert.Equal(RuntimeLogoutStage.None, state.LogoutStage); + Assert.False(state.CompleteLogout()); + Assert.True(state.CaptureOwnership().IsSessionIdle); + } + + [Fact] + public void LogoutHold_PlayerKillerAddsTwentySeconds() + { + var state = new RuntimeWorldTransitState(); + Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: true)); + + // 3 s is not enough for a PK (RequestLogOff @ 0x00562E67: +20.0). + Assert.False(state.AdvanceLogoutHold(3.5d)); + Assert.False(state.AdvanceLogoutHold(19.0d)); + Assert.True(state.AdvanceLogoutHold(0.6d)); + Assert.Equal( + RuntimeLogoutStage.PresentationActive, + state.LogoutStage); + } + + [Fact] + public void LogoutConfirmation_LegalFromTheRequestHold() + { + // ACE's >= 6 s confirmation floor makes this unreachable live, but + // the machine is total: a confirmation during the hold cancels the + // pending wormhole (retail ExecuteLogOff clears logOffRequested). + var state = new RuntimeWorldTransitState(); + Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: false)); + Assert.True(state.AcknowledgeLogoutConfirmed()); + Assert.Equal(RuntimeLogoutStage.Confirmed, state.LogoutStage); + // The hold no longer advances a confirmed lifecycle. + Assert.False(state.AdvanceLogoutHold(10.0d)); + Assert.True(state.CompleteLogout()); + } + + [Fact] + public void LogoutRequest_RefusedDuringTeleportAndCancelRollsBack() + { + var state = new RuntimeWorldTransitState(); + _ = BeginPortal(state, OutdoorCell, sequence: 3); + Assert.False(state.TryBeginLogoutRequest(isPlayerKiller: false)); + + var idle = new RuntimeWorldTransitState(); + Assert.True(idle.TryBeginLogoutRequest(isPlayerKiller: false)); + Assert.True(idle.CancelLogoutRequest()); + Assert.Equal(RuntimeLogoutStage.None, idle.LogoutStage); + Assert.True(idle.CaptureOwnership().IsSessionIdle); + // Cancel is only legal from Requested. + Assert.False(idle.CancelLogoutRequest()); + } + + [Fact] + public void LogoutLifecycle_ClearsOnSessionReset() + { + var state = new RuntimeWorldTransitState(); + Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: false)); + state.ResetSession(); + Assert.Equal(RuntimeLogoutStage.None, state.LogoutStage); + Assert.True(state.CaptureOwnership().IsSessionIdle); + // A fresh session can log out again. + Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: false)); + } + private static long BeginPortal( RuntimeWorldTransitState state, uint cell,