diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 9a1b9814..0bf9af4a 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -107,8 +107,75 @@ horizontal `HJustify` mapping while in this code, since it shares the ## #409 — Client-wide UI tooltip system is unshipped (GF-16, deferred out of Campaign CC gate round 1) -**Status:** OPEN -**Severity:** LOW-MEDIUM (cosmetic/discoverability — no gameplay impact, but retail shows a tooltip on hover for ~253 authored elements client-wide and acdream shows none) +**Status:** CODE-COMPLETE 2026-08-16 (this commit) — pending the user's connected visual gate (see the gate note at the bottom of this entry). +**Severity:** LOW-MEDIUM (cosmetic/discoverability — no gameplay impact, but retail shows a tooltip on hover for authored elements client-wide and acdream showed none before this fix) + +**2026-08-16 re-derivation + port (this commit).** Full re-derivation from +`docs/research/named-retail/acclient_2013_pseudo_c.txt` corrected two things +the original GF-16 filing below got wrong from a shallower pass: **`P0x47` +is NOT a "tooltip behavior enum" — it is the element-desc id WITHIN the +popup LayoutDesc (`P0x48`) to instantiate as the popup's root** +(`UIElementManager::StartTooltip @0x0045DE90` passes it straight to +`LayoutDesc::AccessElementDesc`), and **`P0x4A` is read off the freshly +INSTANTIATED popup's own root element, not the hovering trigger element** +(it names that popup's text-child id). A live-DAT sweep (installed EoR +build) found **434 elements author at least one of the five trigger +properties** (243 with literal `P0x49` `StringInfo` text this port shows; +the other 191 rely on retail's DYNAMIC `InqProperty(0x49)` text override, +out of scope — see register row TS-85), superseding the original "~253" +estimate. Layout `0x21000041` holds four 30x30 popup skins +(`0x10000487`/`0x10000395`/`0x10000397`/`0x10000398`), each a four-piece +bevel frame around one shared Type-12 text child `0x10000396` — confirmed +by `TooltipLiveDatTests`. + +**Shipped:** the six-property data layer (`ElementInfo`/`UiElement` +`Tooltip*`/`AuthoredTooltip*` fields, `ElementReader`, +`DatWidgetFactory.ResolveTooltipText`, `LayoutImporter.BuildWidget`); the +hover-dwell/auto-hide/dismissal state machine (`UiRoot.Tick`'s existing +`CheckTooltip` port gained `TooltipShow`/`TooltipHide` C# events, a +per-element `P0x50` delay-override consult, and dismissal wiring at every +retail-confirmed teardown site — hover-target change, owner-element +removal, duration timeout); `RetailTooltipPresenter` (owned by +`RetailUiRuntime`, mounted alongside `RetailDialogFactory`) — builds the +popup via the SAME `LayoutImporter`/dat-lock seam dialogs use, auto-resizes +by the retail measured-vs-authored-text delta (word-wrapped at the display +width via the existing `UiText.WrapWords` primitive), positions at the +mouse cursor clamped to the display, keeps itself topmost over dialogs via +its own later per-tick `BringToFront` (register AD-106), and gates on both +the global `Misc.TooltipEnable` preference (client-local, `SettingsStore`'s +new `MiscSettings` section — retail's OWN 2013 Config tab authors no +visible row for it either, confirmed by the OP campaign's own research, so +no new options-panel row was added) and the widget's own `P0x4B`. **No +click-dismissal was ported** — `UIElementManager::MouseDownEvent +@0x0045DB60` calls the SAME `SwitchMouseOver` hover-change check that +already drives dismissal, and it no-ops when the hit-tested element hasn't +changed, so retail itself does not dismiss a tooltip by clicking its own +owner. + +**Deferred (register TS-85):** dynamic per-element tooltip text +(`InqProperty(0x49)`, 191 elements) and the `P0x3D` per-element wrap-width +override (zero elements author one today). **Deferred (register AD-106):** +the topmost-z-order mechanism is a sibling-with-later-reraise adaptation, +not retail's literal separate presentation layer. + +**Gate note (5-10 min, `ACDREAM_RETAIL_UI=1`):** hover the mouse over any +of these and hold still — a small tooltip box should appear after a brief +pause (~0.25 s, matching retail's registered default) and disappear when +you move to a different control: (1) chargen Appearance page — the rotate +arrows beside the preview ("Rotate left."/"Rotate right.") and any color +swatch ("Changes color of selected clothing or body part."); (2) the same +page's hair/eyes/nose spin arrows (longer help text — should WRAP across +multiple lines, not run off-screen); (3) any other screen with a tooltip +you recall from retail. Confirm: the box sits right at the cursor (not +offset), never runs off the edge of the window even near a corner, and +disappears on its own after ~10 s if you hold still without moving away. +No click-to-dismiss is expected — only moving off the control, or a very +long hold, closes it. + +--- + +**Original GF-16 filing (superseded by the re-derivation above; kept for +investigation history).** Found during Campaign CC gate round 1's Batch D root-cause investigation (`docs/research/2026-08-16-campaign-cc-gate-round1-findings.md`, GF-16 diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index dbe19519..22945b52 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) — 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) +## 2. Adaptation (AD) — 82 active rows (AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -106,6 +106,7 @@ readiness/requeue adaptation. See | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AD-106 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system).** Retail's tooltip popup is a separate always-on-top presentation surface — `UIElementManager::StartTooltip @0x00459700` positions and latches it into `m_pTooltipElement`, drawn independently of the ordinary `UIElement` sibling tree (the SAME class of separation the AP-229 register row already establishes for retail's dialogs vs acdream's flat sibling list under one `Host.Root`). `RetailTooltipPresenter` instead mounts the popup as an ordinary `UiRoot` child sibling (`_host.AddChild(root)`) and keeps it topmost by calling `BringToFront` from its OWN `Tick()`, which `RetailUiRuntime.Tick` schedules AFTER both `RetailDialogFactory.Tick()` and `Host.Tick()` in the same frame — guaranteeing the tooltip wins whatever z-order race those two just ran, every frame, regardless of which dialog/screen last called its own `BringToFront`. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`Tick`, `OnTooltipShow`'s `AddChild`/`BringToFront`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`Tick`'s three-call ordering, `MountTooltipPresenter`) | Reproduces the one observable invariant a user can check (tooltips always draw on top of dialogs and screens) without porting retail's literal separate-layer architecture (no second draw pass, no dedicated presentation root) — the SAME tradeoff AP-229 already accepted for dialogs, extended one layer further. The ordering is enforced structurally (three sequential calls in one method), not by convention, so it cannot silently regress from an unrelated edit reordering unrelated `Tick` calls elsewhere. | A FUTURE always-on-top UI surface that calls its own unconditional per-tick `BringToFront` AFTER `TooltipPresenter?.Tick()` in `RetailUiRuntime.Tick`'s ordering could bury a currently-shown tooltip — the exact failure class AP-229 already named for dialogs-vs-screens, now with three layers instead of two. | `UIElementManager::StartTooltip @0x00459700` (`m_pTooltipElement` ownership); AP-229's own dialog/screen precedent | | AD-73 | Filed 2026-08-11 at the Campaign OP OP2 rework (fix round after a double REJECT). `UiTabPanel` (dat Type 8, formerly `UiTabControl`) does NOT perform retail's automatic tab-table wiring / default-page activation at construction. Retail `UIElement_Panel::SetupTabPageHash @0x0046C2E0` + `::Update @0x0046BD00` unconditionally activate the authored default page for ANY instance that carries a tab table. `UiTabPanel` instead stays DORMANT — no click binding, no page-visibility flip, no tab Open/Closed write — until a controller explicitly calls `ActivateTabBehavior()`. | `src/AcDream.App/UI/UiTabPanel.cs` (`ActivateTabBehavior`); factory site `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (Type-8 arm) | Four already-shipped Type-8 hosts author a tab table today — character sheet root `0x10000227`, spellbook root `0x100002A8`, and vendor `0x100000B8` already implement this exact switching in their own C# controllers (`CharacterStatController`/`SpellbookWindowController`/`VendorUiController`); activating `UiTabPanel`'s own copy unconditionally would double-drive the same page-visibility/tab-state writes those controllers already own. Combat `0x100000A2` has no controller at all and is INTENTIONALLY left inert (its 8 stance pages have no switching UI yet) rather than have `UiTabPanel` silently take ownership. Only newly-authored hosts opt in (Options panel, Campaign OP slice OP3+; Configure Keyboard, OP8). This is what let the unconditional Type-8 factory mapping become safe after the OP2 REJECT (`docs/research/2026-08-11-op2-review-blast.md`, `docs/research/2026-08-11-op2-review-mechanism.md`). | A future panel that authors a Type-8 tab table but never gets a controller call to `ActivateTabBehavior()` renders with every tab button at its authored default (Closed) and every page slot at its default `Visible=true` — i.e. every page overlapping, no single active page — instead of retail's exactly-one-visible-page behavior. This is silent unless the diagnostic `UnresolvedEntries`/`BehaviorActive` surface is checked; a controller author who forgets the activation call will see a visually broken tab host, not a crash. | `UIElement_Panel::SetupTabPageHash @0x0046C2E0`; `UIElement_Panel::Update @0x0046BD00`; `UIElement_Panel::OpenTab @0x0046BE20`. ADDENDUM (2026-08-11, re-review closure): `UiTemplateListBox` additionally reports `ConsumesDatChildren = true` where the pre-rework fallback did not — inert against every shipped layout because no Type-5 element in any of the 32 fixtures authors children (now conformance-PINNED in `OP2ReworkBlastRadiusConformanceTests`, so an authored child appearing in a future DAT regeneration fails the build instead of silently vanishing) | | ~~AD-53~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `Transition.CliffSlide` now consumes only `collision_info.last_known_contact_plane.N`, exactly as retail does. The invented `LastWalkablePlane -> LastKnownContactPlane -> UnitZ` fallback chain is gone; invalid/default or parallel data takes retail's degenerate `OK_TS` return. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CliffSlide`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::cliff_slide` pc:272397 (0050a6d0); `last_known_contact_plane` maintenance pc:272659-272668 (~0050ad07) | | ~~AD-54~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** Every stored walkable polygon now routes unconditionally to `PrecipiceSlide`, including a plane steeper than `FloorZ`; the invented steep-walkable reroute to `CliffSlide` is gone. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` pc:273001-273090 (0050b3d0) | @@ -405,10 +406,11 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15). NARROWED 2026-08-15 at Campaign CC slice CC5 — Appearance and Summary CLOSED.** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20`; Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770`; Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. CC5 ports the Appearance/Summary primitives faithfully into `RuntimeCharacterCreationState` (`RandomizeAppearanceLocked`/`RandomizeClothingLocked`/`RandomizeCharacterLocked`, exposed as `TryRandomizeAppearance`/`TryRandomizeClothing`/`TryRandomizeCharacter`) and wires both pages' Random buttons to them — those two gaps are CLOSED, not approximated. **Still open:** Heritage/Profession/Town's Random handlers still use CC4's UNIFORM pick over every valid option (not `RandomizeHeritageGroup`'s hasToD-bounded roll, `RandomizeTemplate`'s exclude-current-preset roll, or `SetStartArea`'s literal 3/4 bound) — narrowing those three was not in CC5's scope; Skills' Random stays hard-disabled (`RandomizeSkills` remains unported). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Randomize`, CC5 — real primitive, retired from this row); `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (CC5's Randomize section) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in for the THREE remaining pages without porting three more retail algorithms this round did not scope (Heritage/Profession/Town's own roll algorithms, now the only ones left). | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks on Heritage/Profession/Town would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exclude-current-preset weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102); Appearance/Summary now match retail's real distribution exactly (RandInt/RollDice ported verbatim). Skills has no Random affordance at all until `RandomizeSkills` lands. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::SetStartArea` random-bound call site | | AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12). Updated 2026-08-16 at Campaign CC slice CC7** — the row's own predicted resolution has now happened; text corrected rather than retired (see below). `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button (`gmCharacterManagementUI::UpdateButtons @ 0x004ec240`, ~0x004ec319-0x004ec32e: `_charSet.set_.m_num < _charSet.numAllowedCharacters_`) — CC7 ported that exact gate into `RuntimeCharacterSelectionButtons.CanCreate` (`RuntimeCharacterSelectionState.BuildButtons`) and wired `CharacterManagementUiController`'s Create button to it, closing the citation gap this row previously left open. ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`); `src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs` (`CanCreate`, CC7's retail-cited gate); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (Create's `Enabled` binding, CC7) | Both layers are now intentionally KEPT, matching this row's own prediction: the Create-button gate reproduces retail's real enforcement point for the ordinary UI path, while `TryBeginFinish`'s own refusal remains defense-in-depth for any caller that reaches Finish without going through that button (a headless bot, a future scripted client, or a UI bug that lets Finish fire while stale) — exactly the residual case the row's own risk column called out. | None remaining for the ordinary UI path (both layers now agree with retail's real enforcement site); a caller that bypasses the Create-button gate entirely still hits `TryBeginFinish`'s own refusal, which has no direct `DoFinish` citation (by design — retail's OWN `DoFinish` never checks this, only its UI layer does). | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (the retail enforcement site, now ported); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | -## 4. Temporary stopgap (TS) — 49 active rows (TS-82 RETIRED 2026-08-15 at Campaign CC slice CC5 — the Summary page is now fully built (name field with NameInputFilter, the three-template listbox, its own live-idle-animated `gmCG3DView` preview, and the Finish gate's real UI), closing the last placeholder this row tracked (narrowed to Summary-only at CC6b-MOUNT after the Appearance page landed); TS-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — CORRECTED at the same-round review (F1): the original filing argued this from the ctor never touching `m_bZoomedIn`, an unsound "elided/uninitialized byte" inference (heap `operator new` memory is indeterminate, not zero); the real, sound evidence is `gmCGAppearancePage::InitializePage @ 0x0047FDD0`'s EXPLICIT `this->m_bZoomedIn = 0;` at `0x004802C3`, written immediately after that same function sets the camera to the zoomed-IN per-heritage eye (`0x00480286-0x0048029E`) — a genuine retail quirk this implies: the character starts framed close-up AND not-zoomed-in at the same time, so the FIRST Zoom In click tweens close-eye→close-eye (visually null) while still freezing the animation, which the port reproduces faithfully — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-84 filed 2026-08-15 at Campaign CC slice CC6a (renumbered from its branch-local TS-82 at the CC6b-PRE merge: the CC4 branch independently allocated TS-82 for the Appearance/Summary placeholder pages, and landed first), corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 50 active rows (TS-85 filed 2026-08-16 at #409 (client-wide retail tooltip system) — two unported tooltip sub-mechanisms: (1) DYNAMIC tooltip text via the retail InqProperty(0x49) virtual override (191 of 434 live-DAT-probed tooltip-property-authoring elements have no literal StringInfo text and show nothing), and (2) the per-element P0x3D wrap-max-width override (RetailTooltipPresenter always wraps at the display width, the confirmed retail fallback — no probed element authors P0x3D); TS-82 RETIRED 2026-08-15 at Campaign CC slice CC5 — the Summary page is now fully built (name field with NameInputFilter, the three-template listbox, its own live-idle-animated `gmCG3DView` preview, and the Finish gate's real UI), closing the last placeholder this row tracked (narrowed to Summary-only at CC6b-MOUNT after the Appearance page landed); TS-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — CORRECTED at the same-round review (F1): the original filing argued this from the ctor never touching `m_bZoomedIn`, an unsound "elided/uninitialized byte" inference (heap `operator new` memory is indeterminate, not zero); the real, sound evidence is `gmCGAppearancePage::InitializePage @ 0x0047FDD0`'s EXPLICIT `this->m_bZoomedIn = 0;` at `0x004802C3`, written immediately after that same function sets the camera to the zoomed-IN per-heritage eye (`0x00480286-0x0048029E`) — a genuine retail quirk this implies: the character starts framed close-up AND not-zoomed-in at the same time, so the FIRST Zoom In click tweens close-eye→close-eye (visually null) while still freezing the animation, which the port reproduces faithfully — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-84 filed 2026-08-15 at Campaign CC slice CC6a (renumbered from its branch-local TS-82 at the CC6b-PRE merge: the CC4 branch independently allocated TS-82 for the Appearance/Summary placeholder pages, and landed first), corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| TS-85 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system).** Two sub-mechanisms of retail's tooltip system are unported. **(1) Dynamic tooltip text:** `UIElement::StartTooltipAtMouse @0x00460D70` prefers the element's own cached `m_TTText` field and falls back to a virtual `InqProperty(0x49, ...)` call whenever it is empty — which is ALWAYS true for a pure DAT-imported element, since nothing in `UIElement::OnSetAttribute`'s switch ever writes `m_TTText` from a dat property (see `ElementInfo.TooltipText`'s own doc comment). A live-DAT sweep found 191 of the 434 elements authoring at least one tooltip-trigger property have NO literal `P0x49` `StringInfo` text (list rows, dynamically-computed status displays) — `RetailTooltipPresenter.OnTooltipShow` requires non-null `AuthoredTooltipText`, so these show nothing. **(2) The per-element wrap-width override:** `UIElement_Text::InqSizewMargins @0x00469660`'s `UITS_MAX_WIDTH` branch checks `GetAttribute_Int(this, 0x3D, ...)` before falling back to `RenderDevice::GetDisplayWidth()`; `RetailTooltipPresenter.ApplyTooltipText` always wraps at `UiRoot.EffectiveCanvasSize.X` (the confirmed fallback) and never checks for a `P0x3D` override — the live-DAT sweep found zero tooltip-bearing elements author one. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`OnTooltipShow`'s text-presence gate; `ApplyTooltipText`'s wrap-width literal) | The 243 elements WITH literal text — the "core" case #409 ships — cover every hover-text scenario the investigation's own landmark checks exercised (main-game-UI Appearance-page rotate/color/spin hints, etc.); the P0x3D sweep found zero authoring elements, so the unconditional display-width fallback is not an approximation for any element that exists today. | If a future DAT revision adds a dynamically-computed tooltip (a new list row, a status readout) or authors a `P0x3D` override, it silently shows no tooltip / wraps at the wrong width instead of erroring — indistinguishable from "the element authors no tooltip at all" without re-running the sweep. | `UIElement::StartTooltipAtMouse @0x00460D70` (`InqProperty(0x49)` fallback); `UIElement_Text::InqSizewMargins @0x00469660` (`UITS_MAX_WIDTH` branch, `GetAttribute_Int(this, 0x3D, ...)`) | | TS-84 | Chargen 3D preview (Campaign CC slice CC6a foundation): `ChargenClothingTable`'s composer skips retail's ~8-branch Setup-id substitution chain (`ClothingTable::BuildObjDesc @ 0x005A7900`'s Umbraen/Penumbraen/Undead/Anakshay fallback) when a garment's `ClothingBaseEffects` has no entry for the resolved body Setup. MEASURED (not assumed) against the installed EoR dat across all 26 heritage/gender combinations via `ChargenAppearanceCatalogInstalledDatTests`, with the measurement now PINNED by a real assertion rather than diagnostic-only output (review fix round F7): the 9 standard heritages whose UI actually shows clothing controls resolve every default gear choice with zero coverage gaps. Undead is a real gap — its default gear choices (both genders) have NO base-effect entry on **ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear** (not the three-slot "headgear/trousers/footwear" this row originally understated, with a self-contradicting "4 of 4 non-shirt slots" aside — corrected at the review fix round F2) — for Undead's own live body Setup (male 0x02001A9C / female 0x02001AA0), because that Setup is one of the skeleton/zombie variants the un-ported chain exists to redirect. The four measured missing clothing-table ids are identical on both genders and in a fixed order: `0x10000009, 0x100000F9, 0x10000001, 0x10000007` (Headgear, Trousers, Shirt, Footwear — the factory's own composition order). Gear Knight and both Olthoi variants also show gaps under a synthetic "select every offered option" sweep, but retail hides the clothing controls entirely for those three heritages (`gmCGAppearancePage::Update @ 0x0047E8F0`'s `m_pClothesButton->SetVisible(0)` branches for `mHeritageGroup == 6` and `== 0xc \|\| == 0xd`), so a real chargen selection never reaches them — not a live gap. | `src/AcDream.Core/CharGen/ChargenClothingTable.cs`; `src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs` (`ComposeClothingSlot`) | CC6a is explicitly the rendering-foundation slice (index→ObjDesc factory + static-pose offscreen renderer, no page mount yet); porting the ~8-branch substitution chain is bounded follow-up work once CC6b wires real clothing-slot UI, not a blocker for the foundation deliverable — and the installed-DAT test proves the gap is narrow (one heritage, all four of ITS slots) rather than pervasive. | Undead's default clothing preview renders the bare body mesh for ALL FOUR slots — headgear, trousers, shirt, AND footwear (no clothing part/texture override applied on any of them, though the dye subpalette contribution — gated on a DIFFERENT lookup — is unaffected) — until the chain, or an equivalent per-heritage default-clothing-Setup map, is ported. | `ClothingTable::BuildObjDesc @ 0x005A7900` (Umbraen/Penumbraen/Undead/Anakshay Setup-substitution branches); `gmCGAppearancePage::Update @ 0x0047E8F0` (clothes-button visibility gate); `tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs` | | TS-73 | **NARROWED 2026-08-11 at Campaign OP slice OP4.** `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged @0x0059A8E0`'s local side-effect switch (step 2) still covers only the two `PlayerModule`-state-mutating cases (`case 2`/`case 0x12` fellowship mutual exclusion) — that part is unchanged. Of the four presentation-binding cases, TWO are now closed: `0x07 ViewCombatTarget` (re-pointed `ICombatGameplaySettingsSource` reads `RuntimeCharacterOptionsState` live — `CharacterOptionCombatSettingsSource`, `src/AcDream.App/Combat/LiveCombatAttackOperations.cs`) and `0x30 DisableDistanceFog` (`WeatherSystem.DisableDistanceFogSource`, a poll bound once in `GameWindow.cs`, forces `FogMode.Off` in `WeatherSystem.Snapshot`) — NEITHER lives inside `TrySetOption`'s own switch; both are separate App-layer poll bindings, so the literal claim in this row's title ("this Runtime-only seam can reach") stays true, but the user-observable symptom is fixed for these two ids. The remaining two, `0x04 DisableMostWeatherEffects` and `0x05 PersistentAtDay`, stay open — see TS-6 (weather-particle subsystem not yet located) and TS-75 (day/night force) respectively; this row no longer duplicates either. | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`RuntimeCharacterOptionsState.TrySetOption`) | The remaining two options are correctly scoped to their OWN pre-existing/new rows (TS-6, TS-75) rather than re-litigated here. | Toggling `DisableMostWeatherEffects`/`PersistentAtDay` writes the bit and dirties/auto-saves it correctly, but produces NONE of retail's immediate local presentation change (weather doesn't stop, day/night doesn't force) — see TS-6/TS-75 for why. `ViewCombatTarget`/`DisableDistanceFog` are retired from this row's risk: both now behave correctly. | `CPlayerModule::OnChanged @0x0059A8E0`; `docs/research/2026-08-10-character-options-map.md` §1.5 | | TS-75 | "Always Daylight Outdoors" (`PlayerOption PersistentAtDay`, `CPlayerModule::OnChanged` case `0x05` → `LScape::SetDay(value)`) has no acdream consumer. The campaign plan's own Group-B binding table cites `RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` as the target seam — **that citation is a mechanism mismatch, corrected here**: `ForcedDayGroupIndex` selects which WEATHER-VARIETY day-group (`RuntimeWorldDayGroupDefinition`, e.g. a Clear/Overcast/Rain/Snow/Storm pick) is always chosen — the SAME deterministic-per-day-RNG mechanism `WeatherSystem`'s own roll uses (see TS-6) — NOT retail's time-of-day day/night force. No acdream mechanism currently overrides the sky cycle's TIME to stay in daytime lighting; wiring this option correctly needs that mechanism built first, not just a poll into the wrong field. | `src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs` (`RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` — NOT the right target); no current consumer exists | Filed rather than silently wired to the wrong field — a poll into `ForcedDayGroupIndex` would have SILENTLY changed the character's weather-variety odds instead of forcing daytime, an incorrect fix masquerading as a correct one (CLAUDE.md's "no workarounds" rule). | Toggling the option writes the bit and dirties/auto-saves it correctly, but night still falls normally — no observable daylight-forcing behavior. | `CPlayerModule::OnChanged @0x0059A8E0` case 5; `LScape::SetDay` (not yet located in the decomp) | diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index c2f939fd..2a636103 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -1183,4 +1183,21 @@ public static class DatWidgetFactory /// private static string? NormalizeEscapes(string? raw) => raw?.Replace("\\n", "\n").Replace("\r", string.Empty); + + /// + /// #409 (client-wide retail tooltip system): resolves the already- + /// extracted (dat property + /// 0x49) through , applying the + /// SAME escape normalization every other authored StringInfo + /// (captions, 0x17) gets at this one choke point. Null when the + /// element authors no tooltip text or no resolver is available. + /// + internal static string? ResolveTooltipText( + ElementInfo info, + Func? stringResolve) + { + if (stringResolve is null || info.TooltipText is not { } tooltipText) + return null; + return NormalizeEscapes(stringResolve(tooltipText)); + } } diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index 6f5edbd5..072bfaa3 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -266,6 +266,95 @@ public sealed class ElementInfo /// public int MarginLeft, MarginRight, MarginTop, MarginBottom; + /// + /// #409 (client-wide retail tooltip system): whether THIS element is + /// eligible to trigger retail's hover tooltip at all — dat property + /// 0x4B (BoolBaseProperty). Retail + /// UIElement::OnSetAttribute @0x00462d80 case 0x18 + /// (GetPropertyName()-0x33==0x18, i.e. property id + /// 0x33+0x18=0x4B) calls UIRegion::SetTooltipOn, which + /// toggles bit 5 of __bitfield164; UIElement::MouseHover + /// @0x00462520 checks exactly that bit (plus the GLOBAL + /// UIElementManager::m_tooltipEnable preference) before calling + /// StartTooltipAtMouse. Default false — the ctor-cleared bit, + /// matching "an element must explicitly author P0x4B=true to + /// ever show a tooltip." + /// + public bool TooltipEnabled; + + /// + /// #409: the tooltip's TEXT, dat property 0x49 + /// (StringInfo). Retail UIElement::StartTooltipAtMouse + /// @0x00460D70 prefers the element's own runtime-settable + /// m_TTText field (set only by the separate, non-dat-authored + /// UIElement::SetTooltip API — game code showing dynamic + /// status text, out of this property's scope) and falls back to + /// vtable->InqProperty(0x49, ...) — the generic dat-authored + /// property bag read — whenever m_TTText is empty, which for a + /// pure DAT-imported element is ALWAYS (nothing in + /// OnSetAttribute's switch populates m_TTText from a dat + /// property). Kept RAW (unresolved ) at + /// this layer — is dat-free/string-free by + /// design; resolution through DatStringResolver happens in + /// DatWidgetFactory, mirroring the existing 0x17 caption + /// pattern exactly. + /// + public UiStringInfoValue? TooltipText; + + /// + /// #409: the ROOT ELEMENT ID to instantiate WITHIN the tooltip popup + /// LayoutDesc () — dat property + /// 0x47 (EnumBaseProperty, read via + /// UIElement::GetAttribute_Enum(this, 0x47, ...)). Retail + /// UIElementManager::StartTooltip @0x0045DE90 passes this value + /// straight to LayoutDesc::AccessElementDesc — it is an + /// element-desc token WITHIN the popup layout, NOT a "tooltip behavior + /// enum" (an earlier, less complete investigation guessed the latter; + /// full decomp re-derivation for this port corrects it). Zero when the + /// element authors no tooltip. + /// + public uint TooltipRootElementId; + + /// + /// #409: the tooltip POPUP LayoutDesc DID — dat property 0x48 + /// (DataIdBaseProperty, read via + /// UIElement::GetAttribute_DataID(this, 0x48, ...)). Retail + /// authors 0x21000041 here on every tooltip-bearing element + /// (live-DAT-probe-confirmed by the #409 investigation). Retail falls + /// back to the HOVERING element's OWN containing m_layout DID + /// when P0x48 is absent but P0x47 is present — an edge + /// case this port does not reproduce (every live-DAT element the #409 + /// sweep found authors P0x48 directly alongside P0x47; + /// see the divergence register for the honest row). Zero when absent. + /// + public uint TooltipLayoutDid; + + /// + /// #409: the tooltip TEXT CHILD's element id — dat property + /// 0x4A (EnumBaseProperty). Unlike the other five + /// tooltip properties, retail reads THIS one off the freshly + /// INSTANTIATED popup's own ROOT element (UIElement:: + /// GetAttribute_Enum(tooltipRootElement, 0x4a, &textChildId) + /// inside UIElementManager::StartTooltip @0x0045DE90's + /// fallback branch), not off the hovering trigger element — so this + /// field is meaningful only when read from the popup layout's OWN + /// resolved root /UiElement, which the + /// same generic property-read machinery populates uniformly for every + /// imported element regardless of role. + /// + public uint TooltipTextChildElementId; + + /// + /// #409: a per-element hover-dwell delay OVERRIDE in seconds — dat + /// property 0x50 (FloatBaseProperty). Retail + /// UIElementManager::CheckTooltip @0x0045B6E0 reads + /// UIElement::GetAttribute_Float(hoveredElement, 0x50, &delay) + /// and uses it in place of the global m_tooltipDelay + /// (Misc.TooltipDelay preference, default 0.25 s) when present. + /// Null when the element authors no override. + /// + public float? TooltipDelaySeconds; + /// /// Resolves a property for a state using retail's DirectState-as-base rule. A /// named state's key overrides DirectState by presence, including false/zero. @@ -603,6 +692,32 @@ public static class ElementReader { info.Invisible = invisible; } + + // #409 (client-wide retail tooltip system): the six per-element + // tooltip properties. See each ElementInfo field's own doc comment + // for the exact decomp anchor. TooltipTextChildElementId (0x4A) is + // read the SAME generic way here even though retail only consults + // it off a tooltip POPUP's own root element — the read is uniform + // and harmless for every other element (it stays 0). + if (info.TryGetEffectiveBool(0x4Bu, out bool tooltipOn)) + { + info.TooltipEnabled = tooltipOn; + } + + if (info.TryGetEffectiveProperty(0x49u, out var tooltipText) + && tooltipText.Kind == UiPropertyKind.StringInfo) + { + info.TooltipText = tooltipText.StringInfoValue; + } + + info.TooltipRootElementId = ReadReferencedElementId(info, 0x47u); + info.TooltipLayoutDid = ReadReferencedElementId(info, 0x48u); + info.TooltipTextChildElementId = ReadReferencedElementId(info, 0x4Au); + + if (info.TryGetEffectiveFloat(0x50u, out float tooltipDelay)) + { + info.TooltipDelaySeconds = tooltipDelay; + } } private static List ReadTabTable(ElementInfo info) diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index bb411eb4..f149a8c0 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -121,6 +121,18 @@ public static class LayoutImporter // doc comment for why this does NOT set Visible here. w.AuthoredInvisible = info.Invisible; + // #409: the six per-element tooltip properties, same pure-data- + // passthrough shape as AuthoredInvisible above. TooltipText is the + // one property resolved to a display string here (mirrors every + // other authored StringInfo caption in this file); the rest stay + // raw ids/flags for RetailTooltipPresenter to act on. + w.AuthoredTooltipEnabled = info.TooltipEnabled; + w.AuthoredTooltipText = DatWidgetFactory.ResolveTooltipText(info, stringResolve); + w.AuthoredTooltipRootElementId = info.TooltipRootElementId; + w.AuthoredTooltipLayoutDid = info.TooltipLayoutDid; + w.AuthoredTooltipTextChildElementId = info.TooltipTextChildElementId; + w.AuthoredTooltipDelaySeconds = info.TooltipDelaySeconds; + if (info.Id != 0) byId[info.Id] = w; // Behavioral widgets that draw their full appearance + reproduce their dat diff --git a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs new file mode 100644 index 00000000..18a8c539 --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs @@ -0,0 +1,244 @@ +using System; +using System.Linq; +using AcDream.App.UI; + +namespace AcDream.App.UI.Layout; + +/// +/// #409 — client-wide retail hover-tooltip system. Consumes 's +/// existing hover-dwell timer (/ +/// ) and, for any widget that authors the five +/// tooltip properties, instantiates retail's popup LayoutDesc, auto-sizes and +/// positions it, and mounts/unmounts it as a topmost sibling of the retained tree. +/// +/// +/// Retail mechanism (decomp anchors — see also each ElementInfo.Tooltip* +/// field's own doc comment for the individual property citations): +/// +/// UIElementManager::CheckTooltip @0x0045B6E0 — the +/// per-frame dwell/auto-hide timer, ported into . +/// UIElement::MouseHover @0x00462520 — the per-element +/// gate (P0x4B TooltipOn bit + the global m_tooltipEnable +/// preference); ported as this class's + +/// check. +/// UIElement::StartTooltipAtMouse @0x00460D70 — text +/// resolution (P0x49) and dispatch to the manager. +/// UIElementManager::StartTooltip @0x0045DE90 — +/// instantiates the popup (P0x47 root element id within P0x48's +/// LayoutDesc), resolves the text child (P0x4A, read off the POPUP's own +/// root), sets its text, and auto-resizes the root by the measured-vs-authored +/// text delta. +/// UIElementManager::StartTooltip @0x00459700 — +/// positions the popup's top-left AT the mouse cursor (no hotspot offset), +/// clamped to the display. +/// +/// +/// +/// +/// Live-DAT-probed structure (#409 investigation): layout 0x21000041's +/// root (id 0, 800x600, a pure catalog container) holds four 30x30 popup skins +/// (0x10000487/0x10000395/0x10000397/0x10000398), +/// each a four-piece bevel frame around one Type-12 text child +/// 0x10000396 — every one of those four roots' own P0x4A resolves +/// to exactly 0x10000396, confirming the decomp reading. 434 installed +/// elements author at least one of the five properties (243 with literal +/// P0x49 text this port can show; the other 191 rely on retail's DYNAMIC +/// InqProperty(0x49) override, out of this port's scope — see the +/// divergence register). A fifth root id (0x100001F0) and a second popup +/// layout (0x21000026) also appear on a handful of elements; this class +/// is fully data-driven off each widget's own authored properties, so neither +/// needed special-casing. +/// +/// +public sealed class RetailTooltipPresenter : IDisposable +{ + private readonly UiRoot _host; + + /// Resolves a tooltip popup's LayoutDesc + root element (the + /// widget's own / + /// ) to a mounted-ready + /// . Wraps LayoutImporter.Import with the + /// runtime's dat lock/resolve/font context — the same shape + /// RetailUiRuntime.CreateLayout already gives . + private readonly Func _createLayout; + + private UiElement? _popupRoot; + private UiElement? _owner; + private bool _disposed; + + public RetailTooltipPresenter(UiRoot host, Func createLayout) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _createLayout = createLayout ?? throw new ArgumentNullException(nameof(createLayout)); + _host.TooltipShow += OnTooltipShow; + _host.TooltipHide += OnTooltipHide; + } + + /// + /// The client-side Misc.TooltipEnable preference + /// (UIElementManager::Init @0x0045EE10 registers it as a + /// UserPreferences.ini key via UIPreferences::AttachPreference, + /// default true — m_tooltipEnable=1 @0x0045F756). NOT part of the + /// server-synced CharacterOptionTable — retail's 2013 Config tab + /// doesn't expose a row for it either (research confirms it's UserPreferences- + /// only, absent from the visible tab), so this stays a plain client-local + /// setting on the presenter rather than routing through + /// RuntimeCharacterOptionsState. Gates ONLY the popup presentation — + /// matching retail's own gate point at UIElement::MouseHover — NOT + /// 's dwell timer, which keeps running either way exactly + /// as retail's CheckTooltip does. + /// + public bool Enabled { get; set; } = true; + + private void OnTooltipShow(UiElement widget) + { + RemovePopup(); + + if (!Enabled || !widget.AuthoredTooltipEnabled) + return; + if (string.IsNullOrEmpty(widget.AuthoredTooltipText)) + return; + if (widget.AuthoredTooltipLayoutDid == 0u || widget.AuthoredTooltipRootElementId == 0u) + return; + + ImportedLayout? layout; + try + { + layout = _createLayout(widget.AuthoredTooltipLayoutDid, widget.AuthoredTooltipRootElementId); + } + catch (Exception error) + { + Console.WriteLine( + $"[UI] #409 tooltip popup layout=0x{widget.AuthoredTooltipLayoutDid:X8} " + + $"root=0x{widget.AuthoredTooltipRootElementId:X8} failed to build: {error.Message}"); + return; + } + if (layout is null) + return; + + UiElement root = layout.Root; + UiElement? textChild = root.AuthoredTooltipTextChildElementId != 0u + ? layout.FindElement(root.AuthoredTooltipTextChildElementId) + : null; + if (textChild is UiText text) + ApplyTooltipText(root, text, widget.AuthoredTooltipText!); + + SetClickThroughRecursive(root); + PositionAtMouse(root); + + _host.AddChild(root); + _host.BringToFront(root); + _popupRoot = root; + _owner = widget; + } + + private void OnTooltipHide(UiElement widget) + { + if (ReferenceEquals(_owner, widget)) + RemovePopup(); + } + + private void RemovePopup() + { + if (_popupRoot is null) + return; + _host.RemoveChild(_popupRoot); + _popupRoot = null; + _owner = null; + } + + /// Force-hides whatever tooltip is currently showing, if any. + /// Session-reset callers use this (mirrors 's + /// own role for dialogs) so a stale popup cannot survive a reconnect. + public void HideCurrent() => RemovePopup(); + + /// Retail UIElementManager::StartTooltip @0x0045DE90's text + /// + auto-resize step. Wraps at the display width (retail's + /// UIElement_Text::InqSizewMargins(..., UITS_MAX_WIDTH) falls back to + /// RenderDevice::GetDisplayWidth() when the text element authors no + /// P0x3D max-width override — unmodeled here, no probed tooltip + /// element authors one), then grows the popup ROOT by exactly the delta + /// between the measured wrapped size and the text child's AUTHORED size — + /// the authored gap becomes the popup's padding. Retail's final branch + /// (grow further if the text child has vertical scroll overflow) has no + /// acdream analog for a freshly-built, unscrolled popup and is a + /// structural no-op here. + private void ApplyTooltipText(UiElement root, UiText text, string tooltipText) + { + float authoredTextWidth = text.Width; + float authoredTextHeight = text.Height; + + Func measure = text.DatFont is { } datFont + ? datFont.MeasureWidth + : text.Font is { } bitmapFont + ? bitmapFont.MeasureWidth + : static s => s.Length * 8f; + + float wrapWidth = MathF.Max(1f, _host.EffectiveCanvasSize.X); + var wrapped = UiText.WrapWords(tooltipText, measure, wrapWidth); + text.LinesProvider = () => wrapped + .Select(line => new UiText.Line(line, text.DefaultColor)) + .ToArray(); + + float measuredWidth = wrapped.Count == 0 ? 0f : wrapped.Max(measure); + float lineHeight = text.DatFont?.LineHeight ?? text.Font?.LineHeight ?? 14f; + float measuredHeight = wrapped.Count * lineHeight; + + root.Width += measuredWidth - authoredTextWidth; + root.Height += measuredHeight - authoredTextHeight; + text.Width = measuredWidth; + text.Height = measuredHeight; + } + + /// Retail UIElementManager::StartTooltip @0x00459700: the + /// popup's top-left lands EXACTLY at the mouse cursor (no hotspot offset), + /// clamped so it never crosses the right/bottom display edge (nor goes + /// negative, mirroring retail's own max(0, ...) defensive clamp). + private void PositionAtMouse(UiElement root) + { + var canvas = _host.EffectiveCanvasSize; + float x = Math.Clamp(_host.MouseX, 0, MathF.Max(0f, canvas.X - root.Width)); + float y = Math.Clamp(_host.MouseY, 0, MathF.Max(0f, canvas.Y - root.Height)); + root.Left = x; + root.Top = y; + } + + /// A tooltip must never intercept the pointer — the very next + /// hover-hit-test would otherwise find the popup itself and immediately + /// dismiss it (no decomp counterpart needed: retail's tooltip is a + /// separate, non-hit-tested presentation layer by construction, per the + /// AP-229 register row's own finding on dialogs). + /// and already default ClickThrough=true, but + /// this walk makes the guarantee unconditional across whatever the popup + /// LayoutDesc happens to be authored with. + private static void SetClickThroughRecursive(UiElement element) + { + element.ClickThrough = true; + foreach (UiElement child in element.Children) + SetClickThroughRecursive(child); + } + + /// Re-asserts the popup's z-order above whatever + /// raised this frame — mirrors that + /// factory's own per-tick BringToFront re-raise (see its own doc + /// comment on the dialog/screen sibling z-order war, register AP-229) so a + /// dialog opened WHILE a tooltip is already showing cannot bury it. Must + /// run after ticks both + /// and in the same + /// frame — see the divergence register row this class's own commit files + /// for the acknowledged "sibling with a later re-raise" shape. + public void Tick() + { + if (_popupRoot is not null) + _host.BringToFront(_popupRoot); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _host.TooltipShow -= OnTooltipShow; + _host.TooltipHide -= OnTooltipHide; + RemovePopup(); + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index da1e74b3..996b9317 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -528,6 +528,7 @@ public sealed class RetailUiRuntime : IDisposable MountIndicators(); MountJumpPowerbar(); MountDialogFactory(); + MountTooltipPresenter(); MountSocialPanel(); MountCharacter(); MountPlugins(); @@ -630,6 +631,9 @@ public sealed class RetailUiRuntime : IDisposable public UiNineSlicePanel? InventoryFrame { get; private set; } public InventoryController? InventoryPanelController { get; private set; } public RetailDialogFactory? DialogFactory { get; private set; } + /// #409: client-wide retail hover-tooltip presenter. Constructed + /// alongside — see ConfigureDialogFactory. + public RetailTooltipPresenter? TooltipPresenter { get; private set; } public ExternalContainerController? ExternalContainerController { get; private set; } public VendorUiController? VendorController { get; private set; } public OptionsPanelController? OptionsPanelController { get; private set; } @@ -813,6 +817,11 @@ public sealed class RetailUiRuntime : IDisposable CharacterCreationController?.Tick(); DialogFactory?.Tick(); Host.Tick(deltaSeconds); + // #409: MUST tick after both DialogFactory and Host so the tooltip's + // own BringToFront re-raise always lands on top of whatever z-order + // those two just asserted this frame — see RetailTooltipPresenter.Tick's + // own doc comment and the register row filed alongside this class. + TooltipPresenter?.Tick(); _automation?.Tick(deltaSeconds); } @@ -979,6 +988,7 @@ public sealed class RetailUiRuntime : IDisposable { CharacterManagementController?.ResetSession(); DialogFactory?.Reset(); + TooltipPresenter?.HideCurrent(); } finally { @@ -3314,6 +3324,47 @@ public sealed class RetailUiRuntime : IDisposable $"[UI] retail DialogFactory from LayoutDesc 0x{layoutId:X8}; confirmation root 0x15."); } + /// + /// #409: mounts the client-wide retail tooltip presenter. Unlike + /// 's catalog resolve, the tooltip popup + /// layout/root are entirely data-driven off each hovered widget's OWN + /// authored 0x47/0x48 properties ('s + /// own class doc) — this method only wires the generic layout-by-DID + /// resolver and seeds the client-local Misc.TooltipEnable/ + /// Misc.TooltipDelay preferences from the shared + /// (the same instance + /// _bindings.Chat.Store already threads to every other client-local + /// preference reader in this file). + /// + private void MountTooltipPresenter() + { + if (TooltipPresenter is not null) + return; + + ImportedLayout? CreateTooltipLayout(uint layoutDid, uint rootElementId) + { + lock (_bindings.Assets.DatLock) + { + return LayoutImporter.Import( + _bindings.Assets.Dats, + layoutDid, + rootElementId, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont); + } + } + + TooltipPresenter = new RetailTooltipPresenter(Host.Root, CreateTooltipLayout); + + if (_bindings.Chat.Store is { } store) + { + MiscSettings misc = store.LoadMisc(); + TooltipPresenter.Enabled = misc.TooltipEnable; + Host.Root.TooltipDelayMs = (int)(misc.TooltipDelaySeconds * 1000f); + } + } + private UiShortcutDigitGraphics LoadShortcutDigitGraphics() { if (_shortcutDigitGraphics is not null) @@ -4194,7 +4245,11 @@ public sealed class RetailUiRuntime : IDisposable _characterCreationMount?.Dispose(); _gameplayConfirmationController?.Dispose(); }, - () => DialogFactory?.Dispose(), + () => + { + DialogFactory?.Dispose(); + TooltipPresenter?.Dispose(); + }, _panelUi.Dispose, Host.Dispose); _shutdown.CompleteOrThrow(); diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index 668d51fc..fb99830f 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -70,6 +70,60 @@ public abstract class UiElement /// public bool AuthoredInvisible { get; internal set; } + /// + /// #409 (client-wide retail tooltip system): mirrors + /// ElementInfo.TooltipEnabled (dat property 0x4B) — a + /// PURE DATA PASSTHROUGH set by LayoutImporter.BuildWidget. + /// Gates whether may show a + /// tooltip for this widget at all (retail's per-element + /// UIRegion::SetTooltipOn bit, checked by + /// UIElement::MouseHover @0x00462520 alongside the global + /// enable preference). See . + /// + public bool AuthoredTooltipEnabled { get; internal set; } + + /// + /// #409: mirrors ElementInfo.TooltipText (dat property + /// 0x49), already resolved to a display string through + /// DatStringResolver and escape-normalized at import time — + /// the same treatment every other authored StringInfo caption + /// gets. Null when the element authors no tooltip text. + /// + public string? AuthoredTooltipText { get; internal set; } + + /// + /// #409: mirrors ElementInfo.TooltipRootElementId (dat property + /// 0x47) — the element-desc id WITHIN + /// 's LayoutDesc to instantiate + /// as the tooltip popup's root. Zero when absent. + /// + public uint AuthoredTooltipRootElementId { get; internal set; } + + /// + /// #409: mirrors ElementInfo.TooltipLayoutDid (dat property + /// 0x48) — the tooltip popup LayoutDesc DID (retail authors + /// 0x21000041 on every tooltip-bearing element). Zero when + /// absent. + /// + public uint AuthoredTooltipLayoutDid { get; internal set; } + + /// + /// #409: mirrors ElementInfo.TooltipTextChildElementId (dat + /// property 0x4A). Meaningful only when read off a tooltip + /// POPUP's own instantiated ROOT widget — see the + /// ElementInfo field's own doc for why retail reads this off + /// the popup, not the hovering trigger element. + /// + public uint AuthoredTooltipTextChildElementId { get; internal set; } + + /// + /// #409: mirrors ElementInfo.TooltipDelaySeconds (dat property + /// 0x50) — a per-element hover-dwell override in seconds, used + /// by 's tooltip timer in place of the global + /// when present. Null = no override. + /// + public float? AuthoredTooltipDelaySeconds { get; internal set; } + private readonly Dictionary _stateCursors = new(); /// Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState. diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index a9374c8a..971d66d5 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -330,11 +330,51 @@ public sealed class UiRoot : UiElement // Hover / tooltip tracking. private UiElement? _hoverWidget; private long _hoverStartedMs; + /// Global hover-dwell delay in ms before a tooltip fires — retail + /// UIElementManager::m_tooltipDelay, default 0.25 s + /// (UIElementManager::UIElementManager @0x0045F5D0), the + /// Misc.TooltipDelay preference. A hovered widget's own + /// (dat property + /// 0x50) overrides this per UIElementManager::CheckTooltip + /// @0x0045B6E0 — see . public int TooltipDelayMs { get; set; } = 250; + /// How long a shown tooltip stays up before auto-hiding — retail + /// m_tooltipDuration, a fixed 10 s + /// (UIElementManager::CheckTooltip @0x0045B6E0's own auto-hide + /// check); not a user preference. public int TooltipDurationMs { get; set; } = 10_000; private bool _tooltipFired; private long _tooltipShownMs; + /// + /// #409: fired once, synchronously, when a hovered widget's dwell delay + /// elapses (retail's UIElementManager::StartHover -> + /// UIElement::MouseHover edge). + /// is the production consumer — it decides whether the widget actually + /// authors a tooltip and, if so, builds/positions the popup. + /// + public event Action? TooltipShow; + + /// + /// #409: fired when a previously-shown tooltip must go away — hover + /// left the widget (), the widget's subtree is + /// being removed (), or the shown + /// duration elapsed ('s auto-hide branch). Only fires + /// if actually fired for this widget first + /// (mirrors retail's own m_pTooltipElement != null guard at every + /// one of those three call sites). + /// + public event Action? TooltipHide; + + /// Retail UIElementManager::CheckTooltip @0x0045B6E0: a + /// per-element FLOAT delay override (dat property 0x50) replaces + /// the global when the hovered element + /// authors one. + private int EffectiveTooltipDelayMs(UiElement widget) + => widget.AuthoredTooltipDelaySeconds is { } seconds + ? (int)(seconds * 1000f) + : TooltipDelayMs; + private long _nowMs; /// Raised when an event was not consumed by any widget. @@ -440,6 +480,12 @@ public sealed class UiRoot : UiElement { var leave = new UiEvent(_hoverWidget!.EventId, _hoverWidget, UiEventType.HoverLeave); _hoverWidget.OnEvent(in leave); + // #409: retail UIElementManager::DeletingElement @0x0045E520 tears + // down the active tooltip when its owner element is removed + // (m_pTooltipOwner == ebp). Only fire if a tooltip actually + // showed for this widget (mirrors that null-guarded check). + if (_tooltipFired) + TooltipHide?.Invoke(_hoverWidget); _hoverWidget = null; _tooltipFired = false; } @@ -475,21 +521,23 @@ public sealed class UiRoot : UiElement { _nowMs = nowMs; - // Tooltip timer: once mouse has hovered over the same widget for - // TooltipDelayMs, fire a Tooltip event on it exactly once. + // Tooltip timer: once mouse has hovered over the same widget for its + // effective dwell delay, fire a Tooltip event on it exactly once. if (_hoverWidget is not null && !_tooltipFired - && _nowMs - _hoverStartedMs >= TooltipDelayMs) + && _nowMs - _hoverStartedMs >= EffectiveTooltipDelayMs(_hoverWidget)) { var e = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.Tooltip); _hoverWidget.OnEvent(in e); _tooltipFired = true; _tooltipShownMs = _nowMs; + TooltipShow?.Invoke(_hoverWidget); } else if (_hoverWidget is not null && _tooltipFired && _nowMs - _tooltipShownMs >= TooltipDurationMs) { var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave); _hoverWidget.OnEvent(in leave); + TooltipHide?.Invoke(_hoverWidget); _hoverWidget = null; _tooltipFired = false; } @@ -1241,6 +1289,16 @@ public sealed class UiRoot : UiElement { var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave); _hoverWidget.OnEvent(in leave); + // #409: retail UIElementManager::SwitchMouseOver @0x0045B560 calls + // StopHover (which tears down m_pTooltipElement) the instant the + // hovered element changes — the ONLY confirmed dismissal trigger + // besides duration-timeout and element-removal. Notably, retail's + // MouseDownEvent @0x0045DB60 calls SwitchMouseOver with the SAME + // hit-tested element, so clicking the tooltip's own owner does + // NOT dismiss it (SwitchMouseOver no-ops when the target hasn't + // changed) — no click-dismissal is ported here for that reason. + if (_tooltipFired) + TooltipHide?.Invoke(_hoverWidget); } _hoverWidget = w; _hoverStartedMs = _nowMs; diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/MiscSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/MiscSettings.cs new file mode 100644 index 00000000..bfb092e7 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/MiscSettings.cs @@ -0,0 +1,33 @@ +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// #409 (client-wide retail tooltip system): the two Misc.* +/// UserPreferences.ini keys retail's UIElementManager::Init +/// @0x0045EE10 registers (UIPreferences::AttachPreference, +/// Misc_TooltipDelay/Misc_TooltipEnable) and binds onto +/// UIElementManager::m_tooltipDelay/m_tooltipEnable. These are +/// CLIENT-LOCAL preferences, not part of the server-synced +/// CharacterOptionTable — the #409 investigation confirmed retail's +/// own 2013 Config tab does not expose a visible row for either (they exist +/// in UserPreferences.ini and are registered engine-side, but +/// gmConfigUI::InitOptions's 27 authored rows never author one), so +/// this record follows the / +/// client-local persistence precedent WITHOUT a +/// matching options-panel row — persisted, but not (yet) user-editable +/// in-client, exactly matching what retail itself ships. +/// +public sealed record MiscSettings( + bool TooltipEnable, + float TooltipDelaySeconds) +{ + /// + /// Retail's registered defaults: m_tooltipEnable=1, + /// m_tooltipDelay=0.25f + /// (UIElementManager::UIElementManager @0x0045F5D0). The + /// preference's registered clamp range is [0,10] seconds + /// (UIPreferences::SetPreferenceRange(&Misc_TooltipDelay, 0f, 10f)). + /// + public static MiscSettings Default { get; } = new( + TooltipEnable: true, + TooltipDelaySeconds: 0.25f); +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index efb9c141..81230fa9 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -12,7 +12,7 @@ public readonly record struct UiWindowPosition(float X, float Y); /// /// JSON-backed persistence for non-keybind settings (Display / Audio / Chat / -/// Character; the OP9-retired Gameplay section is no longer read or +/// CameraTurning / Character / Misc; the OP9-retired Gameplay section is no longer read or /// written — see the class's Schema note below for how a leftover /// "gameplay" block from an older settings.json is tolerated). The /// graphical host supplies a canonical portable configuration path. Coexists @@ -280,6 +280,38 @@ public sealed class SettingsStore public void SaveCameraTurning(CameraTurningSettings cameraTurning) => SaveSection("cameraTurning", BuildCameraTurningObject(cameraTurning)); + /// + /// #409: load the two Misc.* tooltip preferences. Same fall-back + /// behaviour as . + /// + public MiscSettings LoadMisc() + { + if (!File.Exists(_path)) return MiscSettings.Default; + try + { + using var stream = File.OpenRead(_path); + var doc = JsonDocument.Parse(stream); + var root = doc.RootElement; + if (!root.TryGetProperty("misc", out var misc) + || misc.ValueKind != JsonValueKind.Object) + return MiscSettings.Default; + + var d = MiscSettings.Default; + return new MiscSettings( + TooltipEnable: ReadBool (misc, "tooltipEnable", d.TooltipEnable), + TooltipDelaySeconds: ReadFloat(misc, "tooltipDelaySeconds", d.TooltipDelaySeconds)); + } + catch (Exception ex) + { + Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); + return MiscSettings.Default; + } + } + + /// Save the tooltip preferences, preserving all other top-level keys. + public void SaveMisc(MiscSettings misc) + => SaveSection("misc", BuildMiscObject(misc)); + /// /// Load per-character settings keyed by . /// Missing file or missing toon entry → . @@ -652,6 +684,13 @@ public sealed class SettingsStore ["vsync"] = d.VSync, }; + private static SortedDictionary BuildMiscObject(MiscSettings m) + => new(StringComparer.Ordinal) + { + ["tooltipDelaySeconds"] = m.TooltipDelaySeconds, + ["tooltipEnable"] = m.TooltipEnable, + }; + private static SortedDictionary BuildCameraTurningObject(CameraTurningSettings c) => new(StringComparer.Ordinal) { diff --git a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs index 8cc2c57b..c01af433 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs @@ -478,4 +478,130 @@ public class ElementReaderTests Assert.Equal(0u, info.ScrollbarElementId); } + + // ── #409: the six per-element tooltip property readers ───────────────── + + private static UiPropertyValue DataIdProp(uint value) => new() + { + Kind = UiPropertyKind.DataId, + UnsignedValue = value, + }; + + private static UiPropertyValue FloatProp(float value) => new() + { + Kind = UiPropertyKind.Float, + FloatValue = value, + }; + + private static UiPropertyValue StringInfoProp(uint tableId, uint stringId) => new() + { + Kind = UiPropertyKind.StringInfo, + StringInfoValue = new UiStringInfoValue(0, stringId, tableId, 0, 0, 0), + }; + + [Fact] + public void TooltipRootElementId_0x47_DecodesEnumProperty() + { + ElementInfo info = WithDirectProperty(0x47u, EnumProp(0x10000487u)); + ElementReader.ApplyCanonicalLegacyProjection(info); + Assert.Equal(0x10000487u, info.TooltipRootElementId); + } + + [Fact] + public void TooltipLayoutDid_0x48_DecodesDataIdProperty() + { + ElementInfo info = WithDirectProperty(0x48u, DataIdProp(0x21000041u)); + ElementReader.ApplyCanonicalLegacyProjection(info); + Assert.Equal(0x21000041u, info.TooltipLayoutDid); + } + + [Fact] + public void TooltipText_0x49_KeptRawAsStringInfo() + { + ElementInfo info = WithDirectProperty(0x49u, StringInfoProp(0x23000003u, 0x0AAAAAAAu)); + ElementReader.ApplyCanonicalLegacyProjection(info); + Assert.Equal(0x23000003u, info.TooltipText!.Value.TableId); + Assert.Equal(0x0AAAAAAAu, info.TooltipText!.Value.StringId); + } + + [Fact] + public void TooltipTextChildElementId_0x4A_DecodesEnumProperty() + { + ElementInfo info = WithDirectProperty(0x4Au, EnumProp(0x10000396u)); + ElementReader.ApplyCanonicalLegacyProjection(info); + Assert.Equal(0x10000396u, info.TooltipTextChildElementId); + } + + [Fact] + public void TooltipEnabled_0x4B_DecodesBoolProperty() + { + ElementInfo info = WithDirectProperty(0x4Bu, BoolProp(true)); + ElementReader.ApplyCanonicalLegacyProjection(info); + Assert.True(info.TooltipEnabled); + } + + [Fact] + public void TooltipEnabled_0x4B_AbsentDefaultsFalse() + { + var info = new ElementInfo(); + ElementReader.ApplyCanonicalLegacyProjection(info); + Assert.False(info.TooltipEnabled); + } + + [Fact] + public void TooltipDelaySeconds_0x50_DecodesFloatProperty() + { + ElementInfo info = WithDirectProperty(0x50u, FloatProp(0f)); + ElementReader.ApplyCanonicalLegacyProjection(info); + Assert.Equal(0f, info.TooltipDelaySeconds); + } + + [Fact] + public void TooltipDelaySeconds_0x50_AbsentStaysNull() + { + var info = new ElementInfo(); + ElementReader.ApplyCanonicalLegacyProjection(info); + Assert.Null(info.TooltipDelaySeconds); + } + + [Fact] + public void AllSixTooltipProperties_AbsentTogether_LeaveEveryFieldAtItsDefault() + { + // Regression pin: an element authoring NONE of the tooltip properties + // must never accidentally pick up a nonzero id/flag from unrelated + // property parsing. + var info = new ElementInfo(); + ElementReader.ApplyCanonicalLegacyProjection(info); + + Assert.False(info.TooltipEnabled); + Assert.Null(info.TooltipText); + Assert.Equal(0u, info.TooltipRootElementId); + Assert.Equal(0u, info.TooltipLayoutDid); + Assert.Equal(0u, info.TooltipTextChildElementId); + Assert.Null(info.TooltipDelaySeconds); + } + + [Fact] + public void Merge_TooltipProperties_RecomputeFromTheMergedStateBag() + { + // ApplyCanonicalLegacyProjection recomputes every tooltip field fresh + // from Merge's own combined base+derived state bag (the same "no + // separate scalar-merge rule needed" shape TabTable/ScrollbarElementId + // already rely on) — a base-authored tooltip survives an unrelated + // derived override. + var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId, Name = "" }; + direct.Properties.Values[0x47u] = EnumProp(0x10000487u); + direct.Properties.Values[0x48u] = DataIdProp(0x21000041u); + direct.Properties.Values[0x4Bu] = BoolProp(true); + var base_ = new ElementInfo(); + base_.States[UiStateInfo.DirectStateId] = direct; + ElementReader.ApplyCanonicalLegacyProjection(base_); + + var derived = new ElementInfo(); // authors nothing of its own + ElementInfo merged = ElementReader.Merge(base_, derived); + + Assert.Equal(0x10000487u, merged.TooltipRootElementId); + Assert.Equal(0x21000041u, merged.TooltipLayoutDid); + Assert.True(merged.TooltipEnabled); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs index 7bce7247..4b0341d1 100644 --- a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs @@ -298,6 +298,71 @@ public class LayoutImporterTests Assert.Null(tree.FindElement(0xBBB00001u)); } + // ── #409: the six tooltip fields are a pure passthrough onto UiElement ── + + [Fact] + public void BuildWidget_TooltipProperties_CopyOntoTheWidget_AndTextResolves() + { + var root = new ElementInfo { Id = 0x1, Type = 3, Width = 100, Height = 40 }; + var trigger = new ElementInfo + { + Id = 0x2, Type = 3, X = 0, Y = 0, Width = 40, Height = 20, + TooltipEnabled = true, + TooltipRootElementId = 0x10000487u, + TooltipLayoutDid = 0x21000041u, + TooltipTextChildElementId = 0x10000396u, + TooltipDelaySeconds = 0.5f, + TooltipText = new UiStringInfoValue(0, 0x0AAAAAAAu, 0x23000003u, 0, 0, 0), + }; + + string? Resolve(UiStringInfoValue info) + => info.TableId == 0x23000003u && info.StringId == 0x0AAAAAAAu ? "Rotate left." : null; + + var tree = LayoutImporter.BuildFromInfos( + root, [trigger], NoTex, null, fontResolve: null, stringResolve: Resolve); + + UiElement found = tree.FindElement(0x2)!; + Assert.True(found.AuthoredTooltipEnabled); + Assert.Equal(0x10000487u, found.AuthoredTooltipRootElementId); + Assert.Equal(0x21000041u, found.AuthoredTooltipLayoutDid); + Assert.Equal(0x10000396u, found.AuthoredTooltipTextChildElementId); + Assert.Equal(0.5f, found.AuthoredTooltipDelaySeconds); + Assert.Equal("Rotate left.", found.AuthoredTooltipText); + } + + [Fact] + public void BuildWidget_TooltipText_NoStringResolver_StaysNull() + { + var root = new ElementInfo { Id = 0x1, Type = 3, Width = 100, Height = 40 }; + var trigger = new ElementInfo + { + Id = 0x2, Type = 3, X = 0, Y = 0, Width = 40, Height = 20, + TooltipText = new UiStringInfoValue(0, 0x0AAAAAAAu, 0x23000003u, 0, 0, 0), + }; + + var tree = LayoutImporter.BuildFromInfos(root, [trigger], NoTex, null); + + UiElement found = tree.FindElement(0x2)!; + Assert.Null(found.AuthoredTooltipText); + } + + [Fact] + public void BuildWidget_NoTooltipProperties_EveryWidgetFieldStaysAtItsDefault() + { + var root = new ElementInfo { Id = 0x1, Type = 3, Width = 100, Height = 40 }; + var trigger = new ElementInfo { Id = 0x2, Type = 3, X = 0, Y = 0, Width = 40, Height = 20 }; + + var tree = LayoutImporter.BuildFromInfos(root, [trigger], NoTex, null); + + UiElement found = tree.FindElement(0x2)!; + Assert.False(found.AuthoredTooltipEnabled); + Assert.Null(found.AuthoredTooltipText); + Assert.Equal(0u, found.AuthoredTooltipRootElementId); + Assert.Equal(0u, found.AuthoredTooltipLayoutDid); + Assert.Equal(0u, found.AuthoredTooltipTextChildElementId); + Assert.Null(found.AuthoredTooltipDelaySeconds); + } + // ── Helpers ─────────────────────────────────────────────────────────────── private static ElementInfo BuildSliceContainer(uint id, uint ReadOrder, uint l, uint t, uint r) diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs new file mode 100644 index 00000000..dc24b28c --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs @@ -0,0 +1,268 @@ +using System.Collections.Generic; +using System.Linq; +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// #409 (client-wide retail tooltip system) fixture coverage for +/// and its hover- +/// timer wiring. Builds synthetic popup layouts in-memory (no dats) via +/// — see +/// for the installed-DAT structural pins. +/// +public sealed class RetailTooltipPresenterTests +{ + /// Minimal concrete hover target — plain + /// with default (false) ClickThrough, so it is hit-testable. + private sealed class HoverTarget : UiElement; + + private const uint PopupRootId = 0x900u; + private const uint TextChildId = 0x901u; + private const uint PopupLayoutDid = 0x21000041u; + + /// Builds a fresh 30x30/26x26 popup — the exact live-DAT-probed + /// shape (four-piece bevel frame around one Type-12 text child) — every + /// call, mirroring retail's own fresh-instance-per-show behaviour. + private static ImportedLayout BuildPopup() + { + var rootInfo = new ElementInfo + { + Id = PopupRootId, Type = 3, X = 0, Y = 0, Width = 30, Height = 30, + TooltipTextChildElementId = TextChildId, + }; + var textInfo = new ElementInfo + { + Id = TextChildId, Type = 12, X = 2, Y = 2, Width = 26, Height = 26, + }; + return LayoutImporter.BuildFromInfos( + rootInfo, [textInfo], _ => (0u, 0, 0), null); + } + + private static (UiRoot Root, RetailTooltipPresenter Presenter, List<(uint, uint)> Requests) + CreateHarness() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var requests = new List<(uint LayoutDid, uint RootElementId)>(); + var presenter = new RetailTooltipPresenter(root, (layoutDid, rootElementId) => + { + requests.Add((layoutDid, rootElementId)); + return BuildPopup(); + }); + return (root, presenter, requests); + } + + private static HoverTarget AddFullyAuthoredTarget(UiRoot root, string text = "Rotate left.") + { + var target = new HoverTarget + { + Left = 100, Top = 100, Width = 40, Height = 20, + AuthoredTooltipEnabled = true, + AuthoredTooltipText = text, + AuthoredTooltipRootElementId = PopupRootId, + AuthoredTooltipLayoutDid = PopupLayoutDid, + }; + root.AddChild(target); + return target; + } + + [Fact] + public void NoTooltipShows_WhenPropertiesAbsent() + { + // Regression pin: an element that authors NONE of the five tooltip + // properties must never produce a popup, even after the dwell delay + // and the mouse resting on it. + var (root, _, requests) = CreateHarness(); + var target = new HoverTarget { Left = 100, Top = 100, Width = 40, Height = 20 }; + root.AddChild(target); + int childrenBefore = root.Children.Count; + + root.OnMouseMove(110, 110); + root.Tick(0.016, 0); + root.Tick(0.016, root.TooltipDelayMs + 50); + + Assert.Empty(requests); + Assert.Equal(childrenBefore, root.Children.Count); + } + + [Fact] + public void DelayThenShow_MountsPopupOnlyAfterTheDwellDelay() + { + var (root, _, requests) = CreateHarness(); + AddFullyAuthoredTarget(root); + int childrenBefore = root.Children.Count; + + root.OnMouseMove(110, 110); + root.Tick(0.016, 0); + Assert.Equal(childrenBefore, root.Children.Count); // not yet — dwell hasn't elapsed + + root.Tick(0.016, root.TooltipDelayMs - 1); + Assert.Equal(childrenBefore, root.Children.Count); // still one ms short + + root.Tick(0.016, root.TooltipDelayMs); + Assert.Equal(childrenBefore + 1, root.Children.Count); // popup mounted + Assert.Single(requests, r => r == (PopupLayoutDid, PopupRootId)); + } + + [Fact] + public void GlobalEnableGateOff_SuppressesPresentation_ButTheDwellTimerStillFires() + { + // Retail gates the POPUP at UIElement::MouseHover, not the dwell + // timer itself (UIElementManager::CheckTooltip has no m_tooltipEnable + // check) — the C# TooltipShow event must still fire; only the + // presenter's own decision to build something is suppressed. + var (root, presenter, requests) = CreateHarness(); + presenter.Enabled = false; + AddFullyAuthoredTarget(root); + int childrenBefore = root.Children.Count; + bool eventFired = false; + root.TooltipShow += _ => eventFired = true; + + root.OnMouseMove(110, 110); + root.Tick(0.016, 0); + root.Tick(0.016, root.TooltipDelayMs); + + Assert.True(eventFired); + Assert.Empty(requests); + Assert.Equal(childrenBefore, root.Children.Count); + } + + [Fact] + public void WidgetOwnTooltipDisabled_SuppressesPresentation() + { + var (root, _, requests) = CreateHarness(); + var target = AddFullyAuthoredTarget(root); + target.AuthoredTooltipEnabled = false; + + root.OnMouseMove(110, 110); + root.Tick(0.016, 0); + root.Tick(0.016, root.TooltipDelayMs); + + Assert.Empty(requests); + } + + [Fact] + public void MissingText_SuppressesPresentation() + { + var (root, _, requests) = CreateHarness(); + var target = AddFullyAuthoredTarget(root); + target.AuthoredTooltipText = null; + + root.OnMouseMove(110, 110); + root.Tick(0.016, 0); + root.Tick(0.016, root.TooltipDelayMs); + + Assert.Empty(requests); + } + + [Fact] + public void DismissesOnHoverTargetChange() + { + var (root, _, _) = CreateHarness(); + AddFullyAuthoredTarget(root); + var other = new HoverTarget { Left = 400, Top = 400, Width = 40, Height = 20 }; + root.AddChild(other); + int childrenBeforeShow = root.Children.Count; + + root.OnMouseMove(110, 110); + root.Tick(0.016, 0); + root.Tick(0.016, root.TooltipDelayMs); + Assert.Equal(childrenBeforeShow + 1, root.Children.Count); + + // Retail UIElementManager::SwitchMouseOver @0x0045B560: hovering a + // DIFFERENT element tears down the active tooltip immediately. + root.OnMouseMove(410, 410); + Assert.Equal(childrenBeforeShow, root.Children.Count); + } + + [Fact] + public void DismissesWhenTheOwnerElementIsRemoved() + { + var (root, _, _) = CreateHarness(); + var target = AddFullyAuthoredTarget(root); + int childrenBeforeShow = root.Children.Count; + + root.OnMouseMove(110, 110); + root.Tick(0.016, 0); + root.Tick(0.016, root.TooltipDelayMs); + Assert.Equal(childrenBeforeShow + 1, root.Children.Count); + + // Retail UIElementManager::DeletingElement @0x0045E520: the tooltip's + // owner going away tears the popup down too. + root.RemoveChild(target); + Assert.Equal(childrenBeforeShow - 1, root.Children.Count); // target removed, popup removed + } + + [Fact] + public void AutoHidesAfterTheDurationElapses() + { + var (root, _, _) = CreateHarness(); + AddFullyAuthoredTarget(root); + int childrenBeforeShow = root.Children.Count; + + root.OnMouseMove(110, 110); + root.Tick(0.016, 0); + root.Tick(0.016, root.TooltipDelayMs); + Assert.Equal(childrenBeforeShow + 1, root.Children.Count); + + root.Tick(0.016, root.TooltipDelayMs + root.TooltipDurationMs); + Assert.Equal(childrenBeforeShow, root.Children.Count); + } + + [Fact] + public void PerElementDelayOverride_ReplacesTheGlobalDelay() + { + var (root, _, requests) = CreateHarness(); + var target = AddFullyAuthoredTarget(root); + target.AuthoredTooltipDelaySeconds = 0f; // the live-DAT-probed override value + + root.OnMouseMove(110, 110); + root.Tick(0.016, 0); // the very next tick after hover starts — no dwell wait + Assert.Single(requests); + } + + [Fact] + public void PositionClampsToStayFullyOnTheDisplay() + { + var (root, _, _) = CreateHarness(); + // Small canvas + a hover point near the bottom-right corner, so the + // 30x30 popup would overflow both edges without the clamp. + root.Width = 40f; + root.Height = 40f; + var target = new HoverTarget + { + Left = 0, Top = 0, Width = 40, Height = 40, + AuthoredTooltipEnabled = true, + AuthoredTooltipText = "hi", + AuthoredTooltipRootElementId = PopupRootId, + AuthoredTooltipLayoutDid = PopupLayoutDid, + }; + root.AddChild(target); + + root.OnMouseMove(38, 38); + root.Tick(0.016, 0); + root.Tick(0.016, root.TooltipDelayMs); + + UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target)); + Assert.True(popup.Left + popup.Width <= 40f, $"popup right edge {popup.Left + popup.Width} exceeds canvas width 40"); + Assert.True(popup.Top + popup.Height <= 40f, $"popup bottom edge {popup.Top + popup.Height} exceeds canvas height 40"); + Assert.True(popup.Left >= 0f); + Assert.True(popup.Top >= 0f); + } + + [Fact] + public void AutoResizesTheRootByTheMeasuredTextDelta() + { + var (root, _, _) = CreateHarness(); + var target = AddFullyAuthoredTarget( + root, text: "This is a much longer tooltip than the authored placeholder."); + + root.OnMouseMove(110, 110); + root.Tick(0.016, 0); + root.Tick(0.016, root.TooltipDelayMs); + + UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target)); + Assert.True(popup.Width > 30f, $"expected the popup to grow past its authored 30px width, got {popup.Width}"); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/TooltipLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/TooltipLiveDatTests.cs new file mode 100644 index 00000000..5cb83c72 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/TooltipLiveDatTests.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// #409 (client-wide retail tooltip system) installed-DAT acceptance gate. +/// Opt in with ACDREAM_PROBE_LIVE_MOUNT=1; ACDREAM_DAT_DIR can +/// override the ordinary Documents/Asheron's Call location. Pins retail's +/// tooltip popup LayoutDesc 0x21000041 structure and a client-wide +/// sweep landmark, mirroring 's +/// pattern. +/// +public sealed class TooltipLiveDatTests +{ + private static string DatDirectory => + System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + + /// The tooltip popup catalog LayoutDesc — every probed + /// tooltip-bearing element's P0x48 resolves here (with one exception, + /// 0x21000026, an alternate skin used by a handful of elements — + /// RetailTooltipPresenter is data-driven off each element's OWN P0x48 + /// so neither this constant nor 0x21000026 is hardcoded in production + /// code; it is cited here only to pin the probe's own finding). + private const uint TooltipCatalogLayoutId = 0x21000041u; + + /// The four popup "skin" root element ids live-DAT-probed inside + /// . Each is a 30x30 four-piece bevel + /// frame around the SAME text child id (). + private static readonly uint[] PopupSkinRootIds = + [0x10000487u, 0x10000395u, 0x10000397u, 0x10000398u]; + + private const uint TooltipTextChildId = 0x10000396u; + + [InstalledDatFact] + public void TooltipCatalog_EveryPopupSkin_SharesTheSameTextChild() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + ElementInfo? root = LayoutImporter.ImportInfos(dats, TooltipCatalogLayoutId); + Assert.NotNull(root); + Assert.Equal(0u, root!.Id); + Assert.Equal(800f, root.Width); + Assert.Equal(600f, root.Height); + + foreach (uint skinRootId in PopupSkinRootIds) + { + ElementInfo skin = Assert.Single(root.Children, c => c.Id == skinRootId); + // UIElementManager::StartTooltip @0x0045DE90's own fallback read: + // GetAttribute_Enum(tooltipRootElement, 0x4a, &textChildId) off the + // freshly-instantiated POPUP root itself. + Assert.Equal(TooltipTextChildId, skin.TooltipTextChildElementId); + + ElementInfo textChild = Assert.Single( + AllDescendants(skin), e => e.Id == TooltipTextChildId); + Assert.Equal(12u, textChild.Type); // UIElement_Text + } + } + + [InstalledDatFact] + public void TooltipCatalog_ImportsThroughLayoutImporter_WithTextChildResolvable() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + ImportedLayout? popup = LayoutImporter.Import( + dats, + TooltipCatalogLayoutId, + 0x10000487u, + _ => (0u, 0, 0), + null); + Assert.NotNull(popup); + Assert.Equal(30f, popup!.Root.Width); + Assert.Equal(30f, popup.Root.Height); + Assert.Equal(TooltipTextChildId, popup.Root.AuthoredTooltipTextChildElementId); + + UiElement? textChild = popup.FindElement(TooltipTextChildId); + Assert.IsType(textChild); + } + + /// + /// A known tooltip-bearing element (the Appearance page's left rotate + /// button, live-DAT-probed) authors all five trigger properties with + /// literal StringInfo text — the "core" case + /// can show end to end. + /// + [InstalledDatFact] + public void KnownElement_AuthorsAllFiveTooltipProperties_WithResolvableText() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x21000005u); + Assert.NotNull(tree); + ElementInfo rotateLeft = Assert.Single(AllDescendants(tree!), e => e.Id == 0x100005A4u); + + Assert.True(rotateLeft.TooltipEnabled); + Assert.Equal(0x10000487u, rotateLeft.TooltipRootElementId); + Assert.Equal(TooltipCatalogLayoutId, rotateLeft.TooltipLayoutDid); + Assert.NotNull(rotateLeft.TooltipText); + + var strings = new DatStringResolver(dats); + string? resolved = DatWidgetFactory.ResolveTooltipText(rotateLeft, strings.Resolve); + Assert.Equal("Rotate left.", resolved); + } + + /// + /// Landmark client-wide sweep (mirrors + /// 's own shape): + /// every installed LayoutDesc, counting elements authoring at least one + /// of the five tooltip-trigger properties. Asserts landmarks + a floor, + /// not a brittle exact total, so the gate survives a future DAT + /// revision. + /// + [InstalledDatFact] + public void ClientWideSweep_FindsKnownLandmarksAndAFloorCount() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + var withProperties = new List<(uint LayoutId, uint ElementId, bool HasText)>(); + foreach (uint layoutId in dats.GetAllIdsOfType()) + { + ElementInfo? tree; + try { tree = LayoutImporter.ImportInfos(dats, layoutId); } + catch { continue; } + if (tree is null) continue; + + foreach (ElementInfo e in AllDescendants(tree)) + { + bool any = e.TooltipRootElementId != 0 || e.TooltipLayoutDid != 0 + || e.TooltipText.HasValue || e.TooltipEnabled + || e.TooltipDelaySeconds.HasValue; + if (any) + withProperties.Add((layoutId, e.Id, e.TooltipText.HasValue)); + } + } + + Console.WriteLine($"[409-DAT] {withProperties.Count} elements author >=1 tooltip property " + + $"({withProperties.Count(f => f.HasText)} with literal StringInfo text)."); + + // #409 investigation landmark (main game UI, Appearance rotate button). + Assert.Contains(withProperties, f => f.LayoutId == 0x21000005u && f.ElementId == 0x100005A4u); + // Floor: the live-DAT sweep found 434 total / 243 with literal text at + // filing time — assert comfortably below both so a future content + // patch that only ADDS tooltip authoring cannot flake this gate. + Assert.True(withProperties.Count >= 400, + $"expected at least 400 tooltip-property-authoring elements, found {withProperties.Count}."); + Assert.True(withProperties.Count(f => f.HasText) >= 200, + $"expected at least 200 elements with literal tooltip text, found {withProperties.Count(f => f.HasText)}."); + } + + private static IEnumerable AllDescendants(ElementInfo root) + { + yield return root; + foreach (ElementInfo child in root.Children) + foreach (ElementInfo descendant in AllDescendants(child)) + yield return descendant; + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs index 4cb2c790..75beed29 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -631,4 +631,57 @@ public sealed class SettingsStoreTests : System.IDisposable Assert.Equal(crafting, store.LoadNamedWindowLayout("crafting", "chat", default)); Assert.Null(store.LoadNamedWindowLayout("missing", "chat", default)); } + + // -- #409: Misc section (Misc.TooltipEnable / Misc.TooltipDelay) -------- + + [Fact] + public void LoadMisc_returns_defaults_when_file_is_missing() + { + var store = new SettingsStore(_tempPath); + Assert.Equal(MiscSettings.Default, store.LoadMisc()); + } + + [Fact] + public void SaveMisc_then_LoadMisc_round_trips_all_fields() + { + var store = new SettingsStore(_tempPath); + var original = new MiscSettings(TooltipEnable: false, TooltipDelaySeconds: 1.5f); + + store.SaveMisc(original); + var loaded = store.LoadMisc(); + + Assert.Equal(original, loaded); + } + + [Fact] + public void LoadMisc_falls_back_per_field_when_keys_missing() + { + File.WriteAllText(_tempPath, """ + { + "version": 3, + "misc": { "tooltipEnable": false } + } + """); + var store = new SettingsStore(_tempPath); + + var loaded = store.LoadMisc(); + + Assert.False(loaded.TooltipEnable); + Assert.Equal(MiscSettings.Default.TooltipDelaySeconds, loaded.TooltipDelaySeconds); + } + + [Fact] + public void SaveMisc_preserves_display_section_and_vice_versa() + { + var store = new SettingsStore(_tempPath); + store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" }); + store.SaveMisc(MiscSettings.Default with { TooltipDelaySeconds = 2f }); + + Assert.Equal("2560x1440", store.LoadDisplay().Resolution); + Assert.Equal(2f, store.LoadMisc().TooltipDelaySeconds); + + store.SaveDisplay(DisplaySettings.Default with { ShowFps = true }); + Assert.Equal(2f, store.LoadMisc().TooltipDelaySeconds); + Assert.True(store.LoadDisplay().ShowFps); + } }