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

295 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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_lcoord` → **the 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.