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

@ -79,7 +79,15 @@ internal sealed record InteractionRetainedUiDependencies(
Func<double> ClientTime, Func<double> ClientTime,
Action<string> Log, Action<string> Log,
AcDream.App.Rendering.Gpu.IGpuDevice GpuDevice, AcDream.App.Rendering.Gpu.IGpuDevice GpuDevice,
ICurrentGpuFrameSource GpuFrameSource) ICurrentGpuFrameSource GpuFrameSource,
// Batch C (Map/House toolbar panel): the same shape as ClientTime above —
// GameWindow's WorldTimeService is a stable for-the-window-lifetime
// service (unlike the per-session entity/world state Radar's deferred
// slots exist for), so a direct closure is enough; no DeferredXSource
// needed. gmMapUI::Update @0x004a1eb0 reads GameTime::current_game_time
// every 5s — MapPageController owns that cadence, this just supplies the
// current reading.
Func<AcDream.Core.World.DerethDateTime.Calendar> CurrentCalendar)
{ {
public RuntimeActionState Actions => Runtime.ActionOwner; public RuntimeActionState Actions => Runtime.ActionOwner;
@ -970,6 +978,14 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
AllegianceSetUpdateSubscription: on => AllegianceSetUpdateSubscription: on =>
late.GameRuntime.AllegianceSetUpdateSubscription(on), late.GameRuntime.AllegianceSetUpdateSubscription(on),
Trade: d.Runtime.Trade), Trade: d.Runtime.Trade),
// Batch C (overnight hover/UI round): HousePosition/
// HouseLines/HouseShown are left unwired (their bindings
// default to "no house"/empty/no-op) — the House wire
// groundwork (RuntimeHouseState) lands separately; the
// panel mounts and the Map tab works standalone either way.
MapHouse: new MapHouseRuntimeBindings(
CurrentCalendar: d.CurrentCalendar,
PlayerCellId: () => d.PlayerController.Controller?.CellId ?? 0u),
StackSplitQuantity: d.StackSplitQuantity, StackSplitQuantity: d.StackSplitQuantity,
Plugins: d.UiRegistry, Plugins: d.UiRegistry,
Persistence: persistence, Persistence: persistence,

View file

