feat(ui): House tab ownership text — DisplayPurchaseTimeText + RuntimeHouseState

Derived the mechanism from the decomp before writing code: neither
gmHouseUI::PostInit @0x004a2710 nor gmMapUI::PostInit @0x004a1c70 sends a
HouseQuery, and six of gmHouseUI's seven Display* builders early-return on
m_pHouseData == 0. The only text a houseless character's House tab shows is
gmHouseUI::DisplayPurchaseTimeText @0x004a3110's expired branch (it doesn't
gate on m_pHouseData) — the local player's PropertyInt.HousePurchaseTimestamp
plus HouseSystem::HasPurchaseWaitPeriodExpired renders exactly "You may buy
another house immediately." for a fresh character. Exhaustive search of the
2013 EoR decomp, ACE, and the live DAT found zero support for a second
"You do not currently own a house." line the task brief described — this
commit ports what the decomp actually shows.

Ships:
- RuntimeHouseState: a minimal (no disposal, no construction-transaction
  Fault() point) Runtime owner per ISSUES #413's own sizing note, wired
  through GameEventWiring's existing HouseData/HouseStatus delegate holes,
  LiveSessionEventRouter, and GameRuntime.HouseOwner. Participates in
  RuntimeGenerationReset (new House stage) since a fresh login must not
  show a stale character's house state.
- HousePageController.Bindings.Lines/OnShown wired to real data; OnShown
  fires WorldSession.SendHouseQuery() on tab-open (AD-107: an acdream
  trigger, not a ported retail call site — filed in the divergence
  register).
- Fixed a real bug found along the way: HousePageController.Bind never
  wired UiTemplateListBox.TemplateResolver, so no row could ever render
  regardless of Lines content. Now reuses the Map tab's generic hotspot
  resolver.

Live-verified against a real local ACE server and the +Acdream character
(--session-config auto-select + a UI automation script): screenshot and
structural UI-tree dump both confirm the House tab renders exactly "You may
buy another house immediately." Graceful logout confirmed both launches.

ISSUES #413 narrowed to its one remaining piece: the six owned-house-only
Display* builders (DisplayBuyPayment/RentPayment/BuyTime/RentTimes/
Location/WarningText), unexercisable without a test character that owns a
house.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 03:38:16 +02:00
parent eb6f3bd8c8
commit 06512f0957
13 changed files with 601 additions and 99 deletions

View file

@ -24,62 +24,72 @@ 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, seven Display* line builders unported)
## #413 — House tab shows no content (owned-house display, six Display* line builders unported)
**Status:** OPEN (filed 2026-08-17, Batch C — overnight hover/UI round, Map/House
toolbar panel).
**Status:** NARROWED 2026-08-17 (House-tab ownership-text closer session).
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.** The Map/House panel (host `0x2100006E` slot `0x1000018C`,
`RetailPanelCatalog.MapHouse = 16`) is fully mounted with a working Map tab
(date/time, coordinates, player/house markers, 53 town hotspots with retail
tooltips) and a House tab that mounts correctly with its authored ListBox
(`0x100001E6`) and row template — but the ListBox is genuinely EMPTY, matching
retail's own `gmHouseUI::PostInit @0x004a2710` (it never calls `Update`/
`DisplayHouseData`; the box only populates after a server notice arrives).
The full wire *parsing* groundwork also shipped: outbound HouseQuery
(`0x021E`, `WorldSession.SendHouseQuery`) and inbound parsers for all four
House opcodes (`GameEvents.ParseHouseData`/`ParseHouseStatus`/
`ParseUpdateRentTime`/`ParseUpdateRentPayment`, wired into
`GameEventWiring.WireAll` as optional delegate holes) — all tested
(`HouseEventsTests`, `GameEventWiringTests.WireAll_HouseFamily_ReachesTheirCallbacks`).
**What's shipped (this session, on top of Batch C's mount + parser
groundwork).**
**What's missing — three pieces, all deliberately deferred (the task's own
pre-authorized fallback: "land the default-content tab + the enum/parser
groundwork, and file the remainder as a precise ISSUES entry"):**
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`.
1. **A `RuntimeHouseState` owner.** The wire delegate holes exist but nothing
consumes them yet — no session-scoped state class holds the parsed
`HouseData`/`HouseStatus`/rent fields, and `HousePageController.Bindings.Lines`/
`OnShown` are unwired defaults (`() => Array.Empty<string>()`, no-op).
Sizing note: a FULL `GameRuntime`-integrated owner (the
`RuntimeTradeState` precedent — construction-transaction `Fault()`
injection point, `GameRuntimeConstructionPoint` enum entry, disposal
ordering, convergence tracking at 2-3 sites) is a substantial standalone
undertaking; judged disproportionate to add alongside the completed Map
tab in one session. A lighter read-only mirror (closer to
`FriendsState`/`SquelchState`'s weight, no full owner ceremony) may be
the right shape — evaluate against the codebase's "single canonical
owner" architecture before choosing.
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
stays unported (its format string is BN-unrecoverable) — renders no
line, not a guess.
2. **`gmHouseUI::DisplayPurchaseTimeText @0x004a3110`'s port** — the ONE
builder decomp-confirmed simple enough to land (no `m_pHouseData` early
return; reads the LOCAL PLAYER's own `PropertyInt.HousePurchaseTimestamp`
(`= 199` decimal, confirmed already in `src/AcDream.Core/Properties/PropertyInt.cs:356`)
and `HouseSystem::HasPurchaseWaitPeriodExpired(timestamp) =
(Timer::get_real_time() - timestamp) > 0x278d00` — clean, no FPU noise,
`0x278d00` = 2,592,000 s = 30 days, the house-abandon cooldown). Two fully
recovered literal strings for the expired case: `"You may buy another
house immediately."` (no house owned) / `"...after you abandon this
one."` (owns a house) — these are what a fresh test character (no
`HousePurchaseTimestamp` set, i.e. `0`) would show once queried, since
`HasPurchaseWaitPeriodExpired(0)` is trivially true. The NOT-expired
branch's future-dated wait message uses a `strftime` format string
(`data_7ab7ec`) and a suffix (`". This restriction does not appl…"`)
that BN truncates and this session did not attempt to recover further —
port the expired branch first, mark the not-expired branch's suffix text
as inferred-pending-verification if ported later.
**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.
3. **The other six `Display*` line builders** (`DisplayBuyPayment`,
**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
@ -87,25 +97,46 @@ groundwork, and file the remainder as a precise ISSUES entry"):**
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). `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.
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 full
citation set: addresses, ACE cross-references, the two ACE writer stubs for
UpdateRentTime/UpdateRentPayment). `src/AcDream.App/UI/Layout/HousePageController.cs`,
**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 once closed:** 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` clears `m_pHouseData`-equivalent, `DisplayPurchaseTimeText`'s
expired/no-house branch fires).
**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)