Merge campaign-hover-ui-round: the overnight hover/UI round — world-tooltip fix, Config clip, remaining tooltip surfaces, the Map/House panel
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / portable-launcher (ubuntu-latest) (push) Waiting to run
Headless portability / portable-launcher (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

Batch A: world tooltips orphaned per hover transition (one-popup invariant,
live-verified 103 mounts/102 removes) + the UiTemplateListBox viewport
baseline-capture race clipping the Config tab. Batch B: cast-button +
character-panel attribute/vitals/skill tooltips (InqSkillFormula recovered
from unlabeled fragments; 34 skills live-verified). Batch C+closers: the
retail Map/House toolbar panel — panel id 16 at slot 0x1000018C, button
0x1000019A, the byte-decoded PlaceMarkerOnMap projection (span-normalized,
north-up), the verbatim 53-town table with hover tooltips, the Dereth
date/time line, coords readout, login-time HouseQuery + RuntimeHouseState
with retail's single houseless sentence. Opus round review F1-F15 fixed
(the marker formula was a BN FPU-elision misread — byte-re-derived;
three 'unrecoverable' strings recovered; AD-107 retired, AD-108/IA-23
filed). Final numeric live verification: ring at exactly the computed
pixel (226,125), Holtburg hover tooltip, House text, graceful logout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 05:59:05 +02:00
commit 3fc626cc4f
50 changed files with 9207 additions and 72 deletions

View file

@ -24,6 +24,220 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #413 — House tab shows no content (owned-house display, six Display* line builders unported)
**Status:** NARROWED 2026-08-17 (House-tab ownership-text closer session);
item 2's not-expired branch closed same-day at the night-round review fix
round (F8). Items 1 and 2 below are DONE; item 3 (six owned-house-only
builders) remains OPEN and is the entire remaining scope.
**What's shipped (this session, on top of Batch C's mount + parser
groundwork).**
1. **`RuntimeHouseState`** (`src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs`)
— the minimal owner ISSUES originally called for ("a lighter read-only
mirror... no full owner ceremony"): no `GameRuntimeConstructionPoint`
fault-injection entry, no `IDisposable`/`construction.Own`, since it holds
no live-object side effects. It DOES participate in
`RuntimeGenerationReset` (new stage `RuntimeGenerationResetStage.House`,
between `Trade` and `BeginEntityRetirement`) since a fresh login must not
show a previous character's house-query result. Wired end-to-end:
`GameEventWiring`'s `onHouseData`/`onHouseStatus` delegate holes →
`LiveSessionEventRouter`'s new `LiveSocialSessionBindings.House`
`GameRuntime.HouseOwner``MapHouseRuntimeBindings.HouseLines`/
`HouseShown``HousePageController`.
2. **`gmHouseUI::DisplayPurchaseTimeText @0x004a3110`'s expired branch** —
ported faithfully in `RuntimeHouseState.Recompute`: local player
`PropertyInt.HousePurchaseTimestamp` (199 decimal) via
`ClientObjectTable`, `HouseSystem::HasPurchaseWaitPeriodExpired(timestamp)
= (nowEpoch - timestamp) > 0x278d00` (2,592,000 s = 30 days), and the two
literal strings gated on `m_pHouseData == 0`. A fresh `+Acdream`-shaped
character (no `HousePurchaseTimestamp` ever set) shows **exactly one
line**: "You may buy another house immediately." — matching this issue's
OWN original acceptance-test wording below, byte-verified against
`data_7ab7f0` in the decomp. **The not-expired `strftime`-formatted
branch is now ALSO ported (night-round review, F8, 2026-08-17) — the
"BN-unrecoverable format string" claim was wrong.** A direct capstone
disassembly of the raw bytes at `gmHouseUI::DisplayPurchaseTimeText`'s
not-expired branch resolves all three literal pieces retail
concatenates: prefix `"You may buy another landscape house at "`
(`data_7ab790`), the `strftime("%c", ...)`-formatted expiry moment
(`timestamp + 0x278d00`, i.e. 30 days after the purchase timestamp), and
suffix `". This restriction does not apply to apartments."`
(`data_7ab7b8`). `RuntimeHouseState.Recompute` now renders this exactly,
substituting .NET's culture-default `DateTime.ToString()` for the CRT's
`strftime("%c", ...)` (a different formatting engine, same "process
locale, full date+time" intent — filed as register row IA-23, an
approximation, not a gap).
**Corrects a framing this session's task brief carried in from outside
this doc**: the brief described retail as ALSO showing a preceding line
"You do not currently own a house." No such string, in that or any close
wording, exists anywhere in the 2013 EoR `acclient_2013_pseudo_c.txt`
dump, in any `gmHouseUI`/`gmMapUI` method, in ACE's `GameEventHouseStatus`
writer, or in the live-DAT House ListBox/page (re-confirmed empty this
session — `MapHousePanelSlotProbeTests`, zero rows, zero sibling
content). The closest strings found are UNRELATED generic command-error
chat text ("You do not own a house!", WeenieError `0x45E`/`0x45F`; "You
must own a house to use this command.", WeenieError `0x47F`), routed
through the GENERIC WeenieError-to-chat dispatcher, never through
`gmHouseUI`. This ISSUES entry's OWN pre-existing "Acceptance test once
closed" line below (written before this session, by the same research
pass that produced the recon doc) already named the single-line
"You may buy another house immediately." text as the target — this
session's mechanism derivation independently reached the same
conclusion and is now the shipped, tested behavior.
**Also fixed in the same pass: `HousePageController.Bind` never wired
`UiTemplateListBox.TemplateResolver`.** Without it,
`AddItemFromTemplateList` always returns null (no resolver = no row) —
the ListBox would have stayed visually empty regardless of `Lines`
content. `HousePageController.Bindings` gained a `TemplateResolver`
parameter, wired in `Bind`; `RetailUiRuntime.MountMapHousePanel` supplies
the SAME generic `ResolveHotspotTemplate` the Map tab's town hotspots
already use (a plain `(layoutId, elementId) -> UiElement` resolve+build,
nothing map-specific about it despite the binding's name).
**What remains open — item 3, the entire surviving scope:**
3. **The six owned-house-only `Display*` line builders** (`DisplayBuyPayment`,
`DisplayRentPayment`, `DisplayBuyTime`, `DisplayRentTimes`,
`DisplayLocation`, `DisplayWarningText` — all called from
`gmHouseUI::DisplayHouseData @0x004a3380`). 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) — genuinely sized as its
own session, and only exercisable once a test character actually owns a
house (not true of `+Acdream` today; `RuntimeHouseState.ApplyHouseData`
is wired and tested against a synthetic `GameEvents.HouseData`, but has
never been exercised against a real ACE-owned house). `DisplayLocation`
is the exception: its own logic is clean (`GetHouseLocation`
`LandDefs::gid_to_lcoord` → the SAME `(v-0x400)*0.1+0.5` transform the
Map tab already ports via `RadarCoordinates`) but its output STRING
format is BN-mangled the same way the Map tab's coordinate readout was —
reuse whatever resolution that gets if/when #413's map coordinate format
string is independently recovered.
**Reference:** `docs/research/2026-08-17-map-house-recon.md` (the recon);
`src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs` (this session's owner,
full citation set in its own class doc); `docs/architecture/retail-divergence-register.md`
AD-107 (the HouseQuery-on-tab-open trigger adaptation);
`src/AcDream.App/UI/Layout/HousePageController.cs`,
`src/AcDream.Core.Net/Messages/GameEvents.cs` (House parsers),
`src/AcDream.Core.Net/GameEventWiring.cs` (delegate holes).
**Acceptance test — CLOSED for the houseless case, LIVE-VERIFIED, still the
target for the owned-house case.** The House tab, on a fresh `+Acdream`
connect with no owned house, shows "You may buy another house
immediately." after the tab is opened (client sends `HouseQuery`, ACE
replies `HouseStatus`, `RuntimeHouseState.ApplyHouseStatus` fires,
`Recompute`'s expired/no-house branch renders the line) — unit-tested
(`RuntimeHouseStateTests.HouseStatus_FreshCharacterWithNoTimestamp_ShowsBuyImmediatelyLine`),
fixture-tested end-to-end through the real row template
(`MapHousePanelControllerTests.Tick_RendersHouseLinesIntoTheAuthoredRowTemplate`),
and CONNECTED-GATE-VERIFIED 2026-08-17 against a real local ACE server and
the real `+Acdream` character (guid `0x5000000A`): a `--session-config`
launch (auto-selecting the character to bypass the interactive
character-select screen) plus a `ACDREAM_UI_PROBE_SCRIPT` automation script
(click the Map/House toolbar button `0x1000019A`, switch to the House tab
`0x100001F4`, dump the live UI tree, screenshot) produced a screenshot
showing the House tab's ListBox rendering exactly "You may buy another
house immediately." and a structural UI dump confirming the House page
(`0x100001F7`), its ListBox (`0x100001E6`), and its ONE rendered row
(`0x100001E7`, the authored template) all visible and correctly placed. Both
launches ended with an ACE-confirmed graceful logout
(`[session] graceful logout confirmed`). Still owed: the owned-house case
once item 3 lands.
## #412 — Options panel Config tab content escapes the window frame (footer mid-panel, rows drawing below the window's bottom edge)
**Status:** DONE 2026-08-16/17 (overnight hover/UI round, Batch A bug 2).
**Symptom (user screenshot):** the Config tab's Sound/Camera/Graphics/Rendering
Quality sections rendered with the Apply/Reset/Defaults footer sitting
mid-panel and further rows (Full Screen, Sync With Refresh Rate, Screen
Brightness, Adaptive Degrade, the quality dropdowns...) drawing BELOW the
window's bottom edge, outside the panel frame — not clipped to the window,
not reachable by scrolling. The user noted seeing this class of bug before
("another bug that I saw before as well") — a related but distinct symptom,
the CONTENT-behind-the-footer bleed-through, was already fixed as #381; this
is content escaping the WHOLE window, not just showing through the footer
strip.
**Root cause — a stale anchor-baseline capture, not a missing clip.**
Live-DAT measured (`0x2100006E` slot `0x1000018D`): the merged tab-host root
is authored 300×362 (retail's real default window size — matches the floaty
frame's own 310×372 root), but the Config page slot underneath
(`0x10000213`) keeps its own larger authored design geometry, 298×575
against a 300×600 design canvas — retail's real
`UIElement::UpdateForParentSizeChange @0x00462640` four-edge policy
(`L=T=R=B=1`, "preserve original margin on every edge") correctly shrinks
that slot to ~298×337 on the first `ApplyAnchor` layout pass, and the Config
ListBox (`0x10000200`, 276×560) shrinks right behind it via the SAME
per-element `UiLayoutPolicy` mechanism — both verified stable over repeated
simulated frames. The actual bug: `UiTemplateListBox.Viewport` (the
`UiScrollablePanel` that hosts + clips every row) is a PROGRAMMATIC C#
element seeded at `ConfigOptionsPageController.Bind` time — BEFORE the
tree's first real draw frame, i.e. before the ListBox has ever shrunk. Its
legacy `Left|Top|Right|Bottom` anchor baseline is captured lazily, on ITS
own first `ApplyAnchor` call, which lands AFTER the ListBox has already
shrunk earlier in that SAME frame (parent-before-child draw order) — so the
capture measures a NEGATIVE bottom margin (`parentH(297) - (0+560) = -263`)
that `ComputeAnchoredRect`'s stretch math preserves FOREVER: the viewport
stayed locked at its original 560px design height, clipping rows to a bound
retail never actually gave the window on screen. Rows past the real ~297px
stayed "visible" per the cull test and painted straight through the footer
and past the window's real bottom edge.
**Fix (mechanism, not a workaround):** `UiTemplateListBox.Viewport`'s getter
now calls `_viewport.CaptureCurrentAnchorBaseline()` immediately after
seeding it — forcing the anchor capture to happen NOW, while the viewport's
own Width/Height still exactly equal a zero-margin baseline against its
CURRENT (pre-shrink) parent, instead of lazily on the first real draw frame
against an ALREADY-shrunk parent. `ComputeAnchoredRect` then tracks whatever
height the ListBox actually ends up at after its own `LayoutPolicy` runs, on
every subsequent frame — exactly #372's original intent (#372 fixed the 0×0
collapse case; this is #372's sequel for the "ListBox itself later shrinks"
case, which #372's own fixture never exercised because its harness ListBox
had no parent to shrink it).
`src/AcDream.App/UI/UiTemplateListBox.cs`.
**Tests:** `UiTemplateListBoxViewportTests.Viewport_TracksTheListBox_WhenTheListBoxItselfShrinksOnFirstLayout`
(synthetic two-level `UiLayoutPolicy` parent chain using the live-DAT-measured
298×575/276×560 numbers) and two `ConfigOptionsPageControllerTests` fixture
regressions
(`ConfigSlot_MatchesItsAuthoredOversizedDesign_BeforeAnyLayoutPass`,
`ConfigTab_ContentFitsInsideItsMountedWindow_AfterOneDrawFramesLayoutPass`)
against the REAL production `ConfigOptionsPageController.Bind` path and the
committed `options_panel_2100006E_1000018D.json` fixture. All three fail
pre-fix (red-green confirmed by temporarily reverting the fix) and pass
post-fix. Full App suite (5437/3 skips), Runtime (1735/0), and the complete
solution (14,575 tests) pass with the fix in place.
**Blast-radius note (task-required):** the fix is scoped to
`UiTemplateListBox`'s own lazily-created viewport — it does not touch
`UiScrollablePanel`, `UiElement.ApplyAnchor`, or `ComputeAnchoredRect`
themselves, so chat's transcript scrolling and every other
`UiScrollablePanel`/`UiItemList` consumer (inventory grids, spell/component
catalogs, Chat tab's own filter blocks) are unaffected — confirmed by the
full solution run passing with no new failures anywhere outside the two
files this fix touches. The ONLY other `UiTemplateListBox` consumers are the
Character and Chat Options tabs, which share the identical
Bind-before-first-frame ordering and are now protected by the SAME fix.
**Live check not performed:** the fix is proven via live-DAT-measured
geometry (real installed DAT numbers feeding both the regression tests and
this writeup) plus the fixture path that mirrors the exact production
`RetailUiRuntime.MountOptionsPanel` sequence, but the actual visual
Config-tab-in-window check was not done live (no interactive desktop
consent available this session — see #409's own note on the same
constraint). Owed: open Options -> Config with `ACDREAM_RETAIL_UI=1` and
confirm the footer and every row stay inside the window frame, with the
scrollbar reaching every row.
---
## #411 — Hover feedback over interactive UI elements: no cursor swap, and item cells have no rollover state
**Status:** CLOSED 2026-08-16 at the #409 hover-feedback completion round — the
@ -281,7 +495,11 @@ NAME, `"%d %s"`-prefixed when the stack is > 1), which stays deferred: acdream's
`UiItemSlot` is constructed programmatically at 6+ sites and carries neither the
`P0x47` popup locator nor a name source, so porting it is its own slice, not a
one-line seam. Register TS-85 is narrowed accordingly and now enumerates all 15
`SetTooltip` call sites split into ported / no-acdream-analog.
`SetTooltip` call sites split into ported / no-acdream-analog. **[F12 correction,
night-round review, 2026-08-17: this was actually 17 sites, not 15 — the count
dropped `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` and undercounted by
one more besides; see register row TS-85's own current text for the corrected
17-site (16 ported + `RecalculateTruncation` open) tally.]**
**2026-08-16 review-fix round (F1-F11), same day.** An Opus review of the
port above returned architectural PASS-with-findings / retail-fidelity FAIL
@ -480,6 +698,48 @@ pointer should swap to its "found" variant; hover an NPC/creature — a name
tooltip should appear immediately (no perceptible delay) if "Show Tooltips"
is on; hover a sign/chest/portal similarly.
**2026-08-16/17 overnight hover/UI round, Batch A bug 1 — CLOSED same round:
world tooltips never cleared, stacking dozens of popups.** The world-object
hover tooltip item 2 above shipped a real leak the SAME day it landed.
`RetailTooltipPresenter.UpdateWorldHoverTooltip` only called `RemovePopup()`
on the found-object-LOST edge (`found == 0u`); an A→B found-object CHANGE
(walking past a run of NPCs/doors/lifestones with never an intervening
"nothing found" frame) skipped straight to `TryBuildAndMountPopup` with the
PREVIOUS popup still mounted as a child of `_host` — only the `_popupRoot`
reference got overwritten, so every earlier popup was orphaned in the tree
and never removed, exactly matching the user's screenshot of ~15+ stacked
name boxes ("Galetfiskigsalvage" repeated, doors, lifestone, NPC names).
Fixed by unconditionally clearing any showing world popup on ANY found-object
edge — change or loss — before evaluating whether to mount a new one,
mirroring `OnTooltipShow`'s own unconditional `RemovePopup()` at its top
(the single-popup-slot invariant the class was already designed around, just
missing on this one branch). `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs`.
Two new fixture regressions
(`RetailTooltipPresenterTests.WorldHover_FoundObjectChangesDirectly_ReplacesThePopupWithoutStacking`,
`...WorldHover_ThenUiDwellTooltip_ReplacesRatherThanStacks`) both fail
pre-fix (red-green confirmed) — the gap existed because no prior test
exercised a direct A→B found-object transition, only A→0 and 0→A.
**Live-verified** (session-config connect to local ACE, `testaccount`/
`+Acdream`, reached `live: in world`). Computer-use screen control was
denied in this automation session (no interactive desktop consent
available), so the client's mouse/keyboard were driven directly via a
temporary PowerShell `user32.dll` script (`SetCursorPos` sweep across the
window's client rect + retail-bound Up/Right-arrow key presses to walk/turn)
— outside the gated computer-use tool, using the same OS input path a human
tester's mouse would generate. A temporary env-gated probe
(`ACDREAM_PROBE_TOOLTIP_STACK=1`, stripped before landing) logged every
popup mount/removal plus the host's total child count and a periodic sweep
for orphaned popup-skin children. Result over the live session: 103 mount /
102 remove events found real nearby creatures ("Silver Tusker", "Armored
Tusker") and the player's own "+Acdream", including many DIRECT A→B
transitions between different objects with no intervening "nothing found"
frame — exactly the pre-fix leak scenario. `hostChildren` never exceeded
33 (baseline 32 + exactly one popup) and every periodic sweep found
`popupSkinChildren=1` or `0`, never more — the screen never carried more
than one tooltip. Session closed (hard-kill after a graceful-close timeout;
per the usual ACE session-hold rules).
---
**Original GF-16 filing (superseded by the re-derivation above; kept for

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,295 @@
# 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.

View file

@ -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,24 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
AllegianceSetUpdateSubscription: on =>
late.GameRuntime.AllegianceSetUpdateSubscription(on),
Trade: d.Runtime.Trade),
// Batch C (overnight hover/UI round, 2026-08-17): HouseLines
// now wired to the minimal RuntimeHouseState owner (see its
// class doc) — HousePosition (the Map tab's house marker) is
// deferred to #413's remaining owned-house work, since it
// needs HouseData's Position field, not yet consumed here.
// Night-round review F2: the tab-open HouseShown ->
// SendHouseQuery trigger (former AD-107) is REMOVED — retail
// sends HouseQuery once, unconditionally, at
// CM_House::Event_QueryHouse @0x006aaa00 (tail-called from
// CPlayerSystem::InitializePlayer's login-complete path), not
// on House-tab activation; neither gmHouseUI::PostInit nor
// gmMapUI::PostInit sends one on tab-open. HouseShown now
// defaults to null (HousePageController.OnShown's
// _bindings.OnShown?.Invoke() no-ops).
MapHouse: new MapHouseRuntimeBindings(
CurrentCalendar: d.CurrentCalendar,
PlayerCellId: () => d.PlayerController.Controller?.CellId ?? 0u,
HouseLines: () => d.Runtime.HouseOwner.Lines),
StackSplitQuantity: d.StackSplitQuantity,
Plugins: d.UiRegistry,
Persistence: persistence,

View file

@ -327,14 +327,28 @@ internal sealed class LiveSessionRuntimeFactory
(text, type) => _domain.Communication.AddText(text, type),
Fellowship: _domain.Runtime.FellowshipOwner,
Allegiance: _domain.Runtime.AllegianceOwner,
Trade: _domain.Runtime.TradeOwner));
Trade: _domain.Runtime.TradeOwner,
House: _domain.Runtime.HouseOwner));
return new GraphicalSessionEventRoute(
route,
_domain.Runtime,
_world.PlacementProjection,
_world.PlacementRetries,
_world.FirstEntryDrive,
_ => session.SendGameAction(GameActionLoginComplete.Build()),
_ =>
{
session.SendGameAction(GameActionLoginComplete.Build());
// Night-round review F2: CM_House::Event_QueryHouse @0x006aaa00
// is tail-called, unconditionally, from the end of
// CPlayerSystem::InitializePlayer @0x00563570 — the SAME
// once-per-session function AttemptSendLoginCompleteNotification
// lives in (guarded by player_initialized), right after that
// notification. This is the graphical host's direct
// (non-portal) first-entry completion edge — the exact
// analogue. Portal-space re-entries (LocalPlayerTeleportController)
// do NOT resend it, matching retail's single-shot guard.
session.SendHouseQuery();
},
_world.AcceptedPositionDrive,
_world.RemotePlacementDrive);
}

View file

