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:
parent
04841b6807
commit
6b0fa4ff0d
15 changed files with 5865 additions and 3 deletions
355
src/AcDream.App/UI/Layout/MapPageController.cs
Normal file
355
src/AcDream.App/UI/Layout/MapPageController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue