diff --git a/docs/research/2026-08-17-map-house-recon.md b/docs/research/2026-08-17-map-house-recon.md index 9ca33f17..008dfca3 100644 --- a/docs/research/2026-08-17-map-house-recon.md +++ b/docs/research/2026-08-17-map-house-recon.md @@ -74,13 +74,34 @@ Retail source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`. 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): `MoveTo(m_x0 + (int)x - - width/2, m_y0 + (int)y - height/2)`, `SetVisible(1)`. 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. +- `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 diff --git a/src/AcDream.App/UI/Layout/MapPageController.cs b/src/AcDream.App/UI/Layout/MapPageController.cs index 8541522b..0a128c5b 100644 --- a/src/AcDream.App/UI/Layout/MapPageController.cs +++ b/src/AcDream.App/UI/Layout/MapPageController.cs @@ -354,17 +354,65 @@ public sealed class MapPageController } /// - /// gmMapUI::PlaceMarkerOnMap @0x004a18b0: center the icon at - /// (markerAreaX0 + x, markerAreaY0 + y), then show it. + /// gmMapUI::PlaceMarkerOnMap @0x004a18b0, ported from a direct + /// byte-read of the PDB-paired acclient.exe (Binary Ninja elides + /// the whole FPU chain to bare, operand-less _ftol2() calls — + /// see docs/research/named-retail/acclient_2013_pseudo_c.txt + /// lines 171827-171855 — so the pseudo-C alone under-specifies this + /// function; capstone disassembly of the raw machine code at that VA + /// is the ground truth here, not the BN text). The prior "center at + /// markerX0+x" reading was WRONG — retail projects the AC display + /// coordinate (/, range + /// approximately ±102.4) onto the marker-area rect's pixel span via a + /// fixed-point-style transform, not a raw pixel add: + /// + /// X = m_x0 - w/2 - (int)( (m_x1-m_x0+1) * (x*10+1024) * (-1/2048) ) + /// Y = m_y0 - h/2 - (int)( (m_y1-m_y0+1) * (2047-(y*10+1024)) * (-1/2048) ) + /// + /// Constants read straight from the binary's .rdata: 0x79bac8 = + /// 10.0, 0x7aac78 = 1024.0, 0x7aac70 = -1/2048 (exactly + /// -0.00048828125), 0x7aac68 = 2047.0. The Y axis's FSUBR + /// (reversed subtract) is retail's north-up flip — Y increases upward + /// on the AC coordinate system but downward in screen pixels. + /// w/h are the icon's own UIRegion::GetWidth/ + /// GetHeight (@0x0069efe0/@0x0069eff0), halved with INTEGER + /// (truncating) division to match retail's cdq;sub;sar idiom — + /// not float division, which would drift by half a pixel on + /// odd-sized icons. Golden case (marker area (6,8)-(247,258), 10x10 + /// icon, position 0.0N/0.0E) reproduces exactly to (122,128) center. /// private void PlaceMarker(UiElement? icon, double x, double y) { if (icon is null) return; - icon.Left = _markerX0 + (float)x - icon.Width / 2f; - icon.Top = _markerY0 + (float)y - icon.Height / 2f; + + (float left, float top) = ComputeMarkerPosition( + _markerX0, _markerX1, _markerY0, _markerY1, + (int)icon.Width, (int)icon.Height, x, y); + icon.Left = left; + icon.Top = top; icon.Visible = true; } + /// + /// The pure PlaceMarkerOnMap math, split out from + /// so tests can assert byte-decoded GOLDEN PIXEL values directly against + /// the formula instead of round-tripping through the port's own output. + /// + internal static (float Left, float Top) ComputeMarkerPosition( + int markerX0, int markerX1, int markerY0, int markerY1, + int iconWidth, int iconHeight, double x, double y) + { + int halfWidth = iconWidth / 2; + int halfHeight = iconHeight / 2; + int extentX = markerX1 - markerX0 + 1; + int extentY = markerY1 - markerY0 + 1; + + int xOffset = (int)(extentX * (x * 10.0 + 1024.0) * (-1.0 / 2048.0)); + int yOffset = (int)(extentY * (2047.0 - (y * 10.0 + 1024.0)) * (-1.0 / 2048.0)); + + return (markerX0 - halfWidth - xOffset, markerY0 - halfHeight - yOffset); + } + private static ElementInfo? FindInfo(ElementInfo info, uint id) { if (info.Id == id) return info; diff --git a/tests/AcDream.App.Tests/UI/Layout/MapPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/MapPageControllerTests.cs index 9d48ef78..a81ddb0d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/MapPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/MapPageControllerTests.cs @@ -103,6 +103,66 @@ public sealed class MapPageControllerTests } } + // ── Marker placement math (GOLDEN PIXEL values, byte-decoded formula) ── + // + // gmMapUI::PlaceMarkerOnMap @0x004a18b0. Binary Ninja elides the entire + // FPU chain to bare, operand-less _ftol2() calls; the formula below was + // recovered by disassembling the raw bytes of the PDB-paired + // acclient.exe directly (capstone) — see MapPageController.ComputeMarkerPosition's + // doc comment and docs/research/2026-08-17-map-house-recon.md's + // corrected PlaceMarkerOnMap entry. Every expected value here is a + // LITERAL computed independently from the formula (by hand / an + // external script), never by calling the port itself — that is the + // whole point of a golden-value test. + // + // 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)) + // + // Marker area used throughout: (6,8)-(247,258) — the live-fixture value + // (MapHousePanelSlotProbeTests). Icon: 10x10 (matches the town hotspot + // template's "plain 10x10 hotspot dot" and this test file's own fixture + // resolver). + + [Theory] + // Canonical case: dead center of Dereth (0.0N/0.0E) -> the marker + // area's own true center pixel. + [InlineData(0.0, 0.0, 122, 128)] + // Far west (x very negative): pixel X moves toward the marker area's + // left edge (m_x0=6), well below the center-case 122. + [InlineData(-100.0, 0.0, 3, 128)] + // Far north (y very positive): pixel Y moves toward the marker area's + // top edge (m_y0=8) — the FSUBR north-up flip means +Y in-game means + // SMALLER pixel Y, not larger. + [InlineData(0.0, 100.0, 122, 5)] + // A real town-table entry: Arwic's landblock cell 0x11CE0001 fed + // through the ALREADY-VERIFIED RadarCoordinates.TryFromCell (a + // different, independently-tested subsystem) to get x=-88.3/y=62.9, + // then through the formula above to get the expected pixel. + [InlineData(-88.30000000000001, 62.900000000000006, 17, 51)] + public void ComputeMarkerPosition_MatchesByteDecodedFormula_GoldenPixels( + double x, double y, int expectedLeft, int expectedTop) + { + (float left, float top) = MapPageController.ComputeMarkerPosition( + markerX0: 6, markerX1: 247, markerY0: 8, markerY1: 258, + iconWidth: 10, iconHeight: 10, x: x, y: y); + + Assert.Equal(expectedLeft, left); + Assert.Equal(expectedTop, top); + } + + [Fact] + public void ComputeMarkerPosition_ArwicCell_MatchesRadarCoordinates() + { + // Cross-check that the golden (x,y) literal used above for the + // "town-table entry" case really is what RadarCoordinates.TryFromCell + // produces for Arwic's landblock, so the golden test above can't + // silently drift from the coordinate subsystem it's chained to. + const uint cellId = 0x11CE0001u; + Assert.True(RadarCoordinates.TryFromCell(cellId, out RadarCoordinates coords)); + Assert.Equal(-88.30000000000001, coords.X, precision: 12); + Assert.Equal(62.900000000000006, coords.Y, precision: 12); + } + // ── Marker placement wiring (real fixture, no re-derivation) ──────────── [Fact] @@ -111,9 +171,10 @@ public sealed class MapPageControllerTests // 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). + // WIRING, not the formula — the formula itself is golden-tested + // above). const uint cellId = 0x11CE0001u; - Assert.True(RadarCoordinates.TryFromCell(cellId, out RadarCoordinates expected)); + Assert.True(RadarCoordinates.TryFromCell(cellId, out _)); ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos(); ImportedLayout layout = FixtureLoader.LoadMapHouseHost(); @@ -134,10 +195,11 @@ public sealed class MapPageControllerTests 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); + // (6,8)-(247,258). GOLDEN pixel value computed independently above + // (ComputeMarkerPosition_MatchesByteDecodedFormula_GoldenPixels' + // Arwic case) — (17,51) for this exact (x,y). + Assert.Equal(17f, playerIcon.Left); + Assert.Equal(51f, playerIcon.Top); } [Fact]