acdream/docs/research/2026-08-17-map-house-recon.md
Erik 6ee3d88863 fix(ui): night-round review — F1 real PlaceMarkerOnMap formula
The prior PlaceMarker() reading ("center at markerX0+x") was wrong.
Binary Ninja elides gmMapUI::PlaceMarkerOnMap @0x004a18b0's entire FPU
chain to bare, operand-less _ftol2() calls, so the pseudo-C
under-specifies the function. A capstone disassembly of the raw bytes
in the PDB-paired acclient.exe recovers the real formula: retail
projects the AC display coordinate (range ~-102.4..102.4) onto the
marker-area rect via a fixed-point-style transform, not a raw pixel
add:

  X = m_x0 - w/2 - (int)((m_x1-m_x0+1) * (x*10+1024)        * (-1/2048))
  Y = m_y0 - h/2 - (int)((m_y1-m_y0+1) * (2047-(y*10+1024)) * (-1/2048))

Constants read directly from .rdata: 0x79bac8=10.0, 0x7aac78=1024.0,
0x7aac70=-1/2048, 0x7aac68=2047.0. The Y axis's FSUBR is retail's
north-up flip. w/h halve with truncating integer division (matching
retail's cdq;sub;sar idiom), not float division.

Extracted the pure math into MapPageController.ComputeMarkerPosition
so it's directly testable, and retargeted MapPageControllerTests to
GOLDEN PIXEL values computed independently from the formula (never
from the port's own output): the reviewer's canonical (0,0)->(122,128)
case, a far-west and far-north case, and a real town-table entry
(Arwic's landblock, cross-checked against RadarCoordinates). Applies
to the green ring, house pin, and all 53 static town hotspots, which
all resolve through the same PlaceMarker call.

Corrected the recon doc's "accepted as-is" note, which had mistaken
"the FPU argument-passing is BN-mangled" for a narrow issue instead of
the whole-formula elision it actually was.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 04:19:29 +02:00

17 KiB
Raw Blame History

Map/House toolbar panel — recon (Batch C, overnight hover/UI round)

Combines the recon handed to this session (address-level findings, verified) with additional desk verification done before implementation: several facts the handoff marked "UNKNOWN" or "likely" were already resolved elsewhere in the repo, and are corrected here.

Panel identity — RESOLVED (corrects the handoff's guess)

The handoff guessed the panel id was "likely one of RetailPanelCatalog's unused ids {1,2,6,14}". That guess is wrong — the answer was already on disk from the FA campaign's own full 16-slot gmPanelUI::SetupChildren dump (docs/research/2026-08-11-fa-panel-structure.md:927-933, itself byte-verified against the live installed DATs at FA3):

0x1000018C = 16  gmMapUI+gmHouseUI pages
0x10000559 = 25  gmJournalUI/gmPageListUI/gmContractsUI (NOT this batch)

So: host 0x2100006E, slot 0x1000018C, RetailPanelCatalog id 16. gmPanelUI::SetupChildren @0x004bc9e0 (pc:195832) confirms the full 16-slot enumeration is exhaustive — 0x10000186 inside the contiguous id run resolves to nothing (IMPORT NULL), consistent with the FA doc's flag.

Toolbar button: cross-referenced the committed fixture tests/AcDream.App.Tests/UI/Layout/fixtures/toolbar_21000016.json (last regenerated 2026-08-11, Campaign OP slice OP5 — same DAT install this session uses) for each of ToolbarController.PanelButtonIds' own P0x10000029 value:

Button element P0x10000029 Panel
0x1000055A 25 Journal (ghosted, out of scope)
0x10000197 12 Social — currently ghosted despite FA docs claiming "no toolbar button authors this id"; flagged, not chased (out of scope for this batch)
0x10000198 13 Magic (registered)
0x10000199 11 Character (registered)
0x100001B1 7 Inventory (registered)
0x1000019A 16 Map/House — THE button this batch un-ghosts
0x1000019B 10 Options (registered)

Slice 1's live probe re-confirms both facts (slot table + button) against the live DAT rather than trusting the committed fixture at face value.

gmMapUI — decompiled, byte-exact

Retail source: docs/research/named-retail/acclient_2013_pseudo_c.txt.

  • gmMapUI::PostInit @0x004a1c70 (pc:171993): resolves m_pDateTimeText=0x100001eb (UIElement_Text, DynamicCast 0xc), m_pCoordinateText=0x100001ef (UIElement_Text), m_pPlayerLocationIcon=0x100001ed, m_pHouseLocationIcon=0x100001ee, m_pMap=0x100001ec. Reads m_pMap's own int attrs 0x1000004e/4f/50/51 into m_boxMapMarkerArea (x0,x1,y0,y1) — the marker-area rect. Reads m_pMap's enum attr 0x47 (literal small property id, NOT 0x10000047) and DataID attr 0x48 into a QualifiedDataID(id, 0x23) (category 0x23 = LayoutDesc) → DBObj::Get. If that resolves, loops the 53-entry s_rgLocations table calling AddMapNote(this, m_pMap, var_c /*=attr 0x47, the template ELEMENT id*/, eax_10 /*=the resolved LayoutDesc*/, &s_rgLocations[i]) for each. So 0x47/0x48 on m_pMap together name a template (LayoutDesc, element) pair for the per-town hotspot widget — the exact same "authored template" pattern Layout.RowTemplateResolver already serves for Friends/Squelch/ Fellowship rows. Slice 1's probe reads the live values.
  • gmMapUI::AddMapNote @0x004a1bb0 (pc:171967): CreateChildElement(mgr, m_pMap, layoutDesc, templateElementId)MoveTo(info.X, info.Y)ResizeTo(info.Width, info.Height)SetTooltip(child, info.Name) (a literal wide string, StringInfo::SetLiteralValue — not a DAT string table lookup). acdream's UiElement.AuthoredTooltipText + AuthoredTooltipEnabled, served by RetailTooltipPresenter (src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs), is the exact seam — set both fields on the built child and the existing tooltip pipeline does the rest. This closes the one remaining item of register row TS-85 (docs/architecture/retail-divergence-register.md), which explicitly named gmMapUI::AddMapNote @0x004A1C51 as the last unported SetTooltip call site.
  • gmMapUI::PlaceMarkerOnMap @0x004a18b0 (pc:171827): CORRECTED 2026-08-17 (night-round review, finding F1) — the "accepted as-is" reading below was WRONG. The BN pseudo-C's operand-less _ftol2() calls are not just "argument-passing mangled" — they swallow the ENTIRE FPU chain (constants, multiplies, the Y-axis FSUBR flip), not merely the arg3/arg4 reads. A direct capstone disassembly of the raw bytes at 0x004a18b0 in the PDB-paired acclient.exe recovers the true formula: retail projects the AC display coordinate (x/y, range ≈ ±102.4) onto the marker-area rect via a fixed-point-style transform, not a raw pixel offset: 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)), then SetVisible(1). Constants read from .rdata: 0x79bac8=10.0, 0x7aac78=1024.0, 0x7aac70=-1/2048, 0x7aac68=2047.0. w/h are UIRegion::GetWidth/GetHeight halved by INTEGER (truncating) division, matching retail's cdq;sub;sar idiom. Golden case: marker area (6,8)-(247,258), 10x10 icon, position 0.0N/0.0E → (122,128) center — reproduced exactly. Ported at src/AcDream.App/UI/Layout/MapPageController.cs's PlaceMarker. The ORIGINAL (wrong) note, kept for the historical record of how the mistake happened: "MoveTo(m_x0 + (int)x - width/2, m_y0 + (int)y - height/2)... the x87/FPU argument-passing is BN-mangled in the raw decomp (the _ftol2() placeholder swallows the actual arg3/arg4 reads) — this formula is the handoff's own already-verified reading and is accepted as-is; the underlying +x-w/2 / +y-h/2 centering pattern is unambiguous from the surrounding integer math." It was not unambiguous — the BN elision hid a whole coordinate-projection transform behind what looked like a plain pixel add.
  • gmMapUI::Update @0x004a1eb0 (pc:172084): re-arms m_nextUpdate = Timer::cur_time + 5.0 every call (5 s cadence, driven by ListenToGlobalMessage's arg2==3 tick case). Date/time block: builds "Date: %s\nTime: %s" from GameTime::GetDateTimeString, only calls SetText when the string actually differs (a change-detect, not a re-stamp every 5 s). Coordinate block, gated on CPlayerSystem::IsOutside():
    • outside: CPlayerSystem::InqPlayerCoords → sign-based N/S/E/W selection (heavily FPU-mangled — BN elides the actual printf format string behind a Formatted/vtable-slot placeholder it cannot resolve; accepted as genuinely unrecoverable from this dump, matching the handoff's own UNKNOWN #3) → SetText (change-detected) → PlaceMarkerOnMap(m_pPlayerLocationIcon, x, y).
    • inside: SetText to a fixed narrow-string constant (also BN-mangled/unrecovered — treated as "empty/blank", matching retail's known behavior of clearing the readout) → m_pPlayerLocationIcon-> SetVisible(0).
    • House marker (independent of the outside/inside branch, gated on m_pHouseLocationIcon != 0): Position::IsValid(&m_HousePosition) → if invalid, SetVisible(0); if valid, Position::get_outside_cell_id(&m_HousePosition)LandDefs::gid_to_lcoordthe identical (v - 0x400) * 0.1 + 0.5 transform on both axes → PlaceMarkerOnMap(m_pHouseLocationIcon, x, y).
  • CPlayerSystem::InqPlayerCoords @0x00560090 (pc:364615): confirms the (lcoord - 0x400) * 0.1 + 0.5 display transform per axis, fed by CPhysicsObj::get_landscape_coord. Which of that function's two raw outputs maps to which InqPlayerCoords axis is ambiguous in the BN decomp (a esp+0x10/esp+0x24 swap that can't be resolved without disassembly); not chased — see "Precision decision" below.
  • LandDefs::gid_to_lcoord @0x00497a90 (pc:163500): clean, no FPU noise. Already ported at src/AcDream.Core/Physics/LandDefs.cs:73 (LandDefs.GidToLcoord, issue #106, cross-checked against ACE) — the edx_2 < 0x100 low-word check that looked suspicious in the raw BN text is exactly the existing port's own documented finding (low = cellId & 0xFFFF; if (low >= 0x100) return false — a 16-bit sub-register access BN renders as a full-width compare). No re-port needed, per the WorldBuilder-inventory doctrine ("read the inventory FIRST... re-porting when we already have a tested port is how bugs slip in") extended here to the equivalent Core-physics precedent.
  • Also present but out of scope: gmMapUI::ListenToElementMessage @0x004a2350 idMessage 0x1c handles a GM-only ("PlayerDesc:: PlayerIsPSR") click-to-teleport on the map (lcoord_to_gid from the click pixel → Position → presumably a teleport notice). Not part of any assigned slice; flagged for a future issue if wanted, not filed given no immediate need.

Precision decision (player marker)

InqPlayerCoords' raw inputs come from CPhysicsObj::get_landscape_coord, which is not itself ported and whose BN decomp is FPU-mangled beyond safe recovery tonight. At this map's scale (53-entry pixel-rect town table covering the FULL Dereth landmass, lcoord range [0, 0x7F8)=2040 mapped to ~0.1 map-units/cell, i.e. ~0.8 map-units per landblock, well under a pixel) integer landcell precision is visually indistinguishable from sub-cell precision. The port therefore computes the player marker exactly like the (byte-exact, unambiguous) house marker: current outdoor cell id → LandDefs.GidToLcoord → the same (v-0x400)*0.1+0.5 transform. This is a reasoned substitution of an ALREADY-VERIFIED equivalent primitive, not a guess — flagged here and in a divergence-register row for the one case where it could matter (crossing a cell boundary at the exact map-rendering threshold), not for the sub-pixel precision itself.

Wire byte layout — Position (used by HouseData.Position)

references/ACE/Source/ACE.Server/Network/Structure/AllegianceHierarchy.cs:192-212 writes Cell(uint32) + Pos.XYZ(float×3) + Rotation.WXYZ(float×4) = 32 bytes. acdream already has this exact shape as AcDream.Core.Net.Messages.CreateObject.ServerPosition (CreateObject.cs:479, parsed at CreateObject.cs:611-619) — reused rather than re-defined.

House — gmHouseUI, decompiled

  • gmHouseUI::PostInit @0x004a2710 (pc:172581): resolves m_pTextBox=0x100001e6 (UIElement_ListBox, DynamicCast 5). Registers FOUR notice handlers: 0x4dd225 (HouseData), 0x4dd226 (HouseStatus), 0x4dd227 (UpdateRentTime), 0x4dd228 (UpdateRentPayment) — matching wire opcodes 0x0225-0x0228.
  • gmHouseUI::GetHouseLocation @0x004a27b0: reads m_pHouseData at offset 0x74 (an enum, == 4 is a short-circuit "no location" case) or falls through to Position::IsValid(&m_pHouseData->m_pos /*+0x2c*/)LandDefs::gid_to_lcoord(Position::get_outside_cell_id(...)).
  • Seven Display* line builders (DisplayBuyPayment, DisplayRentPayment, DisplayBuyTime, DisplayRentTimes, DisplayLocation, DisplayWarningText, DisplayPurchaseTimeText), all called in sequence from DisplayHouseData @0x004a3380 and from both Update overloads. Each is dozens-to-a-few-hundred lines of heavily FPU/string-mangled BN pseudo-C (PStringBase sprintf chains, HousePaymentList iteration, IsPaidInFull/ConstructRentWarningMessage-style formatting). Sized as genuinely disproportionate for tonight's batch — this matches the task brief's own pre-authorized fallback ("if the whole owned-house wire half balloons beyond reach tonight, land the default-content tab + the enum/parser groundwork, and file the remainder as a precise ISSUES entry"). Decision: land the mount (default authored content, zero wire) and the wire groundwork (enum route registration + parsers + RuntimeHouseState raw-field owner), and file the seven line builders as an ISSUES entry rather than porting them tonight.

Wire — GameEventType already has all four ids (corrects the handoff)

The handoff claimed "0x0227/0x0228 absent from the enum". Checked src/AcDream.Core.Net/Messages/GameEventType.cs:73-76 directly — all four are already defined:

HouseData        = 0x0225
HouseStatus      = 0x0226
UpdateRentTime   = 0x0227
UpdateRentPayment= 0x0228

What's actually missing (confirmed by grepping src/ for every GameEventType.House*/UpdateRent* reference: zero hits) is routing — no parser, no GameEventWiring registration, no consumer. ACE's own writers for two of the four are themselves stubs worth knowing about before treating any live capture as ground truth: GameEventHouseUpdateRentTime.cs always writes a hardcoded rentTime = 0u; GameEventHouseUpdateRentPayment.cs always writes an empty List<HousePayment>. GameEventHouseData/GameEventHouseStatus write real data (HouseData/(uint)WeenieError).

Marker table — s_rgLocations[0x35] (53 entries), verbatim

docs/research/named-retail/acclient_2013_pseudo_c.txt:977225-977651. Struct gmMapUI::LocationRolloverInfo { uint X,Y,Width,Height; wchar_t* Name; } (acclient.h:55686). Values are direct pixel rects passed to MoveTo/ResizeTo on m_pMap — no coordinate transform (unlike the player/house markers). Ported verbatim into a static C# array (see MapLocations.cs below) — town list cross-checked as the complete classic Dereth town set (Holtburg, Arwic, Yaraq, Shoushi, Rithwic, Samsur, Zaikhal, Xarabydun, Yanshi, Nanto, Kara, Lin, Mayoi, Baishi, Sawato, Tou-Tou, Al-Jalima, Al-Arqas, Qalaba'r, Silyun, Bandit Castle, Fort Tethana, Glenden Wood, Cragstone, Dryreach, Eastham, Lytelthorpe, MacNiall's Freehold, Linvak Tukal, Uziz, Wai Jhou, Timaru, Sanamar, Stonehold, Redspire, Bluespire, Greenspire, Neydisa, Mt Esper-Crater Village, Plateau Village, Fiun Outpost, Danby's Outpost, Candeth Keep, Khayyaban, Kryst, Hebian-to, Oolutanga's Refuge, Ulgrim's Island, Ayan Baqur, Aerlinthe Island, Singularity Caul Island).

Seams reused (no new infrastructure needed)

  • Panel mount recipe: RetailUiRuntime.MountSocialPanel (src/AcDream.App/UI/RetailUiRuntime.cs:3033-3257) and Layout.SocialPanelController.cs — copied for a 2-tab MapHousePanelController.
  • UiTabPanel (src/AcDream.App/UI/UiTabPanel.cs) — same tab-table/ ActivateTabBehavior mechanism.
  • ToolbarController + RetailPanelCatalog — add panel id 16 to both Mounted and Toolbar arrays; button un-ghosts automatically once its panel id resolves via TryGetWindowName.
  • RetailTooltipPresenter via UiElement.AuthoredTooltipText/ AuthoredTooltipEnabled — town marker tooltips, closing TS-85's last item.
  • Layout.RowTemplateResolver pattern — the per-town hotspot child is a template-instantiated element exactly like Friends/Squelch/Fellowship rows (template LayoutDesc/element resolved from m_pMap's own 0x48/ 0x47 attrs).
  • LandDefs.GidToLcoord/LcoordToGid (src/AcDream.Core/Physics/LandDefs.cs) — coordinate math, already ported and tested (issue #106).
  • ServerPosition (src/AcDream.Core.Net/Messages/CreateObject.cs:479) — House wire's Position field, already parsed elsewhere.
  • WorldTimeService.CurrentCalendar / DerethDateTime — calendar data; only a formatter matching retail's "Date: %s\nTime: %s" shape is new.
  • RuntimeTradeState (src/AcDream.Runtime/Gameplay/RuntimeTradeState.cs) — J4.x-style session-scoped owner pattern, template for RuntimeHouseState if the wire groundwork lands.

Open items carried into slice reports

  1. Live probe (slice 1) must re-confirm the desk-verified slot/panelId/ button facts above against the ACTUAL live DAT install, not just trust the committed fixture + FA doc (which are consistent with each other but both need the live cross-check the task mandates).
  2. m_pMap's attrs 0x47/0x48 (hotspot template element/LayoutDesc) need a live read — the decomp explains their MEANING but not their VALUE.
  3. House ListBox 0x100001e6's authored default content (the "You do not currently own a house" text) needs a live read to confirm it's baked into the LayoutDesc rather than something the client synthesizes.
  4. Coordinate/N-S-E-W format string for the outside coordinate readout is unrecoverable from this decomp dump (BN elides it behind an unresolved vtable-slot placeholder) — implemented per the task's pre-authorized fallback (sign-based N/S/E/W, one-decimal magnitude, "42.1N, 33.6E" shape), marked inferred-pending-verification in code.
  5. Seven House Display* line builders deferred to an ISSUES entry (see above) — ownership of the wire groundwork (parsers, RuntimeHouseState raw fields) still lands this session so a future session can pick up the text formatting without re-doing the wire.
  6. Toolbar button 0x10000197 (panel id 12 = Social) appears to be a real, currently-ghosted toolbar entry for the Social panel — contradicts SocialPanelController's class doc ("No toolbar button authors this id"). Flagged, not chased — outside Batch C's scope.