@ -1419,7 +1419,8 @@ public sealed class GameWindow :
ClientTimerNow, ClientTimerNow,
Console.WriteLine, Console.WriteLine,
hostInputCamera.GpuDevice, hostInputCamera.GpuDevice,
hostInputCamera.GpuFrameLifetime), hostInputCamera.GpuFrameLifetime,
() => WorldTime.CurrentCalendar),
_retailUiLease, _retailUiLease,
this).Compose( this).Compose(
platformResult, platformResult,

View file

@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the House tab of the retail Map/House panel (<c>gmHouseUI</c>,
/// class id <c>0x10000025</c>) — <see cref="MapHousePanelController"/>'s
/// second page.
///
/// <para>
/// Retail references: <c>gmHouseUI::PostInit @0x004a2710</c> resolves ONE
/// <c>UIElement_ListBox</c> (<c>m_pTextBox = 0x100001e6</c>) and registers
/// four notice handlers for wire opcodes <c>0x0225-0x0228</c>.
/// <c>gmHouseUI::AddHousePanelText @0x004a2810</c> is
/// <c>UIElement_ListBox::AddItemFromTemplateList(this, 0, nullptr)</c> —
/// live-DAT-confirmed (<c>MapHousePanelSlotProbeTests</c>) as a single
/// <c>UIElement_Text</c> (Type 12) row template at LayoutDesc
/// <c>0x21000025</c> element <c>0x100001e7</c>, no scrollbar authored. The
/// ListBox itself authors ZERO static child rows — the box starts genuinely
/// empty until the first server notice populates it (retail's own
/// <c>PostInit</c> never calls <c>Update</c>/<c>DisplayHouseData</c>).
/// </para>
///
/// <para>
/// <b>Scope (Batch C, 2026-08-17 recon doc).</b> Of the seven
/// <c>Display*</c> line builders <c>DisplayHouseData</c> calls, only
/// <c>DisplayPurchaseTimeText @0x004a3110</c>'s two simple, fully-recovered
/// literal strings ("You may buy another house immediately." / "...after
/// you abandon this one.") are wired end-to-end this session — see
/// <c>RuntimeHouseState.PurchaseAvailabilityText</c>. The other six
/// (BuyPayment/RentPayment/BuyTime/RentTimes/Location/WarningText) only
/// matter once a house is actually owned and are filed as an ISSUES entry
/// rather than guessed at from FPU-mangled decomp.
/// </para>
/// </summary>
public sealed class HousePageController
{
public const uint TextBoxId = 0x100001E6u;
public sealed record Bindings(
Func<IReadOnlyList<string>> Lines,
// Fires once when the page transitions to visible — the seam that
// sends the outbound HouseQuery (0x021E) so the server has a
// reason to answer with fresh HouseData/HouseStatus. NOT a ported
// retail call site (PostInit never triggers a query) — an acdream
// convention, documented as such (recon doc open item).
Action? OnShown = null);
private readonly UiTemplateListBox _listBox;
private readonly Bindings _bindings;
private IReadOnlyList<string> _lastLines = Array.Empty<string>();
private HousePageController(UiTemplateListBox listBox, Bindings bindings)
{
_listBox = listBox;
_bindings = bindings;
}
public static HousePageController? Bind(UiElement page, Bindings bindings)
{
ArgumentNullException.ThrowIfNull(page);
ArgumentNullException.ThrowIfNull(bindings);
if (UiElement.FindDescendant(page, TextBoxId) is not UiTemplateListBox listBox)
{
Console.WriteLine(
$"[D.2b] House tab: ListBox 0x{TextBoxId:X8} not found or not a template list box.");
return null;
}
var controller = new HousePageController(listBox, bindings);
controller.Refresh(bindings.Lines());
return controller;
}
/// <summary>Per-frame poll — cheap no-op when the line set hasn't
/// changed (reference-content compare via SequenceEqual, mirroring the
/// other social-panel pages' revision-gated rebuild discipline).</summary>
public void Tick()
{
IReadOnlyList<string> lines = _bindings.Lines();
if (lines.SequenceEqual(_lastLines)) return;
Refresh(lines);
}
public void OnShown() => _bindings.OnShown?.Invoke();
private void Refresh(IReadOnlyList<string> lines)
{
_lastLines = lines;
_listBox.Flush();
foreach (string line in lines)
{
UiElement? row = _listBox.AddItemFromTemplateList(0);
if (row is UiText text)
text.LinesProvider = () => [new UiText.Line(line, Vector4.One)];
}
}
}

View file

@ -0,0 +1,180 @@
using System;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Mounts retail's two-tab Map/House panel — LayoutDesc <c>0x2100006E</c>
/// slot <c>0x1000018C</c>, <see cref="AcDream.App.UI.RetailPanelCatalog"/>
/// id <b>16</b>. Batch C (overnight hover/UI round, 2026-08-17), built on
/// the <see cref="SocialPanelController"/>/<see cref="OptionsPanelController"/>
/// recipe (Type-8 tab host, <see cref="UiTabPanel.ActivateTabBehavior"/>,
/// per-page scoped controllers).
///
/// <para>
/// <b>Slot/panelId/button — resolved, not guessed.</b> The FA campaign's own
/// full 16-slot <c>gmPanelUI::SetupChildren</c> dump
/// (<c>docs/research/2026-08-11-fa-panel-structure.md:927-933</c>) already
/// named slot <c>0x1000018C</c> as "gmMapUI+gmHouseUI pages", panel id 16 —
/// this session's <c>MapHousePanelSlotProbeTests</c> re-confirmed it live.
/// The toolbar button is <c>0x1000019A</c> (own authored
/// <c>P0x10000029 = 16</c>), one of three currently-ghosted panel buttons
/// (see <c>docs/research/2026-08-17-map-house-recon.md</c>).
/// </para>
///
/// <para>
/// <b>Tab table — live-DAT-confirmed.</b>
/// <c>button 0x100001F3 -&gt; page 0x100001F6 (Map, DEFAULT)</c>,
/// <c>button 0x100001F4 -&gt; page 0x100001F7 (House)</c>. The page roots are
/// themselves typed <c>0x10000026</c> (gmMapUI) / <c>0x10000025</c>
/// (gmHouseUI) in the DAT — <see cref="DatWidgetFactory"/> has no special
/// case for either id, so they build as generic containers (same as every
/// other unmodeled retail UI class); <see cref="MapPageController"/> and
/// <see cref="HousePageController"/> find their own signature children by
/// id underneath.
/// </para>
///
/// <para>
/// <b>Close button</b>: <c>0x100001F5</c> (Type 1, top-right at
/// <c>(276,0) 24x25</c>) — same authored position/size as the Social
/// panel's own close button, the established <c>gmPanelUI</c> sibling
/// convention.
/// </para>
/// </summary>
public sealed class MapHousePanelController : IRetainedPanelController
{
public const uint HostLayoutId = 0x2100006Eu;
public const uint SlotElementId = 0x1000018Cu;
private const uint MapButtonId = 0x100001F3u;
private const uint MapPageId = 0x100001F6u;
private const uint HouseButtonId = 0x100001F4u;
private const uint HousePageId = 0x100001F7u;
private const uint CloseButtonId = 0x100001F5u;
public sealed record Callbacks(
Action Toggle,
MapPageController.Bindings Map,
HousePageController.Bindings House);
private readonly UiTabPanel _tabPanel;
private readonly MapPageController? _map;
private readonly HousePageController? _house;
private readonly Action<uint, uint> _onActivePageChanged;
private bool _visible;
private bool _disposed;
public UiElement Root => _tabPanel;
public UiTabPanel TabPanel => _tabPanel;
private MapHousePanelController(
UiTabPanel tabPanel, MapPageController? map, HousePageController? house)
{
_tabPanel = tabPanel;
_map = map;
_house = house;
// House's outbound query is a fire-when-shown convenience (see
// HousePageController.Bindings.OnShown's own doc — not a ported
// retail trigger, an acdream one), gated the same "window shown AND
// my tab active" conjunction the social panel's Fellowship/
// Allegiance pages use for their own declarations.
_onActivePageChanged = (_, _) => FireHouseShownIfActive();
_tabPanel.ActivePageChanged += _onActivePageChanged;
}
/// <param name="rootInfo">The pre-Build <see cref="ElementInfo"/> tree
/// <paramref name="layout"/> was built from — <see cref="MapPageController"/>
/// needs it to read <c>m_pMap</c>'s own authored int/enum attrs, which
/// only exist on <see cref="ElementInfo"/>, not the built
/// <see cref="UiElement"/> tree.</param>
public static MapHousePanelController? Bind(
ElementInfo rootInfo, ImportedLayout layout, Callbacks callbacks)
{
ArgumentNullException.ThrowIfNull(rootInfo);
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(callbacks);
if (layout.Root is not UiTabPanel tabPanel)
{
Console.WriteLine(
"[D.2b] MapHousePanelController.Bind: root did not build as UiTabPanel "
+ $"(actual type {layout.Root.GetType().Name}) — Map/House panel will not open.");
return null;
}
if (layout.FindElement(CloseButtonId) is UiButton close)
close.OnClick = callbacks.Toggle;
else
Console.WriteLine(
$"[D.2b] MapHousePanelController: close button 0x{CloseButtonId:X8} not found.");
UiElement? mapPage = UiElement.FindDescendant(tabPanel, MapPageId);
UiElement? housePage = UiElement.FindDescendant(tabPanel, HousePageId);
MapPageController? map = null;
if (mapPage is not null)
{
ElementInfo? mapPageInfo = FindInfo(rootInfo, MapPageId);
map = mapPageInfo is null
? null
: MapPageController.Bind(mapPage, mapPageInfo, callbacks.Map);
}
HousePageController? house = housePage is null
? null
: HousePageController.Bind(housePage, callbacks.House);
if (mapPage is null)
Console.WriteLine($"[D.2b] MapHousePanelController: Map page 0x{MapPageId:X8} not found.");
if (housePage is null)
Console.WriteLine($"[D.2b] MapHousePanelController: House page 0x{HousePageId:X8} not found.");
return new MapHousePanelController(tabPanel, map, house);
}
public void ActivateTabs() => _tabPanel.ActivateTabBehavior();
public bool IsShowingHouse => _tabPanel.ActivePageElementId == HousePageId;
public void OnShown()
{
_visible = true;
FireHouseShownIfActive();
}
public void OnHidden() => _visible = false;
private void FireHouseShownIfActive()
{
if (_visible && IsShowingHouse)
_house?.OnShown();
}
/// <summary>Per-frame poll: Map's 5 s-gated refresh (cheap when not due)
/// and House's revision-gated row rebuild. Both stay unconditional
/// (unlike Friends/Squelch's DAT-locked rebuild) — Map's own cadence
/// gate and House's list compare are both cheap even while hidden.</summary>
public void Tick(double deltaSeconds)
{
if (_disposed) return;
_map?.Tick(deltaSeconds);
_house?.Tick();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_tabPanel.ActivePageChanged -= _onActivePageChanged;
}
private static ElementInfo? FindInfo(ElementInfo info, uint id)
{
if (info.Id == id) return info;
foreach (ElementInfo child in info.Children)
{
ElementInfo? found = FindInfo(child, id);
if (found is not null) return found;
}
return null;
}
}

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"),
};
}