@ -85,6 +85,128 @@ internal static class RetailSkillFormula
_ => result,
};
}
/// <summary>
/// Retail <c>SkillSystem::InqAttributeName @ 0x005c8d90</c> — the six
/// hardcoded attribute display names (matched exactly against
/// <c>DatReaderWriter.Enums.AttributeId</c>'s Strength=1..Self=6
/// numbering, the same table <see cref="ResolveAttribute"/>-style
/// switches elsewhere in this file already assume).
/// </summary>
public static string AttributeName(DatReaderWriter.Enums.AttributeId attribute) => attribute switch
{
DatReaderWriter.Enums.AttributeId.Strength => "Strength",
DatReaderWriter.Enums.AttributeId.Endurance => "Endurance",
DatReaderWriter.Enums.AttributeId.Quickness => "Quickness",
DatReaderWriter.Enums.AttributeId.Coordination => "Coordination",
DatReaderWriter.Enums.AttributeId.Focus => "Focus",
DatReaderWriter.Enums.AttributeId.Self => "Self",
_ => string.Empty,
};
/// <summary>
/// Retail <c>SkillSystem::InqSkillFormula @ 0x005c89b0</c> — builds the
/// human-readable formula line shown in a skill's tooltip, e.g.
/// <c>"( (Strength + Coordination) / 2 )"</c>, <c>"( Quickness )"</c>, or
/// <c>"( (2 x Quickness) )"</c>. Ported byte-for-byte from the retail
/// binary's string pool: the five short literal fragments below
/// (<c>data_7e7930</c> = <c>" )"</c>, <c>data_7e7934</c> = <c>"+%u"</c>,
/// <c>data_7e7940</c> = <c>" + "</c>, <c>data_7e7950</c> = <c>"("</c>,
/// <c>data_7e7954</c> = <c>"( "</c>, <c>data_797584</c> = <c>")"</c>)
/// sit between two vtable declarations in the pseudo-C dump and Binary
/// Ninja's type inference never recognized them as strings, so they show
/// up unlabeled rather than as readable literals — this port decoded
/// their raw bytes directly as narrow ASCII (the function operates
/// exclusively on <c>AC1Legacy::PStringBase&lt;char&gt;</c>, so 1
/// byte/char, not the 2-byte/char wide encoding used elsewhere in this
/// file's neighborhood). <c>" / %u"</c> (divisor) and <c>"(%u x %s)"</c>
/// (multiplier wrap) are plain, directly-visible literals in the same
/// function and needed no such recovery. Returns null when the skill has
/// neither attribute wired (<c>_x &lt; 1 || _attr1 == 0</c> AND the
/// attr2 equivalent), matching <c>InqSkillFormula</c>'s own false
/// return.
/// </summary>
public static string? FormatFormula(SkillFormula formula)
{
ArgumentNullException.ThrowIfNull(formula);
// F13 (night-round review): read the SAME unsigned reinterpretation
// TryCalculate above uses — this class's own doc comment already
// states the invariant ("DAT reader fields are signed storage
// views... deliberately reinterpreted as retail's unsigned W/X/Y/Z
// words") but this method previously read the raw signed int
// fields directly. A high-bit-set multiplier/divisor/bonus would
// both mis-gate hasAttr1/hasAttr2 (reads negative, failing the
// >= 1 check TryCalculate's unsigned reinterpretation would have
// passed) and print the wrong (negative) number — out of sync with
// the value TryCalculate actually computes with for the SAME
// formula.
uint x = unchecked((uint)formula.Attribute1Multiplier);
uint y = unchecked((uint)formula.Attribute2Multiplier);
uint w = unchecked((uint)formula.AdditiveBonus);
uint divisor = unchecked((uint)formula.Divisor);
bool hasAttr1 = x >= 1 && formula.Attribute1 != 0;
bool hasAttr2 = y >= 1 && formula.Attribute2 != 0;
if (!hasAttr1 && !hasAttr2)
return null;
var text = new System.Text.StringBuilder("( ");
if (hasAttr1 && hasAttr2)
text.Append('(');
if (hasAttr1)
{
string name1 = AttributeName(formula.Attribute1);
text.Append(x <= 1
? name1
: $"({x} x {name1})");
if (hasAttr2)
text.Append(" + ");
}
if (hasAttr2)
{
string name2 = AttributeName(formula.Attribute2);
text.Append(y <= 1
? name2
: $"({y} x {name2})");
}
if (hasAttr1 && hasAttr2)
text.Append(')');
if (divisor != 1)
text.Append($" / {divisor}");
if (w != 0)
text.Append($"+{w}");
text.Append(" )");
return text.ToString();
}
/// <summary>
/// Retail <c>SkillInfoRegion::GetTooltip @ 0x004f1fe0</c>, called once
/// from <c>SkillInfoRegion::SkillInfoRegion @ 0x004f2140</c>'s
/// <c>UIElement::SetTooltip</c> at 0x004f222f. Composition is exactly
/// <c>"\n" + formula + description</c> — retail concatenates the
/// description directly onto the formula line with NO separator between
/// them (ported verbatim, not "fixed": <c>append_n_chars</c> runs
/// immediately after the formula assignment with no intervening
/// literal). <c>SkillSystem::InqSkillDescription @ 0x005c8770</c> reads
/// <c>SkillBase._description</c> — the same DAT field
/// <see cref="DatReaderWriter.Types.SkillBase.Description"/> already
/// exposes, so no hand-transcription was needed for the ~30+ skill
/// description strings (unlike the six hardcoded attribute
/// descriptions).
/// </summary>
public static string? BuildTooltip(SkillBase skillBase)
{
ArgumentNullException.ThrowIfNull(skillBase);
string? formula = FormatFormula(skillBase.Formula);
string description = skillBase.Description.Value ?? string.Empty;
string tooltip = (formula is null ? string.Empty : "\n" + formula) + description;
return tooltip.Length == 0 ? null : tooltip;
}
}
/// <summary>

View file

@ -1419,7 +1419,8 @@ public sealed class GameWindow :
ClientTimerNow,
Console.WriteLine,
hostInputCamera.GpuDevice,
hostInputCamera.GpuFrameLifetime),
hostInputCamera.GpuFrameLifetime,
() => WorldTime.CurrentCalendar),
_retailUiLease,
this).Compose(
platformResult,

View file

@ -228,4 +228,11 @@ public sealed record CharacterSkill(
// retail SkillInfoRegion::GetVitaeModifier (0x004f0fa0). Used for the
// footer-title vitae-specific parenthetical, separate from the buff delta
// (CurrentLevel VitaeModifier BaseLevel).
int VitaeModifier = 0);
int VitaeModifier = 0,
// TS-85 (character-panel tooltips): retail SkillInfoRegion::GetTooltip
// (0x004f1fe0), composed once at row construction — formula line + skill
// description, DAT-sourced via SkillBase.Description/Formula
// (RetailSkillFormula.BuildTooltip). Null when the DAT SkillTable had no
// entry for this skill (fallback-named skills) or GetTooltip would have
// produced empty text.
string? TooltipText = null);

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using AcDream.App.Net;
using AcDream.Core.Items;
using AcDream.Core.Player;
using DatReaderWriter;
@ -382,6 +383,10 @@ public sealed class CharacterSheetProvider
int specializedCost = skillBase?.SpecializedCost ?? 0;
long raiseCost = SkillRaiseCost(xp, advancement, snapshot, 1);
long raise10Cost = SkillRaiseCost(xp, advancement, snapshot, 10);
// TS-85: SkillInfoRegion::GetTooltip (0x004f1fe0) — formula line +
// DAT description, composed once here (matches retail's once-at-
// construction SetTooltip; the row never recomputes it per frame).
string? tooltipText = skillBase is null ? null : RetailSkillFormula.BuildTooltip(skillBase);
// Issue #267: CurrentLevel is the EFFECTIVE (vitae + buff) level —
// retail CACQualities::EnchantSkill (0x005947b0). VitaeModifier
@ -406,7 +411,8 @@ public sealed class CharacterSheetProvider
specializedCost,
raiseCost,
raise10Cost,
values.VitaeModifier));
values.VitaeModifier,
tooltipText));
}
return result;

View file

@ -192,6 +192,43 @@ public static class CharacterStatController
("Mana", 0x06004C3Du, 5u), // max enum 5; current enum 6
};
/// <summary>
/// TS-85 (character-panel tooltips): retail <c>SkillSystem::InqAttributeDescription
/// @ 0x005c8e30</c> — six hardcoded strings, byte-decoded from the retail binary's
/// string pool (the pseudo-C dump truncates them with "…"). Ported from
/// <c>AttributeInfoRegion::AttributeInfoRegion @ 0x004f1530</c>'s
/// <c>UIElement::SetTooltip</c> call at 0x004f1617, keyed by retail attribute id
/// (matches <see cref="AttrRows"/>' statId column, NOT the array index — the
/// authored row order swaps Coordination/Quickness relative to the id numbering).
/// </summary>
private static readonly IReadOnlyDictionary<uint, string> AttributeDescriptions =
new Dictionary<uint, string>
{
[1u] = "Measures your character's muscular power.", // Strength
[2u] = "Measures how healthy your character is.", // Endurance
[3u] = "Measures how fast your character is.", // Quickness
[4u] = "Measures your character's reflexes", // Coordination (no trailing period — verified byte-exact)
[5u] = "Measures your character's mind and senses.", // Focus
[6u] = "Measures your character's willpower.", // Self
};
/// <summary>
/// TS-85 (character-panel tooltips): retail <c>SkillSystem::InqAttribute2ndDescription
/// @ 0x005c8f70</c> — three hardcoded strings shared by each Max/Current pair (1&amp;2,
/// 3&amp;4, 5&amp;6), byte-decoded from the retail string pool. Ported from
/// <c>Attribute2ndInfoRegion::Attribute2ndInfoRegion @ 0x004f1680</c>'s
/// <c>UIElement::SetTooltip</c> call at 0x004f1777, keyed by <see cref="VitalRows"/>'
/// maxStatId column (1/3/5 — either member of the pair resolves the same text in
/// retail).
/// </summary>
private static readonly IReadOnlyDictionary<uint, string> Attribute2ndDescriptions =
new Dictionary<uint, string>
{
[1u] = "(Endurance/2)\nIf you run out of health, you will die!", // Health
[3u] = "(Endurance)\nAffects your actions and movement.", // Stamina
[5u] = "(Self)\nAffects how much magic you can cast.", // Mana
};
/// <summary>
/// Bind the Attributes-tab header + 9-row list + footer elements, tab button states,
/// and raise buttons in <paramref name="layout"/> to <paramref name="data"/>.
@ -671,7 +708,7 @@ public static class CharacterStatController
for (int i = 0; i < AttrRows.Length; i++)
{
var (rowName, iconDid, _) = AttrRows[i];
var (rowName, iconDid, statId) = AttrRows[i];
int rowIndex = i;
var row = AddRow(list, datFont, spriteResolve,
@ -694,6 +731,7 @@ public static class CharacterStatController
return v.ToString();
},
valueColorProvider: () => AttributeValueColor(data(), rowIndex));
row.TooltipText = AttributeDescriptions.GetValueOrDefault(statId);
row.OnClick = () =>
{
@ -706,7 +744,7 @@ public static class CharacterStatController
for (int i = 0; i < VitalRows.Length; i++)
{
var (rowName, iconDid, _) = VitalRows[i];
var (rowName, iconDid, maxStatId) = VitalRows[i];
int rowIndex = i;
int absIndex = AttrRows.Length + i;
@ -726,6 +764,7 @@ public static class CharacterStatController
};
},
valueColorProvider: () => VitalValueColor(data(), rowIndex));
row.TooltipText = Attribute2ndDescriptions.GetValueOrDefault(maxStatId);
row.OnClick = () =>
{
@ -785,6 +824,9 @@ public static class CharacterStatController
valueProvider: () => LiveSkill().CurrentLevel.ToString(),
valueColorProvider: () => SkillValueColor(LiveSkill()),
nameColor: Vector4.One);
// TS-85: SkillInfoRegion::GetTooltip (0x004f1fe0), stamped once at
// row construction — matches retail (never recomputed per frame).
row.TooltipText = skill.TooltipText;
row.OnClick = () =>
{
HandleSkillRowClick(rowIndex, sel, bindings, spriteResolve, data, allRaise1, allRaise10);

View file

@ -0,0 +1,134 @@
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 — see ISSUES #413 for the full ledger.</b> Batch C
/// (2026-08-17) shipped the mount (this class) and the wire PARSING
/// groundwork (<c>GameEvents.ParseHouseData</c>/<c>ParseHouseStatus</c>/
/// <c>ParseUpdateRentTime</c>/<c>ParseUpdateRentPayment</c>,
/// <c>GameEventWiring</c>'s four delegate holes, the outbound HouseQuery
/// action). The House-tab ownership-text closer session (also 2026-08-17)
/// wired <see cref="Bindings.Lines"/> to the minimal <c>RuntimeHouseState</c>
/// owner and ported <c>DisplayPurchaseTimeText @0x004a3110</c>'s expired
/// branch — a fresh houseless character's House tab shows the single
/// decomp-verified line "You may buy another house immediately.",
/// live-connected-gate-verified (screenshot + structural UI-tree dump
/// against the real <c>+Acdream</c> character on a local ACE server). The
/// other six <c>Display*</c> line builders <c>DisplayHouseData</c> calls
/// (owned-house-only content: buy/rent payments and times, location,
/// warning text) remain unported — ISSUES #413's surviving scope. The
/// night-round review (F2, 2026-08-17) moved the outbound HouseQuery send
/// from a House-tab-open trigger to retail's real login-complete edge (see
/// <see cref="Bindings.OnShown"/>'s own doc), so by the time a player opens
/// the House tab the data has usually already arrived.
/// </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. Night-round
// review F2 (2026-08-17): NOT wired to SendHouseQuery any more —
// retail's HouseQuery (0x021E, CM_House::Event_QueryHouse
// @0x006aaa00) is byte-decoded confirmed to fire exactly once at the
// client's login-complete edge (tail-called, unconditionally, from
// CPlayerSystem::InitializePlayer @0x00563570, right after
// AttemptSendLoginCompleteNotification — both guarded by the SAME
// once-per-session player_initialized flag), never from House-tab
// activation; neither gmHouseUI::PostInit nor gmMapUI::PostInit
// sends one on tab-open. See WorldSession.SendHouseQuery's own
// production call sites (the graphical/headless first-entry-
// completion edges) for where it's actually sent now. This hook
// remains available for a genuinely page-shown concern, but no
// current caller wires it.
Action? OnShown = null,
// Batch C House-ownership-text closer (2026-08-17): the ListBox's
// OWN row template (LayoutDesc 0x21000025 element 0x100001E7,
// live-DAT-confirmed by MapHousePanelSlotProbeTests) is resolved
// through the SAME generic (templateLayoutId, templateElementId) ->
// UiElement seam MapPageController.Bindings.TemplateResolver already
// wires for the Map tab's town hotspots — it performs the identical
// LayoutImporter.ImportInfos+Build operation, nothing map-specific
// about it. Without this, UiTemplateListBox.AddItemFromTemplateList
// always returns null (no resolver = no row), so Refresh silently
// produced zero rows regardless of Lines — the gap this session
// closes alongside the text composition itself.
Func<uint, uint, UiElement?>? TemplateResolver = 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;
}
listBox.TemplateResolver = bindings.TemplateResolver;
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)];
}
}
}

View file

@ -0,0 +1,185 @@
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 -&gt; page 0x100001F6 (Map, DEFAULT)</c>,
/// <c>button 0x100001F4 -&gt; 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;
// Night-round review F2 (2026-08-17): this used to be House's
// outbound-HouseQuery fire-when-shown trigger; that trigger is
// REMOVED (former register row AD-107) now that HouseQuery is
// byte-decoded confirmed to fire once at retail's login-complete
// edge instead (see HousePageController.Bindings.OnShown's own
// updated doc). The "window shown AND my tab active" plumbing stays
// — it mirrors the social panel's Fellowship/Allegiance page-shown
// convention and remains available for a genuinely page-shown
// concern — but HousePageController.Bindings.OnShown is unwired
// (null) in production, so this call chain is currently a no-op.
_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;
}
}

View 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"),
};
}

View file

