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; } }