From 989f665214547c7b5be7da2c3352366f1e955d6f Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 06:29:15 +0200 Subject: [PATCH] feat(CT-GF1): port retail ancestor-clip to the retained UI tree Fixes the CT7 gate finding: on the Titles tab, the authored divider 0x10000530 escapes the Character window above its top edge at the CT6-correct 372px mounted default (computed Y ~ -178, matching the owner's screenshot). Retail clips child rendering to the intersected ancestor clip-rect chain -- UIRegion::DrawHere @0x0069FA30 takes the element's screen Box2D plus a SmartArray of inherited clip rects, intersects them (the min/max clamp loop @0x0069FAA7..0x0069FB82), and draws EraseSelf/DrawChildren/DrawSelf with the intersected rect only when non-empty (the var_24 gate @0x0069FB8E). Our UiElement draw walk rendered children unclipped by default, so any authored element relying on clipping -- this divider, and the chat input row at small window sizes (the owner's earlier "text input sticks out on resize" report) -- became a visible artifact. Mechanism (element-level, reusing the existing clip-rect-stack infrastructure in UiRenderContext.PushClip/PopClip): - UiElement.ClipsChildren now defaults to TRUE for every element (was an opt-in used only by UiScrollablePanel/UiItemList). Each element's children draw AND hit-test clipped to the intersection of its own rect with the inherited ancestor clip; an element positioned outside its parent's box silently disappears, matching retail's non-empty-intersection gate. HitTest's existing early bounds check already implemented this shape for ClipsChildren=true elements -- flipping the default aligns hit-testing with the new draw-clip default in one property, per the plan's own point 4. - UiElement.ExpandsClipForPopup (default false) is the one opt-out: retail spawns a menu popup as a SEPARATE top-level region (UIElement_Menu::MakePopup), clipped only by the screen; acdream draws UiMenu's popup inline from the owning button in a second traversal (OnDrawOverlay, pre-existing -- its own doc comment already says "regardless of this element's position in the tree"). DrawOverlays now resets the accumulated clip to unbounded (UiRenderContext.PushClipUnbounded, sharing the existing clip stack) for exactly the OnDrawOverlay call of an opted-in element. UiMenu overrides ExpandsClipForPopup=>true, paired with ClipsChildren=>false so its own out-of-bounds OnHitTest union (the popup occupies ly<0 or ly>=Height depending on open direction) stays reachable through the same early-bounds gate that now defaults on for every other element. Opt-out audit (grep for OnDrawOverlay overrides + negative/overflow OnDraw coordinates across src/AcDream.App/UI): UiMenu's popup is the ONLY OnDrawOverlay override client-wide, so it is the only element needing ExpandsClipForPopup. RetailTooltipPresenter's popup and UiRoot's drag ghost both already escape structurally -- the tooltip mounts as an ordinary UiRoot CHILD (sibling of every window, clipped only by the canvas), and the drag ghost is drawn directly by UiRoot outside the tree entirely -- neither needed a code change, both are covered by new tests proving the invariant. UiResizeGrip and UiNineSlicePanel's frame/bevel draw entirely within their own [0,Width]x[0,Height] (grip flush at the window's own edges; the window's own Width/Height already represents the OUTER frame including its 5px bevel, so its ClipsChildren push already covers the frame's own content children correctly -- no negative insets found). UiScrollbar draws entirely within its own bounds (confirmed by reading OnDraw). Hit-testing: aligned with the new default via the single ClipsChildren flip (see above); UiMenu's own opt-out override keeps its popup hit-test union working, verified by the full UiMenuTests suite staying green. Divergence register: AD-113 filed for the ExpandsClipForPopup adaptation (inline popup drawing vs retail's separate top-level region). Fixed two pre-existing test-harness gaps the new default surfaced (both real bugs in the harnesses, not workarounds around the fix): - ChatLayoutConformanceTests' bottom-right-grip grow test read a STALE (pre-shrink) grip screen position because it drove two resize gestures back-to-back with no intervening Draw pass -- the only place UiElement.ApplyAnchor/LayoutPolicy.Apply run. A real frame draws every tick, so production never hits this; the test now inserts a real DrawSelfAndChildren pass between the two gestures, matching a real frame boundary. - VendorUiControllerTests' hand-built Items/Buying/Selling page containers were left at their bare 0x0 UiElement default (the harness never runs a real DAT-driven layout pass) -- harmless before ancestor clipping existed, but now hides every child of an unsized page. Sized them to the window's own content root, matching production's shape (a tab page fills the window body). Tests (all confirmed as genuine regression pins by temporarily reverting the relevant default/override and observing the exact predicted failure, then reverting back): - CharacterTitlesControllerTests.TitlesPage_Divider_ClipsAwayAtThe CT6Default_AndAppearsWhenTheWindowGrowsTaller: the literal gate repro against the real character_2100002E.json fixture through RetailWindowFrame.Mount at the CT6 372px default -- the divider renders nothing (computed Y ~ -173, matching the owner's ~-178); growing the window to 600px renders it at its authored spot. - ChatLayoutConformanceTests.ResizingTheWindowSmall_NoInputRowQuad RendersOutsideTheWindowRect: no input-row quad escapes the chat window rect at three small sizes (300x100 sanity control, 120x40/80x30 genuine pre-fix overflow -- verified failing without the fix at Y=38/55 past the window edge). - UiAncestorClipTests (new file): the core mechanism against plain synthetic elements (culled-outside / clipped-at-the-edge / hit-test parity), UiMenu's popup escaping a tiny owning window (and staying clipped while closed), and the tooltip's structural immunity (mounts as a UiRoot sibling, unaffected by a tiny ancestor window). Verification: full solution build green; hermetic suite green (--filter "Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live& Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux& Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure", 14,000+ tests across every project); InstalledDat lane green (ACDREAM_RUN_INSTALLED_DAT_TESTS=1, Status!=KnownFailure, 205+34+3+172 tests). CharacterTitlesControllerTests' existing suite and the full UiMenuTests/UiScrollbarTests suites are unaffected. src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) -- untouched by this change and deliberately left out of this commit. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 3 +- src/AcDream.App/UI/UiElement.cs | 62 +++- src/AcDream.App/UI/UiMenu.cs | 25 ++ src/AcDream.App/UI/UiRenderContext.cs | 15 + .../Layout/CharacterTitlesControllerTests.cs | 147 ++++++++++ .../UI/Layout/ChatLayoutConformanceTests.cs | 93 ++++++ .../UI/Layout/VendorUiControllerTests.cs | 14 + .../UI/UiAncestorClipTests.cs | 274 ++++++++++++++++++ 8 files changed, 627 insertions(+), 6 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/UiAncestorClipTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 157c7281..1c99a8b6 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -67,7 +67,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 86 active rows (AD-112 filed 2026-08-23 with the sky default-script port — camera-anchored synthetic script owners instead of retail's sky-cell physics objects; AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- +## 2. Adaptation (AD) — 87 active rows (AD-113 filed 2026-08-25 at Campaign CT slice CT-GF1 — `UiMenu`'s inline-drawn popup opts out of the new client-wide ancestor-clip default (`ExpandsClipForPopup`), standing in for retail's separate top-level popup region; AD-112 filed 2026-08-23 with the sky default-script port — camera-anchored synthetic script owners instead of retail's sky-cell physics objects; AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- -to-line-break shaping, which retail's `ItemExamineUI::AddItemInfo @0x004AC050` does not do (wire text appends verbatim; the escape decode retail runs at `StringInfo` resolution now lives at our string source, `DatStringResolver` → `RetailStringEscapes`); AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate @@ -209,6 +209,7 @@ readiness/requeue adaptation. See | AD-100 | **Filed 2026-08-15 at the Campaign CC CC2 review, finding F2 (unrequested `0xF643` handling).** When a `0xF643` (`CharGenVerificationResponse`) arrives with NO outstanding create/restore request, acdream DROPS the message with a once-per-session stderr log. Retail has no such gate: `Handle_CharGenVerificationResponse @0x0055E8B0` processes whatever arrives, discriminating create-vs-restore by its OWN persistent verification state (case 1 branches on `GetVerificationState() == PENDING` → new `CharacterIdentity` + `AddIdentity`, else unpacks into the existing identity at `slot`) — an unsolicited reply would be applied against whatever that state happens to be. acdream's transport-level latch (`PendingCharGenVerificationRequest`) is the equivalent discriminator, but when it is `None` there is no state to apply the reply against, so the honest move is drop-and-log rather than guessing a family. | `src/AcDream.Core.Net/WorldSession.cs` (the `CharGenVerificationResponse.ResponseOpcode` arm in `ProcessDatagram`; `_loggedUnexpectedCharGenVerificationResponse`) | Processing an unsolicited reply requires retail's persistent chargen verification state, which lives in CC3's Runtime owner, not the transport. Until then a reply with no outstanding request is either a server bug or a latch-lifecycle bug on our side — surfacing it in the log beats silently misrouting it to an arbitrary event. Pinned by `WorldSessionCharacterCreationTests.ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed`. | A server that sends a spontaneous/duplicate `0xF643` (ACE can double-send NameInUse — see the CC2 review's F3 note) has its second copy dropped here, where retail would re-process it. If CC3's verification gate ever needs retail's re-process semantics, this drop must move behind that owner's state. | `Handle_CharGenVerificationResponse @0x0055E8B0`; `CharGenState::GetVerificationState`; CC2 review F2 (2026-08-15) | | AD-102 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Heritage page's Viamontian button and the Town page's Sanamar button).** Retail gates BOTH controls behind `CPlayerSystem::AccountHasThroneOfDestiny`: `gmCGHeritagePage::ListenToElementMessage @ 0x00483860` shows `MakeToDWarningDialog` instead of selecting Viamontian (element `0x100003c3`) for a non-ToD account, and `gmCGTownPage::ListenToElementMessage @ 0x0047c480` does the same for Sanamar (element `0x1000040b`, `startArea` index 3 — also the reason `CharGenState::RandomizeStartArea`'s ToD-aware `RandInt(3 or 4)` bound exists). acdream's `ChargenOptions` (CC1) carries no account/DLC-ownership signal anywhere in the model, so both controls ship WITHOUT the gate — every installed heritage/town in `Options.HeritagesById`/`Options.StarterAreas` is always selectable, matching what a ToD-owning account would see. | `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`HeritageByButtonId[0x100003C3u]`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`StartAreaByButtonId[0x1000040Bu]`, `Randomize`) | ACE's server-side `CharacterCreate` handler never checks ToD ownership either (the field is purely a retail-client UI gate), so accepting the selection unconditionally never produces a request the emulator would reject; adding an account-ownership model to CC1's DAT-only `ChargenOptions` is out of this slice's scope and would need its own design (where does the "ToD owned" bit come from — account service, launcher config, a new env flag?). | None observable against ACE. A future retail-parity gate that specifically checks "does a non-ToD account get warned off Viamontian/Sanamar" will fail until an account-ownership signal exists to gate on. | `gmCGHeritagePage::ListenToElementMessage @ 0x00483860`; `gmCGTownPage::ListenToElementMessage @ 0x0047c480`; `gmCGTownPage::SetTown @ 0x0047c360`; `CharGenState::RandomizeStartArea` (DoRandom case 4, `RandInt(hasToD ? 4 : 3)`) | | AD-99 | **Filed 2026-08-15 at Campaign LA gate round 2 finding 1 (character-select Exit button).** On a confirmed Exit, acdream closes the client through the existing graceful window-close path (`d.Window.Close`, the same seam `GameplayInputCommandController`'s in-world Escape fallback already uses) instead of retail's real post-confirm behavior: `RecvNotice_CloseDialog`'s case-1 arm queues UI mode `0x10000009`, which `gmEpilogueUI::Register` claims — a brief epilogue/farewell screen — before the process actually terminates. The confirmation dialog itself (`MakeConfirmExitDialog`, its exact `ID_CharacterManagement_ConfirmExit` text, and the `m_confirmExitDialogContext != 0` re-entry guard) IS ported faithfully; only the post-confirm destination differs, the same shape as AD-74's Options-panel exit. | `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (`RequestExit`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`CharacterSelectionRuntimeBindings.RequestExit`); `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` (`d.Window.Close` binding) | acdream has no `gmEpilogueUI` port (out of scope this round); reusing the ONE existing graceful-shutdown seam keeps `disconnected`/`exited` status events firing through `GameWindow.OnClosing` → `CompleteShutdown` rather than inventing a second shutdown path, per explicit direction for this finding. | A user confirming Exit sees the window close immediately instead of retail's brief epilogue screen; a future feature wanting to reproduce that screen (or an intermediate "logged off, returned to character select" state) has no seam yet — same gap class as AD-44. | `gmCharacterManagementUI::MakeConfirmExitDialog @0x004ed250`; `RecvNotice_CloseDialog @0x004ed760` case 1; `gmEpilogueUI::Register(0x10000009)` @0x0047a680; `gmCharacterManagementUI::OnAction @0x004ed410` (Escape key, unported — button-only this round) | +| AD-113 | **Filed 2026-08-25 at Campaign CT slice CT-GF1 (client-wide retained-UI ancestor clip).** Porting retail's `UIRegion::DrawHere @0x0069FA30` ancestor-clip intersection (an element's screen rect is intersected against the FULL inherited clip-rect chain and the subtree is skipped when the intersection is empty — the `var_24` gate @0x0069FB8E) as `UiElement.ClipsChildren`'s new client-wide default (true, threaded through the pre-existing `UiRenderContext.PushClip`/`PopClip`) needed one deliberate opt-out: retail spawns a menu's dropdown popup as a SEPARATE top-level region (`UIElement_Menu::MakePopup`), clipped only by the screen, while acdream's `UiMenu` draws its popup INLINE from the owning button in a second traversal (`OnDrawOverlay`, pre-existing, "regardless of this element's position in the tree" by its own doc comment). Without an escape, the new ancestor clip would wrongly cut off a popup that legitimately extends outside its own (possibly short) owning window — e.g. a channel dropdown opened upward past a short chat window's top edge. `UiElement.ExpandsClipForPopup` (default false) resets the accumulated clip to unbounded for exactly the `OnDrawOverlay` call of an opted-in element (`UiRenderContext.PushClipUnbounded`, sharing the existing clip stack); `UiMenu` overrides it true, paired with `ClipsChildren => false` so its own out-of-bounds `OnHitTest` union (the popup occupies `ly < 0` or `ly >= Height` depending on open direction) stays reachable through the same early-bounds gate that now defaults on for every other element. | `src/AcDream.App/UI/UiElement.cs` (`ClipsChildren`, `ExpandsClipForPopup`, `DrawOverlays`); `src/AcDream.App/UI/UiRenderContext.cs` (`PushClipUnbounded`); `src/AcDream.App/UI/UiMenu.cs` (the two overrides) | The popup is the ONLY overlay-drawing widget in the tree today (grep-confirmed: exactly one `OnDrawOverlay` override client-wide), and it already renders on top of the whole UI by construction (the overlay pass beats even rect backgrounds), so exempting it from the ancestor clip matches its existing "regardless of tree position" contract rather than introducing new behavior. | A future `OnDrawOverlay` override that is NOT a screen-anchored popup (e.g. an in-place highlight meant to stay window-clipped) would silently escape every ancestor's clip if it left `ExpandsClipForPopup` at its default; the opt-in default direction makes that the exception rather than the rule, but a widget that WANTS window-clipped overlay content has no dedicated seam beyond simply not overriding the escape. | `UIRegion::DrawHere @0x0069FA30`; `UIElement_Menu::MakePopup`; the register's own AP-201 retirement note (the FIRST `ClipsChildren`/`PushClip` port, for `UiScrollablePanel`'s viewport) | --- diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index 0a295d14..0002e869 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -544,11 +544,51 @@ public abstract class UiElement protected virtual void OnDrawOverlay(UiRenderContext ctx) { } /// - /// When true, descendant drawing and hit-testing are clipped to this element's - /// local bounds. Scrollable listboxes use this so edge rows can remain visible - /// at arbitrary pixel offsets without painting or receiving input outside the viewport. + /// Whether descendant drawing and hit-testing are clipped to this element's + /// local bounds. THIS IS THE DEFAULT (true) FOR EVERY ELEMENT — CT-GF1 port + /// of retail's ancestor-clip chain: UIRegion::DrawHere @0x0069FA30 takes + /// the element's screen Box2D plus a SmartArray<Box2D> of + /// inherited clip rects, intersects them (the min/max clamp loop + /// @0x0069FAA7..0x0069FB82), and draws — EraseSelf/DrawChildren/ + /// DrawSelf all receive the intersected rect — ONLY when the intersection + /// is non-empty (the var_24 gate @0x0069FB8E). An element positioned + /// outside its parent's box therefore silently disappears in retail, exactly + /// like / + /// (already wrapping the child-draw and child-hit-test walks below) now does for + /// every element by default, not just the scrollable listboxes that opted in + /// before this default flipped (owner gate finding: the Titles page's authored + /// divider 0x10000530 escaped the Character window at the CT6-correct 372px + /// mounted default — retail clips it away; acdream drew it floating above the + /// window). + /// + /// + /// Override to ONLY for a widget that must draw or accept + /// input beyond its own bounds by deliberate design — today just + /// , whose popup (and its own out-of-bounds + /// OnHitTest override) stands in for retail's separate top-level popup + /// region; see for the drawing half of that + /// opt-out and the divergence register row it cites. + /// /// - protected virtual bool ClipsChildren => false; + protected virtual bool ClipsChildren => true; + + /// + /// True when this element's content must ignore the + /// standard ancestor clip chain that now threads through + /// every element by default (CT-GF1). Retail spawns popups/dropdowns as SEPARATE + /// top-level regions (UIElement_Menu::MakePopup), so only the SCREEN clips + /// them — never an intervening window or panel's own client rect. Ours draws a + /// popup INLINE from its owning widget instead of reparenting to a new root (see + /// 's own doc comment — that second traversal already + /// exists so popups composite "regardless of this element's position in the + /// tree"), so without this escape hatch the new default clip would wrongly cut off + /// a popup that legitimately extends outside its owning window — e.g. a dropdown + /// opened near the bottom of a short window. Default false (ordinary overlay + /// content, if any is ever added beyond , stays clipped like + /// everything else). See the divergence register row this property's introducing + /// commit adds for the seam it stands in for. + /// + protected virtual bool ExpandsClipForPopup => false; /// Per-frame tick (animations, timers, caret blink). protected virtual void OnTick(double deltaSeconds) { } @@ -658,7 +698,19 @@ public abstract class UiElement ctx.PushAlpha(Opacity); try { - OnDrawOverlay(ctx); + // ExpandsClipForPopup (CT-GF1): a popup drawn here must ignore whatever + // ancestor clip the walk down to this element accumulated — see the + // property's own doc comment for the retail-parity rationale. + if (ExpandsClipForPopup) + { + ctx.PushClipUnbounded(); + try { OnDrawOverlay(ctx); } + finally { ctx.PopClip(); } + } + else + { + OnDrawOverlay(ctx); + } if (_children.Count > 0) { bool clipsChildren = ClipsChildren; diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index fdcf54b9..56114730 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -396,6 +396,31 @@ public sealed class UiMenu : UiElement /// must NOT be built (an invisible label child would intercept the button click). public override bool ConsumesDatChildren => true; + /// + /// CT-GF1 opt-out: 's new client-wide default + /// (true) also gates 's early "am I even inside my + /// own bounds" check — which would return null for every popup click before ever + /// reaching 's own out-of-bounds union below (the popup + /// occupies ly < 0 when it opens upward, or ly >= Height when it + /// opens downward — see 's doc). UiMenu has no real dat + /// children ( is true), so this override changes + /// nothing about child drawing/hit-testing; it exists purely to keep this element's + /// OWN out-of-bounds popup region reachable, pairing with + /// below for the drawing half of the same escape. + /// + protected override bool ClipsChildren => false; + + /// + /// CT-GF1: the popup drawn in is retail's stand-in for a + /// SEPARATE top-level region (UIElement_Menu::MakePopup) — see + /// 's own doc comment for the full + /// rationale and the divergence register row it cites. Without this, the new + /// default ancestor clip would cut off a popup that legitimately opens outside its + /// owning window (e.g. a short chat window's channel dropdown, which draws its rows + /// ABOVE the button and can extend past the window's own top edge). + /// + protected override bool ExpandsClipForPopup => true; + protected override void OnDraw(UiRenderContext ctx) { var resolve = SpriteResolve; diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index 6abfbf83..3697f7c9 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -115,6 +115,21 @@ public sealed class UiRenderContext _clipStack.RemoveAt(_clipStack.Count - 1); } + /// + /// Discard every inherited clip rect for the duration of one overlay draw — + /// the escape hatch uses so a popup + /// drawn inline from its owning widget (see that property's doc comment for the + /// retail-parity rationale) is not wrongly clipped by the ancestor chain the + /// CT-GF1 default clip () now threads through + /// every other element. Shares 's stack, so pair the two + /// exactly like . + /// + public void PushClipUnbounded() + { + _clipStack.Add(_clip); + _clip = null; + } + /// Route subsequent draws to the overlay layer (flushed on top of the whole /// UI). Used by the root for the popup/overlay traversal. Pair with . public void BeginOverlayLayer() => TextRenderer.OverlayMode = true; diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs index 07f7beb2..27424699 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs @@ -1,4 +1,7 @@ using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Runtime; @@ -611,6 +614,150 @@ public sealed class CharacterTitlesControllerTests } } + // ── CT-GF1 ancestor-clip gate repro ───────────────────────────────── + + /// + /// OWNER GATE FINDING (screenshots on file, Campaign CT slice CT-GF1): + /// on the Titles tab, the page's authored divider 0x10000530 (300x9, + /// authored Y=60 in the 575px page, top-edge mode 2 = bottom-anchored at + /// 515px from the page bottom) escapes the window at the CT6-correct + /// 372px mounted default — the page is only ~337px tall there, so the + /// divider's bottom-anchor math computes a NEGATIVE Y and renders ABOVE + /// the window entirely. Retail clips this away + /// (UIRegion::DrawHere @0x0069FA30's ancestor-clip rect + /// intersection, non-empty gate @0x0069FB8E); acdream drew it floating + /// above the window before this fix. Sibling divider 0x10000534 + /// (authored Y=550) has the same shape but lands harmlessly at this + /// size — both share sprite 0x06001420, which is why the assertions + /// below key on Y-RANGE (this divider's own resolved screen position), + /// not texture. + /// + /// Mounts the real fixture through the same + /// production shape as RetailUiRuntime.MountCharacter (372px + /// default: ContentHeight=362f + the 10px NineSlice inset), switches to + /// the REAL Titles tab via 's own + /// click handler (not a manual Visible poke — the CT3 tab-switch + /// closure this file's own CharacterTabs_UseImportedChromeWithout... + /// sibling test already exercises), and draws through a + /// . Unlike 's + /// harness (which resolves every sprite to texture 0 for the OTHER + /// Titles tests in this file — sufficient for their geometry/wiring + /// assertions), this test resolves real non-zero textures so + /// UiDatElement.OnDraw actually queues quad geometry to inspect. + /// + [Fact] + public void TitlesPage_Divider_ClipsAwayAtTheCT6Default_AndAppearsWhenTheWindowGrowsTaller() + { + ImportedLayout layout = LayoutImporter.Build( + FixtureLoader.LoadCharacterInfos(), id => (id, 8, 8), null); + CharacterStatController.Bind( + layout, SampleData.SampleCharacter, spriteResolve: id => (id, 8, 8)); + + var titlesTab = Assert.IsType( + layout.FindElement(CharacterStatController.TabTitlesId)); + Assert.NotNull(titlesTab.OnClick); + titlesTab.OnClick!(); // the REAL tab-switch path — flips TitlesPage.Visible + + UiElement divider = UiElement.FindDescendant(layout.Root, 0x10000530u)!; + Assert.NotNull(divider); + UiElement siblingDivider = UiElement.FindDescendant(layout.Root, 0x10000534u)!; + Assert.NotNull(siblingDivider); + + var screen = new UiRoot { Width = 1600f, Height = 1200f }; + RetailWindowHandle handle = RetailWindowFrame.Mount( + screen, + layout.Root, + id => (id, 8, 8), + new RetailWindowFrame.Options + { + WindowName = WindowNames.Character, + Chrome = RetailWindowChrome.NineSlice, + Left = 0f, + Top = 0f, + // CT6's own corrected default: the host's content parent is + // 300x362, not 0x2100002E's raw 300x600 authoring canvas. + ContentHeight = 362f, + MinWidth = 310f, + MaxWidth = 310f, + MinHeight = 372f, + MaxHeight = 1000f, + ResizeX = false, + ResizeY = true, + ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, + }); + Assert.Equal(372f, handle.Height); // the CT6-correct mounted default + + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(screen.Width, screen.Height)); + var ctx = new UiRenderContext(renderer, new Vector2(screen.Width, screen.Height)); + handle.OuterFrame.DrawSelfAndChildren(ctx); + + // At the 372px default the divider's computed Y must be negative + // (above the window) — the owner's reported Y≈-178 shape. + Vector2 dividerAtDefault = divider.ScreenPosition; + Assert.True( + dividerAtDefault.Y + divider.Height <= 0f, + "expected the Titles divider to compute a Y above the window at the 372px " + + $"default (owner-reported ≈-178); got {dividerAtDefault.Y}"); + // Nothing at all may render meaningfully above the window's own top + // edge (Y=0 itself is the window's own top border/frame, not "above + // the window") — the exact shape of the owner's screenshot finding. + AssertNoQuadCoversY(renderer, -10_000f, -1f); + + // Grow the window taller. A real frame draws every tick, which is + // what reflows a bottom-anchored child against its parent's CURRENT + // size (UiElement.ApplyAnchor / LayoutPolicy.Apply run only from + // DrawSelfAndChildren) — two passes, matching the CT6 sibling test's + // own raw-edge-LayoutPolicy "policies settle" pattern above. + handle.OuterFrame.Height = 600f; + renderer.Begin(new Vector2(screen.Width, screen.Height)); + handle.OuterFrame.DrawSelfAndChildren(ctx); + renderer.Begin(new Vector2(screen.Width, screen.Height)); + handle.OuterFrame.DrawSelfAndChildren(ctx); + + Vector2 dividerGrown = divider.ScreenPosition; + Assert.True( + dividerGrown.Y >= 0f && dividerGrown.Y + divider.Height <= 600f, + "expected the Titles divider to land inside the grown window at its authored " + + $"spot; got {dividerGrown.Y}"); + AssertQuadCoversY(renderer, dividerGrown.Y, dividerGrown.Y + divider.Height); + } + + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } + + private static void AssertNoQuadCoversY(TextRenderer renderer, float yLo, float yHi) + { + foreach (var seg in renderer.DebugSpriteSegmentVerts) + { + for (int i = 0; i < seg.Verts.Count / 8; i++) + { + float vy = seg.Verts[i * 8 + 1]; + Assert.False( + vy > yLo - 0.01f && vy < yHi + 0.01f, + $"unexpected quad vertex at Y={vy} inside the clipped-away range " + + $"[{yLo},{yHi}] (texture {seg.Texture})"); + } + } + } + + private static void AssertQuadCoversY(TextRenderer renderer, float yLo, float yHi) + { + bool found = renderer.DebugSpriteSegmentVerts.Any(seg => + { + for (int i = 0; i < seg.Verts.Count / 8; i++) + { + float vy = seg.Verts[i * 8 + 1]; + if (vy >= yLo - 0.5f && vy <= yHi + 0.5f) return true; + } + return false; + }); + Assert.True(found, $"expected at least one quad in Y range [{yLo},{yHi}]"); + } + // ── Lifecycle ─────────────────────────────────────────────────────── [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs index bb7ff5b6..b21ca13a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs @@ -603,6 +603,23 @@ public class ChatLayoutConformanceTests Assert.Equal(390f, handle.Width); Assert.Equal(100f, handle.Height); + // CT-GF1: a real frame draws every tick, which is what reflows an + // anchored child's Left/Top against its parent's CURRENT size + // (UiElement.ApplyAnchor runs only from DrawSelfAndChildren) — so by + // the time a player's next click lands, the grip is already + // repositioned for the just-shrunk window. This test drives the resize + // directly without an intervening render, so without this draw pass the + // grip's ScreenPosition below stays at its PRE-shrink (now stale, wider) + // anchor and lands outside the shrunk window's own bounds — UiElement's + // new default ancestor clip (ClipsChildren) then refuses the press + // before it ever reaches the grip. Matches a real frame boundary, not a + // workaround for the clip. + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(root.Width, root.Height)); + var drawCtx = new UiRenderContext(renderer, new Vector2(root.Width, root.Height)); + root.DrawSelfAndChildren(drawCtx); + // Now grow from the shrunken state — this is the reported-broken direction. var brGripAfterShrink = Assert.IsType(layout.FindElement(0x100006A1u)); var gs2 = brGripAfterShrink.ScreenPosition; @@ -732,6 +749,82 @@ public class ChatLayoutConformanceTests $"input ends {input.Left + input.Width} past send start {send.Left}"); } + /// + /// CT-GF1 regression pin — companion to + /// above, which + /// only proves the input row's Left/Width GEOMETRY stays inside the + /// window: that test's LayoutImporter.Build call resolves every + /// sprite through (texture 0), and + /// UiDatElement.OnDraw's own tex == 0 guard means nothing + /// ever reaches a quad — exactly why the owner's "text input sticks out + /// on resize" report was previously unreproducible in a fixture. This + /// test resolves REAL non-zero textures (same id => (id, 8, 8) + /// pattern as MountedChatWindow_LiveGrip_ActuallyEmitsASpriteDraw_ + /// NotJustResolvesSpriteFile above) and draws the whole mounted + /// window through a at small sizes, then + /// asserts every emitted quad's vertices stay inside the window's own + /// [0,width]x[0,height] rect. The mechanism CT-GF1 ports + /// (UiElement.ClipsChildren's new client-wide default, + /// retail's UIRegion::DrawHere @0x0069FA30 ancestor-clip + /// intersection) is what makes this true now — confirmed a real + /// regression pin (not vacuous) by temporarily reverting the default: + /// the 120x40/80x30 cases fail without the fix (a quad renders ~15-38px + /// past the window's bottom edge, the input row's authored ~72px extent + /// no longer fitting a window shrunk below the ~100px it was designed + /// for) and pass with it; 300x100 is the "still comfortably fits, sanity" + /// control case. + /// + [Theory] + [InlineData(300f, 100f)] + [InlineData(120f, 40f)] + [InlineData(80f, 30f)] + public void ResizingTheWindowSmall_NoInputRowQuadRendersOutsideTheWindowRect(float width, float height) + { + var infos = FixtureLoader.LoadChatInfos(); + ImportedLayout layout = LayoutImporter.Build(infos, id => (id, 8, 8), null); + var controller = ChatWindowController.Bind( + infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, + new ChatWindowState(), null, null, NoTex); + Assert.NotNull(controller); + UiElement window = layout.FindElement(0x10000600u)!; + var root = new UiRoot { Width = 800f, Height = 600f }; + root.AddChild(window); + + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(root.Width, root.Height)); + var ctx = new UiRenderContext(renderer, new Vector2(root.Width, root.Height)); + window.DrawSelfAndChildren(ctx); + + window.Width = width; + window.Height = height; + window.ResetAnchorCapture(); + // Two frames — same "raw-edge LayoutPolicy needs a settle pass" reasoning + // as the geometry sibling test above. + renderer.Begin(new Vector2(root.Width, root.Height)); + window.DrawSelfAndChildren(ctx); + renderer.Begin(new Vector2(root.Width, root.Height)); + window.DrawSelfAndChildren(ctx); + + float windowLeft = window.ScreenPosition.X; + float windowTop = window.ScreenPosition.Y; + const float Slop = 0.5f; + foreach (var seg in renderer.DebugSpriteSegmentVerts) + { + for (int i = 0; i < seg.Verts.Count / 8; i++) + { + float vx = seg.Verts[i * 8]; + float vy = seg.Verts[i * 8 + 1]; + Assert.True( + vx >= windowLeft - Slop && vx <= windowLeft + width + Slop + && vy >= windowTop - Slop && vy <= windowTop + height + Slop, + $"quad vertex ({vx},{vy}) escapes the {width}x{height} chat window rect " + + $"[{windowLeft},{windowTop}]-[{windowLeft + width},{windowTop + height}] " + + $"(texture {seg.Texture})"); + } + } + } + private static void ApplyLayoutPassLocal(UiElement parent) { foreach (var child in parent.Children) diff --git a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs index d37c6585..389fafc2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs @@ -274,6 +274,20 @@ public sealed class VendorUiControllerTests root.AddChild(ItemsPage); root.AddChild(BuyingPage); root.AddChild(SellingPage); + // CT-GF1: UiElement.ClipsChildren now defaults to true (retail's + // UIRegion::DrawHere ancestor-clip port) — a page container's + // CHILDREN are unreachable by draw or hit-test once the container's + // own Width/Height clips them away. This hand-built harness never ran + // a real DAT-driven layout pass, so these bare TestElement pages were + // left at their 0x0 default; that was harmless before this default + // flipped (nothing clipped, so a 0x0 "page" still let its children + // draw/hit-test anywhere) but now hides every child of an unsized + // page, matching production's shape (a tab page fills the window + // body below the tab strip) — not a workaround, just giving the + // hand-built fixture the geometry a real mounted page always has. + ItemsPage.Width = root.Width; ItemsPage.Height = root.Height; + BuyingPage.Width = root.Width; BuyingPage.Height = root.Height; + SellingPage.Width = root.Width; SellingPage.Height = root.Height; ItemsPage.AddChild(ItemList); ItemsPage.AddChild(ItemScrollbar); ItemsPage.AddChild(TypeMenu); diff --git a/tests/AcDream.App.Tests/UI/UiAncestorClipTests.cs b/tests/AcDream.App.Tests/UI/UiAncestorClipTests.cs new file mode 100644 index 00000000..e240990c --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiAncestorClipTests.cs @@ -0,0 +1,274 @@ +using System.Linq; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI; + +/// +/// Campaign CT slice CT-GF1: mechanism-level tests for the client-wide retained-UI +/// ancestor clip ('s new default-true, porting +/// retail's UIRegion::DrawHere @0x0069FA30 clip-rect-chain intersection) and its +/// one deliberate opt-out (, used by +/// 's inline-drawn popup). The Titles-page divider gate repro lives +/// in CharacterTitlesControllerTests (the real owner-reported symptom); the chat +/// input-row regression pin lives in ChatLayoutConformanceTests. This file covers +/// the underlying mechanism directly with small synthetic trees. +/// +public sealed class UiAncestorClipTests +{ + private sealed class TestElement : UiElement { } + + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } + + private static (RecordingGpuDevice device, TextRenderer renderer, UiRenderContext ctx) MakeContext( + float w, float h) + { + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(w, h)); + var ctx = new UiRenderContext(renderer, new Vector2(w, h)); + return (device, renderer, ctx); + } + + private static bool AnyQuadAt(TextRenderer renderer, System.Func predicate) + { + foreach (var seg in renderer.DebugSpriteSegmentVerts) + { + for (int i = 0; i < seg.Verts.Count / 8; i++) + { + if (predicate(seg.Verts[i * 8], seg.Verts[i * 8 + 1])) + return true; + } + } + return false; + } + + /// + /// Core mechanism, plain elements (no dat import involved): a child positioned + /// entirely outside its parent's [0,Width]x[0,Height] rect renders NOTHING — the + /// retail UIRegion::DrawHere "intersection empty -> skip the subtree" gate + /// (@0x0069FB8E). A sibling positioned INSIDE the parent still renders normally. + /// + [Fact] + public void ChildOutsideParentBounds_RendersNothing_SiblingInsideStillRenders() + { + var parent = new TestElement { Width = 50f, Height = 50f }; + var outside = new UiSolidSpriteFill + { + Left = -100f, Top = -100f, Width = 20f, Height = 20f, + SpriteId = 7u, + SpriteResolve = id => (id, 8, 8), + }; + var inside = new UiSolidSpriteFill + { + Left = 5f, Top = 5f, Width = 10f, Height = 10f, + SpriteId = 9u, + SpriteResolve = id => (id, 8, 8), + }; + parent.AddChild(outside); + parent.AddChild(inside); + + var (_, renderer, ctx) = MakeContext(200f, 200f); + parent.DrawSelfAndChildren(ctx); + + Assert.DoesNotContain(renderer.DebugSpriteSegmentVerts, s => s.Texture == 7u); + Assert.Contains(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u); + } + + /// + /// A child straddling the parent's edge is clipped to the visible sliver, not + /// culled outright and not drawn full-size — the intersected rect + /// UIRegion::DrawHere passes to DrawSelf. + /// + [Fact] + public void ChildStraddlingParentEdge_ClipsToTheVisibleSliver() + { + var parent = new TestElement { Width = 50f, Height = 50f }; + var straddling = new UiSolidSpriteFill + { + Left = 40f, Top = 10f, Width = 30f, Height = 10f, // spans x=[40,70), parent ends at 50 + SpriteId = 3u, + SpriteResolve = id => (id, 8, 8), + }; + parent.AddChild(straddling); + + var (_, renderer, ctx) = MakeContext(200f, 200f); + parent.DrawSelfAndChildren(ctx); + + var seg = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 3u); + float maxX = 0f; + for (int i = 0; i < seg.Verts.Count / 8; i++) + maxX = System.MathF.Max(maxX, seg.Verts[i * 8]); + Assert.True(maxX <= 50.01f, $"clipped quad's rightmost X ({maxX}) must not exceed the parent's edge (50)"); + } + + /// + /// Hit-testing gets the SAME default: a point outside the parent's bounds never + /// reaches a child positioned there, even though the child's own local hit-test + /// would otherwise accept it (aligning HitTest with the new draw-clip default per + /// the CT-GF1 plan's point 4). + /// + [Fact] + public void ChildOutsideParentBounds_IsNeverHit() + { + var parent = new TestElement { Width = 50f, Height = 50f }; + var outside = new TestElement { Left = -30f, Top = -30f, Width = 20f, Height = 20f }; + parent.AddChild(outside); + + UiElement? hit = parent.HitTest(-20f, -20f); // lands inside `outside`'s own local rect + + Assert.Null(hit); + } + + /// + /// CT-GF1's one opt-out: 's popup (drawn inline via + /// OnDrawOverlay) must keep escaping its owning window's ancestor clip — + /// retail's separate top-level popup region, see 's + /// doc comment and the AD-113 divergence register row. A small window (80x18, the + /// menu button's own size) sits well below the canvas top; the popup opens UPWARD + /// (the class default) and must still render there, well outside the window's own + /// [0,80]x[0,18] rect. + /// + [Fact] + public void UiMenuPopup_StillRendersOutsideItsOwningWindow_AncestorClipDoesNotCutItOff() + { + var root = new TestElement { Width = 200f, Height = 200f }; + var window = new TestElement { Left = 10f, Top = 150f, Width = 80f, Height = 18f }; + var menu = new UiMenu + { + Width = 80f, + Height = 18f, + Items = new[] { new UiMenu.MenuItem("Row", (object?)null) }, + SpriteResolve = id => (id, 8, 8), + }; + root.AddChild(window); + window.AddChild(menu); + + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); + Assert.True(menu.IsOpen); + + // Not wrapped in BeginOverlayLayer/EndOverlayLayer (which UiRoot.DrawCore does + // in production, routing overlay draws to a SEPARATE buffer with no debug + // accessor) — the clip mechanism under test is layer-agnostic, so drawing to + // the normal buffer keeps DebugSpriteSegmentVerts usable here. + var (_, renderer, ctx) = MakeContext(200f, 200f); + root.DrawOverlays(ctx); + + // The popup opens upward (bottom touches the button's top, y=0), so its + // absolute screen top is window.Top(150) minus its own outer height — well + // above window.Top. Assert at least one popup quad renders strictly above the + // owning window's own top edge, i.e. outside window's [0,18] local rect. + bool escapedAboveWindow = AnyQuadAt(renderer, (_, y) => y < 150f - 0.5f); + Assert.True( + escapedAboveWindow, + "expected the open UiMenu popup to render above its owning window's top edge"); + } + + /// + /// Companion negative check: with the SAME geometry but the popup left CLOSED, no + /// quad renders above the window at all — proving the escape above is specifically + /// about the OPEN popup's content, not a blanket unclipped draw for the whole menu. + /// + [Fact] + public void UiMenuClosed_NothingRendersAboveItsOwningWindow() + { + var root = new TestElement { Width = 200f, Height = 200f }; + var window = new TestElement { Left = 10f, Top = 150f, Width = 80f, Height = 18f }; + var menu = new UiMenu + { + Width = 80f, + Height = 18f, + Items = new[] { new UiMenu.MenuItem("Row", (object?)null) }, + SpriteResolve = id => (id, 8, 8), + }; + root.AddChild(window); + window.AddChild(menu); + Assert.False(menu.IsOpen); + + // Not wrapped in BeginOverlayLayer/EndOverlayLayer (which UiRoot.DrawCore does + // in production, routing overlay draws to a SEPARATE buffer with no debug + // accessor) — the clip mechanism under test is layer-agnostic, so drawing to + // the normal buffer keeps DebugSpriteSegmentVerts usable here. + var (_, renderer, ctx) = MakeContext(200f, 200f); + root.DrawOverlays(ctx); + + Assert.False(AnyQuadAt(renderer, (_, y) => y < 150f - 0.5f)); + } + + /// + /// Retail's hover tooltip is the OTHER content this codebase draws "regardless of + /// tree position" (see 's class doc): it mounts + /// its popup as an ordinary CHILD (a sibling of every window), + /// not nested inside whatever widget triggered it — so unlike 's + /// popup, it needs no opt-out; it was + /// never inside the triggering window's ancestor-clip subtree to begin with. This + /// pins that structural invariant survives CT-GF1: hovering a target buried inside + /// a tiny (20x20) window still mounts and DRAWS the tooltip popup, unclipped by that + /// window's own bounds. + /// + [Fact] + public void RetailTooltip_StillRendersOutsideATinyAncestorWindow_BecauseItMountsAtRootLevel() + { + const uint popupRootId = 0x900u; + const uint textChildId = 0x901u; + const uint popupLayoutDid = 0x21000041u; + const uint popupBgSprite = 42u; + + ImportedLayout BuildPopup() + { + var rootInfo = new ElementInfo + { + Id = popupRootId, Type = 3, X = 0, Y = 0, Width = 30, Height = 30, + TooltipTextChildElementId = textChildId, + }; + rootInfo.StateMedia[""] = (popupBgSprite, 1); + var textInfo = new ElementInfo + { + Id = textChildId, Type = 12, X = 2, Y = 2, Width = 26, Height = 26, + }; + return LayoutImporter.BuildFromInfos( + rootInfo, new[] { textInfo }, id => (id, 8, 8), null); + } + + var root = new UiRoot { Width = 800f, Height = 600f }; + var presenter = new RetailTooltipPresenter(root, (_, _) => BuildPopup()); + + // A tiny "window" ancestor (20x20) hosting the hover target as a nested child. + // If the tooltip were drawn from INSIDE this subtree, CT-GF1's new default + // ancestor clip would cut it off — the popup's mouse-anchored position (32px + // offset per PositionAtMouse) lands well outside a 20x20 rect. + var window = new TestElement { Left = 5f, Top = 5f, Width = 20f, Height = 20f }; + var target = new TestElement + { + Left = 2f, Top = 2f, Width = 10f, Height = 10f, + AuthoredTooltipEnabled = true, + AuthoredTooltipText = "Rotate left.", + AuthoredTooltipRootElementId = popupRootId, + AuthoredTooltipLayoutDid = popupLayoutDid, + }; + window.AddChild(target); + root.AddChild(window); + + root.OnMouseMove(10, 10); // inside `target`, well inside the 20x20 window + root.Tick(0.016, 0); + root.Tick(0.016, root.TooltipDelayMs); + + // Mounted as a UiRoot SIBLING of `window`, not nested inside it. + UiElement popup = Assert.Single(root.Children, c => c != window); + Assert.Same(root, popup.Parent); + + var (_, renderer, ctx) = MakeContext(800f, 600f); + root.DrawSelfAndChildren(ctx); + + Assert.Contains(renderer.DebugSpriteSegmentVerts, s => s.Texture == popupBgSprite); + + presenter.Dispose(); + } +}