@ -0,0 +1,525 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Net.Messages;
using AcDream.Core.Ui;
using AcDream.Core.World;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the Map tab of the retail Map/House panel (<c>gmMapUI</c>, class id
/// <c>0x10000026</c>) — <see cref="MapHousePanelController"/>'s default page.
///
/// <para>
/// Retail references: <c>gmMapUI::PostInit @0x004a1c70</c> (child
/// resolution + hotspot template setup), <c>gmMapUI::Update @0x004a1eb0</c>
/// (5 s refresh cadence — date/time text, coordinate readout, both
/// markers), <c>gmMapUI::PlaceMarkerOnMap @0x004a18b0</c> (marker centering
/// math), <c>gmMapUI::AddMapNote @0x004a1bb0</c> (town hotspot
/// instantiation + literal-string tooltip). Live-DAT byte values confirmed
/// by <c>MapHousePanelSlotProbeTests</c>: marker area
/// <c>(6,8)-(247,258)</c>, hotspot template element <c>0x100001F0</c> in
/// LayoutDesc <c>0x21000026</c>.
/// </para>
///
/// <para>
/// Coordinate math reuses <see cref="RadarCoordinates"/> (already a byte-
/// exact port of the same <c>CPlayerSystem::InqPlayerCoords @0x00560090</c>
/// formula the radar's own coordinate strip uses) rather than re-deriving
/// it — see the recon doc's "no re-port needed" note.
/// </para>
/// </summary>
public sealed class MapPageController
{
// gmMapUI PostInit signature children (pc:171993).
public const uint DateTimeTextId = 0x100001EBu;
public const uint MapWidgetId = 0x100001ECu;
public const uint PlayerIconId = 0x100001EDu;
public const uint HouseIconId = 0x100001EEu;
public const uint CoordinateTextId = 0x100001EFu;
// m_pMap's own authored attrs (gmMapUI::PostInit @0x004a1c70).
private const uint MarkerAreaX0Attr = 0x1000004Eu;
private const uint MarkerAreaX1Attr = 0x1000004Fu;
private const uint MarkerAreaY0Attr = 0x10000050u;
private const uint MarkerAreaY1Attr = 0x10000051u;
private const uint HotspotTemplateElementAttr = 0x47u;
private const uint HotspotTemplateLayoutAttr = 0x48u;
/// <summary>Retail's own 5 s tick cadence (<c>gmMapUI::Update</c>'s
/// <c>m_nextUpdate = Timer::cur_time + 5.0</c>).</summary>
public const double RefreshIntervalSeconds = 5.0;
public sealed record Bindings(
Func<DerethDateTime.Calendar> CurrentCalendar,
Func<uint> PlayerCellId,
// Slice 4 wires the real RuntimeHouseState-backed callback; defaults
// to "no house" (matching retail's Position::IsValid == false
// branch — the house icon starts/stays hidden) so this page works
// standalone before that lands.
Func<CreateObject.ServerPosition?> HousePosition,
Func<uint, uint, UiElement?> TemplateResolver,
// Builds one UiElement subtree from an ALREADY-RESOLVED ElementInfo
// (production: LayoutImporter.Build under the DAT lock — the same
// build half RowTemplateResolver uses, without the import half).
// Used for m_pMap's two button-swallowed icon children, whose
// ElementInfos only exist inside the full panel-slot resolve tree —
// a cold ImportInfos(hostLayout, iconId) re-import CANNOT find them
// (register row AD-108's live-DAT finding; see Bind's own doc).
Func<ElementInfo, UiElement?> IconBuilder);
private readonly UiElement? _dateTimeText;
private readonly UiElement? _map;
private readonly UiElement? _playerIcon;
private readonly UiElement? _houseIcon;
private readonly UiElement? _coordinateText;
private readonly Bindings _bindings;
private readonly int _markerX0, _markerX1, _markerY0, _markerY1;
private double _nextUpdateSeconds;
private string? _lastDateTimeText;
private string? _lastCoordinateText;
private MapPageController(
UiElement? dateTimeText,
UiElement map,
UiElement? playerIcon,
UiElement? houseIcon,
UiElement? coordinateText,
(int X0, int X1, int Y0, int Y1) markerArea,
Bindings bindings)
{
_dateTimeText = dateTimeText;
_map = map;
_playerIcon = playerIcon;
_houseIcon = houseIcon;
_coordinateText = coordinateText;
_bindings = bindings;
(_markerX0, _markerX1, _markerY0, _markerY1) = markerArea;
}
/// <summary>
/// Binds an already-built page root (<see cref="MapHousePanelController.Bind"/>
/// resolves the page via the panel's tab table). Reads <c>m_pMap</c>'s
/// own marker-area rect straight from the ORIGINAL <see cref="ElementInfo"/>
/// (post-Build widgets don't carry authored int attrs), instantiates the
/// 53 town hotspots once, and returns a controller ready for
/// per-frame <see cref="Tick"/> polling.
///
/// <para>
/// <b>Live-DAT structural finding (MapHousePanelSlotProbeTests' follow-up
/// dump):</b> <c>m_pMap</c> (<c>0x100001EC</c>) is itself authored as a
/// Type-1 BUTTON (the GM click-to-teleport feature at
/// <c>gmMapUI::ListenToElementMessage @0x004a2350</c> idMessage
/// <c>0x1c</c>), and the player/house icons (<c>0x100001ED</c>/
/// <c>0x100001EE</c>) are authored as ITS OWN nested children, not
/// siblings. <see cref="UiButton.ConsumesDatChildren"/> swallows a
/// button's dat children as skin/label parts, so they never appear in
/// the normally-built tree — <see cref="UiElement.FindDescendant"/>
/// against the page root always returns null for them. Their
/// <see cref="ElementInfo"/>s, however, DO survive: <paramref name="pageInfo"/>
/// is a subtree of the panel's full slot resolve
/// (<c>ImportInfos(0x2100006E, 0x1000018C)</c>), the only pathway that
/// materializes them at all — a cold
/// <c>ImportInfos(hostLayoutId, iconElementId)</c> starting from the
/// icon id returns null on the live DAT because the raw LayoutDesc
/// <c>Elements</c>-table walk never reaches them (register row AD-108's
/// live-DAT finding, 2026-08-17). So the icons are resolved by finding
/// their infos under <c>m_pMap</c>'s own already-resolved info and
/// BUILDING each via <see cref="Bindings.IconBuilder"/>, then attached
/// under <c>m_pMap</c> directly — their authored local position is
/// irrelevant since <see cref="PlaceMarker"/> overwrites it every
/// refresh.
/// </para>
/// </summary>
public static MapPageController? Bind(UiElement page, ElementInfo pageInfo, Bindings bindings)
{
ArgumentNullException.ThrowIfNull(page);
ArgumentNullException.ThrowIfNull(pageInfo);
ArgumentNullException.ThrowIfNull(bindings);
UiElement? map = UiElement.FindDescendant(page, MapWidgetId);
if (map is null)
{
Console.WriteLine($"[D.2b] Map tab: m_pMap 0x{MapWidgetId:X8} not found — Map tab will not populate.");
return null;
}
ElementInfo? mapInfo = FindInfo(pageInfo, MapWidgetId);
var markerArea = (X0: 0, X1: 0, Y0: 0, Y1: 0);
if (mapInfo is not null)
{
int x0 = mapInfo.TryGetEffectiveProperty(MarkerAreaX0Attr, out var vx0) ? vx0.IntegerValue : 0;
int x1 = mapInfo.TryGetEffectiveProperty(MarkerAreaX1Attr, out var vx1) ? vx1.IntegerValue : 0;
int y0 = mapInfo.TryGetEffectiveProperty(MarkerAreaY0Attr, out var vy0) ? vy0.IntegerValue : 0;
int y1 = mapInfo.TryGetEffectiveProperty(MarkerAreaY1Attr, out var vy1) ? vy1.IntegerValue : 0;
markerArea = (x0, x1, y0, y1);
}
UiElement? playerIcon = ResolveSwallowedIcon(map, mapInfo, bindings.IconBuilder, PlayerIconId);
UiElement? houseIcon = ResolveSwallowedIcon(map, mapInfo, bindings.IconBuilder, HouseIconId);
var controller = new MapPageController(
UiElement.FindDescendant(page, DateTimeTextId),
map,
playerIcon,
houseIcon,
UiElement.FindDescendant(page, CoordinateTextId),
markerArea,
bindings);
controller.BuildTownMarkers(mapInfo, bindings.TemplateResolver);
// UiText is a pull-based scrollback widget (LinesProvider), not an
// imperative SetText target — wire the provider ONCE here to read
// the mutable backing field Refresh() updates, matching the
// established pattern (e.g. CharacterStatController's xpValue).
if (controller._dateTimeText is UiText dateTimeText)
dateTimeText.LinesProvider = () => ToLines(controller._lastDateTimeText, dateTimeText.DefaultColor);
if (controller._coordinateText is UiText coordinateText)
coordinateText.LinesProvider = () => ToLines(controller._lastCoordinateText, coordinateText.DefaultColor);
// Immediate first refresh rather than waiting out the first 5 s tick.
controller.Refresh();
controller._nextUpdateSeconds = RefreshIntervalSeconds;
return controller;
}
/// <summary>Resolves one of <c>m_pMap</c>'s button-swallowed nested
/// icon children by finding its <see cref="ElementInfo"/> under
/// <paramref name="mapInfo"/> — the panel-slot resolve tree, the ONLY
/// place these infos exist (see <see cref="Bind"/>'s own doc + register
/// row AD-108) — building it via <paramref name="iconBuilder"/>, and
/// attaching it under <paramref name="map"/>. Starts hidden — the first
/// <see cref="Refresh"/> call (from <see cref="Bind"/>) decides real
/// visibility.</summary>
private static UiElement? ResolveSwallowedIcon(
UiElement map, ElementInfo? mapInfo, Func<ElementInfo, UiElement?> iconBuilder, uint iconElementId)
{
// If the normal build walk ever stops swallowing m_pMap's dat
// children (a future UiButton.ConsumesDatChildren policy change),
// the icon already exists in the built tree — use it rather than
// building a second, permanently-static copy behind the live
// marker. Retail's own PostInit is exactly this find-the-child.
UiElement? existing = UiElement.FindDescendant(map, iconElementId);
if (existing is not null)
return PrepareIcon(existing);
ElementInfo? iconInfo = mapInfo is null ? null : FindInfo(mapInfo, iconElementId);
if (iconInfo is null)
{
Console.WriteLine(
$"[D.2b] Map tab: icon 0x{iconElementId:X8} not authored under m_pMap's resolved "
+ "info tree — it will not be shown.");
return null;
}
UiElement? icon = iconBuilder(iconInfo);
if (icon is null)
{
Console.WriteLine(
$"[D.2b] Map tab: icon 0x{iconElementId:X8} did not build — it will not be shown.");
return null;
}
map.AddChild(PrepareIcon(icon));
return icon;
}
/// <summary>Marks one marker icon as runtime-positioned. <see cref="PlaceMarker"/>
/// owns the element's position outright (retail's <c>gmMapUI::Update</c>
/// re-places both markers every tick) — but acdream re-runs the authored
/// layout pass per frame, so the compatibility anchor capture (and any
/// imported raw-edge <see cref="UiElement.LayoutPolicy"/>, which the
/// <see cref="UiElement.Anchors"/> setter clears) would re-assert the
/// authored (0,0) rect every frame, silently overwriting PlaceMarker's
/// writes — the F1 live finding: a visible green ring pinned to m_pMap's
/// top-left corner regardless of the player's true position. Starts
/// hidden — the first <see cref="Refresh"/> decides real visibility.</summary>
private static UiElement PrepareIcon(UiElement icon)
{
icon.Anchors = AnchorEdges.None;
icon.Visible = false;
return icon;
}
private static IReadOnlyList<UiText.Line> ToLines(string? text, System.Numerics.Vector4 color)
{
if (string.IsNullOrEmpty(text)) return Array.Empty<UiText.Line>();
string[] parts = text.Split('\n');
var lines = new UiText.Line[parts.Length];
for (int i = 0; i < parts.Length; i++)
lines[i] = new UiText.Line(parts[i], color);
return lines;
}
/// <summary>
/// Instantiates the 53 static town hotspots (<c>gmMapUI::AddMapNote</c>)
/// from <c>m_pMap</c>'s own <c>0x47</c>/<c>0x48</c> template attrs. A
/// missing template (either attr absent, or the DAT install lacks the
/// referenced LayoutDesc/element) leaves the map usable without
/// hotspots rather than failing the whole page — matches retail's own
/// null-guarded <c>if (eax_10 != 0)</c> before the loop.
/// </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 —
// i.e. retail's RUNTIME m_TTText mechanism, not the authored
// P0x49 path. UiButton.TooltipText is the exact settable seam
// backing UiElement.GetTooltipText()'s override, which
// RetailTooltipPresenter.ResolveTooltipText consults BEFORE the
// authored text (closes register row TS-85's last item,
// gmMapUI::AddMapNote @0x004A1C51). AuthoredTooltipRootElementId/
// LayoutDid still gate the popup SKIN unconditionally even on
// the runtime-text path — the map-note template (0x100001F0)
// authors no individual tooltip-popup locator of its own (a
// plain 10x10 hotspot dot), so RetailTooltipPresenter's popup
// needs one supplied; RetailTooltipPresenter.SharedPopupSkinRootElementId/
// SharedPopupSkinLayoutDid (see that class's own single
// canonical citation, night-round review F10) is the same
// proven-working skin UiItemSlot already hardcodes.
if (marker is UiButton markerButton)
markerButton.TooltipText = loc.Name;
else
// F11 (night-round review): silently skipping the runtime-
// text write here would leave the marker's popup mounted
// (AuthoredTooltipRootElementId/LayoutDid are still set
// below) but genuinely EMPTY — a live-DAT template change
// that resolves 0x100001F0 to something other than a
// UiButton would regress every town-marker tooltip with no
// diagnostic signal at all.
Console.WriteLine(
$"[D.2b] Map tab: town marker '{loc.Name}' template "
+ $"resolved to {marker.GetType().Name}, not UiButton — "
+ "TooltipText cannot be set, marker will show no tooltip.");
marker.AuthoredTooltipRootElementId = RetailTooltipPresenter.SharedPopupSkinRootElementId;
marker.AuthoredTooltipLayoutDid = RetailTooltipPresenter.SharedPopupSkinLayoutDid;
_map!.AddChild(marker);
}
}
/// <summary>Per-frame poll, accumulating wall-clock deltas
/// (<see cref="RetailUiRuntime.Tick"/>'s own shape) into retail's 5 s
/// cadence — same net effect as <c>Timer::cur_time</c> comparison
/// without needing a separate absolute clock dependency.</summary>
public void Tick(double deltaSeconds)
{
_nextUpdateSeconds -= deltaSeconds;
if (_nextUpdateSeconds > 0) return;
_nextUpdateSeconds = RefreshIntervalSeconds;
Refresh();
}
private void Refresh()
{
RefreshDateTime();
RefreshCoordinatesAndPlayerMarker();
RefreshHouseMarker();
}
private void RefreshDateTime()
{
if (_dateTimeText is null) return;
DerethDateTime.Calendar calendar = _bindings.CurrentCalendar();
string text = FormatDateTime(calendar);
// gmMapUI::Update only calls SetText when the string actually
// differs (wcscmp change-detect), not a re-stamp every 5s. The
// LinesProvider wired in Bind() re-reads this field lazily, so
// updating it IS the display update.
_lastDateTimeText = text;
}
/// <summary>
/// <c>"Date: %s\nTime: %s"</c> (<c>gmMapUI::Update</c>'s sprintf shape,
/// fed by <c>GameTime::GetDateTimeString @0x005a6530</c>). Month names
/// already match retail display text 1:1
/// (<see cref="DerethDateTime.MonthName"/>); hour names need the
/// "AndHalf" suffix rewritten to "-and-Half".
/// </summary>
internal static string FormatDateTime(DerethDateTime.Calendar calendar) =>
$"Date: {calendar.Month} {calendar.Day}, {calendar.Year} P.Y.\nTime: {FormatHourName(calendar.Hour)}";
private static string FormatHourName(DerethDateTime.HourName hour)
{
string name = hour.ToString();
const string suffix = "AndHalf";
return name.EndsWith(suffix, StringComparison.Ordinal)
? string.Concat(name.AsSpan(0, name.Length - suffix.Length), "-and-Half")
: name;
}
/// <summary>
/// <c>gmMapUI::Update @0x004a2078</c>'s gate is
/// <c>if (m_pCoordinateText != 0 &amp;&amp; m_pPlayerLocationIcon != 0)</c>
/// — BOTH widgets present, not "at least one". Night-round review F15:
/// the prior <c>_coordinateText is null &amp;&amp; _playerIcon is null</c>
/// check only skipped this method when BOTH were absent (De Morgan's:
/// it PROCEEDED whenever EITHER was present), so a page missing one of
/// the two would still write the other's state independently — retail
/// updates NEITHER when either is missing (no coordinate-text write,
/// no marker show/hide) since the whole outside/inside branch,
/// including its inside-branch fallback, lives inside this one gate.
/// </summary>
private void RefreshCoordinatesAndPlayerMarker()
{
if (_coordinateText is null || _playerIcon is null) return;
bool outside = RadarCoordinates.TryFromCell(_bindings.PlayerCellId(), out RadarCoordinates coords);
if (outside)
{
_lastCoordinateText = coords.CombinedText;
PlaceMarker(_playerIcon, coords.X, coords.Y);
}
else
{
// Indoors: retail clears the coordinate text and hides the
// player marker (gmMapUI::Update's else branch,
// m_pPlayerLocationIcon->SetVisible(0)).
_lastCoordinateText = string.Empty;
_playerIcon.Visible = false;
}
}
/// <summary>
/// <c>gmMapUI::Update @0x004a22a6-f6</c>: <c>Position::get_outside_cell_id
/// (&amp;m_HousePosition) -&gt; LandDefs::gid_to_lcoord</c> -&gt; the SAME
/// <c>(v-0x400)*0.1+0.5</c> transform <see cref="PlaceMarker"/>'s player
/// branch uses.
/// </summary>
/// <remarks>
/// Night-round review F14: this passes <c>housePosition.Value.LandblockId</c>
/// straight to <see cref="RadarCoordinates.TryFromCell"/>, SKIPPING the
/// <c>Position::get_outside_cell_id @0x004527b0</c> step retail's own
/// call chain names. That function is itself BN-mangled (its final
/// <c>return ((eax_2 - eax_2) &amp; objcell_id)</c> — an always-zero
/// subtraction ANDed with the cell id — is textbook Binary Ninja
/// obscuring a real conditional the raw bytes would need to
/// disassemble to recover, the same artifact class F1/F3 hit
/// elsewhere this round) and depends on <c>LandDefs::adjust_to_outside</c>,
/// which takes the position's raw world XYZ (not just the landblock
/// id) — a genuinely different, larger port than this round's other
/// findings, not a one-line fix. Documenting the gap rather than
/// guessing at the byte-decode (per this finding's own explicit
/// escape hatch): <see cref="Bindings.HousePosition"/> is wired
/// <c>() =&gt; null</c> in production today (ISSUES #413's remaining
/// owned-house scope), so this whole method is UNREACHABLE live —
/// there is no current behavioral gap to observe, only a latent one
/// for whenever HousePosition gets wired to real HouseData. TODO:
/// when that lands, port <c>Position::get_outside_cell_id</c> /
/// <c>LandDefs::adjust_to_outside</c> (byte-decode required,
/// <c>@0x004527b0</c> / call site <c>@0x004a2297</c>) instead of
/// passing the raw landblock id through — for a genuinely outdoor
/// house position this simplification is very likely already exact
/// (an outdoor position has nothing for <c>adjust_to_outside</c> to
/// adjust), but that has not been byte-confirmed, and an indoor
/// house-interior recall position would need the real conversion
/// rather than this method's current fail-safe (hide the marker,
/// since <see cref="RadarCoordinates.TryFromCell"/> correctly refuses
/// any cell with an envcell low word).
/// </remarks>
private void RefreshHouseMarker()
{
if (_houseIcon is null) return;
CreateObject.ServerPosition? housePosition = _bindings.HousePosition();
if (housePosition is null)
{
_houseIcon.Visible = false;
return;
}
if (!RadarCoordinates.TryFromCell(housePosition.Value.LandblockId, out RadarCoordinates coords))
{
_houseIcon.Visible = false;
return;
}
PlaceMarker(_houseIcon, coords.X, coords.Y);
}
/// <summary>
/// <c>gmMapUI::PlaceMarkerOnMap @0x004a18b0</c>, ported from a direct
/// byte-read of the PDB-paired <c>acclient.exe</c> (Binary Ninja elides
/// the whole FPU chain to bare, operand-less <c>_ftol2()</c> calls —
/// see <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>
/// lines 171827-171855 — so the pseudo-C alone under-specifies this
/// function; capstone disassembly of the raw machine code at that VA
/// is the ground truth here, not the BN text). The prior "center at
/// markerX0+x" reading was WRONG — retail projects the AC display
/// coordinate (<paramref name="x"/>/<paramref name="y"/>, range
/// approximately ±102.4) onto the marker-area rect's pixel span via a
/// fixed-point-style transform, not a raw pixel add:
/// <code>
/// X = m_x0 - w/2 - (int)( (m_x1-m_x0+1) * (x*10+1024) * (-1/2048) )
/// Y = m_y0 - h/2 - (int)( (m_y1-m_y0+1) * (2047-(y*10+1024)) * (-1/2048) )
/// </code>
/// Constants read straight from the binary's .rdata: <c>0x79bac8</c> =
/// 10.0, <c>0x7aac78</c> = 1024.0, <c>0x7aac70</c> = -1/2048 (exactly
/// -0.00048828125), <c>0x7aac68</c> = 2047.0. The Y axis's FSUBR
/// (reversed subtract) is retail's north-up flip — Y increases upward
/// on the AC coordinate system but downward in screen pixels.
/// <c>w</c>/<c>h</c> are the icon's own <c>UIRegion::GetWidth</c>/
/// <c>GetHeight</c> (@0x0069efe0/@0x0069eff0), halved with INTEGER
/// (truncating) division to match retail's <c>cdq;sub;sar</c> idiom —
/// not float division, which would drift by half a pixel on
/// odd-sized icons. Golden case (marker area (6,8)-(247,258), 10x10
/// icon, position 0.0N/0.0E) reproduces exactly to (122,128) center.
/// </summary>
private void PlaceMarker(UiElement? icon, double x, double y)
{
if (icon is null) return;
(float left, float top) = ComputeMarkerPosition(
_markerX0, _markerX1, _markerY0, _markerY1,
(int)icon.Width, (int)icon.Height, x, y);
icon.Left = left;
icon.Top = top;
icon.Visible = true;
}
/// <summary>
/// The pure <c>PlaceMarkerOnMap</c> math, split out from <see cref="PlaceMarker"/>
/// so tests can assert byte-decoded GOLDEN PIXEL values directly against
/// the formula instead of round-tripping through the port's own output.
/// </summary>
internal static (float Left, float Top) ComputeMarkerPosition(
int markerX0, int markerX1, int markerY0, int markerY1,
int iconWidth, int iconHeight, double x, double y)
{
int halfWidth = iconWidth / 2;
int halfHeight = iconHeight / 2;
int extentX = markerX1 - markerX0 + 1;
int extentY = markerY1 - markerY0 + 1;
int xOffset = (int)(extentX * (x * 10.0 + 1024.0) * (-1.0 / 2048.0));
int yOffset = (int)(extentY * (2047.0 - (y * 10.0 + 1024.0)) * (-1.0 / 2048.0));
return (markerX0 - halfWidth - xOffset, markerY0 - halfHeight - yOffset);
}
private static ElementInfo? FindInfo(ElementInfo info, uint id)
{
if (info.Id == id) return info;
foreach (ElementInfo child in info.Children)
{
ElementInfo? found = FindInfo(child, id);
if (found is not null) return found;
}
return null;
}
}

View file

