using System;
using System.Collections.Generic;
using AcDream.Core.Net.Messages;
using AcDream.Core.Ui;
using AcDream.Core.World;
namespace AcDream.App.UI.Layout;
///
/// Binds the Map tab of the retail Map/House panel (gmMapUI, class id
/// 0x10000026) — 's default page.
///
///
/// Retail references: gmMapUI::PostInit @0x004a1c70 (child
/// resolution + hotspot template setup), gmMapUI::Update @0x004a1eb0
/// (5 s refresh cadence — date/time text, coordinate readout, both
/// markers), gmMapUI::PlaceMarkerOnMap @0x004a18b0 (marker centering
/// math), gmMapUI::AddMapNote @0x004a1bb0 (town hotspot
/// instantiation + literal-string tooltip). Live-DAT byte values confirmed
/// by MapHousePanelSlotProbeTests: marker area
/// (6,8)-(247,258), hotspot template element 0x100001F0 in
/// LayoutDesc 0x21000026.
///
///
///
/// Coordinate math reuses (already a byte-
/// exact port of the same CPlayerSystem::InqPlayerCoords @0x00560090
/// formula the radar's own coordinate strip uses) rather than re-deriving
/// it — see the recon doc's "no re-port needed" note.
///
///
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;
/// Retail's own 5 s tick cadence (gmMapUI::Update's
/// m_nextUpdate = Timer::cur_time + 5.0).
public const double RefreshIntervalSeconds = 5.0;
public sealed record Bindings(
Func CurrentCalendar,
Func 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 HousePosition,
Func 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;
}
///
/// Binds an already-built page root (
/// resolves the page via the panel's tab table). Reads m_pMap's
/// own marker-area rect straight from the ORIGINAL
/// (post-Build widgets don't carry authored int attrs), instantiates the
/// 53 town hotspots once, and returns a controller ready for
/// per-frame polling.
///
///
/// Live-DAT structural finding (MapHousePanelSlotProbeTests' follow-up
/// dump): m_pMap (0x100001EC) is itself authored as a
/// Type-1 BUTTON (the GM click-to-teleport feature at
/// gmMapUI::ListenToElementMessage @0x004a2350 idMessage
/// 0x1c), and the player/house icons (0x100001ED/
/// 0x100001EE) are authored as ITS OWN nested children, not
/// siblings. swallows a
/// button's dat children as skin/label parts, so they never appear in
/// the normally-built tree —
/// against the page root always returns null for them. They're
/// resolved the SAME way the town hotspot template is: re-imported
/// standalone via 's
/// against the panel's own host
/// LayoutDesc, then attached under m_pMap directly — their
/// authored local position is irrelevant since
/// overwrites it every refresh.
///
///
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;
}
/// Re-resolves one of m_pMap's button-swallowed nested
/// icon children standalone (see 's own doc) and
/// attaches it under . Starts hidden — the first
/// call (from ) decides real
/// visibility.
private static UiElement? ResolveSwallowedIcon(
UiElement map, Func 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 ToLines(string? text, System.Numerics.Vector4 color)
{
if (string.IsNullOrEmpty(text)) return Array.Empty();
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;
}
///
/// Instantiates the 53 static town hotspots (gmMapUI::AddMapNote)
/// from m_pMap's own 0x47/0x48 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 if (eax_10 != 0) before the loop.
///
private void BuildTownMarkers(ElementInfo? mapInfo, Func 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);
}
}
/// Per-frame poll, accumulating wall-clock deltas
/// ('s own shape) into retail's 5 s
/// cadence — same net effect as Timer::cur_time comparison
/// without needing a separate absolute clock dependency.
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;
}
///
/// "Date: %s\nTime: %s" (gmMapUI::Update's sprintf shape,
/// fed by GameTime::GetDateTimeString @0x005a6530). Month names
/// already match retail display text 1:1
/// (); hour names need the
/// "AndHalf" suffix rewritten to "-and-Half".
///
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;
}
///
/// gmMapUI::Update @0x004a2078's gate is
/// if (m_pCoordinateText != 0 && m_pPlayerLocationIcon != 0)
/// — BOTH widgets present, not "at least one". Night-round review F15:
/// the prior _coordinateText is null && _playerIcon is null
/// 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.
///
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;
}
}
///
/// gmMapUI::Update @0x004a22a6-f6: Position::get_outside_cell_id
/// (&m_HousePosition) -> LandDefs::gid_to_lcoord -> the SAME
/// (v-0x400)*0.1+0.5 transform 's player
/// branch uses.
///
///
/// Night-round review F14: this passes housePosition.Value.LandblockId
/// straight to , SKIPPING the
/// Position::get_outside_cell_id @0x004527b0 step retail's own
/// call chain names. That function is itself BN-mangled (its final
/// return ((eax_2 - eax_2) & objcell_id) — 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 LandDefs::adjust_to_outside,
/// 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): is wired
/// () => null 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 Position::get_outside_cell_id /
/// LandDefs::adjust_to_outside (byte-decode required,
/// @0x004527b0 / call site @0x004a2297) 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 adjust_to_outside 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 correctly refuses
/// any cell with an envcell low word).
///
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);
}
///
/// gmMapUI::PlaceMarkerOnMap @0x004a18b0, ported from a direct
/// byte-read of the PDB-paired acclient.exe (Binary Ninja elides
/// the whole FPU chain to bare, operand-less _ftol2() calls —
/// see docs/research/named-retail/acclient_2013_pseudo_c.txt
/// 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 (/, range
/// approximately ±102.4) onto the marker-area rect's pixel span via a
/// fixed-point-style transform, not a raw pixel add:
///
/// 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) )
///
/// Constants read straight from the binary's .rdata: 0x79bac8 =
/// 10.0, 0x7aac78 = 1024.0, 0x7aac70 = -1/2048 (exactly
/// -0.00048828125), 0x7aac68 = 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.
/// w/h are the icon's own UIRegion::GetWidth/
/// GetHeight (@0x0069efe0/@0x0069eff0), halved with INTEGER
/// (truncating) division to match retail's cdq;sub;sar 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.
///
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;
}
///
/// The pure PlaceMarkerOnMap math, split out from
/// so tests can assert byte-decoded GOLDEN PIXEL values directly against
/// the formula instead of round-tripping through the port's own output.
///
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;
}
}