feat(ui): Map/House panel — slices 2+3, panel shell + Map tab
Mounts host 0x2100006E slot 0x1000018C (RetailPanelCatalog.MapHouse = 16) as a two-tab UiTabPanel (Map default, House second) through the OP3/FA3 recipe (LayoutImporter.Build -> Bind -> ActivateTabBehavior). Toolbar button 0x1000019A un-ghosts (added to both RetailPanelCatalog.Mounted and .Toolbar). Combined into one commit because MapHousePanelController.Bind depends on both MapPageController and HousePageController existing — splitting them would mean landing dead code first. Map tab (gmMapUI, MapPageController): - Calendar formatter matching gmMapUI::Update's "Date: %s\nTime: %s" shape, reusing WorldTimeService.CurrentCalendar (new Func<DerethDateTime.Calendar> dependency threaded through InteractionRetainedUiDependencies/GameWindow — a stable long-lived service, not routed through the deferred-binding machinery Radar's per-session state needs). MonthName enum values already match retail display text; HourName's "AndHalf" suffix is rewritten to "-and-Half". - Coordinate math + marker placement reuse RadarCoordinates/ LandDefs.GidToLcoord verbatim (both already byte-exact ports of CPlayerSystem::InqPlayerCoords/LandDefs::gid_to_lcoord) — no re-port. PlaceMarkerOnMap's centering math (m_x0 + x - w/2) ported from gmMapUI::PlaceMarkerOnMap @0x004a18b0. Indoor gating clears the coordinate text and hides the player marker, matching gmMapUI::Update's else branch. - 53-town s_rgLocations table ported verbatim into MapLocations.cs. Markers built once at bind time via the panel's own RowTemplateResolver against m_pMap's authored hotspot-template attrs (0x47/0x48), with literal-string tooltips through AuthoredTooltipText/Enabled (RetailTooltipPresenter) — closes divergence-register row TS-85's last item, gmMapUI::AddMapNote @0x004A1C51. - Structural finding: m_pMap (0x100001EC) is itself authored as a Type-1 BUTTON (the GM click-to-teleport hook at gmMapUI::ListenToElementMessage), and the player/house icons (0x100001ED/EE) are its own NESTED children, not siblings — UiButton.ConsumesDatChildren swallows them from the normally-built tree. Both are re-resolved standalone through the same template resolver the town hotspots use and reattached under m_pMap. House tab (gmHouseUI, HousePageController): mounts the ListBox (0x100001E6) with its authored row template, wired to an empty Lines() source by default — genuinely empty until Slice 4's wire lands, matching retail's own PostInit (no Update call, no static content). 21 new tests (7 MapHousePanelControllerTests, 14 MapPageControllerTests): tab table pairing, close button, town-hotspot count/tooltips, calendar formatter golden values (Frostfell 27/119 P.Y., every HourName incl. AndHalf), player/house marker placement and indoor-gating reproduced against the real fixture via already-tested RadarCoordinates (no re-derivation). Fixture map_house_2100006E_1000018C.json captured via the shared RetailLayoutFixtureGenerator (other 34 fixtures deliberately NOT regenerated — out of scope for this batch, would touch unrelated panels' schema drift). Full solution builds clean; App suite 5391/0 failed/71 skipped (non-live; one earlier flaky streaming failure unrelated to this change, confirmed pre-existing on the branch before these commits). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
04841b6807
commit
6b0fa4ff0d
15 changed files with 5865 additions and 3 deletions
|
|
@ -79,7 +79,15 @@ internal sealed record InteractionRetainedUiDependencies(
|
|||
Func<double> ClientTime,
|
||||
Action<string> Log,
|
||||
AcDream.App.Rendering.Gpu.IGpuDevice GpuDevice,
|
||||
ICurrentGpuFrameSource GpuFrameSource)
|
||||
ICurrentGpuFrameSource GpuFrameSource,
|
||||
// Batch C (Map/House toolbar panel): the same shape as ClientTime above —
|
||||
// GameWindow's WorldTimeService is a stable for-the-window-lifetime
|
||||
// service (unlike the per-session entity/world state Radar's deferred
|
||||
// slots exist for), so a direct closure is enough; no DeferredXSource
|
||||
// needed. gmMapUI::Update @0x004a1eb0 reads GameTime::current_game_time
|
||||
// every 5s — MapPageController owns that cadence, this just supplies the
|
||||
// current reading.
|
||||
Func<AcDream.Core.World.DerethDateTime.Calendar> CurrentCalendar)
|
||||
{
|
||||
public RuntimeActionState Actions => Runtime.ActionOwner;
|
||||
|
||||
|
|
@ -970,6 +978,14 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
AllegianceSetUpdateSubscription: on =>
|
||||
late.GameRuntime.AllegianceSetUpdateSubscription(on),
|
||||
Trade: d.Runtime.Trade),
|
||||
// Batch C (overnight hover/UI round): HousePosition/
|
||||
// HouseLines/HouseShown are left unwired (their bindings
|
||||
// default to "no house"/empty/no-op) — the House wire
|
||||
// groundwork (RuntimeHouseState) lands separately; the
|
||||
// panel mounts and the Map tab works standalone either way.
|
||||
MapHouse: new MapHouseRuntimeBindings(
|
||||
CurrentCalendar: d.CurrentCalendar,
|
||||
PlayerCellId: () => d.PlayerController.Controller?.CellId ?? 0u),
|
||||
StackSplitQuantity: d.StackSplitQuantity,
|
||||
Plugins: d.UiRegistry,
|
||||
Persistence: persistence,
|
||||
|
|
|
|||
|
|
@ -1419,7 +1419,8 @@ public sealed class GameWindow :
|
|||
ClientTimerNow,
|
||||
Console.WriteLine,
|
||||
hostInputCamera.GpuDevice,
|
||||
hostInputCamera.GpuFrameLifetime),
|
||||
hostInputCamera.GpuFrameLifetime,
|
||||
() => WorldTime.CurrentCalendar),
|
||||
_retailUiLease,
|
||||
this).Compose(
|
||||
platformResult,
|
||||
|
|
|
|||
102
src/AcDream.App/UI/Layout/HousePageController.cs
Normal file
102
src/AcDream.App/UI/Layout/HousePageController.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the House tab of the retail Map/House panel (<c>gmHouseUI</c>,
|
||||
/// class id <c>0x10000025</c>) — <see cref="MapHousePanelController"/>'s
|
||||
/// second page.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail references: <c>gmHouseUI::PostInit @0x004a2710</c> resolves ONE
|
||||
/// <c>UIElement_ListBox</c> (<c>m_pTextBox = 0x100001e6</c>) and registers
|
||||
/// four notice handlers for wire opcodes <c>0x0225-0x0228</c>.
|
||||
/// <c>gmHouseUI::AddHousePanelText @0x004a2810</c> is
|
||||
/// <c>UIElement_ListBox::AddItemFromTemplateList(this, 0, nullptr)</c> —
|
||||
/// live-DAT-confirmed (<c>MapHousePanelSlotProbeTests</c>) as a single
|
||||
/// <c>UIElement_Text</c> (Type 12) row template at LayoutDesc
|
||||
/// <c>0x21000025</c> element <c>0x100001e7</c>, no scrollbar authored. The
|
||||
/// ListBox itself authors ZERO static child rows — the box starts genuinely
|
||||
/// empty until the first server notice populates it (retail's own
|
||||
/// <c>PostInit</c> never calls <c>Update</c>/<c>DisplayHouseData</c>).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Scope (Batch C, 2026-08-17 recon doc).</b> Of the seven
|
||||
/// <c>Display*</c> line builders <c>DisplayHouseData</c> calls, only
|
||||
/// <c>DisplayPurchaseTimeText @0x004a3110</c>'s two simple, fully-recovered
|
||||
/// literal strings ("You may buy another house immediately." / "...after
|
||||
/// you abandon this one.") are wired end-to-end this session — see
|
||||
/// <c>RuntimeHouseState.PurchaseAvailabilityText</c>. The other six
|
||||
/// (BuyPayment/RentPayment/BuyTime/RentTimes/Location/WarningText) only
|
||||
/// matter once a house is actually owned and are filed as an ISSUES entry
|
||||
/// rather than guessed at from FPU-mangled decomp.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class HousePageController
|
||||
{
|
||||
public const uint TextBoxId = 0x100001E6u;
|
||||
|
||||
public sealed record Bindings(
|
||||
Func<IReadOnlyList<string>> Lines,
|
||||
// Fires once when the page transitions to visible — the seam that
|
||||
// sends the outbound HouseQuery (0x021E) so the server has a
|
||||
// reason to answer with fresh HouseData/HouseStatus. NOT a ported
|
||||
// retail call site (PostInit never triggers a query) — an acdream
|
||||
// convention, documented as such (recon doc open item).
|
||||
Action? OnShown = null);
|
||||
|
||||
private readonly UiTemplateListBox _listBox;
|
||||
private readonly Bindings _bindings;
|
||||
private IReadOnlyList<string> _lastLines = Array.Empty<string>();
|
||||
|
||||
private HousePageController(UiTemplateListBox listBox, Bindings bindings)
|
||||
{
|
||||
_listBox = listBox;
|
||||
_bindings = bindings;
|
||||
}
|
||||
|
||||
public static HousePageController? Bind(UiElement page, Bindings bindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(page);
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
|
||||
if (UiElement.FindDescendant(page, TextBoxId) is not UiTemplateListBox listBox)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] House tab: ListBox 0x{TextBoxId:X8} not found or not a template list box.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var controller = new HousePageController(listBox, bindings);
|
||||
controller.Refresh(bindings.Lines());
|
||||
return controller;
|
||||
}
|
||||
|
||||
/// <summary>Per-frame poll — cheap no-op when the line set hasn't
|
||||
/// changed (reference-content compare via SequenceEqual, mirroring the
|
||||
/// other social-panel pages' revision-gated rebuild discipline).</summary>
|
||||
public void Tick()
|
||||
{
|
||||
IReadOnlyList<string> lines = _bindings.Lines();
|
||||
if (lines.SequenceEqual(_lastLines)) return;
|
||||
Refresh(lines);
|
||||
}
|
||||
|
||||
public void OnShown() => _bindings.OnShown?.Invoke();
|
||||
|
||||
private void Refresh(IReadOnlyList<string> lines)
|
||||
{
|
||||
_lastLines = lines;
|
||||
_listBox.Flush();
|
||||
foreach (string line in lines)
|
||||
{
|
||||
UiElement? row = _listBox.AddItemFromTemplateList(0);
|
||||
if (row is UiText text)
|
||||
text.LinesProvider = () => [new UiText.Line(line, Vector4.One)];
|
||||
}
|
||||
}
|
||||
}
|
||||
180
src/AcDream.App/UI/Layout/MapHousePanelController.cs
Normal file
180
src/AcDream.App/UI/Layout/MapHousePanelController.cs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Mounts retail's two-tab Map/House panel — LayoutDesc <c>0x2100006E</c>
|
||||
/// slot <c>0x1000018C</c>, <see cref="AcDream.App.UI.RetailPanelCatalog"/>
|
||||
/// id <b>16</b>. Batch C (overnight hover/UI round, 2026-08-17), built on
|
||||
/// the <see cref="SocialPanelController"/>/<see cref="OptionsPanelController"/>
|
||||
/// recipe (Type-8 tab host, <see cref="UiTabPanel.ActivateTabBehavior"/>,
|
||||
/// per-page scoped controllers).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Slot/panelId/button — resolved, not guessed.</b> The FA campaign's own
|
||||
/// full 16-slot <c>gmPanelUI::SetupChildren</c> dump
|
||||
/// (<c>docs/research/2026-08-11-fa-panel-structure.md:927-933</c>) already
|
||||
/// named slot <c>0x1000018C</c> as "gmMapUI+gmHouseUI pages", panel id 16 —
|
||||
/// this session's <c>MapHousePanelSlotProbeTests</c> re-confirmed it live.
|
||||
/// The toolbar button is <c>0x1000019A</c> (own authored
|
||||
/// <c>P0x10000029 = 16</c>), one of three currently-ghosted panel buttons
|
||||
/// (see <c>docs/research/2026-08-17-map-house-recon.md</c>).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Tab table — live-DAT-confirmed.</b>
|
||||
/// <c>button 0x100001F3 -> page 0x100001F6 (Map, DEFAULT)</c>,
|
||||
/// <c>button 0x100001F4 -> page 0x100001F7 (House)</c>. The page roots are
|
||||
/// themselves typed <c>0x10000026</c> (gmMapUI) / <c>0x10000025</c>
|
||||
/// (gmHouseUI) in the DAT — <see cref="DatWidgetFactory"/> has no special
|
||||
/// case for either id, so they build as generic containers (same as every
|
||||
/// other unmodeled retail UI class); <see cref="MapPageController"/> and
|
||||
/// <see cref="HousePageController"/> find their own signature children by
|
||||
/// id underneath.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Close button</b>: <c>0x100001F5</c> (Type 1, top-right at
|
||||
/// <c>(276,0) 24x25</c>) — same authored position/size as the Social
|
||||
/// panel's own close button, the established <c>gmPanelUI</c> sibling
|
||||
/// convention.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class MapHousePanelController : IRetainedPanelController
|
||||
{
|
||||
public const uint HostLayoutId = 0x2100006Eu;
|
||||
public const uint SlotElementId = 0x1000018Cu;
|
||||
|
||||
private const uint MapButtonId = 0x100001F3u;
|
||||
private const uint MapPageId = 0x100001F6u;
|
||||
private const uint HouseButtonId = 0x100001F4u;
|
||||
private const uint HousePageId = 0x100001F7u;
|
||||
private const uint CloseButtonId = 0x100001F5u;
|
||||
|
||||
public sealed record Callbacks(
|
||||
Action Toggle,
|
||||
MapPageController.Bindings Map,
|
||||
HousePageController.Bindings House);
|
||||
|
||||
private readonly UiTabPanel _tabPanel;
|
||||
private readonly MapPageController? _map;
|
||||
private readonly HousePageController? _house;
|
||||
private readonly Action<uint, uint> _onActivePageChanged;
|
||||
private bool _visible;
|
||||
private bool _disposed;
|
||||
|
||||
public UiElement Root => _tabPanel;
|
||||
public UiTabPanel TabPanel => _tabPanel;
|
||||
|
||||
private MapHousePanelController(
|
||||
UiTabPanel tabPanel, MapPageController? map, HousePageController? house)
|
||||
{
|
||||
_tabPanel = tabPanel;
|
||||
_map = map;
|
||||
_house = house;
|
||||
|
||||
// House's outbound query is a fire-when-shown convenience (see
|
||||
// HousePageController.Bindings.OnShown's own doc — not a ported
|
||||
// retail trigger, an acdream one), gated the same "window shown AND
|
||||
// my tab active" conjunction the social panel's Fellowship/
|
||||
// Allegiance pages use for their own declarations.
|
||||
_onActivePageChanged = (_, _) => FireHouseShownIfActive();
|
||||
_tabPanel.ActivePageChanged += _onActivePageChanged;
|
||||
}
|
||||
|
||||
/// <param name="rootInfo">The pre-Build <see cref="ElementInfo"/> tree
|
||||
/// <paramref name="layout"/> was built from — <see cref="MapPageController"/>
|
||||
/// needs it to read <c>m_pMap</c>'s own authored int/enum attrs, which
|
||||
/// only exist on <see cref="ElementInfo"/>, not the built
|
||||
/// <see cref="UiElement"/> tree.</param>
|
||||
public static MapHousePanelController? Bind(
|
||||
ElementInfo rootInfo, ImportedLayout layout, Callbacks callbacks)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rootInfo);
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(callbacks);
|
||||
|
||||
if (layout.Root is not UiTabPanel tabPanel)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[D.2b] MapHousePanelController.Bind: root did not build as UiTabPanel "
|
||||
+ $"(actual type {layout.Root.GetType().Name}) — Map/House panel will not open.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (layout.FindElement(CloseButtonId) is UiButton close)
|
||||
close.OnClick = callbacks.Toggle;
|
||||
else
|
||||
Console.WriteLine(
|
||||
$"[D.2b] MapHousePanelController: close button 0x{CloseButtonId:X8} not found.");
|
||||
|
||||
UiElement? mapPage = UiElement.FindDescendant(tabPanel, MapPageId);
|
||||
UiElement? housePage = UiElement.FindDescendant(tabPanel, HousePageId);
|
||||
|
||||
MapPageController? map = null;
|
||||
if (mapPage is not null)
|
||||
{
|
||||
ElementInfo? mapPageInfo = FindInfo(rootInfo, MapPageId);
|
||||
map = mapPageInfo is null
|
||||
? null
|
||||
: MapPageController.Bind(mapPage, mapPageInfo, callbacks.Map);
|
||||
}
|
||||
HousePageController? house = housePage is null
|
||||
? null
|
||||
: HousePageController.Bind(housePage, callbacks.House);
|
||||
|
||||
if (mapPage is null)
|
||||
Console.WriteLine($"[D.2b] MapHousePanelController: Map page 0x{MapPageId:X8} not found.");
|
||||
if (housePage is null)
|
||||
Console.WriteLine($"[D.2b] MapHousePanelController: House page 0x{HousePageId:X8} not found.");
|
||||
|
||||
return new MapHousePanelController(tabPanel, map, house);
|
||||
}
|
||||
|
||||
public void ActivateTabs() => _tabPanel.ActivateTabBehavior();
|
||||
|
||||
public bool IsShowingHouse => _tabPanel.ActivePageElementId == HousePageId;
|
||||
|
||||
public void OnShown()
|
||||
{
|
||||
_visible = true;
|
||||
FireHouseShownIfActive();
|
||||
}
|
||||
|
||||
public void OnHidden() => _visible = false;
|
||||
|
||||
private void FireHouseShownIfActive()
|
||||
{
|
||||
if (_visible && IsShowingHouse)
|
||||
_house?.OnShown();
|
||||
}
|
||||
|
||||
/// <summary>Per-frame poll: Map's 5 s-gated refresh (cheap when not due)
|
||||
/// and House's revision-gated row rebuild. Both stay unconditional
|
||||
/// (unlike Friends/Squelch's DAT-locked rebuild) — Map's own cadence
|
||||
/// gate and House's list compare are both cheap even while hidden.</summary>
|
||||
public void Tick(double deltaSeconds)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_map?.Tick(deltaSeconds);
|
||||
_house?.Tick();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_tabPanel.ActivePageChanged -= _onActivePageChanged;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
79
src/AcDream.App/UI/Layout/MapLocations.cs
Normal file
79
src/AcDream.App/UI/Layout/MapLocations.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// One retail map hotspot rect + rollover name. Mirrors
|
||||
/// <c>gmMapUI::LocationRolloverInfo</c> (<c>acclient.h:55686</c>): X/Y/Width/
|
||||
/// Height are direct pixel offsets within <c>m_pMap</c> (no coordinate
|
||||
/// transform — <c>gmMapUI::AddMapNote @0x004a1bb0</c> passes them straight to
|
||||
/// <c>MoveTo</c>/<c>ResizeTo</c>), and Name is the literal tooltip text
|
||||
/// (<c>StringInfo::SetLiteralValue</c>, not a DAT string-table lookup).
|
||||
/// </summary>
|
||||
public readonly record struct MapLocation(int X, int Y, int Width, int Height, string Name);
|
||||
|
||||
/// <summary>
|
||||
/// Verbatim port of retail's static <c>s_rgLocations[0x35]</c> table
|
||||
/// (<c>docs/research/named-retail/acclient_2013_pseudo_c.txt:977225-977651</c>),
|
||||
/// consumed by <c>gmMapUI::PostInit @0x004a1c70</c> to place the 53 town
|
||||
/// hotspots on the Map tab. Ported verbatim, in original index order —
|
||||
/// do not resort or "clean up"; the order is not semantically meaningful
|
||||
/// but the values must match byte-for-byte.
|
||||
/// </summary>
|
||||
public static class MapLocations
|
||||
{
|
||||
public static readonly MapLocation[] All =
|
||||
{
|
||||
new(0xb2, 0x14, 0xb, 0xc, "Aerlinthe Island"),
|
||||
new(0x12, 0x4a, 0x5, 0x5, "Ahurenga"),
|
||||
new(0x8d, 0xa6, 0x7, 0x6, "Al-Arqas"),
|
||||
new(0x81, 0x79, 0x7, 0x6, "Al-Jalima"),
|
||||
new(0xbe, 0x58, 0x9, 0x8, "Arwic"),
|
||||
new(0x13, 0xc9, 0x7, 0x6, "Ayan Baqur"),
|
||||
new(0xc8, 0xbe, 0x7, 0x6, "Baishi"),
|
||||
new(0xb8, 0x35, 0x5, 0x5, "Bandit Castle"),
|
||||
new(0x22, 0x54, 0x5, 0x5, "Bluespire"),
|
||||
new(0x2c, 0xeb, 0x5, 0x5, "Candeth Keep"),
|
||||
new(0xb4, 0x61, 0x9, 0x8, "Cragstone"),
|
||||
new(0x5b, 0x66, 0x5, 0x5, "Danby's Outpost"),
|
||||
new(0xd3, 0x8a, 0x9, 0x8, "Dryreach"),
|
||||
new(0xc7, 0x6a, 0x9, 0x8, "Eastham"),
|
||||
new(0x38, 0xd, 0x5, 0x5, "Fiun Outpost"),
|
||||
new(0x25, 0x7f, 0x9, 0x8, "Fort Tethana"),
|
||||
new(0x9c, 0x5e, 0x9, 0x8, "Glenden Wood"),
|
||||
new(0x2b, 0x4f, 0x5, 0x5, "Greenspire"),
|
||||
new(0xe0, 0xb1, 0x7, 0x6, "Hebian-to"),
|
||||
new(0xa4, 0x4d, 0x9, 0x8, "Holtburg"),
|
||||
new(0xb6, 0xe6, 0x7, 0x6, "Kara"),
|
||||
new(0x9b, 0xb9, 0x7, 0x6, "Khayyaban"),
|
||||
new(0xe0, 0xda, 0x7, 0x6, "Kryst"),
|
||||
new(0xd4, 0xc3, 0x7, 0x6, "Lin"),
|
||||
new(0x9f, 0xe0, 0x5, 0x5, "Linvak Tukal"),
|
||||
new(0xb9, 0x7e, 0x9, 0x8, "Lytelthorpe"),
|
||||
new(0xeb, 0xdc, 0x7, 0x6, "MacNiall's Freehold"),
|
||||
new(0xdf, 0xcb, 0x7, 0x6, "Mayoi"),
|
||||
new(0x8d, 0x35, 0x5, 0x5, "Mt Esper-Crater Village"),
|
||||
new(0xe0, 0xbf, 0x7, 0x6, "Nanto"),
|
||||
new(0x8e, 0x2e, 0x5, 0x5, "Neydisa"),
|
||||
new(0xf0, 0x80, 0x5, 0x5, "Oolutanga's Refuge"),
|
||||
new(0x4a, 0x4f, 0x5, 0x5, "Plateau Village"),
|
||||
new(0x94, 0xda, 0x7, 0x6, "Qalaba'r"),
|
||||
new(0x1a, 0x53, 0x5, 0x5, "Redspire"),
|
||||
new(0xc1, 0x72, 0x9, 0x8, "Rithwic"),
|
||||
new(0x92, 0x85, 0x7, 0x6, "Samsur"),
|
||||
new(0x32, 0x2a, 0x5, 0x5, "Sanamar"),
|
||||
new(0xc3, 0xa3, 0x7, 0x6, "Sawato"),
|
||||
new(0xd5, 0xab, 0x7, 0x6, "Shoushi"),
|
||||
new(0x29, 0x19, 0x5, 0x5, "Silyun"),
|
||||
new(0x6, 0xef, 0xf, 0x10, "Singularity Caul Island"),
|
||||
new(0x64, 0x30, 0x5, 0x5, "Stonehold"),
|
||||
new(0x20, 0x4c, 0x5, 0x5, "Timaru"),
|
||||
new(0xef, 0xa3, 0x7, 0x6, "Tou-Tou"),
|
||||
new(0x83, 0x94, 0x7, 0x6, "Tufa"),
|
||||
new(0x70, 0xf4, 0x5, 0x5, "Ulgrim's Island"),
|
||||
new(0x9f, 0xa0, 0x7, 0x6, "Uziz"),
|
||||
new(0x3f, 0xcb, 0x7, 0x6, "Wai Jhou"),
|
||||
new(0x90, 0xb5, 0x7, 0x6, "Xarabydun"),
|
||||
new(0xaf, 0x91, 0x7, 0x6, "Yanshi"),
|
||||
new(0x79, 0x9c, 0x7, 0x6, "Yaraq"),
|
||||
new(0x7b, 0x70, 0x7, 0x6, "Zaikhal"),
|
||||
};
|
||||
}
|
||||
355
src/AcDream.App/UI/Layout/MapPageController.cs
Normal file
355
src/AcDream.App/UI/Layout/MapPageController.cs
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
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.
|
||||
// AuthoredTooltipText/Enabled is the exact seam
|
||||
// RetailTooltipPresenter already serves (closes register row
|
||||
// TS-85's last item, gmMapUI::AddMapNote @0x004A1C51).
|
||||
marker.AuthoredTooltipText = loc.Name;
|
||||
marker.AuthoredTooltipEnabled = true;
|
||||
_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;
|
||||
}
|
||||
|
||||
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;
|
||||
if (_playerIcon is not null)
|
||||
_playerIcon.Visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshHouseMarker()
|
||||
{
|
||||
if (_houseIcon is null) return;
|
||||
|
||||
CreateObject.ServerPosition? housePosition = _bindings.HousePosition();
|
||||
if (housePosition is null)
|
||||
{
|
||||
_houseIcon.Visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Position::get_outside_cell_id(&m_HousePosition) -> gid_to_lcoord
|
||||
// -> the SAME (v-0x400)*0.1+0.5 transform PlaceMarkerOnMap's player
|
||||
// branch uses (gmMapUI::Update @0x004a22a6-f6).
|
||||
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>: center the icon at
|
||||
/// <c>(markerAreaX0 + x, markerAreaY0 + y)</c>, then show it.
|
||||
/// </summary>
|
||||
private void PlaceMarker(UiElement? icon, double x, double y)
|
||||
{
|
||||
if (icon is null) return;
|
||||
icon.Left = _markerX0 + (float)x - icon.Width / 2f;
|
||||
icon.Top = _markerY0 + (float)y - icon.Height / 2f;
|
||||
icon.Visible = true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,21 @@ public static class RetailPanelCatalog
|
|||
/// </summary>
|
||||
public const uint SocialPanel = 12u;
|
||||
|
||||
/// <summary>
|
||||
/// Batch C (overnight hover/UI round, 2026-08-17): the two-tab Map/House
|
||||
/// panel's <c>gmPanelUI</c> slot key — byte-verified from the live
|
||||
/// installed DATs (host <c>0x2100006E</c> slot <c>0x1000018C</c>'s own
|
||||
/// authored <c>0x10000029 = 16</c>, and the toolbar Map/House button
|
||||
/// <c>0x1000019A</c>'s own authored <c>0x10000029 = 16</c>,
|
||||
/// <c>MapHousePanelSlotProbeTests</c>). Already independently named by
|
||||
/// the FA campaign's full 16-slot dump
|
||||
/// (<c>docs/research/2026-08-11-fa-panel-structure.md:927-933</c>,
|
||||
/// "gmMapUI+gmHouseUI pages"). Unlike <see cref="SocialPanel"/>, a real
|
||||
/// toolbar button opens this one — it is in BOTH <see cref="Mounted"/>
|
||||
/// and <see cref="Toolbar"/>.
|
||||
/// </summary>
|
||||
public const uint MapHouse = 16u;
|
||||
|
||||
private static readonly (uint PanelId, string WindowName)[] Mounted =
|
||||
{
|
||||
(CharacterInformation, WindowNames.CharacterInformation),
|
||||
|
|
@ -55,6 +70,7 @@ public static class RetailPanelCatalog
|
|||
(Vitae, WindowNames.Vitae),
|
||||
(Options, WindowNames.Options),
|
||||
(SocialPanel, WindowNames.SocialPanel),
|
||||
(MapHouse, WindowNames.MapHouse),
|
||||
};
|
||||
|
||||
private static readonly (uint PanelId, string WindowName)[] Toolbar =
|
||||
|
|
@ -63,6 +79,7 @@ public static class RetailPanelCatalog
|
|||
(Character, WindowNames.Character),
|
||||
(Magic, WindowNames.Spellbook),
|
||||
(Options, WindowNames.Options),
|
||||
(MapHouse, WindowNames.MapHouse),
|
||||
};
|
||||
|
||||
public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted;
|
||||
|
|
|
|||
|
|
@ -289,6 +289,20 @@ public sealed record SocialRuntimeBindings(
|
|||
// Trailing/optional per the established compatibility convention.
|
||||
AcDream.Runtime.Gameplay.IRuntimeTradeView? Trade = null);
|
||||
|
||||
/// <summary>
|
||||
/// Batch C (overnight hover/UI round, 2026-08-17): bindings for the
|
||||
/// two-tab Map/House panel. <see cref="HousePosition"/> defaults to
|
||||
/// "no house" and <see cref="HouseLines"/> to empty when the caller doesn't
|
||||
/// wire the House wire groundwork — the panel still mounts and the Map tab
|
||||
/// still works standalone.
|
||||
/// </summary>
|
||||
public sealed record MapHouseRuntimeBindings(
|
||||
Func<AcDream.Core.World.DerethDateTime.Calendar> CurrentCalendar,
|
||||
Func<uint> PlayerCellId,
|
||||
Func<CreateObject.ServerPosition?>? HousePosition = null,
|
||||
Func<IReadOnlyList<string>>? HouseLines = null,
|
||||
Action? HouseShown = null);
|
||||
|
||||
public sealed record InventoryRuntimeBindings(
|
||||
ClientObjectTable Objects,
|
||||
Func<uint> PlayerGuid,
|
||||
|
|
@ -451,6 +465,7 @@ public sealed record RetailUiRuntimeBindings(
|
|||
AppraisalRuntimeBindings Appraisal,
|
||||
OptionsRuntimeBindings Options,
|
||||
SocialRuntimeBindings Social,
|
||||
MapHouseRuntimeBindings MapHouse,
|
||||
StackSplitQuantityState StackSplitQuantity,
|
||||
BufferedUiRegistry? Plugins,
|
||||
RetailUiPersistenceBindings? Persistence,
|
||||
|
|
@ -542,6 +557,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountDialogFactory();
|
||||
MountTooltipPresenter();
|
||||
MountSocialPanel();
|
||||
MountMapHousePanel();
|
||||
MountCharacter();
|
||||
MountPlugins();
|
||||
MountInventory();
|
||||
|
|
@ -650,6 +666,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public VendorUiController? VendorController { get; private set; }
|
||||
public OptionsPanelController? OptionsPanelController { get; private set; }
|
||||
public SocialPanelController? SocialPanelController { get; private set; }
|
||||
public MapHousePanelController? MapHousePanelController { get; private set; }
|
||||
internal CharacterManagementUiController? CharacterManagementController =>
|
||||
_characterManagementMount?.Controller;
|
||||
internal CharacterCreationUiController? CharacterCreationController =>
|
||||
|
|
@ -822,6 +839,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
SelectedObjectController?.Tick(deltaSeconds);
|
||||
ExternalContainerController?.Tick();
|
||||
SocialPanelController?.Tick();
|
||||
MapHousePanelController?.Tick(deltaSeconds);
|
||||
_itemCooldownController?.Tick();
|
||||
_characterManagementMount?.Tick();
|
||||
CharacterManagementController?.Tick();
|
||||
|
|
@ -3256,6 +3274,117 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
Console.WriteLine("[UI] retail social panel from LayoutDesc importer (0x2100006E slot 0x1000018F).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch C (overnight hover/UI round, 2026-08-17): the two-tab Map/House
|
||||
/// panel — host <c>0x2100006E</c> slot <c>0x1000018C</c>,
|
||||
/// <see cref="RetailPanelCatalog.MapHouse"/> id 16. Same import/Build/Bind
|
||||
/// recipe as <see cref="MountSocialPanel"/>. The House tab's ListBox rows
|
||||
/// resolve through its own authored template directly via
|
||||
/// <see cref="UiTemplateListBox.AddItemFromTemplateList"/> (a FIXED
|
||||
/// single template, unlike Friends/Squelch/Fellowship's live-roster
|
||||
/// row families) — the <see cref="Layout.RowTemplateResolver"/> below
|
||||
/// serves only the Map tab's per-town hotspot template.
|
||||
/// </summary>
|
||||
private void MountMapHousePanel()
|
||||
{
|
||||
ElementInfo? rootInfo;
|
||||
ImportedLayout? layout;
|
||||
var strings = new DatStringResolver(_bindings.Assets.Dats);
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
rootInfo = LayoutImporter.ImportInfos(
|
||||
_bindings.Assets.Dats,
|
||||
Layout.MapHousePanelController.HostLayoutId,
|
||||
Layout.MapHousePanelController.SlotElementId);
|
||||
layout = rootInfo is null
|
||||
? null
|
||||
: LayoutImporter.Build(
|
||||
rootInfo,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont,
|
||||
strings.Resolve);
|
||||
}
|
||||
if (rootInfo is null || layout is null)
|
||||
{
|
||||
Console.WriteLine("[UI] Map/House panel: LayoutDesc 0x2100006E slot 0x1000018C not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// The town-hotspot template (m_pMap's own 0x47/0x48 attrs) is
|
||||
// resolved once and cached — same "resolve the ElementInfo once,
|
||||
// Build a fresh UiElement per call" shape RowTemplateResolver uses
|
||||
// for the social panel's row families.
|
||||
var hotspotTemplate = new Layout.RowTemplateResolver(
|
||||
(templateLayoutId, templateElementId) => LayoutImporter.ImportInfos(
|
||||
_bindings.Assets.Dats, templateLayoutId, templateElementId),
|
||||
info => LayoutImporter.Build(
|
||||
info,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont).Root);
|
||||
UiElement? ResolveHotspotTemplate(uint templateLayoutId, uint templateElementId)
|
||||
{
|
||||
lock (_bindings.Assets.DatLock)
|
||||
return hotspotTemplate.Resolve(templateLayoutId, templateElementId);
|
||||
}
|
||||
|
||||
MapHouseRuntimeBindings mh = _bindings.MapHouse;
|
||||
var callbacks = new Layout.MapHousePanelController.Callbacks(
|
||||
Toggle: () => ToggleWindow(WindowNames.MapHouse),
|
||||
Map: new Layout.MapPageController.Bindings(
|
||||
CurrentCalendar: mh.CurrentCalendar,
|
||||
PlayerCellId: mh.PlayerCellId,
|
||||
HousePosition: mh.HousePosition ?? (static () => null),
|
||||
TemplateResolver: ResolveHotspotTemplate),
|
||||
House: new Layout.HousePageController.Bindings(
|
||||
Lines: mh.HouseLines ?? (static () => Array.Empty<string>()),
|
||||
OnShown: mh.HouseShown));
|
||||
|
||||
Layout.MapHousePanelController? controller;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
controller = Layout.MapHousePanelController.Bind(rootInfo, layout, callbacks);
|
||||
if (controller is null)
|
||||
{
|
||||
Console.WriteLine("[UI] Map/House panel: required root did not build as UiTabPanel.");
|
||||
return;
|
||||
}
|
||||
|
||||
controller.ActivateTabs();
|
||||
MapHousePanelController = controller;
|
||||
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
controller.Root,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.MapHouse,
|
||||
Chrome = RetailWindowChrome.NineSlice,
|
||||
Left = 240f,
|
||||
Top = 160f,
|
||||
Visible = false,
|
||||
ResizeX = false,
|
||||
ResizeY = false,
|
||||
ConstrainDragToParent = true,
|
||||
ConstrainResizeToParent = true,
|
||||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
|
||||
| AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
ContentClickThrough = false,
|
||||
DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo),
|
||||
Controller = controller,
|
||||
});
|
||||
_panelUi.RegisterMainPanel(
|
||||
RetailPanelCatalog.MapHouse,
|
||||
WindowNames.MapHouse,
|
||||
handle,
|
||||
rootInfo.TryGetEffectiveBool(
|
||||
RetailPanelUiController.RestorePreviousPropertyId,
|
||||
out bool restorePrevious)
|
||||
&& restorePrevious);
|
||||
Console.WriteLine("[UI] retail Map/House panel from LayoutDesc importer (0x2100006E slot 0x1000018C).");
|
||||
}
|
||||
|
||||
private void MountDialogFactory()
|
||||
{
|
||||
if (DialogFactory is not null)
|
||||
|
|
|
|||
|
|
@ -34,4 +34,8 @@ public static class WindowNames
|
|||
/// <summary>Campaign FA slice FA3: the four-tab Friends/Allegiance/
|
||||
/// Fellowship/Squelch panel (<see cref="RetailPanelCatalog.SocialPanel"/>).</summary>
|
||||
public const string SocialPanel = "social-panel";
|
||||
|
||||
/// <summary>Batch C (overnight hover/UI round): the two-tab Map/House
|
||||
/// panel (<see cref="RetailPanelCatalog.MapHouse"/>).</summary>
|
||||
public const string MapHouse = "map-house";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -257,7 +257,8 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
ClientTime: static () => 0d,
|
||||
Log: static _ => { },
|
||||
GpuDevice: null!,
|
||||
GpuFrameSource: null!);
|
||||
GpuFrameSource: null!,
|
||||
CurrentCalendar: static () => default);
|
||||
}
|
||||
|
||||
public InteractionRetainedUiDependencies Dependencies { get; }
|
||||
|
|
|
|||
|
|
@ -272,6 +272,15 @@ public static class FixtureLoader
|
|||
public static ElementInfo LoadSocialPanelHostInfos()
|
||||
=> LoadInfos("social_panel_2100006E_1000018F.json");
|
||||
|
||||
/// <summary>The two-tab Map/House panel host slot <c>0x1000018C</c> —
|
||||
/// Batch C (overnight hover/UI round). This is what
|
||||
/// <see cref="MapHousePanelController.Bind"/> actually mounts.</summary>
|
||||
public static ImportedLayout LoadMapHouseHost()
|
||||
=> LayoutImporter.Build(LoadMapHouseHostInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
public static ElementInfo LoadMapHouseHostInfos()
|
||||
=> LoadInfos("map_house_2100006E_1000018C.json");
|
||||
|
||||
/// <summary>Configure Keyboard screen LayoutDesc <c>0x21000009</c> (standalone
|
||||
/// import — its own separate full-screen window, NOT nested under the Options
|
||||
/// panel's <c>0x2100006E</c> host — Campaign OP slice OP8).</summary>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
using System.Linq;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Controller-level tests for <see cref="MapHousePanelController"/> against
|
||||
/// the committed <c>map_house_2100006E_1000018C.json</c> fixture (Batch C,
|
||||
/// overnight hover/UI round) — the SAME <c>LayoutImporter.ImportInfos(dats,
|
||||
/// 0x2100006Eu, 0x1000018Cu)</c> catalog import
|
||||
/// <see cref="RetailUiRuntime.MountMapHousePanel"/> performs against real
|
||||
/// DATs. No DAT access, no live runtime — dat-free per the established
|
||||
/// <see cref="FixtureLoader"/> pattern.
|
||||
/// </summary>
|
||||
public sealed class MapHousePanelControllerTests
|
||||
{
|
||||
/// <summary>Serves BOTH the town-hotspot template (any (layoutId,
|
||||
/// elementId) pair not the player/house icon ids) and the two icons
|
||||
/// <see cref="MapPageController.Bind"/> re-resolves standalone
|
||||
/// (<c>m_pMap</c>'s own button-swallowed children) — a real
|
||||
/// <see cref="RowTemplateResolver"/> would set <c>DatElementId</c> the
|
||||
/// same way <see cref="LayoutImporter.Build"/> does, so tests that need
|
||||
/// to find these icons back by id after the fact need it too.</summary>
|
||||
private static UiElement? FakeHotspotTemplate(uint layoutId, uint elementId)
|
||||
=> new UiText { Width = 10f, Height = 10f, DatElementId = elementId };
|
||||
|
||||
private static MapHousePanelController.Callbacks MakeCallbacks(
|
||||
List<string>? calls = null,
|
||||
Func<DerethDateTime.Calendar>? currentCalendar = null,
|
||||
Func<uint>? playerCellId = null,
|
||||
Func<CreateObject.ServerPosition?>? housePosition = null,
|
||||
Func<IReadOnlyList<string>>? houseLines = null)
|
||||
{
|
||||
calls ??= new List<string>();
|
||||
return new MapHousePanelController.Callbacks(
|
||||
Toggle: () => calls.Add("toggle"),
|
||||
Map: new MapPageController.Bindings(
|
||||
CurrentCalendar: currentCalendar ?? (static () => default),
|
||||
PlayerCellId: playerCellId ?? (static () => 0u),
|
||||
HousePosition: housePosition ?? (static () => null),
|
||||
TemplateResolver: FakeHotspotTemplate),
|
||||
House: new HousePageController.Bindings(
|
||||
Lines: houseLines ?? (static () => Array.Empty<string>()),
|
||||
OnShown: () => calls.Add("house-shown")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_RootBuildsAsUiTabPanel()
|
||||
{
|
||||
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||
|
||||
MapHousePanelController? controller =
|
||||
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks());
|
||||
|
||||
Assert.NotNull(controller);
|
||||
Assert.IsType<UiTabPanel>(controller!.Root);
|
||||
}
|
||||
|
||||
/// <summary>Pins the authored tab table exactly as read from the live
|
||||
/// DATs (<c>MapHousePanelSlotProbeTests</c>): two entries, Map is the
|
||||
/// sole default.</summary>
|
||||
[Fact]
|
||||
public void TabTable_MatchesLiveDatPairing_MapIsDefault()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||
var tabs = Assert.IsType<UiTabPanel>(layout.Root);
|
||||
|
||||
Assert.Equal(2, tabs.Tabs.Count);
|
||||
Assert.Contains(tabs.Tabs, e =>
|
||||
e.ButtonElementId == 0x100001F3u && e.PageElementId == 0x100001F6u && e.IsDefault);
|
||||
Assert.Contains(tabs.Tabs, e =>
|
||||
e.ButtonElementId == 0x100001F4u && e.PageElementId == 0x100001F7u && !e.IsDefault);
|
||||
Assert.Single(tabs.Tabs, e => e.IsDefault);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_Succeeds_AndActivateTabs_SelectsMapByDefault()
|
||||
{
|
||||
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||
MapHousePanelController? controller =
|
||||
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks());
|
||||
|
||||
Assert.NotNull(controller);
|
||||
controller!.ActivateTabs();
|
||||
|
||||
Assert.Empty(controller.TabPanel.UnresolvedEntries);
|
||||
Assert.False(controller.IsShowingHouse);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SwitchToHouse_FiresOnShown_OnlyWhenPanelIsVisible()
|
||||
{
|
||||
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||
var calls = new List<string>();
|
||||
MapHousePanelController? controller =
|
||||
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks(calls));
|
||||
Assert.NotNull(controller);
|
||||
controller!.ActivateTabs();
|
||||
|
||||
// Not visible yet: switching tabs must not fire OnShown.
|
||||
controller.TabPanel.SwitchTo(0x100001F7u);
|
||||
Assert.DoesNotContain("house-shown", calls);
|
||||
|
||||
controller.OnShown();
|
||||
Assert.Contains("house-shown", calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseButton_InvokesToggle()
|
||||
{
|
||||
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||
var calls = new List<string>();
|
||||
MapHousePanelController? controller =
|
||||
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks(calls));
|
||||
Assert.NotNull(controller);
|
||||
|
||||
UiElement? close = layout.FindElement(0x100001F5u);
|
||||
Assert.IsType<UiButton>(close);
|
||||
((UiButton)close!).OnClick?.Invoke();
|
||||
|
||||
Assert.Contains("toggle", calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_BuildsAll53TownHotspots_UnderTheMapWidget()
|
||||
{
|
||||
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||
MapHousePanelController? controller =
|
||||
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks());
|
||||
Assert.NotNull(controller);
|
||||
|
||||
UiElement? map = UiElement.FindDescendant(controller!.Root, MapPageController.MapWidgetId);
|
||||
Assert.NotNull(map);
|
||||
// m_pMap's own children are the player/house icons (re-resolved
|
||||
// standalone — see MapPageController.Bind's doc on why m_pMap being
|
||||
// a Button swallows its authored nested children) PLUS the 53 town
|
||||
// hotspots.
|
||||
var townMarkers = map!.Children
|
||||
.Where(c => c.DatElementId != MapPageController.PlayerIconId
|
||||
&& c.DatElementId != MapPageController.HouseIconId)
|
||||
.ToList();
|
||||
Assert.Equal(55, map.Children.Count);
|
||||
Assert.Equal(53, townMarkers.Count);
|
||||
Assert.All(townMarkers, c => Assert.Contains(
|
||||
MapLocations.All, loc => loc.Width == c.Width && loc.Height == c.Height));
|
||||
Assert.All(townMarkers, c => Assert.True(c.AuthoredTooltipEnabled));
|
||||
Assert.Contains(townMarkers, c => c.AuthoredTooltipText == "Holtburg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_HouseListBoxStartsEmpty_MatchingRetailPostInit()
|
||||
{
|
||||
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||
MapHousePanelController? controller =
|
||||
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks());
|
||||
Assert.NotNull(controller);
|
||||
|
||||
UiElement? box = UiElement.FindDescendant(controller!.Root, HousePageController.TextBoxId);
|
||||
var listBox = Assert.IsType<UiTemplateListBox>(box);
|
||||
Assert.Equal(0, listBox.ContentHeight);
|
||||
}
|
||||
}
|
||||
191
tests/AcDream.App.Tests/UI/Layout/MapPageControllerTests.cs
Normal file
191
tests/AcDream.App.Tests/UI/Layout/MapPageControllerTests.cs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Ui;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Unit coverage for <see cref="MapPageController"/>'s pure math: the
|
||||
/// calendar formatter (<c>"Date: %s\nTime: %s"</c>,
|
||||
/// <c>gmMapUI::Update @0x004a1eb0</c>) and the 53-town static table
|
||||
/// (verbatim port of <c>s_rgLocations</c>). Marker-placement math itself is
|
||||
/// <see cref="RadarCoordinates"/>/<see cref="LandDefs.GidToLcoord"/> — both
|
||||
/// already unit-tested elsewhere; this file only proves the wiring
|
||||
/// reproduces their output through the real fixture (no re-derivation).
|
||||
/// </summary>
|
||||
public sealed class MapPageControllerTests
|
||||
{
|
||||
// ── Calendar formatter ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FormatDateTime_OrdinaryHour_NoAndHalfSuffix()
|
||||
{
|
||||
var calendar = new DerethDateTime.Calendar(
|
||||
119, DerethDateTime.MonthName.Frostfell, 27, DerethDateTime.HourName.Dawnsong);
|
||||
|
||||
string text = MapPageController.FormatDateTime(calendar);
|
||||
|
||||
Assert.Equal("Date: Frostfell 27, 119 P.Y.\nTime: Dawnsong", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatDateTime_AndHalfHour_RewritesSuffixWithHyphens()
|
||||
{
|
||||
var calendar = new DerethDateTime.Calendar(
|
||||
10, DerethDateTime.MonthName.Morningthaw, 1, DerethDateTime.HourName.MorntideAndHalf);
|
||||
|
||||
string text = MapPageController.FormatDateTime(calendar);
|
||||
|
||||
Assert.Equal("Date: Morningthaw 1, 10 P.Y.\nTime: Morntide-and-Half", text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DerethDateTime.HourName.Darktide, "Darktide")]
|
||||
[InlineData(DerethDateTime.HourName.DarktideAndHalf, "Darktide-and-Half")]
|
||||
[InlineData(DerethDateTime.HourName.Gloaming, "Gloaming")]
|
||||
[InlineData(DerethDateTime.HourName.GloamingAndHalf, "Gloaming-and-Half")]
|
||||
[InlineData(DerethDateTime.HourName.WarmtideAndHalf, "Warmtide-and-Half")]
|
||||
public void FormatDateTime_EveryHourName_MatchesExpectedDisplayText(
|
||||
DerethDateTime.HourName hour, string expectedHourText)
|
||||
{
|
||||
var calendar = new DerethDateTime.Calendar(
|
||||
10, DerethDateTime.MonthName.Morningthaw, 1, hour);
|
||||
|
||||
string text = MapPageController.FormatDateTime(calendar);
|
||||
|
||||
Assert.EndsWith($"Time: {expectedHourText}", text);
|
||||
}
|
||||
|
||||
// ── Town table ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void MapLocations_Has53Entries()
|
||||
{
|
||||
Assert.Equal(53, MapLocations.All.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapLocations_AllNamesAreUnique()
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (MapLocation loc in MapLocations.All)
|
||||
Assert.True(names.Add(loc.Name), $"duplicate town name: {loc.Name}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapLocations_Holtburg_MatchesDecompiledByteValues()
|
||||
{
|
||||
// s_rgLocations[0x13] (pc:977379): X=0xa4 Y=0x4d W=9 H=8.
|
||||
MapLocation holtburg = Assert.Single(MapLocations.All, l => l.Name == "Holtburg");
|
||||
Assert.Equal(0xa4, holtburg.X);
|
||||
Assert.Equal(0x4d, holtburg.Y);
|
||||
Assert.Equal(9, holtburg.Width);
|
||||
Assert.Equal(8, holtburg.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapLocations_EveryRectIsWithinTheMapWidgetsAuthoredExtent()
|
||||
{
|
||||
// m_pMap's own authored size (MapHousePanelSlotProbeTests: markerArea
|
||||
// (6,8)-(247,258) — the widest observed extent). Town rects are
|
||||
// independent of the marker-area rect but should still land inside
|
||||
// a sane 0..300 canvas — a coarse sanity check that the verbatim
|
||||
// port didn't transpose a digit.
|
||||
foreach (MapLocation loc in MapLocations.All)
|
||||
{
|
||||
Assert.InRange(loc.X, 0, 260);
|
||||
Assert.InRange(loc.Y, 0, 260);
|
||||
Assert.InRange(loc.Width, 1, 20);
|
||||
Assert.InRange(loc.Height, 1, 20);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Marker placement wiring (real fixture, no re-derivation) ────────────
|
||||
|
||||
[Fact]
|
||||
public void Bind_PlayerMarker_OutdoorCell_ReproducesRadarCoordinatesPlacement()
|
||||
{
|
||||
// Arwic's landblock cell id (0x11CE0001 — an arbitrary real outdoor
|
||||
// cell, picked only because RadarCoordinates.TryFromCell already
|
||||
// proves gid-to-lcoord conformance elsewhere; this test proves the
|
||||
// WIRING, not the formula).
|
||||
const uint cellId = 0x11CE0001u;
|
||||
Assert.True(RadarCoordinates.TryFromCell(cellId, out RadarCoordinates expected));
|
||||
|
||||
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||
var callbacks = new MapHousePanelController.Callbacks(
|
||||
Toggle: () => { },
|
||||
Map: new MapPageController.Bindings(
|
||||
CurrentCalendar: static () => default,
|
||||
PlayerCellId: () => cellId,
|
||||
HousePosition: static () => (CreateObject.ServerPosition?)null,
|
||||
TemplateResolver: (_, e) => new UiText { Width = 10f, Height = 10f, DatElementId = e }),
|
||||
House: new HousePageController.Bindings(Lines: static () => Array.Empty<string>()));
|
||||
|
||||
MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks);
|
||||
Assert.NotNull(controller);
|
||||
|
||||
UiElement? playerIcon = UiElement.FindDescendant(controller!.Root, MapPageController.PlayerIconId);
|
||||
Assert.NotNull(playerIcon);
|
||||
Assert.True(playerIcon!.Visible);
|
||||
|
||||
// markerArea from the live fixture (MapHousePanelSlotProbeTests):
|
||||
// (6,8)-(247,258) -> m_x0=6, m_y0=8.
|
||||
const int markerX0 = 6, markerY0 = 8;
|
||||
Assert.Equal(markerX0 + (float)expected.X - playerIcon.Width / 2f, playerIcon.Left, precision: 3);
|
||||
Assert.Equal(markerY0 + (float)expected.Y - playerIcon.Height / 2f, playerIcon.Top, precision: 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_PlayerMarker_IndoorCell_HidesIconAndClearsCoordinateText()
|
||||
{
|
||||
// Envcell low word (>= 0x100) fails RadarCoordinates.TryFromCell —
|
||||
// the indoor branch (gmMapUI::Update's else: SetVisible(0)).
|
||||
const uint indoorCellId = 0x0012_0100u;
|
||||
Assert.False(RadarCoordinates.TryFromCell(indoorCellId, out _));
|
||||
|
||||
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||
var callbacks = new MapHousePanelController.Callbacks(
|
||||
Toggle: () => { },
|
||||
Map: new MapPageController.Bindings(
|
||||
CurrentCalendar: static () => default,
|
||||
PlayerCellId: () => indoorCellId,
|
||||
HousePosition: static () => (CreateObject.ServerPosition?)null,
|
||||
TemplateResolver: (_, e) => new UiText { Width = 10f, Height = 10f, DatElementId = e }),
|
||||
House: new HousePageController.Bindings(Lines: static () => Array.Empty<string>()));
|
||||
|
||||
MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks);
|
||||
Assert.NotNull(controller);
|
||||
|
||||
UiElement? playerIcon = UiElement.FindDescendant(controller!.Root, MapPageController.PlayerIconId);
|
||||
Assert.NotNull(playerIcon);
|
||||
Assert.False(playerIcon!.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_HouseMarker_NullPosition_StaysHidden()
|
||||
{
|
||||
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||
var callbacks = new MapHousePanelController.Callbacks(
|
||||
Toggle: () => { },
|
||||
Map: new MapPageController.Bindings(
|
||||
CurrentCalendar: static () => default,
|
||||
PlayerCellId: static () => 0u,
|
||||
HousePosition: static () => (CreateObject.ServerPosition?)null,
|
||||
TemplateResolver: (_, e) => new UiText { Width = 10f, Height = 10f, DatElementId = e }),
|
||||
House: new HousePageController.Bindings(Lines: static () => Array.Empty<string>()));
|
||||
|
||||
MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks);
|
||||
Assert.NotNull(controller);
|
||||
|
||||
UiElement? houseIcon = UiElement.FindDescendant(controller!.Root, MapPageController.HouseIconId);
|
||||
Assert.NotNull(houseIcon);
|
||||
Assert.False(houseIcon!.Visible);
|
||||
}
|
||||
}
|
||||
|
|
@ -153,6 +153,11 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
// lane-A unknowns U3/U4/U6/U7/U10.
|
||||
(SocialPanelController.HostLayoutId, SocialPanelController.SlotElementId,
|
||||
"social_panel_2100006E_1000018F.json"),
|
||||
// Batch C (overnight hover/UI round, 2026-08-17): the two-tab
|
||||
// Map/House panel — recon doc
|
||||
// docs/research/2026-08-17-map-house-recon.md.
|
||||
(MapHousePanelController.HostLayoutId, MapHousePanelController.SlotElementId,
|
||||
"map_house_2100006E_1000018C.json"),
|
||||
})
|
||||
{
|
||||
ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue