diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 84980f5b..dbe19519 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 80 active rows (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) — 81 active rows (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 @@ -192,6 +192,7 @@ readiness/requeue adaptation. See | AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic sprite blit, `Graphic::Draw` (0x00693b20) dispatching to `Graphic::PutImage` (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode (confirmed against `BlitMode`, acclient.h ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. **COMPLETED 2026-08-15 (same gate round, misalignment follow-up):** the first substitution (resize the mounted root + stretch only its own background) stretched the ART but left the authored child widgets at 800x600 pixel positions — misaligned against a background whose painting CARRIES visual anchors (the World/Characters captions are art). The substitution now reproduces retail's whole-frame behavior: the root KEEPS its authored 800x600 extent, and while the screen is active `UiRoot.FixedCanvasSize` scales EVERY emitted quad (widgets, glyphs, art, dialogs) uniformly at `TextRenderer.AppendQuad`, with the exact inverse applied to mouse coordinates at the `UiRoot` entry points so hit-testing lives in canvas space. Non-uniform window/canvas stretch, retail-authentic (no letterbox). `UiDatElement` keeps retail's pure copy-or-tile blit; the interim `StretchOwnBackgroundToFill` flag is deleted. **Campaign CC CC4 review-fix round R1 (2026-08-15): `FixedCanvasSize` now has a single arbiter.** Character-creation can be simultaneously active on top of character-management (both author the same 800x600 canvas), so a raw property write from either controller was a last-writer-wins race with no owner — chargen's own Close() nulled the canvas out from under a still-active character-management screen underneath it. `UiRoot.DeclareFixedCanvas(object owner, Vector2 size)`/`RevokeFixedCanvas(object owner)` now own every production write: each screen declares on its activation edge and revokes on close/deactivate/dispose; the effective size is the current declaration set's value (asserted equal across every concurrent declarer — a future mismatched screen throws instead of silently winning), and it nulls only once EVERY declarer has revoked. The raw `FixedCanvasSize` setter stays public only for `UiRootFixedCanvasTests`' isolated scale-math coverage. | `src/AcDream.App/UI/UiRoot.cs` (`FixedCanvasSize`, `DeclareFixedCanvas`, `RevokeFixedCanvas`, `CanvasScale`, `MapWindowToCanvas`, `Draw`); `src/AcDream.App/Rendering/TextRenderer.cs` (`CanvasScale`, `AppendQuad`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` and `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (both declare/revoke through the arbiter on activate/close/deactivate/dispose) | Reproducing retail's literal mechanism (an offscreen fixed-resolution UI render target scaled at presentation) would add RHI surface area for an identical pixel result; scaling at the one quad-emission chokepoint with an inverse input mapping is the same math applied one stage earlier, and the world-space HUD stays native because the scale is scoped to `UiRoot.Draw`. | Glyphs stretch with the frame (retail-authentic blur at large windows). **Gate round 2 filtering follow-up (2026-08-15):** the stretch now filters bilinearly — `TextureCache.GetOrCreateLinearUiTwin` gives every nearest-sampled UI texture (dat-font glyphs, composited icons) a linear-sampled twin that `TextRenderer.DrawSprite` swaps to while `CanvasScale != One` — matching retail's own bilinear-filtered presentation blit instead of aliasing the point-sampled art. Any future fixed-canvas screen (login/disconnected/datapatch) DECLARES via `UiRoot.DeclareFixedCanvas` while active and REVOKES on close — per-screen opt-in through the arbiter, not automatic and not a raw write. If a genuine present-time frame-stretch pass ever lands, this collapses into it. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020; `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf`; `UiRootFixedCanvasTests`; `CharacterScreensFixedCanvasArbiterTests` (the two-controller arbiter gate); `UiDatElementTests.CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored`; the NON-UNIFORM (no-letterbox) aspect behaviour has no decomp citation of its own (batch review F7) — it is inferred from the mechanism chain and CONFIRMED by the user's live gate pass 2026-08-15 (stretched widescreen look accepted as matching retail memory) | | AD-97 | **Filed 2026-08-14 at Campaign LA slice LA7a (character-restore request tail).** Retail's `CharacterRestore` request (`0xF7D9`) is ≥16 bytes: `CPlayerSystem::RestoreCharacter @0x0055d760` is, in the PDB-paired binary, `push 0x008173B4; push 0x008173B4; push guid; call Proto_UI::SendAdminRestoreCharacter @0x00546cf0`, and the callee packs BOTH constant `PStringBase*` arguments (`PStringBase::Pack @0x004fc6f0` emits ≥4 bytes even empty). Binary Ninja renders the two pushes as an uninitialized `edx` local plus `this` — a rendering artifact around constant `0x008173B4` (all 3 of its other pseudo-C appearances sit in provably-broken decompiles), but the arguments are real. acdream sends the 8-byte guid-only form. What the two constant strings contain is unresolved (a live cdb `db poi(0x008173b4)` would settle it). | `src/AcDream.Core.Net/Messages/CharacterRestore.cs` (`BuildRequestBody`) | ACE reads only `ReadUInt32()` and ignores any tail (`CharacterHandler.cs:331-385`), and holtburger ships guid-only from a real client command path against ACE successfully — the tail is unread by every server we can test against, and packing two strings whose CONTENT we cannot verify would be a guess. | A byte-capture comparison against a real retail client differs from offset 8; a future server that validates the full retail shape would reject our 8-byte request. | `CPlayerSystem::RestoreCharacter @0x0055d760` (binary bytes, not the BN rendering); `Proto_UI::SendAdminRestoreCharacter @0x00546cf0`; `PStringBase::Pack @0x004fc6f0`; ACE `CharacterHandler.cs:331-385`; holtburger `character_selection.rs:79-82`; LA7a Opus review F1 (2026-08-14) | | AD-93 | **Filed 2026-08-13 at social gate round 2, item 5 (the refused-drop notice port).** Two narrow gaps in the `ServerSaysAttemptFailed @0x0058EAE0` port: (1) **latched-guid preference** — retail's 0x00A0 dispatcher (`@0x0055B342`) PREFERS `prevRequestObjectID` over the wire guid when picking the item to name; acdream's `InventoryTransactionState.OnMoveFailed` instead REQUIRES the wire guid to match the latch (unobservable against ACE, which always sends the request's own guid on 0x00A0, and it protects a stale latch from mislabeling an unrelated failure — acdream has no retail-style latch timeout). (2) **unlatched request kinds** — retail latches `IR_MOVE`/`IR_WIELD` too; acdream's kind enum has no Move/Wield rows because wields ride `AutoWieldController` outside the single-request gate, so a refused wield/3D-move shows only the generic `HandleFailureEvent` leg, never "The X can't be wielded/moved". | `src/AcDream.Core/Items/InventoryTransactionState.cs` (`OnMoveFailed`); `src/AcDream.Core/Chat/InventoryFailureMessages.cs` (`Compose`'s absent Move/Wield rows); `src/AcDream.App/UI/ItemInteractionController.cs` (`OnInventoryRequestFailed`) | The match requirement is the compensating guard for the missing latch timeout; adding Wield/Move kinds means routing those sends through the single-request gate they deliberately bypass today — a behavior change beyond this gate item. | Only observable against a server that sends 0x00A0 with a guid that differs from the request's item (ACE never does), or on a refused wield/move, which shows no "can't be wielded/moved" verb line where retail would show one. | `ACCWeenieObject::ServerSaysAttemptFailed @0x0058EAE0`; the 0x00A0 dispatcher `@0x0055B342`; `ACCWeenieObject::RecordRequest @0x0058C220`; `docs/research/2026-08-13-confirm-and-weenie-error-display.md` §2 | +| AD-105 | **Filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 (skills info-box formula line clips at the frame's bottom edge).** `CharacterCreationSkillsPage`'s constructor clamps the description pane's (`0x100003fc`) live `Height` down to the bottom edge of the SIBLING gold decorative frame (`0x100003fa`, the SAME GF-12 corner/edge sprite family) whenever the frame's own authored bottom (Y=430 h=110 → 540, live-DAT-measured) sits ABOVE the pane's own raw bottom (Y=460 h=100 → 560) — a 20px overshoot that let a long skill's formula line draw into blank page space below the frame's visible border. Retail's own `ShowSkillsText @0x00481250` has NO code relationship between the two text panes and this frame (`UIElement_Text::SetText` only, no size/clip handoff) — the frame's authored geometry is used here as the only available ground truth for "the visible box," not a decomp-confirmed clip mechanism. | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (constructor, the `InfoBoxFrameElementId` clamp block) | No decomp evidence describes HOW retail reconciles a text pane authored taller than its own decorative frame — this is the most defensible non-arbitrary boundary (an AUTHORED sibling rect, not an invented pixel offset) but is still an INFERENCE, not a confirmed retail mechanism. If retail instead resizes/repositions the frame to the pane, or genuinely allows the same 20px overshoot, this clamp diverges from the real behavior. | A future decomp/cdb capture of `gmCGSkillsPage`'s real screen layout, or a user visual re-check specifically of a 4-5-line skill description (e.g. skill id 52, Deception), could reveal the clamp boundary is wrong (too tight/too loose) — worst case the formula line is STILL cut, one pixel short of what retail shows, or clipped MORE than retail does. | `gmCGSkillsPage::ShowSkillsText @0x00481250` (no frame/size relationship in the decompiled body); live-DAT geometry (`0x100003fa` Y=430 H=110, `0x100003fc` Y=460 H=100) | | AD-104 | **Filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 (Skills info-box title/description overlap).** `CharacterCreationSkillsPage` force-sets `VerticalJustify = VJustify.Top` on the info-box title (`0x100003fb`) and description (`0x100003fc`) panes post-construction, compensating for a client-wide bug: neither element authors dat property `0x15`, and this port's shared unauthored-VJustify default (`ElementInfo.VJustify` field default `Center`, plus `ElementReader.cs`/`DatWidgetFactory.cs`'s import/build-time enum-mapping switches) resolves an absent `0x15` to Center — but retail's REAL ctor default (`UIElement_Text::UIElement_Text @0x004685ff`, `m_eVerticalJustification = 4`) resolves via `UIElement_Text::CalcJustification @0x00467260`'s actual enum table (`1=>Center, 3 or 5=>Bottom(far edge), else=>Top(near edge)`) to Top, not Center. The two panes' own AUTHORED boxes overlap by 75px (title Y=435 h=100, description Y=460 h=100, live-DAT-measured) — under the CORRECT Top default both render near their own box's top edge (25px apart) and no longer collide; under the port's current (wrong) Center default both cluster near the middle of their overlapping boxes and visually collide. | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (constructor, post-`_infoTitle`/`_infoText` resolution) | The shared mapping bug (`ElementReader.cs:507`'s switch, `DatWidgetFactory.cs:704`'s switch, and `ElementInfo.VJustify`'s field default) is CLIENT-WIDE and affects every DAT-imported `UiText` reaching the `Centered`/`RightAligned`/`OneLine` static paths or the multi-line honored-justification path — including already-shipped, visually-verified, FROZEN surfaces (vitals numbers, chat, main game UI, Options panel) that may rely on the CURRENT Center default for their existing correct-looking alignment. A page-scoped override for exactly the two elements proven broken avoids a client-wide regression sweep this session has no budget for; the shared fix is filed as ISSUES.md #410 for its own dedicated investigation. | If ISSUES #410's shared fix ever lands, this page's override becomes redundant (harmless but should be removed in the same commit, since the corrected shared default would already resolve to Top). Until then, any OTHER DAT-imported `UiText` with an unauthored `0x15` that happens to sit close to a sibling text element (the same "two 100px-tall overlapping boxes" shape) can exhibit the same visual-collision symptom, undiscovered until its own gate round. | `UIElement_Text::UIElement_Text @0x004685ff` (ctor default = 4); `UIElement_Text::CalcJustification @0x00467260` (real enum semantics); ISSUES.md #410 | | 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)`) | diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index 5b55fee0..be181039 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -1028,3 +1028,127 @@ render retail-shaped; four residuals visible in the captures: next to the article of clothing…") — the opening paragraph is either scrolled off (box has no visible scrollbar) or missing from the composition; check what retail authors/composes for that box. + +**RE-TEST 3 fix batch (2026-08-16, R4-1..R4-4) is CODE-COMPLETE, pending the +user's visual gate.** All four findings root-caused and fixed via decomp + +live-DAT evidence, no invented pixel offsets. App suite live-DAT env +5372/3 → 5379/3 (+7, zero regressions); Runtime 1735/0 unchanged; full +solution 14585 tests / 4 skips / 1 failure (Core.Net +`NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge`, +the documented full-solution-only flake — confirmed passing standalone, +unrelated to this batch's files). No client launches. + +- **R4-1 FIXED — root cause was a MISSING raw-edge reflow, not a wrap/clip + gap.** Live-DAT probe: the Skills credits value child (`0x100002f3`) is + BASE-INHERITED across four sibling buttons of DIFFERING widths — + Health/Stamina/Mana at 150px share the exact same child id/rect (local + X=116) as the wider, 231px Skills credits button — and the child's own + `OriginalParentWidth` (150, baked in wherever it was first resolved, + matching Health's own actual width) diverges from Skills credits' real + 231px parent. `UiButton.ValueBox` was built from the child's RAW + (un-reflowed) rect, never running it through `UiLayoutPolicy` — the SAME + retail raw-edge system (`UIElement::UpdateForParentSizeChange + @0x00462640`) already used for every LIVE mounted `UiElement` via + `UiElement.ApplyAnchor`. The child's own edge modes (Left=2/Right=1, + live-DAT-confirmed "track the far edge as the parent grows") shift the + value box from X=116 to X=197 for Skills credits specifically — landing + immediately after the caption's own measured 193px span (ends ≈x=196) + instead of colliding mid-caption. Separately, `ValueAlign` mapped + `HJustify.Right` (raw dat 3/5, live-DAT-confirmed authored on ALL four + value children) to Center — `UIElement_Text::CalcJustification + @0x00467260`'s own `ecx_5==3||5` branch is a DISTINCT far-edge formula, + not Center's halved offset; added a `LabelAlignment.Right` case. + Health/Stamina/Mana (whose `OriginalParentWidth` already matches their + own actual width) reflow to their byte-identical raw rect — the fix is + additive, not a per-button special case. + Files: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildButton`'s + value-child block, new `ReflowValueChildRect`), `src/AcDream.App/UI/UiButton.cs` + (`LabelAlignment.Right`, `OnDraw`'s value-draw `vx` switch). Tests: + `DatWidgetFactoryTests.BuildButton_ValueChildBaseInheritedNarrowerParent_ReflowsToWiderButton` + (+ its `..._OriginalParentMatchesActual_RectUnchanged` negative + companion); live-DAT + `CharacterCreationLiveDatTests.SkillsCreditsButton_ValueBoxReflowsPastCaption_HealthValueBoxUnchanged` + (pins the real installed DAT's `197,0,34,28` vs `116,0,34,28`). +- **R4-2 FIXED — the single-sprite-thumb fallback was TILING (UV-repeat) + a small marker graphic instead of drawing it once.** The re-test-2 fix + (R3-4/R3-7) correctly identified the thumb sprite but fed it to + `DrawTiled` (GL_REPEAT UV wrap) — for a small fixed "diamond" marker + drawn into a track-proportional thumb rect far taller than its own + native size (`UIElement_Scrollbar::UpdateLayout @0x4710d0`'s + `max(MinThumb, trackLen*ThumbRatio)` formula, unchanged/still correct + for the rect's SIZE), the texture sampler repeated the marker several + times down the track (~9 on Summary's overview bar, ~2 on Skills, + matching the live capture). New `DrawThumbMarker` draws exactly ONE + instance at the sprite's own native size, centered within the SAME + computed rect — neither tiled (the bug) nor stretched into an elongated + bar (a naive `DrawSprite` fix would have distorted the diamond shape). + The shade slider's own scalar-mode draw path (`DrawVerticalScalar`) was + never touched — it already used the correct native-size `DrawSprite` + pattern this fix now mirrors for model-mode bars. + File: `src/AcDream.App/UI/UiScrollbar.cs` (`DrawVerticalModel`/ + `DrawHorizontalModel`'s fallback branch, new `DrawThumbMarker`). Test: + `UiScrollbarTests.SingleSpriteThumb_DrawsOneUntiledInstance_NotRepeatedDownTrack` + — reads back the actual emitted quad's UV V-coordinate via + `TextRenderer.DebugSpriteSegmentVerts` and asserts it never exceeds 1.0 + (native); confirmed this test FAILS (V=7.875) against the pre-fix + `DrawTiled` call by temporarily reverting and re-running. +- **R4-3 FIXED — the description pane's own authored box is genuinely + taller than the decorative frame that visually contains it.** Live-DAT + geometry walk: the gold frame (`0x100003fa`, the SAME GF-12 corner/edge + sprite family as the Appearance help box) spans Y=430 H=110 (bottom + Y=540), but the description pane (`0x100003fc`) spans Y=460 H=100 + (bottom Y=560) — 20px PAST the frame's own bottom border. Composition- + height simulation against every one of the 38 skills carrying detail + data (real `ChargenTableReader` descriptions + the worst-case + description+bonus+formula line count) confirmed the pane's OWN raw + 100px interior comfortably fits every case (worst: 5 lines / 80px < 90px + interior) — ruling out a wrap-width or line-spacing bug. The real + mismatch is the SIBLING frame's smaller authored bottom, which the pane + was never clamped to, letting a tall composition's last line(s) draw + past the frame's own visible border into blank page space. Retail's + `ShowSkillsText @0x00481250` has no code linking the panes to the frame + (plain `SetText`, no size/clip handoff) — the frame's own authored Y+H + is the only available ground truth, not a decomp-confirmed clip + mechanism, so this is filed as register **AD-105** (a genuine + inference, flagged rather than silently assumed, same shape as R3-3's + own AD-104 scoped correction). `CharacterCreationSkillsPage`'s + constructor now clamps `_infoText.Height` to the frame's bottom edge + whenever it would otherwise be taller (additive; never grows it). + File: `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` + (constructor, new `InfoBoxFrameElementId` clamp block). Tests: fixture + `CharacterCreationUiControllerTests.SkillsPage_InfoBoxDescriptionPane_HeightClampedToFrameBottom` + (the shared `BuildSkillsPage` fixture gained a deliberately-shorter + frame element); live-DAT + `CharacterCreationLiveDatTests.SkillsInfoBoxFrame_ShorterThanDescriptionPane` + (pins the 20px real-DAT mismatch itself). +- **R4-4 FIXED — two stacked gaps, the same "page never touched this + element" shape as prior holdouts.** The help box (`0x100003ab`) is a + purely DAT-authored static paragraph (no `gmCGAppearancePage` runtime + composition function exists for it, unlike Town/Summary's + `SetTownString`/`SetHowToText` — confirmed absent from the named + decomp) that `CharacterCreationAppearancePage` never referenced at all, + so it kept `UiText`'s own chat-style default + (`PreserveEndOnLayout=true`, "keep a view that is already at the end + pinned there"). Its content overflows a 292px-tall frame, and + `UiScrollable.SetExtents`'s own `wasAtEnd` check is vacuously true the + FIRST time a Scroll model transitions from its zero-initialized state + (`ContentHeight=0/ViewHeight=0/ScrollY=0` → `MaxScroll=0` → + `AtEnd=(0>=0)=true`) to real overflowing content — with + `PreserveEndOnLayout` still true, that spuriously pins the very first + render to the BOTTOM, hiding the opening paragraphs exactly as reported + (the visible text is mid-way through the third paragraph). This is a + static instructions box, not a chat transcript — `PreserveEndOnLayout`'s + own doc already carves out exactly this shape ("top-oriented reports + such as Character Information disable it"). Also wired the box's own + NESTED authored scrollbar (property `0x72`, live-DAT-confirmed a direct + Type-11 child of the text box — the SAME nesting shape + `CharacterCreationSummaryPage.HowToScrollRelativeId` already uses) — + never wired by this page before, so a user can reach the rest of the + text even where the box's own height still doesn't fit everything. + File: `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` + (constructor, new `HelpTextId`/`HelpScrollRelativeId` block). Tests: + fixture + `CharacterCreationUiControllerTests.AppearancePage_HelpText_TopOriented_AndOwnScrollbarIsWired` + (the shared `BuildAppearancePage` fixture gained the help box + its + nested scrollbar child, StateMedia-bearing so `UiText`'s own dat- + children carve-out actually builds it). diff --git a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs index e98e81e2..7929129b 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs @@ -144,6 +144,26 @@ internal sealed class CharacterCreationAppearancePage : IDisposable internal const uint ShadeScrollId = 0x10000321u; internal const uint ViewportId = 0x100003BBu; + /// + /// R4-4 (Campaign CC gate round 1 re-test 3): the framed instructions + /// box (the SAME gold corner/edge sprite family the Skills info-box + /// frame uses, 0x100002de-e3/0x100000e8/0xea — + /// live-DAT-confirmed identical children). Its own P0x17 caption + /// is the FULL static help paragraph (no gmCGAppearancePage + /// runtime composition exists for it — unlike Town/Summary's + /// SetTownString/SetHowToText, this text is purely + /// DAT-authored, confirmed by the absence of any matching function in + /// the named decomp). + /// + internal const uint HelpTextId = 0x100003ABu; + + /// The help box's own NESTED scrollbar child (live-DAT- + /// confirmed a direct child of , the SAME + /// structural id/nesting shape as + /// CharacterCreationSummaryPage.HowToScrollRelativeId's own + /// how-to box scrollbar). + private const uint HelpScrollRelativeId = 0x100002E7u; + /// Retail's nine SetColor(0..8) swatch buttons, in /// index order — verbatim off ListenToElementMessage's cases /// 5-0xd (elementId - 0x1000030a). @@ -202,6 +222,7 @@ internal sealed class CharacterCreationAppearancePage : IDisposable private readonly UiButton? _rotateCounterClockwise; private readonly UiButton? _zoomIn; private readonly UiButton? _zoomOut; + private readonly UiText? _helpText; /// The gradient disc (0x1000030e) — Type 3 in the /// authored dat, so (not the base @@ -354,6 +375,39 @@ internal sealed class CharacterCreationAppearancePage : IDisposable _zoomIn?.TrySetRetailState(UiButtonStateMachine.Normal); }; + // R4-4 (Campaign CC gate round 1 re-test 3): the help box's static + // paragraph starts mid-sentence because this port never touched + // this element at all — it built through the plain DatWidgetFactory + // import path with UiText's own chat-style default + // (PreserveEndOnLayout=true, "keep a view that is already at the + // end pinned there" — see that property's own doc: "Chat uses the + // default; top-oriented reports such as Character Information + // disable it"). This box's content overflows its own view (a full + // multi-paragraph instructions block in a 292px-tall frame), and + // UiScrollable.SetExtents's own wasAtEnd check is vacuously true + // the very first time a Scroll model transitions from its + // zero-initialized state (ContentHeight=0/ViewHeight=0/ScrollY=0 -> + // MaxScroll=0 -> AtEnd=(0>=0)=true) to real overflowing content — + // with PreserveEndOnLayout still true, that spuriously pins the + // FIRST-EVER render to the bottom, hiding the opening paragraph + // exactly as reported ("right arrows next to the article of + // clothing..." is mid-way through the third paragraph, not the + // first). This is a static instructions box, not a chat transcript + // — the SAME top-oriented-report shape PreserveEndOnLayout's own + // doc already carves out. Also wires the box's own nested authored + // scrollbar (property 0x72, live-DAT-confirmed a direct child) — + // NEVER wired by this page before — so a user can still reach the + // rest of the text if it doesn't fully fit, the SAME + // scrollbar.Model = text.Scroll linkage + // CharacterCreationSummaryPage's how-to box already uses. + _helpText = Find(pageRoot, HelpTextId); + if (_helpText is not null) + { + _helpText.PreserveEndOnLayout = false; + if (Find(_helpText, HelpScrollRelativeId) is { } helpScroll) + helpScroll.Model = _helpText.Scroll; + } + ApplyChoiceVisibility(); } diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs index b8264fcb..40bac999 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs @@ -341,8 +341,46 @@ internal sealed class CharacterCreationSkillsPage : IDisposable infoTitle.VerticalJustify = VJustify.Top; if (_infoText is { } infoText) infoText.VerticalJustify = VJustify.Top; + + // R4-3 (Campaign CC gate round 1 re-test 3): the description pane's + // own raw box (0x100003fc, Y=460 H=100 -> bottom Y=560, live-DAT- + // measured) extends 20px PAST the bottom of the gold decorative + // frame that visually contains BOTH info panes (0x100003fa, Y=430 + // H=110 -> bottom Y=540, the SAME GF-12 corner/edge sprite family + // Batch C un-consumed — 0x100002de-e3/0x100000e8/0xea). Retail's own + // ShowSkillsText @0x00481250 has NO code relationship between the + // text panes and this frame (SetText only; no clip/size handoff), + // and the frame's 8 children carry no dat property linking them to + // 0x100003fc either — so the frame's own geometry is the only + // authored ground truth for "the visible box," and this port's + // multi-line clip (UiText.DrawText's own PushClip(0,0,Width,Height)) + // was using the WRONG (larger, unbounded) Height, letting a long + // skill's formula line draw into blank page space below the frame's + // own border instead of being contained by it. Clamped to the + // frame's own bottom edge (never grows it — additive, defensive if a + // future dat re-extract makes the frame taller than the pane). + // Scoped exactly like the VJustify.Top correction above: this is + // NOT the client-wide "does a text pane's clip account for a + // sibling decorative frame" mechanism (no evidence any other pane in + // this codebase has the SAME independently-authored-taller-than-its- + // frame shape), so a general import-time fix is unwarranted here. + if (_infoText is { } clampedInfoText + && UiElement.FindDescendant(pageRoot, InfoBoxFrameElementId) is { } frame) + { + float frameBottom = frame.Top + frame.Height; + float paneBottom = clampedInfoText.Top + clampedInfoText.Height; + if (frameBottom < paneBottom) + clampedInfoText.Height = frameBottom - clampedInfoText.Top; + } } + /// + /// The gold decorative frame (Type 12, 8 sprite children — the SAME + /// GF-12 corner/edge family) that visually contains BOTH info panes + /// (0x100003fb/0x100003fc) — see the R4-3 clamp above. + /// + private const uint InfoBoxFrameElementId = 0x100003FAu; + internal void Refresh( IRuntimeCharacterCreationView view, RuntimeCharacterCreationSnapshot snapshot) diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 86b037e4..c2f939fd 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -1009,14 +1009,29 @@ public static class DatWidgetFactory child => child.Type == 12u && child.StateMedia.Count == 0); if (valueChild is not null) { - button.ValueBox = (valueChild.X, valueChild.Y, valueChild.Width, valueChild.Height); + // R4-1 (Campaign CC gate round 1 re-test 3): reflow the value + // child's authored rect through retail's own raw-edge policy + // (UIElement::UpdateForParentSizeChange @0x00462640, ported + // as UiLayoutPolicy) before it becomes ValueBox — see + // ReflowValueChildRect's own doc for why this is needed and + // decomp-cited. + button.ValueBox = ReflowValueChildRect(valueChild, info); button.ValueFont = valueChild.FontDid != 0u && fontResolve is not null ? fontResolve(valueChild.FontDid) ?? elementFont : elementFont; button.ValueColor = valueChild.FontColor ?? System.Numerics.Vector4.One; - button.ValueAlign = valueChild.HJustify == HJustify.Left - ? UiButton.LabelAlignment.Left - : UiButton.LabelAlignment.Center; + button.ValueAlign = valueChild.HJustify switch + { + HJustify.Left => UiButton.LabelAlignment.Left, + // R4-1: HJustify.Right (raw dat 3/5) previously fell into + // this ternary's Center branch — CalcJustification's own + // ecx_5==3||5 case is a DISTINCT far-edge formula (see + // UiButton.LabelAlignment.Right's own doc), and every + // value child in this family (0x100002f1/0x100002f3) + // authors HJustify Right, live-DAT-confirmed. + HJustify.Right => UiButton.LabelAlignment.Right, + _ => UiButton.LabelAlignment.Center, + }; // Seed with whatever the child itself authors (typically // blank) so an unbound button doesn't draw stray leftover // text before a controller writes a real value. @@ -1027,6 +1042,60 @@ public static class DatWidgetFactory return button; } + /// + /// R4-1 (Campaign CC gate round 1 re-test 3): the "Available Skill + /// Credits" value overlapped mid-caption ("Available Skill0Credits") + /// because was built from the value + /// child's RAW authored rect, un-reflowed. Live-DAT probe: the value + /// child (0x100002f3) is BASE-INHERITED across four sibling + /// buttons of DIFFERING widths — Health/Stamina/Mana at 150px share the + /// exact same child id/rect (local X=116) as the wider, 231px Skills + /// credits button, and the child's own OriginalParentWidth (the + /// design-time parent size baked in at whichever button FIRST resolved + /// it — 150, matching Health's own actual width) diverges from Skills + /// credits' actual current parent width (231) — exactly the shape + /// (retail + /// UIElement::UpdateForParentSizeChange @0x00462640, already the + /// production raw-edge reflow for live mounted elements via + /// ) exists to correct. The child's + /// own edge modes (Left=2/Right=1, live-DAT-confirmed) are retail's + /// "track the far edge as the parent grows" reflow: applying them moves + /// the value box from local X=116 to X=197 for Skills credits — landing + /// immediately after the caption's own measured end (~x=196, + /// SkillsCreditsButton_CaptionFitsFullWidth_ValueChildStartsAtMidpoint) + /// instead of colliding mid-caption. Health/Stamina/Mana and the + /// Attribute/Credits value child (whose OWN OriginalParentWidth already + /// matches their actual parent, or whose edge modes are all 0/fixed) + /// reflow to their byte-identical raw rect (deltaX=0 or mode-0 passthrough) + /// — this is additive for every already-correct button, not a per-button + /// special case. + /// + private static (float X, float Y, float Width, float Height) ReflowValueChildRect( + ElementInfo child, ElementInfo parent) + { + float originalParentWidth = child.HasOriginalParentSize ? child.OriginalParentWidth : parent.Width; + float originalParentHeight = child.HasOriginalParentSize ? child.OriginalParentHeight : parent.Height; + + var originalChild = UiPixelRect.FromPositionAndSize( + (int)child.X, (int)child.Y, (int)child.Width, (int)child.Height); + var originalParent = UiPixelRect.FromPositionAndSize( + 0, 0, (int)originalParentWidth, (int)originalParentHeight); + var currentParent = UiPixelRect.FromPositionAndSize( + 0, 0, (int)parent.Width, (int)parent.Height); + // Empty (Width=0/Height=0) "current child" so the static Apply's + // currentChild-preservation branch never engages — every axis comes + // from the Near/Far formula, matching mode 0's own "keep the raw + // authored edge" default for the (frequent) no-anchor case. + var noCurrentChild = new UiPixelRect(0, 0, -1, -1); + + UiPixelRect reflowed = UiLayoutPolicy.Apply( + child.Left, child.Top, child.Right, child.Bottom, + originalChild, originalParent, + noCurrentChild, currentParent); + + return (reflowed.X0, reflowed.Y0, reflowed.Width, reflowed.Height); + } + /// /// Retail UIOption_Checkbox is a UIElement_Button whose visible face is its /// authored indicator child. Its label lives on the option object rather than diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 9b2dd988..45166aba 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -260,8 +260,18 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// — the lifted child's own authored justify. public LabelAlignment ValueAlign { get; set; } = LabelAlignment.Center; - /// Label horizontal alignment options. - public enum LabelAlignment { Center, Left } + /// + /// Label horizontal alignment options. (R4-1, Campaign + /// CC gate round 1 re-test 3) is ValueLabel-only today — every value + /// child on the chargen credit-display family (0x100002f1/0x100002f3) + /// authors dat HJustify Right (raw 3/5), decomp-confirmed by + /// UIElement_Text::CalcJustification @0x00467260's + /// ecx_5==3||5 branch (edi = availWidth - textWidth, i.e. + /// flush to the box's own far edge) — distinct from Center's halved + /// offset. never authors Right today so no + /// existing switch over it needs a new arm. + /// + public enum LabelAlignment { Center, Left, Right } public bool ToggleBehavior { get; } public bool RolloverEnabled { get; } @@ -568,9 +578,17 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful float boxY = ValueBox?.Y ?? 0f; float boxWidth = ValueBox?.Width ?? Width; float boxHeight = ValueBox?.Height ?? Height; - float vx = ValueAlign == LabelAlignment.Left - ? boxX + LabelOffsetX - : boxX + (boxWidth - vf.MeasureWidth(value)) * 0.5f; + float valueWidth = vf.MeasureWidth(value); + // R4-1: Right mirrors CalcJustification's own far-edge formula + // (box's own right edge minus the measured text width, no + // decorative inset — the decomp's Right branch adds none either, + // and this box carries no threaded marginR of its own). + float vx = ValueAlign switch + { + LabelAlignment.Left => boxX + LabelOffsetX, + LabelAlignment.Right => boxX + boxWidth - valueWidth, + _ => boxX + (boxWidth - valueWidth) * 0.5f, + }; float vy = boxY + (boxHeight - vf.LineHeight) * 0.5f; ctx.DrawStringDat(vf, value, vx, vy, ValueColor, Outline, OutlineColor); } diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index 7a798139..3892bdac 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -286,11 +286,60 @@ public sealed class UiScrollbar : UiElement } else { - DrawTiled(ctx, resolve, ThumbSprite, 0f, ty, Width, th); + // R4-2 (Campaign CC gate round 1 re-test 3): the single- + // sprite thumb shape (no top/bottom caps — see this method's + // own doc, the R3-4/R3-7 fallback family: Skills listbox + // 0x100003f8, Summary overview 0x10000401, Summary how-to + // 0x100002e7) is a small fixed "diamond" marker graphic, NOT + // a stretchy bar — DrawTiled's UV-repeat was drawing it + // MULTIPLE times to fill the track-proportional thumb rect + // (~9 repeats on Summary's overview bar, ~2 on Skills, per + // the live capture). DrawThumbMarker draws exactly ONE + // instance at its own native size. + DrawThumbMarker(ctx, resolve, ThumbSprite, 0f, ty, Width, th, vertical: true); } } } + /// + /// R4-2 (Campaign CC gate round 1 re-test 3): draws ONE instance of a + /// single-sprite scrollbar thumb at its own native size, centered + /// within the computed thumb rect ('s own + /// decomp-cited UIElement_Scrollbar::UpdateLayout @0x4710d0 + /// track-proportional geometry stays unchanged — this only changes HOW + /// the sprite fills that rect). Neither (UV- + /// repeat — draws the small marker graphic several times to fill a + /// large proportional thumb rect, R4-2's own "tiled diamonds" report) + /// nor a naive 1:1 stretch across the full computed rect (would distort + /// a small marker into an elongated bar) is correct for this shape — + /// selects which + /// axis is being filled/centered: a vertical scrollbar's thumb rect + /// varies in height (X/Width stay the bar's own full width, matching + /// every other draw call in this class), a horizontal one varies in + /// width (Y/Height stay the bar's own full height). + /// + private void DrawThumbMarker( + UiRenderContext ctx, Func resolve, + uint id, float rectX, float rectY, float rectW, float rectH, bool vertical) + { + if (id == 0 || rectW <= 0f || rectH <= 0f) return; + var (tex, nativeW, nativeH) = resolve(id); + if (tex == 0 || nativeW == 0 || nativeH == 0) return; + + if (vertical) + { + float drawH = MathF.Min(nativeH, rectH); + float y = rectY + (rectH - drawH) * 0.5f; + ctx.DrawSprite(tex, rectX, y, rectW, drawH, 0f, 0f, rectW / nativeW, drawH / nativeH, Vector4.One); + } + else + { + float drawW = MathF.Min(nativeW, rectW); + float x = rectX + (rectW - drawW) * 0.5f; + ctx.DrawSprite(tex, x, rectY, drawW, rectH, 0f, 0f, drawW / nativeW, rectH / nativeH, Vector4.One); + } + } + private void DrawHorizontalModel( UiRenderContext ctx, Func resolve, @@ -315,7 +364,8 @@ public sealed class UiScrollbar : UiElement } else { - DrawTiled(ctx, resolve, ThumbSprite, tx, 0f, tw, Height); + // R4-2: horizontal counterpart of the vertical fallback above. + DrawThumbMarker(ctx, resolve, ThumbSprite, tx, 0f, tw, Height, vertical: false); } } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 0cab5bd2..7dc5b92a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -1673,6 +1673,36 @@ public sealed class CharacterCreationLiveDatTests Assert.Equal(116f, valueChild.X); } + /// + /// R4-1 (re-test 3): the raw authored value-child X (116, pinned above) + /// is NOT where the value actually draws — UiLayoutPolicy's + /// raw-edge reflow (the value child's own Right-tracking edge modes + /// against its base-inherited 150px OriginalParentWidth vs the + /// Skills-credits button's actual 231px width) shifts it to X=197, + /// landing right after the caption's own measured 193px span instead + /// of colliding mid-caption ("Available Skill0Credits"). Health's own + /// value child shares the SAME 150px OriginalParentWidth as its OWN + /// actual 150px-wide button (no divergence), so it reflows to its + /// byte-identical raw rect — proving the fix is additive, not a + /// blanket shift. + /// + [InstalledDatFact] + public void SkillsCreditsButton_ValueBoxReflowsPastCaption_HealthValueBoxUnchanged() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiButton skillsCredits = Assert.IsType(screen.FindElement(0x100003F9u)); + Assert.Equal((197f, 0f, 34f, 28f), skillsCredits.ValueBox); + Assert.Equal(UiButton.LabelAlignment.Right, skillsCredits.ValueAlign); + + UiButton health = Assert.IsType(screen.FindElement(0x100003E3u)); + Assert.Equal((116f, 0f, 34f, 28f), health.ValueBox); + } + /// /// R3-3 (re-test 2): the info-box title (0x100003fb) and /// description (0x100003fc) panes' own AUTHORED boxes overlap — @@ -1714,6 +1744,41 @@ public sealed class CharacterCreationLiveDatTests Assert.True(description.Y > title.Y); } + /// + /// R4-3 (re-test 3): the description pane's own raw box (Y=460, + /// H=100 -> bottom Y=560) extends PAST the bottom of the gold + /// decorative frame that visually contains both info panes + /// (0x100003fa, Y=430 H=110 -> bottom Y=540 — the SAME + /// corner/edge sprite family GF-12 already renders, + /// 0x100002de-e3/0x100000e8/0xea). Pins the + /// geometric mismatch itself (so a future DAT re-extract that removes + /// it is visible) — CharacterCreationSkillsPageTests' own fixture + /// covers the constructor's Height-clamp behavior against this exact + /// shape. + /// + [InstalledDatFact] + public void SkillsInfoBoxFrame_ShorterThanDescriptionPane() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, layoutId, CharacterCreationUiController.RootElementId)); + + ElementInfo frame = Assert.IsType(FindInfo(rootInfo, 0x100003FAu)); + ElementInfo description = Assert.IsType(FindInfo(rootInfo, 0x100003FCu)); + + float frameBottom = frame.Y + frame.Height; + float paneBottom = description.Y + description.Height; + Assert.True( + frameBottom < paneBottom, + $"expected the frame's own bottom (Y={frame.Y} H={frame.Height}, bottom={frameBottom}) to sit " + + $"ABOVE the description pane's own raw bottom (Y={description.Y} H={description.Height}, " + + $"bottom={paneBottom}) — if it no longer does, CharacterCreationSkillsPage's Height clamp " + + "may no longer be needed"); + } + /// /// R3-4/R3-7 (re-test 2): retail authors TWO distinct /// UIElement_Scrollbar thumb shapes. Chat's own scrollbar diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 72574340..30de665e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -457,6 +457,37 @@ public sealed class CharacterCreationUiControllerTests Assert.Equal(VJustify.Top, environment.SkillInfoText().VerticalJustify); } + /// + /// R4-3 (Campaign CC gate round 1 re-test 3): the description pane's + /// own raw box (0x100003fc) is TALLER than the surrounding gold + /// decorative frame that visually contains it (0x100003fa — + /// live-DAT-measured, see 's + /// own R4-3 comment for the full geometry + decomp citation: retail's + /// ShowSkillsText has no code relationship between the text + /// panes and this frame, so the frame's own authored bottom edge is + /// the only ground truth for "the visible box"). Before this fix, a + /// long skill's formula line could draw into blank page space below + /// the frame's own border — BuildSkillsPage's fixture frame + /// (Y=0 H=50) is deliberately shorter than TextInfo's own + /// default pane Height (60), so this pins the constructor clamping the + /// live-mounted pane's own Height down to the frame's bottom edge + /// (50) instead of leaving it at its own larger raw 60. + /// + [Fact] + public void SkillsPage_InfoBoxDescriptionPane_HeightClampedToFrameBottom() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!(); + + Assert.Equal(50f, environment.SkillInfoText().Height, 3f); + // The title pane sits OUTSIDE the frame's own child range in this + // fixture (a sibling, not touched by the clamp) — confirms the fix + // is scoped to the description pane only, matching the constructor. + Assert.Equal(60f, environment.SkillInfoTitle().Height, 3f); + } + /// R2-4a: retail re-selects the row after an arrow click too /// (ListenToElementMessage @0x004814c0's own /// SetSelectedItem(...,1) call following @@ -1229,6 +1260,34 @@ public sealed class CharacterCreationUiControllerTests environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!(); } + /// + /// R4-4 (Campaign CC gate round 1 re-test 3): the framed help/ + /// instructions box (0x100003ab) — before this fix, this + /// element was never touched by 's + /// constructor at all, so it kept 's own chat-style + /// default (PreserveEndOnLayout=true) and its own nested + /// authored scrollbar (live-DAT-confirmed a direct Type-11 child, + /// property 0x72) was never wired to + /// . Pins both halves of the fix: the box is + /// no longer chat-style bottom-pinned, and the scrollbar's + /// now points at the SAME + /// the text itself scrolls. + /// + [Fact] + public void AppearancePage_HelpText_TopOriented_AndOwnScrollbarIsWired() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + UiText helpText = Assert.IsType( + environment.Screen.FindElement(CharacterCreationAppearancePage.HelpTextId)); + Assert.False(helpText.PreserveEndOnLayout); + + UiScrollbar helpScroll = Assert.IsType( + UiElement.FindDescendant(helpText, 0x100002E7u)); + Assert.Same(helpText.Scroll, helpScroll.Model); + } + /// /// GF-10 (Campaign CC gate round 1 Batch B): ports /// gmCGAppearancePage::ZoomIn @0x0047CF00 @@ -2813,6 +2872,21 @@ public sealed class CharacterCreationUiControllerTests page.Children.Add(ScrollbarInfo(0x100003F8u)); page.Children.Add(ButtonInfo(0x100003F9u)); // credits badge page.Children.Add(TextInfo(0x100003FBu)); + // R4-3 (re-test 3): the description pane's own decorative frame + // (0x100003fa, live-DAT-measured SHORTER than the pane it visually + // contains — see CharacterCreationSkillsPage's own R4-3 comment). + // Y=0/Height=50 here is deliberately shorter than TextInfo's own + // default Height=60 so CharacterCreationSkillsPageTests can pin the + // constructor's Height clamp without needing the real installed + // DAT's exact pixel geometry. + page.Children.Add(new ElementInfo + { + Id = 0x100003FAu, + Type = 12u, + Y = 0f, + Width = 200f, + Height = 50f, + }); page.Children.Add(TextInfo(0x100003FCu)); return page; } @@ -2912,6 +2986,22 @@ public sealed class CharacterCreationUiControllerTests page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomInId)); page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomOutId)); + // R4-4 (re-test 3): the framed instructions box, with its OWN + // nested authored scrollbar child — live-DAT-confirmed shape (a + // direct Type-11 child of the Type-12 text box, the SAME nesting + // CharacterCreationSummaryPage's HowToScrollRelativeId already + // uses). Deliberately taller than one view's worth so the long + // static help paragraph genuinely overflows in the test below. + var helpText = TextInfo(CharacterCreationAppearancePage.HelpTextId); + var helpScrollInfo = new ElementInfo { Id = 0x100002E7u, Type = 11u, Width = 12f, Height = 40f }; + // UiText/UiField's own dat-children carve-out (LayoutImporter.BuildWidget) + // only builds a child that carries its own authored StateMedia — the + // SAME "genuinely renderable chrome, not swallowed prototype data" + // gate the real scrollbar's own DirectState track sprite satisfies. + helpScrollInfo.StateMedia[""] = (0x06001919u, 1); + helpText.Children.Add(helpScrollInfo); + page.Children.Add(helpText); + return page; } diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index f6d78b60..e218d444 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -499,6 +499,106 @@ public class DatWidgetFactoryTests Assert.Equal("42", button.ValueLabel); } + /// + /// R4-1 (Campaign CC gate round 1 re-test 3): the value child + /// (0x100002f3) is BASE-INHERITED across four sibling buttons of + /// DIFFERING widths — Health/Stamina/Mana at 150px share the same + /// child id/rect (local X=116) as the wider, 231px Skills credits + /// button — so the child's own authored OriginalParentWidth + /// (baked in at whichever button FIRST resolved it, 150) diverges from + /// the ACTUAL containing button's current width (231) for exactly the + /// wider button. Before this fix, ValueBox was the child's raw + /// (un-reflowed) rect regardless — this fixture reproduces that exact + /// shape (a 150px "design" width baked into the child, hosted under a + /// 231px-wide button, with the child's own Right-tracking edge modes + /// 2/1) and proves UiLayoutPolicy's raw-edge reflow now shifts + /// the value box by the SAME 81px the button grew (116 -> 197), + /// landing clear of "Available Skill Credits"'s own measured span + /// instead of colliding mid-caption ("Available Skill0Credits"). + /// + [Fact] + public void BuildButton_ValueChildBaseInheritedNarrowerParent_ReflowsToWiderButton() + { + uint captionStringId = 333u; + var info = new ElementInfo { Type = 1, Width = 231, Height = 28 }; + info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue + { + Kind = UiPropertyKind.StringInfo, + StringInfoValue = new UiStringInfoValue(0, captionStringId, 0, 0, 0, 0), + }; + + var valueChild = new ElementInfo + { + Type = 12, + X = 116, + Y = 0, + Width = 34, + Height = 28, + Left = 2, + Top = 1, + Right = 1, + Bottom = 1, + OriginalParentWidth = 150, + OriginalParentHeight = 28, + HasOriginalParentSize = true, + HJustify = HJustify.Right, + }; + info.Children.Add(valueChild); + + var button = Assert.IsType(DatWidgetFactory.Create( + info, NoTex, null, + stringResolve: value => value.StringId == captionStringId ? "Available Skill Credits" : null)); + + Assert.Equal("Available Skill Credits", button.Label); + // 116 + (231-150) = 197 -> width/height preserved (34/28). + Assert.Equal((197f, 0f, 34f, 28f), button.ValueBox); + Assert.Equal(UiButton.LabelAlignment.Right, button.ValueAlign); + } + + /// + /// Negative companion to the reflow test above: a value child whose + /// OWN authored parent width already MATCHES the actual button (the + /// Health/Stamina/Mana shape, un-widened) reflows to its byte-identical + /// raw rect — delta is zero, so this is confirmed additive, not a + /// blanket shift. + /// + [Fact] + public void BuildButton_ValueChildOriginalParentMatchesActual_RectUnchanged() + { + uint captionStringId = 334u; + var info = new ElementInfo { Type = 1, Width = 150, Height = 28 }; + info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue + { + Kind = UiPropertyKind.StringInfo, + StringInfoValue = new UiStringInfoValue(0, captionStringId, 0, 0, 0, 0), + }; + + var valueChild = new ElementInfo + { + Type = 12, + X = 116, + Y = 0, + Width = 34, + Height = 28, + Left = 2, + Top = 1, + Right = 1, + Bottom = 1, + OriginalParentWidth = 150, + OriginalParentHeight = 28, + HasOriginalParentSize = true, + }; + info.Children.Add(valueChild); + + var button = Assert.IsType(DatWidgetFactory.Create( + info, NoTex, null, + stringResolve: value => value.StringId == captionStringId ? "Health" : null)); + + Assert.Equal((116f, 0f, 34f, 28f), button.ValueBox); + } + /// /// Negative companion: a button whose caption was LIFTED from a /// distinct Type-12 child (the town-marker shape, diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs index 7a1b0441..36c80c83 100644 --- a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs +++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs @@ -1,3 +1,8 @@ +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 Xunit; @@ -466,4 +471,56 @@ public class UiScrollbarTests Assert.Equal(expectedWidth, width, 3); } + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } + + /// + /// R4-2 (Campaign CC gate round 1 re-test 3): the re-test-2 single- + /// sprite-thumb fallback (R3-4/R3-7, 's + /// no-cap-sprites branch) used to call the same UV-repeat + /// DrawTiled the 3-slice middle tile uses — for a small fixed + /// "diamond" marker sprite drawn into a MUCH taller track-proportional + /// thumb rect, GL_REPEAT wrapping visibly tiled the marker several + /// times down the track (Summary's overview bar ~9, Skills 2, per the + /// live capture). Proves the fix draws exactly ONE quad for the thumb + /// texture whose V range never exceeds native (1.0) — i.e. one + /// unstretched, untiled sprite instance — even though the computed + /// thumb rect (168px trackLen * ThumbRatio 0.75 = 126px, well past the + /// sprite's native 16px) is far taller than the sprite. + /// + [Fact] + public void SingleSpriteThumb_DrawsOneUntiledInstance_NotRepeatedDownTrack() + { + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(800f, 600f)); + var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f)); + + const uint thumbTex = 42u; + var model = new UiScrollable { ContentHeight = 200, ViewHeight = 150 }; + var bar = new UiScrollbar + { + Width = 16f, + Height = 200f, + SpriteResolve = id => id == thumbTex ? (thumbTex, 16, 16) : (0u, 0, 0), + ThumbSprite = thumbTex, + // ThumbTopSprite/ThumbBotSprite stay unset -> the R3-4/R3-7 + // single-sprite fallback shape (no 3-slice caps authored). + Model = model, + }; + + bar.DrawSelfAndChildren(ctx); + + var thumbSegments = renderer.DebugSpriteSegmentVerts + .Where(s => s.Texture == thumbTex) + .ToArray(); + Assert.Single(thumbSegments); + var verts = thumbSegments[0].Verts; + // 8 floats/vertex (x,y,u,v,r,g,b,a), one quad = 6 vertices. + Assert.Equal(6, verts.Count / 8); + for (int i = 0; i < verts.Count; i += 8) + Assert.True(verts[i + 3] <= 1.0001f, $"thumb sprite V={verts[i + 3]} exceeds native (tiled)"); + } }