diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 65528fcf..543ed119 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,12 +24,14 @@ 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. -## #413 — House tab shows no content (owned-house display, six Display* line builders unported) +## #413 — House tab shows no content (owned-house display, Display* line builders unported) **Status:** NARROWED 2026-08-17 (House-tab ownership-text closer session); item 2's not-expired branch closed same-day at the night-round review fix -round (F8). Items 1 and 2 below are DONE; item 3 (six owned-house-only -builders) remains OPEN and is the entire remaining scope. +round (F8); **the houseless case CORRECTED same-day at the morning gate +round (user finding 2 — see the correction note inside item 2)**. Items 1 +and 2 below are DONE; item 3 (owned-house-only builder content) remains +OPEN and is the entire remaining scope. **What's shipped (this session, on top of Batch C's mount + parser groundwork).** @@ -71,23 +73,34 @@ groundwork).** locale, full date+time" intent — filed as register row IA-23, an approximation, not a gap). - **Corrects a framing this session's task brief carried in from outside - this doc**: the brief described retail as ALSO showing a preceding line - "You do not currently own a house." No such string, in that or any close - wording, exists anywhere in the 2013 EoR `acclient_2013_pseudo_c.txt` - dump, in any `gmHouseUI`/`gmMapUI` method, in ACE's `GameEventHouseStatus` - writer, or in the live-DAT House ListBox/page (re-confirmed empty this - session — `MapHousePanelSlotProbeTests`, zero rows, zero sibling - content). The closest strings found are UNRELATED generic command-error - chat text ("You do not own a house!", WeenieError `0x45E`/`0x45F`; "You - must own a house to use this command.", WeenieError `0x47F`), routed - through the GENERIC WeenieError-to-chat dispatcher, never through - `gmHouseUI`. This ISSUES entry's OWN pre-existing "Acceptance test once - closed" line below (written before this session, by the same research - pass that produced the recon doc) already named the single-line - "You may buy another house immediately." text as the target — this - session's mechanism derivation independently reached the same - conclusion and is now the shipped, tested behavior. + **CORRECTION (2026-08-17 morning gate round, user finding 2 — the + paragraph this replaces was WRONG):** the earlier session had refuted + the preceding line "You do not currently own a house." with the claim + that "no such string exists anywhere in the 2013 EoR + `acclient_2013_pseudo_c.txt` dump" — but the user's retail screenshot + (their live client, houseless character) shows exactly that line ABOVE + "You may buy another house immediately.", and the user's side-by-side + retail reports are axioms. The re-derivation found the string DOES + exist in the 2013 binary, at `data_7ab688`: it is + `gmHouseUI::DisplayBuyPayment @0x004a2b30`'s HOUSELESS branch. Two + compounding misreads hid it: (a) `DisplayBuyPayment` was mislabeled + houseless-silent — its `m_pHouseData` gate only selects WHICH text + (`jne 0x4a2b63`); the ListBox emit (`AddItemFromTemplateList` + + `SetTextWithFont`, `@0x004a2b80` onward) runs in BOTH branches; and + (b) the pseudo-C dump renders both `push ` operands as + spurious `&gmHouseUI::vftable'.RecvNotice_*` symbol matches (the same + BN artifact class TS-85/F3 documented), so a TEXT sweep of the dump + finds no house strings there — the raw string pool has them + (byte-decoded via capstone this round: houseless `@0x004a2b57` `push + 0x7ab688` = "You do not currently own a house."; owned `@0x004a2b63` + `push 0x7ab65c` = "The purchase price for this dwelling is:\n" + + `HousePaymentList::ComposeText`, still item-3 scope). The morning-gate + task brief's alternate hypothesis (a DAT string-table id) was also + checked and is NOT the mechanism — it is a plain exe string-pool + literal, same as every other gmHouseUI line. `RuntimeHouseState. + Recompute` now renders the houseless case as retail's exact TWO lines + in builder order: "You do not currently own a house." then the + purchase-time line; `RuntimeHouseStateTests` updated to pin both. **Also fixed in the same pass: `HousePageController.Bind` never wired `UiTemplateListBox.TemplateResolver`.** Without it, @@ -101,10 +114,11 @@ groundwork).** **What remains open — item 3, the entire surviving scope:** -3. **The six owned-house-only `Display*` line builders** (`DisplayBuyPayment`, - `DisplayRentPayment`, `DisplayBuyTime`, `DisplayRentTimes`, - `DisplayLocation`, `DisplayWarningText` — all called from - `gmHouseUI::DisplayHouseData @0x004a3380`). Each is dozens-to-a-few-hundred +3. **The owned-house-only `Display*` line content** (`DisplayBuyPayment`'s + OWNED branch — its houseless branch shipped at the 2026-08-17 morning + gate correction above — plus `DisplayRentPayment`, `DisplayBuyTime`, + `DisplayRentTimes`, `DisplayLocation`, `DisplayWarningText`, all called + from `gmHouseUI::DisplayHouseData @0x004a3380`). Each is dozens-to-a-few-hundred lines of heavily FPU/string-mangled BN pseudo-C (PStringBase sprintf chains, `HousePaymentList` iteration, `IsPaidInFull`/ `ConstructRentWarningMessage`-style formatting) — genuinely sized as its @@ -630,11 +644,28 @@ called out separately. own `Label`. 2. **World-object hover tooltip (NPCs, players, signs, chests, portals) — - SHIPPED.** NOT the UI-element dwell-timer path — retail's mechanism is + SHIPPED; TIMING CORRECTED at the 2026-08-17 morning gate round.** Retail's + mechanism is `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0`, fed every frame by `FindObject @0x004E5430`/`Global_Loop @0x004E5620` - using the current mouse position regardless of input focus. It fires - IMMEDIATELY (no dwell wait) on the found-object id CHANGING, gated by the + using the current mouse position regardless of input focus. **The + original "fires IMMEDIATELY (no dwell wait)" reading here was a misread + — the user's side-by-side retail comparison (retail world tooltips "lag"; + ours popped instantly) sent the derivation back, and the notice's + immediate `StartTooltipAtMouse @0x004E5DFB` turned out to sit inside + `if (UIElementManager::s_pInstance->m_dragElement != 0)` (`@0x004E5D8E` + — a real, distinct PDB field, drag-and-drop only). The ordinary hover + path STAGES the name (`SetTooltip @0x004E5D74` + the `|= 0x20` TooltipOn + bit) and the display rides the SAME `UIElementManager::CheckTooltip + @0x0045B6E0` mouse-idle dwell as UI-element tooltips: 250 ms + (`m_tooltipDelay @0x0045f75d`) since the last mouse move + (`m_lastMouseMoveTime`, stamped on EVERY move `@0x0045e736`), so a + continuously moving mouse shows nothing and the popup appears only once + the cursor rests. Found-object changes under an IDLE mouse swap the + popup the same frame (`SetTooltip`'s text-change teardown `@0x004617FF` + → `ResetTooltip @0x0045C360` tail-calls `CheckTooltip`); the 10 s + duration expiry requires a fresh mouse move before re-arming + (`SwitchMouseOver(null) @0x0045b7b2`).** Gated by the `PlayerModule::ShowTooltips` character option (`CharacterOptionId.ShowTooltips` — already modeled in `CharacterOptionTable`, default true), with text `ACCWeenieObject::GetObjectName(id, NAME_APPROPRIATE, 0)` — the SAME name @@ -695,8 +726,9 @@ called out separately. graceful close per the usual rules): hover an inventory item — a name tooltip should appear (with a count prefix for a stack) AND the mouse pointer should swap to its "found" variant; hover an NPC/creature — a name -tooltip should appear immediately (no perceptible delay) if "Show Tooltips" -is on; hover a sign/chest/portal similarly. +tooltip should appear after the 250 ms idle dwell (mouse must REST; +sweeping continuously shows nothing — corrected 2026-08-17) if "Show +Tooltips" is on; hover a sign/chest/portal similarly. **2026-08-16/17 overnight hover/UI round, Batch A bug 1 — CLOSED same round: world tooltips never cleared, stacking dozens of popups.** The world-object @@ -840,6 +872,18 @@ already proven live) and delete `CharacterCreationUiController`'s own narrow `HideAuthoredInvisibleElements`/AP-230 in the same commit; (4) run a full-client visual matrix, not just chargen. +**2026-08-17 morning gate boundary note (finding 3):** the +STATE-TRANSITION half of retail's `P0x3B` application is now shipped for +NAMED states only — `UiDatElement.TrySetRetailState` honors a `0x3B` +authored inside a named state descriptor (the map town-hotspot highlight's +`Normal`/`Normal_rollover` flip, the first live per-state visibility +machine found). The unnamed-DirectState case is EXPLICITLY excluded there +and remains this issue's scope: honoring it in `TrySetRetailState` would +un-gate this whole item through `LayoutImporter.BuildWidget`'s +post-children state reapply (measured: 10 combat-layout elements went +un-hit-testable, breaking the spell-favorite drag tests, before the +scoping was added). + ## #407 — Windowed resolution offering starves on RDP/virtual displays (video-mode gating) **Status:** DONE (`e601a496`, 2026-08-16 — same gate round, user-directed immediate fix) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index e4fc67c5..b1d2689d 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -412,7 +412,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 edge-fired (no dwell), `PlayerModule::ShowTooltips`-gated, `GetAppropriateName`-only (no stack prefix — a real, decomp-confirmed asymmetry vs. the item-cell case) trigger, 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) plus a hardcoded `AuthoredTooltipRootElementId`/`AuthoredTooltipLayoutDid` pointing at the same shared popup skin `UiItemSlot` already uses (`0x10000395`/`0x21000041`), since the town-marker template authors no locator of its own; verified live post-fix (hovering Aerlinthe Island renders its tooltip correctly) — 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. 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`.** 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/MapPageController.cs b/src/AcDream.App/UI/Layout/MapPageController.cs index 07eaea6d..11f3ac6d 100644 --- a/src/AcDream.App/UI/Layout/MapPageController.cs +++ b/src/AcDream.App/UI/Layout/MapPageController.cs @@ -66,7 +66,15 @@ public sealed class MapPageController // ElementInfos only exist inside the full panel-slot resolve tree — // a cold ImportInfos(hostLayout, iconId) re-import CANNOT find them // (register row AD-108's live-DAT finding; see Bind's own doc). - Func IconBuilder); + Func IconBuilder, + // The template's resolved ElementInfo (production: the SAME cached + // RowTemplateResolver the TemplateResolver above builds from — + // RowTemplateResolver.ResolveInfo). BuildTownMarkers needs the + // AUTHORED shape the built UiButton widget does not carry: the + // button-swallowed rollover-highlight child subtree (2026-08-17 + // morning gate finding 3). Null (fixtures) = markers get no + // highlight child, everything else unchanged. + Func? TemplateInfoResolver = null); private readonly UiElement? _dateTimeText; private readonly UiElement? _map; @@ -259,6 +267,28 @@ public sealed class MapPageController /// referenced LayoutDesc/element) leaves the map usable without /// hotspots rather than failing the whole page — matches retail's own /// null-guarded if (eax_10 != 0) before the loop. + /// + /// + /// 2026-08-17 morning gate finding 3 (live-DAT re-derivation, + /// MapHousePanelSlotProbeTests): the hotspot template + /// (0x100001F0 in 0x21000026) is NOT "a plain 10x10 + /// hotspot dot" — its DirectState authors the full retail note + /// behavior: its OWN tooltip-popup locator + /// P0x47=0x10000398/P0x48=0x21000041 (the map-note popup + /// skin, whose incorporated text child 0x10000396 fonts + /// 0x40000015 — the other three skins font 0x40000002), + /// a zero per-element tooltip delay (P0x50=0.0 — town tooltips + /// fire the instant the dwell arms), P0x4B TooltipOn, and + /// P0x13 RolloverEnabled. Its Normal/Normal_rollover + /// states are PassToChildren descriptors driving the highlight child + /// 0x100001F1 (base 0x100002B7@0x21000042 — a + /// four-piece frame of pure-green 0x06004CC9, byte-decoded + /// A=FF R=00 G=FF B=00) via per-state P0x3B: hidden at rest, + /// GREEN on rollover. The earlier hardcoded shared-skin override here + /// was therefore wrong on both counts and is removed — the built + /// marker's own authored locator/delay (populated by + /// from the template's property bag) wins. + /// /// private void BuildTownMarkers(ElementInfo? mapInfo, Func templateResolver) { @@ -267,6 +297,12 @@ public sealed class MapPageController if (!mapInfo.TryGetEffectiveProperty(HotspotTemplateLayoutAttr, out var templateLayout)) return; if (templateLayout.UnsignedValue == 0) return; + // The template's AUTHORED shape — for the button-swallowed + // highlight child (UiButton.ConsumesDatChildren drops it from the + // built widget, same class of loss as the AD-108 icons). + ElementInfo? templateInfo = _bindings.TemplateInfoResolver?.Invoke( + (uint)templateLayout.UnsignedValue, (uint)templateElement.UnsignedValue); + foreach (MapLocation loc in MapLocations.All) { UiElement? marker = templateResolver( @@ -284,31 +320,39 @@ public sealed class MapPageController // backing UiElement.GetTooltipText()'s override, which // RetailTooltipPresenter.ResolveTooltipText consults BEFORE the // authored text (closes register row TS-85's last item, - // gmMapUI::AddMapNote @0x004A1C51). AuthoredTooltipRootElementId/ - // LayoutDid still gate the popup SKIN unconditionally even on - // the runtime-text path — the map-note template (0x100001F0) - // authors no individual tooltip-popup locator of its own (a - // plain 10x10 hotspot dot), so RetailTooltipPresenter's popup - // needs one supplied; RetailTooltipPresenter.SharedPopupSkinRootElementId/ - // SharedPopupSkinLayoutDid (see that class's own single - // canonical citation, night-round review F10) is the same - // proven-working skin UiItemSlot already hardcodes. + // gmMapUI::AddMapNote @0x004A1C51). The popup SKIN + delay come + // from the marker's own authored properties (see this method's + // doc — finding 3 removed the shared-skin override that + // previously clobbered them here). if (marker is UiButton markerButton) markerButton.TooltipText = loc.Name; else // F11 (night-round review): silently skipping the runtime- - // text write here would leave the marker's popup mounted - // (AuthoredTooltipRootElementId/LayoutDid are still set - // below) but genuinely EMPTY — a live-DAT template change - // that resolves 0x100001F0 to something other than a - // UiButton would regress every town-marker tooltip with no + // text write here would leave the marker's popup authored + // but genuinely EMPTY — a live-DAT template change that + // resolves 0x100001F0 to something other than a UiButton + // would regress every town-marker tooltip with no // diagnostic signal at all. Console.WriteLine( $"[D.2b] Map tab: town marker '{loc.Name}' template " + $"resolved to {marker.GetType().Name}, not UiButton — " + "TooltipText cannot be set, marker will show no tooltip."); - marker.AuthoredTooltipRootElementId = RetailTooltipPresenter.SharedPopupSkinRootElementId; - marker.AuthoredTooltipLayoutDid = RetailTooltipPresenter.SharedPopupSkinLayoutDid; + + // AddMapNote's CreateChildElement builds the note's FULL + // authored subtree; our UiButton swallows dat children, so the + // rollover-highlight child (and its green-frame grandchildren) + // is rebuilt per marker through the SAME IconBuilder seam the + // AD-108 icons use, then re-armed by applying the button's + // initial Normal state (retail UIElement_Button's own initial + // UpdateState_ -> SetState(Normal) -> PassToChildren cascade -> + // the child's Normal-state P0x3B=true hides it until rollover). + if (templateInfo is not null && marker is UiButton stateHost) + { + foreach (ElementInfo childInfo in templateInfo.Children) + if (_bindings.IconBuilder(childInfo) is { } highlight) + marker.AddChild(highlight); + stateHost.TrySetRetailState(UiButtonStateMachine.Normal); + } _map!.AddChild(marker); } } diff --git a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs index 5d2360cf..d217dcaa 100644 --- a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs +++ b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs @@ -287,15 +287,42 @@ public sealed class RetailTooltipPresenter : IDisposable // ── World-object hover tooltip (docs/ISSUES.md #409 follow-on) ───────── // // Port of UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound - // @0x004E5AD0's tooltip half (@0x004E5D13-@0x004E5E00). Unlike the - // dwell-timer UI-element path above, this trigger is EDGE-fired: retail - // calls SetTooltip + StartTooltipAtMouse IMMEDIATELY when SmartBox's - // found-object id CHANGES (@0x004E5D74/@0x004E5DFB) — no dwell wait — - // gated per-edge by PlayerModule::ShowTooltips (@0x004E5D21, - // CharacterOptionId.ShowTooltips in this port's CharacterOptionTable). - // The text is ACCWeenieObject::GetObjectName(id, NAME_APPROPRIATE, 0) - // (@0x004E5D3B) — the SAME call UIElement_UIItem::UpdateTooltip uses, - // but WITHOUT that item-cell's separate stack-count "%d %s" prefix + // @0x004E5AD0's tooltip half (@0x004E5D13-@0x004E5E00), CORRECTED at the + // 2026-08-17 morning gate round (user finding 1: our world tooltips + // popped instantly; retail's "lag"). The night round misread the notice + // as edge-MOUNTING ("SetTooltip + StartTooltipAtMouse IMMEDIATELY ... no + // dwell wait"): the immediate StartTooltipAtMouse @0x004E5DFB is inside + // `if (UIElementManager::s_pInstance->m_dragElement != 0)` (@0x004E5D8E) + // — m_dragElement is a real, distinct PDB field (acclient.h's + // UIElementManager, separate from the m_pTooltipElement family), so the + // immediate mount fires ONLY while a drag-and-drop is in progress + // ("what am I about to drop this on"). In the ordinary hover case the + // notice merely STAGES the name (UIElement::SetTooltip @0x004E5D74 + + // `|= 0x20` TooltipOn @0x004E5D79) on the wrapper, and the DISPLAY rides + // the standard per-frame dwell machinery: + // + // UIElementManager::CheckTooltip @0x0045B6E0 (per frame, hover not + // started): mouse idle since m_lastMouseMoveTime (stamped on EVERY + // move, MouseMoveHandler @0x0045e736) for >= m_tooltipDelay (0.25 s + // default @0x0045f75d; the runtime-constructed wrapper authors no + // P0x50 override) while entered over the wrapper with no capture + // (@0x0045b715) -> StartHover @0x00459250 -> the wrapper's inherited + // UIElement::MouseHover @0x00462520 (TooltipOn bit + m_tooltipEnable) + // -> StartTooltipAtMouse reads the staged m_TTText -> popup mounts. + // + // Found-object CHANGES while a popup is up tear it down via SetTooltip's + // OWN text-change teardown (@0x004617FF: owner == this && popup != null + // -> ResetTooltip @0x0045C360, which tail-calls CheckTooltip) — so with + // an IDLE mouse the replacement popup mounts the SAME frame (the dwell + // deadline long passed), while a MOVING mouse keeps pushing the deadline + // out and shows nothing until it rests. found -> 0 stages EMPTY text + // (ClearTooltip @0x004E5E30 = SetTooltip(empty) @0x004625F9): the same + // teardown fires and nothing remounts. The ShowTooltips gate + // (PlayerModule::ShowTooltips @0x004E5D21, CharacterOptionId.ShowTooltips) + // and the name resolve (@0x004E5D3B) happen at the EDGE, exactly where + // retail reads them. The text is ACCWeenieObject::GetObjectName(id, + // NAME_APPROPRIATE, 0) — the SAME call UIElement_UIItem::UpdateTooltip + // uses, but WITHOUT that item-cell's separate stack-count "%d %s" prefix // (RecvNotice_SmartBoxObjectFound's own text-building block has no // count logic at all — a real, decomp-confirmed asymmetry versus // UiItemSlot's GetTooltipDisplayName). @@ -342,13 +369,18 @@ public sealed class RetailTooltipPresenter : IDisposable /// /// /// Night-round review F10: previously duplicated as three separate - /// private constants with three separate partial citations — this - /// class's own world-hover popup (below), UiItemSlot's item-cell - /// popup, and MapPageController's town-marker popup. All three - /// consumers now reference these SAME two constants; this is the ONE - /// citation. This class is the natural owner since it's the mount - /// point every one of the three consumers ultimately routes through - /// (OnTooltipShow/UpdateWorldHoverTooltip both call + /// private constants with three separate partial citations; this is the + /// ONE citation. Two consumers remain — this class's own world-hover + /// popup (below) and UiItemSlot's item-cell popup. + /// MapPageController's town markers were REMOVED from the list at + /// the 2026-08-17 morning gate round (finding 3): the hotspot template + /// (0x100001F0 in 0x21000026) turned out to author its OWN + /// popup locator (P0x47=0x10000398/P0x48=0x21000041 — the + /// fourth skin, whose incorporated text child fonts 0x40000015 + /// where the other three font 0x40000002), which the earlier + /// shared-constant override was clobbering. This class is the natural + /// owner since it's the mount point every consumer ultimately routes + /// through (OnTooltipShow/UpdateWorldHoverTooltip both call /// TryBuildAndMountPopup with these values or a widget's own). /// public const uint SharedPopupSkinRootElementId = 0x10000395u; @@ -357,6 +389,31 @@ public sealed class RetailTooltipPresenter : IDisposable private uint _worldHoverGuid; private bool _worldTooltipShowing; + /// The wrapper's staged m_TTText — written at the + /// found-object EDGE (SetTooltip @0x004E5D74 / + /// ClearTooltip @0x004E5E30), displayed only when the dwell + /// machinery mounts it. Null/empty = cleared (retail's empty + /// StringInfo; StartTooltipAtMouse @0x00460DA3's + /// IsValid test fails and the wrapper authors no P0x49 + /// fallback, so nothing shows). + private string? _worldStagedText; + + /// When the world popup mounted — retail + /// m_tooltipStart, for the m_tooltipDuration (10 s) + /// auto-hide (CheckTooltip @0x0045b78a). + private long _worldTooltipShownMs; + + /// Set by the duration auto-hide: retail's expiry path calls + /// SwitchMouseOver(this, nullptr) @0x0045b7b2, clearing + /// m_pElementLastEntered — the dwell cannot re-arm until the next + /// mouse move re-enters the wrapper. Without this latch the port would + /// remount one frame after every auto-hide (staged text still present, + /// mouse still idle) in a 10 s flicker loop. + private bool _worldRearmRequiresMouseMove; + + private int _worldLastSeenMouseX = int.MinValue; + private int _worldLastSeenMouseY = int.MinValue; + /// /// The world-hover pick (retail's SmartBox::find_object via /// UIElement_SmartBoxWrapper::FindObject @0x004E5430's fallback @@ -392,50 +449,140 @@ public sealed class RetailTooltipPresenter : IDisposable if (WorldHoverGuidProvider is null) return; + // Post-auto-hide re-arm: retail's duration expiry ran + // SwitchMouseOver(null); the next real mouse move re-enters the + // wrapper and only THEN can the dwell restart (see the + // _worldRearmRequiresMouseMove field doc). + if (_host.MouseX != _worldLastSeenMouseX || _host.MouseY != _worldLastSeenMouseY) + { + _worldLastSeenMouseX = _host.MouseX; + _worldLastSeenMouseY = _host.MouseY; + _worldRearmRequiresMouseMove = false; + } + uint found = _host.Pick(_host.MouseX, _host.MouseY) is null ? WorldHoverGuidProvider() ?? 0u : 0u; - if (found == _worldHoverGuid) - return; // no change -> RecvNotice_SmartBoxObjectFound never re-fires - _worldHoverGuid = found; + // ── The notice edge (RecvNotice_SmartBoxObjectFound @0x004E5AD0's + // tooltip half) — STAGES text; mounts nothing except mid-drag. ── + if (found != _worldHoverGuid) + { + _worldHoverGuid = found; - // #409 follow-on (2026-08-16 overnight hover/UI round, Batch A bug 1): - // every found-object edge — whether to a DIFFERENT object or to - // none at all — tears down whatever world popup is currently up - // FIRST, mirroring OnTooltipShow's own unconditional RemovePopup() at - // its top. The pre-fix code only cleared on the found==0u edge, so an - // A-found-B transition (walking past a run of NPCs/doors/lifestones - // with never a frame of "nothing found" between them) called - // TryBuildAndMountPopup again with the OLD popup still mounted as a - // child of _host — only the _popupRoot reference got overwritten, so - // every previous popup was orphaned in the tree and never removed. - // _popupRoot is a single field by design (retail's own single - // m_pTooltipElement slot); this restores that single-slot invariant. + string? staged = _worldStagedText; + if (found == 0u || WorldTooltipsEnabled?.Invoke() != true) + { + // @0x004E5E2A/@0x004E5E30: bit &= ~0x20 + ClearTooltip. + staged = null; + } + else + { + string? name = WorldHoverNameResolver?.Invoke(found); + // @0x004E5D48: the empty-name guard skips SetTooltip + // entirely — the PREVIOUS staged text stays in place + // (retail's own shape; nameless found objects are rare). + if (!string.IsNullOrEmpty(name)) + staged = name; + } + + // UIElement::SetTooltip @0x004617C0: only a text CHANGE does + // anything (@0x004617D9's operator== guard). On change, the + // popup this surface currently shows is torn down + // (@0x004617FF ResetTooltip) — the dwell block below is + // ResetTooltip's tail-called CheckTooltip, re-run this same + // frame, so an IDLE mouse remounts the replacement immediately. + // + // #409 follow-on (2026-08-16 overnight hover/UI round, Batch A + // bug 1): this teardown must fire on EVERY staging edge — an + // A-found-B transition with no intervening "nothing found" + // frame previously orphaned each old popup in the tree + // (_popupRoot single-slot invariant, retail's own single + // m_pTooltipElement). + if (!string.Equals(staged, _worldStagedText, StringComparison.Ordinal)) + { + _worldStagedText = staged; + if (_worldTooltipShowing) + RemovePopup(); + + // The drag-in-progress exception @0x004E5D8E: m_dragElement + // != 0 -> ResetTooltip + StartTooltipAtMouse IMMEDIATELY + // (@0x004E5DF0/@0x004E5DFB), no dwell — while dragging an + // item over the world you see the drop target's name at + // once. NOT gated on m_tooltipEnable (this path bypasses + // UIElement::MouseHover entirely). + if (!string.IsNullOrEmpty(staged) && _host.DragSource is not null) + { + if (TryBuildAndMountPopup( + SharedPopupSkinRootElementId, SharedPopupSkinLayoutDid, staged!)) + { + _worldTooltipShowing = true; + _worldTooltipShownMs = _host.NowMs; + } + return; + } + } + } + + // ── CheckTooltip @0x0045B6E0, the wrapper's share of it. ── if (_worldTooltipShowing) - RemovePopup(); + { + // Auto-hide after m_tooltipDuration (@0x0045b78a; 10 s default + // @0x0045f767, UiRoot.TooltipDurationMs). The expiry path's + // SwitchMouseOver(null) @0x0045b7b2 means no remount until the + // mouse moves again. + if (_host.NowMs - _worldTooltipShownMs >= _host.TooltipDurationMs) + { + RemovePopup(); + _worldRearmRequiresMouseMove = true; + } + return; + } - if (found == 0u) + // StartTooltipAtMouse @0x00460DA3: empty staged m_TTText + no + // authored P0x49 on the runtime-constructed wrapper -> nothing. + if (string.IsNullOrEmpty(_worldStagedText)) return; - if (WorldTooltipsEnabled?.Invoke() != true) + // @0x0045b715: the arm branch requires no mouse capture. + if (_host.Captured is not null) return; - string? text = WorldHoverNameResolver?.Invoke(found); - if (string.IsNullOrEmpty(text)) + if (_worldRearmRequiresMouseMove) return; - // A UI-element popup cannot be showing here: UiRoot's own hover - // (queried above) is null whenever this branch runs, so its dwell - // timer never arms and OnTooltipShow never fires concurrently. - if (TryBuildAndMountPopup(SharedPopupSkinRootElementId, SharedPopupSkinLayoutDid, text)) + // @0x0045b747: mouse idle since m_lastMouseMoveTime for >= the + // global m_tooltipDelay (the wrapper authors no per-element P0x50). + if (_host.MouseIdleMs < _host.TooltipDelayMs) + return; + + // UIElement::MouseHover @0x0046254C's m_tooltipEnable gate — the + // dwell-mounted path IS gated on the global enable (unlike the + // drag-immediate branch above, which bypasses MouseHover). + if (!Enabled) + return; + + if (TryBuildAndMountPopup( + SharedPopupSkinRootElementId, SharedPopupSkinLayoutDid, _worldStagedText!)) + { _worldTooltipShowing = true; + _worldTooltipShownMs = _host.NowMs; + } } - /// Force-hides whatever tooltip is currently showing, if any. - /// Session-reset callers use this (mirrors 's - /// own role for dialogs) so a stale popup cannot survive a reconnect. - public void HideCurrent() => RemovePopup(); + /// Force-hides whatever tooltip is currently showing, if any, + /// and forgets the staged world-hover text/guid. Session-reset callers + /// use this (mirrors 's own role + /// for dialogs) so neither a stale popup NOR a stale staged name (which + /// the dwell would otherwise remount over the new session's world with + /// an unmoved mouse) can survive a reconnect. + public void HideCurrent() + { + RemovePopup(); + _worldHoverGuid = 0u; + _worldStagedText = null; + _worldRearmRequiresMouseMove = false; + } /// Retail UIElementManager::StartTooltip @0x0045DE90's text /// + auto-resize step. Wraps at the display width (retail's diff --git a/src/AcDream.App/UI/Layout/RowTemplateResolver.cs b/src/AcDream.App/UI/Layout/RowTemplateResolver.cs index 17f018ae..6543e711 100644 --- a/src/AcDream.App/UI/Layout/RowTemplateResolver.cs +++ b/src/AcDream.App/UI/Layout/RowTemplateResolver.cs @@ -71,4 +71,23 @@ public sealed class RowTemplateResolver } return info is null ? null : _build(info); } + + /// The cached import half alone — the template's resolved + /// without building a widget. Same cache, same + /// miss contract as . Consumers that need the + /// template's AUTHORED shape beyond what the built widget carries (the + /// map town markers' button-swallowed highlight child + its per-state + /// property bags — 2026-08-17 morning gate finding 3) read it here + /// instead of a second cold import. + public ElementInfo? ResolveInfo(uint templateLayoutId, uint templateElementId) + { + var key = (templateLayoutId, templateElementId); + if (!_cache.TryGetValue(key, out ElementInfo? info)) + { + info = _importInfos(templateLayoutId, templateElementId); + _cache[key] = info; + ImportCount++; + } + return info; + } } diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs index 7e70a5d6..4027acd0 100644 --- a/src/AcDream.App/UI/Layout/UiDatElement.cs +++ b/src/AcDream.App/UI/Layout/UiDatElement.cs @@ -107,6 +107,36 @@ public class UiDatElement : UiElement, IUiDatStateful ActiveState = stateName; } + // Per-state Invisible (dat property 0x3B): retail's SetState applies + // the committed state's properties through UIElement::OnSetAttribute, + // whose case 8 (@0x00462d80, property id 0x33 + 8 = 0x3B) is + // `SetVisible(value == 0)` — an authored per-STATE true hides the + // element for that state. First consumer (2026-08-17 morning gate + // finding 3): the map town-hotspot template's highlight child + // (0x100001F1 in 0x21000026, base 0x100002B7@0x21000042 — the green + // 0x06004CC9 frame) authors Normal={0x3B:true} / + // Normal_rollover={0x3B:false}, i.e. hidden at rest, shown on + // rollover. + // + // SCOPED TO NAMED STATES ONLY: a 0x3B authored in the unnamed + // DirectState is the CONSTRUCTION-time "authored invisible" class + // (1,083 elements client-wide — docs/ISSUES.md #408, its own + // separately-gated general-honor item; ElementReader.Invisible/GF-13 + // captures it and only chargen's scoped walk acts on it, register + // AP-230). Honoring it here would un-gate #408 through the back + // door: LayoutImporter.BuildWidget's post-children state reapply + // calls TrySetRetailState(DirectStateId) on every built widget, so + // a DirectState honor would hide all 1,083 at import (measured + // same-round: 10 combat-layout elements incl. 0x10000454 went + // un-hit-testable, breaking the spell-favorite drag tests). The + // NAMED-state flip below is a live visibility state machine that + // cannot work at all without the honor — that is this port's line. + if (stateId != UiStateInfo.DirectStateId + && selectedState is not null + && selectedState.Properties.TryGetValue(0x3Bu, out var invisibleProp) + && invisibleProp.Kind == UiPropertyKind.Bool) + Visible = !invisibleProp.BoolValue; + if (selectedState?.PassToChildren == true) { foreach (UiElement child in Children) diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 96cc726b..aca63939 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -3328,6 +3328,15 @@ public sealed class RetailUiRuntime : IDisposable lock (_bindings.Assets.DatLock) return hotspotTemplate.Resolve(templateLayoutId, templateElementId); } + // The import half alone (same cache): BuildTownMarkers reads the + // template's AUTHORED child subtree — the button-swallowed rollover + // highlight (morning gate finding 3) — which the built widget above + // does not carry. + ElementInfo? ResolveHotspotTemplateInfo(uint templateLayoutId, uint templateElementId) + { + lock (_bindings.Assets.DatLock) + return hotspotTemplate.ResolveInfo(templateLayoutId, templateElementId); + } // m_pMap's two button-swallowed icon children (player/house markers) // only exist as ElementInfos INSIDE rootInfo's own panel-slot resolve @@ -3356,7 +3365,8 @@ public sealed class RetailUiRuntime : IDisposable PlayerCellId: mh.PlayerCellId, HousePosition: mh.HousePosition ?? (static () => null), TemplateResolver: ResolveHotspotTemplate, - IconBuilder: BuildSwallowedIcon), + IconBuilder: BuildSwallowedIcon, + TemplateInfoResolver: ResolveHotspotTemplateInfo), House: new Layout.HousePageController.Bindings( Lines: mh.HouseLines ?? (static () => Array.Empty()), OnShown: mh.HouseShown, diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs index 42218789..d3ab64c1 100644 --- a/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs +++ b/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs @@ -131,6 +131,32 @@ public sealed class RetailUiAutomationProbe return true; } + /// + /// 2026-08-17 morning gate: synthetic pointer HOVER (no click) at an + /// element's center, for rollover/tooltip verification. Deliberately + /// does NOT the probe's own micro-clock: hover + /// dwell ( vs + /// ) and the world-tooltip timing + /// must ride the PRODUCTION frame tick's real monotonic clock + /// (UiHost.Tick's TickCount64), which keeps running + /// between script commands — an Advance here would stamp the + /// idle timestamp with this probe's tiny private counter and make the + /// next production tick read an enormous (or negative) idle time. + /// + public bool HoverElement(uint datElementId) + { + var target = FindByDatElementId(datElementId); + if (target is null) return Fail($"element 0x{datElementId:X8} not found"); + _root.OnMouseMove((int)target.CenterX, (int)target.CenterY); + return true; + } + + /// Raw synthetic mouse move (canvas coordinates). Same + /// no- contract as — + /// used to sweep the pointer across the 3D world for the world-hover + /// tooltip's mouse-idle dwell verification. + public void MoveMouse(int x, int y) => _root.OnMouseMove(x, y); + public bool ClickItem(uint itemGuid, ItemDragSource? sourceKind = null) { var target = FindByItemId(itemGuid, sourceKind); diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs index 4fc0e1f7..46d0ec37 100644 --- a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs +++ b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs @@ -195,6 +195,8 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable { "dump" => DoDump(), "click" => DoClick(command), + "hover" => DoHover(command), + "mousemove" => DoMouseMove(command), "doubleclick" => DoDoubleClick(command), "drag" => DoDrag(command), "wait" => DoWait(command), @@ -232,6 +234,34 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable return Stop(command, "usage: click element | click item [source]"); } + /// 2026-08-17 morning gate: `hover element <datId>` — a + /// synthetic pointer hover with NO click, for rollover-state and + /// tooltip-dwell verification (the drive scripts must not move the real + /// OS cursor when a user is present). See + /// 's own doc for the + /// deliberate no-Advance clock contract. + private bool DoHover(ScriptCommand command) + { + var p = command.Parts; + if (p.Length < 3 || !string.Equals(p[1], "element", StringComparison.OrdinalIgnoreCase)) + return Stop(command, "usage: hover element "); + if (!TryParseUInt(p[2], out uint datId)) return Stop(command, $"bad element id '{p[2]}'"); + return _probe.HoverElement(datId) || Stop(command, "hover element failed"); + } + + /// `mousemove <x> <y>` — raw synthetic pointer move in + /// canvas coordinates (world-hover dwell sweeps). + private bool DoMouseMove(ScriptCommand command) + { + var p = command.Parts; + if (p.Length != 3) + return Stop(command, "usage: mousemove "); + if (!TryParseInt(p[1], out int x)) return Stop(command, $"bad x '{p[1]}'"); + if (!TryParseInt(p[2], out int y)) return Stop(command, $"bad y '{p[2]}'"); + _probe.MoveMouse(x, y); + return true; + } + private bool DoDoubleClick(ScriptCommand command) { var p = command.Parts; diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 45166aba..782b32cc 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -398,11 +398,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful if (!HasStateMedia("")) return false; ActiveState = ""; + CascadeStateToChildren(stateId); return true; } if (TryFindState(stateId, out var state)) { ActiveState = state.Name; + CascadeStateToChildren(stateId); return true; } string stateName = UiButtonStateMachine.StateName(stateId); @@ -411,6 +413,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful if (!string.IsNullOrEmpty(stateName) && HasStateMedia(stateName)) { ActiveState = stateName; + CascadeStateToChildren(stateId); return true; } return false; @@ -993,6 +996,40 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful // segments carry no Highlight art), so ActiveState stays "Normal" // forever, but the spin's OWN label color must still swap. ApplyPerStateLabelStyle(requested); + + // Same unconditional-commit principle for the child cascade: retail + // UIElement::SetState @0x00464E70 propagates the committed state to + // children whenever the StateDesc authors PassToChildren, regardless + // of whether THIS element's own sprite changed — keyed off the + // REQUESTED id for the same reason as the label style above. + CascadeStateToChildren(requested); + } + + /// + /// Retail UIElement::SetState @0x00464E70's PassToChildren + /// cascade, ported for buttons (2026-08-17 morning gate finding 3 — + /// previously only + /// had it). First consumer: the map town-hotspot template + /// (0x100001F0 in 0x21000026) authors media-less + /// Normal/Normal_rollover state descriptors with + /// PassToChildren=true whose only job is driving the highlight + /// child's per-state P0x3B visibility flip (the green + /// 0x06004CC9 rollover frame). No-op unless a state descriptor + /// for exists AND authors PassToChildren AND + /// this button actually has stateful children — dat-built buttons + /// normally have none (); only + /// explicitly-attached subtrees (the map markers' highlight, the + /// AD-108 icon seam) can receive the cascade. + /// + private void CascadeStateToChildren(uint stateId) + { + if (Children.Count == 0) + return; + if (!TryFindState(stateId, out UiStateInfo state) || !state.PassToChildren) + return; + foreach (UiElement child in Children) + if (child is IUiDatStateful stateful) + stateful.TrySetRetailState(stateId); } private uint ComputeRequestedStateId() diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 21b61bc1..f22005eb 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -377,6 +377,30 @@ public sealed class UiRoot : UiElement private long _nowMs; + /// Retail UIElementManager::m_lastMouseMoveTime, ported + /// 1:1: stamped UNCONDITIONALLY at the top of every mouse move + /// (MouseMoveHandler @0x0045E710, @0x0045e729/@0x0045e736 + /// — before hit-testing, capture handling, everything) and re-stamped on + /// capture release (ReleaseMouseCapture @0x0045D2B0, + /// @0x0045d2da). Distinct from , whose + /// stamps are deliberately conditional (the !_tooltipFired guard in + /// , no stamp during captured moves) because that + /// field also carries m_bHoverStarted interplay. The world-hover + /// tooltip's idle-dwell gate () + /// needs retail's raw, unconditional timestamp. + private long _lastMouseMoveMs; + + /// Milliseconds since the last mouse move — retail + /// CheckTooltip @0x0045B6E0's dwell operand + /// (@0x0045b747: m_lastMouseMoveTime + delay vs now). + public long MouseIdleMs => _nowMs - _lastMouseMoveMs; + + /// The clock last ran at — retail + /// Timer::local_time as the UI tree sees it. Exposed for sibling + /// per-frame consumers ('s + /// world-tooltip duration clock) so they share ONE frame timestamp. + public long NowMs => _nowMs; + /// Raised when an event was not consumed by any widget. public event Action? WorldMouseFallThrough; @@ -602,6 +626,9 @@ public sealed class UiRoot : UiElement int dy = y - MouseY; MouseX = x; MouseY = y; + // MouseMoveHandler @0x0045e729/@0x0045e736: m_lastMouseMoveTime is + // stamped before ANY routing below (resize/window-drag/capture/hover). + _lastMouseMoveMs = _nowMs; // Window resize takes precedence over move / drag-drop / hover. if (_resizeTarget is not null) @@ -1124,6 +1151,7 @@ public sealed class UiRoot : UiElement // tooltip is already up must leave it up, not clear-then-re-fire it // 250ms later without ever going through TooltipHide. _hoverStartedMs = _nowMs; + _lastMouseMoveMs = _nowMs; // ReleaseMouseCapture @0x0045d2da — the same restart NotifyCaptureLost(previous); if (previous is not null) PointerCaptureChanged?.Invoke(previous, null); @@ -1299,6 +1327,7 @@ public sealed class UiRoot : UiElement public void ResetTooltipTracking() { _hoverStartedMs = _nowMs; + _lastMouseMoveMs = _nowMs; // same fresh idle deadline for the world-hover dwell _tooltipFired = false; } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs b/src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs index 2c1ac073..0c0a91dc 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs @@ -17,26 +17,42 @@ namespace AcDream.Runtime.Gameplay; /// /// /// -/// Retail behavior, exhaustively verified against the decomp before -/// writing this class (all seven line builders read, not just the one -/// ported here): gmHouseUI::PostInit @0x004a2710 never calls -/// Update/DisplayHouseData — the House ListBox +/// Retail behavior: gmHouseUI::PostInit @0x004a2710 never +/// calls Update/DisplayHouseData — the House ListBox /// (0x100001e6) starts genuinely empty (live-DAT-confirmed, /// MapHousePanelSlotProbeTests: children=0, and the whole /// House page 0x100001F7 has NO other static content besides that /// one empty ListBox). Content appears only after a server notice /// (0x0225-0x0228) arrives and Update/DisplayHouseData runs -/// the seven Display* builders in order. SIX of them -/// (DisplayBuyPayment, DisplayRentPayment, -/// DisplayBuyTime, DisplayRentTimes, DisplayLocation, +/// the seven Display* builders in fixed order. FIVE of them +/// (DisplayRentPayment, DisplayBuyTime, +/// DisplayRentTimes, DisplayLocation, /// DisplayWarningText) open with if (this->m_pHouseData != 0) /// and emit NOTHING when houseless — those remain unported, ISSUES #413 /// item 3. /// /// -/// The SEVENTH, gmHouseUI::DisplayPurchaseTimeText @0x004a3110, does -/// NOT gate on m_pHouseData — it always runs, reading the LOCAL -/// PLAYER's own PropertyInt.HousePurchaseTimestamp (0xC7 = 199 +/// CORRECTED at the 2026-08-17 morning gate round (user finding 2: the +/// retail House tab shows TWO lines for a houseless character; ours showed +/// one): the first builder, gmHouseUI::DisplayBuyPayment +/// @0x004a2b30, is NOT houseless-silent — its m_pHouseData gate +/// only selects WHICH text, and the ListBox emit +/// (AddItemFromTemplateList + SetTextWithFont, +/// @0x004a2b80 onward) runs in BOTH branches. The houseless +/// else branch (@0x004a2b57: push 0x7ab688, +/// byte-decoded from the PDB-paired binary — Binary Ninja's pseudo-C +/// rendered the operand as a spurious &vftable.RecvNotice_* +/// symbol match, the same TS-85/F3 artifact class, which is how the night +/// round's exe-pool sweep missed it) is the literal "You do not +/// currently own a house." — exactly the user's retail screenshot. The +/// owned branch (@0x004a2b63, data_7ab65c: "The purchase +/// price for this dwelling is:\n" + HousePaymentList::ComposeText) +/// remains #413 item-3 scope. +/// +/// +/// The SEVENTH builder, gmHouseUI::DisplayPurchaseTimeText @0x004a3110, +/// also does NOT gate on m_pHouseData — it always runs, reading the +/// LOCAL PLAYER's own PropertyInt.HousePurchaseTimestamp (0xC7 = 199 /// decimal) via CBaseQualities::InqInt and /// HouseSystem::HasPurchaseWaitPeriodExpired /// (@0x005bb1d0: (Timer::get_real_time() - timestamp) > @@ -44,22 +60,12 @@ namespace AcDream.Runtime.Gameplay; /// seconds; 0x278d00 = 2,592,000 s = 30 days). For a fresh/houseless /// character with no timestamp ever set (absent property reads as 0), this /// is trivially true, taking the "expired" branch, which reads -/// m_pHouseData == 0 (still houseless) and emits the ONE literal -/// string at data_7ab7f0: "You may buy another house -/// immediately." That is the exact, decomp-verified, single line of -/// content a houseless character's House tab shows once queried — this -/// class ports exactly that (and its owns-a-house sibling at -/// data_7ab818, unreachable by a fresh character but faithfully -/// ported alongside it). No other function, WeenieError-to-chat mapping, -/// or authored LayoutDesc content anywhere in the decomp/live DAT produces -/// a second line for the houseless case — a broader search for chat-scroll -/// strings mentioning house ownership found only unrelated, differently -/// worded command-error text ("You do not own a house!", -/// WeenieError 0x45E/0x45F, and "You must own a house to -/// use this command.", WeenieError 0x47F) routed through the -/// GENERIC WeenieError chat dispatcher, never through gmHouseUI's -/// own notice handlers (which discard the wire WeenieError entirely — see -/// ). +/// m_pHouseData == 0 (still houseless) and emits the literal at +/// data_7ab7f0: "You may buy another house immediately." +/// (owns-a-house sibling at data_7ab818, byte-re-verified this +/// round). So a queried houseless character's House tab shows exactly TWO +/// lines, in builder order: "You do not currently own a house." then the +/// purchase-time line — which this class now renders. /// /// /// The NOT-yet-expired branch of DisplayPurchaseTimeText is now @@ -169,10 +175,27 @@ public sealed class RuntimeHouseState } } - /// gmHouseUI::DisplayPurchaseTimeText @0x004a3110, - /// both branches now ported. Must hold . + /// The ported share of gmHouseUI::Update's fixed + /// seven-builder order: DisplayBuyPayment @0x004a2b30's + /// houseless branch (first) and DisplayPurchaseTimeText + /// @0x004a3110 (last), both branches. The five houseless-silent + /// builders between them, and DisplayBuyPayment's owned branch, are + /// ISSUES #413 item 3. Must hold . private void Recompute(uint selfGuid) { + var lines = new List(2); + + // gmHouseUI::DisplayBuyPayment @0x004a2b30 — NOT houseless-silent + // (2026-08-17 morning gate correction; see the class doc): the + // m_pHouseData gate only selects WHICH text, and the emit runs in + // both branches. Houseless (@0x004a2b57, byte-decoded data_7ab688): + // this exact literal. Owned (@0x004a2b63, data_7ab65c "The purchase + // price for this dwelling is:\n" + HousePaymentList::ComposeText): + // unported, #413 item 3 — the owned case adds nothing here yet. + if (!_ownsHouse) + lines.Add("You do not currently own a house."); + + // gmHouseUI::DisplayPurchaseTimeText @0x004a3110, both branches. int timestamp = _objects?.Get(selfGuid)?.Properties .GetInt((uint)PropertyInt.HousePurchaseTimestamp) ?? 0; long nowEpochSeconds = _timeProvider.GetUtcNow().ToUnixTimeSeconds(); @@ -198,20 +221,18 @@ public sealed class RuntimeHouseState timestamp + PurchaseWaitPeriodSeconds); DateTime expiryLocal = TimeZoneInfo.ConvertTime( expiryUtc, _timeProvider.LocalTimeZone).DateTime; - _lines = new[] - { + lines.Add( "You may buy another landscape house at " - + expiryLocal.ToString(CultureInfo.CurrentCulture) - + ". This restriction does not apply to apartments.", - }; - return; + + expiryLocal.ToString(CultureInfo.CurrentCulture) + + ". This restriction does not apply to apartments."); + } + else + { + lines.Add(_ownsHouse + ? "You may buy another house immediately after you abandon this one." + : "You may buy another house immediately."); } - _lines = new[] - { - _ownsHouse - ? "You may buy another house immediately after you abandon this one." - : "You may buy another house immediately.", - }; + _lines = lines; } } diff --git a/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs index 47ca4eef..f68475fe 100644 --- a/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs @@ -17,6 +17,13 @@ namespace AcDream.App.Tests.UI.Layout; /// public sealed class MapHousePanelControllerTests { + /// The live hotspot template's own authored popup locator + /// (MapHousePanelSlotProbeTests, 2026-08-17 morning gate + /// finding 3: DirectState P0x47=0x10000398/P0x48=0x21000041 + /// — the map-note skin whose text child fonts 0x40000015). + private const uint MapNoteSkinRootId = 0x10000398u; + private const uint MapNoteSkinLayoutId = 0x21000041u; + /// Serves the town-hotspot template. Returns a /// — matching the live template's own authored /// Type 1 (MapHousePanelSlotProbeTests: "hotspot template @@ -25,15 +32,65 @@ public sealed class MapHousePanelControllerTests /// by these tests. A real would set /// DatElementId the same way /// does, so tests that need to find these markers back by id after the - /// fact need it too. + /// fact need it too. Carries the template's own authored tooltip + /// locator (which production populates + /// from the property bag) — finding 3 removed the shared-skin override + /// that used to clobber it, so the fixture must author it the way the + /// live DAT does. private static UiElement? FakeHotspotTemplate(uint layoutId, uint elementId) - => new UiButton(new ElementInfo(), static _ => (0u, 0, 0)) + => new UiButton(MapNoteTemplateInfo(), static _ => (0u, 0, 0)) { Width = 10f, Height = 10f, DatElementId = elementId, + AuthoredTooltipRootElementId = MapNoteSkinRootId, + AuthoredTooltipLayoutDid = MapNoteSkinLayoutId, + AuthoredTooltipDelaySeconds = 0f, // authored P0x50 = 0.0 + AuthoredTooltipEnabled = true, // authored P0x4B }; + /// The template's authored state shape (raw-DAT-dumped): + /// media-less Normal/Normal_rollover descriptors, both + /// PassToChildren=true, plus P0x13 RolloverEnabled. + private static ElementInfo MapNoteTemplateInfo() + { + var info = new ElementInfo { Type = 1, Width = 10, Height = 10 }; + var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + direct.Properties.Values[0x13u] = new UiPropertyValue + { Kind = UiPropertyKind.Bool, BoolValue = true }; + info.States[UiStateInfo.DirectStateId] = direct; + info.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", PassToChildren = true }; + info.States[2u] = new UiStateInfo { Id = 2u, Name = "Normal_rollover", PassToChildren = true }; + return info; + } + + /// The template's highlight child info (live: 0x100001F1, + /// base 0x100002B7@0x21000042 — the green frame), with the + /// authored per-state P0x3B flip. + private static ElementInfo HighlightChildInfo() + { + var info = new ElementInfo { Id = 0x100001F1u, Type = 3, Width = 10, Height = 10 }; + var normal = new UiStateInfo { Id = 1u, Name = "Normal" }; + normal.Properties.Values[0x3Bu] = new UiPropertyValue + { Kind = UiPropertyKind.Bool, BoolValue = true }; + var rollover = new UiStateInfo { Id = 2u, Name = "Normal_rollover" }; + rollover.Properties.Values[0x3Bu] = new UiPropertyValue + { Kind = UiPropertyKind.Bool, BoolValue = false }; + info.States[1u] = normal; + info.States[2u] = rollover; + return info; + } + + /// Template-info resolver standing in for + /// RowTemplateResolver.ResolveInfo: the template with its + /// button-swallowed highlight child attached. + private static ElementInfo? FakeHotspotTemplateInfo(uint layoutId, uint elementId) + { + ElementInfo info = MapNoteTemplateInfo(); + info.Children.Add(HighlightChildInfo()); + return info; + } + /// The /// seam: builds m_pMap's two button-swallowed icon children from their /// OWN s inside the panel-slot resolve tree @@ -41,14 +98,25 @@ public sealed class MapHousePanelControllerTests /// the live DAT, so the icons are found under the already-resolved /// pageInfo and built through this seam instead). Mirrors /// production's LayoutImporter.Build(info, ...).Root, which sets - /// DatElementId from the info's own id. + /// DatElementId from the info's own id — and, like production's + /// type-driven factory, builds a Type-3 info as a stateful + /// FROM that info (the town markers' + /// rollover-highlight child, morning gate finding 3, rides this same + /// seam and needs its per-state property bags carried through). private static UiElement? FakeIconBuilder(ElementInfo info) - => new UiButton(new ElementInfo(), static _ => (0u, 0, 0)) - { - Width = 10f, - Height = 10f, - DatElementId = info.Id, - }; + => info.Type == 3 + ? new UiDatElement(info, static _ => (0u, 0, 0)) + { + Width = 10f, + Height = 10f, + DatElementId = info.Id, + } + : new UiButton(new ElementInfo(), static _ => (0u, 0, 0)) + { + Width = 10f, + Height = 10f, + DatElementId = info.Id, + }; /// The House ListBox's own row template resolves to a /// in the live DAT (MapHousePanelSlotProbeTests: @@ -63,7 +131,8 @@ public sealed class MapHousePanelControllerTests Func? currentCalendar = null, Func? playerCellId = null, Func? housePosition = null, - Func>? houseLines = null) + Func>? houseLines = null, + Func? templateInfoResolver = null) { calls ??= new List(); return new MapHousePanelController.Callbacks( @@ -73,7 +142,8 @@ public sealed class MapHousePanelControllerTests PlayerCellId: playerCellId ?? (static () => 0u), HousePosition: housePosition ?? (static () => null), TemplateResolver: FakeHotspotTemplate, - IconBuilder: FakeIconBuilder), + IconBuilder: FakeIconBuilder, + TemplateInfoResolver: templateInfoResolver), House: new HousePageController.Bindings( Lines: houseLines ?? (static () => Array.Empty()), OnShown: () => calls.Add("house-shown"), @@ -187,15 +257,60 @@ public sealed class MapHousePanelControllerTests // Runtime tooltip text (UiButton.TooltipText, backing // GetTooltipText()) — the retail SetTooltip/m_TTText mechanism, NOT // the DAT-authored AuthoredTooltipText path. The popup-skin locator - // is unconditionally required even on the runtime-text path (see - // RetailTooltipPresenter.SharedPopupSkinRootElementId's doc). - Assert.All(townMarkers, c => Assert.NotEqual(0u, c.AuthoredTooltipRootElementId)); - Assert.All(townMarkers, c => Assert.NotEqual(0u, c.AuthoredTooltipLayoutDid)); + // + zero delay are the TEMPLATE's OWN authored values (2026-08-17 + // morning gate finding 3: P0x47=0x10000398/P0x48=0x21000041/ + // P0x50=0.0), which BuildTownMarkers must NOT clobber — the pre-fix + // code overwrote them with the shared generic skin, losing the + // map-note skin's 0x40000015 font. + Assert.All(townMarkers, c => Assert.Equal(MapNoteSkinRootId, c.AuthoredTooltipRootElementId)); + Assert.All(townMarkers, c => Assert.Equal(MapNoteSkinLayoutId, c.AuthoredTooltipLayoutDid)); + Assert.All(townMarkers, c => Assert.Equal(0f, c.AuthoredTooltipDelaySeconds)); Assert.All(townMarkers, c => Assert.IsType(c)); Assert.All(townMarkers, c => Assert.False(string.IsNullOrEmpty(((UiButton)c).TooltipText))); Assert.Contains(townMarkers, c => ((UiButton)c).TooltipText == "Holtburg"); } + /// + /// 2026-08-17 morning gate finding 3, the hover-highlight half: each + /// marker rebuilds the template's button-swallowed highlight child + /// through the IconBuilder seam and arms it via the initial Normal + /// cascade (hidden at rest, per-state P0x3B); hovering the marker + /// swaps it to Normal_rollover (shown — the green + /// 0x06004CC9 frame in the live DAT). + /// + [Fact] + public void TownMarkers_RolloverHighlight_StartsHidden_ShowsOnHover() + { + ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos(); + ImportedLayout layout = FixtureLoader.LoadMapHouseHost(); + MapHousePanelController? controller = MapHousePanelController.Bind( + rootInfo, layout, MakeCallbacks(templateInfoResolver: FakeHotspotTemplateInfo)); + Assert.NotNull(controller); + + UiElement? map = UiElement.FindDescendant(controller!.Root, MapPageController.MapWidgetId); + Assert.NotNull(map); + var markers = map!.Children + .Where(c => c.DatElementId != MapPageController.PlayerIconId + && c.DatElementId != MapPageController.HouseIconId) + .OfType() + .ToList(); + Assert.Equal(53, markers.Count); + + foreach (UiButton marker in markers) + { + UiElement highlight = Assert.Single(marker.Children); + Assert.False(highlight.Visible); // Normal: P0x3B=true + } + + UiButton hovered = markers[0]; + UiElement hoveredHighlight = hovered.Children[0]; + hovered.OnEvent(new UiEvent(0, hovered, UiEventType.HoverEnter)); + Assert.True(hoveredHighlight.Visible); // Normal_rollover: P0x3B=false + + hovered.OnEvent(new UiEvent(0, hovered, UiEventType.HoverLeave)); + Assert.False(hoveredHighlight.Visible); + } + [Fact] public void Bind_HouseListBoxStartsEmpty_MatchingRetailPostInit() { diff --git a/tests/AcDream.App.Tests/UI/Layout/MapHousePanelSlotProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/MapHousePanelSlotProbeTests.cs index 9f2850b2..79cfce5e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/MapHousePanelSlotProbeTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/MapHousePanelSlotProbeTests.cs @@ -128,11 +128,133 @@ public sealed class MapHousePanelSlotProbeTests if (hasTemplateElement && hasTemplateLayoutDid && tl.UnsignedValue != 0) { ElementInfo? template = LayoutImporter.ImportInfos(dats, (uint)tl.UnsignedValue, (uint)te.UnsignedValue); - Console.WriteLine(template is null - ? "[maphouse] hotspot template IMPORT NULL" - : $"[maphouse] hotspot template type={template.Type} " - + $"({template.Width}x{template.Height}) states={template.States.Count} " - + $"stateMedia={template.StateMedia.Count}"); + if (template is null) + { + Console.WriteLine("[maphouse] hotspot template IMPORT NULL"); + } + else + { + // 2026-08-17 morning gate finding 3: the note element's + // tooltip popup locator is the TEMPLATE's own authored + // P0x47/P0x48 (StartTooltipAtMouse reads them off the + // created note element; P0x48-absent falls back to the + // note's own source layout = m_pMap's P0x48), and the + // hover highlight is the template's own state machine. + // Dump all of it. + string tp47 = template.TryGetEffectiveProperty(0x47u, out var tpe) + ? $"0x{tpe.UnsignedValue:X8} (kind={tpe.Kind})" : "ABSENT"; + string tp48 = template.TryGetEffectiveProperty(0x48u, out var tpl) + ? $"0x{tpl.UnsignedValue:X8} (kind={tpl.Kind})" : "ABSENT"; + string tp4b = template.TryGetEffectiveProperty(0x4Bu, out var tpb) + ? tpb.UnsignedValue.ToString() : "ABSENT"; + Console.WriteLine( + $"[maphouse] hotspot template 0x{template.Id:X8} type={template.Type} " + + $"({template.Width}x{template.Height}) states={template.States.Count} " + + $"stateMedia={template.StateMedia.Count} defaultState='{template.DefaultStateName}' " + + $"popupRoot(P0x47)={tp47} popupLayout(P0x48)={tp48} tooltipOn(P0x4B)={tp4b} " + + $"fontDid=0x{template.FontDid:X8} kids={template.Children.Count}"); + foreach (var s in template.States) + { + Console.WriteLine( + $"[maphouse] template state id=0x{s.Key:X} name='{s.Value.Name}' " + + $"props={s.Value.Properties.Values.Count} " + + $"image={(s.Value.Image is { } img ? $"0x{img.File:X8}/{img.DrawMode}" : "none")}"); + foreach (var pv in s.Value.Properties.Values) + Console.WriteLine( + $"[maphouse] t-prop 0x{pv.Key:X} kind={pv.Value.Kind} " + + $"u=0x{pv.Value.UnsignedValue:X} i={pv.Value.IntegerValue} b={pv.Value.BoolValue} " + + $"color={pv.Value.ColorValue}"); + } + foreach (var m in template.StateMedia) + Console.WriteLine( + $"[maphouse] template stateMedia '{m.Key}' file=0x{m.Value.File:X8} drawMode={m.Value.DrawMode}"); + foreach (ElementInfo tc in template.Children) + { + Console.WriteLine( + $"[maphouse] template kid 0x{tc.Id:X8} type={tc.Type} " + + $"({tc.X},{tc.Y} {tc.Width}x{tc.Height}) stateMedia={tc.StateMedia.Count} " + + $"states={tc.States.Count} defaultState='{tc.DefaultStateName}' kids={tc.Children.Count} " + + $"origParent={(tc.HasOriginalParentSize ? $"{tc.OriginalParentWidth}x{tc.OriginalParentHeight}" : "none")} " + + $"edges=({tc.Left},{tc.Top},{tc.Right},{tc.Bottom})"); + foreach (ElementInfo gk in tc.Children) + Console.WriteLine( + $"[maphouse] kid-kid 0x{gk.Id:X8} type={gk.Type} " + + $"({gk.X},{gk.Y} {gk.Width}x{gk.Height}) stateMedia={gk.StateMedia.Count} " + + $"origParent={(gk.HasOriginalParentSize ? $"{gk.OriginalParentWidth}x{gk.OriginalParentHeight}" : "none")} " + + $"edges=({gk.Left},{gk.Top},{gk.Right},{gk.Bottom})"); + foreach (var m in tc.StateMedia) + Console.WriteLine( + $"[maphouse] kid stateMedia '{m.Key}' file=0x{m.Value.File:X8} drawMode={m.Value.DrawMode}"); + foreach (var s in tc.States) + { + Console.WriteLine( + $"[maphouse] kid state id=0x{s.Key:X} name='{s.Value.Name}' " + + $"props={s.Value.Properties.Values.Count} " + + $"image={(s.Value.Image is { } kimg ? $"0x{kimg.File:X8}/{kimg.DrawMode}" : "none")}"); + foreach (var pv in s.Value.Properties.Values) + Console.WriteLine( + $"[maphouse] prop 0x{pv.Key:X} kind={pv.Value.Kind} " + + $"u=0x{pv.Value.UnsignedValue:X} i={pv.Value.IntegerValue} f={pv.Value.FloatValue} " + + $"b={pv.Value.BoolValue} color={pv.Value.ColorValue}"); + } + } + + // Comparison sweep: all four popup skins in 0x21000041 — + // root media + the INCORPORATED text child 0x10000396's + // per-parent effective font (child-table merge can + // override per skin; the finding-3 question is whether + // the map-note skin's text child fonts differently from + // the generic 0x10000395's). + foreach (uint skinId in new[] { 0x10000487u, 0x10000395u, 0x10000397u, 0x10000398u }) + { + ElementInfo? s41 = LayoutImporter.ImportInfos(dats, 0x21000041u, skinId); + if (s41 is null) + { + Console.WriteLine($"[maphouse] skin-sweep 0x{skinId:X8} IMPORT NULL"); + continue; + } + string rootMedia = s41.StateMedia.TryGetValue("", out var rm) + ? $"0x{rm.File:X8}/{rm.DrawMode}" : "none"; + ElementInfo? text = null; + foreach (ElementInfo k in s41.Children) + if (k.Id == s41.TooltipTextChildElementId) { text = k; break; } + Console.WriteLine( + $"[maphouse] skin-sweep 0x{skinId:X8} ({s41.Width}x{s41.Height}) rootMedia={rootMedia} " + + $"textChild=0x{s41.TooltipTextChildElementId:X8} " + + $"textFontDid=0x{text?.FontDid ?? 0u:X8} " + + $"textColor={(text?.FontColor is { } fc ? fc.ToString() : "none")} " + + $"textSize={text?.Width}x{text?.Height}"); + } + + // Resolve the popup SKIN the note's tooltip would mount: + // authored P0x48, else fallback to the template's own + // source layout (StartTooltipAtMouse @0x00460E7E). + uint popupLayout = template.TryGetEffectiveProperty(0x48u, out var pl) && pl.UnsignedValue != 0 + ? (uint)pl.UnsignedValue + : (uint)tl.UnsignedValue; + uint popupRoot = template.TryGetEffectiveProperty(0x47u, out var pr) + ? (uint)pr.UnsignedValue : 0u; + if (popupRoot != 0u) + { + ElementInfo? skin = LayoutImporter.ImportInfos(dats, popupLayout, popupRoot); + if (skin is null) + { + Console.WriteLine( + $"[maphouse] popup skin 0x{popupRoot:X8} in 0x{popupLayout:X8} IMPORT NULL"); + } + else + { + Console.WriteLine( + $"[maphouse] popup skin 0x{skin.Id:X8} in 0x{popupLayout:X8} type={skin.Type} " + + $"({skin.Width}x{skin.Height}) textChild(P0x4A)=0x{skin.TooltipTextChildElementId:X8} " + + $"stateMedia={skin.StateMedia.Count} kids={skin.Children.Count}"); + foreach (var m in skin.StateMedia) + Console.WriteLine( + $"[maphouse] skin stateMedia '{m.Key}' file=0x{m.Value.File:X8} drawMode={m.Value.DrawMode}"); + DumpSkinTree(skin, " "); + } + } + } } } @@ -194,6 +316,20 @@ public sealed class MapHousePanelSlotProbeTests } } + private static void DumpSkinTree(ElementInfo info, string indent) + { + foreach (ElementInfo c in info.Children) + { + Console.WriteLine( + $"[maphouse] {indent}skin kid 0x{c.Id:X8} type={c.Type} ({c.X},{c.Y} {c.Width}x{c.Height}) " + + $"fontDid=0x{c.FontDid:X8} stateMedia={c.StateMedia.Count} kids={c.Children.Count}"); + foreach (var m in c.StateMedia) + Console.WriteLine( + $"[maphouse] {indent} media '{m.Key}' file=0x{m.Value.File:X8} drawMode={m.Value.DrawMode}"); + DumpSkinTree(c, indent + " "); + } + } + private static bool FindInfo(ElementInfo info, uint id) { if (info.Id == id) return true; diff --git a/tests/AcDream.App.Tests/UI/Layout/MapNoteLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/MapNoteLiveDatTests.cs new file mode 100644 index 00000000..c7aa6de7 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/MapNoteLiveDatTests.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AcDream.App.UI.Layout; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// 2026-08-17 morning gate finding 3 installed-DAT pins: the map town-hotspot +/// template's OWN authored tooltip locator + rollover-highlight mechanism, +/// re-derived after the user's retail screenshot (green marker highlight on +/// hover, parchment-banner tooltip in a special font) refuted the earlier +/// "template authors no locator of its own" recon. Follows +/// 's [InstalledDatFact] pattern. +/// +/// +/// The derivation chain (raw-DAT-dumped + MapHousePanelSlotProbeTests): +/// m_pMap (0x100001EC) authors P0x47=0x100001F0/ +/// P0x48=0x21000026 — the note CONSTRUCTION template +/// (gmMapUI::AddMapNote @0x004a1bb0's CreateChildElement args). +/// The template's DirectState then authors the note's OWN tooltip popup +/// locator P0x47=0x10000398/P0x48=0x21000041, a zero +/// per-element tooltip delay (P0x50=0.0), P0x4B TooltipOn, and +/// P0x13 RolloverEnabled; its Normal/Normal_rollover +/// states are PassToChildren descriptors driving the highlight child +/// 0x100001F1 (base 0x100002B7@0x21000042 — a +/// four-piece frame of pure-green 0x06004CC9, byte-decoded +/// A=FF R=00 G=FF B=00) via per-state P0x3B. +/// +/// +public sealed class MapNoteLiveDatTests +{ + private static string DatDirectory => + System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + + private const uint TemplateLayoutId = 0x21000026u; + private const uint TemplateElementId = 0x100001F0u; + private const uint HighlightChildId = 0x100001F1u; + private const uint MapNoteSkinRootId = 0x10000398u; + private const uint TooltipCatalogLayoutId = 0x21000041u; + private const uint TooltipTextChildId = 0x10000396u; + private const uint MapNoteFontDid = 0x40000015u; + private const uint GenericSkinFontDid = 0x40000002u; + private const uint GreenFrameSurfaceId = 0x06004CC9u; + + [InstalledDatFact] + public void HotspotTemplate_AuthorsItsOwnPopupLocator_ZeroDelay_AndRollover() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + ElementInfo? template = LayoutImporter.ImportInfos(dats, TemplateLayoutId, TemplateElementId); + Assert.NotNull(template); + + // The note's own popup locator — NOT the generic shared skin the + // pre-fix code hardcoded. + Assert.Equal(MapNoteSkinRootId, template!.TooltipRootElementId); + Assert.Equal(TooltipCatalogLayoutId, template.TooltipLayoutDid); + Assert.True(template.TooltipEnabled); // P0x4B + Assert.True(template.TooltipDelaySeconds.HasValue); // P0x50 authored + Assert.Equal(0f, template.TooltipDelaySeconds!.Value); // = 0.0 + + // P0x13 RolloverEnabled — drives UiButtonStateMachine's + // NormalRollover request on pointer-over. + Assert.True(template.TryGetEffectiveBool(0x13u, out bool rollover) && rollover); + + // The two PassToChildren cascade descriptors. + Assert.True(template.States.TryGetValue(1u, out UiStateInfo? normal)); + Assert.True(template.States.TryGetValue(2u, out UiStateInfo? normalRollover)); + Assert.Equal("Normal", normal!.Name); + Assert.Equal("Normal_rollover", normalRollover!.Name); + Assert.True(normal.PassToChildren); + Assert.True(normalRollover.PassToChildren); + } + + [InstalledDatFact] + public void HotspotTemplate_HighlightChild_FlipsPerStateInvisible_WithGreenFrameMedia() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + ElementInfo? template = LayoutImporter.ImportInfos(dats, TemplateLayoutId, TemplateElementId); + Assert.NotNull(template); + + ElementInfo highlight = Assert.Single(template!.Children, c => c.Id == HighlightChildId); + + // Per-state P0x3B: hidden in Normal, shown in Normal_rollover. + Assert.True(highlight.States.TryGetValue(1u, out UiStateInfo? normal)); + Assert.True(highlight.States.TryGetValue(2u, out UiStateInfo? rollover)); + Assert.True(normal!.Properties.TryGetValue(0x3Bu, out var normalInvisible)); + Assert.True(rollover!.Properties.TryGetValue(0x3Bu, out var rolloverInvisible)); + Assert.Equal(UiPropertyKind.Bool, normalInvisible.Kind); + Assert.Equal(UiPropertyKind.Bool, rolloverInvisible.Kind); + Assert.True(normalInvisible.BoolValue); + Assert.False(rolloverInvisible.BoolValue); + + // The base-chain frame (0x100002B7@0x21000042): four edge pieces, + // every one drawing the pure-green line surface 0x06004CC9. + Assert.Equal(4, highlight.Children.Count); + foreach (ElementInfo edge in highlight.Children) + { + Assert.True(edge.StateMedia.TryGetValue("", out var media)); + Assert.Equal(GreenFrameSurfaceId, media.File); + } + } + + [InstalledDatFact] + public void MapNoteSkin_TextChild_FontsTheSpecialFont_UnlikeTheGenericSkins() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + // The four popup skins in 0x21000041 share structure and the SAME + // incorporated text child id — but each skin's child-table merge + // gives 0x10000396 its own effective font, and the map-note skin + // 0x10000398 is the odd one out (0x40000015 vs 0x40000002) — the + // "special/blackletter-style font" of the user's retail screenshot. + var fonts = new Dictionary(); + foreach (uint skinId in new[] { 0x10000487u, 0x10000395u, 0x10000397u, MapNoteSkinRootId }) + { + ElementInfo? skin = LayoutImporter.ImportInfos(dats, TooltipCatalogLayoutId, skinId); + Assert.NotNull(skin); + ElementInfo text = Assert.Single(skin!.Children, c => c.Id == TooltipTextChildId); + fonts[skinId] = text.FontDid; + } + + Assert.Equal(MapNoteFontDid, fonts[MapNoteSkinRootId]); + Assert.Equal(GenericSkinFontDid, fonts[0x10000487u]); + Assert.Equal(GenericSkinFontDid, fonts[0x10000395u]); + Assert.Equal(GenericSkinFontDid, fonts[0x10000397u]); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs index 4cdc83a0..12ac3c0d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs @@ -592,41 +592,103 @@ public sealed class RetailTooltipPresenterTests // ── World-object hover tooltip (docs/ISSUES.md #409 follow-on) ───────── // Port of UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound - // @0x004E5AD0: edge-fired (no dwell wait), gated by PlayerModule:: - // ShowTooltips, uses the fixed popup-skin pair every game-code - // SetTooltip caller in this family shares (see RetailTooltipPresenter's - // own doc note on why UIElement_SmartBoxWrapper's own P0x47/P0x48 - // cannot be read from the installed DAT). + // @0x004E5AD0's tooltip half, CORRECTED at the 2026-08-17 morning gate + // round (user finding 1: retail world tooltips "lag"; ours popped + // instantly). The notice STAGES the name (SetTooltip @0x004E5D74) and + // the DISPLAY rides UIElementManager::CheckTooltip @0x0045B6E0's + // mouse-idle dwell (m_lastMouseMoveTime + m_tooltipDelay, 250 ms + // default); the notice's own immediate StartTooltipAtMouse @0x004E5DFB + // fires ONLY inside the `m_dragElement != 0` branch (@0x004E5D8E — + // drag-and-drop in progress). Gated by PlayerModule::ShowTooltips at + // the edge; uses the fixed popup-skin pair every game-code SetTooltip + // caller in this family shares (see RetailTooltipPresenter's own doc + // note on why UIElement_SmartBoxWrapper's own P0x47/P0x48 cannot be + // read from the installed DAT). private const uint WorldFoundGuid = 0x80000123u; - [Fact] - public void WorldHover_ShowsImmediately_NoDwellWait() + /// A hit-testable drag SOURCE — presses on it become drag-drop + /// candidates and a captured move past the threshold starts the drag + /// (for the m_dragElement != 0 immediate-mount branch). + private sealed class DragSourceTarget : UiElement + { + public override bool IsDragSource => true; + public override object? GetDragPayload() => "payload"; + } + + private static (UiRoot Root, RetailTooltipPresenter Presenter, List<(uint, uint)> Requests) + CreateWorldHarness(Func guidProvider, Func? nameResolver = null, + Func? enabled = null) { var (root, presenter, requests) = CreateHarness(); - presenter.WorldHoverGuidProvider = () => WorldFoundGuid; - presenter.WorldHoverNameResolver = guid => guid == WorldFoundGuid ? "A Drudge" : null; - presenter.WorldTooltipsEnabled = () => true; + presenter.WorldHoverGuidProvider = guidProvider; + presenter.WorldHoverNameResolver = nameResolver ?? (_ => "A Drudge"); + presenter.WorldTooltipsEnabled = enabled ?? (() => true); + return (root, presenter, requests); + } + + [Fact] + public void WorldHover_StagesOnTheFoundEdge_MountsOnlyAfterTheIdleDwell() + { + // THE morning-gate finding-1 pin: a found-object change stages the + // name but mounts NOTHING until the mouse has been idle for the + // dwell delay (CheckTooltip @0x0045b747's m_lastMouseMoveTime + + // m_tooltipDelay test) — the night round's "edge-fired, no dwell" + // reading mounted immediately, which retail only does mid-drag. + var (root, presenter, requests) = CreateWorldHarness(() => WorldFoundGuid); int childrenBefore = root.Children.Count; - // A single Tick — no root.Tick dwell timer involved at all, unlike - // every UI-element case above. - presenter.Tick(); + root.Tick(0.016, 0); + presenter.Tick(); // the found edge fires here — staged, not shown + Assert.Empty(requests); + Assert.Equal(childrenBefore, root.Children.Count); - Assert.Equal(childrenBefore + 1, root.Children.Count); + root.Tick(0.016, root.TooltipDelayMs - 1); + presenter.Tick(); + Assert.Empty(requests); // one ms short of the idle deadline + + root.Tick(0.016, root.TooltipDelayMs); + presenter.Tick(); Assert.Single(requests, r => r == (0x21000041u, 0x10000395u)); + Assert.Equal(childrenBefore + 1, root.Children.Count); + } + + [Fact] + public void WorldHover_MouseMovingContinuously_NeverMountsUntilItRests() + { + // The user-visible half of finding 1: sweeping the cursor across + // NPCs shows NO tooltips in retail — every move restamps + // m_lastMouseMoveTime (MouseMoveHandler @0x0045e736) so the dwell + // deadline never arrives; the popup appears only once the mouse + // RESTS for the delay. + var (root, presenter, requests) = CreateWorldHarness(() => WorldFoundGuid); + + for (long t = 0; t <= 2000; t += 100) // 100 ms between moves < 250 ms dwell + { + root.Tick(0.016, t); + root.OnMouseMove(100 + (int)(t / 10), 100); + presenter.Tick(); + } + Assert.Empty(requests); + + // Rest: no further moves; the dwell elapses from the LAST move. + root.Tick(0.016, 2000 + root.TooltipDelayMs); + presenter.Tick(); + Assert.Single(requests); } [Fact] public void WorldHover_HidesWhenTheFoundGuidClears() { - var (root, presenter, _) = CreateHarness(); + // found -> 0 stages EMPTY text (ClearTooltip @0x004E5E30 = + // SetTooltip(empty)) whose text-change teardown (@0x004617FF) + // removes the showing popup IMMEDIATELY — the teardown edge is not + // dwell-delayed, only the mount is. uint? found = WorldFoundGuid; - presenter.WorldHoverGuidProvider = () => found; - presenter.WorldHoverNameResolver = _ => "A Drudge"; - presenter.WorldTooltipsEnabled = () => true; + var (root, presenter, _) = CreateWorldHarness(() => found); int childrenBefore = root.Children.Count; + root.Tick(0.016, root.TooltipDelayMs); presenter.Tick(); Assert.Equal(childrenBefore + 1, root.Children.Count); @@ -639,15 +701,15 @@ public sealed class RetailTooltipPresenterTests [Fact] public void WorldHover_ShowTooltipsOff_ShowsNothing() { - // PlayerModule::ShowTooltips @0x004E5D21 gates the whole block — - // UpdateCursorState (the found-cursor swap) is NOT gated by it, but - // that is a separate mechanism this presenter does not own. - var (root, presenter, requests) = CreateHarness(); - presenter.WorldHoverGuidProvider = () => WorldFoundGuid; - presenter.WorldHoverNameResolver = _ => "A Drudge"; - presenter.WorldTooltipsEnabled = () => false; + // PlayerModule::ShowTooltips @0x004E5D21 gates the whole staging + // block — UpdateCursorState (the found-cursor swap) is NOT gated by + // it, but that is a separate mechanism this presenter does not own. + var (root, presenter, requests) = CreateWorldHarness( + () => WorldFoundGuid, enabled: () => false); int childrenBefore = root.Children.Count; + root.Tick(0.016, root.TooltipDelayMs + 50); + presenter.Tick(); presenter.Tick(); Assert.Empty(requests); @@ -657,11 +719,13 @@ public sealed class RetailTooltipPresenterTests [Fact] public void WorldHover_NoNameResolved_ShowsNothing() { - var (_, presenter, requests) = CreateHarness(); - presenter.WorldHoverGuidProvider = () => WorldFoundGuid; - presenter.WorldHoverNameResolver = _ => null; - presenter.WorldTooltipsEnabled = () => true; + // @0x004E5D48: an empty resolved name skips SetTooltip entirely — + // with nothing previously staged, nothing ever mounts. + var (root, presenter, requests) = CreateWorldHarness( + () => WorldFoundGuid, nameResolver: _ => null); + root.Tick(0.016, root.TooltipDelayMs + 50); + presenter.Tick(); presenter.Tick(); Assert.Empty(requests); @@ -675,47 +739,43 @@ public sealed class RetailTooltipPresenterTests // either way the found-object pipeline here must not also fire for // whatever the mouse is currently over. This port narrows that to // "no UI element hovered at all" (see the class's own doc note). - var (root, presenter, requests) = CreateHarness(); + var (root, presenter, requests) = CreateWorldHarness(() => WorldFoundGuid); var uiElement = new HoverTarget { Left = 100, Top = 100, Width = 40, Height = 20 }; root.AddChild(uiElement); root.OnMouseMove(110, 110); - presenter.WorldHoverGuidProvider = () => WorldFoundGuid; - presenter.WorldHoverNameResolver = _ => "A Drudge"; - presenter.WorldTooltipsEnabled = () => true; - + root.Tick(0.016, root.TooltipDelayMs + 50); presenter.Tick(); Assert.Empty(requests); } [Fact] - public void WorldHover_FoundObjectChangesDirectly_ReplacesThePopupWithoutStacking() + public void WorldHover_IdleFoundSwap_ReplacesThePopupTheSameFrame_WithoutStacking() { - // #409 follow-on (2026-08-16 overnight hover/UI round, Batch A bug 1): - // the regression that filled the user's screen with dozens of - // stacked tooltips. Walking past a run of NPCs/doors/lifestones never - // produces a frame where the found guid is 0 — it goes straight from - // A to B to C. RecvNotice_SmartBoxObjectFound-equivalent must still - // only ever have ONE popup mounted: found A, then found B (no - // intervening "nothing found" tick) must swap the popup, not add a - // second one on top of the first. + // Two mechanisms in one scenario. (1) Timing: with the mouse IDLE + // and a popup up, a found A -> B change swaps the popup the SAME + // frame — SetTooltip's text-change teardown (@0x004617FF + // ResetTooltip) tail-calls CheckTooltip, whose dwell deadline + // passed long ago, so the replacement mounts with no new wait. + // (2) The single-slot invariant (#409 follow-on, 2026-08-16 + // overnight round Batch A bug 1): walking past a run of NPCs/ + // doors/lifestones never produces a "nothing found" frame — A->B->C + // must swap ONE mounted popup, never orphan-stack the old ones. const uint otherGuid = 0x80000456u; - var (root, presenter, requests) = CreateHarness(); uint current = WorldFoundGuid; - presenter.WorldHoverGuidProvider = () => current; - presenter.WorldHoverNameResolver = guid => - guid == WorldFoundGuid ? "A Drudge" : "A Door"; - presenter.WorldTooltipsEnabled = () => true; + var (root, presenter, requests) = CreateWorldHarness( + () => current, + nameResolver: guid => guid == WorldFoundGuid ? "A Drudge" : "A Door"); int childrenBefore = root.Children.Count; + root.Tick(0.016, root.TooltipDelayMs); presenter.Tick(); Assert.Equal(childrenBefore + 1, root.Children.Count); current = otherGuid; - presenter.Tick(); + presenter.Tick(); // same frame: teardown + idle remount - // Exactly one popup, not two stacked. Assert.Equal(childrenBefore + 1, root.Children.Count); Assert.Equal(2, requests.Count); @@ -731,6 +791,89 @@ public sealed class RetailTooltipPresenterTests Assert.Equal(childrenBefore + 1, root.Children.Count); } + [Fact] + public void WorldHover_AutoHidesAfterTheDuration_AndRemountsOnlyAfterAMouseMovePlusDwell() + { + // CheckTooltip's duration expiry (@0x0045b78a, m_tooltipDuration = + // 10 s @0x0045f767) tears the popup down AND runs + // SwitchMouseOver(null) (@0x0045b7b2) — m_pElementLastEntered goes + // null, so the dwell CANNOT re-arm until the next real mouse move + // re-enters the wrapper. Without that latch the port would remount + // one frame later (text still staged, mouse still idle) in a 10 s + // flicker loop. + var (root, presenter, requests) = CreateWorldHarness(() => WorldFoundGuid); + int childrenBefore = root.Children.Count; + + root.Tick(0.016, root.TooltipDelayMs); + presenter.Tick(); + Assert.Equal(childrenBefore + 1, root.Children.Count); + + long expiry = root.TooltipDelayMs + root.TooltipDurationMs; + root.Tick(0.016, expiry); + presenter.Tick(); + Assert.Equal(childrenBefore, root.Children.Count); // auto-hidden + + root.Tick(0.016, expiry + 500); + presenter.Tick(); + Assert.Equal(childrenBefore, root.Children.Count); // idle but latched — no flicker remount + Assert.Single(requests); + + long moveAt = expiry + 600; + root.Tick(0.016, moveAt); + root.OnMouseMove(5, 5); // re-enter; dwell restarts from this move + presenter.Tick(); + Assert.Single(requests); // dwell not yet elapsed + + root.Tick(0.016, moveAt + root.TooltipDelayMs); + presenter.Tick(); + Assert.Equal(2, requests.Count); + Assert.Equal(childrenBefore + 1, root.Children.Count); + } + + [Fact] + public void WorldHover_DragInProgress_MountsImmediatelyOnTheFoundEdge_NoDwell() + { + // The ONE immediate path in RecvNotice_SmartBoxObjectFound: + // @0x004E5D8E gates ResetTooltip + StartTooltipAtMouse + // (@0x004E5DF0/@0x004E5DFB) on UIElementManager's m_dragElement — + // while dragging an item over the world, the drop target's name + // shows at once, dwell or no dwell. + uint? found = null; + var (root, presenter, requests) = CreateWorldHarness(() => found); + var source = new DragSourceTarget { Left = 100, Top = 100, Width = 40, Height = 20 }; + root.AddChild(source); + int childrenBefore = root.Children.Count; + + root.Tick(0.016, 0); + root.OnMouseDown(UiMouseButton.Left, 110, 110); + root.OnMouseMove(130, 130); // beyond the 3px threshold -> BeginDrag + Assert.NotNull(root.DragSource); + root.OnMouseMove(300, 300); // over the world, mid-drag, mouse JUST moved + + found = WorldFoundGuid; + presenter.Tick(); // the found edge, zero idle time + + Assert.Single(requests); + Assert.Equal(childrenBefore + 1, root.Children.Count); + } + + [Fact] + public void WorldHover_GlobalEnableOff_SuppressesTheDwellMount() + { + // The dwell-mounted path goes through UIElement::MouseHover, whose + // m_tooltipEnable gate (@0x0046254C) this presenter models as + // Enabled — unlike the drag-immediate branch, which calls + // StartTooltipAtMouse directly and bypasses MouseHover entirely. + var (root, presenter, requests) = CreateWorldHarness(() => WorldFoundGuid); + presenter.Enabled = false; + + root.Tick(0.016, root.TooltipDelayMs + 50); + presenter.Tick(); + presenter.Tick(); + + Assert.Empty(requests); + } + [Fact] public void WorldHover_ThenUiDwellTooltip_ReplacesRatherThanStacks() { @@ -738,19 +881,18 @@ public sealed class RetailTooltipPresenterTests // showing, then the mouse settles on a real UI element (dwell path) // — OnTooltipShow's own unconditional RemovePopup() must clear the // world popup, leaving exactly one popup (the UI one), not two. - var (root, presenter, _) = CreateHarness(); - presenter.WorldHoverGuidProvider = () => WorldFoundGuid; - presenter.WorldHoverNameResolver = _ => "A Drudge"; - presenter.WorldTooltipsEnabled = () => true; + var (root, presenter, _) = CreateWorldHarness(() => WorldFoundGuid); int childrenBefore = root.Children.Count; + root.Tick(0.016, root.TooltipDelayMs); presenter.Tick(); Assert.Equal(childrenBefore + 1, root.Children.Count); // world tooltip up var target = AddFullyAuthoredTarget(root); + long moveAt = root.TooltipDelayMs + 10; + root.Tick(0.016, moveAt); root.OnMouseMove(110, 110); - root.Tick(0.016, 0); - root.Tick(0.016, root.TooltipDelayMs); + root.Tick(0.016, moveAt + root.TooltipDelayMs); UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target)); Assert.NotNull(popup); @@ -786,6 +928,10 @@ public sealed class RetailTooltipPresenterTests // _worldTooltipShowing, which is FALSE here (the currently-mounted // popup is UI-owned, not world-owned) — pre-fix, this let the world // path mount a SECOND popup on top without ever clearing the first. + // Post-finding-1: the mouse has been idle since the UI dwell fired, + // so the world dwell deadline is ALSO already met and the world + // popup mounts on this same Tick (through TryBuildAndMountPopup's + // unconditional clear). var (root, presenter, _) = CreateHarness(); var target = AddFullyAuthoredTarget(root); int childrenBefore = root.Children.Count; @@ -817,13 +963,15 @@ public sealed class RetailTooltipPresenterTests // RecvNotice_SmartBoxObjectFound only re-runs when SmartBox:: // set_found_object's target actually changes — a per-frame poll of // the SAME found id must not re-read ShowTooltips or re-resolve the - // name every tick. - var (_, presenter, requests) = CreateHarness(); + // name every tick, and the dwell mount must not rebuild the popup + // on later ticks either. int gateReads = 0, nameReads = 0; - presenter.WorldHoverGuidProvider = () => WorldFoundGuid; - presenter.WorldHoverNameResolver = _ => { nameReads++; return "A Drudge"; }; - presenter.WorldTooltipsEnabled = () => { gateReads++; return true; }; + var (root, presenter, requests) = CreateWorldHarness( + () => WorldFoundGuid, + nameResolver: _ => { nameReads++; return "A Drudge"; }, + enabled: () => { gateReads++; return true; }); + root.Tick(0.016, root.TooltipDelayMs); presenter.Tick(); presenter.Tick(); presenter.Tick(); @@ -844,11 +992,10 @@ public sealed class RetailTooltipPresenterTests // resolver is free to return whatever plain text it wants and the // presenter applies it verbatim (no separate count formatting is // ever added by this class). - var (root, presenter, _) = CreateHarness(); - presenter.WorldHoverGuidProvider = () => WorldFoundGuid; - presenter.WorldHoverNameResolver = _ => "Iron Bars"; - presenter.WorldTooltipsEnabled = () => true; + var (root, presenter, _) = CreateWorldHarness( + () => WorldFoundGuid, nameResolver: _ => "Iron Bars"); + root.Tick(0.016, root.TooltipDelayMs); presenter.Tick(); UiElement popup = Assert.Single(root.Children); diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs index faeabee7..5ecb0cd1 100644 --- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs +++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs @@ -589,6 +589,99 @@ public class UiButtonTests return CreateButton(info); } + // ── 2026-08-17 morning gate finding 3: the PassToChildren cascade + + // per-state P0x3B (Invisible) honor. Fixture mirrors the live map + // town-hotspot template (0x100001F0 in 0x21000026, raw-DAT-dumped): + // a media-less RolloverEnabled button whose Normal/Normal_rollover + // descriptors are PassToChildren and whose highlight child authors + // Normal={0x3B:true} / Normal_rollover={0x3B:false}. ── + + private static ElementInfo MapNoteShapedInfo(bool passToChildren = true) + { + var info = new ElementInfo { Type = 1, Width = 10, Height = 10 }; + AddBoolProperty(info, 0x13u, true); // P0x13 RolloverEnabled + info.States[UiButtonStateMachine.Normal] = new UiStateInfo + { + Id = UiButtonStateMachine.Normal, + Name = "Normal", + PassToChildren = passToChildren, + }; + info.States[UiButtonStateMachine.NormalRollover] = new UiStateInfo + { + Id = UiButtonStateMachine.NormalRollover, + Name = "Normal_rollover", + PassToChildren = passToChildren, + }; + return info; + } + + private static UiDatElement HighlightChild() + { + var info = new ElementInfo { Type = 3, Width = 10, Height = 10 }; + var normal = new UiStateInfo { Id = 1, Name = "Normal" }; + normal.Properties.Values[0x3Bu] = new UiPropertyValue + { Kind = UiPropertyKind.Bool, BoolValue = true }; + var rollover = new UiStateInfo { Id = 2, Name = "Normal_rollover" }; + rollover.Properties.Values[0x3Bu] = new UiPropertyValue + { Kind = UiPropertyKind.Bool, BoolValue = false }; + info.States[1] = normal; + info.States[2] = rollover; + return new UiDatElement(info, NoTex); + } + + [Fact] + public void PassToChildrenStates_CascadeToStatefulChildren_DrivingPerStateInvisible() + { + // Retail UIElement::SetState @0x00464E70's PassToChildren cascade + + // OnSetAttribute @0x00462d80 case 8 (P0x3B -> SetVisible(value==0)): + // the map marker's green rollover frame is hidden at rest and shown + // only while the pointer is over the button. + var b = CreateButton(MapNoteShapedInfo()); + UiDatElement kid = HighlightChild(); + b.AddChild(kid); + Assert.True(kid.Visible); // pre-cascade construction default + + // The initial Normal application (retail's own initial UpdateState_) + // hides the highlight. + Assert.True(b.TrySetRetailState(UiButtonStateMachine.Normal)); + Assert.False(kid.Visible); + + b.OnEvent(new UiEvent(0, b, UiEventType.HoverEnter)); + Assert.True(kid.Visible); // Normal_rollover: P0x3B=false -> shown + + b.OnEvent(new UiEvent(0, b, UiEventType.HoverLeave)); + Assert.False(kid.Visible); // back to Normal: P0x3B=true -> hidden + } + + [Fact] + public void StatesWithoutPassToChildren_DoNotCascade() + { + var b = CreateButton(MapNoteShapedInfo(passToChildren: false)); + UiDatElement kid = HighlightChild(); + b.AddChild(kid); + + b.TrySetRetailState(UiButtonStateMachine.Normal); + b.OnEvent(new UiEvent(0, b, UiEventType.HoverEnter)); + + Assert.True(kid.Visible); // never cascaded, never hidden/shown + } + + [Fact] + public void UiDatElement_TrySetRetailState_HonorsPerStateInvisible() + { + // The child half in isolation — the state-transition application of + // dat property 0x3B, distinct from ElementReader.Invisible's + // construction-time effective read (GF-13/AP-230). + UiDatElement kid = HighlightChild(); + Assert.True(kid.Visible); + + Assert.True(kid.TrySetRetailState(1u)); + Assert.False(kid.Visible); + + Assert.True(kid.TrySetRetailState(2u)); + Assert.True(kid.Visible); + } + private static ElementInfo ButtonInfo(params string[] states) { var info = new ElementInfo { Type = 1, Width = 20, Height = 20 }; diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs index 71133b14..e2868eb6 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs @@ -6,10 +6,13 @@ using AcDream.Runtime.Gameplay; namespace AcDream.Runtime.Tests.Gameplay; /// -/// House-tab conformance (Batch C, 2026-08-17): the ONE decomp-verified -/// line gmHouseUI::DisplayPurchaseTimeText @0x004a3110 emits for a -/// houseless/fresh character, and the wait-period-not-expired case that -/// stays empty (unrecoverable strftime format, ISSUES #413 item 2). +/// House-tab conformance (Batch C, 2026-08-17; two-line correction at the +/// same-day morning gate round): the houseless case renders retail's exact +/// TWO lines in builder order — gmHouseUI::DisplayBuyPayment +/// @0x004a2b30's houseless branch ("You do not currently own a +/// house.", byte-decoded data_7ab688 — the m_pHouseData gate only +/// selects WHICH text; the emit is unconditional) followed by +/// DisplayPurchaseTimeText @0x004a3110's wait-period line. /// public sealed class RuntimeHouseStateTests { @@ -28,7 +31,7 @@ public sealed class RuntimeHouseStateTests } [Fact] - public void HouseStatus_FreshCharacterWithNoTimestamp_ShowsBuyImmediatelyLine() + public void HouseStatus_FreshCharacterWithNoTimestamp_ShowsBothHouselessLines() { var objects = new ClientObjectTable(); objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature }); @@ -40,7 +43,16 @@ public sealed class RuntimeHouseStateTests house.ApplyHouseStatus(weenieError: 0u, Self); Assert.True(house.HasReceivedNotice); - Assert.Equal(["You may buy another house immediately."], house.Lines); + // 2026-08-17 morning gate finding 2 (user retail screenshot): + // BOTH lines, in gmHouseUI::Update's fixed builder order — + // DisplayBuyPayment's houseless literal first, then + // DisplayPurchaseTimeText's expired-branch literal. + Assert.Equal( + [ + "You do not currently own a house.", + "You may buy another house immediately.", + ], + house.Lines); } [Fact] @@ -98,7 +110,11 @@ public sealed class RuntimeHouseStateTests clock.Advance(TimeSpan.FromDays(29)); house.ApplyHouseStatus(weenieError: 0u, Self); - string line = Assert.Single(house.Lines); + // Houseless -> DisplayBuyPayment's line precedes the purchase-time + // line; the not-expired branch supplies the second. + Assert.Equal(2, house.Lines.Count); + Assert.Equal("You do not currently own a house.", house.Lines[0]); + string line = house.Lines[1]; Assert.StartsWith("You may buy another landscape house at ", line); Assert.EndsWith(". This restriction does not apply to apartments.", line); } @@ -124,12 +140,14 @@ public sealed class RuntimeHouseStateTests house.ApplyHouseStatus(weenieError: 0u, Self); DateTime expectedExpiry = purchaseTime.AddSeconds(0x278d00).UtcDateTime; - string line = Assert.Single(house.Lines); - Assert.Contains(expectedExpiry.ToString(System.Globalization.CultureInfo.CurrentCulture), line); + Assert.Equal(2, house.Lines.Count); + Assert.Contains( + expectedExpiry.ToString(System.Globalization.CultureInfo.CurrentCulture), + house.Lines[1]); } [Fact] - public void HouseStatus_TimestampPastThirtyDayWindow_ShowsBuyImmediatelyLine() + public void HouseStatus_TimestampPastThirtyDayWindow_ShowsBothHouselessLines() { var clock = new ManualTimeProvider(); var objects = new ClientObjectTable(); @@ -143,7 +161,12 @@ public sealed class RuntimeHouseStateTests clock.Advance(TimeSpan.FromDays(31)); house.ApplyHouseStatus(weenieError: 0u, Self); - Assert.Equal(["You may buy another house immediately."], house.Lines); + Assert.Equal( + [ + "You do not currently own a house.", + "You may buy another house immediately.", + ], + house.Lines); } [Fact] @@ -170,7 +193,12 @@ public sealed class RuntimeHouseStateTests house.ApplyHouseStatus(weenieError: 0u, Self); - Assert.Equal(["You may buy another house immediately."], house.Lines); + Assert.Equal( + [ + "You do not currently own a house.", + "You may buy another house immediately.", + ], + house.Lines); } private static GameEvents.HouseData SampleHouseData() => new(