@ -186,9 +186,34 @@ public sealed class RetailTooltipPresenter : IDisposable
/// family resolves to). Extracted unchanged from the pre-#411-follow-on
/// <c>OnTooltipShow</c> body — same F4/F5/F8 fixes, same failure
/// handling.
///
/// <para>
/// Night-round review F5: the single-popup invariant (retail's own
/// single <c>m_pTooltipElement</c> slot) is now enforced HERE,
/// structurally, rather than relying on every caller to have already
/// cleared a stale popup before reaching this method. Both existing
/// callers already clear on their own early-return paths too (a hover
/// change that resolves to no valid tooltip text must still tear down
/// the PREVIOUS popup, which never reaches this method at all), so
/// those calls stay — this is a belt-and-braces guarantee, not a
/// replacement for them. It closes a real hole: <see cref="UpdateWorldHoverTooltip"/>'s
/// own clear is gated on <c>_worldTooltipShowing</c> (only true when the
/// WORLD path itself mounted the current popup) and its "a UI popup
/// cannot be showing here" comment assumed <see cref="_host"/>'s hover
/// query is null whenever that branch runs — an assumption that does
/// not hold the instant a modal dialog opens over a stationary cursor:
/// the UI dwell popup from <see cref="OnTooltipShow"/> stays mounted
/// (<c>_owner</c>/<c>_popupRoot</c> set, <c>_worldTooltipShowing</c>
/// still false) while the world path could independently find an
/// object and call this method, mounting a second popup on top. Now it
/// cannot: this call clears whatever is mounted, UI-owned or
/// world-owned, before either ever gets a chance to layer.
/// </para>
/// </summary>
private bool TryBuildAndMountPopup(uint rootElementId, uint layoutDid, string tooltipText)
{
RemovePopup();
ImportedLayout? layout;
try
{
@ -295,17 +320,39 @@ public sealed class RetailTooltipPresenter : IDisposable
// directly by gmGamePlayUI's own mode setup rather than from a
// walkable authored ElementDesc, so its own P0x47/P0x48 cannot be
// read from the DAT. This port therefore REUSES the item catalog's
// confirmed uniform popup-locator pair (WorldPopupRootElementId/
// WorldPopupLayoutDid below) — the SAME "generic runtime-text" skin
// every other game-code SetTooltip caller in this family draws from —
// as the best-evidenced inference for the unrecoverable constant.
// confirmed uniform popup-locator pair (SharedPopupSkinRootElementId/
// SharedPopupSkinLayoutDid below) — the SAME "generic runtime-text"
// skin every other game-code SetTooltip caller in this family draws
// from — as the best-evidenced inference for the unrecoverable
// constant.
/// <summary>Same popup skin every UIItem prototype resolves to
/// (<see cref="UiItemSlot"/>'s own <c>ItemTooltipRootElementId</c>) —
/// see this section's own doc note on why the exact value cannot be
/// read off an authored <c>UIElement_SmartBoxWrapper</c> ElementDesc.</summary>
private const uint WorldPopupRootElementId = 0x10000395u;
private const uint WorldPopupLayoutDid = 0x21000041u;
/// <summary>
/// The shared popup-skin locator pair every tooltip-bearing surface
/// that authors no locator of its own resolves to. Retail's shared
/// UIItem cell-template catalog (<c>ItemListCellTemplate.CatalogLayoutId</c>,
/// LayoutDesc <c>0x21000041</c>) authors the SAME
/// <c>P0x47=0x10000395</c>/<c>P0x48=0x21000041</c> pair on all 49 of
/// its standalone item-cell prototypes (live-DAT-probed 2026-08-16:
/// inventory's 32x32 cell, the toolbar's per-slot prototypes, the
/// container cell, every paperdoll/armor slot skin —
/// <c>TooltipLiveDatTests.PopupSkinRootIds</c>/
/// <c>UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator</c>) —
/// one of the four 30x30 popup skins this presenter mounts for every
/// authored tooltip-bearing element too.
/// </summary>
/// <remarks>
/// Night-round review F10: previously duplicated as three separate
/// private constants with three separate partial citations — this
/// class's own world-hover popup (below), <c>UiItemSlot</c>'s item-cell
/// popup, and <c>MapPageController</c>'s town-marker popup. All three
/// consumers now reference these SAME two constants; this is the ONE
/// citation. This class is the natural owner since it's the mount
/// point every one of the three consumers ultimately routes through
/// (<c>OnTooltipShow</c>/<c>UpdateWorldHoverTooltip</c> both call
/// <c>TryBuildAndMountPopup</c> with these values or a widget's own).
/// </remarks>
public const uint SharedPopupSkinRootElementId = 0x10000395u;
public const uint SharedPopupSkinLayoutDid = 0x21000041u;
private uint _worldHoverGuid;
private bool _worldTooltipShowing;
@ -353,12 +400,23 @@ public sealed class RetailTooltipPresenter : IDisposable
return; // no change -> RecvNotice_SmartBoxObjectFound never re-fires
_worldHoverGuid = found;
// #409 follow-on (2026-08-16 overnight hover/UI round, Batch A bug 1):
// every found-object edge — whether to a DIFFERENT object or to
// none at all — tears down whatever world popup is currently up
// FIRST, mirroring OnTooltipShow's own unconditional RemovePopup() at
// its top. The pre-fix code only cleared on the found==0u edge, so an
// A-found-B transition (walking past a run of NPCs/doors/lifestones
// with never a frame of "nothing found" between them) called
// TryBuildAndMountPopup again with the OLD popup still mounted as a
// child of _host — only the _popupRoot reference got overwritten, so
// every previous popup was orphaned in the tree and never removed.
// _popupRoot is a single field by design (retail's own single
// m_pTooltipElement slot); this restores that single-slot invariant.
if (_worldTooltipShowing)
RemovePopup();
if (found == 0u)
{
if (_worldTooltipShowing)
RemovePopup();
return;
}
if (WorldTooltipsEnabled?.Invoke() != true)
return;
@ -370,7 +428,7 @@ public sealed class RetailTooltipPresenter : IDisposable
// A UI-element popup cannot be showing here: UiRoot's own hover
// (queried above) is null whenever this branch runs, so its dwell
// timer never arms and OnTooltipShow never fires concurrently.
if (TryBuildAndMountPopup(WorldPopupRootElementId, WorldPopupLayoutDid, text))
if (TryBuildAndMountPopup(SharedPopupSkinRootElementId, SharedPopupSkinLayoutDid, text))
_worldTooltipShowing = true;
}

View file

@ -563,11 +563,145 @@ public sealed class SpellcastingUiController : IRetainedPanelController
private void OnSelectionChanged(SelectionTransition _) => UpdateCastAvailability();
/// <summary>
/// gmSpellcastingUI::UpdateCastButtonTooltip @ 0x004c6a30. Enabled and
/// TooltipText are retail's SAME state machine (SetState + SetTooltip
/// side by side throughout that function) — porting the tooltip text
/// without correcting Enabled to match would let the tooltip promise an
/// action the button doesn't actually allow (TS-85).
/// </summary>
private void UpdateCastAvailability()
=> _cast.Enabled = _endowmentSelected[_activeTab]
? _endowmentItemId != 0u
: _selected[_activeTab] is uint spellId
&& _casting.IsTargetReady(spellId);
{
if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u)
{
(bool enabled, string? tooltip) = ComputeEndowmentCastState();
_cast.Enabled = enabled;
_cast.TooltipText = tooltip;
return;
}
if (_selected[_activeTab] is uint spellId)
{
(bool enabled, string? tooltip) = ComputeSpellCastState(spellId);
_cast.Enabled = enabled;
_cast.TooltipText = tooltip;
return;
}
_cast.Enabled = false;
bool anyFavorites = false;
for (int tab = 0; tab < 8 && !anyFavorites; tab++)
anyFavorites = _spellbook.GetFavorites(tab).Count > 0;
// Verbatim literals: "Select a spell to cast" @ data_7b64ec,
// "You have no spells ready to cast" @ data_7b6520.
_cast.TooltipText = anyFavorites
? "Select a spell to cast"
: "You have no spells ready to cast";
}
/// <summary>
/// gmSpellcastingUI::UpdateCastButtonTooltip @ 0x004c6a30's endowment-item
/// branch (<c>m_endowmentItemID != 0</c>). Every literal below is directly
/// visible in the decomp (not the mislabeled-vtable-slot class the
/// plain-spell branch hits above): <c>"USE the %s"</c> @ data_7b64c0,
/// <c>"You must select a target for the %s"</c> @ data_7b6478,
/// <c>" on %s"</c> @ data_7b6464. <c>ItemUses::IsUseable_SelfTarget @
/// 0x004fcd30</c> is exactly <see cref="ItemUseability.AllowsSelfTarget"/>
/// (both test the target-mask Self bit after shifting the high word down
/// 16). NOT ported: the incompatible-target sub-state (<c>"You must select
/// an appropriate\ntarget for the %s"</c> @ data_7b6400), which retail
/// derives from <c>ItemHolder::TargetCompatibleWithObject @ 0x00587520</c>
/// — a ~400-line function with its own chat-message side effects, out of
/// scope for a tooltip batch. A present target is optimistically treated
/// as compatible here, same text as the confirmed-compatible case. See
/// TS-85.
/// </summary>
private (bool enabled, string? tooltip) ComputeEndowmentCastState()
{
ClientObject? endowment = _objects.Get(_endowmentItemId);
if (endowment is null)
return (false, null);
string composedName = ComposeEndowmentName(endowment);
if (ItemUseability.AllowsSelfTarget(endowment.Useability ?? 0u))
return (true, $"USE the {composedName}");
uint? targetId = _selection.SelectedObjectId;
if (targetId is null or 0u)
return (false, $"You must select a target for the {composedName}");
string targetName = _objects.Get(targetId.Value)?.GetAppropriateName() ?? composedName;
return (true, $"USE the {composedName} on {targetName}");
}
/// <summary>
/// Night-round review F4: the vararg to <c>"USE the %s"</c> (and both
/// select-target strings above) is NOT the bare item name — retail
/// builds <c>"%s (%hs)"</c> @0x7b64d8 (item name, spell name) once
/// at <c>@0x004c6bb6-ef</c> and reuses that composed string as the
/// shared operand for all three format strings (byte-confirmed: the
/// three sprintf call sites at <c>0x4c6c7f</c>/<c>0x4c6ca4</c>/
/// <c>0x4c6d46</c> all read the SAME <c>[esp+0x18]</c> slot). e.g.
/// "USE the Lightning Wand (Lightning Bolt VI)".
/// </summary>
private string ComposeEndowmentName(ClientObject endowment)
{
string itemName = endowment.GetAppropriateName();
return _spellbook.TryGetMetadata(_endowmentSpellId, out SpellMetadata spellMetadata)
? $"{itemName} ({spellMetadata.Name})"
: itemName;
}
/// <summary>
/// <c>gmSpellcastingUI::UpdateCastButtonTooltip @ 0x004c6a30</c>'s
/// plain-spell branch (<c>m_endowmentItemID == 0</c>, a spell is
/// highlighted in the open submenu). Night-round review F3 corrects
/// TS-85's "cannot be recovered" claim: the three format strings TS-85
/// took for gmNoticeHandler vtable-slot mislabels (a real BN artifact
/// class, but not what happened here) are recoverable literals once
/// the raw machine code is disassembled directly — the vtable-slot
/// names Binary Ninja printed for the <c>sprintf</c> calls were spurious.
/// Byte-confirmed pushes: <c>"CAST %hs"</c> @0x7b63a4 at both
/// <c>0x4c6f5d</c> (untargeted/self-cast, always enabled) and
/// <c>0x4c6ea4</c> (targeted+compatible, enabled, then <c>" on %s"</c>
/// @0x7b6464 appended with the target's name at <c>0x4c6ee8</c>);
/// <c>"You must select an appropriate target for %hs"</c> @0x7b6348 at
/// <c>0x4c6f18</c> (targeted+incompatible, stays disabled); <c>"You
/// must select a target for %hs"</c> @0x7b63b8 at <c>0x4c6e48</c> (no
/// target selected, stays disabled). <c>%hs</c> is the spell's own
/// name in every case (<c>CSpellBase::InqName</c>, the same call
/// (<c>0x5bbee0</c>) at all four sites) — no item/composed name
/// involved here, unlike the endowment branch above.
/// </summary>
private (bool enabled, string? tooltip) ComputeSpellCastState(uint spellId)
{
if (!_spellbook.TryGetMetadata(spellId, out SpellMetadata metadata))
return (false, null);
string spellName = metadata.Name;
SpellCastGate gate = _casting.EvaluateCastGate(spellId);
switch (gate)
{
case SpellCastGate.NoTargetNeeded:
return (true, $"CAST {spellName}");
case SpellCastGate.TargetCompatible:
{
uint? targetId = _selection.SelectedObjectId;
string? targetName = targetId is uint id and not 0u
? _objects.Get(id)?.GetAppropriateName()
: null;
return (true, targetName is null
? $"CAST {spellName}"
: $"CAST {spellName} on {targetName}");
}
case SpellCastGate.TargetIncompatible:
return (false, $"You must select an appropriate target for {spellName}");
case SpellCastGate.NoTargetSelected:
return (false, $"You must select a target for {spellName}");
default:
return (false, null);
}
}
private void ConfigureSpellName()
{

View file

@ -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;

View file

@ -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,141 @@ 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);
}
// m_pMap's two button-swallowed icon children (player/house markers)
// only exist as ElementInfos INSIDE rootInfo's own panel-slot resolve
// tree — a cold ImportInfos(hostLayout, iconId) re-import returns
// null on the live DAT (register row AD-108). MapPageController.Bind
// locates each icon's info under m_pMap and calls this seam to build
// it: the build half of RowTemplateResolver's shape, no import half.
// Monitor re-entrancy on DatLock is established for this mount path
// (Bind itself runs under the lock below, same as
// ResolveHotspotTemplate's own re-entrant take).
UiElement? BuildSwallowedIcon(ElementInfo iconInfo)
{
lock (_bindings.Assets.DatLock)
return LayoutImporter.Build(
iconInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont).Root;
}
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,
IconBuilder: BuildSwallowedIcon),
House: new Layout.HousePageController.Bindings(
Lines: mh.HouseLines ?? (static () => Array.Empty<string>()),
OnShown: mh.HouseShown,
// Same generic template resolver the Map tab's town
// hotspots use — see HousePageController.Bindings.
// TemplateResolver's own doc for why reusing it is correct.
TemplateResolver: ResolveHotspotTemplate));
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)

View file

@ -19,25 +19,23 @@ public class UiItemSlot : UiElement
/// live-DAT-probed 2026-08-16: every top-level catalog child (inventory's
/// 32x32 cell <c>0x1000033A</c>, the toolbar's per-slot prototypes
/// <c>0x1000043B</c>.., the container cell <c>0x1000033F</c>, and every
/// paperdoll/armor slot skin alike) resolves <c>P0x47=0x10000395</c> /
/// <c>P0x48=0x21000041</c> through catalog inheritance, matching one of
/// the four popup skins <see cref="Layout.RetailTooltipPresenter"/> already
/// mounts for every other tooltip-bearing element
/// (<c>Layout.TooltipLiveDatTests.PopupSkinRootIds</c>). Since
/// <see cref="UiItemSlot"/> cells are built programmatically (never through
/// <c>LayoutImporter.Build</c>), this port hardcodes the uniform pair here
/// rather than re-deriving it per instance — the same "exhaustive scan,
/// then hardcode" shape as <c>RetailCursorCatalog</c>'s five window-control
/// cursor DIDs and <c>ItemListCellTemplate.CatalogLayoutId</c> itself.
/// paperdoll/armor slot skin alike) resolves through catalog inheritance
/// to <see cref="Layout.RetailTooltipPresenter.SharedPopupSkinRootElementId"/>/
/// <see cref="Layout.RetailTooltipPresenter.SharedPopupSkinLayoutDid"/> —
/// see that class for the single canonical citation (night-round review
/// F10 consolidated what used to be three separately-cited copies of the
/// same pair into one). Since <see cref="UiItemSlot"/> cells are built
/// programmatically (never through <c>LayoutImporter.Build</c>), this
/// port hardcodes the uniform pair here rather than re-deriving it per
/// instance — the same "exhaustive scan, then hardcode" shape as
/// <c>RetailCursorCatalog</c>'s five window-control cursor DIDs and
/// <c>ItemListCellTemplate.CatalogLayoutId</c> itself.
/// </summary>
private const uint ItemTooltipRootElementId = 0x10000395u;
private const uint ItemTooltipLayoutDid = 0x21000041u;
public UiItemSlot()
{
ClickThrough = false;
AuthoredTooltipRootElementId = ItemTooltipRootElementId;
AuthoredTooltipLayoutDid = ItemTooltipLayoutDid;
AuthoredTooltipRootElementId = Layout.RetailTooltipPresenter.SharedPopupSkinRootElementId;
AuthoredTooltipLayoutDid = Layout.RetailTooltipPresenter.SharedPopupSkinLayoutDid;
}
public override bool ConsumesDatChildren => true;

View file

@ -152,6 +152,20 @@ public class UiClickablePanel : UiPanel
/// Ignored when <see cref="UseSelectionBars"/> is false.</summary>
public float SelectionBarHeight { get; set; } = 3f;
/// <summary>Settable tooltip, surfaced through the shared
/// <see cref="UiElement.GetTooltipText"/> hover pipeline (same pattern as
/// <see cref="UiButton.TooltipText"/> / <see cref="UiCatalogSlot"/>). TS-85's
/// character-panel gap: retail's <c>AttributeInfoRegion</c> /
/// <c>Attribute2ndInfoRegion</c> / <c>SkillInfoRegion</c> row constructors
/// (<c>UIElement::SetTooltip</c> at 0x004f1617 / 0x004f1777 / 0x004f222f) stamp
/// this once per row at construction — retail never updates it afterward, so a
/// plain settable string (not a live provider) matches.</summary>
public string? TooltipText { get; set; }
/// <inheritdoc />
public override string? GetTooltipText() =>
string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
public UiClickablePanel()
{
// Rows must receive pointer events — override the UiPanel default (ClickThrough=false,

View file

@ -177,6 +177,42 @@ public sealed class UiTemplateListBox : UiDatElement
Height = Height,
};
base.AddChild(_viewport);
// #412-class fix (2026-08-16, overnight hover/UI round, Batch A bug 2):
// #372's seed above only fixed the 0×0 collapse for a ListBox whose OWN
// size never changes after the viewport is created. It does NOT hold for
// the Options panel's real mount: this ListBox (0x10000200 etc.) is a
// DAT-imported element carrying its own retail four-edge UiLayoutPolicy
// (UIElement::UpdateForParentSizeChange @0x00462640), and a page
// controller's Bind (which lazily creates this viewport, calling
// AddItemFromTemplateList) runs BEFORE the tree's first real draw frame —
// i.e. before ANY ApplyAnchor pass has ever run. The Options tab-host's
// page slot (298×575 authored) is taller than its actual 300×362 mounted
// container, so on the FIRST draw frame the slot's LayoutPolicy shrinks
// it top-down (e.g. to ~298×337), and THIS ListBox — also LayoutPolicy-
// driven, recomputed fresh every call, no capture-staleness of its own —
// shrinks right behind it (e.g. to ~282×297) in the SAME frame, BEFORE
// its per-child loop ever reaches the viewport below it. The viewport
// above was seeded at BIND time against the ListBox's PRE-shrink size
// (276×560) but its own legacy Left|Top|Right|Bottom anchor baseline is
// only CAPTURED lazily, on ITS first ApplyAnchor call — which lands AFTER
// the ListBox has already shrunk in that same frame. That capture then
// measures a NEGATIVE bottom margin (parentH(297) - (0+560) = -263) which
// ComputeAnchoredRect's stretch math preserves forever (h = parentH - mB -
// mT = 297 - (-263) - 0 = 560): the viewport is permanently locked at its
// ORIGINAL oversized height, clipping its rows to a bound retail never
// actually gave it on screen. Every row past the real ~297px stays
// "visible" per LayoutScrollableChildren's cull test and paints straight
// through the footer and past the window's real bottom edge — the exact
// "dozens of rows below the window frame" symptom (#412-class report:
// Full Screen/Sync/Screen Brightness/Adaptive Degrade/quality dropdowns
// drawing outside the panel). Forcing the capture to happen NOW, while
// Width/Height still exactly equal the ListBox's CURRENT (pre-shrink, but
// zero-margin) size, makes the captured margins (0,0,0,0) instead of
// negative — ComputeAnchoredRect then tracks whatever height the ListBox
// ACTUALLY ends up at after its own LayoutPolicy runs, on every frame
// after this one, exactly like #372 intended.
_viewport.CaptureCurrentAnchorBaseline();
}
return _viewport;
}

View file

@ -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";
}

View file

@ -124,7 +124,14 @@ public static class GameEventWiring
Action<uint /*whoDeclined*/>? onTradeDecline = null,
Action<uint /*whoReset*/>? onTradeReset = null,
Action<GameEvents.TradeFailure>? onTradeFailure = null,
Action? onTradeClearAcceptance = null)
Action? onTradeClearAcceptance = null,
// House panel (Batch C, Map/House toolbar panel, 2026-08-17): the
// same Runtime-owned delegate-hole shape as trade above —
// RuntimeHouseState (or a lighter equivalent) is the consumer.
Action<GameEvents.HouseData>? onHouseData = null,
Action<uint /*weenieError*/>? onHouseStatus = null,
Action<uint /*rentTime*/>? onHouseUpdateRentTime = null,
Action<IReadOnlyList<GameEvents.HousePayment>>? onHouseUpdateRentPayment = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
@ -402,6 +409,43 @@ public static class GameEventWiring
onTradeClearAcceptance());
}
// ── House panel (0x02250x0228) ───────────────────────────
// Batch C (Map/House toolbar panel, 2026-08-17). gmHouseUI::
// PostInit registers all four; consumers are optional so every
// existing caller compiles unchanged.
if (onHouseData is not null)
{
registrar.Register(GameEventType.HouseData, e =>
{
var p = GameEvents.ParseHouseData(e.Payload.Span);
if (p is not null) onHouseData(p.Value);
});
}
if (onHouseStatus is not null)
{
registrar.Register(GameEventType.HouseStatus, e =>
{
var p = GameEvents.ParseHouseStatus(e.Payload.Span);
if (p is not null) onHouseStatus(p.Value);
});
}
if (onHouseUpdateRentTime is not null)
{
registrar.Register(GameEventType.UpdateRentTime, e =>
{
var p = GameEvents.ParseUpdateRentTime(e.Payload.Span);
if (p is not null) onHouseUpdateRentTime(p.Value);
});
}
if (onHouseUpdateRentPayment is not null)
{
registrar.Register(GameEventType.UpdateRentPayment, e =>
{
var p = GameEvents.ParseUpdateRentPayment(e.Payload.Span);
if (p is not null) onHouseUpdateRentPayment(p);
});
}
if (onConfirmationRequest is not null)
{
registrar.Register(GameEventType.CharacterConfirmationRequest, e =>

View file

@ -58,6 +58,17 @@ public static class ClientCommandRequests
public const uint AddPlayerPermissionOpcode = 0x0219u;
public const uint RemovePlayerPermissionOpcode = 0x021Au;
public const uint AbandonHouseOpcode = 0x021Fu;
// Batch C (Map/House toolbar panel, 2026-08-17): the query the House
// tab needs to populate. ACE GameActionHouseQuery.cs: [GameAction(
// GameActionType.HouseQuery)] (0x021E), Handle reads no payload and
// calls session.Player.HandleActionQueryHouse() — which replies with
// either GameEventHouseStatus (0x0226, no house owned) or
// GameEventHouseData (0x0225, house owned). No known retail client
// call site was found in this session's decomp reading (gmHouseUI::
// PostInit never sends it) — HousePageController fires it when the
// House tab becomes visible, an acdream convention, not a ported
// retail trigger.
public const uint HouseQueryOpcode = 0x021Eu;
// Named-retail anchors:
// CM_Character::Event_TeleToMarketplace @ 0x006A1C20
@ -282,6 +293,11 @@ public static class ClientCommandRequests
public static byte[] BuildAbandonHouse(uint sequence) =>
BuildParameterless(sequence, AbandonHouseOpcode);
// Queries the local player's house info (owned house data, or a
// no-house status) — GameActionHouseQuery.Handle: no payload read.
public static byte[] BuildHouseQuery(uint sequence) =>
BuildParameterless(sequence, HouseQueryOpcode);
private static byte[] BuildParameterless(uint sequence, uint opcode)
{
byte[] body = new byte[12];

View file

@ -1003,6 +1003,126 @@ public static class GameEvents
Guests: guests));
}
// ── House panel (Batch C, Map/House toolbar panel, 2026-08-17) ─────────
// gmHouseUI::PostInit @0x004a2710 registers notice handlers for wire
// opcodes 0x0225-0x0228; the recon doc (docs/research/2026-08-17-map-
// house-recon.md) is the SSOT for the retail-side call sites and the
// two ACE writer stubs (UpdateRentTime always writes 0u; UpdateRentPayment
// always writes an empty list — captured verbatim below, not guessed).
/// <summary>One house purchase/maintenance line item. ACE
/// HousePaymentExtensions.Write: Num(int) + Paid(int) + WeenieID(uint) +
/// Name(String16L) + PluralName(String16L).</summary>
public readonly record struct HousePayment(
int Num, int Paid, uint WeenieID, string Name, string PluralName);
/// <summary>0x0225 HouseData: the owned-house panel snapshot. ACE
/// HouseDataExtensions.Write: BuyTime(uint) + RentTime(uint) +
/// Type(uint HouseType enum) + MaintenanceFree(uint bool) +
/// Buy(List&lt;HousePayment&gt;) + Rent(List&lt;HousePayment&gt;) +
/// Position (the same Cell+Pos.XYZ+Rotation.WXYZ 32-byte shape
/// <see cref="CreateObject.ServerPosition"/> already parses
/// elsewhere).</summary>
public readonly record struct HouseData(
uint BuyTime,
uint RentTime,
uint Type,
bool MaintenanceFree,
IReadOnlyList<HousePayment> Buy,
IReadOnlyList<HousePayment> Rent,
CreateObject.ServerPosition Position);
public static HouseData? ParseHouseData(ReadOnlySpan<byte> payload)
{
try
{
int pos = 0;
if (payload.Length - pos < 16) return null;
uint buyTime = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint rentTime = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
bool maintenanceFree = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)) != 0; pos += 4;
List<HousePayment>? buy = ReadHousePaymentList(payload, ref pos);
if (buy is null) return null;
List<HousePayment>? rent = ReadHousePaymentList(payload, ref pos);
if (rent is null) return null;
if (payload.Length - pos < 32) return null;
var position = new CreateObject.ServerPosition(
LandblockId: BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos + 0)),
PositionX: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 4)),
PositionY: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 8)),
PositionZ: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 12)),
RotationW: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 16)),
RotationX: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 20)),
RotationY: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 24)),
RotationZ: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 28)));
return new HouseData(buyTime, rentTime, type, maintenanceFree, buy, rent, position);
}
catch { return null; }
}
private static List<HousePayment>? ReadHousePaymentList(ReadOnlySpan<byte> payload, ref int pos)
{
if (payload.Length - pos < 4) return null;
uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
var list = new List<HousePayment>((int)Math.Min(count, 4096));
for (uint i = 0; i < count; i++)
{
if (payload.Length - pos < 8) return null;
int num = BinaryPrimitives.ReadInt32LittleEndian(payload.Slice(pos)); pos += 4;
int paid = BinaryPrimitives.ReadInt32LittleEndian(payload.Slice(pos)); pos += 4;
if (payload.Length - pos < 4) return null;
uint weenieId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
string name = ReadString16L(payload, ref pos);
string pluralName = ReadString16L(payload, ref pos);
list.Add(new HousePayment(num, paid, weenieId, name, pluralName));
}
return list;
}
/// <summary>0x0226 HouseStatus: a single WeenieError u32 — retail's
/// <c>RecvNotice_FailedHouseTransaction</c> family (also the "no house
/// owned" reply to a HouseQuery — ACE Player_House.cs
/// HandleActionQueryHouse's <c>new GameEventHouseStatus(Session)</c>
/// defaults to <c>WeenieError.BadParam</c> (corrected 2026-08-17; an
/// earlier note here said <c>WeenieError.None</c>, which is not what
/// <c>GameEventHouseStatus</c>'s own constructor default reads). The
/// value is moot either way — decomp-confirmed retail's own
/// <c>gmHouseUI::Update(uint32_t)</c>/<c>gmMapUI::
/// RecvNotice_FailedHouseTransaction</c> never read this field
/// (<c>AcDream.Runtime.Gameplay.RuntimeHouseState.ApplyHouseStatus</c>
/// accepts and discards it for the same reason).</summary>
public static uint? ParseHouseStatus(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x0227 UpdateRentTime: a single uint (when the current
/// maintenance period began, Unix timestamp). ACE
/// GameEventHouseUpdateRentTime.cs is a STUB that always writes
/// <c>0u</c> — captured here for completeness, not exercised by any
/// live ACE install today.</summary>
public static uint? ParseUpdateRentTime(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x0228 UpdateRentPayment: a List&lt;HousePayment&gt; (the
/// rent items and how much of each has been paid this period). ACE
/// GameEventHouseUpdateRentPayment.cs is a STUB that always writes an
/// EMPTY list — captured here for completeness, not exercised by any
/// live ACE install today.</summary>
public static IReadOnlyList<HousePayment>? ParseUpdateRentPayment(ReadOnlySpan<byte> payload)
{
int pos = 0;
return ReadHousePaymentList(payload, ref pos);
}
// ── Shared string reader (matches LoginRequest.ReadString16L) ───────────
private static string ReadString16L(ReadOnlySpan<byte> source, ref int pos)

View file

@ -2517,6 +2517,15 @@ public sealed class WorldSession : IDisposable
SendGameAction(ClientCommandRequests.BuildMansionRecall(seq));
}
/// <summary>Query the local player's house info — either owned house
/// data (0x0225) or a no-house status (0x0226) comes back
/// (0x021E).</summary>
public void SendHouseQuery()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildHouseQuery(seq));
}
/// <summary>Query the local character's played time (0x01C2).</summary>
public void SendQueryAge()
{

View file

@ -1266,6 +1266,14 @@ internal sealed class HeadlessSessionHost : IDisposable
// RuntimeLiveEntitySessionController's onLoginCompleteSent
// doc for the other two.
_optionsSeeder?.NoteLoginCompleteSent();
// Night-round review F2: CM_House::Event_QueryHouse
// @0x006aaa00 tail-calls unconditionally from the end of
// CPlayerSystem::InitializePlayer — the same once-per-session
// function AttemptSendLoginCompleteNotification lives in
// (guarded by player_initialized) — right after that
// notification. This is the headless direct (non-portal)
// first-entry completion edge.
session.SendHouseQuery();
},
_acceptedPositionDrive);
_eventRoute = eventRoute;

