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>
300 lines
14 KiB
C#
300 lines
14 KiB
C#
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Ui;
|
|
using AcDream.Core.World;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Unit coverage for <see cref="MapPageController"/>'s pure math: the
|
|
/// calendar formatter (<c>"Date: %s\nTime: %s"</c>,
|
|
/// <c>gmMapUI::Update @0x004a1eb0</c>) and the 53-town static table
|
|
/// (verbatim port of <c>s_rgLocations</c>). Marker-placement math itself is
|
|
/// <see cref="RadarCoordinates"/>/<see cref="LandDefs.GidToLcoord"/> — both
|
|
/// already unit-tested elsewhere; this file only proves the wiring
|
|
/// reproduces their output through the real fixture (no re-derivation).
|
|
/// </summary>
|
|
public sealed class MapPageControllerTests
|
|
{
|
|
// ── Calendar formatter ───────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void FormatDateTime_OrdinaryHour_NoAndHalfSuffix()
|
|
{
|
|
var calendar = new DerethDateTime.Calendar(
|
|
119, DerethDateTime.MonthName.Frostfell, 27, DerethDateTime.HourName.Dawnsong);
|
|
|
|
string text = MapPageController.FormatDateTime(calendar);
|
|
|
|
Assert.Equal("Date: Frostfell 27, 119 P.Y.\nTime: Dawnsong", text);
|
|
}
|
|
|
|
[Fact]
|
|
public void FormatDateTime_AndHalfHour_RewritesSuffixWithHyphens()
|
|
{
|
|
var calendar = new DerethDateTime.Calendar(
|
|
10, DerethDateTime.MonthName.Morningthaw, 1, DerethDateTime.HourName.MorntideAndHalf);
|
|
|
|
string text = MapPageController.FormatDateTime(calendar);
|
|
|
|
Assert.Equal("Date: Morningthaw 1, 10 P.Y.\nTime: Morntide-and-Half", text);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(DerethDateTime.HourName.Darktide, "Darktide")]
|
|
[InlineData(DerethDateTime.HourName.DarktideAndHalf, "Darktide-and-Half")]
|
|
[InlineData(DerethDateTime.HourName.Gloaming, "Gloaming")]
|
|
[InlineData(DerethDateTime.HourName.GloamingAndHalf, "Gloaming-and-Half")]
|
|
[InlineData(DerethDateTime.HourName.WarmtideAndHalf, "Warmtide-and-Half")]
|
|
public void FormatDateTime_EveryHourName_MatchesExpectedDisplayText(
|
|
DerethDateTime.HourName hour, string expectedHourText)
|
|
{
|
|
var calendar = new DerethDateTime.Calendar(
|
|
10, DerethDateTime.MonthName.Morningthaw, 1, hour);
|
|
|
|
string text = MapPageController.FormatDateTime(calendar);
|
|
|
|
Assert.EndsWith($"Time: {expectedHourText}", text);
|
|
}
|
|
|
|
// ── Town table ────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void MapLocations_Has53Entries()
|
|
{
|
|
Assert.Equal(53, MapLocations.All.Length);
|
|
}
|
|
|
|
[Fact]
|
|
public void MapLocations_AllNamesAreUnique()
|
|
{
|
|
var names = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (MapLocation loc in MapLocations.All)
|
|
Assert.True(names.Add(loc.Name), $"duplicate town name: {loc.Name}");
|
|
}
|
|
|
|
[Fact]
|
|
public void MapLocations_Holtburg_MatchesDecompiledByteValues()
|
|
{
|
|
// s_rgLocations[0x13] (pc:977379): X=0xa4 Y=0x4d W=9 H=8.
|
|
MapLocation holtburg = Assert.Single(MapLocations.All, l => l.Name == "Holtburg");
|
|
Assert.Equal(0xa4, holtburg.X);
|
|
Assert.Equal(0x4d, holtburg.Y);
|
|
Assert.Equal(9, holtburg.Width);
|
|
Assert.Equal(8, holtburg.Height);
|
|
}
|
|
|
|
[Fact]
|
|
public void MapLocations_EveryRectIsWithinTheMapWidgetsAuthoredExtent()
|
|
{
|
|
// m_pMap's own authored size (MapHousePanelSlotProbeTests: markerArea
|
|
// (6,8)-(247,258) — the widest observed extent). Town rects are
|
|
// independent of the marker-area rect but should still land inside
|
|
// a sane 0..300 canvas — a coarse sanity check that the verbatim
|
|
// port didn't transpose a digit.
|
|
foreach (MapLocation loc in MapLocations.All)
|
|
{
|
|
Assert.InRange(loc.X, 0, 260);
|
|
Assert.InRange(loc.Y, 0, 260);
|
|
Assert.InRange(loc.Width, 1, 20);
|
|
Assert.InRange(loc.Height, 1, 20);
|
|
}
|
|
}
|
|
|
|
// ── Marker placement math (GOLDEN PIXEL values, byte-decoded formula) ──
|
|
//
|
|
// gmMapUI::PlaceMarkerOnMap @0x004a18b0. Binary Ninja elides the entire
|
|
// FPU chain to bare, operand-less _ftol2() calls; the formula below was
|
|
// recovered by disassembling the raw bytes of the PDB-paired
|
|
// acclient.exe directly (capstone) — see MapPageController.ComputeMarkerPosition's
|
|
// doc comment and docs/research/2026-08-17-map-house-recon.md's
|
|
// corrected PlaceMarkerOnMap entry. Every expected value here is a
|
|
// LITERAL computed independently from the formula (by hand / an
|
|
// external script), never by calling the port itself — that is the
|
|
// whole point of a golden-value test.
|
|
//
|
|
// X = m_x0 - w/2 - (int)((m_x1-m_x0+1) * (x*10+1024) * (-1/2048))
|
|
// Y = m_y0 - h/2 - (int)((m_y1-m_y0+1) * (2047-(y*10+1024)) * (-1/2048))
|
|
//
|
|
// Marker area used throughout: (6,8)-(247,258) — the live-fixture value
|
|
// (MapHousePanelSlotProbeTests). Icon: 10x10 (matches the town hotspot
|
|
// template's "plain 10x10 hotspot dot" and this test file's own fixture
|
|
// resolver).
|
|
|
|
[Theory]
|
|
// Canonical case: dead center of Dereth (0.0N/0.0E) -> the marker
|
|
// area's own true center pixel.
|
|
[InlineData(0.0, 0.0, 122, 128)]
|
|
// Far west (x very negative): pixel X moves toward the marker area's
|
|
// left edge (m_x0=6), well below the center-case 122.
|
|
[InlineData(-100.0, 0.0, 3, 128)]
|
|
// Far north (y very positive): pixel Y moves toward the marker area's
|
|
// top edge (m_y0=8) — the FSUBR north-up flip means +Y in-game means
|
|
// SMALLER pixel Y, not larger.
|
|
[InlineData(0.0, 100.0, 122, 5)]
|
|
// A real town-table entry: Arwic's landblock cell 0x11CE0001 fed
|
|
// through the ALREADY-VERIFIED RadarCoordinates.TryFromCell (a
|
|
// different, independently-tested subsystem) to get x=-88.3/y=62.9,
|
|
// then through the formula above to get the expected pixel.
|
|
[InlineData(-88.30000000000001, 62.900000000000006, 17, 51)]
|
|
public void ComputeMarkerPosition_MatchesByteDecodedFormula_GoldenPixels(
|
|
double x, double y, int expectedLeft, int expectedTop)
|
|
{
|
|
(float left, float top) = MapPageController.ComputeMarkerPosition(
|
|
markerX0: 6, markerX1: 247, markerY0: 8, markerY1: 258,
|
|
iconWidth: 10, iconHeight: 10, x: x, y: y);
|
|
|
|
Assert.Equal(expectedLeft, left);
|
|
Assert.Equal(expectedTop, top);
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeMarkerPosition_ArwicCell_MatchesRadarCoordinates()
|
|
{
|
|
// Cross-check that the golden (x,y) literal used above for the
|
|
// "town-table entry" case really is what RadarCoordinates.TryFromCell
|
|
// produces for Arwic's landblock, so the golden test above can't
|
|
// silently drift from the coordinate subsystem it's chained to.
|
|
const uint cellId = 0x11CE0001u;
|
|
Assert.True(RadarCoordinates.TryFromCell(cellId, out RadarCoordinates coords));
|
|
Assert.Equal(-88.30000000000001, coords.X, precision: 12);
|
|
Assert.Equal(62.900000000000006, coords.Y, precision: 12);
|
|
}
|
|
|
|
// ── Marker placement wiring (real fixture, no re-derivation) ────────────
|
|
|
|
[Fact]
|
|
public void Bind_PlayerMarker_OutdoorCell_ReproducesRadarCoordinatesPlacement()
|
|
{
|
|
// Arwic's landblock cell id (0x11CE0001 — an arbitrary real outdoor
|
|
// cell, picked only because RadarCoordinates.TryFromCell already
|
|
// proves gid-to-lcoord conformance elsewhere; this test proves the
|
|
// WIRING, not the formula — the formula itself is golden-tested
|
|
// above).
|
|
const uint cellId = 0x11CE0001u;
|
|
Assert.True(RadarCoordinates.TryFromCell(cellId, out _));
|
|
|
|
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
|
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
|
var callbacks = new MapHousePanelController.Callbacks(
|
|
Toggle: () => { },
|
|
Map: new MapPageController.Bindings(
|
|
CurrentCalendar: static () => default,
|
|
PlayerCellId: () => cellId,
|
|
HousePosition: static () => (CreateObject.ServerPosition?)null,
|
|
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);
|
|
Assert.NotNull(controller);
|
|
|
|
UiElement? playerIcon = UiElement.FindDescendant(controller!.Root, MapPageController.PlayerIconId);
|
|
Assert.NotNull(playerIcon);
|
|
Assert.True(playerIcon!.Visible);
|
|
|
|
// markerArea from the live fixture (MapHousePanelSlotProbeTests):
|
|
// (6,8)-(247,258). GOLDEN pixel value computed independently above
|
|
// (ComputeMarkerPosition_MatchesByteDecodedFormula_GoldenPixels'
|
|
// Arwic case) — (17,51) for this exact (x,y).
|
|
Assert.Equal(17f, playerIcon.Left);
|
|
Assert.Equal(51f, playerIcon.Top);
|
|
}
|
|
|
|
[Fact]
|
|
public void Bind_PlayerMarker_IndoorCell_HidesIconAndClearsCoordinateText()
|
|
{
|
|
// Envcell low word (>= 0x100) fails RadarCoordinates.TryFromCell —
|
|
// the indoor branch (gmMapUI::Update's else: SetVisible(0)).
|
|
const uint indoorCellId = 0x0012_0100u;
|
|
Assert.False(RadarCoordinates.TryFromCell(indoorCellId, out _));
|
|
|
|
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
|
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
|
var callbacks = new MapHousePanelController.Callbacks(
|
|
Toggle: () => { },
|
|
Map: new MapPageController.Bindings(
|
|
CurrentCalendar: static () => default,
|
|
PlayerCellId: () => indoorCellId,
|
|
HousePosition: static () => (CreateObject.ServerPosition?)null,
|
|
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);
|
|
Assert.NotNull(controller);
|
|
|
|
UiElement? playerIcon = UiElement.FindDescendant(controller!.Root, MapPageController.PlayerIconId);
|
|
Assert.NotNull(playerIcon);
|
|
Assert.False(playerIcon!.Visible);
|
|
}
|
|
|
|
[Fact]
|
|
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 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
|
|
// it would have written coordinate text here even with a missing
|
|
// player icon.
|
|
const uint cellId = 0x11CE0001u; // outdoor: TryFromCell succeeds.
|
|
Assert.True(RadarCoordinates.TryFromCell(cellId, out _));
|
|
|
|
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
|
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
|
var callbacks = new MapHousePanelController.Callbacks(
|
|
Toggle: () => { },
|
|
Map: new MapPageController.Bindings(
|
|
CurrentCalendar: static () => default,
|
|
PlayerCellId: () => cellId,
|
|
HousePosition: static () => (CreateObject.ServerPosition?)null,
|
|
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 = info.Id }),
|
|
House: new HousePageController.Bindings(Lines: static () => Array.Empty<string>()));
|
|
|
|
MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks);
|
|
Assert.NotNull(controller);
|
|
|
|
Assert.Null(
|
|
UiElement.FindDescendant(controller!.Root, MapPageController.PlayerIconId));
|
|
|
|
var coordinateText = Assert.IsType<UiText>(
|
|
UiElement.FindDescendant(controller.Root, MapPageController.CoordinateTextId));
|
|
Assert.Empty(coordinateText.LinesProvider());
|
|
}
|
|
|
|
[Fact]
|
|
public void Bind_HouseMarker_NullPosition_StaysHidden()
|
|
{
|
|
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
|
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
|
var callbacks = new MapHousePanelController.Callbacks(
|
|
Toggle: () => { },
|
|
Map: new MapPageController.Bindings(
|
|
CurrentCalendar: static () => default,
|
|
PlayerCellId: static () => 0u,
|
|
HousePosition: static () => (CreateObject.ServerPosition?)null,
|
|
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);
|
|
Assert.NotNull(controller);
|
|
|
|
UiElement? houseIcon = UiElement.FindDescendant(controller!.Root, MapPageController.HouseIconId);
|
|
Assert.NotNull(houseIcon);
|
|
Assert.False(houseIcon!.Visible);
|
|
}
|
|
}
|