View file

@ -0,0 +1,355 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Net.Messages;
using AcDream.Core.Ui;
using AcDream.Core.World;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the Map tab of the retail Map/House panel (<c>gmMapUI</c>, class id
/// <c>0x10000026</c>) — <see cref="MapHousePanelController"/>'s default page.
///
/// <para>
/// Retail references: <c>gmMapUI::PostInit @0x004a1c70</c> (child
/// resolution + hotspot template setup), <c>gmMapUI::Update @0x004a1eb0</c>
/// (5 s refresh cadence — date/time text, coordinate readout, both
/// markers), <c>gmMapUI::PlaceMarkerOnMap @0x004a18b0</c> (marker centering
/// math), <c>gmMapUI::AddMapNote @0x004a1bb0</c> (town hotspot
/// instantiation + literal-string tooltip). Live-DAT byte values confirmed
/// by <c>MapHousePanelSlotProbeTests</c>: marker area
/// <c>(6,8)-(247,258)</c>, hotspot template element <c>0x100001F0</c> in
/// LayoutDesc <c>0x21000026</c>.
/// </para>
///
/// <para>
/// Coordinate math reuses <see cref="RadarCoordinates"/> (already a byte-
/// exact port of the same <c>CPlayerSystem::InqPlayerCoords @0x00560090</c>
/// formula the radar's own coordinate strip uses) rather than re-deriving
/// it — see the recon doc's "no re-port needed" note.
/// </para>
/// </summary>
public sealed class MapPageController
{
// gmMapUI PostInit signature children (pc:171993).
public const uint DateTimeTextId = 0x100001EBu;
public const uint MapWidgetId = 0x100001ECu;
public const uint PlayerIconId = 0x100001EDu;
public const uint HouseIconId = 0x100001EEu;
public const uint CoordinateTextId = 0x100001EFu;
// m_pMap's own authored attrs (gmMapUI::PostInit @0x004a1c70).
private const uint MarkerAreaX0Attr = 0x1000004Eu;
private const uint MarkerAreaX1Attr = 0x1000004Fu;
private const uint MarkerAreaY0Attr = 0x10000050u;
private const uint MarkerAreaY1Attr = 0x10000051u;
private const uint HotspotTemplateElementAttr = 0x47u;
private const uint HotspotTemplateLayoutAttr = 0x48u;
/// <summary>Retail's own 5 s tick cadence (<c>gmMapUI::Update</c>'s
/// <c>m_nextUpdate = Timer::cur_time + 5.0</c>).</summary>
public const double RefreshIntervalSeconds = 5.0;
public sealed record Bindings(
Func<DerethDateTime.Calendar> CurrentCalendar,
Func<uint> PlayerCellId,
// Slice 4 wires the real RuntimeHouseState-backed callback; defaults
// to "no house" (matching retail's Position::IsValid == false
// branch — the house icon starts/stays hidden) so this page works
// standalone before that lands.
Func<CreateObject.ServerPosition?> HousePosition,
Func<uint, uint, UiElement?> TemplateResolver);
private readonly UiElement? _dateTimeText;
private readonly UiElement? _map;
private readonly UiElement? _playerIcon;
private readonly UiElement? _houseIcon;
private readonly UiElement? _coordinateText;
private readonly Bindings _bindings;
private readonly int _markerX0, _markerX1, _markerY0, _markerY1;
private double _nextUpdateSeconds;
private string? _lastDateTimeText;
private string? _lastCoordinateText;
private MapPageController(
UiElement? dateTimeText,
UiElement map,
UiElement? playerIcon,
UiElement? houseIcon,
UiElement? coordinateText,
(int X0, int X1, int Y0, int Y1) markerArea,
Bindings bindings)
{
_dateTimeText = dateTimeText;
_map = map;
_playerIcon = playerIcon;
_houseIcon = houseIcon;
_coordinateText = coordinateText;
_bindings = bindings;
(_markerX0, _markerX1, _markerY0, _markerY1) = markerArea;
}
/// <summary>
/// Binds an already-built page root (<see cref="MapHousePanelController.Bind"/>
/// resolves the page via the panel's tab table). Reads <c>m_pMap</c>'s
/// own marker-area rect straight from the ORIGINAL <see cref="ElementInfo"/>
/// (post-Build widgets don't carry authored int attrs), instantiates the
/// 53 town hotspots once, and returns a controller ready for
/// per-frame <see cref="Tick"/> polling.
///
/// <para>
/// <b>Live-DAT structural finding (MapHousePanelSlotProbeTests' follow-up
/// dump):</b> <c>m_pMap</c> (<c>0x100001EC</c>) is itself authored as a
/// Type-1 BUTTON (the GM click-to-teleport feature at
/// <c>gmMapUI::ListenToElementMessage @0x004a2350</c> idMessage
/// <c>0x1c</c>), and the player/house icons (<c>0x100001ED</c>/
/// <c>0x100001EE</c>) are authored as ITS OWN nested children, not
/// 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.
/// </para>
/// </summary>
public static MapPageController? Bind(UiElement page, ElementInfo pageInfo, Bindings bindings)
{
ArgumentNullException.ThrowIfNull(page);
ArgumentNullException.ThrowIfNull(pageInfo);
ArgumentNullException.ThrowIfNull(bindings);
UiElement? map = UiElement.FindDescendant(page, MapWidgetId);
if (map is null)
{
Console.WriteLine($"[D.2b] Map tab: m_pMap 0x{MapWidgetId:X8} not found — Map tab will not populate.");
return null;
}
ElementInfo? mapInfo = FindInfo(pageInfo, MapWidgetId);
var markerArea = (X0: 0, X1: 0, Y0: 0, Y1: 0);
if (mapInfo is not null)
{
int x0 = mapInfo.TryGetEffectiveProperty(MarkerAreaX0Attr, out var vx0) ? vx0.IntegerValue : 0;
int x1 = mapInfo.TryGetEffectiveProperty(MarkerAreaX1Attr, out var vx1) ? vx1.IntegerValue : 0;
int y0 = mapInfo.TryGetEffectiveProperty(MarkerAreaY0Attr, out var vy0) ? vy0.IntegerValue : 0;
int y1 = mapInfo.TryGetEffectiveProperty(MarkerAreaY1Attr, out var vy1) ? vy1.IntegerValue : 0;
markerArea = (x0, x1, y0, y1);
}
UiElement? playerIcon = ResolveSwallowedIcon(map, bindings.TemplateResolver, PlayerIconId);
UiElement? houseIcon = ResolveSwallowedIcon(map, bindings.TemplateResolver, HouseIconId);
var controller = new MapPageController(
UiElement.FindDescendant(page, DateTimeTextId),
map,
playerIcon,
houseIcon,
UiElement.FindDescendant(page, CoordinateTextId),
markerArea,
bindings);
controller.BuildTownMarkers(mapInfo, bindings.TemplateResolver);
// UiText is a pull-based scrollback widget (LinesProvider), not an
// imperative SetText target — wire the provider ONCE here to read
// the mutable backing field Refresh() updates, matching the
// established pattern (e.g. CharacterStatController's xpValue).
if (controller._dateTimeText is UiText dateTimeText)
dateTimeText.LinesProvider = () => ToLines(controller._lastDateTimeText, dateTimeText.DefaultColor);
if (controller._coordinateText is UiText coordinateText)
coordinateText.LinesProvider = () => ToLines(controller._lastCoordinateText, coordinateText.DefaultColor);
// Immediate first refresh rather than waiting out the first 5 s tick.
controller.Refresh();
controller._nextUpdateSeconds = RefreshIntervalSeconds;
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
/// <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? icon = templateResolver(MapHousePanelController.HostLayoutId, iconElementId);
if (icon is null)
{
Console.WriteLine(
$"[D.2b] Map tab: icon 0x{iconElementId:X8} did not resolve — it will not be shown.");
return null;
}
icon.Visible = false;
map.AddChild(icon);
return icon;
}
private static IReadOnlyList<UiText.Line> ToLines(string? text, System.Numerics.Vector4 color)
{
if (string.IsNullOrEmpty(text)) return Array.Empty<UiText.Line>();
string[] parts = text.Split('\n');
var lines = new UiText.Line[parts.Length];
for (int i = 0; i < parts.Length; i++)
lines[i] = new UiText.Line(parts[i], color);
return lines;
}
/// <summary>
/// Instantiates the 53 static town hotspots (<c>gmMapUI::AddMapNote</c>)
/// from <c>m_pMap</c>'s own <c>0x47</c>/<c>0x48</c> template attrs. A
/// missing template (either attr absent, or the DAT install lacks the
/// referenced LayoutDesc/element) leaves the map usable without
/// hotspots rather than failing the whole page — matches retail's own
/// null-guarded <c>if (eax_10 != 0)</c> before the loop.
/// </summary>
private void BuildTownMarkers(ElementInfo? mapInfo, Func<uint, uint, UiElement?> templateResolver)
{
if (mapInfo is null) return;
if (!mapInfo.TryGetEffectiveProperty(HotspotTemplateElementAttr, out var templateElement)) return;
if (!mapInfo.TryGetEffectiveProperty(HotspotTemplateLayoutAttr, out var templateLayout)) return;
if (templateLayout.UnsignedValue == 0) return;
foreach (MapLocation loc in MapLocations.All)
{
UiElement? marker = templateResolver(
(uint)templateLayout.UnsignedValue, (uint)templateElement.UnsignedValue);
if (marker is null) continue;
marker.Left = loc.X;
marker.Top = loc.Y;
marker.Width = loc.Width;
marker.Height = loc.Height;
// gmMapUI::AddMapNote's UIElement::SetTooltip call — a LITERAL
// string (StringInfo::SetLiteralValue), not a DAT table lookup.
// AuthoredTooltipText/Enabled is the exact seam
// RetailTooltipPresenter already serves (closes register row
// TS-85's last item, gmMapUI::AddMapNote @0x004A1C51).
marker.AuthoredTooltipText = loc.Name;
marker.AuthoredTooltipEnabled = true;
_map!.AddChild(marker);
}
}
/// <summary>Per-frame poll, accumulating wall-clock deltas
/// (<see cref="RetailUiRuntime.Tick"/>'s own shape) into retail's 5 s
/// cadence — same net effect as <c>Timer::cur_time</c> comparison
/// without needing a separate absolute clock dependency.</summary>
public void Tick(double deltaSeconds)
{
_nextUpdateSeconds -= deltaSeconds;
if (_nextUpdateSeconds > 0) return;
_nextUpdateSeconds = RefreshIntervalSeconds;
Refresh();
}
private void Refresh()
{
RefreshDateTime();
RefreshCoordinatesAndPlayerMarker();
RefreshHouseMarker();
}
private void RefreshDateTime()
{
if (_dateTimeText is null) return;
DerethDateTime.Calendar calendar = _bindings.CurrentCalendar();
string text = FormatDateTime(calendar);
// gmMapUI::Update only calls SetText when the string actually
// differs (wcscmp change-detect), not a re-stamp every 5s. The
// LinesProvider wired in Bind() re-reads this field lazily, so
// updating it IS the display update.
_lastDateTimeText = text;
}
/// <summary>
/// <c>"Date: %s\nTime: %s"</c> (<c>gmMapUI::Update</c>'s sprintf shape,
/// fed by <c>GameTime::GetDateTimeString @0x005a6530</c>). Month names
/// already match retail display text 1:1
/// (<see cref="DerethDateTime.MonthName"/>); hour names need the
/// "AndHalf" suffix rewritten to "-and-Half".
/// </summary>
internal static string FormatDateTime(DerethDateTime.Calendar calendar) =>
$"Date: {calendar.Month} {calendar.Day}, {calendar.Year} P.Y.\nTime: {FormatHourName(calendar.Hour)}";
private static string FormatHourName(DerethDateTime.HourName hour)
{
string name = hour.ToString();
const string suffix = "AndHalf";
return name.EndsWith(suffix, StringComparison.Ordinal)
? string.Concat(name.AsSpan(0, name.Length - suffix.Length), "-and-Half")
: name;
}
private void RefreshCoordinatesAndPlayerMarker()
{
if (_coordinateText is null && _playerIcon is null) return;
bool outside = RadarCoordinates.TryFromCell(_bindings.PlayerCellId(), out RadarCoordinates coords);
if (outside)
{
_lastCoordinateText = coords.CombinedText;
PlaceMarker(_playerIcon, coords.X, coords.Y);
}
else
{
// Indoors: retail clears the coordinate text and hides the
// player marker (gmMapUI::Update's else branch,
// m_pPlayerLocationIcon->SetVisible(0)).
_lastCoordinateText = string.Empty;
if (_playerIcon is not null)
_playerIcon.Visible = false;
}
}
private void RefreshHouseMarker()
{
if (_houseIcon is null) return;
CreateObject.ServerPosition? housePosition = _bindings.HousePosition();
if (housePosition is null)
{
_houseIcon.Visible = false;
return;
}
// Position::get_outside_cell_id(&m_HousePosition) -> gid_to_lcoord
// -> the SAME (v-0x400)*0.1+0.5 transform PlaceMarkerOnMap's player
// branch uses (gmMapUI::Update @0x004a22a6-f6).
if (!RadarCoordinates.TryFromCell(housePosition.Value.LandblockId, out RadarCoordinates coords))
{
_houseIcon.Visible = false;
return;
}
PlaceMarker(_houseIcon, coords.X, coords.Y);
}
/// <summary>
/// <c>gmMapUI::PlaceMarkerOnMap @0x004a18b0</c>: center the icon at
/// <c>(markerAreaX0 + x, markerAreaY0 + y)</c>, then show it.
/// </summary>
private void PlaceMarker(UiElement? icon, double x, double y)
{
if (icon is null) return;
icon.Left = _markerX0 + (float)x - icon.Width / 2f;
icon.Top = _markerY0 + (float)y - icon.Height / 2f;
icon.Visible = true;
}
private static ElementInfo? FindInfo(ElementInfo info, uint id)
{
if (info.Id == id) return info;
foreach (ElementInfo child in info.Children)
{
ElementInfo? found = FindInfo(child, id);
if (found is not null) return found;
}
return null;
}
}

View file

@ -42,6 +42,21 @@ public static class RetailPanelCatalog
/// </summary> /// </summary>
public const uint SocialPanel = 12u; public const uint SocialPanel = 12u;
/// <summary>
/// Batch C (overnight hover/UI round, 2026-08-17): the two-tab Map/House
/// panel's <c>gmPanelUI</c> slot key — byte-verified from the live
/// installed DATs (host <c>0x2100006E</c> slot <c>0x1000018C</c>'s own
/// authored <c>0x10000029 = 16</c>, and the toolbar Map/House button
/// <c>0x1000019A</c>'s own authored <c>0x10000029 = 16</c>,
/// <c>MapHousePanelSlotProbeTests</c>). Already independently named by
/// the FA campaign's full 16-slot dump
/// (<c>docs/research/2026-08-11-fa-panel-structure.md:927-933</c>,
/// "gmMapUI+gmHouseUI pages"). Unlike <see cref="SocialPanel"/>, a real
/// toolbar button opens this one — it is in BOTH <see cref="Mounted"/>
/// and <see cref="Toolbar"/>.
/// </summary>
public const uint MapHouse = 16u;
private static readonly (uint PanelId, string WindowName)[] Mounted = private static readonly (uint PanelId, string WindowName)[] Mounted =
{ {
(CharacterInformation, WindowNames.CharacterInformation), (CharacterInformation, WindowNames.CharacterInformation),
@ -55,6 +70,7 @@ public static class RetailPanelCatalog
(Vitae, WindowNames.Vitae), (Vitae, WindowNames.Vitae),
(Options, WindowNames.Options), (Options, WindowNames.Options),
(SocialPanel, WindowNames.SocialPanel), (SocialPanel, WindowNames.SocialPanel),
(MapHouse, WindowNames.MapHouse),
}; };
private static readonly (uint PanelId, string WindowName)[] Toolbar = private static readonly (uint PanelId, string WindowName)[] Toolbar =
@ -63,6 +79,7 @@ public static class RetailPanelCatalog
(Character, WindowNames.Character), (Character, WindowNames.Character),
(Magic, WindowNames.Spellbook), (Magic, WindowNames.Spellbook),
(Options, WindowNames.Options), (Options, WindowNames.Options),
(MapHouse, WindowNames.MapHouse),
}; };
public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted; public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted;

