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:
Erik 2026-08-17 09:22:46 +02:00
parent ef567bfa20
commit 942a02af11
12 changed files with 682 additions and 46 deletions

View file

@ -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()
{