fix(ui): morning gate — map town markers: green rollover highlight + the authored map-note tooltip skin/font
User finding 3 (retail screenshot): hovering a town on the Map tab turns its marker GREEN and shows the name on a special-font tooltip — clearly not our generic 0x10000395 popup skin, and we had no hover highlight at all. Re-derivation (live-DAT probe + raw ElementDesc dump + surface byte-decode; MapNoteLiveDatTests pins all of it): - m_pMap (0x100001EC)'s P0x47/P0x48 = 0x100001F0 @ 0x21000026 are the note CONSTRUCTION template (AddMapNote @0x004a1bb0's CreateChildElement args) — that part we had right. - The TEMPLATE's own DirectState authors the note's tooltip popup locator P0x47=0x10000398/P0x48=0x21000041 — the FOURTH popup skin, whose incorporated text child 0x10000396 fonts 0x40000015 where the other three skins font 0x40000002 (the user's "special font") — plus P0x50=0.0 (zero per-element tooltip delay: town tooltips fire the instant the dwell arms; UiRoot already honors it), P0x4B TooltipOn, and P0x13 RolloverEnabled. Batch C's "the template authors no locator of its own" claim was WRONG, and BuildTownMarkers' hardcoded shared-skin override was clobbering the authored values — removed. - The hover highlight: the template's Normal/Normal_rollover states are PassToChildren descriptors driving the swallowed highlight child 0x100001F1 (base 0x100002B7@0x21000042 — a four-piece frame all drawing 0x06004CC9, byte-decoded PURE GREEN A=FF R=00 G=FF B=00) via per-state P0x3B (Invisible): hidden at rest, green on rollover. Port: - UiButton.CascadeStateToChildren — retail UIElement::SetState @0x00464E70's PassToChildren cascade, keyed off the REQUESTED state id (properties commit unconditionally; only the sprite draw is art-gated, the existing #382/AP-222 distinction). - UiDatElement.TrySetRetailState honors per-state P0x3B for NAMED states (OnSetAttribute @0x00462d80 case 8: SetVisible(value==0)). The unnamed-DirectState case is explicitly excluded — honoring it would un-gate ISSUES #408 (1,083 authored-invisible elements) through BuildWidget's post-children state reapply; measured breaking the spell-favorite drag tests before the scoping (note added to #408). - MapPageController.BuildTownMarkers rebuilds the button-swallowed highlight child per marker through the AD-108 IconBuilder seam (Bindings.TemplateInfoResolver, backed by RowTemplateResolver.ResolveInfo — same cache) and arms it with the initial Normal cascade. Register TS-85's Batch C paragraph corrected; RetailTooltipPresenter's F10 shared-skin remark updated (MapPageController no longer a consumer). Tests: 3 installed-DAT pins (locator/delay/rollover; per-state P0x3B + green frame; the four-skin font sweep), UiButton cascade + UiDatElement P0x3B units, MapHousePanel marker no-clobber + hover-highlight fixture. App suite 5487 passed / 3 skips (5490 total, +11 over baseline); Runtime 1744/1744. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ef567bfa20
commit
942a02af11
12 changed files with 682 additions and 46 deletions
|
|
@ -872,6 +872,18 @@ already proven live) and delete `CharacterCreationUiController`'s own
|
||||||
narrow `HideAuthoredInvisibleElements`/AP-230 in the same commit; (4) run
|
narrow `HideAuthoredInvisibleElements`/AP-230 in the same commit; (4) run
|
||||||
a full-client visual matrix, not just chargen.
|
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)
|
## #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)
|
**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 —
|
// ElementInfos only exist inside the full panel-slot resolve tree —
|
||||||
// a cold ImportInfos(hostLayout, iconId) re-import CANNOT find them
|
// a cold ImportInfos(hostLayout, iconId) re-import CANNOT find them
|
||||||
// (register row AD-108's live-DAT finding; see Bind's own doc).
|
// (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? _dateTimeText;
|
||||||
private readonly UiElement? _map;
|
private readonly UiElement? _map;
|
||||||
|
|
@ -259,6 +267,28 @@ public sealed class MapPageController
|
||||||
/// referenced LayoutDesc/element) leaves the map usable without
|
/// referenced LayoutDesc/element) leaves the map usable without
|
||||||
/// hotspots rather than failing the whole page — matches retail's own
|
/// hotspots rather than failing the whole page — matches retail's own
|
||||||
/// null-guarded <c>if (eax_10 != 0)</c> before the loop.
|
/// 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>
|
/// </summary>
|
||||||
private void BuildTownMarkers(ElementInfo? mapInfo, Func<uint, uint, UiElement?> templateResolver)
|
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 (!mapInfo.TryGetEffectiveProperty(HotspotTemplateLayoutAttr, out var templateLayout)) return;
|
||||||
if (templateLayout.UnsignedValue == 0) 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)
|
foreach (MapLocation loc in MapLocations.All)
|
||||||
{
|
{
|
||||||
UiElement? marker = templateResolver(
|
UiElement? marker = templateResolver(
|
||||||
|
|
@ -284,31 +320,39 @@ public sealed class MapPageController
|
||||||
// backing UiElement.GetTooltipText()'s override, which
|
// backing UiElement.GetTooltipText()'s override, which
|
||||||
// RetailTooltipPresenter.ResolveTooltipText consults BEFORE the
|
// RetailTooltipPresenter.ResolveTooltipText consults BEFORE the
|
||||||
// authored text (closes register row TS-85's last item,
|
// authored text (closes register row TS-85's last item,
|
||||||
// gmMapUI::AddMapNote @0x004A1C51). AuthoredTooltipRootElementId/
|
// gmMapUI::AddMapNote @0x004A1C51). The popup SKIN + delay come
|
||||||
// LayoutDid still gate the popup SKIN unconditionally even on
|
// from the marker's own authored properties (see this method's
|
||||||
// the runtime-text path — the map-note template (0x100001F0)
|
// doc — finding 3 removed the shared-skin override that
|
||||||
// authors no individual tooltip-popup locator of its own (a
|
// previously clobbered them here).
|
||||||
// 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.
|
|
||||||
if (marker is UiButton markerButton)
|
if (marker is UiButton markerButton)
|
||||||
markerButton.TooltipText = loc.Name;
|
markerButton.TooltipText = loc.Name;
|
||||||
else
|
else
|
||||||
// F11 (night-round review): silently skipping the runtime-
|
// F11 (night-round review): silently skipping the runtime-
|
||||||
// text write here would leave the marker's popup mounted
|
// text write here would leave the marker's popup authored
|
||||||
// (AuthoredTooltipRootElementId/LayoutDid are still set
|
// but genuinely EMPTY — a live-DAT template change that
|
||||||
// below) but genuinely EMPTY — a live-DAT template change
|
// resolves 0x100001F0 to something other than a UiButton
|
||||||
// that resolves 0x100001F0 to something other than a
|
// would regress every town-marker tooltip with no
|
||||||
// UiButton would regress every town-marker tooltip with no
|
|
||||||
// diagnostic signal at all.
|
// diagnostic signal at all.
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"[D.2b] Map tab: town marker '{loc.Name}' template "
|
$"[D.2b] Map tab: town marker '{loc.Name}' template "
|
||||||
+ $"resolved to {marker.GetType().Name}, not UiButton — "
|
+ $"resolved to {marker.GetType().Name}, not UiButton — "
|
||||||
+ "TooltipText cannot be set, marker will show no tooltip.");
|
+ "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);
|
_map!.AddChild(marker);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -369,13 +369,18 @@ public sealed class RetailTooltipPresenter : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Night-round review F10: previously duplicated as three separate
|
/// Night-round review F10: previously duplicated as three separate
|
||||||
/// private constants with three separate partial citations — this
|
/// private constants with three separate partial citations; this is the
|
||||||
/// class's own world-hover popup (below), <c>UiItemSlot</c>'s item-cell
|
/// ONE citation. Two consumers remain — this class's own world-hover
|
||||||
/// popup, and <c>MapPageController</c>'s town-marker popup. All three
|
/// popup (below) and <c>UiItemSlot</c>'s item-cell popup.
|
||||||
/// consumers now reference these SAME two constants; this is the ONE
|
/// <c>MapPageController</c>'s town markers were REMOVED from the list at
|
||||||
/// citation. This class is the natural owner since it's the mount
|
/// the 2026-08-17 morning gate round (finding 3): the hotspot template
|
||||||
/// point every one of the three consumers ultimately routes through
|
/// (<c>0x100001F0</c> in <c>0x21000026</c>) turned out to author its OWN
|
||||||
/// (<c>OnTooltipShow</c>/<c>UpdateWorldHoverTooltip</c> both call
|
/// 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).
|
/// <c>TryBuildAndMountPopup</c> with these values or a widget's own).
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public const uint SharedPopupSkinRootElementId = 0x10000395u;
|
public const uint SharedPopupSkinRootElementId = 0x10000395u;
|
||||||
|
|
|
||||||
|
|
@ -71,4 +71,23 @@ public sealed class RowTemplateResolver
|
||||||
}
|
}
|
||||||
return info is null ? null : _build(info);
|
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;
|
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)
|
if (selectedState?.PassToChildren == true)
|
||||||
{
|
{
|
||||||
foreach (UiElement child in Children)
|
foreach (UiElement child in Children)
|
||||||
|
|
|
||||||
|
|
@ -3328,6 +3328,15 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
lock (_bindings.Assets.DatLock)
|
lock (_bindings.Assets.DatLock)
|
||||||
return hotspotTemplate.Resolve(templateLayoutId, templateElementId);
|
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)
|
// m_pMap's two button-swallowed icon children (player/house markers)
|
||||||
// only exist as ElementInfos INSIDE rootInfo's own panel-slot resolve
|
// only exist as ElementInfos INSIDE rootInfo's own panel-slot resolve
|
||||||
|
|
@ -3356,7 +3365,8 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
PlayerCellId: mh.PlayerCellId,
|
PlayerCellId: mh.PlayerCellId,
|
||||||
HousePosition: mh.HousePosition ?? (static () => null),
|
HousePosition: mh.HousePosition ?? (static () => null),
|
||||||
TemplateResolver: ResolveHotspotTemplate,
|
TemplateResolver: ResolveHotspotTemplate,
|
||||||
IconBuilder: BuildSwallowedIcon),
|
IconBuilder: BuildSwallowedIcon,
|
||||||
|
TemplateInfoResolver: ResolveHotspotTemplateInfo),
|
||||||
House: new Layout.HousePageController.Bindings(
|
House: new Layout.HousePageController.Bindings(
|
||||||
Lines: mh.HouseLines ?? (static () => Array.Empty<string>()),
|
Lines: mh.HouseLines ?? (static () => Array.Empty<string>()),
|
||||||
OnShown: mh.HouseShown,
|
OnShown: mh.HouseShown,
|
||||||
|
|
|
||||||
|
|
@ -398,11 +398,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
if (!HasStateMedia(""))
|
if (!HasStateMedia(""))
|
||||||
return false;
|
return false;
|
||||||
ActiveState = "";
|
ActiveState = "";
|
||||||
|
CascadeStateToChildren(stateId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (TryFindState(stateId, out var state))
|
if (TryFindState(stateId, out var state))
|
||||||
{
|
{
|
||||||
ActiveState = state.Name;
|
ActiveState = state.Name;
|
||||||
|
CascadeStateToChildren(stateId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
string stateName = UiButtonStateMachine.StateName(stateId);
|
string stateName = UiButtonStateMachine.StateName(stateId);
|
||||||
|
|
@ -411,6 +413,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
if (!string.IsNullOrEmpty(stateName) && HasStateMedia(stateName))
|
if (!string.IsNullOrEmpty(stateName) && HasStateMedia(stateName))
|
||||||
{
|
{
|
||||||
ActiveState = stateName;
|
ActiveState = stateName;
|
||||||
|
CascadeStateToChildren(stateId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -993,6 +996,40 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
// segments carry no Highlight art), so ActiveState stays "Normal"
|
// segments carry no Highlight art), so ActiveState stays "Normal"
|
||||||
// forever, but the spin's OWN label color must still swap.
|
// forever, but the spin's OWN label color must still swap.
|
||||||
ApplyPerStateLabelStyle(requested);
|
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()
|
private uint ComputeRequestedStateId()
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,13 @@ namespace AcDream.App.Tests.UI.Layout;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MapHousePanelControllerTests
|
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
|
/// <summary>Serves the town-hotspot template. Returns a
|
||||||
/// <see cref="UiButton"/> — matching the live template's own authored
|
/// <see cref="UiButton"/> — matching the live template's own authored
|
||||||
/// Type 1 (<c>MapHousePanelSlotProbeTests</c>: "hotspot template
|
/// 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
|
/// by these tests. A real <see cref="RowTemplateResolver"/> would set
|
||||||
/// <c>DatElementId</c> the same way <see cref="LayoutImporter.Build"/>
|
/// <c>DatElementId</c> the same way <see cref="LayoutImporter.Build"/>
|
||||||
/// does, so tests that need to find these markers back by id after the
|
/// 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)
|
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,
|
Width = 10f,
|
||||||
Height = 10f,
|
Height = 10f,
|
||||||
DatElementId = elementId,
|
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"/>
|
/// <summary>The <see cref="MapPageController.Bindings.IconBuilder"/>
|
||||||
/// seam: builds m_pMap's two button-swallowed icon children from their
|
/// seam: builds m_pMap's two button-swallowed icon children from their
|
||||||
/// OWN <see cref="ElementInfo"/>s inside the panel-slot resolve tree
|
/// 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
|
/// the live DAT, so the icons are found under the already-resolved
|
||||||
/// <c>pageInfo</c> and built through this seam instead). Mirrors
|
/// <c>pageInfo</c> and built through this seam instead). Mirrors
|
||||||
/// production's <c>LayoutImporter.Build(info, ...).Root</c>, which sets
|
/// 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)
|
private static UiElement? FakeIconBuilder(ElementInfo info)
|
||||||
=> new UiButton(new ElementInfo(), static _ => (0u, 0, 0))
|
=> info.Type == 3
|
||||||
{
|
? new UiDatElement(info, static _ => (0u, 0, 0))
|
||||||
Width = 10f,
|
{
|
||||||
Height = 10f,
|
Width = 10f,
|
||||||
DatElementId = info.Id,
|
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
|
/// <summary>The House ListBox's own row template resolves to a
|
||||||
/// <see cref="UiText"/> in the live DAT (<c>MapHousePanelSlotProbeTests</c>:
|
/// <see cref="UiText"/> in the live DAT (<c>MapHousePanelSlotProbeTests</c>:
|
||||||
|
|
@ -63,7 +131,8 @@ public sealed class MapHousePanelControllerTests
|
||||||
Func<DerethDateTime.Calendar>? currentCalendar = null,
|
Func<DerethDateTime.Calendar>? currentCalendar = null,
|
||||||
Func<uint>? playerCellId = null,
|
Func<uint>? playerCellId = null,
|
||||||
Func<CreateObject.ServerPosition?>? housePosition = 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>();
|
calls ??= new List<string>();
|
||||||
return new MapHousePanelController.Callbacks(
|
return new MapHousePanelController.Callbacks(
|
||||||
|
|
@ -73,7 +142,8 @@ public sealed class MapHousePanelControllerTests
|
||||||
PlayerCellId: playerCellId ?? (static () => 0u),
|
PlayerCellId: playerCellId ?? (static () => 0u),
|
||||||
HousePosition: housePosition ?? (static () => null),
|
HousePosition: housePosition ?? (static () => null),
|
||||||
TemplateResolver: FakeHotspotTemplate,
|
TemplateResolver: FakeHotspotTemplate,
|
||||||
IconBuilder: FakeIconBuilder),
|
IconBuilder: FakeIconBuilder,
|
||||||
|
TemplateInfoResolver: templateInfoResolver),
|
||||||
House: new HousePageController.Bindings(
|
House: new HousePageController.Bindings(
|
||||||
Lines: houseLines ?? (static () => Array.Empty<string>()),
|
Lines: houseLines ?? (static () => Array.Empty<string>()),
|
||||||
OnShown: () => calls.Add("house-shown"),
|
OnShown: () => calls.Add("house-shown"),
|
||||||
|
|
@ -187,15 +257,60 @@ public sealed class MapHousePanelControllerTests
|
||||||
// Runtime tooltip text (UiButton.TooltipText, backing
|
// Runtime tooltip text (UiButton.TooltipText, backing
|
||||||
// GetTooltipText()) — the retail SetTooltip/m_TTText mechanism, NOT
|
// GetTooltipText()) — the retail SetTooltip/m_TTText mechanism, NOT
|
||||||
// the DAT-authored AuthoredTooltipText path. The popup-skin locator
|
// the DAT-authored AuthoredTooltipText path. The popup-skin locator
|
||||||
// is unconditionally required even on the runtime-text path (see
|
// + zero delay are the TEMPLATE's OWN authored values (2026-08-17
|
||||||
// RetailTooltipPresenter.SharedPopupSkinRootElementId's doc).
|
// morning gate finding 3: P0x47=0x10000398/P0x48=0x21000041/
|
||||||
Assert.All(townMarkers, c => Assert.NotEqual(0u, c.AuthoredTooltipRootElementId));
|
// P0x50=0.0), which BuildTownMarkers must NOT clobber — the pre-fix
|
||||||
Assert.All(townMarkers, c => Assert.NotEqual(0u, c.AuthoredTooltipLayoutDid));
|
// 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.IsType<UiButton>(c));
|
||||||
Assert.All(townMarkers, c => Assert.False(string.IsNullOrEmpty(((UiButton)c).TooltipText)));
|
Assert.All(townMarkers, c => Assert.False(string.IsNullOrEmpty(((UiButton)c).TooltipText)));
|
||||||
Assert.Contains(townMarkers, c => ((UiButton)c).TooltipText == "Holtburg");
|
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]
|
[Fact]
|
||||||
public void Bind_HouseListBoxStartsEmpty_MatchingRetailPostInit()
|
public void Bind_HouseListBoxStartsEmpty_MatchingRetailPostInit()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -128,11 +128,133 @@ public sealed class MapHousePanelSlotProbeTests
|
||||||
if (hasTemplateElement && hasTemplateLayoutDid && tl.UnsignedValue != 0)
|
if (hasTemplateElement && hasTemplateLayoutDid && tl.UnsignedValue != 0)
|
||||||
{
|
{
|
||||||
ElementInfo? template = LayoutImporter.ImportInfos(dats, (uint)tl.UnsignedValue, (uint)te.UnsignedValue);
|
ElementInfo? template = LayoutImporter.ImportInfos(dats, (uint)tl.UnsignedValue, (uint)te.UnsignedValue);
|
||||||
Console.WriteLine(template is null
|
if (template is null)
|
||||||
? "[maphouse] hotspot template IMPORT NULL"
|
{
|
||||||
: $"[maphouse] hotspot template type={template.Type} "
|
Console.WriteLine("[maphouse] hotspot template IMPORT NULL");
|
||||||
+ $"({template.Width}x{template.Height}) states={template.States.Count} "
|
}
|
||||||
+ $"stateMedia={template.StateMedia.Count}");
|
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)
|
private static bool FindInfo(ElementInfo info, uint id)
|
||||||
{
|
{
|
||||||
if (info.Id == id) return true;
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -589,6 +589,99 @@ public class UiButtonTests
|
||||||
return CreateButton(info);
|
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)
|
private static ElementInfo ButtonInfo(params string[] states)
|
||||||
{
|
{
|
||||||
var info = new ElementInfo { Type = 1, Width = 20, Height = 20 };
|
var info = new ElementInfo { Type = 1, Width = 20, Height = 20 };
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue