fix(ui): Map tab player/house icon resolution — build the button-swallowed icons from the panel-slot resolve tree, detached from the per-frame layout pass

Two mechanisms, both live-verified (register row AD-108 updated to match):

1. RESOLUTION. The player/house icons (0x100001ED/0x100001EE) are authored
   as nested dat children of m_pMap (0x100001EC), itself a Type-1 button
   whose UiButton.ConsumesDatChildren swallows them at build. The old
   ResolveSwallowedIcon re-imported them standalone via
   ImportInfos(hostLayout, iconId) — which returns null on the live DAT:
   FindDesc walks the raw top-level Elements table (one entry for
   0x2100006E) and never reaches them. Their ElementInfos only materialize
   inside the full panel-slot resolve (ImportInfos(0x2100006E, 0x1000018C))
   that MountMapHousePanel already imports — the pageInfo Bind already
   receives. The fix finds each icon's info under m_pMap's own resolved
   info subtree and BUILDS it through the new Bindings.IconBuilder seam
   (production: LayoutImporter.Build under the DAT lock — the build half
   of RowTemplateResolver's shape). An icon the normal walk DID build is
   preferred (FindDescendant first), so a future ConsumesDatChildren
   policy change cannot double-build.

2. POSITION. Found by this fix's own F1 live verification: the resolved
   ring rendered pinned to m_pMap's top-left. PlaceMarker owns marker
   position outright (retail's gmMapUI::Update re-places every tick;
   retail's UpdateForParentSizeChange runs only on real parent resize),
   but acdream re-runs ApplyAnchor per frame and the icon's compatibility
   anchor had captured the authored (0,0) rect while the panel was still
   hidden, re-asserting it over PlaceMarker's writes every frame.
   PrepareIcon now sets Anchors=None (clearing any imported LayoutPolicy),
   the established runtime-positioned-element convention.

Live numeric gate (session character +Acdream, cell 0xF07E003F):
independent computation (gid_to_lcoord -> display (90.8E, 0.5S) ->
byte-decoded PlaceMarkerOnMap formula, 17x16 icon, marker area
(6,8)-(247,258)) predicts local pixel (226,125); the connected client's
UI-tree dump shows the icon at screen (1166,195) under m_pMap (940,70) =
local (226,125) — exact match in both panel-open dumps. Coordinate text
"0.5S,90.8E", Holtburg town-marker tooltip (real-mouse hover), and the
House tab's "You may buy another house immediately." sentence all
confirmed on screen; ACE-confirmed graceful logout.

New pin: MapHousePanelLiveDatMountTests ([InstalledDatFact]) reproduces
the production mount recipe against the installed DATs — the test that
would have caught this at Batch C: pins the cold-import null, the
panel-slot resolution of both icons with non-degenerate extents, AND
that PlaceMarker's writes survive the per-frame ApplyAnchor pass.

Gates: Release build green; App suite (live-DAT mode) 5479/3 skips
(baseline 5478 + the new pin); Runtime 1744/0; full solution green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 05:58:17 +02:00
parent 38c580ff6d
commit e316e190cb
6 changed files with 289 additions and 42 deletions

File diff suppressed because one or more lines are too long

View file

@ -58,7 +58,15 @@ public sealed class MapPageController
// branch — the house icon starts/stays hidden) so this page works
// standalone before that lands.
Func<CreateObject.ServerPosition?> HousePosition,
Func<uint, uint, UiElement?> TemplateResolver);
Func<uint, uint, UiElement?> TemplateResolver,
// Builds one UiElement subtree from an ALREADY-RESOLVED ElementInfo
// (production: LayoutImporter.Build under the DAT lock — the same
// build half RowTemplateResolver uses, without the import half).
// Used for m_pMap's two button-swallowed icon children, whose
// 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);
private readonly UiElement? _dateTimeText;
private readonly UiElement? _map;
@ -108,13 +116,20 @@ public sealed class MapPageController
/// siblings. <see cref="UiButton.ConsumesDatChildren"/> swallows a
/// button's dat children as skin/label parts, so they never appear in
/// the normally-built tree — <see cref="UiElement.FindDescendant"/>
/// against the page root always returns null for them. They're
/// resolved the SAME way the town hotspot template is: re-imported
/// standalone via <paramref name="bindings"/>'s
/// <see cref="Bindings.TemplateResolver"/> against the panel's own host
/// LayoutDesc, then attached under <c>m_pMap</c> directly — their
/// authored local position is irrelevant since <see cref="PlaceMarker"/>
/// overwrites it every refresh.
/// against the page root always returns null for them. Their
/// <see cref="ElementInfo"/>s, however, DO survive: <paramref name="pageInfo"/>
/// is a subtree of the panel's full slot resolve
/// (<c>ImportInfos(0x2100006E, 0x1000018C)</c>), the only pathway that
/// materializes them at all — a cold
/// <c>ImportInfos(hostLayoutId, iconElementId)</c> starting from the
/// icon id returns null on the live DAT because the raw LayoutDesc
/// <c>Elements</c>-table walk never reaches them (register row AD-108's
/// live-DAT finding, 2026-08-17). So the icons are resolved by finding
/// their infos under <c>m_pMap</c>'s own already-resolved info and
/// BUILDING each via <see cref="Bindings.IconBuilder"/>, then attached
/// under <c>m_pMap</c> directly — their authored local position is
/// irrelevant since <see cref="PlaceMarker"/> overwrites it every
/// refresh.
/// </para>
/// </summary>
public static MapPageController? Bind(UiElement page, ElementInfo pageInfo, Bindings bindings)
@ -141,8 +156,8 @@ public sealed class MapPageController
markerArea = (x0, x1, y0, y1);
}
UiElement? playerIcon = ResolveSwallowedIcon(map, bindings.TemplateResolver, PlayerIconId);
UiElement? houseIcon = ResolveSwallowedIcon(map, bindings.TemplateResolver, HouseIconId);
UiElement? playerIcon = ResolveSwallowedIcon(map, mapInfo, bindings.IconBuilder, PlayerIconId);
UiElement? houseIcon = ResolveSwallowedIcon(map, mapInfo, bindings.IconBuilder, HouseIconId);
var controller = new MapPageController(
UiElement.FindDescendant(page, DateTimeTextId),
@ -170,23 +185,60 @@ public sealed class MapPageController
return controller;
}
/// <summary>Re-resolves one of <c>m_pMap</c>'s button-swallowed nested
/// icon children standalone (see <see cref="Bind"/>'s own doc) and
/// attaches it under <paramref name="map"/>. Starts hidden — the first
/// <summary>Resolves one of <c>m_pMap</c>'s button-swallowed nested
/// icon children by finding its <see cref="ElementInfo"/> under
/// <paramref name="mapInfo"/> — the panel-slot resolve tree, the ONLY
/// place these infos exist (see <see cref="Bind"/>'s own doc + register
/// row AD-108) — building it via <paramref name="iconBuilder"/>, and
/// attaching it under <paramref name="map"/>. Starts hidden — the first
/// <see cref="Refresh"/> call (from <see cref="Bind"/>) decides real
/// visibility.</summary>
private static UiElement? ResolveSwallowedIcon(
UiElement map, Func<uint, uint, UiElement?> templateResolver, uint iconElementId)
UiElement map, ElementInfo? mapInfo, Func<ElementInfo, UiElement?> iconBuilder, uint iconElementId)
{
UiElement? icon = templateResolver(MapHousePanelController.HostLayoutId, iconElementId);
// If the normal build walk ever stops swallowing m_pMap's dat
// children (a future UiButton.ConsumesDatChildren policy change),
// the icon already exists in the built tree — use it rather than
// building a second, permanently-static copy behind the live
// marker. Retail's own PostInit is exactly this find-the-child.
UiElement? existing = UiElement.FindDescendant(map, iconElementId);
if (existing is not null)
return PrepareIcon(existing);
ElementInfo? iconInfo = mapInfo is null ? null : FindInfo(mapInfo, iconElementId);
if (iconInfo is null)
{
Console.WriteLine(
$"[D.2b] Map tab: icon 0x{iconElementId:X8} not authored under m_pMap's resolved "
+ "info tree — it will not be shown.");
return null;
}
UiElement? icon = iconBuilder(iconInfo);
if (icon is null)
{
Console.WriteLine(
$"[D.2b] Map tab: icon 0x{iconElementId:X8} did not resolve — it will not be shown.");
$"[D.2b] Map tab: icon 0x{iconElementId:X8} did not build — it will not be shown.");
return null;
}
map.AddChild(PrepareIcon(icon));
return icon;
}
/// <summary>Marks one marker icon as runtime-positioned. <see cref="PlaceMarker"/>
/// owns the element's position outright (retail's <c>gmMapUI::Update</c>
/// re-places both markers every tick) — but acdream re-runs the authored
/// layout pass per frame, so the compatibility anchor capture (and any
/// imported raw-edge <see cref="UiElement.LayoutPolicy"/>, which the
/// <see cref="UiElement.Anchors"/> setter clears) would re-assert the
/// authored (0,0) rect every frame, silently overwriting PlaceMarker's
/// writes — the F1 live finding: a visible green ring pinned to m_pMap's
/// top-left corner regardless of the player's true position. Starts
/// hidden — the first <see cref="Refresh"/> decides real visibility.</summary>
private static UiElement PrepareIcon(UiElement icon)
{
icon.Anchors = AnchorEdges.None;
icon.Visible = false;
map.AddChild(icon);
return icon;
}

View file

@ -3329,6 +3329,25 @@ public sealed class RetailUiRuntime : IDisposable
return hotspotTemplate.Resolve(templateLayoutId, templateElementId);
}
// m_pMap's two button-swallowed icon children (player/house markers)
// only exist as ElementInfos INSIDE rootInfo's own panel-slot resolve
// tree — a cold ImportInfos(hostLayout, iconId) re-import returns
// null on the live DAT (register row AD-108). MapPageController.Bind
// locates each icon's info under m_pMap and calls this seam to build
// it: the build half of RowTemplateResolver's shape, no import half.
// Monitor re-entrancy on DatLock is established for this mount path
// (Bind itself runs under the lock below, same as
// ResolveHotspotTemplate's own re-entrant take).
UiElement? BuildSwallowedIcon(ElementInfo iconInfo)
{
lock (_bindings.Assets.DatLock)
return LayoutImporter.Build(
iconInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont).Root;
}
MapHouseRuntimeBindings mh = _bindings.MapHouse;
var callbacks = new Layout.MapHousePanelController.Callbacks(
Toggle: () => ToggleWindow(WindowNames.MapHouse),
@ -3336,7 +3355,8 @@ public sealed class RetailUiRuntime : IDisposable
CurrentCalendar: mh.CurrentCalendar,
PlayerCellId: mh.PlayerCellId,
HousePosition: mh.HousePosition ?? (static () => null),
TemplateResolver: ResolveHotspotTemplate),
TemplateResolver: ResolveHotspotTemplate,
IconBuilder: BuildSwallowedIcon),
House: new Layout.HousePageController.Bindings(
Lines: mh.HouseLines ?? (static () => Array.Empty<string>()),
OnShown: mh.HouseShown,

View file

@ -17,17 +17,14 @@ namespace AcDream.App.Tests.UI.Layout;
/// </summary>
public sealed class MapHousePanelControllerTests
{
/// <summary>Serves BOTH the town-hotspot template (any (layoutId,
/// elementId) pair not the player/house icon ids) and the two icons
/// <see cref="MapPageController.Bind"/> re-resolves standalone
/// (<c>m_pMap</c>'s own button-swallowed children). Returns a
/// <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
/// type=1") — so <see cref="MapPageController.BuildTownMarkers"/>'s
/// <c>marker is UiButton</c> tooltip-text branch is actually exercised
/// 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 icons back by id after the
/// does, so tests that need to find these markers back by id after the
/// fact need it too.</summary>
private static UiElement? FakeHotspotTemplate(uint layoutId, uint elementId)
=> new UiButton(new ElementInfo(), static _ => (0u, 0, 0))
@ -37,6 +34,22 @@ public sealed class MapHousePanelControllerTests
DatElementId = elementId,
};
/// <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
/// (register row AD-108 — a standalone re-import cannot find them on
/// 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>
private static UiElement? FakeIconBuilder(ElementInfo info)
=> 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>:
/// "row template type=12" — <c>UIElement_Text</c>), unlike the Map tab's
@ -59,7 +72,8 @@ public sealed class MapHousePanelControllerTests
CurrentCalendar: currentCalendar ?? (static () => default),
PlayerCellId: playerCellId ?? (static () => 0u),
HousePosition: housePosition ?? (static () => null),
TemplateResolver: FakeHotspotTemplate),
TemplateResolver: FakeHotspotTemplate,
IconBuilder: FakeIconBuilder),
House: new HousePageController.Bindings(
Lines: houseLines ?? (static () => Array.Empty<string>()),
OnShown: () => calls.Add("house-shown"),
@ -158,10 +172,10 @@ public sealed class MapHousePanelControllerTests
UiElement? map = UiElement.FindDescendant(controller!.Root, MapPageController.MapWidgetId);
Assert.NotNull(map);
// m_pMap's own children are the player/house icons (re-resolved
// standalone — see MapPageController.Bind's doc on why m_pMap being
// a Button swallows its authored nested children) PLUS the 53 town
// hotspots.
// m_pMap's own children are the player/house icons (found under the
// panel-slot resolve tree and rebuilt via Bindings.IconBuilder — see
// MapPageController.Bind's doc on why m_pMap being a Button swallows
// its authored nested children) PLUS the 53 town hotspots.
var townMarkers = map!.Children
.Where(c => c.DatElementId != MapPageController.PlayerIconId
&& c.DatElementId != MapPageController.HouseIconId)

View file

@ -0,0 +1,157 @@
using System.IO;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Net.Messages;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Live-DAT mount pin for the Map tab's player/house marker icons — THE test
/// that would have caught the Batch C icon-resolution gap (register row
/// AD-108) at commit time.
///
/// <para>
/// The fixture-based <see cref="MapHousePanelControllerTests"/> could not
/// catch it: their fake resolvers answer ANY id, so the production resolve
/// mechanism itself was never exercised against real data. This test
/// reproduces <see cref="RetailUiRuntime.MountMapHousePanel"/>'s exact
/// recipe against the INSTALLED DATs (sprite/font resolution stubbed —
/// structure only, same as every committed-fixture build): the panel-slot
/// <c>LayoutImporter.ImportInfos(dats, hostLayoutId, slotElementId)</c>
/// import, <see cref="LayoutImporter.Build"/>, real
/// import-then-build hotspot/template resolution, and the
/// <see cref="MapPageController.Bindings.IconBuilder"/> build seam —
/// then asserts the two icons actually materialize as built elements.
/// </para>
///
/// <para>
/// Gated like every other installed-DAT family here:
/// <c>[InstalledDatFact]</c>, opt in with
/// <c>ACDREAM_PROBE_LIVE_MOUNT=1</c> (the App suite's live-DAT baseline
/// mode); <c>ACDREAM_DAT_DIR</c> overrides the ordinary
/// Documents/Asheron's Call location.
/// </para>
/// </summary>
public sealed class MapHousePanelLiveDatMountTests
{
private static string DatDirectory =>
Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
[InstalledDatFact]
public void MountRecipe_ResolvesPlayerAndHouseIcons_UnderTheMapWidget()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
// ── AD-108's structural facts, pinned ────────────────────────────
// 1) A COLD standalone re-import starting from the icon's own id
// returns null: the raw LayoutDesc Elements-table walk never
// reaches m_pMap's nested children. This is WHY the IconBuilder
// seam exists — if a future DAT regeneration makes this resolve,
// this pin flags that the seam could be revisited.
Assert.Null(LayoutImporter.ImportInfos(
dats,
MapHousePanelController.HostLayoutId,
MapPageController.PlayerIconId));
Assert.Null(LayoutImporter.ImportInfos(
dats,
MapHousePanelController.HostLayoutId,
MapPageController.HouseIconId));
// 2) The full panel-slot resolve — what MountMapHousePanel actually
// imports — DOES materialize both icons, nested under m_pMap.
ElementInfo? rootInfo = LayoutImporter.ImportInfos(
dats,
MapHousePanelController.HostLayoutId,
MapHousePanelController.SlotElementId);
Assert.NotNull(rootInfo);
// ── The production mount recipe (sprites/fonts stubbed) ──────────
ImportedLayout layout = LayoutImporter.Build(rootInfo!, static _ => (0u, 0, 0), null);
UiElement? ResolveTemplate(uint layoutId, uint elementId)
{
ElementInfo? info = LayoutImporter.ImportInfos(dats, layoutId, elementId);
return info is null
? null
: LayoutImporter.Build(info, static _ => (0u, 0, 0), null).Root;
}
UiElement? BuildIcon(ElementInfo info)
=> LayoutImporter.Build(info, static _ => (0u, 0, 0), null).Root;
// Mutable cell: Bind sees "no position yet" (0 — the real mount-time
// state, the panel mounts before the session enters world), then the
// player lands outdoors and the 5 s cadence re-refreshes.
uint playerCell = 0u;
var callbacks = new MapHousePanelController.Callbacks(
Toggle: static () => { },
Map: new MapPageController.Bindings(
CurrentCalendar: static () => default,
PlayerCellId: () => playerCell,
HousePosition: static () => (CreateObject.ServerPosition?)null,
TemplateResolver: ResolveTemplate,
IconBuilder: BuildIcon),
House: new HousePageController.Bindings(
Lines: static () => Array.Empty<string>(),
TemplateResolver: ResolveTemplate));
MapHousePanelController? controller =
MapHousePanelController.Bind(rootInfo!, layout, callbacks);
Assert.NotNull(controller);
// ── Pin 1: both icons resolve as BUILT elements ──────────────────
UiElement? map = UiElement.FindDescendant(
controller!.Root, MapPageController.MapWidgetId);
Assert.NotNull(map);
UiElement? playerIcon = UiElement.FindDescendant(
controller.Root, MapPageController.PlayerIconId);
UiElement? houseIcon = UiElement.FindDescendant(
controller.Root, MapPageController.HouseIconId);
Assert.NotNull(playerIcon);
Assert.NotNull(houseIcon);
// Attached directly under m_pMap (PlaceMarker positions them in its
// local space), with real authored extents — PlaceMarker's centering
// divides the icon's own Width/Height, so a zero-sized build would
// silently mis-center every marker.
Assert.Same(map, playerIcon!.Parent);
Assert.Same(map, houseIcon!.Parent);
Assert.True(playerIcon.Width > 0 && playerIcon.Height > 0,
$"player icon built with degenerate extent {playerIcon.Width}x{playerIcon.Height}");
Assert.True(houseIcon.Width > 0 && houseIcon.Height > 0,
$"house icon built with degenerate extent {houseIcon.Width}x{houseIcon.Height}");
// ── Pin 2: PlaceMarker's writes survive the per-frame layout pass ─
// The F1 live finding's second half: the client re-runs the authored
// layout pass (parent → child.ApplyAnchor) every frame. Pre-fix the
// icon's compatibility anchor captured the authored (0,0) rect while
// the panel sat indoors/hidden, then re-asserted it every frame —
// a visible ring pinned to m_pMap's top-left corner regardless of
// the player's position. Reproduce that exact frame order here.
playerIcon.ApplyAnchor(map!.Width, map.Height); // frame while cell unknown
houseIcon.ApplyAnchor(map.Width, map.Height);
Assert.False(playerIcon.Visible);
playerCell = 0x11CE0001u; // Arwic (independently pinned:
// display coords -88.3 / 62.9)
controller.Tick(MapPageController.RefreshIntervalSeconds + 0.01);
Assert.True(playerIcon.Visible);
(float expectedLeft, float expectedTop) = MapPageController.ComputeMarkerPosition(
markerX0: 6, markerX1: 247, markerY0: 8, markerY1: 258,
(int)playerIcon.Width, (int)playerIcon.Height, -88.30000000000001, 62.900000000000006);
Assert.Equal(expectedLeft, playerIcon.Left);
Assert.Equal(expectedTop, playerIcon.Top);
playerIcon.ApplyAnchor(map.Width, map.Height); // the next frame's pass
Assert.Equal(expectedLeft, playerIcon.Left);
Assert.Equal(expectedTop, playerIcon.Top);
}
}