View file

@ -109,6 +109,7 @@ internal enum GameRuntimeConstructionPoint
FellowshipCreated,
AllegianceCreated,
TradeCreated,
HouseCreated,
MovementCreated,
ActionsCreated,
EnvironmentCreated,
@ -127,6 +128,7 @@ internal sealed class GameRuntimeConstructionContext
public RuntimeFellowshipState? Fellowship { get; set; }
public RuntimeAllegianceState? Allegiance { get; set; }
public RuntimeTradeState? Trade { get; set; }
public RuntimeHouseState? House { get; set; }
public RuntimeLocalPlayerMovementState? Movement { get; set; }
public RuntimeActionState? Actions { get; set; }
public GameRuntimeEventHub? Events { get; set; }
@ -282,6 +284,17 @@ public sealed class GameRuntime
context,
faultInjection);
// House tab (Batch C, Map/House toolbar panel, 2026-08-17):
// deliberately minimal owner (ISSUES #413's own sizing note) —
// no live-object side effects, nothing to dispose, so no
// construction.Own() (unlike Trade above, which owns staged
// items' TradeState flags on live objects).
context.House = new RuntimeHouseState(context.EntityObjects.Objects);
Fault(
GameRuntimeConstructionPoint.HouseCreated,
context,
faultInjection);
context.Movement = new RuntimeLocalPlayerMovementState();
// Campaign CH slice CH2: local jump refusals (CommenceJump/
// DoJump's WeenieError family — research doc §4.2/§6.4) reach
@ -337,7 +350,8 @@ public sealed class GameRuntime
context.PlayerIdentity,
context.Fellowship,
context.Allegiance,
context.Trade);
context.Trade,
context.House);
context.Movement.AttachPhysicsPublication(
new RuntimeLocalPlayerPhysicsPublicationState(
@ -393,6 +407,7 @@ public sealed class GameRuntime
FellowshipOwner = context.Fellowship;
AllegianceOwner = context.Allegiance;
TradeOwner = context.Trade;
HouseOwner = context.House;
MovementOwner = context.Movement;
ActionOwner = context.Actions;
EnvironmentOwner = environment;
@ -501,6 +516,11 @@ public sealed class GameRuntime
/// <summary>Secure trade (2026-08-14): third sibling J-owner.</summary>
public RuntimeTradeState TradeOwner { get; }
/// <summary>Batch C (2026-08-17): House tab minimal owner — see
/// <see cref="RuntimeHouseState"/>'s own class doc for the sizing
/// rationale.</summary>
public RuntimeHouseState HouseOwner { get; }
public RuntimeActionState ActionOwner { get; }
public RuntimeLocalPlayerMovementState MovementOwner { get; }
internal RuntimeLocalPlayerPhysicsPublicationState

View file

@ -0,0 +1,217 @@
using System.Globalization;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Properties;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Canonical presentation-independent owner for the House tab of retail's
/// two-tab Map/House panel (<c>gmHouseUI</c>). Deliberately MINIMAL —
/// "houseless-status only" per ISSUES #413's own sizing note: a full
/// <c>RuntimeTradeState</c>-weight owner (construction-transaction
/// <c>Fault()</c> injection point, disposal ordering, convergence tracking)
/// is disproportionate for what this slice needs, since (unlike Trade) this
/// owner holds no live-object side effects and nothing that requires
/// disposal.
/// </summary>
/// <remarks>
/// <para>
/// <b>Retail behavior, exhaustively verified against the decomp before
/// writing this class (all seven line builders read, not just the one
/// ported here):</b> <c>gmHouseUI::PostInit @0x004a2710</c> never calls
/// <c>Update</c>/<c>DisplayHouseData</c> — the House ListBox
/// (<c>0x100001e6</c>) starts genuinely empty (live-DAT-confirmed,
/// <c>MapHousePanelSlotProbeTests</c>: <c>children=0</c>, and the whole
/// House page <c>0x100001F7</c> has NO other static content besides that
/// one empty ListBox). Content appears only after a server notice
/// (0x0225-0x0228) arrives and <c>Update</c>/<c>DisplayHouseData</c> runs
/// the seven <c>Display*</c> builders in order. SIX of them
/// (<c>DisplayBuyPayment</c>, <c>DisplayRentPayment</c>,
/// <c>DisplayBuyTime</c>, <c>DisplayRentTimes</c>, <c>DisplayLocation</c>,
/// <c>DisplayWarningText</c>) open with <c>if (this-&gt;m_pHouseData != 0)</c>
/// and emit NOTHING when houseless — those remain unported, ISSUES #413
/// item 3.
/// </para>
/// <para>
/// The SEVENTH, <c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>, does
/// NOT gate on <c>m_pHouseData</c> — it always runs, reading the LOCAL
/// PLAYER's own <c>PropertyInt.HousePurchaseTimestamp</c> (0xC7 = 199
/// decimal) via <c>CBaseQualities::InqInt</c> and
/// <c>HouseSystem::HasPurchaseWaitPeriodExpired</c>
/// (<c>@0x005bb1d0</c>: <c>(Timer::get_real_time() - timestamp) &gt;
/// 0x278d00</c>; <c>Timer::get_real_time = time(0)</c>, Unix epoch
/// seconds; <c>0x278d00</c> = 2,592,000 s = 30 days). For a fresh/houseless
/// character with no timestamp ever set (absent property reads as 0), this
/// is trivially true, taking the "expired" branch, which reads
/// <c>m_pHouseData == 0</c> (still houseless) and emits the ONE literal
/// string at <c>data_7ab7f0</c>: <b>"You may buy another house
/// immediately."</b> That is the exact, decomp-verified, single line of
/// content a houseless character's House tab shows once queried — this
/// class ports exactly that (and its owns-a-house sibling at
/// <c>data_7ab818</c>, unreachable by a fresh character but faithfully
/// ported alongside it). No other function, WeenieError-to-chat mapping,
/// or authored LayoutDesc content anywhere in the decomp/live DAT produces
/// a second line for the houseless case — a broader search for chat-scroll
/// strings mentioning house ownership found only unrelated, differently
/// worded command-error text (<c>"You do not own a house!"</c>,
/// WeenieError <c>0x45E</c>/<c>0x45F</c>, and <c>"You must own a house to
/// use this command."</c>, WeenieError <c>0x47F</c>) routed through the
/// GENERIC WeenieError chat dispatcher, never through <c>gmHouseUI</c>'s
/// own notice handlers (which discard the wire WeenieError entirely — see
/// <see cref="ApplyHouseStatus"/>).
/// </para>
/// <para>
/// The NOT-yet-expired branch of <c>DisplayPurchaseTimeText</c> is now
/// ported (night-round review F8) — its literals were NOT unrecoverable;
/// the "BN-truncated suffix" was another instance of Binary Ninja's
/// operator-overload plumbing obscuring plain pushed string constants (the
/// same artifact class TS-85/F3 hit). A direct capstone disassembly of the
/// raw bytes resolves all three pieces retail concatenates: prefix
/// <c>"You may buy another landscape house at "</c> @<c>0x7ab790</c>
/// (pushed <c>@0x004a3265</c>), the <c>strftime</c> format literal
/// <c>"%c"</c> @<c>0x7ab7ec</c> (pushed <c>@0x004a321d</c>) applied to
/// <c>localtime(timestamp + 0x278d00)</c> — the expiry moment, not "now" —
/// and suffix <c>". This restriction does not apply to apartments."</c>
/// @<c>0x7ab7b8</c> (pushed <c>@0x004a3235</c>). <c>strftime</c>'s
/// <c>"%c"</c> is the C runtime's locale-default full date+time
/// representation; this port's honest analogue is .NET's own
/// culture-default <c>DateTime.ToString()</c> (no explicit format) — NOT a
/// byte-identical reproduction of the CRT's locale table, since .NET and
/// the CRT do not share a formatting engine, but the same "whatever the
/// process's own locale says" intent. Register row IA-23
/// (<c>docs/architecture/retail-divergence-register.md</c>) records this
/// approximation.
/// </para>
/// </remarks>
public sealed class RuntimeHouseState
{
/// <summary><c>HouseSystem::HasPurchaseWaitPeriodExpired</c>'s
/// literal, <c>0x278d00</c> = 2,592,000 seconds = 30 days.</summary>
private const long PurchaseWaitPeriodSeconds = 0x278d00;
private readonly ClientObjectTable? _objects;
private readonly TimeProvider _timeProvider;
private readonly object _gate = new();
private bool _hasReceivedNotice;
private bool _ownsHouse;
private IReadOnlyList<string> _lines = Array.Empty<string>();
/// <summary>Borrows the canonical object table (optional for bare
/// fixtures) to read the local player's own
/// <c>PropertyInt.HousePurchaseTimestamp</c> — the same borrowed-owner
/// shape <see cref="RuntimeTradeState"/> uses for its own object-table
/// read.</summary>
public RuntimeHouseState(
ClientObjectTable? objects = null, TimeProvider? timeProvider = null)
{
_objects = objects;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>The House tab's exact ListBox content — empty until the
/// first server notice arrives, matching retail's own PostInit (never
/// calls Update/DisplayHouseData).</summary>
public IReadOnlyList<string> Lines
{
get { lock (_gate) return _lines; }
}
/// <summary>Whether any of the four House notices (0x0225-0x0228) has
/// arrived this session.</summary>
public bool HasReceivedNotice
{
get { lock (_gate) return _hasReceivedNotice; }
}
/// <summary>0x0225 HouseData — <c>RecvNotice_UpdateHouseData</c>
/// (owned-house case). Only <see cref="_ownsHouse"/> is consumed today;
/// the owned-house payload itself (buy/rent payments, times, location)
/// feeds ISSUES #413's remaining six builders, not yet ported.</summary>
public void ApplyHouseData(GameEvents.HouseData data, uint selfGuid)
{
lock (_gate)
{
_hasReceivedNotice = true;
_ownsHouse = true;
Recompute(selfGuid);
}
}
/// <summary>0x0226 HouseStatus — <c>RecvNotice_FailedHouseTransaction</c>
/// (the "no house owned" reply to a HouseQuery, per ACE's
/// <c>HandleActionQueryHouse</c>). <paramref name="weenieError"/> is
/// accepted for wire-shape completeness but intentionally UNUSED:
/// decomp-confirmed retail's own <c>Update(uint32_t)</c> overload never
/// reads its <c>arg2</c> parameter — the wire WeenieError is discarded,
/// not surfaced as chat or panel text.</summary>
public void ApplyHouseStatus(uint weenieError, uint selfGuid)
{
_ = weenieError;
lock (_gate)
{
_hasReceivedNotice = true;
_ownsHouse = false;
Recompute(selfGuid);
}
}
/// <summary>Generation reset — a fresh login must not show a previous
/// character's house-query result. Restores the exact pre-notice
/// "genuinely empty" state.</summary>
public void ResetSession()
{
lock (_gate)
{
_hasReceivedNotice = false;
_ownsHouse = false;
_lines = Array.Empty<string>();
}
}
/// <summary><c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>,
/// both branches now ported. Must hold <see cref="_gate"/>.</summary>
private void Recompute(uint selfGuid)
{
int timestamp = _objects?.Get(selfGuid)?.Properties
.GetInt((uint)PropertyInt.HousePurchaseTimestamp) ?? 0;
long nowEpochSeconds = _timeProvider.GetUtcNow().ToUnixTimeSeconds();
bool expired = (nowEpochSeconds - timestamp) > PurchaseWaitPeriodSeconds;
if (!expired)
{
// Not-yet-expired branch, byte-decoded (night-round review
// F8): retail computes the EXPIRY moment (timestamp + 30 days,
// @0x004a3212's `var_42c += 0x278d00`), formats it through
// `localtime` + `strftime("%c", ...)` (@0x004a322c), and
// concatenates prefix + date + suffix
// (@0x004a3265/@0x004a321d/@0x004a3235). .NET's
// culture-default DateTime.ToString() is the honest %c
// analogue (see the class doc's own note — not byte-identical
// to the CRT's locale table, same "process locale" intent).
// TimeProvider.LocalTimeZone (not the ambient system zone
// directly) keeps this deterministically testable while
// matching retail's own `localtime()` (process-local time) in
// production, where TimeProvider.System.LocalTimeZone IS
// TimeZoneInfo.Local.
DateTimeOffset expiryUtc = DateTimeOffset.FromUnixTimeSeconds(
timestamp + PurchaseWaitPeriodSeconds);
DateTime expiryLocal = TimeZoneInfo.ConvertTime(
expiryUtc, _timeProvider.LocalTimeZone).DateTime;
_lines = new[]
{
"You may buy another landscape house at "
+ expiryLocal.ToString(CultureInfo.CurrentCulture)
+ ". This restriction does not apply to apartments.",
};
return;
}
_lines = new[]
{
_ownsHouse
? "You may buy another house immediately after you abandon this one."
: "You may buy another house immediately.",
};
}
}

View file

@ -50,18 +50,34 @@ public sealed class RuntimeSpellCastState
public uint? LastRequestedTargetId { get; private set; }
public event Action? StateChanged;
public bool IsTargetReady(uint spellId)
public bool IsTargetReady(uint spellId) =>
EvaluateCastGate(spellId)
is SpellCastGate.NoTargetNeeded or SpellCastGate.TargetCompatible;
/// <summary>
/// <c>gmSpellcastingUI::UpdateCastButtonTooltip @0x004c6a30</c>'s
/// plain-spell branch (<c>m_endowmentItemID == 0</c>) gate, split out
/// (night-round review F3) so the tooltip presenter can distinguish
/// retail's four states rather than just the enabled/disabled boolean
/// <see cref="IsTargetReady"/> collapses them to. Byte-decoded call
/// sites: the untargeted/self-cast branch at <c>0x4c6f35</c>, the
/// targeted-and-compatible branch at <c>0x4c6e57</c>
/// (<c>ClientMagicSystem::ObjectCompatibleWithSpell @0x567c30</c>), and
/// the two disabled tails at <c>0x4c6f04</c> (incompatible) and
/// <c>0x4c6e2b</c> (nothing selected).
/// </summary>
public SpellCastGate EvaluateCastGate(uint spellId)
{
if (!_spellbook.Knows(spellId)
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
return false;
return SpellCastGate.Unknown;
if (spell.IsSelfTargeted || spell.IsUntargeted || spell.TargetMask == 0u)
return true;
return _selection.SelectedObjectId is uint target and not 0u
&& _operations.IsTargetCompatible(
target,
spell,
showMessage: false);
return SpellCastGate.NoTargetNeeded;
if (_selection.SelectedObjectId is not (uint target and not 0u))
return SpellCastGate.NoTargetSelected;
return _operations.IsTargetCompatible(target, spell, showMessage: false)
? SpellCastGate.TargetCompatible
: SpellCastGate.TargetIncompatible;
}
public CastRequestResult Cast(uint spellId)
@ -159,3 +175,27 @@ public enum CastRequestResult
MissingComponents,
Unavailable,
}
/// <summary>
/// The four retail cast-button states <c>gmSpellcastingUI::
/// UpdateCastButtonTooltip @0x004c6a30</c>'s plain-spell branch
/// distinguishes — see <see cref="RuntimeSpellCastState.EvaluateCastGate"/>.
/// </summary>
public enum SpellCastGate
{
/// <summary>Spell metadata is missing / not known.</summary>
Unknown,
/// <summary>Untargeted, self-targeted, or no target mask — always
/// castable. Retail: <c>"CAST %hs"</c> @0x7b63a4.</summary>
NoTargetNeeded,
/// <summary>A target is selected and compatible. Retail: <c>"CAST
/// %hs"</c> @0x7b63a4, then <c>" on %s"</c> @0x7b6464 appended with
/// the target's name.</summary>
TargetCompatible,
/// <summary>A target is selected but incompatible. Retail: <c>"You
/// must select an appropriate target for %hs"</c> @0x7b6348.</summary>
TargetIncompatible,
/// <summary>No target is selected. Retail: <c>"You must select a
/// target for %hs"</c> @0x7b63b8.</summary>
NoTargetSelected,
}

View file

@ -60,15 +60,24 @@ public enum RuntimeGenerationResetStage
/// beside its fellowship/allegiance precedents.
/// </summary>
Trade = 14,
BeginEntityRetirement = 15,
RetireEntities = 16,
DrainHostProjection = 17,
CompleteCanonicalEntities = 18,
CompleteHostProjection = 19,
ChatIdentity = 20,
PlayerSnapshots = 21,
PlayerIdentity = 22,
Complete = 23,
/// <summary>
/// Batch C (Map/House toolbar panel, 2026-08-17): the House tab's
/// query result is session-scoped like fellowship/allegiance/trade
/// above — a fresh login must not show a previous character's house
/// data. See <see cref="RuntimeHouseState"/>'s class doc for why this
/// owner is lighter-weight than its three siblings (no disposal, no
/// construction-transaction Fault() point).
/// </summary>
House = 15,
BeginEntityRetirement = 16,
RetireEntities = 17,
DrainHostProjection = 18,
CompleteCanonicalEntities = 19,
CompleteHostProjection = 20,
ChatIdentity = 21,
PlayerSnapshots = 22,
PlayerIdentity = 23,
Complete = 24,
}
public readonly record struct RuntimeGenerationResetSnapshot(
@ -119,6 +128,7 @@ public sealed class RuntimeGenerationReset
private readonly RuntimeFellowshipState _fellowship;
private readonly RuntimeAllegianceState _allegiance;
private readonly RuntimeTradeState _trade;
private readonly RuntimeHouseState _house;
private ResetState? _state;
private RuntimeGenerationToken _lastCompletedGeneration;
private bool _hasCompletedGeneration;
@ -136,7 +146,8 @@ public sealed class RuntimeGenerationReset
RuntimeLocalPlayerIdentityState identity,
RuntimeFellowshipState fellowship,
RuntimeAllegianceState allegiance,
RuntimeTradeState trade)
RuntimeTradeState trade,
RuntimeHouseState house)
{
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
_communication = communication
@ -157,6 +168,7 @@ public sealed class RuntimeGenerationReset
_allegiance = allegiance
?? throw new ArgumentNullException(nameof(allegiance));
_trade = trade ?? throw new ArgumentNullException(nameof(trade));
_house = house ?? throw new ArgumentNullException(nameof(house));
}
public RuntimeGenerationToken? ActiveRetiringGeneration =>
@ -333,6 +345,9 @@ public sealed class RuntimeGenerationReset
case RuntimeGenerationResetStage.Trade:
Advance(state, _trade.Clear);
break;
case RuntimeGenerationResetStage.House:
Advance(state, _house.ResetSession);
break;
case RuntimeGenerationResetStage.BeginEntityRetirement:
_ = _entityObjects.BeginSessionClear();
state.Retirements = _entityObjects

View file

@ -88,7 +88,11 @@ public sealed record LiveSocialSessionBindings(
RuntimeAllegianceState? Allegiance = null,
// Secure trade (2026-08-14): the third sibling J-owner, same
// trailing/optional compatibility convention.
RuntimeTradeState? Trade = null);
RuntimeTradeState? Trade = null,
// Batch C (Map/House toolbar panel, 2026-08-17): same trailing/optional
// compatibility convention — a minimal owner (RuntimeHouseState's own
// class doc), not a full sibling J-owner.
RuntimeHouseState? House = null);
/// <summary>
/// Owns every inbound subscription for one exact live session. Domain state
@ -322,6 +326,14 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
: null,
onTradeClearAcceptance: social.Trade is { } tradeClear
? tradeClear.ApplyClearAcceptance
: null,
// Batch C (Map/House toolbar panel, 2026-08-17): same
// conditional delegate-hole discipline as trade above.
onHouseData: social.House is { } houseData
? data => houseData.ApplyHouseData(data, inventory.PlayerGuid())
: null,
onHouseStatus: social.House is { } houseStatus
? weenieError => houseStatus.ApplyHouseStatus(weenieError, inventory.PlayerGuid())
: null));
ConstructionCheckpoint();

