From bce17b3cfbd7c9684b2d639f0fb27baf3f2af705 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 16:51:58 +0200 Subject: [PATCH] =?UTF-8?q?fix=20#430=20#440:=20character=20panel=20?= =?UTF-8?q?=E2=80=94=20tooltips=20can=20mount,=20and=20rows=20refresh=20on?= =?UTF-8?q?=20the=20authoritative=20record?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects the owner found at the CA5 drive, one shared theme: the data was right and the presentation seam was dead. #430 (tooltips): the TS-85 Batch-B port set runtime TooltipText on the runtime-built attribute/vital/skill rows but never gave them a popup locator, and RetailTooltipPresenter.OnTooltipShow refuses any widget with AuthoredTooltipRootElementId == 0 — the tooltip could never mount, on any row, ever. (The register's 'live-verified on the Character tab' was the OPTIONS panel's Character tab — authored elements with authored locators; a different surface.) Rows now carry the shared popup skin 0x10000395/0x21000041 — live-DAT probed as the ONLY locator pair the character layout references, and the same inference UiItemSlot already ships for runtime-built widgets. TS-85's row carries the dated correction. #440 (train row stuck): training a skill debited credits on screen but left the row in the untrained section until the NEXT click — because the sheet-changed subscription only refreshed the captured sheet, and row STRUCTURE rebuilt exclusively in click handlers (the raise 'completed' callback runs after SEND, before the server answers; the owner's second click was simply the first rebuild after the record landed, and ACE's rejection of that second train — 'Failed to train', no credit change — matches the owner's report exactly). The same gap kept CA4's awaiting-ghost from visually releasing. CharacterStatController.Bind now returns the data-changed refresh and MountCharacter invokes it on every authoritative sheet change, mirroring retail's quality-change broadcast (InfoRegion::OnQualityChanged @ 0x004F0EB0). Pinned by DataChangedRefresh_MovesATrainedSkillToItsSection_WithoutAClick and Rows_CarryTheSharedTooltipPopupLocatorAndDescriptionText. Owner visual re-check owed next session (hover-dwell a row; train a skill and watch it move immediately). Full hermetic suite 15,329 passed / 0 failed. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 44 +++++++- .../retail-divergence-register.md | 2 +- .../UI/Layout/CharacterStatController.cs | 24 ++++- src/AcDream.App/UI/RetailUiRuntime.cs | 14 ++- .../UI/Layout/CharacterStatControllerTests.cs | 100 ++++++++++++++++++ 5 files changed, 177 insertions(+), 7 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 131df0a0..6b9fd3fa 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,34 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #440 — CLOSED: Trained skill did not move to its section (and raise buttons never visually un-ghosted) until the next click + +**Status:** CLOSED 2026-08-24, found by the owner during the CA5 drive. +**Component:** character panel / row refresh + +**Symptom (owner):** training an untrained skill consumed credits (visible +immediately) and the confirmation text appeared, but the skill stayed in +the untrained section; clicking Train AGAIN (which the server rejects — +"Failed to train", no credit change) made it move. + +**Root cause:** the panel's sheet-changed subscription only refreshed the +captured `currentSheet` — per-frame text pulls (credits, values) updated, +but the ROW STRUCTURE (section buckets, selection, raise-button states) +rebuilt only on clicks (`RefreshAfterRaise` ran as the raise's `completed` +callback — synchronously after SEND, before the server's answer). The +second click was simply the first row rebuild after the record landed. +The same gap kept CA4's awaiting-ghost from visually releasing. + +**Fix:** `CharacterStatController.Bind` now returns the data-changed +refresh (`RefreshAfterRaise(null)` — rebuild + reselect + re-evaluate +buttons), and `RetailUiRuntime.MountCharacter`'s subscription invokes it on +every authoritative sheet change — mirroring retail's quality-change +broadcast (`InfoRegion::OnQualityChanged @ 0x004F0EB0` → +`ListenToElementMessage @ 0x004EFBE0`). Pinned by +`DataChangedRefresh_MovesATrainedSkillToItsSection_WithoutAClick`. + +--- + ## #439 — Flake candidate: LossySession_FivePercentSeeded_ZeroMessageLoss_Headroom256 fails under full parallel suite load **Status:** OPEN (observation filed; do NOT chase individually per docs/release-gate.md) @@ -419,9 +447,21 @@ immediately (F2 overlay shows guids). --- -## #430 — No tooltips on skills and attributes in the character panel +## #430 — CLOSED: No tooltips on skills and attributes in the character panel -**Status:** OPEN +**Status:** CLOSED 2026-08-24 (found during the CA5 gate follow-up). +Root cause: the TS-85 Batch-B port set runtime `TooltipText` on the +runtime-built rows but never gave them a popup locator, and +`RetailTooltipPresenter.OnTooltipShow` refuses any widget with +`AuthoredTooltipRootElementId == 0` — the tooltip could NEVER mount. +(TS-85's "live-verified on the Character tab" was the OPTIONS panel's +Character tab — authored elements with authored locators.) Fix: rows now +carry the shared popup skin `0x10000395`/`0x21000041` — live-DAT probed as +the ONLY locator pair layout `0x2100002E` references, and the same +inference `UiItemSlot` already ships for runtime-built widgets. Pinned by +`Rows_CarryTheSharedTooltipPopupLocatorAndDescriptionText`. Owner visual +re-check owed at the next session (hover a row, hold the mouse still for +the 0.25 s dwell). **Severity:** LOW (information affordance missing) **Filed:** 2026-08-23 (owner report) **Component:** retail UI / character panel diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 9738eb3c..e0942e31 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -423,7 +423,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | 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); REWRITTEN at the same-day F3 review round; NARROWED again at the same-day live-failure round.** LIVE-FAILURE-ROUND NARROWING: the `m_TTText` READ side is now ported — `RetailTooltipPresenter.ResolveTooltipText` consults `UiElement.GetTooltipText()` (this port's `m_TTText`) BEFORE the authored `P0x49`, exactly as `StartTooltipAtMouse @0x00460DA3`/`@0x00460DDF` orders them, and the `P0x48`-absent fallback to the element's own layout (`@0x00460E7E`) is ported through `UiElement.SourceLayoutDid`. That lit up every acdream surface whose controller ALREADY writes runtime tooltip text (the four Options tabs, Configure Keyboard, the social pages) — live-verified 2026-08-16 on the Character tab. What remains deferred is the WRITE side at the retail `SetTooltip` call sites acdream has no analog for yet, enumerated below. 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; the live-failure round re-measured that set and found every one of the 187 authors BOTH popup-locator ids (`P0x47`+`P0x48`) — i.e. they are runtime-`SetTooltip` targets by construction, waiting only for text. **F12 correction (night-round review, 2026-08-17): this is 17 sites, not 15** — the original tally dropped `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` (mentioned two sentences below as its own closed row) and undercounted by one more besides. The 17 `SetTooltip` call sites, enumerated from the decomp at the live-failure round, split into: PORTED (an acdream controller already writes the text, and the presenter now reads it) — the Options rows `@0x00485E65`/`@0x00484803`/`@0x00487053`, chargen skills `@0x00481981`, the radar `@0x004D9605`; PORTED 2026-08-16 (hover-feedback completion round, docs/ISSUES.md #409/#411): inventory/shortcut item hover `UIElement_UIItem::UpdateTooltip @0x004E1CB0` — `UiItemSlot` now hardcodes the catalog's uniform popup locator (`P0x47=0x10000395`/`P0x48=0x21000041`, live-DAT-confirmed uniform across all 47 UIItem-type catalog prototypes, `TooltipLiveDatTests.UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator`) and a `TooltipTextResolve` delegate wired at every physical-item construction site (inventory, external container, paperdoll — closing the separate `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` row below too, vendor, secure trade, toolbar), backed by the new `ClientObject.GetTooltipDisplayName()` (NAME_APPROPRIATE + the `"%d %s"` stack-count prefix, matching the decomp exactly); and the SmartBox found-object world-hover tooltip `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0` (`@0x004E5D74`/`@0x004E5DFB`) — `RetailTooltipPresenter.UpdateWorldHoverTooltip` ports its `PlayerModule::ShowTooltips`-gated, `GetAppropriateName`-only (no stack prefix — a real, decomp-confirmed asymmetry vs. the item-cell case) trigger — **TIMING CORRECTED at the 2026-08-17 morning gate round (user finding: retail world tooltips "lag"; ours popped instantly): the original "edge-fired (no dwell)" reading was a misread — the notice's immediate `StartTooltipAtMouse @0x004E5DFB` sits inside `if (s_pInstance->m_dragElement != 0)` (`@0x004E5D8E`; `m_dragElement` is a real, distinct PDB field in `acclient.h`'s `UIElementManager`, separate from the `m_pTooltipElement` family), so the immediate mount is DRAG-ONLY; the ordinary hover path merely STAGES the name (`SetTooltip @0x004E5D74` + `|= 0x20`) and the display rides `CheckTooltip @0x0045B6E0`'s mouse-idle dwell (`m_lastMouseMoveTime` stamped on EVERY move `@0x0045e736` + `m_tooltipDelay` 0.25 s `@0x0045f75d`), with found-object changes under an idle mouse swapping the popup same-frame via `SetTooltip`'s own text-change teardown (`@0x004617FF` → `ResetTooltip @0x0045C360` tail-calling `CheckTooltip`) and the 10 s `m_tooltipDuration` expiry requiring a fresh mouse move before re-arming (`SwitchMouseOver(null) @0x0045b7b2`) — all now ported, including the drag-immediate branch**, reusing the SAME popup locator since an exhaustive DAT sweep found `UIElement_SmartBoxWrapper` (class `0x10000030`) has no authored `ElementDesc` anywhere installed (`TooltipLiveDatTests.SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled`) — the popup-skin choice is therefore the best-evidenced inference, not a measured value, and is called out here as such. **BATCH B (2026-08-17) CLOSED the spellcasting and character-panel rows of this list. BATCH C (2026-08-17, Map/House toolbar panel) CLOSES THE LAST REMAINING ITEM: `gmMapUI::AddMapNote @0x004A1C51`'s 53 town-hotspot tooltips are now ported via `MapPageController.BuildTownMarkers` (`src/AcDream.App/UI/Layout/MapPageController.cs`), setting `UiButton.TooltipText` (retail's RUNTIME `m_TTText`/`SetTooltip` mechanism, not the DAT-authored `P0x49` path an earlier same-day cut mistakenly used and which never rendered live during verification); literal town names from `MapLocations.cs` (a verbatim port of `s_rgLocations`), not a DAT string-table lookup, matching `AddMapNote`'s own `StringInfo::SetLiteralValue` call. CORRECTED at the same-day morning gate round (user finding 3 — the retail screenshot's green hover highlight + special-font parchment tooltip): Batch C's "the town-marker template authors no locator of its own" claim was WRONG — the template (`0x100001F0` in `0x21000026`, `MapNoteLiveDatTests`) authors its OWN `P0x47=0x10000398`/`P0x48=0x21000041` (the fourth popup skin, whose incorporated text child `0x10000396` fonts `0x40000015` where the other three skins font `0x40000002`), a zero per-element delay `P0x50=0.0`, `P0x4B` TooltipOn, and `P0x13` RolloverEnabled with PassToChildren `Normal`/`Normal_rollover` states flipping the highlight child `0x100001F1`'s per-state `P0x3B` (the green `0x06004CC9` frame, byte-decoded A=FF R=00 G=FF B=00); the hardcoded shared-skin override was removed (the built marker's authored locator wins) and the rollover highlight + per-state-`P0x3B` + button PassToChildren cascade are now ported (`UiButton.CascadeStateToChildren`, `UiDatElement.TrySetRetailState`'s 0x3B honor). Sub-mechanism (1)'s `SetTooltip`-call-site enumeration is 16 of 17 known sites PORTED — `UIElement_Text::RecalculateTruncation @0x00466F80` (the headline, highest-volume site named at the top of sub-mechanism (1)) remains the ONE open item, exactly as this row's own sub-mechanism (1) text above already scoped it out (its own "Sized as genuinely disproportionate... deferred here" note). The prior "all 15 known sites accounted for" close (F12 correction, night-round review) was wrong twice over: the count is 17, not 15, and RecalculateTruncation was never actually ported — it was always the one deliberately-deferred item, not a closed one.** Batch B audit findings: the endowment icon `@0x004C63A1`, favorite `@0x004C7206`, and submenu `@0x004C67D8` sites turned out to be ALREADY CORRECT — all three are `UiCatalogSlot`-based and the pre-existing `Label`-driven `GetTooltipText` already carried retail's exact text (`SpellCastSubMenu::AddFavorite @0x004C7060`/`UpdateFromPlayerModule @0x004C6570` both build a single-arg `Formatted` PStringBase — plain spell name, no wrapper — for the favorite-bar/submenu case; `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` mirrors the cast button's confirmed `"%s (%hs)"` literal at `data_7b64d8` — item name, then spell name in parens — via the identical two-value narrow/wide prep sequence, one address away in the same function family). The cast button `@0x004C6FE8`/`ClearTooltip @0x004C6AAE` was the one real gap (`UiButton` had no tooltip wiring at all): now ported via `SpellcastingUiController.UpdateCastAvailability`/`ComputeEndowmentCastState`, sourced from `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30`. Fully verified literal text: the no-selection states (`"Select a spell to cast"` @ `data_7b64ec`, `"You have no spells ready to cast"` @ `data_7b6520`) and the complete endowment-item branch (`"USE the %s"` @ `data_7b64c0`, `" on %s"` @ `data_7b6464`, `"You must select a target for the %s"` @ `data_7b6478`; `ItemUses::IsUseable_SelfTarget @0x004fcd30` is exactly `ItemUseability.AllowsSelfTarget`). NOT ported: the endowment branch's incompatible-target sub-state (`"You must select an appropriate\ntarget for the %s"` @ `data_7b6400`, gated by `ItemHolder::TargetCompatibleWithObject @0x00587520` — a ~400-line function with its own chat-message side effects, out of scope for a tooltip batch; a present target is optimistically treated as compatible, same text as the confirmed-compatible case). **CORRECTED at the night-round review (F3/F4, 2026-08-17): the plain-spell branch's wording is NOT unrecoverable — the "genuine `gmNoticeHandler` vtable SLOTS" claim above was itself the artifact. `PStringBase::sprintf`'s second argument at those three call sites is a raw pushed literal (a plain `push 0x7bXXXX; call sprintf`); Binary Ninja's pseudo-C rendering of that operand as `&gmSpellcastingUI::\`vftable'.RecvNotice_XXX` was a spurious symbol match, not the true operand — a direct capstone disassembly of the raw bytes at `0x4c6e48`/`0x4c6ea4`/`0x4c6f18`/`0x4c6f5d` resolves the actual constants: `"CAST %hs"` @0x7b63a4 (untargeted/self-cast at `0x4c6f35`, and targeted+compatible at `0x4c6e57` — both enabled, the latter appending `" on %s"` @0x7b6464 with the target's name), `"You must select an appropriate target for %hs"` @0x7b6348 (targeted+incompatible, disabled), `"You must select a target for %hs"` @0x7b63b8 (no target, disabled); `%hs` is the spell's own name in all four call sites (`CSpellBase::InqName`, the same call `0x5bbee0` throughout). Now ported: `RuntimeSpellCastState.EvaluateCastGate` (the four-state gate) + `SpellcastingUiController.ComputeSpellCastState`. Also corrected the endowment branch's "USE the %s" operand: it was NOT the bare item name (F4) — the vararg to `"USE the %s"`/`"You must select a target for the %s"`/the still-unported incompatible-target string is the SAME composed `"%s (%hs)"` string (item name, spell name) built once at `@0x004c6bb6-ef` from format literal `data_7b64d8`, byte-confirmed by all three sprintf call sites (`0x4c6c7f`/`0x4c6ca4`/`0x4c6d46`) reading the identical `[esp+0x18]` slot — now ported via `SpellcastingUiController.ComposeEndowmentName`.** The character-panel `AttributeInfoRegion @0x004F1617` / `Attribute2ndInfoRegion @0x004F1777` / `SkillInfoRegion @0x004F222F` constructors are now fully ported through a new `UiClickablePanel.TooltipText` (the same settable-string seam as `UiButton.TooltipText`): the six hardcoded attribute descriptions (`SkillSystem::InqAttributeDescription @0x005c8e30`) and three hardcoded, pair-shared vitals descriptions (`SkillSystem::InqAttribute2ndDescription @0x005c8f70`) were byte-decoded from the retail string pool (the pseudo-C dump truncates them with "…"); skill tooltips compose `SkillInfoRegion::GetTooltip @0x004f1fe0`'s exact `"\n" + formula + description` (ported verbatim, including the confirmed lack of any separator between the formula and description text) from the ALREADY-DAT-parsed `DatReaderWriter.Types.SkillBase.Description`/`.Formula` fields (portal `0x0E000004`, the same resource `CharacterSheetProvider.SkillTable` already reads for skill names/costs) rather than hand-transcribed literals — no guessing was needed for the ~30+ skill description strings. The formula-to-text algorithm itself (`SkillSystem::InqSkillFormula @0x005c89b0`, e.g. producing `"( (Strength + Coordination) / 2 )"`) was fully recovered by byte-decoding six short literal fragments (`data_7e7930`/`7e7934`/`7e7940`/`7e7950`/`7e7954`/`797584`) the pseudo-C dump left completely unlabeled — they sit between two `gmSpellcastingUI` vtable declarations and Binary Ninja's type inference never recognized them as strings, so the raw hex had to be read directly as narrow ASCII (confirmed against the function's own directly-visible `" / %u"` and `"(%u x %s)"` literals, which needed no such recovery). Retail's runtime sites also SET the `P0x4B` on-bit themselves (`__bitfield164 |= 0x20`, eight sites) — the port models that as "runtime text present implies tooltip-on", so only the authored-text path consults the authored bit. **(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` (`ResolveTooltipText`'s runtime-then-authored order; `ApplyTooltipText`'s wrap-width literal; `UpdateWorldHoverTooltip`/`TryBuildAndMountPopup` — the world-hover half added 2026-08-16); `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.TooltipText`'s own doc comment carries the same F3 correction); `src/AcDream.App/UI/UiItemSlot.cs` (`TooltipTextResolve`, `GetTooltipText`, the hardcoded popup-locator constants); `src/AcDream.Core/Items/ClientObject.cs` (`GetTooltipDisplayName`); `src/AcDream.App/UI/CursorFeedbackController.cs` (the related #411 found-cursor fix, same round — see that row); Batch B (2026-08-17) additions: `src/AcDream.App/UI/UiPanel.cs` (`UiClickablePanel.TooltipText`/`GetTooltipText`); `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`UpdateCastAvailability`, `ComputeEndowmentCastState`); night-round review (F3/F4, 2026-08-17) additions: `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`ComputeSpellCastState`, `ComposeEndowmentName`); `src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs` (`EvaluateCastGate`, `SpellCastGate`); `src/AcDream.App/Net/RetailSkillFormula.cs` (`AttributeName`, `FormatFormula`, `BuildTooltip`); `src/AcDream.App/UI/Layout/CharacterSheet.cs` (`CharacterSkill.TooltipText`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`BuildLiveCharacterSkills`'s tooltip compose); `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`AttributeDescriptions`, `Attribute2ndDescriptions`, `BuildAttributeRows`/`BuildSkillRows` row wiring) | 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, ...)`); Batch B (2026-08-17): `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30` (cast-button state machine); `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` (endowment-icon `"%s (%hs)"` confirmation); `SpellCastSubMenu::AddFavorite @0x004C7060` / `UpdateFromPlayerModule @0x004C6570` (favorite/submenu plain-name confirmation); `ItemUses::IsUseable_SelfTarget @0x004fcd30`; `AttributeInfoRegion::AttributeInfoRegion @0x004f1530` / `Attribute2ndInfoRegion::Attribute2ndInfoRegion @0x004f1680` / `SkillInfoRegion::SkillInfoRegion @0x004f2140` / `SkillInfoRegion::GetTooltip @0x004f1fe0`; `SkillSystem::InqAttributeName @0x005c8d90` / `InqAttributeDescription @0x005c8e30` / `InqAttribute2ndName @0x005c8ed0` / `InqAttribute2ndDescription @0x005c8f70` / `InqSkillFormula @0x005c89b0` | +| TS-85 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system); REWRITTEN at the same-day F3 review round; NARROWED again at the same-day live-failure round.** LIVE-FAILURE-ROUND NARROWING: the `m_TTText` READ side is now ported — `RetailTooltipPresenter.ResolveTooltipText` consults `UiElement.GetTooltipText()` (this port's `m_TTText`) BEFORE the authored `P0x49`, exactly as `StartTooltipAtMouse @0x00460DA3`/`@0x00460DDF` orders them, and the `P0x48`-absent fallback to the element's own layout (`@0x00460E7E`) is ported through `UiElement.SourceLayoutDid`. That lit up every acdream surface whose controller ALREADY writes runtime tooltip text (the four Options tabs, Configure Keyboard, the social pages) — live-verified 2026-08-16 on the Character tab. What remains deferred is the WRITE side at the retail `SetTooltip` call sites acdream has no analog for yet, enumerated below. 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; the live-failure round re-measured that set and found every one of the 187 authors BOTH popup-locator ids (`P0x47`+`P0x48`) — i.e. they are runtime-`SetTooltip` targets by construction, waiting only for text. **F12 correction (night-round review, 2026-08-17): this is 17 sites, not 15** — the original tally dropped `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` (mentioned two sentences below as its own closed row) and undercounted by one more besides. The 17 `SetTooltip` call sites, enumerated from the decomp at the live-failure round, split into: PORTED (an acdream controller already writes the text, and the presenter now reads it) — the Options rows `@0x00485E65`/`@0x00484803`/`@0x00487053`, chargen skills `@0x00481981`, the radar `@0x004D9605`; PORTED 2026-08-16 (hover-feedback completion round, docs/ISSUES.md #409/#411): inventory/shortcut item hover `UIElement_UIItem::UpdateTooltip @0x004E1CB0` — `UiItemSlot` now hardcodes the catalog's uniform popup locator (`P0x47=0x10000395`/`P0x48=0x21000041`, live-DAT-confirmed uniform across all 47 UIItem-type catalog prototypes, `TooltipLiveDatTests.UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator`) and a `TooltipTextResolve` delegate wired at every physical-item construction site (inventory, external container, paperdoll — closing the separate `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` row below too, vendor, secure trade, toolbar), backed by the new `ClientObject.GetTooltipDisplayName()` (NAME_APPROPRIATE + the `"%d %s"` stack-count prefix, matching the decomp exactly); and the SmartBox found-object world-hover tooltip `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0` (`@0x004E5D74`/`@0x004E5DFB`) — `RetailTooltipPresenter.UpdateWorldHoverTooltip` ports its `PlayerModule::ShowTooltips`-gated, `GetAppropriateName`-only (no stack prefix — a real, decomp-confirmed asymmetry vs. the item-cell case) trigger — **TIMING CORRECTED at the 2026-08-17 morning gate round (user finding: retail world tooltips "lag"; ours popped instantly): the original "edge-fired (no dwell)" reading was a misread — the notice's immediate `StartTooltipAtMouse @0x004E5DFB` sits inside `if (s_pInstance->m_dragElement != 0)` (`@0x004E5D8E`; `m_dragElement` is a real, distinct PDB field in `acclient.h`'s `UIElementManager`, separate from the `m_pTooltipElement` family), so the immediate mount is DRAG-ONLY; the ordinary hover path merely STAGES the name (`SetTooltip @0x004E5D74` + `|= 0x20`) and the display rides `CheckTooltip @0x0045B6E0`'s mouse-idle dwell (`m_lastMouseMoveTime` stamped on EVERY move `@0x0045e736` + `m_tooltipDelay` 0.25 s `@0x0045f75d`), with found-object changes under an idle mouse swapping the popup same-frame via `SetTooltip`'s own text-change teardown (`@0x004617FF` → `ResetTooltip @0x0045C360` tail-calling `CheckTooltip`) and the 10 s `m_tooltipDuration` expiry requiring a fresh mouse move before re-arming (`SwitchMouseOver(null) @0x0045b7b2`) — all now ported, including the drag-immediate branch**, reusing the SAME popup locator since an exhaustive DAT sweep found `UIElement_SmartBoxWrapper` (class `0x10000030`) has no authored `ElementDesc` anywhere installed (`TooltipLiveDatTests.SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled`) — the popup-skin choice is therefore the best-evidenced inference, not a measured value, and is called out here as such. **BATCH B (2026-08-17) CLOSED the spellcasting and character-panel rows of this list. BATCH C (2026-08-17, Map/House toolbar panel) CLOSES THE LAST REMAINING ITEM: `gmMapUI::AddMapNote @0x004A1C51`'s 53 town-hotspot tooltips are now ported via `MapPageController.BuildTownMarkers` (`src/AcDream.App/UI/Layout/MapPageController.cs`), setting `UiButton.TooltipText` (retail's RUNTIME `m_TTText`/`SetTooltip` mechanism, not the DAT-authored `P0x49` path an earlier same-day cut mistakenly used and which never rendered live during verification); literal town names from `MapLocations.cs` (a verbatim port of `s_rgLocations`), not a DAT string-table lookup, matching `AddMapNote`'s own `StringInfo::SetLiteralValue` call. CORRECTED at the same-day morning gate round (user finding 3 — the retail screenshot's green hover highlight + special-font parchment tooltip): Batch C's "the town-marker template authors no locator of its own" claim was WRONG — the template (`0x100001F0` in `0x21000026`, `MapNoteLiveDatTests`) authors its OWN `P0x47=0x10000398`/`P0x48=0x21000041` (the fourth popup skin, whose incorporated text child `0x10000396` fonts `0x40000015` where the other three skins font `0x40000002`), a zero per-element delay `P0x50=0.0`, `P0x4B` TooltipOn, and `P0x13` RolloverEnabled with PassToChildren `Normal`/`Normal_rollover` states flipping the highlight child `0x100001F1`'s per-state `P0x3B` (the green `0x06004CC9` frame, byte-decoded A=FF R=00 G=FF B=00); the hardcoded shared-skin override was removed (the built marker's authored locator wins) and the rollover highlight + per-state-`P0x3B` + button PassToChildren cascade are now ported (`UiButton.CascadeStateToChildren`, `UiDatElement.TrySetRetailState`'s 0x3B honor). Sub-mechanism (1)'s `SetTooltip`-call-site enumeration is 16 of 17 known sites PORTED — `UIElement_Text::RecalculateTruncation @0x00466F80` (the headline, highest-volume site named at the top of sub-mechanism (1)) remains the ONE open item, exactly as this row's own sub-mechanism (1) text above already scoped it out (its own "Sized as genuinely disproportionate... deferred here" note). The prior "all 15 known sites accounted for" close (F12 correction, night-round review) was wrong twice over: the count is 17, not 15, and RecalculateTruncation was never actually ported — it was always the one deliberately-deferred item, not a closed one.** Batch B audit findings: the endowment icon `@0x004C63A1`, favorite `@0x004C7206`, and submenu `@0x004C67D8` sites turned out to be ALREADY CORRECT — all three are `UiCatalogSlot`-based and the pre-existing `Label`-driven `GetTooltipText` already carried retail's exact text (`SpellCastSubMenu::AddFavorite @0x004C7060`/`UpdateFromPlayerModule @0x004C6570` both build a single-arg `Formatted` PStringBase — plain spell name, no wrapper — for the favorite-bar/submenu case; `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` mirrors the cast button's confirmed `"%s (%hs)"` literal at `data_7b64d8` — item name, then spell name in parens — via the identical two-value narrow/wide prep sequence, one address away in the same function family). The cast button `@0x004C6FE8`/`ClearTooltip @0x004C6AAE` was the one real gap (`UiButton` had no tooltip wiring at all): now ported via `SpellcastingUiController.UpdateCastAvailability`/`ComputeEndowmentCastState`, sourced from `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30`. Fully verified literal text: the no-selection states (`"Select a spell to cast"` @ `data_7b64ec`, `"You have no spells ready to cast"` @ `data_7b6520`) and the complete endowment-item branch (`"USE the %s"` @ `data_7b64c0`, `" on %s"` @ `data_7b6464`, `"You must select a target for the %s"` @ `data_7b6478`; `ItemUses::IsUseable_SelfTarget @0x004fcd30` is exactly `ItemUseability.AllowsSelfTarget`). NOT ported: the endowment branch's incompatible-target sub-state (`"You must select an appropriate\ntarget for the %s"` @ `data_7b6400`, gated by `ItemHolder::TargetCompatibleWithObject @0x00587520` — a ~400-line function with its own chat-message side effects, out of scope for a tooltip batch; a present target is optimistically treated as compatible, same text as the confirmed-compatible case). **CORRECTED at the night-round review (F3/F4, 2026-08-17): the plain-spell branch's wording is NOT unrecoverable — the "genuine `gmNoticeHandler` vtable SLOTS" claim above was itself the artifact. `PStringBase::sprintf`'s second argument at those three call sites is a raw pushed literal (a plain `push 0x7bXXXX; call sprintf`); Binary Ninja's pseudo-C rendering of that operand as `&gmSpellcastingUI::\`vftable'.RecvNotice_XXX` was a spurious symbol match, not the true operand — a direct capstone disassembly of the raw bytes at `0x4c6e48`/`0x4c6ea4`/`0x4c6f18`/`0x4c6f5d` resolves the actual constants: `"CAST %hs"` @0x7b63a4 (untargeted/self-cast at `0x4c6f35`, and targeted+compatible at `0x4c6e57` — both enabled, the latter appending `" on %s"` @0x7b6464 with the target's name), `"You must select an appropriate target for %hs"` @0x7b6348 (targeted+incompatible, disabled), `"You must select a target for %hs"` @0x7b63b8 (no target, disabled); `%hs` is the spell's own name in all four call sites (`CSpellBase::InqName`, the same call `0x5bbee0` throughout). Now ported: `RuntimeSpellCastState.EvaluateCastGate` (the four-state gate) + `SpellcastingUiController.ComputeSpellCastState`. Also corrected the endowment branch's "USE the %s" operand: it was NOT the bare item name (F4) — the vararg to `"USE the %s"`/`"You must select a target for the %s"`/the still-unported incompatible-target string is the SAME composed `"%s (%hs)"` string (item name, spell name) built once at `@0x004c6bb6-ef` from format literal `data_7b64d8`, byte-confirmed by all three sprintf call sites (`0x4c6c7f`/`0x4c6ca4`/`0x4c6d46`) reading the identical `[esp+0x18]` slot — now ported via `SpellcastingUiController.ComposeEndowmentName`.** **#430 CORRECTION (2026-08-24): the character-panel port below set the TEXT but omitted the popup LOCATOR on the runtime-built rows, so these tooltips never mounted until the CA5-adjacent fix gave rows the shared 0x10000395/0x21000041 skin (live-DAT probed; UiItemSlot precedent).** The character-panel `AttributeInfoRegion @0x004F1617` / `Attribute2ndInfoRegion @0x004F1777` / `SkillInfoRegion @0x004F222F` constructors are now fully ported through a new `UiClickablePanel.TooltipText` (the same settable-string seam as `UiButton.TooltipText`): the six hardcoded attribute descriptions (`SkillSystem::InqAttributeDescription @0x005c8e30`) and three hardcoded, pair-shared vitals descriptions (`SkillSystem::InqAttribute2ndDescription @0x005c8f70`) were byte-decoded from the retail string pool (the pseudo-C dump truncates them with "…"); skill tooltips compose `SkillInfoRegion::GetTooltip @0x004f1fe0`'s exact `"\n" + formula + description` (ported verbatim, including the confirmed lack of any separator between the formula and description text) from the ALREADY-DAT-parsed `DatReaderWriter.Types.SkillBase.Description`/`.Formula` fields (portal `0x0E000004`, the same resource `CharacterSheetProvider.SkillTable` already reads for skill names/costs) rather than hand-transcribed literals — no guessing was needed for the ~30+ skill description strings. The formula-to-text algorithm itself (`SkillSystem::InqSkillFormula @0x005c89b0`, e.g. producing `"( (Strength + Coordination) / 2 )"`) was fully recovered by byte-decoding six short literal fragments (`data_7e7930`/`7e7934`/`7e7940`/`7e7950`/`7e7954`/`797584`) the pseudo-C dump left completely unlabeled — they sit between two `gmSpellcastingUI` vtable declarations and Binary Ninja's type inference never recognized them as strings, so the raw hex had to be read directly as narrow ASCII (confirmed against the function's own directly-visible `" / %u"` and `"(%u x %s)"` literals, which needed no such recovery). Retail's runtime sites also SET the `P0x4B` on-bit themselves (`__bitfield164 |= 0x20`, eight sites) — the port models that as "runtime text present implies tooltip-on", so only the authored-text path consults the authored bit. **(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` (`ResolveTooltipText`'s runtime-then-authored order; `ApplyTooltipText`'s wrap-width literal; `UpdateWorldHoverTooltip`/`TryBuildAndMountPopup` — the world-hover half added 2026-08-16); `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.TooltipText`'s own doc comment carries the same F3 correction); `src/AcDream.App/UI/UiItemSlot.cs` (`TooltipTextResolve`, `GetTooltipText`, the hardcoded popup-locator constants); `src/AcDream.Core/Items/ClientObject.cs` (`GetTooltipDisplayName`); `src/AcDream.App/UI/CursorFeedbackController.cs` (the related #411 found-cursor fix, same round — see that row); Batch B (2026-08-17) additions: `src/AcDream.App/UI/UiPanel.cs` (`UiClickablePanel.TooltipText`/`GetTooltipText`); `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`UpdateCastAvailability`, `ComputeEndowmentCastState`); night-round review (F3/F4, 2026-08-17) additions: `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`ComputeSpellCastState`, `ComposeEndowmentName`); `src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs` (`EvaluateCastGate`, `SpellCastGate`); `src/AcDream.App/Net/RetailSkillFormula.cs` (`AttributeName`, `FormatFormula`, `BuildTooltip`); `src/AcDream.App/UI/Layout/CharacterSheet.cs` (`CharacterSkill.TooltipText`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`BuildLiveCharacterSkills`'s tooltip compose); `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`AttributeDescriptions`, `Attribute2ndDescriptions`, `BuildAttributeRows`/`BuildSkillRows` row wiring) | 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, ...)`); Batch B (2026-08-17): `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30` (cast-button state machine); `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` (endowment-icon `"%s (%hs)"` confirmation); `SpellCastSubMenu::AddFavorite @0x004C7060` / `UpdateFromPlayerModule @0x004C6570` (favorite/submenu plain-name confirmation); `ItemUses::IsUseable_SelfTarget @0x004fcd30`; `AttributeInfoRegion::AttributeInfoRegion @0x004f1530` / `Attribute2ndInfoRegion::Attribute2ndInfoRegion @0x004f1680` / `SkillInfoRegion::SkillInfoRegion @0x004f2140` / `SkillInfoRegion::GetTooltip @0x004f1fe0`; `SkillSystem::InqAttributeName @0x005c8d90` / `InqAttributeDescription @0x005c8e30` / `InqAttribute2ndName @0x005c8ed0` / `InqAttribute2ndDescription @0x005c8f70` / `InqSkillFormula @0x005c89b0` | | 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/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index d7d430d8..d29dfdc4 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -247,7 +247,17 @@ public static class CharacterStatController /// icon rendering is not asserted. /// /// - public static void Bind( + /// + /// #431-CA5 gate fix (2026-08-24): the data-changed refresh. Retail's + /// panel refreshes rows from qualities on every authoritative + /// quality-change element message (InfoRegion::OnQualityChanged @ + /// 0x004F0EB0 → the 0x10000004 broadcast); before this, rows only + /// rebuilt on CLICKS, so a trained skill stayed in the untrained + /// section (and CA4's awaiting-ghost never visually released) until the + /// next click. The caller invokes this from the sheet-changed + /// subscription. + /// + public static Action Bind( ImportedLayout layout, Func data, UiDatFont? datFont = null, @@ -601,6 +611,8 @@ public static class CharacterStatController RefreshActiveRaiseButtons(); } + + return () => RefreshAfterRaise(null); } private static UiScrollbar? PrepareSkillScrollbar( @@ -1502,6 +1514,16 @@ public static class CharacterStatController { var row = new UiClickablePanel { + // #430: runtime-built rows author no P0x47/P0x48 of their own, and + // OnTooltipShow refuses a widget without a popup locator — so the + // Batch-B TooltipText never mounted. Use the shared popup skin, + // the ONLY locator pair the character layout (0x2100002E) itself + // references (live-DAT probed 2026-08-24) and the same inference + // UiItemSlot already ships for runtime-built widgets. + AuthoredTooltipRootElementId = + RetailTooltipPresenter.SharedPopupSkinRootElementId, + AuthoredTooltipLayoutDid = + RetailTooltipPresenter.SharedPopupSkinLayoutDid, Left = left, Top = top, Width = width, diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 62b8c251..c259f2c3 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -4017,9 +4017,7 @@ public sealed class RetailUiRuntime : IDisposable } CharacterSheetProvider provider = _bindings.Character.Provider; CharacterSheet currentSheet = provider.BuildSheet(); - _characterSheetSubscription = provider.SubscribeChanged( - () => currentSheet = provider.BuildSheet()); - CharacterStatController.Bind( + Action refreshRows = CharacterStatController.Bind( layout, () => currentSheet, _bindings.Assets.DefaultFont, @@ -4027,6 +4025,16 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Assets.ResolveSprite, (request, completed) => HandleCharacterRaise(provider, request, completed), () => CloseWindow(WindowNames.Character)); + // #431-CA5 gate fix: rebuild the ROWS on every authoritative sheet + // change, not only on clicks — a train's skill record must move the + // row to the trained section (and un-ghost the raise controls) the + // moment it lands, mirroring retail's quality-change broadcast + // (InfoRegion::OnQualityChanged @ 0x004F0EB0). + _characterSheetSubscription = provider.SubscribeChanged(() => + { + currentSheet = provider.BuildSheet(); + refreshRows(); + }); RetailWindowHandle handle = RetailWindowFrame.Mount( Host.Root, layout.Root, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 430452f6..a62590b0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -883,6 +883,106 @@ public class CharacterStatControllerTests Assert.Equal(Vector4.One, healingTexts[2].LinesProvider()[0].Color); } + [Fact] + public void DataChangedRefresh_MovesATrainedSkillToItsSection_WithoutAClick() + { + // #431 CA5-gate bug: a TrainSkill's authoritative record updated the + // sheet, but rows only rebuilt on CLICKS — the skill sat in the + // untrained section (credits visibly debited) until the user clicked + // train AGAIN. Bind now returns the data-changed refresh; invoking + // it must re-bucket the rows, mirroring retail's quality-change + // broadcast (InfoRegion::OnQualityChanged @ 0x004F0EB0). + var list = new UiPanel { Width = 300 }; + var layout = Fake((CharacterStatController.ListBoxId, list)); + + CharacterSheet sheet = SampleData.SampleCharacter(); + Action refresh = CharacterStatController.Bind(layout, () => sheet, + spriteResolve: id => (id, 16, 16)); + + ClickTab(layout, left: 92f); + var untrained = sheet.Skills.First( + s => s.AdvancementClass == CharacterSkillAdvancementClass.Untrained); + int trainedBefore = sheet.Skills.Count( + s => s.AdvancementClass == CharacterSkillAdvancementClass.Trained); + Assert.Equal(trainedBefore, RowsUnderHeader(list, "Trained Skills")); + + // The server record lands: the skill is now Trained. CharacterSheet + // is a plain class (init-only), so rebuild it like the provider does. + CharacterSheet updated = SampleData.SampleCharacter(); + updated.GetType(); // (no-op guard for clarity) + sheet = new CharacterSheet + { + Name = sheet.Name, + UnassignedXp = sheet.UnassignedXp, + SkillCredits = sheet.SkillCredits, + Skills = sheet.Skills + .Select(s => s.Id == untrained.Id + ? s with { AdvancementClass = CharacterSkillAdvancementClass.Trained } + : s) + .ToList(), + }; + + refresh(); + + Assert.Equal(trainedBefore + 1, RowsUnderHeader(list, "Trained Skills")); + } + + [Fact] + public void Rows_CarryTheSharedTooltipPopupLocatorAndDescriptionText() + { + // #430: runtime-built rows author no popup locator, and the tooltip + // presenter refuses a widget without one — so the row descriptions + // could never mount. Rows must carry the shared popup skin (the only + // locator pair the character layout itself references; the same + // inference UiItemSlot ships) alongside their runtime text. + var list = new UiPanel { Width = 300 }; + var layout = Fake((CharacterStatController.ListBoxId, list)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter, + spriteResolve: id => (id, 16, 16)); + + var attributeRows = list.Children.OfType().ToList(); + Assert.NotEmpty(attributeRows); + Assert.All(attributeRows, row => + { + Assert.Equal( + RetailTooltipPresenter.SharedPopupSkinRootElementId, + row.AuthoredTooltipRootElementId); + Assert.Equal( + RetailTooltipPresenter.SharedPopupSkinLayoutDid, + row.AuthoredTooltipLayoutDid); + }); + // The six attribute rows carry the retail descriptions. + Assert.All( + attributeRows.Take(6), + row => Assert.False(string.IsNullOrEmpty(row.GetTooltipText()))); + } + + private static int RowsUnderHeader(UiElement list, string header) + { + // The rows live inside a nested content container; find the + // container whose ordered children interleave section headers and + // rows, then count rows between the named header and the next one. + static bool IsHeader(UiElement c, string text) => + c is UiPanel and not UiClickablePanel + && c.Children.OfType().Any( + x => x.LinesProvider()[0].Text == text); + UiElement? container = new[] { list }.Concat(Descendants(list)) + .FirstOrDefault(e => e.Children.Any(c => IsHeader(c, header))); + Assert.NotNull(container); + var children = container!.Children.ToList(); + int start = children.FindIndex(c => IsHeader(c, header)); + Assert.True(start >= 0, $"header '{header}' not found"); + int count = 0; + for (int i = start + 1; i < children.Count; i++) + { + if (children[i] is UiClickablePanel) count++; + else if (children[i] is UiPanel p + && p.Children.OfType().Any()) break; + } + return count; + } + [Fact] public void SkillsTab_ClickThenAttributesTab_RestoresAttributeRows() {