User finding 3 (retail screenshot): hovering a town on the Map tab turns its marker GREEN and shows the name on a special-font tooltip — clearly not our generic 0x10000395 popup skin, and we had no hover highlight at all. Re-derivation (live-DAT probe + raw ElementDesc dump + surface byte-decode; MapNoteLiveDatTests pins all of it): - m_pMap (0x100001EC)'s P0x47/P0x48 = 0x100001F0 @ 0x21000026 are the note CONSTRUCTION template (AddMapNote @0x004a1bb0's CreateChildElement args) — that part we had right. - The TEMPLATE's own DirectState authors the note's tooltip popup locator P0x47=0x10000398/P0x48=0x21000041 — the FOURTH popup skin, whose incorporated text child 0x10000396 fonts 0x40000015 where the other three skins font 0x40000002 (the user's "special font") — plus P0x50=0.0 (zero per-element tooltip delay: town tooltips fire the instant the dwell arms; UiRoot already honors it), P0x4B TooltipOn, and P0x13 RolloverEnabled. Batch C's "the template authors no locator of its own" claim was WRONG, and BuildTownMarkers' hardcoded shared-skin override was clobbering the authored values — removed. - The hover highlight: the template's Normal/Normal_rollover states are PassToChildren descriptors driving the swallowed highlight child 0x100001F1 (base 0x100002B7@0x21000042 — a four-piece frame all drawing 0x06004CC9, byte-decoded PURE GREEN A=FF R=00 G=FF B=00) via per-state P0x3B (Invisible): hidden at rest, green on rollover. Port: - UiButton.CascadeStateToChildren — retail UIElement::SetState @0x00464E70's PassToChildren cascade, keyed off the REQUESTED state id (properties commit unconditionally; only the sprite draw is art-gated, the existing #382/AP-222 distinction). - UiDatElement.TrySetRetailState honors per-state P0x3B for NAMED states (OnSetAttribute @0x00462d80 case 8: SetVisible(value==0)). The unnamed-DirectState case is explicitly excluded — honoring it would un-gate ISSUES #408 (1,083 authored-invisible elements) through BuildWidget's post-children state reapply; measured breaking the spell-favorite drag tests before the scoping (note added to #408). - MapPageController.BuildTownMarkers rebuilds the button-swallowed highlight child per marker through the AD-108 IconBuilder seam (Bindings.TemplateInfoResolver, backed by RowTemplateResolver.ResolveInfo — same cache) and arms it with the initial Normal cascade. Register TS-85's Batch C paragraph corrected; RetailTooltipPresenter's F10 shared-skin remark updated (MapPageController no longer a consumer). Tests: 3 installed-DAT pins (locator/delay/rollover; per-state P0x3B + green frame; the four-skin font sweep), UiButton cascade + UiDatElement P0x3B units, MapHousePanel marker no-clobber + hover-highlight fixture. App suite 5487 passed / 3 skips (5490 total, +11 over baseline); Runtime 1744/1744. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
569 lines
28 KiB
C#
569 lines
28 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,
|
|
// 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<ElementInfo, UiElement?> 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<uint, uint, ElementInfo?>? 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;
|
|
}
|
|
|
|
/// <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. Their
|
|
/// <see cref="ElementInfo"/>s, however, DO survive: <paramref name="pageInfo"/>
|
|
/// is a subtree of the panel's full slot resolve
|
|
/// (<c>ImportInfos(0x2100006E, 0x1000018C)</c>), the only pathway that
|
|
/// materializes them at all — a cold
|
|
/// <c>ImportInfos(hostLayoutId, iconElementId)</c> starting from the
|
|
/// icon id returns null on the live DAT because the raw LayoutDesc
|
|
/// <c>Elements</c>-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 <c>m_pMap</c>'s own already-resolved info and
|
|
/// BUILDING each via <see cref="Bindings.IconBuilder"/>, 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, 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;
|
|
}
|
|
|
|
/// <summary>Resolves one of <c>m_pMap</c>'s button-swallowed nested
|
|
/// icon children by finding its <see cref="ElementInfo"/> under
|
|
/// <paramref name="mapInfo"/> — the panel-slot resolve tree, the ONLY
|
|
/// place these infos exist (see <see cref="Bind"/>'s own doc + register
|
|
/// row AD-108) — building it via <paramref name="iconBuilder"/>, and
|
|
/// attaching 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, ElementInfo? mapInfo, Func<ElementInfo, UiElement?> 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;
|
|
}
|
|
|
|
/// <summary>Marks one marker icon as runtime-positioned. <see cref="PlaceMarker"/>
|
|
/// owns the element's position outright (retail's <c>gmMapUI::Update</c>
|
|
/// 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 <see cref="UiElement.LayoutPolicy"/>, which the
|
|
/// <see cref="UiElement.Anchors"/> 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 <see cref="Refresh"/> decides real visibility.</summary>
|
|
private static UiElement PrepareIcon(UiElement icon)
|
|
{
|
|
icon.Anchors = AnchorEdges.None;
|
|
icon.Visible = false;
|
|
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.
|
|
///
|
|
/// <para>
|
|
/// <b>2026-08-17 morning gate finding 3 (live-DAT re-derivation,
|
|
/// <c>MapHousePanelSlotProbeTests</c>):</b> the hotspot template
|
|
/// (<c>0x100001F0</c> in <c>0x21000026</c>) is NOT "a plain 10x10
|
|
/// hotspot dot" — its DirectState authors the full retail note
|
|
/// behavior: its OWN tooltip-popup locator
|
|
/// <c>P0x47=0x10000398</c>/<c>P0x48=0x21000041</c> (the map-note popup
|
|
/// skin, whose incorporated text child <c>0x10000396</c> fonts
|
|
/// <c>0x40000015</c> — the other three skins font <c>0x40000002</c>),
|
|
/// a zero per-element tooltip delay (<c>P0x50=0.0</c> — town tooltips
|
|
/// fire the instant the dwell arms), <c>P0x4B</c> TooltipOn, and
|
|
/// <c>P0x13</c> RolloverEnabled. Its <c>Normal</c>/<c>Normal_rollover</c>
|
|
/// states are PassToChildren descriptors driving the highlight child
|
|
/// <c>0x100001F1</c> (base <c>0x100002B7</c>@<c>0x21000042</c> — a
|
|
/// four-piece frame of pure-green <c>0x06004CC9</c>, byte-decoded
|
|
/// A=FF R=00 G=FF B=00) via per-state <c>P0x3B</c>: 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
|
|
/// <see cref="LayoutImporter"/> from the template's property bag) wins.
|
|
/// </para>
|
|
/// </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;
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
/// <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 && m_pPlayerLocationIcon != 0)</c>
|
|
/// — BOTH widgets present, not "at least one". Night-round review F15:
|
|
/// the prior <c>_coordinateText is null && _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
|
|
/// (&m_HousePosition) -> LandDefs::gid_to_lcoord</c> -> 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) & 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>() => 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;
|
|
}
|
|
}
|