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, // Builds one UiElement subtree from an ALREADY-RESOLVED ElementInfo // (production: LayoutImporter.Build under the DAT lock — the same // build half RowTemplateResolver uses, without the import half). // Used for m_pMap's two button-swallowed icon children, whose // ElementInfos only exist inside the full panel-slot resolve tree — // a cold ImportInfos(hostLayout, iconId) re-import CANNOT find them // (register row AD-108's live-DAT finding; see Bind's own doc). Func IconBuilder, // The template's resolved ElementInfo (production: the SAME cached // RowTemplateResolver the TemplateResolver above builds from — // RowTemplateResolver.ResolveInfo). BuildTownMarkers needs the // AUTHORED shape the built UiButton widget does not carry: the // button-swallowed rollover-highlight child subtree (2026-08-17 // morning gate finding 3). Null (fixtures) = markers get no // highlight child, everything else unchanged. Func? TemplateInfoResolver = null); 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. Their /// s, however, DO survive: /// is a subtree of the panel's full slot resolve /// (ImportInfos(0x2100006E, 0x1000018C)), the only pathway that /// materializes them at all — a cold /// ImportInfos(hostLayoutId, iconElementId) starting from the /// icon id returns null on the live DAT because the raw LayoutDesc /// Elements-table walk never reaches them (register row AD-108's /// live-DAT finding, 2026-08-17). So the icons are resolved by finding /// their infos under m_pMap's own already-resolved info and /// BUILDING each via , 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, mapInfo, bindings.IconBuilder, PlayerIconId); UiElement? houseIcon = ResolveSwallowedIcon(map, mapInfo, bindings.IconBuilder, 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; } /// Resolves one of m_pMap's button-swallowed nested /// icon children by finding its under /// — the panel-slot resolve tree, the ONLY /// place these infos exist (see 's own doc + register /// row AD-108) — building it via , and /// attaching it under . Starts hidden — the first /// call (from ) decides real /// visibility. private static UiElement? ResolveSwallowedIcon( UiElement map, ElementInfo? mapInfo, Func iconBuilder, uint iconElementId) { // If the normal build walk ever stops swallowing m_pMap's dat // children (a future UiButton.ConsumesDatChildren policy change), // the icon already exists in the built tree — use it rather than // building a second, permanently-static copy behind the live // marker. Retail's own PostInit is exactly this find-the-child. UiElement? existing = UiElement.FindDescendant(map, iconElementId); if (existing is not null) return PrepareIcon(existing); ElementInfo? iconInfo = mapInfo is null ? null : FindInfo(mapInfo, iconElementId); if (iconInfo is null) { Console.WriteLine( $"[D.2b] Map tab: icon 0x{iconElementId:X8} not authored under m_pMap's resolved " + "info tree — it will not be shown."); return null; } UiElement? icon = iconBuilder(iconInfo); if (icon is null) { Console.WriteLine( $"[D.2b] Map tab: icon 0x{iconElementId:X8} did not build — it will not be shown."); return null; } map.AddChild(PrepareIcon(icon)); return icon; } /// Marks one marker icon as runtime-positioned. /// owns the element's position outright (retail's gmMapUI::Update /// re-places both markers every tick) — but acdream re-runs the authored /// layout pass per frame, so the compatibility anchor capture (and any /// imported raw-edge , which the /// setter clears) would re-assert the /// authored (0,0) rect every frame, silently overwriting PlaceMarker's /// writes — the F1 live finding: a visible green ring pinned to m_pMap's /// top-left corner regardless of the player's true position. Starts /// hidden — the first decides real visibility. private static UiElement PrepareIcon(UiElement icon) { icon.Anchors = AnchorEdges.None; icon.Visible = false; 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. /// /// /// 2026-08-17 morning gate finding 3 (live-DAT re-derivation, /// MapHousePanelSlotProbeTests): the hotspot template /// (0x100001F0 in 0x21000026) is NOT "a plain 10x10 /// hotspot dot" — its DirectState authors the full retail note /// behavior: its OWN tooltip-popup locator /// P0x47=0x10000398/P0x48=0x21000041 (the map-note popup /// skin, whose incorporated text child 0x10000396 fonts /// 0x40000015 — the other three skins font 0x40000002), /// a zero per-element tooltip delay (P0x50=0.0 — town tooltips /// fire the instant the dwell arms), P0x4B TooltipOn, and /// P0x13 RolloverEnabled. Its Normal/Normal_rollover /// states are PassToChildren descriptors driving the highlight child /// 0x100001F1 (base 0x100002B7@0x21000042 — a /// four-piece frame of pure-green 0x06004CC9, byte-decoded /// A=FF R=00 G=FF B=00) via per-state P0x3B: hidden at rest, /// GREEN on rollover. The earlier hardcoded shared-skin override here /// was therefore wrong on both counts and is removed — the built /// marker's own authored locator/delay (populated by /// from the template's property bag) wins. /// /// 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; // The template's AUTHORED shape — for the button-swallowed // highlight child (UiButton.ConsumesDatChildren drops it from the // built widget, same class of loss as the AD-108 icons). ElementInfo? templateInfo = _bindings.TemplateInfoResolver?.Invoke( (uint)templateLayout.UnsignedValue, (uint)templateElement.UnsignedValue); 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). The popup SKIN + delay come // from the marker's own authored properties (see this method's // doc — finding 3 removed the shared-skin override that // previously clobbered them here). 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 authored // 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."); // AddMapNote's CreateChildElement builds the note's FULL // authored subtree; our UiButton swallows dat children, so the // rollover-highlight child (and its green-frame grandchildren) // is rebuilt per marker through the SAME IconBuilder seam the // AD-108 icons use, then re-armed by applying the button's // initial Normal state (retail UIElement_Button's own initial // UpdateState_ -> SetState(Normal) -> PassToChildren cascade -> // the child's Normal-state P0x3B=true hides it until rollover). if (templateInfo is not null && marker is UiButton stateHost) { foreach (ElementInfo childInfo in templateInfo.Children) if (_bindings.IconBuilder(childInfo) is { } highlight) marker.AddChild(highlight); stateHost.TrySetRetailState(UiButtonStateMachine.Normal); } _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; } }