View file

@ -289,6 +289,20 @@ public sealed record SocialRuntimeBindings(
// Trailing/optional per the established compatibility convention. // Trailing/optional per the established compatibility convention.
AcDream.Runtime.Gameplay.IRuntimeTradeView? Trade = null); AcDream.Runtime.Gameplay.IRuntimeTradeView? Trade = null);
/// <summary>
/// Batch C (overnight hover/UI round, 2026-08-17): bindings for the
/// two-tab Map/House panel. <see cref="HousePosition"/> defaults to
/// "no house" and <see cref="HouseLines"/> to empty when the caller doesn't
/// wire the House wire groundwork — the panel still mounts and the Map tab
/// still works standalone.
/// </summary>
public sealed record MapHouseRuntimeBindings(
Func<AcDream.Core.World.DerethDateTime.Calendar> CurrentCalendar,
Func<uint> PlayerCellId,
Func<CreateObject.ServerPosition?>? HousePosition = null,
Func<IReadOnlyList<string>>? HouseLines = null,
Action? HouseShown = null);
public sealed record InventoryRuntimeBindings( public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects, ClientObjectTable Objects,
Func<uint> PlayerGuid, Func<uint> PlayerGuid,
@ -451,6 +465,7 @@ public sealed record RetailUiRuntimeBindings(
AppraisalRuntimeBindings Appraisal, AppraisalRuntimeBindings Appraisal,
OptionsRuntimeBindings Options, OptionsRuntimeBindings Options,
SocialRuntimeBindings Social, SocialRuntimeBindings Social,
MapHouseRuntimeBindings MapHouse,
StackSplitQuantityState StackSplitQuantity, StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? Plugins, BufferedUiRegistry? Plugins,
RetailUiPersistenceBindings? Persistence, RetailUiPersistenceBindings? Persistence,
@ -542,6 +557,7 @@ public sealed class RetailUiRuntime : IDisposable
MountDialogFactory(); MountDialogFactory();
MountTooltipPresenter(); MountTooltipPresenter();
MountSocialPanel(); MountSocialPanel();
MountMapHousePanel();
MountCharacter(); MountCharacter();
MountPlugins(); MountPlugins();
MountInventory(); MountInventory();
@ -650,6 +666,7 @@ public sealed class RetailUiRuntime : IDisposable
public VendorUiController? VendorController { get; private set; } public VendorUiController? VendorController { get; private set; }
public OptionsPanelController? OptionsPanelController { get; private set; } public OptionsPanelController? OptionsPanelController { get; private set; }
public SocialPanelController? SocialPanelController { get; private set; } public SocialPanelController? SocialPanelController { get; private set; }
public MapHousePanelController? MapHousePanelController { get; private set; }
internal CharacterManagementUiController? CharacterManagementController => internal CharacterManagementUiController? CharacterManagementController =>
_characterManagementMount?.Controller; _characterManagementMount?.Controller;
internal CharacterCreationUiController? CharacterCreationController => internal CharacterCreationUiController? CharacterCreationController =>
@ -822,6 +839,7 @@ public sealed class RetailUiRuntime : IDisposable
SelectedObjectController?.Tick(deltaSeconds); SelectedObjectController?.Tick(deltaSeconds);
ExternalContainerController?.Tick(); ExternalContainerController?.Tick();
SocialPanelController?.Tick(); SocialPanelController?.Tick();
MapHousePanelController?.Tick(deltaSeconds);
_itemCooldownController?.Tick(); _itemCooldownController?.Tick();
_characterManagementMount?.Tick(); _characterManagementMount?.Tick();
CharacterManagementController?.Tick(); CharacterManagementController?.Tick();
@ -3256,6 +3274,117 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[UI] retail social panel from LayoutDesc importer (0x2100006E slot 0x1000018F)."); Console.WriteLine("[UI] retail social panel from LayoutDesc importer (0x2100006E slot 0x1000018F).");
} }
/// <summary>
/// Batch C (overnight hover/UI round, 2026-08-17): the two-tab Map/House
/// panel — host <c>0x2100006E</c> slot <c>0x1000018C</c>,
/// <see cref="RetailPanelCatalog.MapHouse"/> id 16. Same import/Build/Bind
/// recipe as <see cref="MountSocialPanel"/>. The House tab's ListBox rows
/// resolve through its own authored template directly via
/// <see cref="UiTemplateListBox.AddItemFromTemplateList"/> (a FIXED
/// single template, unlike Friends/Squelch/Fellowship's live-roster
/// row families) — the <see cref="Layout.RowTemplateResolver"/> below
/// serves only the Map tab's per-town hotspot template.
/// </summary>
private void MountMapHousePanel()
{
ElementInfo? rootInfo;
ImportedLayout? layout;
var strings = new DatStringResolver(_bindings.Assets.Dats);
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
Layout.MapHousePanelController.HostLayoutId,
Layout.MapHousePanelController.SlotElementId);
layout = rootInfo is null
? null
: LayoutImporter.Build(
rootInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve);
}
if (rootInfo is null || layout is null)
{
Console.WriteLine("[UI] Map/House panel: LayoutDesc 0x2100006E slot 0x1000018C not found.");
return;
}
// The town-hotspot template (m_pMap's own 0x47/0x48 attrs) is
// resolved once and cached — same "resolve the ElementInfo once,
// Build a fresh UiElement per call" shape RowTemplateResolver uses
// for the social panel's row families.
var hotspotTemplate = new Layout.RowTemplateResolver(
(templateLayoutId, templateElementId) => LayoutImporter.ImportInfos(
_bindings.Assets.Dats, templateLayoutId, templateElementId),
info => LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont).Root);
UiElement? ResolveHotspotTemplate(uint templateLayoutId, uint templateElementId)
{
lock (_bindings.Assets.DatLock)
return hotspotTemplate.Resolve(templateLayoutId, templateElementId);
}
MapHouseRuntimeBindings mh = _bindings.MapHouse;
var callbacks = new Layout.MapHousePanelController.Callbacks(
Toggle: () => ToggleWindow(WindowNames.MapHouse),
Map: new Layout.MapPageController.Bindings(
CurrentCalendar: mh.CurrentCalendar,
PlayerCellId: mh.PlayerCellId,
HousePosition: mh.HousePosition ?? (static () => null),
TemplateResolver: ResolveHotspotTemplate),
House: new Layout.HousePageController.Bindings(
Lines: mh.HouseLines ?? (static () => Array.Empty<string>()),
OnShown: mh.HouseShown));
Layout.MapHousePanelController? controller;
lock (_bindings.Assets.DatLock)
controller = Layout.MapHousePanelController.Bind(rootInfo, layout, callbacks);
if (controller is null)
{
Console.WriteLine("[UI] Map/House panel: required root did not build as UiTabPanel.");
return;
}
controller.ActivateTabs();
MapHousePanelController = controller;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
controller.Root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.MapHouse,
Chrome = RetailWindowChrome.NineSlice,
Left = 240f,
Top = 160f,
Visible = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
ContentClickThrough = false,
DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo),
Controller = controller,
});
_panelUi.RegisterMainPanel(
RetailPanelCatalog.MapHouse,
WindowNames.MapHouse,
handle,
rootInfo.TryGetEffectiveBool(
RetailPanelUiController.RestorePreviousPropertyId,
out bool restorePrevious)
&& restorePrevious);
Console.WriteLine("[UI] retail Map/House panel from LayoutDesc importer (0x2100006E slot 0x1000018C).");
}
private void MountDialogFactory() private void MountDialogFactory()
{ {
if (DialogFactory is not null) if (DialogFactory is not null)

View file

@ -34,4 +34,8 @@ public static class WindowNames
/// <summary>Campaign FA slice FA3: the four-tab Friends/Allegiance/ /// <summary>Campaign FA slice FA3: the four-tab Friends/Allegiance/
/// Fellowship/Squelch panel (<see cref="RetailPanelCatalog.SocialPanel"/>).</summary> /// Fellowship/Squelch panel (<see cref="RetailPanelCatalog.SocialPanel"/>).</summary>
public const string SocialPanel = "social-panel"; public const string SocialPanel = "social-panel";
/// <summary>Batch C (overnight hover/UI round): the two-tab Map/House
/// panel (<see cref="RetailPanelCatalog.MapHouse"/>).</summary>
public const string MapHouse = "map-house";
} }