View file

@ -184,7 +184,8 @@ public sealed class MapPageControllerTests
CurrentCalendar: static () => default,
PlayerCellId: () => cellId,
HousePosition: static () => (CreateObject.ServerPosition?)null,
TemplateResolver: (_, e) => new UiText { Width = 10f, Height = 10f, DatElementId = e }),
TemplateResolver: (_, e) => new UiText { Width = 10f, Height = 10f, DatElementId = e },
IconBuilder: info => new UiText { Width = 10f, Height = 10f, DatElementId = info.Id }),
House: new HousePageController.Bindings(Lines: static () => Array.Empty<string>()));
MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks);
@ -218,7 +219,8 @@ public sealed class MapPageControllerTests
CurrentCalendar: static () => default,
PlayerCellId: () => indoorCellId,
HousePosition: static () => (CreateObject.ServerPosition?)null,
TemplateResolver: (_, e) => new UiText { Width = 10f, Height = 10f, DatElementId = e }),
TemplateResolver: (_, e) => new UiText { Width = 10f, Height = 10f, DatElementId = e },
IconBuilder: info => new UiText { Width = 10f, Height = 10f, DatElementId = info.Id }),
House: new HousePageController.Bindings(Lines: static () => Array.Empty<string>()));
MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks);
@ -230,12 +232,12 @@ public sealed class MapPageControllerTests
}
[Fact]
public void Refresh_PlayerIconTemplateResolutionFails_CoordinateTextStaysEmptyToo()
public void Refresh_PlayerIconResolutionFails_CoordinateTextStaysEmptyToo()
{
// F15 (night-round review): gmMapUI::Update @0x004a2078's gate is
// `if (m_pCoordinateText != 0 && m_pPlayerLocationIcon != 0)` — BOTH
// widgets present, not "at least one". A player-icon template
// resolution failure (leaving _playerIcon null, e.g. a future DAT
// widgets present, not "at least one". A player-icon resolution
// failure (leaving _playerIcon null, e.g. a future DAT
// regression) must skip the coordinate-text write too, not just the
// marker placement — the OLD `_coordinateText is null &&
// _playerIcon is null` gate only skipped when BOTH were absent, so
@ -252,13 +254,14 @@ public sealed class MapPageControllerTests
CurrentCalendar: static () => default,
PlayerCellId: () => cellId,
HousePosition: static () => (CreateObject.ServerPosition?)null,
// Simulates the player icon's own standalone template
// resolution failing (ResolveSwallowedIcon's own null path)
// while every other swallowed-icon/town-marker resolution
// still succeeds normally.
TemplateResolver: (_, e) => e == MapPageController.PlayerIconId
TemplateResolver: (_, e) => new UiText { Width = 10f, Height = 10f, DatElementId = e },
// Simulates the player icon's own build failing
// (ResolveSwallowedIcon's null-build path, e.g. a future DAT
// regression) while the house icon and every town-marker
// resolution still succeed normally.
IconBuilder: info => info.Id == MapPageController.PlayerIconId
? null
: new UiText { Width = 10f, Height = 10f, DatElementId = e }),
: new UiText { Width = 10f, Height = 10f, DatElementId = info.Id }),
House: new HousePageController.Bindings(Lines: static () => Array.Empty<string>()));
MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks);
@ -283,7 +286,8 @@ public sealed class MapPageControllerTests
CurrentCalendar: static () => default,
PlayerCellId: static () => 0u,
HousePosition: static () => (CreateObject.ServerPosition?)null,
TemplateResolver: (_, e) => new UiText { Width = 10f, Height = 10f, DatElementId = e }),
TemplateResolver: (_, e) => new UiText { Width = 10f, Height = 10f, DatElementId = e },
IconBuilder: info => new UiText { Width = 10f, Height = 10f, DatElementId = info.Id }),
House: new HousePageController.Bindings(Lines: static () => Array.Empty<string>()));
MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks);