Merge campaign-hover-ui-round: morning gate fixes — world-tooltip dwell, House two-liner, map marker highlight + authored skin
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / portable-launcher (ubuntu-latest) (push) Waiting to run
Headless portability / portable-launcher (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / portable-launcher (ubuntu-latest) (push) Waiting to run
Headless portability / portable-launcher (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The instant world-tooltip path is drag-only (@0x004E5D8E gate) — ordinary hover stages the name and rides CheckTooltip's mouse-idle dwell; ported. DisplayBuyPayment emits in BOTH branches — houseless push 0x7ab688 = 'You do not currently own a house.' (BN vftable-mislabel hid it); the tab now shows retail's two lines. The map hotspot template authors its own popup locator (fourth skin 0x10000398, font 0x40000015, P0x50=0 instant) plus a pure-green highlight frame via PassToChildren state cascade — override removed, cascade ported (named-states-only P0x3B honor, #408-scoped). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
80495dc92a
18 changed files with 1320 additions and 229 deletions
102
docs/ISSUES.md
102
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 <literal>` 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)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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<ElementInfo, UiElement?> IconBuilder);
|
||||
Func<ElementInfo, UiElement?> 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<uint, uint, ElementInfo?>? 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 <c>if (eax_10 != 0)</c> before the loop.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>2026-08-17 morning gate finding 3 (live-DAT re-derivation,
|
||||
/// <c>MapHousePanelSlotProbeTests</c>):</b> the hotspot template
|
||||
/// (<c>0x100001F0</c> in <c>0x21000026</c>) is NOT "a plain 10x10
|
||||
/// hotspot dot" — its DirectState authors the full retail note
|
||||
/// behavior: its OWN tooltip-popup locator
|
||||
/// <c>P0x47=0x10000398</c>/<c>P0x48=0x21000041</c> (the map-note popup
|
||||
/// skin, whose incorporated text child <c>0x10000396</c> fonts
|
||||
/// <c>0x40000015</c> — the other three skins font <c>0x40000002</c>),
|
||||
/// a zero per-element tooltip delay (<c>P0x50=0.0</c> — town tooltips
|
||||
/// fire the instant the dwell arms), <c>P0x4B</c> TooltipOn, and
|
||||
/// <c>P0x13</c> RolloverEnabled. Its <c>Normal</c>/<c>Normal_rollover</c>
|
||||
/// states are PassToChildren descriptors driving the highlight child
|
||||
/// <c>0x100001F1</c> (base <c>0x100002B7</c>@<c>0x21000042</c> — a
|
||||
/// four-piece frame of pure-green <c>0x06004CC9</c>, byte-decoded
|
||||
/// A=FF R=00 G=FF B=00) via per-state <c>P0x3B</c>: 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
|
||||
/// <see cref="LayoutImporter"/> from the template's property bag) wins.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void BuildTownMarkers(ElementInfo? mapInfo, Func<uint, uint, UiElement?> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Night-round review F10: previously duplicated as three separate
|
||||
/// private constants with three separate partial citations — this
|
||||
/// class's own world-hover popup (below), <c>UiItemSlot</c>'s item-cell
|
||||
/// popup, and <c>MapPageController</c>'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
|
||||
/// (<c>OnTooltipShow</c>/<c>UpdateWorldHoverTooltip</c> 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 <c>UiItemSlot</c>'s item-cell popup.
|
||||
/// <c>MapPageController</c>'s town markers were REMOVED from the list at
|
||||
/// the 2026-08-17 morning gate round (finding 3): the hotspot template
|
||||
/// (<c>0x100001F0</c> in <c>0x21000026</c>) turned out to author its OWN
|
||||
/// popup locator (<c>P0x47=0x10000398</c>/<c>P0x48=0x21000041</c> — the
|
||||
/// fourth skin, whose incorporated text child fonts <c>0x40000015</c>
|
||||
/// where the other three font <c>0x40000002</c>), 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 (<c>OnTooltipShow</c>/<c>UpdateWorldHoverTooltip</c> both call
|
||||
/// <c>TryBuildAndMountPopup</c> with these values or a widget's own).
|
||||
/// </remarks>
|
||||
public const uint SharedPopupSkinRootElementId = 0x10000395u;
|
||||
|
|
@ -357,6 +389,31 @@ public sealed class RetailTooltipPresenter : IDisposable
|
|||
private uint _worldHoverGuid;
|
||||
private bool _worldTooltipShowing;
|
||||
|
||||
/// <summary>The wrapper's staged <c>m_TTText</c> — written at the
|
||||
/// found-object EDGE (<c>SetTooltip @0x004E5D74</c> /
|
||||
/// <c>ClearTooltip @0x004E5E30</c>), displayed only when the dwell
|
||||
/// machinery mounts it. Null/empty = cleared (retail's empty
|
||||
/// <c>StringInfo</c>; <c>StartTooltipAtMouse @0x00460DA3</c>'s
|
||||
/// <c>IsValid</c> test fails and the wrapper authors no <c>P0x49</c>
|
||||
/// fallback, so nothing shows).</summary>
|
||||
private string? _worldStagedText;
|
||||
|
||||
/// <summary>When the world popup mounted — retail
|
||||
/// <c>m_tooltipStart</c>, for the <c>m_tooltipDuration</c> (10 s)
|
||||
/// auto-hide (<c>CheckTooltip @0x0045b78a</c>).</summary>
|
||||
private long _worldTooltipShownMs;
|
||||
|
||||
/// <summary>Set by the duration auto-hide: retail's expiry path calls
|
||||
/// <c>SwitchMouseOver(this, nullptr) @0x0045b7b2</c>, clearing
|
||||
/// <c>m_pElementLastEntered</c> — 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.</summary>
|
||||
private bool _worldRearmRequiresMouseMove;
|
||||
|
||||
private int _worldLastSeenMouseX = int.MinValue;
|
||||
private int _worldLastSeenMouseY = int.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// The world-hover pick (retail's <c>SmartBox::find_object</c> via
|
||||
/// <c>UIElement_SmartBoxWrapper::FindObject @0x004E5430</c>'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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Force-hides whatever tooltip is currently showing, if any.
|
||||
/// Session-reset callers use this (mirrors <see cref="RetailDialogFactory.Reset"/>'s
|
||||
/// own role for dialogs) so a stale popup cannot survive a reconnect.</summary>
|
||||
public void HideCurrent() => RemovePopup();
|
||||
/// <summary>Force-hides whatever tooltip is currently showing, if any,
|
||||
/// and forgets the staged world-hover text/guid. Session-reset callers
|
||||
/// use this (mirrors <see cref="RetailDialogFactory.Reset"/>'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.</summary>
|
||||
public void HideCurrent()
|
||||
{
|
||||
RemovePopup();
|
||||
_worldHoverGuid = 0u;
|
||||
_worldStagedText = null;
|
||||
_worldRearmRequiresMouseMove = false;
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>UIElementManager::StartTooltip @0x0045DE90</c>'s text
|
||||
/// + auto-resize step. Wraps at the display width (retail's
|
||||
|
|
|
|||
|
|
@ -71,4 +71,23 @@ public sealed class RowTemplateResolver
|
|||
}
|
||||
return info is null ? null : _build(info);
|
||||
}
|
||||
|
||||
/// <summary>The cached import half alone — the template's resolved
|
||||
/// <see cref="ElementInfo"/> without building a widget. Same cache, same
|
||||
/// miss contract as <see cref="Resolve"/>. 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.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<string>()),
|
||||
OnShown: mh.HouseShown,
|
||||
|
|
|
|||
|
|
@ -131,6 +131,32 @@ public sealed class RetailUiAutomationProbe
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-17 morning gate: synthetic pointer HOVER (no click) at an
|
||||
/// element's center, for rollover/tooltip verification. Deliberately
|
||||
/// does NOT <see cref="Advance"/> the probe's own micro-clock: hover
|
||||
/// dwell (<see cref="UiRoot.MouseIdleMs"/> vs
|
||||
/// <see cref="UiRoot.TooltipDelayMs"/>) and the world-tooltip timing
|
||||
/// must ride the PRODUCTION frame tick's real monotonic clock
|
||||
/// (<c>UiHost.Tick</c>'s <c>TickCount64</c>), which keeps running
|
||||
/// between script commands — an <c>Advance</c> 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Raw synthetic mouse move (canvas coordinates). Same
|
||||
/// no-<see cref="Advance"/> contract as <see cref="HoverElement"/> —
|
||||
/// used to sweep the pointer across the 3D world for the world-hover
|
||||
/// tooltip's mouse-idle dwell verification.</summary>
|
||||
public void MoveMouse(int x, int y) => _root.OnMouseMove(x, y);
|
||||
|
||||
public bool ClickItem(uint itemGuid, ItemDragSource? sourceKind = null)
|
||||
{
|
||||
var target = FindByItemId(itemGuid, sourceKind);
|
||||
|
|
|
|||
|
|
@ -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 <datId> | click item <guid> [source]");
|
||||
}
|
||||
|
||||
/// <summary>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
|
||||
/// <see cref="RetailUiAutomationProbe.HoverElement"/>'s own doc for the
|
||||
/// deliberate no-<c>Advance</c> clock contract.</summary>
|
||||
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 <datId>");
|
||||
if (!TryParseUInt(p[2], out uint datId)) return Stop(command, $"bad element id '{p[2]}'");
|
||||
return _probe.HoverElement(datId) || Stop(command, "hover element failed");
|
||||
}
|
||||
|
||||
/// <summary>`mousemove <x> <y>` — raw synthetic pointer move in
|
||||
/// canvas coordinates (world-hover dwell sweeps).</summary>
|
||||
private bool DoMouseMove(ScriptCommand command)
|
||||
{
|
||||
var p = command.Parts;
|
||||
if (p.Length != 3)
|
||||
return Stop(command, "usage: mousemove <x> <y>");
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UIElement::SetState @0x00464E70</c>'s PassToChildren
|
||||
/// cascade, ported for buttons (2026-08-17 morning gate finding 3 —
|
||||
/// previously only <see cref="Layout.UiDatElement.TrySetRetailState"/>
|
||||
/// had it). First consumer: the map town-hotspot template
|
||||
/// (<c>0x100001F0</c> in <c>0x21000026</c>) authors media-less
|
||||
/// <c>Normal</c>/<c>Normal_rollover</c> state descriptors with
|
||||
/// <c>PassToChildren=true</c> whose only job is driving the highlight
|
||||
/// child's per-state <c>P0x3B</c> visibility flip (the green
|
||||
/// <c>0x06004CC9</c> rollover frame). No-op unless a state descriptor
|
||||
/// for <paramref name="stateId"/> exists AND authors PassToChildren AND
|
||||
/// this button actually has stateful children — dat-built buttons
|
||||
/// normally have none (<see cref="ConsumesDatChildren"/>); only
|
||||
/// explicitly-attached subtrees (the map markers' highlight, the
|
||||
/// AD-108 icon seam) can receive the cascade.
|
||||
/// </summary>
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -377,6 +377,30 @@ public sealed class UiRoot : UiElement
|
|||
|
||||
private long _nowMs;
|
||||
|
||||
/// <summary>Retail <c>UIElementManager::m_lastMouseMoveTime</c>, ported
|
||||
/// 1:1: stamped UNCONDITIONALLY at the top of every mouse move
|
||||
/// (<c>MouseMoveHandler @0x0045E710</c>, <c>@0x0045e729</c>/<c>@0x0045e736</c>
|
||||
/// — before hit-testing, capture handling, everything) and re-stamped on
|
||||
/// capture release (<c>ReleaseMouseCapture @0x0045D2B0</c>,
|
||||
/// <c>@0x0045d2da</c>). Distinct from <see cref="_hoverStartedMs"/>, whose
|
||||
/// stamps are deliberately conditional (the <c>!_tooltipFired</c> guard in
|
||||
/// <see cref="UpdateHover"/>, no stamp during captured moves) because that
|
||||
/// field also carries <c>m_bHoverStarted</c> interplay. The world-hover
|
||||
/// tooltip's idle-dwell gate (<see cref="Layout.RetailTooltipPresenter"/>)
|
||||
/// needs retail's raw, unconditional timestamp.</summary>
|
||||
private long _lastMouseMoveMs;
|
||||
|
||||
/// <summary>Milliseconds since the last mouse move — retail
|
||||
/// <c>CheckTooltip @0x0045B6E0</c>'s dwell operand
|
||||
/// (<c>@0x0045b747</c>: <c>m_lastMouseMoveTime + delay</c> vs now).</summary>
|
||||
public long MouseIdleMs => _nowMs - _lastMouseMoveMs;
|
||||
|
||||
/// <summary>The clock <see cref="Tick"/> last ran at — retail
|
||||
/// <c>Timer::local_time</c> as the UI tree sees it. Exposed for sibling
|
||||
/// per-frame consumers (<see cref="Layout.RetailTooltipPresenter"/>'s
|
||||
/// world-tooltip duration clock) so they share ONE frame timestamp.</summary>
|
||||
public long NowMs => _nowMs;
|
||||
|
||||
/// <summary>Raised when an event was not consumed by any widget.</summary>
|
||||
public event Action<UiMouseButton, int, int, uint>? 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,26 +17,42 @@ namespace AcDream.Runtime.Gameplay;
|
|||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Retail behavior, exhaustively verified against the decomp before
|
||||
/// writing this class (all seven line builders read, not just the one
|
||||
/// ported here):</b> <c>gmHouseUI::PostInit @0x004a2710</c> never calls
|
||||
/// <c>Update</c>/<c>DisplayHouseData</c> — the House ListBox
|
||||
/// <b>Retail behavior:</b> <c>gmHouseUI::PostInit @0x004a2710</c> never
|
||||
/// calls <c>Update</c>/<c>DisplayHouseData</c> — the House ListBox
|
||||
/// (<c>0x100001e6</c>) starts genuinely empty (live-DAT-confirmed,
|
||||
/// <c>MapHousePanelSlotProbeTests</c>: <c>children=0</c>, and the whole
|
||||
/// House page <c>0x100001F7</c> has NO other static content besides that
|
||||
/// one empty ListBox). Content appears only after a server notice
|
||||
/// (0x0225-0x0228) arrives and <c>Update</c>/<c>DisplayHouseData</c> runs
|
||||
/// the seven <c>Display*</c> builders in order. SIX of them
|
||||
/// (<c>DisplayBuyPayment</c>, <c>DisplayRentPayment</c>,
|
||||
/// <c>DisplayBuyTime</c>, <c>DisplayRentTimes</c>, <c>DisplayLocation</c>,
|
||||
/// the seven <c>Display*</c> builders in fixed order. FIVE of them
|
||||
/// (<c>DisplayRentPayment</c>, <c>DisplayBuyTime</c>,
|
||||
/// <c>DisplayRentTimes</c>, <c>DisplayLocation</c>,
|
||||
/// <c>DisplayWarningText</c>) open with <c>if (this->m_pHouseData != 0)</c>
|
||||
/// and emit NOTHING when houseless — those remain unported, ISSUES #413
|
||||
/// item 3.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The SEVENTH, <c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>, does
|
||||
/// NOT gate on <c>m_pHouseData</c> — it always runs, reading the LOCAL
|
||||
/// PLAYER's own <c>PropertyInt.HousePurchaseTimestamp</c> (0xC7 = 199
|
||||
/// <b>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):</b> the first builder, <c>gmHouseUI::DisplayBuyPayment
|
||||
/// @0x004a2b30</c>, is NOT houseless-silent — its <c>m_pHouseData</c> gate
|
||||
/// only selects WHICH text, and the ListBox emit
|
||||
/// (<c>AddItemFromTemplateList</c> + <c>SetTextWithFont</c>,
|
||||
/// <c>@0x004a2b80</c> onward) runs in BOTH branches. The houseless
|
||||
/// <c>else</c> branch (<c>@0x004a2b57</c>: <c>push 0x7ab688</c>,
|
||||
/// byte-decoded from the PDB-paired binary — Binary Ninja's pseudo-C
|
||||
/// rendered the operand as a spurious <c>&vftable.RecvNotice_*</c>
|
||||
/// symbol match, the same TS-85/F3 artifact class, which is how the night
|
||||
/// round's exe-pool sweep missed it) is the literal <b>"You do not
|
||||
/// currently own a house."</b> — exactly the user's retail screenshot. The
|
||||
/// owned branch (<c>@0x004a2b63</c>, <c>data_7ab65c</c>: <c>"The purchase
|
||||
/// price for this dwelling is:\n"</c> + <c>HousePaymentList::ComposeText</c>)
|
||||
/// remains #413 item-3 scope.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The SEVENTH builder, <c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>,
|
||||
/// also does NOT gate on <c>m_pHouseData</c> — it always runs, reading the
|
||||
/// LOCAL PLAYER's own <c>PropertyInt.HousePurchaseTimestamp</c> (0xC7 = 199
|
||||
/// decimal) via <c>CBaseQualities::InqInt</c> and
|
||||
/// <c>HouseSystem::HasPurchaseWaitPeriodExpired</c>
|
||||
/// (<c>@0x005bb1d0</c>: <c>(Timer::get_real_time() - timestamp) >
|
||||
|
|
@ -44,22 +60,12 @@ namespace AcDream.Runtime.Gameplay;
|
|||
/// seconds; <c>0x278d00</c> = 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
|
||||
/// <c>m_pHouseData == 0</c> (still houseless) and emits the ONE literal
|
||||
/// string at <c>data_7ab7f0</c>: <b>"You may buy another house
|
||||
/// immediately."</b> 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
|
||||
/// <c>data_7ab818</c>, 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 (<c>"You do not own a house!"</c>,
|
||||
/// WeenieError <c>0x45E</c>/<c>0x45F</c>, and <c>"You must own a house to
|
||||
/// use this command."</c>, WeenieError <c>0x47F</c>) routed through the
|
||||
/// GENERIC WeenieError chat dispatcher, never through <c>gmHouseUI</c>'s
|
||||
/// own notice handlers (which discard the wire WeenieError entirely — see
|
||||
/// <see cref="ApplyHouseStatus"/>).
|
||||
/// <c>m_pHouseData == 0</c> (still houseless) and emits the literal at
|
||||
/// <c>data_7ab7f0</c>: <b>"You may buy another house immediately."</b>
|
||||
/// (owns-a-house sibling at <c>data_7ab818</c>, 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The NOT-yet-expired branch of <c>DisplayPurchaseTimeText</c> is now
|
||||
|
|
@ -169,10 +175,27 @@ public sealed class RuntimeHouseState
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary><c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>,
|
||||
/// both branches now ported. Must hold <see cref="_gate"/>.</summary>
|
||||
/// <summary>The ported share of <c>gmHouseUI::Update</c>'s fixed
|
||||
/// seven-builder order: <c>DisplayBuyPayment @0x004a2b30</c>'s
|
||||
/// houseless branch (first) and <c>DisplayPurchaseTimeText
|
||||
/// @0x004a3110</c> (last), both branches. The five houseless-silent
|
||||
/// builders between them, and DisplayBuyPayment's owned branch, are
|
||||
/// ISSUES #413 item 3. Must hold <see cref="_gate"/>.</summary>
|
||||
private void Recompute(uint selfGuid)
|
||||
{
|
||||
var lines = new List<string>(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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ namespace AcDream.App.Tests.UI.Layout;
|
|||
/// </summary>
|
||||
public sealed class MapHousePanelControllerTests
|
||||
{
|
||||
/// <summary>The live hotspot template's own authored popup locator
|
||||
/// (<c>MapHousePanelSlotProbeTests</c>, 2026-08-17 morning gate
|
||||
/// finding 3: DirectState <c>P0x47=0x10000398</c>/<c>P0x48=0x21000041</c>
|
||||
/// — the map-note skin whose text child fonts <c>0x40000015</c>).</summary>
|
||||
private const uint MapNoteSkinRootId = 0x10000398u;
|
||||
private const uint MapNoteSkinLayoutId = 0x21000041u;
|
||||
|
||||
/// <summary>Serves the town-hotspot template. Returns a
|
||||
/// <see cref="UiButton"/> — matching the live template's own authored
|
||||
/// Type 1 (<c>MapHousePanelSlotProbeTests</c>: "hotspot template
|
||||
|
|
@ -25,15 +32,65 @@ public sealed class MapHousePanelControllerTests
|
|||
/// by these tests. A real <see cref="RowTemplateResolver"/> would set
|
||||
/// <c>DatElementId</c> the same way <see cref="LayoutImporter.Build"/>
|
||||
/// does, so tests that need to find these markers back by id after the
|
||||
/// fact need it too.</summary>
|
||||
/// fact need it too. Carries the template's own authored tooltip
|
||||
/// locator (which production <see cref="LayoutImporter"/> 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.</summary>
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>The template's authored state shape (raw-DAT-dumped):
|
||||
/// media-less <c>Normal</c>/<c>Normal_rollover</c> descriptors, both
|
||||
/// <c>PassToChildren=true</c>, plus <c>P0x13</c> RolloverEnabled.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The template's highlight child info (live: <c>0x100001F1</c>,
|
||||
/// base <c>0x100002B7</c>@<c>0x21000042</c> — the green frame), with the
|
||||
/// authored per-state <c>P0x3B</c> flip.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Template-info resolver standing in for
|
||||
/// <c>RowTemplateResolver.ResolveInfo</c>: the template with its
|
||||
/// button-swallowed highlight child attached.</summary>
|
||||
private static ElementInfo? FakeHotspotTemplateInfo(uint layoutId, uint elementId)
|
||||
{
|
||||
ElementInfo info = MapNoteTemplateInfo();
|
||||
info.Children.Add(HighlightChildInfo());
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>The <see cref="MapPageController.Bindings.IconBuilder"/>
|
||||
/// seam: builds m_pMap's two button-swallowed icon children from their
|
||||
/// OWN <see cref="ElementInfo"/>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
|
||||
/// <c>pageInfo</c> and built through this seam instead). Mirrors
|
||||
/// production's <c>LayoutImporter.Build(info, ...).Root</c>, which sets
|
||||
/// <c>DatElementId</c> from the info's own id.</summary>
|
||||
/// <c>DatElementId</c> from the info's own id — and, like production's
|
||||
/// type-driven factory, builds a Type-3 info as a stateful
|
||||
/// <see cref="UiDatElement"/> 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).</summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>The House ListBox's own row template resolves to a
|
||||
/// <see cref="UiText"/> in the live DAT (<c>MapHousePanelSlotProbeTests</c>:
|
||||
|
|
@ -63,7 +131,8 @@ public sealed class MapHousePanelControllerTests
|
|||
Func<DerethDateTime.Calendar>? currentCalendar = null,
|
||||
Func<uint>? playerCellId = null,
|
||||
Func<CreateObject.ServerPosition?>? housePosition = null,
|
||||
Func<IReadOnlyList<string>>? houseLines = null)
|
||||
Func<IReadOnlyList<string>>? houseLines = null,
|
||||
Func<uint, uint, ElementInfo?>? templateInfoResolver = null)
|
||||
{
|
||||
calls ??= new List<string>();
|
||||
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<string>()),
|
||||
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<UiButton>(c));
|
||||
Assert.All(townMarkers, c => Assert.False(string.IsNullOrEmpty(((UiButton)c).TooltipText)));
|
||||
Assert.Contains(townMarkers, c => ((UiButton)c).TooltipText == "Holtburg");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>P0x3B</c>); hovering the marker
|
||||
/// swaps it to <c>Normal_rollover</c> (shown — the green
|
||||
/// <c>0x06004CC9</c> frame in the live DAT).
|
||||
/// </summary>
|
||||
[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<UiButton>()
|
||||
.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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
135
tests/AcDream.App.Tests/UI/Layout/MapNoteLiveDatTests.cs
Normal file
135
tests/AcDream.App.Tests/UI/Layout/MapNoteLiveDatTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="TooltipLiveDatTests"/>'s <c>[InstalledDatFact]</c> pattern.
|
||||
///
|
||||
/// <para>
|
||||
/// The derivation chain (raw-DAT-dumped + <c>MapHousePanelSlotProbeTests</c>):
|
||||
/// <c>m_pMap</c> (<c>0x100001EC</c>) authors <c>P0x47=0x100001F0</c>/
|
||||
/// <c>P0x48=0x21000026</c> — the note CONSTRUCTION template
|
||||
/// (<c>gmMapUI::AddMapNote @0x004a1bb0</c>'s <c>CreateChildElement</c> args).
|
||||
/// The template's DirectState then authors the note's OWN tooltip popup
|
||||
/// locator <c>P0x47=0x10000398</c>/<c>P0x48=0x21000041</c>, a zero
|
||||
/// per-element tooltip delay (<c>P0x50=0.0</c>), <c>P0x4B</c> TooltipOn, and
|
||||
/// <c>P0x13</c> RolloverEnabled; its <c>Normal</c>/<c>Normal_rollover</c>
|
||||
/// states are PassToChildren descriptors driving the highlight child
|
||||
/// <c>0x100001F1</c> (base <c>0x100002B7</c>@<c>0x21000042</c> — a
|
||||
/// four-piece frame of pure-green <c>0x06004CC9</c>, byte-decoded
|
||||
/// A=FF R=00 G=FF B=00) via per-state <c>P0x3B</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<uint, uint>();
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
/// <summary>A hit-testable drag SOURCE — presses on it become drag-drop
|
||||
/// candidates and a captured move past the threshold starts the drag
|
||||
/// (for the <c>m_dragElement != 0</c> immediate-mount branch).</summary>
|
||||
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<uint?> guidProvider, Func<uint, string?>? nameResolver = null,
|
||||
Func<bool>? 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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@ using AcDream.Runtime.Gameplay;
|
|||
namespace AcDream.Runtime.Tests.Gameplay;
|
||||
|
||||
/// <summary>
|
||||
/// House-tab conformance (Batch C, 2026-08-17): the ONE decomp-verified
|
||||
/// line <c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c> 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 — <c>gmHouseUI::DisplayBuyPayment
|
||||
/// @0x004a2b30</c>'s houseless branch ("You do not currently own a
|
||||
/// house.", byte-decoded <c>data_7ab688</c> — the m_pHouseData gate only
|
||||
/// selects WHICH text; the emit is unconditional) followed by
|
||||
/// <c>DisplayPurchaseTimeText @0x004a3110</c>'s wait-period line.
|
||||
/// </summary>
|
||||
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue