feat(ui): Map/House panel — slices 2+3, panel shell + Map tab

Mounts host 0x2100006E slot 0x1000018C (RetailPanelCatalog.MapHouse = 16)
as a two-tab UiTabPanel (Map default, House second) through the OP3/FA3
recipe (LayoutImporter.Build -> Bind -> ActivateTabBehavior). Toolbar
button 0x1000019A un-ghosts (added to both RetailPanelCatalog.Mounted and
.Toolbar). Combined into one commit because MapHousePanelController.Bind
depends on both MapPageController and HousePageController existing —
splitting them would mean landing dead code first.

Map tab (gmMapUI, MapPageController):
- Calendar formatter matching gmMapUI::Update's "Date: %s\nTime: %s"
  shape, reusing WorldTimeService.CurrentCalendar (new
  Func<DerethDateTime.Calendar> dependency threaded through
  InteractionRetainedUiDependencies/GameWindow — a stable long-lived
  service, not routed through the deferred-binding machinery Radar's
  per-session state needs). MonthName enum values already match retail
  display text; HourName's "AndHalf" suffix is rewritten to "-and-Half".
- Coordinate math + marker placement reuse RadarCoordinates/
  LandDefs.GidToLcoord verbatim (both already byte-exact ports of
  CPlayerSystem::InqPlayerCoords/LandDefs::gid_to_lcoord) — no re-port.
  PlaceMarkerOnMap's centering math (m_x0 + x - w/2) ported from
  gmMapUI::PlaceMarkerOnMap @0x004a18b0. Indoor gating clears the
  coordinate text and hides the player marker, matching
  gmMapUI::Update's else branch.
- 53-town s_rgLocations table ported verbatim into MapLocations.cs.
  Markers built once at bind time via the panel's own RowTemplateResolver
  against m_pMap's authored hotspot-template attrs (0x47/0x48), with
  literal-string tooltips through AuthoredTooltipText/Enabled
  (RetailTooltipPresenter) — closes divergence-register row TS-85's last
  item, gmMapUI::AddMapNote @0x004A1C51.
- Structural finding: m_pMap (0x100001EC) is itself authored as a Type-1
  BUTTON (the GM click-to-teleport hook at
  gmMapUI::ListenToElementMessage), and the player/house icons
  (0x100001ED/EE) are its own NESTED children, not siblings —
  UiButton.ConsumesDatChildren swallows them from the normally-built
  tree. Both are re-resolved standalone through the same template
  resolver the town hotspots use and reattached under m_pMap.

House tab (gmHouseUI, HousePageController): mounts the ListBox
(0x100001E6) with its authored row template, wired to an empty Lines()
source by default — genuinely empty until Slice 4's wire lands, matching
retail's own PostInit (no Update call, no static content).

21 new tests (7 MapHousePanelControllerTests, 14 MapPageControllerTests):
tab table pairing, close button, town-hotspot count/tooltips, calendar
formatter golden values (Frostfell 27/119 P.Y., every HourName incl.
AndHalf), player/house marker placement and indoor-gating reproduced
against the real fixture via already-tested RadarCoordinates (no
re-derivation). Fixture map_house_2100006E_1000018C.json captured via
the shared RetailLayoutFixtureGenerator (other 34 fixtures deliberately
NOT regenerated — out of scope for this batch, would touch unrelated
panels' schema drift).

Full solution builds clean; App suite 5391/0 failed/71 skipped (non-live;
one earlier flaky streaming failure unrelated to this change, confirmed
pre-existing on the branch before these commits).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 02:04:19 +02:00
parent 04841b6807
commit 6b0fa4ff0d
15 changed files with 5865 additions and 3 deletions

View file

@ -0,0 +1,79 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// One retail map hotspot rect + rollover name. Mirrors
/// <c>gmMapUI::LocationRolloverInfo</c> (<c>acclient.h:55686</c>): X/Y/Width/
/// Height are direct pixel offsets within <c>m_pMap</c> (no coordinate
/// transform — <c>gmMapUI::AddMapNote @0x004a1bb0</c> passes them straight to
/// <c>MoveTo</c>/<c>ResizeTo</c>), and Name is the literal tooltip text
/// (<c>StringInfo::SetLiteralValue</c>, not a DAT string-table lookup).
/// </summary>
public readonly record struct MapLocation(int X, int Y, int Width, int Height, string Name);
/// <summary>
/// Verbatim port of retail's static <c>s_rgLocations[0x35]</c> table
/// (<c>docs/research/named-retail/acclient_2013_pseudo_c.txt:977225-977651</c>),
/// consumed by <c>gmMapUI::PostInit @0x004a1c70</c> to place the 53 town
/// hotspots on the Map tab. Ported verbatim, in original index order —
/// do not resort or "clean up"; the order is not semantically meaningful
/// but the values must match byte-for-byte.
/// </summary>
public static class MapLocations
{
public static readonly MapLocation[] All =
{
new(0xb2, 0x14, 0xb, 0xc, "Aerlinthe Island"),
new(0x12, 0x4a, 0x5, 0x5, "Ahurenga"),
new(0x8d, 0xa6, 0x7, 0x6, "Al-Arqas"),
new(0x81, 0x79, 0x7, 0x6, "Al-Jalima"),
new(0xbe, 0x58, 0x9, 0x8, "Arwic"),
new(0x13, 0xc9, 0x7, 0x6, "Ayan Baqur"),
new(0xc8, 0xbe, 0x7, 0x6, "Baishi"),
new(0xb8, 0x35, 0x5, 0x5, "Bandit Castle"),
new(0x22, 0x54, 0x5, 0x5, "Bluespire"),
new(0x2c, 0xeb, 0x5, 0x5, "Candeth Keep"),
new(0xb4, 0x61, 0x9, 0x8, "Cragstone"),
new(0x5b, 0x66, 0x5, 0x5, "Danby's Outpost"),
new(0xd3, 0x8a, 0x9, 0x8, "Dryreach"),
new(0xc7, 0x6a, 0x9, 0x8, "Eastham"),
new(0x38, 0xd, 0x5, 0x5, "Fiun Outpost"),
new(0x25, 0x7f, 0x9, 0x8, "Fort Tethana"),
new(0x9c, 0x5e, 0x9, 0x8, "Glenden Wood"),
new(0x2b, 0x4f, 0x5, 0x5, "Greenspire"),
new(0xe0, 0xb1, 0x7, 0x6, "Hebian-to"),
new(0xa4, 0x4d, 0x9, 0x8, "Holtburg"),
new(0xb6, 0xe6, 0x7, 0x6, "Kara"),
new(0x9b, 0xb9, 0x7, 0x6, "Khayyaban"),
new(0xe0, 0xda, 0x7, 0x6, "Kryst"),
new(0xd4, 0xc3, 0x7, 0x6, "Lin"),
new(0x9f, 0xe0, 0x5, 0x5, "Linvak Tukal"),
new(0xb9, 0x7e, 0x9, 0x8, "Lytelthorpe"),
new(0xeb, 0xdc, 0x7, 0x6, "MacNiall's Freehold"),
new(0xdf, 0xcb, 0x7, 0x6, "Mayoi"),
new(0x8d, 0x35, 0x5, 0x5, "Mt Esper-Crater Village"),
new(0xe0, 0xbf, 0x7, 0x6, "Nanto"),
new(0x8e, 0x2e, 0x5, 0x5, "Neydisa"),
new(0xf0, 0x80, 0x5, 0x5, "Oolutanga's Refuge"),
new(0x4a, 0x4f, 0x5, 0x5, "Plateau Village"),
new(0x94, 0xda, 0x7, 0x6, "Qalaba'r"),
new(0x1a, 0x53, 0x5, 0x5, "Redspire"),
new(0xc1, 0x72, 0x9, 0x8, "Rithwic"),
new(0x92, 0x85, 0x7, 0x6, "Samsur"),
new(0x32, 0x2a, 0x5, 0x5, "Sanamar"),
new(0xc3, 0xa3, 0x7, 0x6, "Sawato"),
new(0xd5, 0xab, 0x7, 0x6, "Shoushi"),
new(0x29, 0x19, 0x5, 0x5, "Silyun"),
new(0x6, 0xef, 0xf, 0x10, "Singularity Caul Island"),
new(0x64, 0x30, 0x5, 0x5, "Stonehold"),
new(0x20, 0x4c, 0x5, 0x5, "Timaru"),
new(0xef, 0xa3, 0x7, 0x6, "Tou-Tou"),
new(0x83, 0x94, 0x7, 0x6, "Tufa"),
new(0x70, 0xf4, 0x5, 0x5, "Ulgrim's Island"),
new(0x9f, 0xa0, 0x7, 0x6, "Uziz"),
new(0x3f, 0xcb, 0x7, 0x6, "Wai Jhou"),
new(0x90, 0xb5, 0x7, 0x6, "Xarabydun"),
new(0xaf, 0x91, 0x7, 0x6, "Yanshi"),
new(0x79, 0x9c, 0x7, 0x6, "Yaraq"),
new(0x7b, 0x70, 0x7, 0x6, "Zaikhal"),
};
}