View file

@ -198,6 +198,14 @@ public sealed class RuntimeLiveEntitySessionController
// truthful terminal admission edge.
_initialLoginCompleteSent = true;
_session.SendGameAction(GameActionLoginComplete.Build());
// Night-round review F2: CM_House::Event_QueryHouse
// @0x006aaa00 tail-calls unconditionally from the end of
// CPlayerSystem::InitializePlayer — the same once-per-session
// function AttemptSendLoginCompleteNotification lives in
// (guarded by player_initialized) — right after that
// notification. This is the content-less direct host's own
// terminal admission edge, its equivalent moment.
_session.SendHouseQuery();
_onLoginCompleteSent?.Invoke();
}
}

View file

@ -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; }

View file

@ -74,6 +74,46 @@ public sealed class RetailSkillFormulaTests
Assert.Equal(uint.MaxValue, result);
}
/// <summary>
/// F13 (night-round review): <see cref="RetailSkillFormula.FormatFormula"/>
/// previously read the raw signed <c>int</c> fields directly instead of
/// the SAME unsigned reinterpretation <see cref="RetailSkillFormula.TryCalculate"/>
/// uses. A multiplier whose stored bit pattern has the high bit set
/// would read as a small negative number here (failing the
/// <c>&gt;= 1</c> hasAttr gate, or printing a negative multiplier) instead
/// of the huge unsigned value <c>TryCalculate</c> actually computes
/// with for the SAME formula.
/// </summary>
[Fact]
public void FormatFormula_MultiplierIsReinterpretedAsRetailUnsignedWord()
{
SkillFormula formula = Formula(w: 0, x: -1, y: 0, z: 1);
formula.Attribute1 = AttributeId.Strength;
string? text = RetailSkillFormula.FormatFormula(formula);
Assert.NotNull(text);
Assert.Contains(uint.MaxValue.ToString(), text);
Assert.DoesNotContain("-1", text);
}
/// <summary>Same reinterpretation, but for the divisor and additive-bonus
/// suffixes rather than the multiplier — both must read unsigned too.</summary>
[Fact]
public void FormatFormula_DivisorAndAdditiveBonusAreReinterpretedAsRetailUnsignedWords()
{
SkillFormula formula = Formula(w: -1, x: 1, y: 0, z: unchecked((uint)-2));
formula.Attribute1 = AttributeId.Strength;
string? text = RetailSkillFormula.FormatFormula(formula);
Assert.NotNull(text);
Assert.Contains($"/ {unchecked((uint)-2)}", text);
Assert.Contains($"+{uint.MaxValue}", text);
Assert.DoesNotContain("-1", text);
Assert.DoesNotContain("-2", text);
}
[Fact]
public void LiveResolverLooksUpTheDatFormulaAndTreatsMissingAttributesAsZero()
{

View file

@ -1108,4 +1108,119 @@ public sealed class ConfigOptionsPageControllerTests
+ $"built widget rendered {actual.Value}.");
}
}
// ── #412-class regression: Config tab content escaping the window frame ──
//
// 2026-08-16/17 overnight hover/UI round, Batch A bug 2. The user's
// screenshot showed the Config tab's Apply/Reset/Defaults footer sitting
// mid-panel with further rows (Full Screen, Sync With Refresh Rate,
// Screen Brightness, Adaptive Degrade, the quality dropdowns...) drawing
// BELOW the window's bottom edge, outside the panel frame. Live-DAT
// measured root cause: the tab host's authored page slot (0x10000213,
// 298x575) is taller than its actual mounted container (the merged
// 0x1000018D root, authored 300x362 — retail's own
// UIElement::UpdateForParentSizeChange @0x00462640 four-edge policy
// shrinks it correctly on the first ApplyAnchor pass). The Config ListBox
// (0x10000200, 276x560) shrinks right behind it via the SAME per-element
// UiLayoutPolicy. But UiTemplateListBox.Viewport (the UiScrollablePanel
// that actually hosts + clips every row) is a programmatic C# element
// seeded at Bind time — BEFORE any ApplyAnchor pass has ever run — with
// the ListBox's THEN-current (pre-shrink) 276x560 size. Its own legacy
// Left|Top|Right|Bottom anchor baseline is captured lazily, on its first
// ApplyAnchor call, which lands AFTER the ListBox has already shrunk in
// that same frame — producing a negative captured bottom margin that
// ComputeAnchoredRect's stretch math preserves forever: the viewport
// stayed locked at its original 560px height, well past the real ~297px
// available, so rows drew (and were culled) against a bound retail never
// actually gave the window on screen. Fixed in UiTemplateListBox.Viewport
// by forcing the capture immediately after seeding, while the viewport's
// own Width/Height still exactly equal a zero-margin baseline.
[Fact]
public void ConfigSlot_MatchesItsAuthoredOversizedDesign_BeforeAnyLayoutPass()
{
// Pins the LIVE-DAT-measured authored facts this whole bug turns on:
// the tab host's merged root is the SLOT's own (cropped) 300x362
// extent, but the Config page slot underneath keeps ITS OWN larger
// authored design geometry (298x575, drawn against a 300x600 design
// canvas) until a layout pass actually reflows it.
(OptionsPanelController controller, _, bool bound) = BindReal();
Assert.True(bound);
Assert.Equal(300f, controller.TabPanel.Width);
Assert.Equal(362f, controller.TabPanel.Height);
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
Assert.Equal(575f, configSlot.Height);
Assert.NotNull(configSlot.LayoutPolicy);
}
[Fact]
public void ConfigTab_ContentFitsInsideItsMountedWindow_AfterOneDrawFramesLayoutPass()
{
(OptionsPanelController controller, _, bool bound) = BindReal();
Assert.True(bound);
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId));
UiElement viewport = Assert.Single(listBox.Children);
// Drive the SAME top-down ApplyAnchor walk DrawSelfAndChildren runs
// every real frame — parent before children, all the way down —
// TWICE, to prove the result is a stable steady state and not an
// artifact of a single simulated pass.
for (int frame = 0; frame < 2; frame++)
ApplyAnchorRecursive(controller.TabPanel);
// The viewport must track the REAL (shrunk) ListBox extent, not stay
// locked at its original oversized 560px design height.
Assert.True(
viewport.Height <= listBox.Height + 0.5f,
$"viewport height {viewport.Height} exceeds its ListBox's actual "
+ $"height {listBox.Height} — rows will draw/cull past where "
+ "the window actually is (the #412-class bug).");
// Nothing in the Config page may extend past the slot's own bottom
// edge, and the slot itself may not extend past the mounted window's
// own bottom edge — the exact "content escapes the window frame"
// symptom the user's screenshot showed.
float slotBottom = configSlot.Top + configSlot.Height;
Assert.True(
slotBottom <= controller.TabPanel.Height + 0.5f,
$"Config slot bottom {slotBottom} exceeds the mounted window's "
+ $"own height {controller.TabPanel.Height}.");
foreach (uint footerId in new[]
{
ConfigOptionsPageController_ApplyButtonId,
ConfigOptionsPageController_ResetButtonId,
ConfigOptionsPageController_DefaultsButtonId,
})
{
UiElement? btn = UiElement.FindDescendant(configSlot, footerId);
Assert.NotNull(btn);
float bottom = btn!.Top + btn.Height;
Assert.True(
bottom <= slotBottom + 0.5f,
$"footer 0x{footerId:X8} bottom {bottom} exceeds the Config "
+ $"slot's own bottom {slotBottom}.");
}
}
// Apply/Reset/Defaults element ids — ConfigOptionsPageController's own
// constants of the same name are private; mirrored here rather than
// widening that class's surface just for this test.
private const uint ConfigOptionsPageController_ApplyButtonId = 0x100001FCu;
private const uint ConfigOptionsPageController_ResetButtonId = 0x100001FDu;
private const uint ConfigOptionsPageController_DefaultsButtonId = 0x100001FEu;
private static void ApplyAnchorRecursive(UiElement e)
{
foreach (UiElement child in e.Children)
{
child.ApplyAnchor(e.Width, e.Height);
ApplyAnchorRecursive(child);
}
}
}

View file

@ -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>

View file

@ -0,0 +1,249 @@
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 the town-hotspot template. Returns a
/// <see cref="UiButton"/> — matching the live template's own authored
/// Type 1 (<c>MapHousePanelSlotProbeTests</c>: "hotspot template
/// type=1") — so <see cref="MapPageController.BuildTownMarkers"/>'s
/// <c>marker is UiButton</c> tooltip-text branch is actually exercised
/// by these tests. 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 markers back by id after the
/// fact need it too.</summary>
private static UiElement? FakeHotspotTemplate(uint layoutId, uint elementId)
=> new UiButton(new ElementInfo(), static _ => (0u, 0, 0))
{
Width = 10f,
Height = 10f,
DatElementId = elementId,
};
/// <summary>The <see cref="MapPageController.Bindings.IconBuilder"/>
/// seam: builds m_pMap's two button-swallowed icon children from their
/// OWN <see cref="ElementInfo"/>s inside the panel-slot resolve tree
/// (register row AD-108 — a standalone re-import cannot find them on
/// the live DAT, so the icons are found under the already-resolved
/// <c>pageInfo</c> and built through this seam instead). Mirrors
/// production's <c>LayoutImporter.Build(info, ...).Root</c>, which sets
/// <c>DatElementId</c> from the info's own id.</summary>
private static UiElement? FakeIconBuilder(ElementInfo info)
=> new UiButton(new ElementInfo(), static _ => (0u, 0, 0))
{
Width = 10f,
Height = 10f,
DatElementId = info.Id,
};
/// <summary>The House ListBox's own row template resolves to a
/// <see cref="UiText"/> in the live DAT (<c>MapHousePanelSlotProbeTests</c>:
/// "row template type=12" — <c>UIElement_Text</c>), unlike the Map tab's
/// UiButton hotspots above — a fresh instance per call, matching
/// production's real resolver.</summary>
private static UiElement? FakeHouseRowTemplate(uint layoutId, uint elementId)
=> new UiText { Width = 280f, Height = 28f };
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,
IconBuilder: FakeIconBuilder),
House: new HousePageController.Bindings(
Lines: houseLines ?? (static () => Array.Empty<string>()),
OnShown: () => calls.Add("house-shown"),
TemplateResolver: FakeHouseRowTemplate));
}
[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 (found under the
// panel-slot resolve tree and rebuilt via Bindings.IconBuilder — 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));
// Runtime tooltip text (UiButton.TooltipText, backing
// GetTooltipText()) — the retail SetTooltip/m_TTText mechanism, NOT
// the DAT-authored AuthoredTooltipText path. The popup-skin locator
// is unconditionally required even on the runtime-text path (see
// RetailTooltipPresenter.SharedPopupSkinRootElementId's doc).
Assert.All(townMarkers, c => Assert.NotEqual(0u, c.AuthoredTooltipRootElementId));
Assert.All(townMarkers, c => Assert.NotEqual(0u, c.AuthoredTooltipLayoutDid));
Assert.All(townMarkers, c => Assert.IsType<UiButton>(c));
Assert.All(townMarkers, c => Assert.False(string.IsNullOrEmpty(((UiButton)c).TooltipText)));
Assert.Contains(townMarkers, c => ((UiButton)c).TooltipText == "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);
}
/// <summary>
/// Batch C House-ownership-text closer (2026-08-17): the ONE
/// decomp-verified <c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>
/// line a houseless character's HouseQuery response renders — proves
/// <see cref="HousePageController"/>'s revision-gated
/// <see cref="MapHousePanelController.Tick"/> poll actually rebuilds the
/// authored row template with real text end-to-end, the same way
/// <see cref="RuntimeHouseStateTests"/> proves the text composition in
/// isolation.
/// </summary>
[Fact]
public void Tick_RendersHouseLinesIntoTheAuthoredRowTemplate()
{
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
string[] lines = ["You may buy another house immediately."];
MapHousePanelController? controller = MapHousePanelController.Bind(
rootInfo, layout, MakeCallbacks(houseLines: () => lines));
Assert.NotNull(controller);
controller!.Tick(0.016);
UiElement? box = UiElement.FindDescendant(controller.Root, HousePageController.TextBoxId);
var listBox = Assert.IsType<UiTemplateListBox>(box);
// Rows land in the ListBox's internal scrollable viewport (AddChild
// there, not directly on the ListBox itself — UiTemplateListBox's
// own #372/#412 dormancy machinery), exposed to tests via
// ViewportForTest.
UiScrollablePanel viewport = Assert.IsType<UiScrollablePanel>(
listBox.ViewportForTest);
Assert.Single(viewport.Children);
var row = Assert.IsType<UiText>(viewport.Children[0]);
Assert.Equal(
"You may buy another house immediately.",
Assert.Single(row.LinesProvider()).Text);
}
}

View file

@ -0,0 +1,157 @@
using System.IO;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Net.Messages;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Live-DAT mount pin for the Map tab's player/house marker icons — THE test
/// that would have caught the Batch C icon-resolution gap (register row
/// AD-108) at commit time.
///
/// <para>
/// The fixture-based <see cref="MapHousePanelControllerTests"/> could not
/// catch it: their fake resolvers answer ANY id, so the production resolve
/// mechanism itself was never exercised against real data. This test
/// reproduces <see cref="RetailUiRuntime.MountMapHousePanel"/>'s exact
/// recipe against the INSTALLED DATs (sprite/font resolution stubbed —
/// structure only, same as every committed-fixture build): the panel-slot
/// <c>LayoutImporter.ImportInfos(dats, hostLayoutId, slotElementId)</c>
/// import, <see cref="LayoutImporter.Build"/>, real
/// import-then-build hotspot/template resolution, and the
/// <see cref="MapPageController.Bindings.IconBuilder"/> build seam —
/// then asserts the two icons actually materialize as built elements.
/// </para>
///
/// <para>
/// Gated like every other installed-DAT family here:
/// <c>[InstalledDatFact]</c>, opt in with
/// <c>ACDREAM_PROBE_LIVE_MOUNT=1</c> (the App suite's live-DAT baseline
/// mode); <c>ACDREAM_DAT_DIR</c> overrides the ordinary
/// Documents/Asheron's Call location.
/// </para>
/// </summary>
public sealed class MapHousePanelLiveDatMountTests
{
private static string DatDirectory =>
Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
[InstalledDatFact]
public void MountRecipe_ResolvesPlayerAndHouseIcons_UnderTheMapWidget()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
// ── AD-108's structural facts, pinned ────────────────────────────
// 1) A COLD standalone re-import starting from the icon's own id
// returns null: the raw LayoutDesc Elements-table walk never
// reaches m_pMap's nested children. This is WHY the IconBuilder
// seam exists — if a future DAT regeneration makes this resolve,
// this pin flags that the seam could be revisited.
Assert.Null(LayoutImporter.ImportInfos(
dats,
MapHousePanelController.HostLayoutId,
MapPageController.PlayerIconId));
Assert.Null(LayoutImporter.ImportInfos(
dats,
MapHousePanelController.HostLayoutId,
MapPageController.HouseIconId));
// 2) The full panel-slot resolve — what MountMapHousePanel actually
// imports — DOES materialize both icons, nested under m_pMap.
ElementInfo? rootInfo = LayoutImporter.ImportInfos(
dats,
MapHousePanelController.HostLayoutId,
MapHousePanelController.SlotElementId);
Assert.NotNull(rootInfo);
// ── The production mount recipe (sprites/fonts stubbed) ──────────
ImportedLayout layout = LayoutImporter.Build(rootInfo!, static _ => (0u, 0, 0), null);
UiElement? ResolveTemplate(uint layoutId, uint elementId)
{
ElementInfo? info = LayoutImporter.ImportInfos(dats, layoutId, elementId);
return info is null
? null
: LayoutImporter.Build(info, static _ => (0u, 0, 0), null).Root;
}
UiElement? BuildIcon(ElementInfo info)
=> LayoutImporter.Build(info, static _ => (0u, 0, 0), null).Root;
// Mutable cell: Bind sees "no position yet" (0 — the real mount-time
// state, the panel mounts before the session enters world), then the
// player lands outdoors and the 5 s cadence re-refreshes.
uint playerCell = 0u;
var callbacks = new MapHousePanelController.Callbacks(
Toggle: static () => { },
Map: new MapPageController.Bindings(
CurrentCalendar: static () => default,
PlayerCellId: () => playerCell,
HousePosition: static () => (CreateObject.ServerPosition?)null,
TemplateResolver: ResolveTemplate,
IconBuilder: BuildIcon),
House: new HousePageController.Bindings(
Lines: static () => Array.Empty<string>(),
TemplateResolver: ResolveTemplate));
MapHousePanelController? controller =
MapHousePanelController.Bind(rootInfo!, layout, callbacks);
Assert.NotNull(controller);
// ── Pin 1: both icons resolve as BUILT elements ──────────────────
UiElement? map = UiElement.FindDescendant(
controller!.Root, MapPageController.MapWidgetId);
Assert.NotNull(map);
UiElement? playerIcon = UiElement.FindDescendant(
controller.Root, MapPageController.PlayerIconId);
UiElement? houseIcon = UiElement.FindDescendant(
controller.Root, MapPageController.HouseIconId);
Assert.NotNull(playerIcon);
Assert.NotNull(houseIcon);
// Attached directly under m_pMap (PlaceMarker positions them in its
// local space), with real authored extents — PlaceMarker's centering
// divides the icon's own Width/Height, so a zero-sized build would
// silently mis-center every marker.
Assert.Same(map, playerIcon!.Parent);
Assert.Same(map, houseIcon!.Parent);
Assert.True(playerIcon.Width > 0 && playerIcon.Height > 0,
$"player icon built with degenerate extent {playerIcon.Width}x{playerIcon.Height}");
Assert.True(houseIcon.Width > 0 && houseIcon.Height > 0,
$"house icon built with degenerate extent {houseIcon.Width}x{houseIcon.Height}");
// ── Pin 2: PlaceMarker's writes survive the per-frame layout pass ─
// The F1 live finding's second half: the client re-runs the authored
// layout pass (parent → child.ApplyAnchor) every frame. Pre-fix the
// icon's compatibility anchor captured the authored (0,0) rect while
// the panel sat indoors/hidden, then re-asserted it every frame —
// a visible ring pinned to m_pMap's top-left corner regardless of
// the player's position. Reproduce that exact frame order here.
playerIcon.ApplyAnchor(map!.Width, map.Height); // frame while cell unknown
houseIcon.ApplyAnchor(map.Width, map.Height);
Assert.False(playerIcon.Visible);
playerCell = 0x11CE0001u; // Arwic (independently pinned:
// display coords -88.3 / 62.9)
controller.Tick(MapPageController.RefreshIntervalSeconds + 0.01);
Assert.True(playerIcon.Visible);
(float expectedLeft, float expectedTop) = MapPageController.ComputeMarkerPosition(
markerX0: 6, markerX1: 247, markerY0: 8, markerY1: 258,
(int)playerIcon.Width, (int)playerIcon.Height, -88.30000000000001, 62.900000000000006);
Assert.Equal(expectedLeft, playerIcon.Left);
Assert.Equal(expectedTop, playerIcon.Top);
playerIcon.ApplyAnchor(map.Width, map.Height); // the next frame's pass
Assert.Equal(expectedLeft, playerIcon.Left);
Assert.Equal(expectedTop, playerIcon.Top);
}
}

View file

