acdream/src/AcDream.App/UI/Layout/MapPageController.cs
Erik 0b0c7aa485 fix(ui): night-round review — F11/F13/F14/F15 one-liners
F11: logs when a Map tab town-marker's template resolves to something
other than a UiButton — that path previously silently skipped the
TooltipText write with no diagnostic, leaving a mounted-but-empty
tooltip popup indistinguishable from "no template configured".

F13: RetailSkillFormula.FormatFormula now reads Attribute1Multiplier/
Attribute2Multiplier/AdditiveBonus/Divisor through the SAME unsigned
reinterpretation TryCalculate already uses (this class's own doc
comment already stated the invariant; FormatFormula just didn't follow
it). A high-bit-set value would previously both mis-gate hasAttr1/
hasAttr2 and print a negative number, out of sync with what
TryCalculate actually computes with for the same formula. Added
regression tests, empirically verified to fail without the fix.

F14: documented the RefreshHouseMarker gap rather than guessing at the
byte-decode — Position::get_outside_cell_id @0x004527b0 is itself
BN-mangled (its `(eax_2 - eax_2) & objcell_id` return is the same
decompiler-obscures-a-real-conditional artifact class this round hit
elsewhere) and depends on LandDefs::adjust_to_outside, a genuinely
larger port than this round's other findings. HousePosition is wired
() => null in production today (ISSUES #413's remaining scope), so
this method is currently unreachable; left a TODO citing the retail
call chain for whenever that lands.

F15: fixed RefreshCoordinatesAndPlayerMarker's gate to AND-on-both-
present, matching gmMapUI::Update @0x004a2078's exact
`if (m_pCoordinateText != 0 && m_pPlayerLocationIcon != 0)` condition.
The prior `_coordinateText is null && _playerIcon is null` check only
skipped when BOTH were absent (proceeding whenever EITHER was
present), letting coordinate text and the player marker update
independently instead of as the single gated unit retail treats them
as. Added a regression test (player-icon template resolution failure
must also skip the coordinate-text write), empirically verified to
fail without the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 05:06:17 +02:00

473 lines
22 KiB
C#

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 —
// i.e. retail's RUNTIME m_TTText mechanism, not the authored
// P0x49 path. UiButton.TooltipText is the exact settable seam
// backing UiElement.GetTooltipText()'s override, which
// RetailTooltipPresenter.ResolveTooltipText consults BEFORE the
// authored text (closes register row TS-85's last item,
// gmMapUI::AddMapNote @0x004A1C51). AuthoredTooltipRootElementId/
// LayoutDid still gate the popup SKIN unconditionally even on
// the runtime-text path — the map-note template (0x100001F0)
// authors no individual tooltip-popup locator of its own (a
// plain 10x10 hotspot dot), so RetailTooltipPresenter's popup
// needs one supplied; RetailTooltipPresenter.SharedPopupSkinRootElementId/
// SharedPopupSkinLayoutDid (see that class's own single
// canonical citation, night-round review F10) is the same
// proven-working skin UiItemSlot already hardcodes.
if (marker is UiButton markerButton)
markerButton.TooltipText = loc.Name;
else
// F11 (night-round review): silently skipping the runtime-
// text write here would leave the marker's popup mounted
// (AuthoredTooltipRootElementId/LayoutDid are still set
// below) but genuinely EMPTY — a live-DAT template change
// that resolves 0x100001F0 to something other than a
// UiButton would regress every town-marker tooltip with no
// diagnostic signal at all.
Console.WriteLine(
$"[D.2b] Map tab: town marker '{loc.Name}' template "
+ $"resolved to {marker.GetType().Name}, not UiButton — "
+ "TooltipText cannot be set, marker will show no tooltip.");
marker.AuthoredTooltipRootElementId = RetailTooltipPresenter.SharedPopupSkinRootElementId;
marker.AuthoredTooltipLayoutDid = RetailTooltipPresenter.SharedPopupSkinLayoutDid;
_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;
}
/// <summary>
/// <c>gmMapUI::Update @0x004a2078</c>'s gate is
/// <c>if (m_pCoordinateText != 0 &amp;&amp; m_pPlayerLocationIcon != 0)</c>
/// — BOTH widgets present, not "at least one". Night-round review F15:
/// the prior <c>_coordinateText is null &amp;&amp; _playerIcon is null</c>
/// check only skipped this method when BOTH were absent (De Morgan's:
/// it PROCEEDED whenever EITHER was present), so a page missing one of
/// the two would still write the other's state independently — retail
/// updates NEITHER when either is missing (no coordinate-text write,
/// no marker show/hide) since the whole outside/inside branch,
/// including its inside-branch fallback, lives inside this one gate.
/// </summary>
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;
_playerIcon.Visible = false;
}
}
/// <summary>
/// <c>gmMapUI::Update @0x004a22a6-f6</c>: <c>Position::get_outside_cell_id
/// (&amp;m_HousePosition) -&gt; LandDefs::gid_to_lcoord</c> -&gt; the SAME
/// <c>(v-0x400)*0.1+0.5</c> transform <see cref="PlaceMarker"/>'s player
/// branch uses.
/// </summary>
/// <remarks>
/// Night-round review F14: this passes <c>housePosition.Value.LandblockId</c>
/// straight to <see cref="RadarCoordinates.TryFromCell"/>, SKIPPING the
/// <c>Position::get_outside_cell_id @0x004527b0</c> step retail's own
/// call chain names. That function is itself BN-mangled (its final
/// <c>return ((eax_2 - eax_2) &amp; objcell_id)</c> — an always-zero
/// subtraction ANDed with the cell id — is textbook Binary Ninja
/// obscuring a real conditional the raw bytes would need to
/// disassemble to recover, the same artifact class F1/F3 hit
/// elsewhere this round) and depends on <c>LandDefs::adjust_to_outside</c>,
/// which takes the position's raw world XYZ (not just the landblock
/// id) — a genuinely different, larger port than this round's other
/// findings, not a one-line fix. Documenting the gap rather than
/// guessing at the byte-decode (per this finding's own explicit
/// escape hatch): <see cref="Bindings.HousePosition"/> is wired
/// <c>() =&gt; null</c> in production today (ISSUES #413's remaining
/// owned-house scope), so this whole method is UNREACHABLE live —
/// there is no current behavioral gap to observe, only a latent one
/// for whenever HousePosition gets wired to real HouseData. TODO:
/// when that lands, port <c>Position::get_outside_cell_id</c> /
/// <c>LandDefs::adjust_to_outside</c> (byte-decode required,
/// <c>@0x004527b0</c> / call site <c>@0x004a2297</c>) instead of
/// passing the raw landblock id through — for a genuinely outdoor
/// house position this simplification is very likely already exact
/// (an outdoor position has nothing for <c>adjust_to_outside</c> to
/// adjust), but that has not been byte-confirmed, and an indoor
/// house-interior recall position would need the real conversion
/// rather than this method's current fail-safe (hide the marker,
/// since <see cref="RadarCoordinates.TryFromCell"/> correctly refuses
/// any cell with an envcell low word).
/// </remarks>
private void RefreshHouseMarker()
{
if (_houseIcon is null) return;
CreateObject.ServerPosition? housePosition = _bindings.HousePosition();
if (housePosition is null)
{
_houseIcon.Visible = false;
return;
}
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>, ported from a direct
/// byte-read of the PDB-paired <c>acclient.exe</c> (Binary Ninja elides
/// the whole FPU chain to bare, operand-less <c>_ftol2()</c> calls —
/// see <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>
/// lines 171827-171855 — so the pseudo-C alone under-specifies this
/// function; capstone disassembly of the raw machine code at that VA
/// is the ground truth here, not the BN text). The prior "center at
/// markerX0+x" reading was WRONG — retail projects the AC display
/// coordinate (<paramref name="x"/>/<paramref name="y"/>, range
/// approximately ±102.4) onto the marker-area rect's pixel span via a
/// fixed-point-style transform, not a raw pixel add:
/// <code>
/// X = m_x0 - w/2 - (int)( (m_x1-m_x0+1) * (x*10+1024) * (-1/2048) )
/// Y = m_y0 - h/2 - (int)( (m_y1-m_y0+1) * (2047-(y*10+1024)) * (-1/2048) )
/// </code>
/// Constants read straight from the binary's .rdata: <c>0x79bac8</c> =
/// 10.0, <c>0x7aac78</c> = 1024.0, <c>0x7aac70</c> = -1/2048 (exactly
/// -0.00048828125), <c>0x7aac68</c> = 2047.0. The Y axis's FSUBR
/// (reversed subtract) is retail's north-up flip — Y increases upward
/// on the AC coordinate system but downward in screen pixels.
/// <c>w</c>/<c>h</c> are the icon's own <c>UIRegion::GetWidth</c>/
/// <c>GetHeight</c> (@0x0069efe0/@0x0069eff0), halved with INTEGER
/// (truncating) division to match retail's <c>cdq;sub;sar</c> idiom —
/// not float division, which would drift by half a pixel on
/// odd-sized icons. Golden case (marker area (6,8)-(247,258), 10x10
/// icon, position 0.0N/0.0E) reproduces exactly to (122,128) center.
/// </summary>
private void PlaceMarker(UiElement? icon, double x, double y)
{
if (icon is null) return;
(float left, float top) = ComputeMarkerPosition(
_markerX0, _markerX1, _markerY0, _markerY1,
(int)icon.Width, (int)icon.Height, x, y);
icon.Left = left;
icon.Top = top;
icon.Visible = true;
}
/// <summary>
/// The pure <c>PlaceMarkerOnMap</c> math, split out from <see cref="PlaceMarker"/>
/// so tests can assert byte-decoded GOLDEN PIXEL values directly against
/// the formula instead of round-tripping through the port's own output.
/// </summary>
internal static (float Left, float Top) ComputeMarkerPosition(
int markerX0, int markerX1, int markerY0, int markerY1,
int iconWidth, int iconHeight, double x, double y)
{
int halfWidth = iconWidth / 2;
int halfHeight = iconHeight / 2;
int extentX = markerX1 - markerX0 + 1;
int extentY = markerY1 - markerY0 + 1;
int xOffset = (int)(extentX * (x * 10.0 + 1024.0) * (-1.0 / 2048.0));
int yOffset = (int)(extentY * (2047.0 - (y * 10.0 + 1024.0)) * (-1.0 / 2048.0));
return (markerX0 - halfWidth - xOffset, markerY0 - halfHeight - yOffset);
}
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;
}
}