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,
Action<string> Log,
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;
@ -970,6 +978,14 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
AllegianceSetUpdateSubscription: on =>
late.GameRuntime.AllegianceSetUpdateSubscription(on),
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,
Plugins: d.UiRegistry,
Persistence: persistence,

View file

@ -1419,7 +1419,8 @@ public sealed class GameWindow :
ClientTimerNow,
Console.WriteLine,
hostInputCamera.GpuDevice,
hostInputCamera.GpuFrameLifetime),
hostInputCamera.GpuFrameLifetime,
() => WorldTime.CurrentCalendar),
_retailUiLease,
this).Compose(
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>
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 =
{
(CharacterInformation, WindowNames.CharacterInformation),
@ -55,6 +70,7 @@ public static class RetailPanelCatalog
(Vitae, WindowNames.Vitae),
(Options, WindowNames.Options),
(SocialPanel, WindowNames.SocialPanel),
(MapHouse, WindowNames.MapHouse),
};
private static readonly (uint PanelId, string WindowName)[] Toolbar =
@ -63,6 +79,7 @@ public static class RetailPanelCatalog
(Character, WindowNames.Character),
(Magic, WindowNames.Spellbook),
(Options, WindowNames.Options),
(MapHouse, WindowNames.MapHouse),
};
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.
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(
ClientObjectTable Objects,
Func<uint> PlayerGuid,
@ -451,6 +465,7 @@ public sealed record RetailUiRuntimeBindings(
AppraisalRuntimeBindings Appraisal,
OptionsRuntimeBindings Options,
SocialRuntimeBindings Social,
MapHouseRuntimeBindings MapHouse,
StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? Plugins,
RetailUiPersistenceBindings? Persistence,
@ -542,6 +557,7 @@ public sealed class RetailUiRuntime : IDisposable
MountDialogFactory();
MountTooltipPresenter();
MountSocialPanel();
MountMapHousePanel();
MountCharacter();
MountPlugins();
MountInventory();
@ -650,6 +666,7 @@ public sealed class RetailUiRuntime : IDisposable
public VendorUiController? VendorController { get; private set; }
public OptionsPanelController? OptionsPanelController { get; private set; }
public SocialPanelController? SocialPanelController { get; private set; }
public MapHousePanelController? MapHousePanelController { get; private set; }
internal CharacterManagementUiController? CharacterManagementController =>
_characterManagementMount?.Controller;
internal CharacterCreationUiController? CharacterCreationController =>
@ -822,6 +839,7 @@ public sealed class RetailUiRuntime : IDisposable
SelectedObjectController?.Tick(deltaSeconds);
ExternalContainerController?.Tick();
SocialPanelController?.Tick();
MapHousePanelController?.Tick(deltaSeconds);
_itemCooldownController?.Tick();
_characterManagementMount?.Tick();
CharacterManagementController?.Tick();
@ -3256,6 +3274,117 @@ public sealed class RetailUiRuntime : IDisposable
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()
{
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/
/// Fellowship/Squelch panel (<see cref="RetailPanelCatalog.SocialPanel"/>).</summary>
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";
}