@ -0,0 +1,215 @@
using System.IO;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Batch C (overnight hover/UI round, Map/House toolbar panel) discovery
/// probe. Desk research (docs/research/2026-08-17-map-house-recon.md,
/// itself built on the FA campaign's already-DAT-verified 16-slot
/// <c>gmPanelUI::SetupChildren</c> dump) already resolved host
/// <c>0x2100006E</c> slot <c>0x1000018C</c> / <c>RetailPanelCatalog</c> id
/// <b>16</b> as the Map/House tab host, and toolbar button
/// <c>0x1000019A</c> as the (currently ghosted) button that opens it. This
/// probe RE-CONFIRMS both against the live installed DATs rather than
/// trusting the committed fixture, and additionally reads the facts the
/// decomp explains the MEANING of but not the VALUE of: the tab table, the
/// <c>m_pMap</c> marker-area rect + hotspot template attrs (<c>0x47</c>/
/// <c>0x48</c>), and the House ListBox's authored default content.
///
/// <para>
/// Kept as a permanent env-gated pin (like <c>FaPanelSlotProbeTests</c>) —
/// it documents authored truth for future sessions, not a one-shot
/// throwaway.
/// </para>
/// </summary>
public sealed class MapHousePanelSlotProbeTests
{
private const uint HostLayoutId = 0x2100006Eu;
private const uint SlotElementId = 0x1000018Cu;
private const uint ToolbarLayoutId = 0x21000016u;
private const uint MapHouseToolbarButtonId = 0x1000019Au;
// gmMapUI PostInit signature children (pc:171993).
private const uint MapDateTimeTextId = 0x100001EBu;
private const uint MapWidgetId = 0x100001ECu;
private const uint MapPlayerIconId = 0x100001EDu;
private const uint MapHouseIconId = 0x100001EEu;
private const uint MapCoordinateTextId = 0x100001EFu;
// gmHouseUI PostInit signature child (pc:172581).
private const uint HouseTextBoxId = 0x100001E6u;
[Fact]
public void ProbeMapHousePanelSlot()
{
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1")
return;
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
using var dats = new DatCollection(datDir, DatAccessType.Read);
// 1) The slot itself — panel id, type, tab table.
ElementInfo? slot = LayoutImporter.ImportInfos(dats, HostLayoutId, SlotElementId);
if (slot is null)
{
Console.WriteLine($"[maphouse] slot 0x{SlotElementId:X8} -> IMPORT NULL");
return;
}
string panelId = slot.TryGetEffectiveProperty(0x10000029u, out var p)
? $"{p.UnsignedValue} (kind={p.Kind})"
: "ABSENT";
Console.WriteLine(
$"[maphouse] slot 0x{SlotElementId:X8} panelId={panelId} type={slot.Type} "
+ $"({slot.X},{slot.Y} {slot.Width}x{slot.Height}) children={slot.Children.Count} "
+ $"tabTableCount={slot.TabTable.Count}");
foreach (var tab in slot.TabTable)
Console.WriteLine(
$"[maphouse] tab button=0x{tab.ButtonElementId:X8} page=0x{tab.PageElementId:X8} "
+ $"default={tab.IsDefault}");
bool hasMapSignature = FindInfo(slot, MapDateTimeTextId) || FindInfo(slot, MapWidgetId);
bool hasHouseSignature = FindInfo(slot, HouseTextBoxId);
Console.WriteLine(
$"[maphouse] hasMapSignature={hasMapSignature} hasHouseSignature={hasHouseSignature}");
// Two levels of children so we can see the tab pages' own shape even
// if the recovered signature ids above are absent from this install.
foreach (ElementInfo c in slot.Children)
{
Console.WriteLine(
$"[maphouse] child 0x{c.Id:X8} type={c.Type} ({c.X},{c.Y} {c.Width}x{c.Height}) "
+ $"kids={c.Children.Count}");
foreach (ElementInfo g in c.Children)
Console.WriteLine(
$"[maphouse] g 0x{g.Id:X8} type={g.Type} ({g.X},{g.Y} {g.Width}x{g.Height}) "
+ $"kids={g.Children.Count}");
}
// 2) The toolbar button that should open panel 16.
ElementInfo? button = LayoutImporter.ImportInfos(dats, ToolbarLayoutId, MapHouseToolbarButtonId);
string buttonPanelId = button is not null && button.TryGetEffectiveProperty(0x10000029u, out var bp)
? $"{bp.UnsignedValue} (kind={bp.Kind})"
: "ABSENT/NULL";
Console.WriteLine($"[maphouse] toolbar button 0x{MapHouseToolbarButtonId:X8} panelId={buttonPanelId}");
// 3) m_pMap's own marker-area rect + hotspot template attrs.
ElementInfo? map = FindDescendant(slot, MapWidgetId);
if (map is null)
{
Console.WriteLine($"[maphouse] m_pMap 0x{MapWidgetId:X8} NOT FOUND under slot");
}
else
{
string x0 = map.TryGetEffectiveProperty(0x1000004Eu, out var vx0) ? vx0.IntegerValue.ToString() : "ABSENT";
string x1 = map.TryGetEffectiveProperty(0x1000004Fu, out var vx1) ? vx1.IntegerValue.ToString() : "ABSENT";
string y0 = map.TryGetEffectiveProperty(0x10000050u, out var vy0) ? vy0.IntegerValue.ToString() : "ABSENT";
string y1 = map.TryGetEffectiveProperty(0x10000051u, out var vy1) ? vy1.IntegerValue.ToString() : "ABSENT";
bool hasTemplateElement = map.TryGetEffectiveProperty(0x47u, out var te);
bool hasTemplateLayoutDid = map.TryGetEffectiveProperty(0x48u, out var tl);
string templateElement = hasTemplateElement
? $"0x{te.UnsignedValue:X8} (kind={te.Kind})" : "ABSENT";
string templateLayoutDid = hasTemplateLayoutDid
? $"0x{tl.UnsignedValue:X8} (kind={tl.Kind})" : "ABSENT";
Console.WriteLine(
$"[maphouse] m_pMap 0x{MapWidgetId:X8} markerArea=({x0},{y0})-({x1},{y1}) "
+ $"templateElement(attr 0x47)={templateElement} templateLayoutDid(attr 0x48)={templateLayoutDid} "
+ $"type={map.Type} children={map.Children.Count}");
// If both resolved, try importing the actual hotspot template so we
// know what widget class AddMapNote instantiates per town.
if (hasTemplateElement && hasTemplateLayoutDid && tl.UnsignedValue != 0)
{
ElementInfo? template = LayoutImporter.ImportInfos(dats, (uint)tl.UnsignedValue, (uint)te.UnsignedValue);
Console.WriteLine(template is null
? "[maphouse] hotspot template IMPORT NULL"
: $"[maphouse] hotspot template type={template.Type} "
+ $"({template.Width}x{template.Height}) states={template.States.Count} "
+ $"stateMedia={template.StateMedia.Count}");
}
}
ElementInfo? playerIcon = FindDescendant(slot, MapPlayerIconId);
ElementInfo? houseIcon = FindDescendant(slot, MapHouseIconId);
Console.WriteLine(
$"[maphouse] playerIcon found={playerIcon is not null} type={playerIcon?.Type} "
+ $"stateMedia={playerIcon?.StateMedia.Count}");
Console.WriteLine(
$"[maphouse] houseIcon found={houseIcon is not null} type={houseIcon?.Type} "
+ $"stateMedia={houseIcon?.StateMedia.Count}");
// 4) House ListBox default content — dump its own children/text so we
// know whether "You do not currently own a house..." is authored
// directly in the layout or synthesized at runtime.
ElementInfo? houseBox = FindDescendant(slot, HouseTextBoxId);
if (houseBox is null)
{
Console.WriteLine($"[maphouse] house listbox 0x{HouseTextBoxId:X8} NOT FOUND under slot");
}
else
{
Console.WriteLine(
$"[maphouse] house listbox 0x{HouseTextBoxId:X8} type={houseBox.Type} "
+ $"children={houseBox.Children.Count} templateListCount={houseBox.TemplateList.Count} "
+ $"scrollbar=0x{houseBox.ScrollbarElementId:X8}");
foreach (var t in houseBox.TemplateList)
{
Console.WriteLine(
$"[maphouse] template layoutId=0x{t.TemplateLayoutId:X8} elementId=0x{t.TemplateElementId:X8}");
ElementInfo? rowTemplate = LayoutImporter.ImportInfos(dats, t.TemplateLayoutId, t.TemplateElementId);
if (rowTemplate is null)
{
Console.WriteLine("[maphouse] row template IMPORT NULL");
continue;
}
Console.WriteLine(
$"[maphouse] row template type={rowTemplate.Type} "
+ $"({rowTemplate.Width}x{rowTemplate.Height}) kids={rowTemplate.Children.Count}");
foreach (ElementInfo rc in rowTemplate.Children)
Console.WriteLine(
$"[maphouse] rc 0x{rc.Id:X8} type={rc.Type} ({rc.X},{rc.Y} {rc.Width}x{rc.Height})");
}
foreach (ElementInfo row in houseBox.Children)
{
string caption = row.TryGetEffectiveProperty(0x17u, out var cap)
? $"StringInfo(table={cap.StringInfoValue.TableId:X},id={cap.StringInfoValue.StringId:X},lit={cap.StringInfoValue.English != 0})"
: "NO 0x17";
Console.WriteLine(
$"[maphouse] row 0x{row.Id:X8} type={row.Type} caption={caption} kids={row.Children.Count}");
foreach (ElementInfo g in row.Children)
{
string gcaption = g.TryGetEffectiveProperty(0x17u, out var gcap)
? $"StringInfo(table={gcap.StringInfoValue.TableId:X},id={gcap.StringInfoValue.StringId:X})"
: "NO 0x17";
Console.WriteLine($"[maphouse] g 0x{g.Id:X8} type={g.Type} caption={gcaption}");
}
}
}
}
private static bool FindInfo(ElementInfo info, uint id)
{
if (info.Id == id) return true;
foreach (ElementInfo c in info.Children)
if (FindInfo(c, id)) return true;
return false;
}
private static ElementInfo? FindDescendant(ElementInfo info, uint id)
{
if (info.Id == id) return info;
foreach (ElementInfo c in info.Children)
{
ElementInfo? found = FindDescendant(c, id);
if (found is not null) return found;
}
return null;
}
}

View file

@ -0,0 +1,300 @@
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 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]
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 — the formula itself is golden-tested
// above).
const uint cellId = 0x11CE0001u;
Assert.True(RadarCoordinates.TryFromCell(cellId, out _));
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 },
IconBuilder: info => new UiText { Width = 10f, Height = 10f, DatElementId = info.Id }),
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). 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]
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 },
IconBuilder: info => new UiText { Width = 10f, Height = 10f, DatElementId = info.Id }),
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 Refresh_PlayerIconResolutionFails_CoordinateTextStaysEmptyToo()
{
// F15 (night-round review): gmMapUI::Update @0x004a2078's gate is
// `if (m_pCoordinateText != 0 && m_pPlayerLocationIcon != 0)` — BOTH
// widgets present, not "at least one". A player-icon resolution
// failure (leaving _playerIcon null, e.g. a future DAT
// regression) must skip the coordinate-text write too, not just the
// marker placement — the OLD `_coordinateText is null &&
// _playerIcon is null` gate only skipped when BOTH were absent, so
// it would have written coordinate text here even with a missing
// player icon.
const uint cellId = 0x11CE0001u; // outdoor: TryFromCell succeeds.
Assert.True(RadarCoordinates.TryFromCell(cellId, out _));
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 },
// Simulates the player icon's own build failing
// (ResolveSwallowedIcon's null-build path, e.g. a future DAT
// regression) while the house icon and every town-marker
// resolution still succeed normally.
IconBuilder: info => info.Id == MapPageController.PlayerIconId
? null
: new UiText { Width = 10f, Height = 10f, DatElementId = info.Id }),
House: new HousePageController.Bindings(Lines: static () => Array.Empty<string>()));
MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks);
Assert.NotNull(controller);
Assert.Null(
UiElement.FindDescendant(controller!.Root, MapPageController.PlayerIconId));
var coordinateText = Assert.IsType<UiText>(
UiElement.FindDescendant(controller.Root, MapPageController.CoordinateTextId));
Assert.Empty(coordinateText.LinesProvider());
}
[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 },
IconBuilder: info => new UiText { Width = 10f, Height = 10f, DatElementId = info.Id }),
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);
}
}

View file

@ -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);

View file

@ -689,6 +689,128 @@ public sealed class RetailTooltipPresenterTests
Assert.Empty(requests);
}
[Fact]
public void WorldHover_FoundObjectChangesDirectly_ReplacesThePopupWithoutStacking()
{
// #409 follow-on (2026-08-16 overnight hover/UI round, Batch A bug 1):
// the regression that filled the user's screen with dozens of
// stacked tooltips. Walking past a run of NPCs/doors/lifestones never
// produces a frame where the found guid is 0 — it goes straight from
// A to B to C. RecvNotice_SmartBoxObjectFound-equivalent must still
// only ever have ONE popup mounted: found A, then found B (no
// intervening "nothing found" tick) must swap the popup, not add a
// second one on top of the first.
const uint otherGuid = 0x80000456u;
var (root, presenter, requests) = CreateHarness();
uint current = WorldFoundGuid;
presenter.WorldHoverGuidProvider = () => current;
presenter.WorldHoverNameResolver = guid =>
guid == WorldFoundGuid ? "A Drudge" : "A Door";
presenter.WorldTooltipsEnabled = () => true;
int childrenBefore = root.Children.Count;
presenter.Tick();
Assert.Equal(childrenBefore + 1, root.Children.Count);
current = otherGuid;
presenter.Tick();
// Exactly one popup, not two stacked.
Assert.Equal(childrenBefore + 1, root.Children.Count);
Assert.Equal(2, requests.Count);
current = WorldFoundGuid;
presenter.Tick();
current = otherGuid;
presenter.Tick();
current = WorldFoundGuid;
presenter.Tick();
// Several more A/B/A swaps still leave exactly one popup mounted —
// this is the "dozens of stacked name boxes" scenario, minus the bug.
Assert.Equal(childrenBefore + 1, root.Children.Count);
}
[Fact]
public void WorldHover_ThenUiDwellTooltip_ReplacesRatherThanStacks()
{
// The other half of the "no stacking" contract: a world tooltip
// showing, then the mouse settles on a real UI element (dwell path)
// — OnTooltipShow's own unconditional RemovePopup() must clear the
// world popup, leaving exactly one popup (the UI one), not two.
var (root, presenter, _) = CreateHarness();
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => "A Drudge";
presenter.WorldTooltipsEnabled = () => true;
int childrenBefore = root.Children.Count;
presenter.Tick();
Assert.Equal(childrenBefore + 1, root.Children.Count); // world tooltip up
var target = AddFullyAuthoredTarget(root);
root.OnMouseMove(110, 110);
root.Tick(0.016, 0);
root.Tick(0.016, root.TooltipDelayMs);
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
Assert.NotNull(popup);
// childrenBefore world-target(none) + target(1) + popup(1) == +2 total,
// never +3 (world popup replaced, not stacked).
Assert.Equal(childrenBefore + 2, root.Children.Count);
// Night-round review F6: the test previously stopped here, which
// only proved OnTooltipShow's OWN unconditional RemovePopup()
// cleared the world popup — it never actually exercised what
// happens on the NEXT presenter.Tick() (UpdateWorldHoverTooltip
// still thinks a world-hover target exists, since its own
// _worldHoverGuid/_worldTooltipShowing bookkeeping was never
// re-evaluated after the transition). The mouse is now over the UI
// target, so Pick(...) finds it and WorldHoverGuidProvider is
// ignored (found=0u) — this must leave the UI popup exactly as-is,
// no incorrect extra removal or re-mount.
presenter.Tick();
Assert.Equal(childrenBefore + 2, root.Children.Count);
Assert.Same(popup, root.Children.Single(c => !ReferenceEquals(c, target)));
}
[Fact]
public void UiDwellTooltip_ThenModalStealsHitTesting_WorldHoverReplacesRatherThanStacks()
{
// Night-round review F5's own reproduction: the UI->world hole. A
// UI element's dwell tooltip is showing; a modal then opens WITHOUT
// the mouse moving (UiRoot.Modal claims EXCLUSIVE hit-testing —
// HitTestTopDown @0x... "Modal gets exclusive hit-test" — so
// Pick(MouseX, MouseY) now returns null even though the tooltip's
// owner widget is still mounted, still visible, and its popup is
// still up). UpdateWorldHoverTooltip's own clear is gated on
// _worldTooltipShowing, which is FALSE here (the currently-mounted
// popup is UI-owned, not world-owned) — pre-fix, this let the world
// path mount a SECOND popup on top without ever clearing the first.
var (root, presenter, _) = CreateHarness();
var target = AddFullyAuthoredTarget(root);
int childrenBefore = root.Children.Count;
root.OnMouseMove(110, 110);
root.Tick(0.016, 0);
root.Tick(0.016, root.TooltipDelayMs);
Assert.Equal(childrenBefore + 1, root.Children.Count); // UI tooltip up
// Modal opens elsewhere on screen, stealing exclusive hit-testing —
// the mouse never moves.
root.Modal = new UiPanel { Left = 0, Top = 0, Width = 10, Height = 10 };
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => "A Drudge";
presenter.WorldTooltipsEnabled = () => true;
presenter.Tick();
// Exactly one popup (the world one, having replaced the UI one) —
// never two stacked.
Assert.Equal(childrenBefore + 1, root.Children.Count);
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
Assert.NotNull(popup);
}
[Fact]
public void WorldHover_ReEvaluatesGateAndTextOnlyOnTheFoundGuidEdge()
{

View file

@ -152,6 +152,13 @@ public sealed class SpellcastingUiControllerTests
CurrentlyEquippedLocation = EquipMask.Held,
SpellId = 2670u,
IconId = 0x06001234u,
// TS-85: gmSpellcastingUI::UpdateCastButtonTooltip's endowment
// branch (0x004c6a30) gates immediate-use on
// ItemUses::IsUseable_SelfTarget (0x004fcd30) reading the ITEM's
// own Useability target mask -- a self-castable orb authors the
// Self target bit so clicking Cast fires without a target
// selection, matching this test's intent.
Useability = (ItemUseability.Self << 16) | ItemUseability.Wielded,
});
controller.Tick();
@ -711,6 +718,170 @@ public sealed class SpellcastingUiControllerTests
Assert.Null(selection.SelectedObjectId);
}
// ── Night-round review F3/F4/F7: cast-button tooltip states ────────────
//
// gmSpellcastingUI::UpdateCastButtonTooltip @0x004c6a30, byte-decoded
// (see SpellcastingUiController.ComputeSpellCastState/
// ComposeEndowmentName's own doc comments). These pin the four
// plain-spell states (two pre-existed only as a bare-name fallback; the
// two DISABLED states are genuinely new coverage per F7) and the
// endowment branch's composed-name fix (F4).
[Fact]
public void CastAvailability_UntargetedSpell_IsEnabled_WithCastSpellNameTooltip()
{
SpellMetadata spell = BuildSpell(
42u, "Test Untargeted", isUntargeted: true, isSelfTargeted: false, targetMask: 0u);
var spellbook = new Spellbook(SpellTable.Create([spell]));
spellbook.OnSpellLearned(42u);
spellbook.SetFavorite(0, 0, 42u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
using SpellcastingUiController controller = Bind(layout, spellbook, objects, _ => { })!;
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
Assert.True(cast.Enabled);
Assert.Equal("CAST Test Untargeted", cast.TooltipText);
}
[Fact]
public void CastAvailability_TargetedSpell_CompatibleTargetSelected_AppendsOnTargetName()
{
SpellMetadata spell = BuildSpell(
42u, "Test Targeted", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
var spellbook = new Spellbook(SpellTable.Create([spell]));
spellbook.OnSpellLearned(42u);
spellbook.SetFavorite(0, 0, 42u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject { ObjectId = 5u, Name = "Drudge" });
var selection = new SelectionState();
var operations = new ConfigurableSpellCastOperations { TargetCompatible = true };
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
using SpellcastingUiController controller = Bind(
layout, spellbook, objects, _ => { }, selection: selection, operations: operations)!;
selection.Select(5u, SelectionChangeSource.World);
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
Assert.True(cast.Enabled);
Assert.Equal("CAST Test Targeted on Drudge", cast.TooltipText);
}
[Fact]
public void CastAvailability_TargetedSpell_NoTargetSelected_IsDisabled_WithNeedsTargetTooltip()
{
// F7: this DISABLED state was previously untested — the bare-name
// fallback the old code shipped never distinguished it from the
// ready/enabled case.
SpellMetadata spell = BuildSpell(
42u, "Test Targeted", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
var spellbook = new Spellbook(SpellTable.Create([spell]));
spellbook.OnSpellLearned(42u);
spellbook.SetFavorite(0, 0, 42u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
using SpellcastingUiController controller = Bind(layout, spellbook, objects, _ => { })!;
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
Assert.False(cast.Enabled);
Assert.Equal("You must select a target for Test Targeted", cast.TooltipText);
}
[Fact]
public void CastAvailability_TargetedSpell_IncompatibleTargetSelected_IsDisabled_WithNeedsAppropriateTargetTooltip()
{
// F7: this DISABLED state was previously untested.
SpellMetadata spell = BuildSpell(
42u, "Test Targeted", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
var spellbook = new Spellbook(SpellTable.Create([spell]));
spellbook.OnSpellLearned(42u);
spellbook.SetFavorite(0, 0, 42u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject { ObjectId = 5u, Name = "Drudge" });
var selection = new SelectionState();
var operations = new ConfigurableSpellCastOperations { TargetCompatible = false };
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
using SpellcastingUiController controller = Bind(
layout, spellbook, objects, _ => { }, selection: selection, operations: operations)!;
selection.Select(5u, SelectionChangeSource.World);
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
Assert.False(cast.Enabled);
Assert.Equal("You must select an appropriate target for Test Targeted", cast.TooltipText);
}
[Fact]
public void CastAvailability_EndowmentSelfTarget_ComposesItemAndSpellName()
{
SpellMetadata spell = BuildSpell(
2670u, "Lightning Bolt VI", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
var spellbook = new Spellbook(SpellTable.Create([spell]));
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
Name = "Lightning Wand",
Type = ItemType.Caster,
WielderId = 1u,
CurrentlyEquippedLocation = EquipMask.Held,
SpellId = 2670u,
// ItemUseability.Self shifted into the TARGET half.
Useability = ItemUseability.Self << 16,
});
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
using SpellcastingUiController controller = Bind(layout, spellbook, objects, _ => { })!;
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
Assert.True(cast.Enabled);
Assert.Equal("USE the Lightning Wand (Lightning Bolt VI)", cast.TooltipText);
}
[Fact]
public void CastAvailability_EndowmentNeedsTarget_NoTargetSelected_ComposesItemAndSpellName()
{
SpellMetadata spell = BuildSpell(
2670u, "Lightning Bolt VI", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
var spellbook = new Spellbook(SpellTable.Create([spell]));
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
Name = "Lightning Wand",
Type = ItemType.Caster,
WielderId = 1u,
CurrentlyEquippedLocation = EquipMask.Held,
SpellId = 2670u,
// Remote (not Self) shifted into the TARGET half — requires an
// external target, same as retail's non-self-castable wands.
Useability = ItemUseability.Remote << 16,
});
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
using SpellcastingUiController controller = Bind(layout, spellbook, objects, _ => { })!;
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
Assert.False(cast.Enabled);
Assert.Equal(
"You must select a target for the Lightning Wand (Lightning Bolt VI)",
cast.TooltipText);
}
private static SpellcastingUiController? Bind(
ImportedLayout layout,
Spellbook spellbook,
@ -720,13 +891,14 @@ public sealed class SpellcastingUiControllerTests
UiShortcutDigitGraphics? shortcutDigits = null,
uint emptySlotSprite = 0u,
SelectionState? selection = null,
Action<uint>? examineSpell = null)
Action<uint>? examineSpell = null,
IRuntimeSpellCastOperations? operations = null)
{
SelectionState selectionState = selection ?? new SelectionState();
var casting = new RuntimeSpellCastState(
spellbook,
selectionState,
new NoopSpellCastOperations());
operations ?? new NoopSpellCastOperations());
return SpellcastingUiController.Bind(
layout, spellbook, casting, objects, () => 1u,
spellId => spellId,
@ -760,6 +932,57 @@ public sealed class SpellcastingUiControllerTests
public void IncrementBusy() { }
}
/// <summary>Same as <see cref="NoopSpellCastOperations"/> but with a
/// settable target-compatibility answer — the night-round review
/// (F3/F7) cast-button-tooltip tests need to force both the
/// compatible and incompatible target branches.</summary>
private sealed class ConfigurableSpellCastOperations : IRuntimeSpellCastOperations
{
public bool TargetCompatible = true;
public uint LocalPlayerId => 1u;
public bool CanSend => true;
public bool HasRequiredComponents(uint spellId) => true;
public bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage) => TargetCompatible;
public void StopCompletely() { }
public void SendUntargeted(uint spellId) { }
public void SendTargeted(uint targetId, uint spellId) { }
public void DisplayMessage(string message) { }
public void IncrementBusy() { }
}
/// <summary>Minimal <see cref="SpellMetadata"/> builder for cast-gate
/// tests — most of the record's fields are irrelevant to
/// <see cref="RuntimeSpellCastState.EvaluateCastGate"/>.</summary>
private static SpellMetadata BuildSpell(
uint spellId, string name, bool isUntargeted, bool isSelfTargeted, uint targetMask) =>
new(
SpellId: spellId,
Name: name,
School: "Life",
Family: 0u,
IconId: 0u,
SpellWords: "",
Duration: 0f,
ManaCost: 0,
IsDebuff: false,
IsFellowship: false,
Description: "",
SortKey: 0,
Difficulty: 0,
Flags: isSelfTargeted ? (uint)SpellFlags.SelfTargeted : 0u,
Generation: 1,
IsFastWindup: false,
IsOffensive: false,
IsUntargeted: isUntargeted,
Speed: 0f,
CasterEffect: 0u,
TargetEffect: 0u,
TargetMask: targetMask,
SpellType: 0);
private static void ApplyAnchors(UiElement parent)
{
foreach (UiElement child in parent.Children)

File diff suppressed because it is too large Load diff

View file

@ -76,4 +76,75 @@ public sealed class UiTemplateListBoxViewportTests
Assert.True(row1.Visible, "row 1 culled — the #372 blank-tab bug");
Assert.True(row2.Visible, "row 2 culled — the #372 blank-tab bug");
}
/// <summary>
/// #412-class regression (2026-08-16, overnight hover/UI round, Batch A bug
/// 2): the Options panel's Config tab escaped past the window frame — the
/// footer sitting mid-panel with further rows drawing below the window's
/// bottom edge. #372's own fixture above never exercises this because
/// <see cref="MakeListBox"/> gives the ListBox no parent — its own size
/// never changes after the viewport is seeded. The real Options mount is
/// different: this ListBox is itself a DAT-imported <see cref="UiElement"/>
/// carrying a real <see cref="UiLayoutPolicy"/> from its authored parent
/// (the Config page slot), and a page controller's Bind — which lazily
/// creates this viewport — runs BEFORE the tree's first draw frame, i.e.
/// before the ListBox has ever shrunk to fit its actual (smaller than
/// authored) container. This reproduces that ordering with a real
/// LayoutPolicy-driven parent standing in for the page slot.
/// </summary>
[Fact]
public void Viewport_TracksTheListBox_WhenTheListBoxItselfShrinksOnFirstLayout()
{
// A stand-in for the Config page slot: authored 298×575 against an
// authored 300×600 design canvas (live-DAT-measured values), but its
// real mounted container is only 300×362 — exactly retail's
// UIElement::UpdateForParentSizeChange four-edge policy (L=T=R=B=1,
// "preserve original margin on every edge").
var slotPolicy = new UiLayoutPolicy(
leftMode: 1, topMode: 1, rightMode: 1, bottomMode: 1,
originalChild: UiPixelRect.FromPositionAndSize(2, 25, 298, 575),
originalParent: UiPixelRect.FromPositionAndSize(0, 0, 300, 600));
var slot = new UiPanel
{
Left = 2, Top = 25, Width = 298, Height = 575,
LayoutPolicy = slotPolicy,
};
var root = new UiPanel { Width = 300, Height = 362 };
root.AddChild(slot);
// The ListBox itself ALSO carries a real LayoutPolicy (live-DAT
// measured: authored 276×560 against the slot's own 298×575 design
// extent) — this is what shrinks it out from under the viewport.
var box = MakeListBox(276f, 560f);
var listBoxPolicy = new UiLayoutPolicy(
leftMode: 1, topMode: 1, rightMode: 1, bottomMode: 1,
originalChild: UiPixelRect.FromPositionAndSize(0, 0, 276, 560),
originalParent: UiPixelRect.FromPositionAndSize(0, 0, 298, 575));
box.LayoutPolicy = listBoxPolicy;
slot.AddChild(box);
// Seed the viewport with rows BEFORE any layout pass has ever run —
// exactly ConfigOptionsPageController.Bind's own ordering (it runs
// before RetailWindowFrame.Mount's first draw frame).
box.AddPrebuiltRow(new UiText { Width = 260f, Height = 20f });
UiScrollablePanel viewport = box.ViewportForTest!;
// Drive ONE simulated draw-frame's top-down ApplyAnchor walk — the
// SAME order DrawSelfAndChildren runs every frame: parent before
// children, all the way down.
slot.ApplyAnchor(root.Width, root.Height); // slot shrinks: 575 -> ~337
box.ApplyAnchor(slot.Width, slot.Height); // listbox shrinks: 560 -> ~297 (still ahead of the viewport)
viewport.ApplyAnchor(box.Width, box.Height); // the viewport's FIRST EVER ApplyAnchor call
// Pre-fix: the viewport's legacy anchor baseline captured a NEGATIVE
// bottom margin against the ALREADY-SHRUNK ListBox (parentH(~297) -
// (0+560) < 0), which ComputeAnchoredRect's stretch math preserves
// forever — the viewport stayed locked at its original 560px height,
// clipping rows to a bound retail never actually gave it on screen.
Assert.Equal(box.Height, viewport.Height, 3);
Assert.True(
viewport.Height < 400f,
$"viewport height {viewport.Height} did not shrink with its ListBox "
+ "(560 == the pre-fix stale-capture bug)");
}
}

