diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 0bf9af4a..59a66e52 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -107,10 +107,10 @@ 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:** CODE-COMPLETE 2026-08-16 (this commit) — pending the user's connected visual gate (see the gate note at the bottom of this entry).
+**Status:** CODE-COMPLETE 2026-08-16, review-fix round F1-F11 landed same day (`fix(ui): #409 tooltip review fix round`) — 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
+**2026-08-16 re-derivation + port.** 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
@@ -119,11 +119,40 @@ popup LayoutDesc (`P0x48`) to instantiate as the popup's root**
`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
+build) found **430 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
+the other 187 have no literal text and show nothing — see register row
+TS-85 for the honest scope of what's missing there), superseding the
+original "~253" estimate.
+
+**2026-08-16 review-fix round (F1-F11), same day.** An Opus review of the
+port above returned architectural PASS-with-findings / retail-fidelity FAIL
+with twelve findings; F1-F11 landed in one fix commit (F12 was info-only).
+Highlights: the popup now offsets +32px from the mouse on both axes
+(`StartTooltip @0x00459700`, was landing flush at the cursor); the dwell
+timer now anchors to mouse-IDLE like retail's `m_lastMouseMoveTime`, not
+hover-enter, and is gated off while the mouse has capture
+(`CheckTooltip @0x0045B6E0`); `ReleaseCapture` no longer tears down an
+already-shown tooltip's fired latch (it only restarts the idle deadline,
+matching `ReleaseMouseCapture @0x0045D2B0`); the popup no longer mounts an
+empty bevel artifact when its text child fails to resolve
+(`StartTooltip @0x0045DE90`'s `DynamicCast` gate); the popup and its text
+child now null their anchor policy before resizing, mirroring the sibling
+`RetailMessageDialogView`; the auto-resize now applies retail's
+`ResizeTo @0x00463C30` max/min width/height clamps; and a session reset now
+also clears `UiRoot`'s own hover/tooltip-fired latch (not just the
+presenter's popup element) so a post-reset hover re-shows immediately. The
+review's biggest correction was to the ORIGINAL port's own framing of what
+it deferred: "191 elements rely on retail's dynamic `InqProperty(0x49)`
+override" was FALSE — retail's own base `InqProperty` reads the same
+authored property bags this port already reads, so most no-literal-text
+elements show nothing in retail too. Register row TS-85 is rewritten (not
+just re-counted) with the real gap: the `m_TTText`/`SetTooltip` runtime-text
+family, headed by the `P0xD0` truncated-text auto-tooltip
+(`UIElement_Text::RecalculateTruncation @0x00466F80`) — sized and found
+disproportionate to port in the same round (it needs a per-line-position
+truncation model this port's `UiText` doesn't have), so it stays deferred,
+honestly described. 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`.
@@ -152,11 +181,20 @@ 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.
+**Deferred (register TS-85, rewritten at the F3 review round):** the
+`m_TTText`/`SetTooltip` runtime-text family headed by the `P0xD0`
+truncated-text auto-tooltip (187 of 430 tooltip-property-authoring elements
+have no literal text and show nothing; an unmeasured subset of those would
+show retail's truncation tooltip instead) and the `P0x3D` per-element
+wrap-width override (zero elements author one today). **Deferred (register
+AD-106, two honest additions at the F10 review round):** the topmost-z-order
+mechanism is a sibling-with-later-reraise adaptation, not retail's literal
+separate presentation layer — the guarantee is versus dialogs/screens ONLY
+(the overlay popup layer and the drag ghost still paint above regardless),
+and the per-tick `BringToFront` re-raise chain now has four rungs
+(`CharacterManagementUiController`, `CharacterCreationUiController`,
+`RetailDialogFactory`, `RetailTooltipPresenter`) — bounded and enumerable
+today, but a design smell worth flagging.
**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
@@ -2893,6 +2931,18 @@ two subsequent full runs. Same FILE as #302 but a DIFFERENT test — filed
separately per the never-conflate rule.
**Filed:** 2026-08-08, observed during #344's suite runs.
+**2026-08-16 recurrence (Campaign #409 tooltip review-fix round).** The Opus
+reviewer of `a377b9bf` hit this same assertion failing TWICE under
+full-solution load, unrelated to any #409 tooltip code; passes standalone
+26/26. Joining the same running known-flake set the CC7 gate row already
+names (`docs/plans/2026-08-15-character-creation-campaign.md`, CC7 row) for
+Core.Net `NakEmissionTests.LossSoak_...` / Content `DecodedTextureCacheTests`
+/ App `SocialPanelLiveMountProbeTests.ProbeLiveMountShapes` /
+`RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate` —
+a full-solution gate hitting exactly one of these five allocation/timing-
+sensitive tests, with a clean standalone or immediate-rerun pass, is this
+known class, not a new regression.
+
---
## #344 — Mid-teleport crash: world-frame owners disagree during a long portal into a dungeon
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 22945b52..5bf087a9 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -106,7 +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-106 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system).** Retail's tooltip popup is a separate always-on-top presentation surface — `UIElementManager::StartTooltip @0x00459700` positions and latches it into `m_pTooltipElement`, drawn independently of the ordinary `UIElement` sibling tree (the SAME class of separation the AP-229 register row already establishes for retail's dialogs vs acdream's flat sibling list under one `Host.Root`). `RetailTooltipPresenter` instead mounts the popup as an ordinary `UiRoot` child sibling (`_host.AddChild(root)`) and keeps it topmost by calling `BringToFront` from its OWN `Tick()`, which `RetailUiRuntime.Tick` schedules AFTER both `RetailDialogFactory.Tick()` and `Host.Tick()` in the same frame — guaranteeing the tooltip wins whatever z-order race those two just ran, every frame, regardless of which dialog/screen last called its own `BringToFront`. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`Tick`, `OnTooltipShow`'s `AddChild`/`BringToFront`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`Tick`'s three-call ordering, `MountTooltipPresenter`) | Reproduces the one observable invariant a user can check (tooltips always draw on top of dialogs and screens) without porting retail's literal separate-layer architecture (no second draw pass, no dedicated presentation root) — the SAME tradeoff AP-229 already accepted for dialogs, extended one layer further. The ordering is enforced structurally (three sequential calls in one method), not by convention, so it cannot silently regress from an unrelated edit reordering unrelated `Tick` calls elsewhere. **F10 correction (2026-08-16 review round), two honest additions:** (1) the guarantee is versus dialogs/screens ONLY — `UiRoot.DrawCore`'s own second pass (`ctx.BeginOverlayLayer(); DrawOverlays(ctx); DrawDragGhost(ctx);`) routes open dropdown/menu popups and the drag ghost to a renderer overlay layer that paints over the WHOLE sibling tree unconditionally, so both still paint above a shown tooltip regardless of any `BringToFront` ordering — no z-order fix in the sibling tree can reach that layer. (2) counting the full chain by its own actual participants (not just the three calls local to `RetailUiRuntime.Tick`'s tooltip-adjacent lines), the per-tick `BringToFront` ratchet has FOUR rungs in frame order: `CharacterManagementUiController.Tick`, `CharacterCreationUiController.Tick` (both named in `RetailDialogFactory`'s own GF-15 doc comment as the screens it re-asserts over), `RetailDialogFactory.Tick`, then `RetailTooltipPresenter.Tick`. Four independent per-tick self-reraises stacked by tick ORDER is a design smell — a correct z-order model would need at most one authoritative comparison, not N racing assertions — but is bounded and enumerable in practice (no unbounded surface list, the order is fixed source, not runtime-discovered) so it is left as observed rather than restructured this round. | A FUTURE always-on-top UI surface that calls its own unconditional per-tick `BringToFront` AFTER `TooltipPresenter?.Tick()` in `RetailUiRuntime.Tick`'s ordering could bury a currently-shown tooltip — the exact failure class AP-229 already named for dialogs-vs-screens, now with four layers instead of two. | `UIElementManager::StartTooltip @0x00459700` (`m_pTooltipElement` ownership); AP-229's own dialog/screen precedent |
| AD-73 | Filed 2026-08-11 at the Campaign OP OP2 rework (fix round after a double REJECT). `UiTabPanel` (dat Type 8, formerly `UiTabControl`) does NOT perform retail's automatic tab-table wiring / default-page activation at construction. Retail `UIElement_Panel::SetupTabPageHash @0x0046C2E0` + `::Update @0x0046BD00` unconditionally activate the authored default page for ANY instance that carries a tab table. `UiTabPanel` instead stays DORMANT — no click binding, no page-visibility flip, no tab Open/Closed write — until a controller explicitly calls `ActivateTabBehavior()`. | `src/AcDream.App/UI/UiTabPanel.cs` (`ActivateTabBehavior`); factory site `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (Type-8 arm) | Four already-shipped Type-8 hosts author a tab table today — character sheet root `0x10000227`, spellbook root `0x100002A8`, and vendor `0x100000B8` already implement this exact switching in their own C# controllers (`CharacterStatController`/`SpellbookWindowController`/`VendorUiController`); activating `UiTabPanel`'s own copy unconditionally would double-drive the same page-visibility/tab-state writes those controllers already own. Combat `0x100000A2` has no controller at all and is INTENTIONALLY left inert (its 8 stance pages have no switching UI yet) rather than have `UiTabPanel` silently take ownership. Only newly-authored hosts opt in (Options panel, Campaign OP slice OP3+; Configure Keyboard, OP8). This is what let the unconditional Type-8 factory mapping become safe after the OP2 REJECT (`docs/research/2026-08-11-op2-review-blast.md`, `docs/research/2026-08-11-op2-review-mechanism.md`). | A future panel that authors a Type-8 tab table but never gets a controller call to `ActivateTabBehavior()` renders with every tab button at its authored default (Closed) and every page slot at its default `Visible=true` — i.e. every page overlapping, no single active page — instead of retail's exactly-one-visible-page behavior. This is silent unless the diagnostic `UnresolvedEntries`/`BehaviorActive` surface is checked; a controller author who forgets the activation call will see a visually broken tab host, not a crash. | `UIElement_Panel::SetupTabPageHash @0x0046C2E0`; `UIElement_Panel::Update @0x0046BD00`; `UIElement_Panel::OpenTab @0x0046BE20`. ADDENDUM (2026-08-11, re-review closure): `UiTemplateListBox` additionally reports `ConsumesDatChildren = true` where the pre-rework fallback did not — inert against every shipped layout because no Type-5 element in any of the 32 fixtures authors children (now conformance-PINNED in `OP2ReworkBlastRadiusConformanceTests`, so an authored child appearing in a future DAT regeneration fails the build instead of silently vanishing) |
| ~~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) |
@@ -406,11 +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) — 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)
+## 4. Temporary stopgap (TS) — 50 active rows (TS-85 filed 2026-08-16 at #409 (client-wide retail tooltip system), REWRITTEN same-day at the F3 review round — two unported tooltip sub-mechanisms: (1) the `m_TTText`/`SetTooltip` runtime-text family headed by the `P0xD0` truncated-text auto-tooltip (187 of 430 live-DAT-probed tooltip-property-authoring elements have no literal StringInfo text and show nothing; the ORIGINAL filing's "dynamic InqProperty(0x49) override" framing was FALSE — retail's own base `InqProperty` reads the same authored bags this port already does, so most of those 187 show nothing in retail too — see the row's own full text for the correction), 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-85 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system); REWRITTEN at the same-day F3 review round — the original filing's item (1) framing was FALSE and is corrected below, not just re-counted.** Two sub-mechanisms of retail's tooltip system are unported. **(1) The `m_TTText`/`SetTooltip` runtime-text family (headed by the `P0xD0` truncated-text auto-tooltip):** the ORIGINAL filing argued this port's gap was "retail's dynamic `InqProperty(0x49)` override" — that framing is false. `UIElement::InqProperty @0x004638D0`, the BASE implementation every element uses unless its own class overrides the virtual, reads exactly the same authored property bags (`m_instanceProperties`, `m_curStateDesc`, `m_desc`) this port's `ElementReader` already walks generically — so an element with no literal `P0x49` gets NOTHING from retail's own default `InqProperty` either. The REAL second text source is the element's cached `m_TTText` field, set ONLY by the explicit, non-dat `UIElement::SetTooltip` call (`UIElement::StartTooltipAtMouse @0x00460D70` prefers `m_TTText` over the `InqProperty` fallback whenever it is non-empty). `SetTooltip` has 15+ known game-code call sites (Options rows `@0x00485E65`, chargen `@0x00481981`, the paperdoll endowment icon `@0x004C63A1`, the spellcast button `@0x004C6FE8`/`@0x004C6AAE`, and more), headed by the highest-volume one: `UIElement_Text::RecalculateTruncation @0x00466F80`, gated on authored `P0xD0` — an overflowing single/wrapped line calls `SetTooltip(this, ownText) @0x00467064` + sets enable bit 5 `@0x00467076`; a line that now fits calls `ClearTooltip @0x00467064`/clears the bit `@0x00466ff9`. `RecalculateTruncation`'s own truncation-POSITION computation (the rest of the function, `@0x004670a1` onward) walks a `GlyphList` per-line-position model (`FindCompleteLineFromY`/`FindPosFromLineAndPixels`/`FindPixelsFromPos`) this port's `UiText` has no equivalent of — `UiText` clips visually via a scissor rect (`DrawClippedText`'s `PushClip`) with no tracked "does this line overflow" state at all, so porting the auto-tooltip trigger requires building that state first. Sized as genuinely disproportionate for a single fix-round commit alongside F1-F2/F4-F11 and deferred here rather than shipped as a partial/unverified stub. A live-DAT sweep found 187 of the 430 elements authoring at least one tooltip-trigger property have NO literal `P0x49` `StringInfo` text — `RetailTooltipPresenter.OnTooltipShow` requires non-null `AuthoredTooltipText`, so these show nothing; an unmeasured subset of the 187 would show retail's truncation auto-tooltip instead of 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); `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.TooltipText`'s own doc comment carries the same F3 correction) | 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.). F9 correction: "243 with literal text" is not automatically "243 showable" — `RetailTooltipPresenter.OnTooltipShow`'s real gate is the FULL conjunction of `TooltipEnabled` (P0x4B) AND non-null text (P0x49) AND both popup-locator ids (P0x47/P0x48) — so this was measured, not assumed: `TooltipLiveDatTests.ClientWideSweep_FindsKnownLandmarksAndAFloorCount`'s `Showable` column finds the intersection is exactly 243, i.e. every element authoring literal text also authors the other three properties together (they are evidently authored as one group in practice). 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 `P0xD0`-truncated text element, a game-code `SetTooltip` caller, 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 AND separately auditing which of the 187 no-literal-text elements would actually truncate at their authored width. | `UIElement::InqProperty @0x004638D0` (base authored-bag read, NOT a dynamic override); `UIElement::StartTooltipAtMouse @0x00460D70` (`m_TTText`-vs-`InqProperty` preference order); `UIElement_Text::RecalculateTruncation @0x00466F80` (`P0xD0` gate, `SetTooltip`/`ClearTooltip` sites); `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/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs
index 072bfaa3..2cf514f1 100644
--- a/src/AcDream.App/UI/Layout/ElementReader.cs
+++ b/src/AcDream.App/UI/Layout/ElementReader.cs
@@ -298,6 +298,28 @@ public sealed class ElementInfo
/// design; resolution through DatStringResolver happens in
/// DatWidgetFactory, mirroring the existing 0x17 caption
/// pattern exactly.
+ ///
+ ///
+ /// F3 correction (2026-08-16 review round): the vtable dispatch through
+ /// InqProperty(0x49, ...) is NOT itself a "dynamic override"
+ /// mechanism for a plain element. UIElement::InqProperty
+ /// @0x004638D0 — the BASE implementation every element uses unless
+ /// its own class overrides the virtual — reads exactly the same
+ /// authored property bags this reader already walks
+ /// (m_instanceProperties, m_curStateDesc, m_desc).
+ /// So an element with no literal P0x49 gets NOTHING back from
+ /// retail's own default InqProperty either — the earlier framing
+ /// ("191 elements rely on retail's dynamic InqProperty(0x49) override")
+ /// overstated what that fallback does for the general case. The REAL
+ /// second text source is m_TTText via the explicit
+ /// UIElement::SetTooltip call family (~15+ game-code call
+ /// sites), headed by the truncated-text auto-tooltip
+ /// (UIElement_Text::RecalculateTruncation @0x00466F80, gated on
+ /// authored P0xD0: an overflowing line calls
+ /// SetTooltip(this, ownText) @0x00467064; a line that now fits
+ /// calls ClearTooltip @0x00467076). See register row TS-85 for
+ /// the honest scope of what remains unported.
+ ///
///
public UiStringInfoValue? TooltipText;
@@ -355,6 +377,35 @@ public sealed class ElementInfo
///
public float? TooltipDelaySeconds;
+ ///
+ /// #409 F8: retail's per-element auto-resize WIDTH clamps — dat
+ /// properties 0x3D (max, IntegerBaseProperty) and
+ /// 0x3F (min). UIElement::ResizeTo @0x00463C30 reads both
+ /// via GetAttribute_Int (@0x00463c9c/@0x00463cba)
+ /// and clamps a caller-requested width to [min, max] BEFORE
+ /// applying it —
+ /// is the one caller in this port that grows an element via that path.
+ /// Same property/direction mapping RetailWindowFrame.ResolveConstraint
+ /// already uses for window frames (0x3D=>MaxWidth, 0x3F=>MinWidth), ported
+ /// independently here since that helper only runs at window-mount time
+ /// off a caller-supplied , not generically for
+ /// every imported element. Null = no authored override (retail's
+ /// GetAttribute_Int returning false leaves the requested value
+ /// unclamped on that side).
+ ///
+ public int? MaxWidth;
+ public int? MinWidth;
+
+ ///
+ /// #409 F8: retail's per-element auto-resize HEIGHT clamps — dat
+ /// properties 0x3C (max) and 0x3E (min), read by the same
+ /// UIElement::ResizeTo @0x00463C30 (@0x00463c64/
+ /// @0x00463c80). See 's doc comment for the
+ /// full citation; this pair is the height side of the same clamp.
+ ///
+ public int? MaxHeight;
+ public int? MinHeight;
+
///
/// 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.
@@ -718,6 +769,19 @@ public static class ElementReader
{
info.TooltipDelaySeconds = tooltipDelay;
}
+
+ // #409 F8: the four ResizeTo auto-resize clamps (0x3C/0x3D/0x3E/0x3F
+ // — see MaxWidth's/MaxHeight's own doc comments for the decomp
+ // anchor). Read generically for every element, same shape as the
+ // margins above; only RetailTooltipPresenter consumes them today.
+ if (info.TryGetEffectiveInteger(0x3Du, out int maxWidth))
+ info.MaxWidth = maxWidth;
+ if (info.TryGetEffectiveInteger(0x3Fu, out int minWidth))
+ info.MinWidth = minWidth;
+ if (info.TryGetEffectiveInteger(0x3Cu, out int maxHeight))
+ info.MaxHeight = maxHeight;
+ if (info.TryGetEffectiveInteger(0x3Eu, out int minHeight))
+ info.MinHeight = minHeight;
}
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 f149a8c0..18a9e1b6 100644
--- a/src/AcDream.App/UI/Layout/LayoutImporter.cs
+++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs
@@ -133,6 +133,12 @@ public static class LayoutImporter
w.AuthoredTooltipTextChildElementId = info.TooltipTextChildElementId;
w.AuthoredTooltipDelaySeconds = info.TooltipDelaySeconds;
+ // #409 F8: the four ResizeTo auto-resize clamps (0x3C/0x3D/0x3E/0x3F).
+ w.AuthoredResizeMaxWidth = info.MaxWidth;
+ w.AuthoredResizeMinWidth = info.MinWidth;
+ w.AuthoredResizeMaxHeight = info.MaxHeight;
+ w.AuthoredResizeMinHeight = info.MinHeight;
+
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
index 18a8c539..afa91066 100644
--- a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs
+++ b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs
@@ -29,8 +29,9 @@ namespace AcDream.App.UI.Layout;
/// 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.
+/// positions the popup's top-left at the mouse cursor OFFSET by 32px on
+/// BOTH axes (@0x00459739/@0x00459747), clamped to the
+/// display.
///
///
///
@@ -40,14 +41,17 @@ namespace AcDream.App.UI.Layout;
/// (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
+/// to exactly 0x10000396, confirming the decomp reading. 430 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.
+/// P0x49 text this port shows; the other 187 have no literal text —
+/// see register row TS-85, corrected at the 2026-08-16 review round: retail's
+/// OWN default InqProperty(0x49) reads the SAME authored bags this
+/// port already reads, so most of those 187 show nothing in retail either;
+/// the real gap is the separate m_TTText/SetTooltip family
+/// headed by the P0xD0 truncated-text auto-tooltip). 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
@@ -76,8 +80,10 @@ public sealed class RetailTooltipPresenter : IDisposable
///
/// The client-side Misc.TooltipEnable preference
- /// (UIElementManager::Init @0x0045EE10 registers it as a
- /// UserPreferences.ini key via UIPreferences::AttachPreference,
+ /// (UIElementManager::Init @0x0045EE10 registers the LIVE binding
+ /// via UserPreferences::RegisterPreference @0x0045EE92 — see
+ /// 's
+ /// own doc comment for the full two-mechanism citation split —
/// 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-
@@ -120,8 +126,31 @@ public sealed class RetailTooltipPresenter : IDisposable
UiElement? textChild = root.AuthoredTooltipTextChildElementId != 0u
? layout.FindElement(root.AuthoredTooltipTextChildElementId)
: null;
- if (textChild is UiText text)
- ApplyTooltipText(root, text, widget.AuthoredTooltipText!);
+ // F5: retail requires GetChildRecursive to resolve AND DynamicCast
+ // to UIElement_Text (type 0xc) before it ever calls the positioning/
+ // show half of StartTooltip (UIElementManager::StartTooltip
+ // @0x0045DE90, @0x0045df59/@0x0045df65 — @0x0045df6f gates the rest
+ // of the function on a non-null cast result). Every popup skin this
+ // port's live-DAT sweep found resolves cleanly, so this only guards
+ // a malformed/future LayoutDesc — but mounting anyway there would
+ // show an empty, unsized 30x30 bevel artifact instead of retail's
+ // silent no-op.
+ if (textChild is not UiText text)
+ return;
+
+ // F4: null the per-frame anchor recompute on BOTH the popup root and
+ // its text child before resizing, the same shape the sibling
+ // RetailMessageDialogView uses for its popup/message pair
+ // (RetailMessageDialogView.cs:49-53) — ApplyAnchor otherwise
+ // recomputes margins against the popup's authored edge mode every
+ // frame, which would silently fight the resize below if that mode
+ // isn't the assumed "no stretch" default.
+ root.LayoutPolicy = null;
+ root.Anchors = AnchorEdges.None;
+ text.LayoutPolicy = null;
+ text.Anchors = AnchorEdges.None;
+
+ ApplyTooltipText(root, text, widget.AuthoredTooltipText!);
SetClickThroughRecursive(root);
PositionAtMouse(root);
@@ -162,11 +191,31 @@ public sealed class RetailTooltipPresenter : IDisposable
/// 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.
+ /// structural no-op here.
+ ///
+ ///
+ /// F8 additions (2026-08-16 review round): (1) InqSizewMargins
+ /// returns a size with the text element's own margins already added
+ /// back in (@0x004697a4/@0x004697bc: += m_margR +
+ /// m_margL / += m_margD + m_margU) before StartTooltip
+ /// diffs it against the AUTHORED width/height — zeroing
+ /// here (same shape the sibling
+ /// RetailMessageDialogView uses for its own message child,
+ /// RetailMessageDialogView.cs:53) keeps our margin-free measured
+ /// size exactly comparable, without needing to separately track and
+ /// re-add an inset this port doesn't otherwise model for the tooltip
+ /// text child. (2) the grown size is clamped through the SAME
+ /// authored-override clamp UIElement::ResizeTo @0x00463C30
+ /// applies to every resize (P0x3C max-height/P0x3E
+ /// min-height/P0x3D max-width/P0x3F min-width, max
+ /// checked before min on each axis) before the value is ever assigned —
+ /// this port had been assigning the grown size directly, unclamped.
+ ///
private void ApplyTooltipText(UiElement root, UiText text, string tooltipText)
{
float authoredTextWidth = text.Width;
float authoredTextHeight = text.Height;
+ text.Padding = 0f;
Func measure = text.DatFont is { } datFont
? datFont.MeasureWidth
@@ -184,21 +233,46 @@ public sealed class RetailTooltipPresenter : IDisposable
float lineHeight = text.DatFont?.LineHeight ?? text.Font?.LineHeight ?? 14f;
float measuredHeight = wrapped.Count * lineHeight;
- root.Width += measuredWidth - authoredTextWidth;
- root.Height += measuredHeight - authoredTextHeight;
+ float requestedWidth = root.Width + (measuredWidth - authoredTextWidth);
+ float requestedHeight = root.Height + (measuredHeight - authoredTextHeight);
+
+ // ResizeTo's own clamp order: max first, then min, independently
+ // per axis (@0x00463c64/@0x00463c80 for height, @0x00463c9c/
+ // @0x00463cba for width).
+ if (root.AuthoredResizeMaxHeight is { } maxHeight && requestedHeight > maxHeight)
+ requestedHeight = maxHeight;
+ if (root.AuthoredResizeMinHeight is { } minHeight && requestedHeight < minHeight)
+ requestedHeight = minHeight;
+ if (root.AuthoredResizeMaxWidth is { } maxWidth && requestedWidth > maxWidth)
+ requestedWidth = maxWidth;
+ if (root.AuthoredResizeMinWidth is { } minWidth && requestedWidth < minWidth)
+ requestedWidth = minWidth;
+
+ root.Width = requestedWidth;
+ root.Height = requestedHeight;
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).
+ /// popup's top-left lands at the mouse cursor OFFSET by
+ /// on BOTH axes (@0x00459739: mouseX + 0x20; @0x00459747:
+ /// mouseY + 0x20), clamped so it never crosses the right/bottom
+ /// display edge (nor goes negative, mirroring retail's own
+ /// max(0, ...) defensive clamp, @0x00459753/@0x00459769).
+ /// Retail's own clamp ORDER (max-vs-display computed against the raw
+ /// offset mouse position, not the already-floored one — @0x00459784-
+ /// @0x0045979c) can go negative when the popup is bigger than the
+ /// display; cannot express that (its min
+ /// must be max) — immaterial for every popup this port
+ /// builds (fixed 30x30 skins plus a bounded auto-grow), left unmatched.
+ private const float MouseOffsetPx = 32f;
+
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));
+ float x = Math.Clamp(_host.MouseX + MouseOffsetPx, 0, MathF.Max(0f, canvas.X - root.Width));
+ float y = Math.Clamp(_host.MouseY + MouseOffsetPx, 0, MathF.Max(0f, canvas.Y - root.Height));
root.Left = x;
root.Top = y;
}
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 996b9317..00a03e20 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -632,7 +632,7 @@ public sealed class RetailUiRuntime : IDisposable
public InventoryController? InventoryPanelController { get; private set; }
public RetailDialogFactory? DialogFactory { get; private set; }
/// #409: client-wide retail hover-tooltip presenter. Constructed
- /// alongside — see ConfigureDialogFactory.
+ /// alongside — see MountTooltipPresenter.
public RetailTooltipPresenter? TooltipPresenter { get; private set; }
public ExternalContainerController? ExternalContainerController { get; private set; }
public VendorUiController? VendorController { get; private set; }
@@ -989,6 +989,13 @@ public sealed class RetailUiRuntime : IDisposable
CharacterManagementController?.ResetSession();
DialogFactory?.Reset();
TooltipPresenter?.HideCurrent();
+ // #409 F11: HideCurrent only tears down the presenter's own
+ // popup element — it leaves UiRoot's own hover-dwell/tooltip-
+ // shown latch untouched. Without this, a reconnect while the
+ // mouse never left the hovered widget leaves _tooltipFired
+ // latched true and the tooltip cannot re-arm until either the
+ // 10 s auto-hide timeout or a hover-target change.
+ Host.Root.ResetTooltipTracking();
}
finally
{
diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs
index fb99830f..bd1545c5 100644
--- a/src/AcDream.App/UI/UiElement.cs
+++ b/src/AcDream.App/UI/UiElement.cs
@@ -124,6 +124,24 @@ public abstract class UiElement
///
public float? AuthoredTooltipDelaySeconds { get; internal set; }
+ ///
+ /// #409 F8: mirrors ElementInfo.MaxWidth/MinWidth (dat
+ /// properties 0x3D/0x3F) — the UIElement::ResizeTo
+ /// @0x00463C30 auto-resize width clamp. Null = no authored
+ /// override on that side.
+ ///
+ public int? AuthoredResizeMaxWidth { get; internal set; }
+ public int? AuthoredResizeMinWidth { get; internal set; }
+
+ ///
+ /// #409 F8: mirrors ElementInfo.MaxHeight/MinHeight (dat
+ /// properties 0x3C/0x3E) — the height side of the same
+ /// UIElement::ResizeTo @0x00463C30 clamp. Null = no authored
+ /// override on that side.
+ ///
+ public int? AuthoredResizeMaxHeight { get; internal set; }
+ public int? AuthoredResizeMinHeight { 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 971d66d5..21b61bc1 100644
--- a/src/AcDream.App/UI/UiRoot.cs
+++ b/src/AcDream.App/UI/UiRoot.cs
@@ -523,7 +523,11 @@ public sealed class UiRoot : UiElement
// 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
+ // #409 F6: retail's arm branch is gated `m_pElementWithMouseCapture
+ // == 0` (UIElementManager::CheckTooltip @0x0045B6E0, @0x0045b715) —
+ // a widget hovered before a drag/resize/scrollbar-thumb capture
+ // began must not pop a tooltip mid-gesture.
+ if (_hoverWidget is not null && !_tooltipFired && Captured is null
&& _nowMs - _hoverStartedMs >= EffectiveTooltipDelayMs(_hoverWidget))
{
var e = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.Tooltip);
@@ -1112,9 +1116,14 @@ public sealed class UiRoot : UiElement
{
UiElement? previous = Captured;
Captured = null;
- // Retail restarts the tooltip idle deadline when capture is released.
+ // #409 F7: retail restarts the tooltip idle deadline when capture is
+ // released (ReleaseMouseCapture @0x0045D2B0, @0x0045d2ce/@0x0045d2da
+ // — m_lastMouseMoveTime only). It does NOT touch m_bHoverStarted
+ // (our _tooltipFired): that field means "hover started" (a tooltip
+ // is showing), not "capture is active", and a mouse-up while a
+ // tooltip is already up must leave it up, not clear-then-re-fire it
+ // 250ms later without ever going through TooltipHide.
_hoverStartedMs = _nowMs;
- _tooltipFired = false;
NotifyCaptureLost(previous);
if (previous is not null)
PointerCaptureChanged?.Invoke(previous, null);
@@ -1271,6 +1280,28 @@ public sealed class UiRoot : UiElement
// ── Hover / tooltip ─────────────────────────────────────────────────
+ ///
+ /// #409 F11: forgets the in-progress hover-dwell/tooltip-shown latch
+ /// without touching itself or firing any
+ /// hover-leave event.
+ /// calls this alongside
+ /// (which only tears down the presenter's own popup element) so a
+ /// reconnect that hides an in-flight tooltip also re-arms the dwell
+ /// timer — otherwise a mouse that never left the hovered widget across
+ /// the reset would leave _tooltipFired latched true and the
+ /// widget would not show a tooltip again until either the 10 s
+ /// auto-hide timeout elapses or the hover target changes. No literal
+ /// retail counterpart (retail's own session teardown is a full
+ /// UIElementManager re-construction, not a partial reset), but
+ /// the effect matches: a fresh dwell deadline, same as
+ /// 's own idle-timestamp restart.
+ ///
+ public void ResetTooltipTracking()
+ {
+ _hoverStartedMs = _nowMs;
+ _tooltipFired = false;
+ }
+
private void UpdateHover(int x, int y)
{
// An open popup claims hover first (#374) — its item highlight must
@@ -1282,6 +1313,19 @@ public sealed class UiRoot : UiElement
{
if (w?.ReceivesHoverMouseMove == true)
DispatchMouseMove(w, x, y);
+ // #409 F2: retail's dwell timer anchors to mouse-IDLE, not
+ // hover-enter — UIElementManager::MouseMoveHandler @0x0045E710
+ // stamps m_lastMouseMoveTime on EVERY move (@0x0045e729/
+ // @0x0045e736), unconditionally, before any hit-testing; the
+ // arm check in CheckTooltip @0x0045B6E0 (@0x0045b747) compares
+ // against that timestamp. Jiggling the mouse within the SAME
+ // widget must keep re-arming the deadline, not just entering
+ // it once. Guarded by !_tooltipFired — retail's m_bHoverStarted
+ // (our _tooltipFired) keeps a SHOWN tooltip from being
+ // re-armed by further moves; ReleaseCapture already ports the
+ // same field's other writer (@0x0045D2B0).
+ if (!_tooltipFired)
+ _hoverStartedMs = _nowMs;
return;
}
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/MiscSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/MiscSettings.cs
index bfb092e7..b1621cae 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/MiscSettings.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/MiscSettings.cs
@@ -2,19 +2,29 @@ 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.
+/// UserPreferences.ini keys, Misc_TooltipDelay/
+/// Misc_TooltipEnable. F9 correction (2026-08-16 review round): two
+/// DISTINCT retail mechanisms touch these, not one —
+/// UIElementManager::Init @0x0045EE10 calls
+/// UserPreferences::RegisterPreference (@0x0045EE41 for
+/// Misc_TooltipDelay, @0x0045EE92 for
+/// Misc_TooltipEnable) to bind the LIVE runtime variables
+/// (UIElementManager::m_tooltipDelay/m_tooltipEnable) to their
+/// named preference with a change callback; a SEPARATE preference-metadata
+/// table builder (containing @0x00403811-@0x0040384E) calls
+/// UIPreferences::AttachPreference (declaring the name/kind/help-text
+/// hash) and, for the float, UIPreferences::SetPreferenceRange
+/// (@0x00403821, the [0,10] clamp). 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 are attached to the global
+/// preference registry, but gmConfigUI::InitOptions's 27 authored
+/// rows — a DIFFERENT, later function that decides which attached
+/// preferences actually get a widget — 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,
diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs
index dc24b28c..9d0a7c11 100644
--- a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs
@@ -265,4 +265,173 @@ public sealed class RetailTooltipPresenterTests
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}");
}
+
+ // ── 2026-08-16 review fix round (F1-F11) pins ──────────────────────
+
+ [Fact]
+ public void F1_PositionsAtMouse_OffsetBy32PixelsOnBothAxes()
+ {
+ // UIElementManager::StartTooltip @0x00459700 adds a 32px (0x20)
+ // offset on BOTH axes before clamping (@0x00459739/@0x00459747) —
+ // the popup must not land flush at the cursor.
+ var (root, _, _) = CreateHarness();
+ var target = AddFullyAuthoredTarget(root);
+
+ 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.Equal(142f, popup.Left); // 110 + 32
+ Assert.Equal(142f, popup.Top); // 110 + 32
+ }
+
+ [Fact]
+ public void F2_MouseMoveWithinTheSameWidget_ResetsTheDwellClock()
+ {
+ // Retail's dwell timer anchors to mouse-IDLE, not hover-enter —
+ // UIElementManager::MouseMoveHandler @0x0045E710 stamps
+ // m_lastMouseMoveTime on EVERY move (@0x0045e729/@0x0045e736);
+ // CheckTooltip @0x0045B6E0 (@0x0045b747) compares against that.
+ // Jiggling the mouse within the SAME widget must keep pushing the
+ // deadline out, not leave the original hover-enter time in place.
+ var (root, _, requests) = CreateHarness();
+ AddFullyAuthoredTarget(root);
+
+ root.OnMouseMove(110, 110); // hover starts at nowMs=0
+ root.Tick(0.016, 0);
+ root.Tick(0.016, root.TooltipDelayMs - 10); // nowMs=240, 10ms short
+ Assert.Empty(requests);
+
+ // Jiggle within the same widget at nowMs=240 — resets the deadline.
+ root.OnMouseMove(111, 111);
+ root.Tick(0.016, root.TooltipDelayMs); // nowMs=250 — the OLD deadline
+ Assert.Empty(requests); // must NOT have fired yet if the clock reset
+
+ root.Tick(0.016, (root.TooltipDelayMs - 10) + root.TooltipDelayMs); // nowMs=490 (240+250)
+ Assert.Single(requests);
+ }
+
+ [Fact]
+ public void F5_TextChildResolvesToSomethingOtherThanUiText_NeverMounts()
+ {
+ // Retail requires GetChildRecursive to resolve AND DynamicCast to
+ // UIElement_Text (type 0xc) before StartTooltip @0x0045DE90's
+ // positioning/show half ever runs (@0x0045df59/@0x0045df65/
+ // @0x0045df6f) — a text-child id that resolves to something else
+ // must produce NO popup at all, not an empty unsized bevel.
+ var rootInfo = new ElementInfo
+ {
+ Id = PopupRootId, Type = 3, X = 0, Y = 0, Width = 30, Height = 30,
+ TooltipTextChildElementId = TextChildId,
+ };
+ var notTextInfo = new ElementInfo
+ {
+ Id = TextChildId, Type = 3, X = 2, Y = 2, Width = 26, Height = 26, // type 3, NOT 12 -> UiDatElement
+ };
+ var root = new UiRoot { Width = 800f, Height = 600f };
+ var requests = new List<(uint, uint)>();
+ var presenter = new RetailTooltipPresenter(root, (layoutDid, rootElementId) =>
+ {
+ requests.Add((layoutDid, rootElementId));
+ return LayoutImporter.BuildFromInfos(rootInfo, [notTextInfo], _ => (0u, 0, 0), null);
+ });
+ AddFullyAuthoredTarget(root);
+ int childrenBefore = root.Children.Count;
+
+ root.OnMouseMove(110, 110);
+ root.Tick(0.016, 0);
+ root.Tick(0.016, root.TooltipDelayMs);
+
+ Assert.Single(requests); // the layout WAS built...
+ Assert.Equal(childrenBefore, root.Children.Count); // ...but never mounted
+ }
+
+ [Fact]
+ public void F6_MouseCaptureElsewhere_SuppressesTheDwellArm_UntilCaptureReleases()
+ {
+ // CheckTooltip @0x0045B6E0's arm branch is gated
+ // `m_pElementWithMouseCapture == 0` (@0x0045b715) — a widget
+ // hovered before a drag/resize/scrollbar-thumb capture began must
+ // not pop a tooltip mid-gesture.
+ var (root, _, requests) = CreateHarness();
+ AddFullyAuthoredTarget(root);
+ var other = new HoverTarget { Left = 400, Top = 400, Width = 40, Height = 20 };
+ root.AddChild(other);
+
+ root.OnMouseMove(110, 110);
+ root.Tick(0.016, 0);
+
+ root.SetCapture(other);
+ root.Tick(0.016, root.TooltipDelayMs + 50);
+ Assert.Empty(requests); // captured elsewhere — must not arm
+
+ root.ReleaseCapture(); // restarts the idle deadline at nowMs=300
+ root.Tick(0.016, root.TooltipDelayMs + 50 + root.TooltipDelayMs + 50);
+ Assert.Single(requests); // now arms normally
+ }
+
+ [Fact]
+ public void F7_ReleaseCaptureWhileATooltipIsAlreadyShown_DoesNotHideAndReshowIt()
+ {
+ // ReleaseMouseCapture @0x0045D2B0 touches ONLY the idle timestamp
+ // (m_lastMouseMoveTime), never m_bHoverStarted — a mouse-up while a
+ // tooltip is already up must leave it up, not clear-then-re-fire it
+ // 250ms later without ever going through TooltipHide.
+ var (root, _, requests) = CreateHarness();
+ var target = AddFullyAuthoredTarget(root);
+ int childrenBeforeShow = root.Children.Count;
+ bool hideFired = false;
+ root.TooltipHide += _ => hideFired = true;
+
+ root.OnMouseMove(110, 110);
+ root.Tick(0.016, 0);
+ root.Tick(0.016, root.TooltipDelayMs);
+ Assert.Equal(childrenBeforeShow + 1, root.Children.Count); // tooltip showing
+ Assert.Single(requests);
+
+ root.SetCapture(target);
+ root.ReleaseCapture(); // restarts the idle deadline at nowMs=TooltipDelayMs
+
+ // Advance PAST where the buggy old code's re-armed deadline would
+ // land (TooltipDelayMs + TooltipDelayMs) — the bug reset
+ // _tooltipFired=false here, so the dwell-arm branch would refire
+ // OnTooltipShow (RemovePopup + rebuild) a full delay later, even
+ // though nothing about the hover ever changed.
+ root.Tick(0.016, root.TooltipDelayMs + root.TooltipDelayMs + 10);
+ Assert.False(hideFired, "tooltip must not hide on a mere capture release");
+ Assert.Equal(childrenBeforeShow + 1, root.Children.Count); // still showing
+ Assert.Single(requests); // still exactly one OnTooltipShow call, not re-fired
+ }
+
+ [Fact]
+ public void F8_AutoResizeAppliesTheAuthoredMaxWidthClamp()
+ {
+ // UIElement::ResizeTo @0x00463C30 clamps the auto-grown size
+ // against P0x3C/0x3D/0x3E/0x3F BEFORE assigning it.
+ var rootInfo = new ElementInfo
+ {
+ Id = PopupRootId, Type = 3, X = 0, Y = 0, Width = 30, Height = 30,
+ TooltipTextChildElementId = TextChildId,
+ MaxWidth = 40, // P0x3D
+ };
+ var textInfo = new ElementInfo
+ {
+ Id = TextChildId, Type = 12, X = 2, Y = 2, Width = 26, Height = 26,
+ };
+ var root = new UiRoot { Width = 800f, Height = 600f };
+ var presenter = new RetailTooltipPresenter(root, (_, _) =>
+ LayoutImporter.BuildFromInfos(rootInfo, [textInfo], _ => (0u, 0, 0), null));
+ var target = AddFullyAuthoredTarget(
+ root,
+ text: "This is a much longer tooltip than the authored placeholder, long "
+ + "enough to want to grow well past forty pixels wide.");
+
+ 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.Equal(40f, popup.Width); // clamped, not the larger natural measured size
+ }
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/TooltipLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/TooltipLiveDatTests.cs
index 5cb83c72..b15876bd 100644
--- a/tests/AcDream.App.Tests/UI/Layout/TooltipLiveDatTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/TooltipLiveDatTests.cs
@@ -127,7 +127,7 @@ public sealed class TooltipLiveDatTests
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
- var withProperties = new List<(uint LayoutId, uint ElementId, bool HasText)>();
+ var withProperties = new List<(uint LayoutId, uint ElementId, bool HasText, bool Showable)>();
foreach (uint layoutId in dats.GetAllIdsOfType())
{
ElementInfo? tree;
@@ -141,22 +141,37 @@ public sealed class TooltipLiveDatTests
|| e.TooltipText.HasValue || e.TooltipEnabled
|| e.TooltipDelaySeconds.HasValue;
if (any)
- withProperties.Add((layoutId, e.Id, e.TooltipText.HasValue));
+ {
+ // F9 (2026-08-16 review round): "with literal text" alone
+ // over-counts what RetailTooltipPresenter.OnTooltipShow
+ // actually shows — that gate is a FULL AND across
+ // TooltipEnabled (P0x4B) AND non-null text (P0x49) AND
+ // both popup-locator ids (P0x47/P0x48), not just text
+ // presence. Measure the real intersection instead of
+ // assuming "243 with text" == "243 showable".
+ bool showable = e.TooltipEnabled && e.TooltipText.HasValue
+ && e.TooltipLayoutDid != 0 && e.TooltipRootElementId != 0;
+ withProperties.Add((layoutId, e.Id, e.TooltipText.HasValue, showable));
+ }
}
}
Console.WriteLine($"[409-DAT] {withProperties.Count} elements author >=1 tooltip property "
- + $"({withProperties.Count(f => f.HasText)} with literal StringInfo text).");
+ + $"({withProperties.Count(f => f.HasText)} with literal StringInfo text, "
+ + $"{withProperties.Count(f => f.Showable)} pass the full OnTooltipShow gate).");
// #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.
+ // Floor: the live-DAT sweep found 430 total / 243 with literal text /
+ // 243 fully showable (2026-08-16, both post-F9 measurements) —
+ // assert comfortably below all three 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)}.");
+ Assert.True(withProperties.Count(f => f.Showable) >= 200,
+ $"expected at least 200 fully showable elements, found {withProperties.Count(f => f.Showable)}.");
}
private static IEnumerable AllDescendants(ElementInfo root)