View file

@ -257,7 +257,8 @@ public sealed class InteractionRetainedUiCompositionTests
ClientTime: static () => 0d, ClientTime: static () => 0d,
Log: static _ => { }, Log: static _ => { },
GpuDevice: null!, GpuDevice: null!,
GpuFrameSource: null!); GpuFrameSource: null!,
CurrentCalendar: static () => default);
} }
public InteractionRetainedUiDependencies Dependencies { get; } public InteractionRetainedUiDependencies Dependencies { get; }

View file

@ -272,6 +272,15 @@ public static class FixtureLoader
public static ElementInfo LoadSocialPanelHostInfos() public static ElementInfo LoadSocialPanelHostInfos()
=> LoadInfos("social_panel_2100006E_1000018F.json"); => LoadInfos("social_panel_2100006E_1000018F.json");
/// <summary>The two-tab Map/House panel host slot <c>0x1000018C</c> —
/// Batch C (overnight hover/UI round). This is what
/// <see cref="MapHousePanelController.Bind"/> actually mounts.</summary>
public static ImportedLayout LoadMapHouseHost()
=> LayoutImporter.Build(LoadMapHouseHostInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadMapHouseHostInfos()
=> LoadInfos("map_house_2100006E_1000018C.json");
/// <summary>Configure Keyboard screen LayoutDesc <c>0x21000009</c> (standalone /// <summary>Configure Keyboard screen LayoutDesc <c>0x21000009</c> (standalone
/// import — its own separate full-screen window, NOT nested under the Options /// import — its own separate full-screen window, NOT nested under the Options
/// panel's <c>0x2100006E</c> host — Campaign OP slice OP8).</summary> /// panel's <c>0x2100006E</c> host — Campaign OP slice OP8).</summary>

View file

@ -0,0 +1,171 @@
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Net.Messages;
using AcDream.Core.World;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Controller-level tests for <see cref="MapHousePanelController"/> against
/// the committed <c>map_house_2100006E_1000018C.json</c> fixture (Batch C,
/// overnight hover/UI round) — the SAME <c>LayoutImporter.ImportInfos(dats,
/// 0x2100006Eu, 0x1000018Cu)</c> catalog import
/// <see cref="RetailUiRuntime.MountMapHousePanel"/> performs against real
/// DATs. No DAT access, no live runtime — dat-free per the established
/// <see cref="FixtureLoader"/> pattern.
/// </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) — 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 fact need it too.</summary>
private static UiElement? FakeHotspotTemplate(uint layoutId, uint elementId)
=> new UiText { Width = 10f, Height = 10f, DatElementId = elementId };
private static MapHousePanelController.Callbacks MakeCallbacks(
List<string>? calls = null,
Func<DerethDateTime.Calendar>? currentCalendar = null,
Func<uint>? playerCellId = null,
Func<CreateObject.ServerPosition?>? housePosition = null,
Func<IReadOnlyList<string>>? houseLines = null)
{
calls ??= new List<string>();
return new MapHousePanelController.Callbacks(
Toggle: () => calls.Add("toggle"),
Map: new MapPageController.Bindings(
CurrentCalendar: currentCalendar ?? (static () => default),
PlayerCellId: playerCellId ?? (static () => 0u),
HousePosition: housePosition ?? (static () => null),
TemplateResolver: FakeHotspotTemplate),
House: new HousePageController.Bindings(
Lines: houseLines ?? (static () => Array.Empty<string>()),
OnShown: () => calls.Add("house-shown")));
}
[Fact]
public void Bind_RootBuildsAsUiTabPanel()
{
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
MapHousePanelController? controller =
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks());
Assert.NotNull(controller);
Assert.IsType<UiTabPanel>(controller!.Root);
}
/// <summary>Pins the authored tab table exactly as read from the live
/// DATs (<c>MapHousePanelSlotProbeTests</c>): two entries, Map is the
/// sole default.</summary>
[Fact]
public void TabTable_MatchesLiveDatPairing_MapIsDefault()
{
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
var tabs = Assert.IsType<UiTabPanel>(layout.Root);
Assert.Equal(2, tabs.Tabs.Count);
Assert.Contains(tabs.Tabs, e =>
e.ButtonElementId == 0x100001F3u && e.PageElementId == 0x100001F6u && e.IsDefault);
Assert.Contains(tabs.Tabs, e =>
e.ButtonElementId == 0x100001F4u && e.PageElementId == 0x100001F7u && !e.IsDefault);
Assert.Single(tabs.Tabs, e => e.IsDefault);
}
[Fact]
public void Bind_Succeeds_AndActivateTabs_SelectsMapByDefault()
{
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
MapHousePanelController? controller =
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks());
Assert.NotNull(controller);
controller!.ActivateTabs();
Assert.Empty(controller.TabPanel.UnresolvedEntries);
Assert.False(controller.IsShowingHouse);
}
[Fact]
public void SwitchToHouse_FiresOnShown_OnlyWhenPanelIsVisible()
{
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
var calls = new List<string>();
MapHousePanelController? controller =
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks(calls));
Assert.NotNull(controller);
controller!.ActivateTabs();
// Not visible yet: switching tabs must not fire OnShown.
controller.TabPanel.SwitchTo(0x100001F7u);
Assert.DoesNotContain("house-shown", calls);
controller.OnShown();
Assert.Contains("house-shown", calls);
}
[Fact]
public void CloseButton_InvokesToggle()
{
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
var calls = new List<string>();
MapHousePanelController? controller =
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks(calls));
Assert.NotNull(controller);
UiElement? close = layout.FindElement(0x100001F5u);
Assert.IsType<UiButton>(close);
((UiButton)close!).OnClick?.Invoke();
Assert.Contains("toggle", calls);
}
[Fact]
public void Bind_BuildsAll53TownHotspots_UnderTheMapWidget()
{
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
MapHousePanelController? controller =
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks());
Assert.NotNull(controller);
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.
var townMarkers = map!.Children
.Where(c => c.DatElementId != MapPageController.PlayerIconId
&& c.DatElementId != MapPageController.HouseIconId)
.ToList();
Assert.Equal(55, map.Children.Count);
Assert.Equal(53, townMarkers.Count);
Assert.All(townMarkers, c => Assert.Contains(
MapLocations.All, loc => loc.Width == c.Width && loc.Height == c.Height));
Assert.All(townMarkers, c => Assert.True(c.AuthoredTooltipEnabled));
Assert.Contains(townMarkers, c => c.AuthoredTooltipText == "Holtburg");
}
[Fact]
public void Bind_HouseListBoxStartsEmpty_MatchingRetailPostInit()
{
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
MapHousePanelController? controller =
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks());
Assert.NotNull(controller);
UiElement? box = UiElement.FindDescendant(controller!.Root, HousePageController.TextBoxId);
var listBox = Assert.IsType<UiTemplateListBox>(box);
Assert.Equal(0, listBox.ContentHeight);
}
}