View file

@ -1771,6 +1771,48 @@ public sealed class GameEventWiringTests
Assert.True(observed.Value.IsLoggedIn);
}
/// <summary>Batch C (Map/House toolbar panel, 2026-08-17): all four
/// house wire opcodes (0x0225-0x0228) reach their registered
/// callbacks.</summary>
[Fact]
public void WireAll_HouseFamily_ReachesTheirCallbacks()
{
var dispatcher = new GameEventDispatcher();
GameEvents.HouseData? data = null;
uint? status = null;
uint? rentTime = null;
IReadOnlyList<GameEvents.HousePayment>? rentPayment = null;
GameEventWiring.WireAll(
dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(),
onHouseData: d => data = d,
onHouseStatus: code => status = code,
onHouseUpdateRentTime: t => rentTime = t,
onHouseUpdateRentPayment: p => rentPayment = p);
byte[] houseDataWire = new AceWireWriter()
.Write(0u).Write(0u).Write(0u).Write(0u)
.Write(0).Write(0)
.Write(0x00120001u)
.Write(0f).Write(0f).Write(0f)
.Write(1f).Write(0f).Write(0f).Write(0f)
.ToArray();
dispatcher.Dispatch(GameEventEnvelope.TryParse(
WrapEnvelope(GameEventType.HouseData, houseDataWire))!.Value);
dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope(
GameEventType.HouseStatus, new AceWireWriter().Write(0u).ToArray()))!.Value);
dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope(
GameEventType.UpdateRentTime, new AceWireWriter().Write(1_700_000_000u).ToArray()))!.Value);
dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope(
GameEventType.UpdateRentPayment, new AceWireWriter().Write(0).ToArray()))!.Value);
Assert.NotNull(data);
Assert.Equal(0x00120001u, data!.Value.Position.LandblockId);
Assert.Equal(0u, status);
Assert.Equal(1_700_000_000u, rentTime);
Assert.NotNull(rentPayment);
Assert.Empty(rentPayment);
}
private static byte[] BuildEnchantment(
ushort spellId,
ushort layer,

View file

@ -0,0 +1,156 @@
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Batch C (overnight hover/UI round, Map/House toolbar panel, 2026-08-17):
/// golden-byte coverage for the House panel's four inbound events
/// (0x0225-0x0228, gmHouseUI::PostInit's registered notice handlers) and the
/// outbound HouseQuery action (0x021E). Wire shapes cross-checked against
/// ACE's HouseDataExtensions/HousePaymentExtensions
/// (references/ACE/Source/ACE.Server/Network/Structure/HouseData.cs,
/// HousePayment.cs) — see docs/research/2026-08-17-map-house-recon.md.
/// </summary>
public sealed class HouseEventsTests
{
[Fact]
public void BuildHouseQuery_WritesEnvelopeSequenceOpcodeOnly()
{
byte[] body = ClientCommandRequests.BuildHouseQuery(9);
Assert.Equal(12, body.Length);
Assert.Equal(ClientCommandRequests.HouseQueryOpcode,
System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
}
[Fact]
public void ParseHouseStatus_ReadsWeenieError()
{
byte[] wire = new AceWireWriter().Write(0u).ToArray();
Assert.Equal(0u, GameEvents.ParseHouseStatus(wire));
}
[Fact]
public void ParseHouseStatus_TruncatedPayload_ReturnsNull()
{
Assert.Null(GameEvents.ParseHouseStatus(System.Array.Empty<byte>()));
}
[Fact]
public void ParseUpdateRentTime_ReadsTimestamp()
{
// The real ACE writer always sends 0u (a stub) — parsed at whatever
// value arrives, not hardcoded to that stub's output.
byte[] wire = new AceWireWriter().Write(1_700_000_000u).ToArray();
Assert.Equal(1_700_000_000u, GameEvents.ParseUpdateRentTime(wire));
}
[Fact]
public void ParseUpdateRentPayment_EmptyList_RoundTrips()
{
// The real ACE writer always sends an empty list (a stub).
byte[] wire = new AceWireWriter().Write(0).ToArray();
var payments = GameEvents.ParseUpdateRentPayment(wire);
Assert.NotNull(payments);
Assert.Empty(payments);
}
[Fact]
public void ParseUpdateRentPayment_OneEntry_RoundTrips()
{
byte[] wire = new AceWireWriter()
.Write(1)
.Write(400) // Num
.Write(150) // Paid
.Write(273u) // WeenieID (pyreal)
.WriteString16L("Pyreal")
.WriteString16L("Pyreals")
.ToArray();
var payments = GameEvents.ParseUpdateRentPayment(wire);
Assert.NotNull(payments);
GameEvents.HousePayment payment = Assert.Single(payments);
Assert.Equal(400, payment.Num);
Assert.Equal(150, payment.Paid);
Assert.Equal(273u, payment.WeenieID);
Assert.Equal("Pyreal", payment.Name);
Assert.Equal("Pyreals", payment.PluralName);
}
[Fact]
public void ParseHouseData_NoHouseOwned_EmptyListsAndZeroFields()
{
byte[] wire = new AceWireWriter()
.Write(0u) // BuyTime
.Write(0u) // RentTime
.Write(0u) // Type (Undef)
.Write(0u) // MaintenanceFree
.Write(0) // Buy.Count
.Write(0) // Rent.Count
// Position: Cell + Pos.XYZ + Rotation.WXYZ
.Write(0x00120001u)
.Write(10f).Write(20f).Write(30f)
.Write(1f).Write(0f).Write(0f).Write(0f)
.ToArray();
GameEvents.HouseData? data = GameEvents.ParseHouseData(wire);
Assert.NotNull(data);
Assert.Equal(0u, data!.Value.BuyTime);
Assert.Empty(data.Value.Buy);
Assert.Empty(data.Value.Rent);
Assert.Equal(0x00120001u, data.Value.Position.LandblockId);
Assert.Equal(10f, data.Value.Position.PositionX);
Assert.Equal(30f, data.Value.Position.PositionZ);
Assert.Equal(1f, data.Value.Position.RotationW);
}
[Fact]
public void ParseHouseData_OwnedHouse_ReadsBuyAndRentLists()
{
byte[] wire = new AceWireWriter()
.Write(1_650_000_000u) // BuyTime
.Write(1_699_000_000u) // RentTime
.Write(1u) // Type (Cottage)
.Write(0u) // MaintenanceFree = false
.Write(1) // Buy.Count
.Write(1).Write(1).Write(273u)
.WriteString16L("Pyreal").WriteString16L("Pyreals")
.Write(2) // Rent.Count
.Write(300).Write(300).Write(273u)
.WriteString16L("Pyreal").WriteString16L("Pyreals")
.Write(1).Write(0).Write(1049u)
.WriteString16L("Writ of the Chosen").WriteString16L("Writs of the Chosen")
// Position
.Write(0x00340002u)
.Write(-15f).Write(45f).Write(0f)
.Write(0.7071f).Write(0f).Write(0f).Write(0.7071f)
.ToArray();
GameEvents.HouseData? data = GameEvents.ParseHouseData(wire);
Assert.NotNull(data);
Assert.Equal(1_650_000_000u, data!.Value.BuyTime);
Assert.Equal(1_699_000_000u, data.Value.RentTime);
Assert.Equal(1u, data.Value.Type);
Assert.False(data.Value.MaintenanceFree);
Assert.Single(data.Value.Buy);
Assert.Equal(2, data.Value.Rent.Count);
Assert.Equal("Writ of the Chosen", data.Value.Rent[1].Name);
Assert.Equal(0x00340002u, data.Value.Position.LandblockId);
}
[Fact]
public void ParseHouseData_TruncatedPayload_ReturnsNull()
{
byte[] wire = new AceWireWriter().Write(0u).Write(0u).ToArray();
Assert.Null(GameEvents.ParseHouseData(wire));
}
}

View file

@ -0,0 +1,199 @@
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Properties;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
/// <summary>
/// House-tab conformance (Batch C, 2026-08-17): the ONE decomp-verified
/// line <c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c> emits for a
/// houseless/fresh character, and the wait-period-not-expired case that
/// stays empty (unrecoverable strftime format, ISSUES #413 item 2).
/// </summary>
public sealed class RuntimeHouseStateTests
{
private const uint Self = 0x50000001u;
[Fact]
public void EmptyBeforeAnyNoticeArrives()
{
// gmHouseUI::PostInit never calls Update/DisplayHouseData — the
// ListBox starts genuinely empty (live-DAT-confirmed: the House
// page's ListBox children=0, no other page content).
var house = new RuntimeHouseState();
Assert.Empty(house.Lines);
Assert.False(house.HasReceivedNotice);
}
[Fact]
public void HouseStatus_FreshCharacterWithNoTimestamp_ShowsBuyImmediatelyLine()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature });
// No PropertyInt.HousePurchaseTimestamp set — absent reads as 0,
// matching a fresh character that has never purchased or abandoned
// a house. HasPurchaseWaitPeriodExpired(0) is trivially true.
var house = new RuntimeHouseState(objects);
house.ApplyHouseStatus(weenieError: 0u, Self);
Assert.True(house.HasReceivedNotice);
Assert.Equal(["You may buy another house immediately."], house.Lines);
}
[Fact]
public void HouseStatus_WeenieErrorValueIsDiscarded()
{
// Decomp-confirmed: gmHouseUI::Update(uint32_t)/gmMapUI::
// RecvNotice_FailedHouseTransaction never read their arg2. The
// rendered text must not depend on the wire WeenieError value.
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature });
var houseA = new RuntimeHouseState(objects);
var houseB = new RuntimeHouseState(objects);
houseA.ApplyHouseStatus(weenieError: 0u, Self);
houseB.ApplyHouseStatus(weenieError: 0x45Fu /* HouseEvicted */, Self);
Assert.Equal(houseA.Lines, houseB.Lines);
}
[Fact]
public void HouseData_OwnedHouseWithExpiredWaitPeriod_ShowsAbandonFirstLine()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature });
var house = new RuntimeHouseState(objects);
house.ApplyHouseData(SampleHouseData(), Self);
Assert.Equal(
["You may buy another house immediately after you abandon this one."],
house.Lines);
}
[Fact]
public void HouseStatus_TimestampWithinThirtyDayWindow_ShowsExpiryDateLine()
{
// HouseSystem::HasPurchaseWaitPeriodExpired: (now - timestamp) >
// 0x278d00 (2,592,000 s = 30 days). Inside the window, retail's
// gmHouseUI::DisplayPurchaseTimeText composes prefix + strftime("%c")
// of (timestamp + 30 days) + suffix (night-round review F8 — the
// "unrecoverable strftime branch" from ISSUES #413 item 2 was
// byte-decoded and is now ported). The exact date substring is
// locale/timezone-formatted (.NET's honest %c analogue), so this
// pins the STRUCTURE (prefix/suffix, non-empty middle), not the
// exact rendered date text.
var clock = new ManualTimeProvider();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature });
var bundle = new PropertyBundle();
bundle.Ints[(uint)PropertyInt.HousePurchaseTimestamp] =
(int)clock.GetUtcNow().ToUnixTimeSeconds();
objects.UpsertProperties(Self, bundle);
var house = new RuntimeHouseState(objects, clock);
clock.Advance(TimeSpan.FromDays(29));
house.ApplyHouseStatus(weenieError: 0u, Self);
string line = Assert.Single(house.Lines);
Assert.StartsWith("You may buy another landscape house at ", line);
Assert.EndsWith(". This restriction does not apply to apartments.", line);
}
[Fact]
public void HouseStatus_TimestampWithinThirtyDayWindow_ExpiryDateIsTimestampPlusThirtyDays()
{
// Pins the actual computed expiry moment (retail: timestamp +
// 0x278d00 = 2,592,000 s, formatted via localtime — this fixture's
// LocalTimeZone is UTC, so the rendered date is exactly the UTC
// expiry instant with no offset ambiguity).
var clock = new ManualTimeProvider();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature });
DateTimeOffset purchaseTime = clock.GetUtcNow();
var bundle = new PropertyBundle();
bundle.Ints[(uint)PropertyInt.HousePurchaseTimestamp] =
(int)purchaseTime.ToUnixTimeSeconds();
objects.UpsertProperties(Self, bundle);
var house = new RuntimeHouseState(objects, clock);
clock.Advance(TimeSpan.FromDays(29));
house.ApplyHouseStatus(weenieError: 0u, Self);
DateTime expectedExpiry = purchaseTime.AddSeconds(0x278d00).UtcDateTime;
string line = Assert.Single(house.Lines);
Assert.Contains(expectedExpiry.ToString(System.Globalization.CultureInfo.CurrentCulture), line);
}
[Fact]
public void HouseStatus_TimestampPastThirtyDayWindow_ShowsBuyImmediatelyLine()
{
var clock = new ManualTimeProvider();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature });
var bundle = new PropertyBundle();
bundle.Ints[(uint)PropertyInt.HousePurchaseTimestamp] =
(int)clock.GetUtcNow().ToUnixTimeSeconds();
objects.UpsertProperties(Self, bundle);
var house = new RuntimeHouseState(objects, clock);
clock.Advance(TimeSpan.FromDays(31));
house.ApplyHouseStatus(weenieError: 0u, Self);
Assert.Equal(["You may buy another house immediately."], house.Lines);
}
[Fact]
public void ResetSession_RestoresGenuinelyEmptyPreNoticeState()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature });
var house = new RuntimeHouseState(objects);
house.ApplyHouseStatus(weenieError: 0u, Self);
Assert.NotEmpty(house.Lines);
house.ResetSession();
Assert.Empty(house.Lines);
Assert.False(house.HasReceivedNotice);
}
[Fact]
public void MissingObjectTable_DefaultsTimestampToZero()
{
// Bare-fixture callers (no ClientObjectTable) must not throw — the
// same optional-borrow discipline RuntimeTradeState uses.
var house = new RuntimeHouseState();
house.ApplyHouseStatus(weenieError: 0u, Self);
Assert.Equal(["You may buy another house immediately."], house.Lines);
}
private static GameEvents.HouseData SampleHouseData() => new(
BuyTime: 0u,
RentTime: 0u,
Type: 0u,
MaintenanceFree: false,
Buy: Array.Empty<GameEvents.HousePayment>(),
Rent: Array.Empty<GameEvents.HousePayment>(),
Position: new CreateObject.ServerPosition(0u, 0f, 0f, 0f, 1f, 0f, 0f, 0f));
private sealed class ManualTimeProvider : TimeProvider
{
private DateTimeOffset _now = new(2026, 8, 17, 0, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => _now;
// F8: pin LocalTimeZone to UTC so the not-yet-expired branch's
// TimeZoneInfo.ConvertTime call is deterministic across machines —
// the real production TimeProvider.System.LocalTimeZone is
// TimeZoneInfo.Local, matching retail's own localtime() call.
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
public void Advance(TimeSpan elapsed) => _now += elapsed;
}
}

View file

@ -165,10 +165,18 @@ public sealed class RuntimeLiveEntitySessionControllerTests
Spawn(playerGuid, incarnation: 1);
sink.Spawned(spawn);
Assert.Single(gameActions);
// Night-round review F2: the content-less direct host's terminal
// admission edge now also sends HouseQuery (0x021E), matching
// retail's CM_House::Event_QueryHouse tail-call from
// CPlayerSystem::InitializePlayer right after
// AttemptSendLoginCompleteNotification. It's the session's first
// NextGameActionSequence() call (LoginComplete doesn't consume one),
// so its sequence is deterministically 1.
Assert.Equal(2, gameActions.Count);
Assert.Equal(GameActionLoginComplete.Build(), gameActions[0]);
Assert.Equal(ClientCommandRequests.BuildHouseQuery(1u), gameActions[1]);
sink.Spawned(spawn);
Assert.Single(gameActions);
Assert.Equal(2, gameActions.Count);
gameActions.Clear();
sink.TeleportStarted(1u);