diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 8d97c6af..04cc21cb 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -64,7 +64,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 84 active rows (AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 84 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) 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 @@ -172,7 +172,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 | 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 e12cd58a..48cc2cfe 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -1025,7 +1025,16 @@ internal sealed class SessionPlayerCompositionPhase // 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)); + 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/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/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index 7a941381..1e0554a9 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -58,6 +58,18 @@ internal interface ILocalPlayerTeleportNetworkSink /// 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(); @@ -103,6 +115,8 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink public void ArmLoginTunnel() => Required().ArmLoginTunnel(); + public void RequestLogout() => Required().RequestLogout(); + public void ResetSession() => Required().ResetSession(); public void ResetGenerationPresentation() => @@ -185,6 +199,105 @@ internal sealed class RuntimeLoginLifecycleSource _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 { @@ -381,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); @@ -414,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) { @@ -579,6 +713,15 @@ internal sealed class LocalPlayerTeleportController /// 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, @@ -590,7 +733,8 @@ internal sealed class LocalPlayerTeleportController ILocalPlayerTeleportSession session, ILocalPlayerTeleportPresentation presentation, RuntimeAcceptedPositionDriveController acceptedPositionDrive, - ILocalPlayerLoginLifecycleSource loginLifecycle) + ILocalPlayerLoginLifecycleSource loginLifecycle, + ILocalPlayerLogoutOperations logout) { _authority = authority ?? throw new ArgumentNullException(nameof(authority)); _input = input ?? throw new ArgumentNullException(nameof(input)); @@ -605,6 +749,7 @@ internal sealed class LocalPlayerTeleportController ?? throw new ArgumentNullException(nameof(acceptedPositionDrive)); _loginLifecycle = loginLifecycle ?? throw new ArgumentNullException(nameof(loginLifecycle)); + _logout = logout ?? throw new ArgumentNullException(nameof(logout)); } public bool IsActive => _transit.IsTeleportActive; @@ -642,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)) @@ -768,9 +923,199 @@ internal sealed class LocalPlayerTeleportController 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) diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index dd52aada..b1eb268b 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( 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/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 61aa3da9..2e6e8b2b 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs @@ -1829,6 +1829,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests 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 734aa47e..3ff9ba57 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs @@ -1063,6 +1063,8 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests 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 a3ec0f36..c1e8f5d1 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -935,6 +935,13 @@ public sealed class LocalPlayerTeleportControllerTests 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, @@ -1064,7 +1071,8 @@ public sealed class LocalPlayerTeleportControllerTests Session, Presentation, AcceptedPositionDrive, - LoginLifecycle); + LoginLifecycle, + Logout); } /// @@ -1483,6 +1491,8 @@ public sealed class LocalPlayerTeleportControllerTests } public void OnLocalPlayerFirstEntryCompleted() => FirstEntryCompletions++; public void ArmLoginTunnel() => LoginTunnelArms++; + public int LogoutRequests; + public void RequestLogout() => LogoutRequests++; public void ResetSession() { } @@ -1871,6 +1881,164 @@ public sealed class LocalPlayerTeleportControllerTests 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; } @@ -1886,6 +2054,30 @@ public sealed class LocalPlayerTeleportControllerTests } = 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; @@ -1910,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.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,