View file

@ -0,0 +1,191 @@
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 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).
const uint cellId = 0x11CE0001u;
Assert.True(RadarCoordinates.TryFromCell(cellId, out RadarCoordinates expected));
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 }),
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) -> m_x0=6, m_y0=8.
const int markerX0 = 6, markerY0 = 8;
Assert.Equal(markerX0 + (float)expected.X - playerIcon.Width / 2f, playerIcon.Left, precision: 3);
Assert.Equal(markerY0 + (float)expected.Y - playerIcon.Height / 2f, playerIcon.Top, precision: 3);
}
[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 }),
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 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 }),
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);
}
}

View file

@ -153,6 +153,11 @@ public sealed class RetailLayoutFixtureGenerator
// lane-A unknowns U3/U4/U6/U7/U10. // lane-A unknowns U3/U4/U6/U7/U10.
(SocialPanelController.HostLayoutId, SocialPanelController.SlotElementId, (SocialPanelController.HostLayoutId, SocialPanelController.SlotElementId,
"social_panel_2100006E_1000018F.json"), "social_panel_2100006E_1000018F.json"),
// Batch C (overnight hover/UI round, 2026-08-17): the two-tab
// Map/House panel — recon doc
// docs/research/2026-08-17-map-house-recon.md.
(MapHousePanelController.HostLayoutId, MapHousePanelController.SlotElementId,
"map_house_2100006E_1000018C.json"),
}) })
{ {
ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId); ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId);

File diff suppressed because it is too large Load diff