diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 7dd8084a..4cab7d03 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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()`, 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) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 16418262..ae1b209a 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 82 active rows (AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 83 active rows (AD-107 filed 2026-08-17 at the House-tab ownership-text closer — HouseQuery fires on House-tab-open, an invented trigger timing since neither `gmHouseUI::PostInit`/`gmMapUI::PostInit` sends one; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -106,6 +106,7 @@ readiness/requeue adaptation. See | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AD-107 | **Filed 2026-08-17 at the House-tab ownership-text closer (Batch C follow-up).** `HousePageController.Bindings.OnShown` sends the outbound `0x021E` HouseQuery when the House tab becomes the active page while the Map/House panel is visible (wired in `MapHousePanelController`'s `FireHouseShownIfActive`, ultimately `late.Session.CurrentSession?.SendHouseQuery()` in `RetailUiRuntime.MountMapHousePanel`). Neither `gmHouseUI::PostInit @0x004a2710` nor `gmMapUI::PostInit @0x004a1c70` sends a HouseQuery — both merely register their four/two notice handlers (0x0225-0x0228 / 0x0225-0x0226) and leave `m_pTextBox` genuinely empty until an UNPROMPTED server notice arrives (login-time house sync, a slumlord interaction, or an abandon/purchase completing). Live-DAT-confirmed: the House page's ListBox (`0x100001E6`) authors ZERO rows and the page has no other static content (`MapHousePanelSlotProbeTests`). | `src/AcDream.App/UI/Layout/HousePageController.cs` (`Bindings.OnShown` doc); `src/AcDream.App/UI/Layout/MapHousePanelController.cs` (`FireHouseShownIfActive`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`MountMapHousePanel`'s `HouseShown` binding) | Without SOME trigger, acdream's House tab would show the SAME genuinely-empty content retail's own passive design produces for the overwhelming majority of houseless play sessions (no slumlord visited, no login-time house sync because there is no house) — matching retail's letter but defeating the tab's purpose for a player who actually wants to check their housing status. Firing on tab-open reuses the EXACT wire message (`0x021E`, `WorldSession.SendHouseQuery`) and EXACT response handling (`RuntimeHouseState.ApplyHouseData`/`ApplyHouseStatus`, themselves faithful ports of `gmHouseUI::DisplayPurchaseTimeText @0x004a3110`'s expired branch) — only the TRIGGER TIMING is invented, not the wire format or the rendered text. | If retail's actual trigger is later discovered (e.g. some other UI element or a periodic client-side poll this decomp pass missed), this adaptation should be replaced with the real one; until then, a user who opens the House tab sends one extra `0x021E` per tab-activation that retail's own client would not have sent at that moment — harmless network overhead ACE already handles from other call sites (slumlord `ActOnUse`, `@house`-adjacent commands), not a new attack surface or wire-format deviation. | `gmHouseUI::PostInit @0x004a2710`; `gmMapUI::PostInit @0x004a1c70` (both decomp-confirmed to never call `Update`/`DisplayHouseData` or send any outbound action) | | AD-106 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system).** Retail's tooltip popup is a separate always-on-top presentation surface — `UIElementManager::StartTooltip @0x00459700` positions and latches it into `m_pTooltipElement`, drawn independently of the ordinary `UIElement` sibling tree (the SAME class of separation the AP-229 register row already establishes for retail's dialogs vs acdream's flat sibling list under one `Host.Root`). `RetailTooltipPresenter` instead mounts the popup as an ordinary `UiRoot` child sibling (`_host.AddChild(root)`) and keeps it topmost by calling `BringToFront` from its OWN `Tick()`, which `RetailUiRuntime.Tick` schedules AFTER both `RetailDialogFactory.Tick()` and `Host.Tick()` in the same frame — guaranteeing the tooltip wins whatever z-order race those two just ran, every frame, regardless of which dialog/screen last called its own `BringToFront`. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`Tick`, `OnTooltipShow`'s `AddChild`/`BringToFront`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`Tick`'s three-call ordering, `MountTooltipPresenter`) | Reproduces the one observable invariant a user can check (tooltips always draw on top of dialogs and screens) without porting retail's literal separate-layer architecture (no second draw pass, no dedicated presentation root) — the SAME tradeoff AP-229 already accepted for dialogs, extended one layer further. The ordering is enforced structurally (three sequential calls in one method), not by convention, so it cannot silently regress from an unrelated edit reordering unrelated `Tick` calls elsewhere. **F10 correction (2026-08-16 review round), two honest additions:** (1) the guarantee is versus dialogs/screens ONLY — `UiRoot.DrawCore`'s own second pass (`ctx.BeginOverlayLayer(); DrawOverlays(ctx); DrawDragGhost(ctx);`) routes open dropdown/menu popups and the drag ghost to a renderer overlay layer that paints over the WHOLE sibling tree unconditionally, so both still paint above a shown tooltip regardless of any `BringToFront` ordering — no z-order fix in the sibling tree can reach that layer. (2) counting the full chain by its own actual participants (not just the three calls local to `RetailUiRuntime.Tick`'s tooltip-adjacent lines), the per-tick `BringToFront` ratchet has FOUR rungs in frame order: `CharacterManagementUiController.Tick`, `CharacterCreationUiController.Tick` (both named in `RetailDialogFactory`'s own GF-15 doc comment as the screens it re-asserts over), `RetailDialogFactory.Tick`, then `RetailTooltipPresenter.Tick`. Four independent per-tick self-reraises stacked by tick ORDER is a design smell — a correct z-order model would need at most one authoritative comparison, not N racing assertions — but is bounded and enumerable in practice (no unbounded surface list, the order is fixed source, not runtime-discovered) so it is left as observed rather than restructured this round. | A FUTURE always-on-top UI surface that calls its own unconditional per-tick `BringToFront` AFTER `TooltipPresenter?.Tick()` in `RetailUiRuntime.Tick`'s ordering could bury a currently-shown tooltip — the exact failure class AP-229 already named for dialogs-vs-screens, now with four layers instead of two. | `UIElementManager::StartTooltip @0x00459700` (`m_pTooltipElement` ownership); AP-229's own dialog/screen precedent | | AD-73 | Filed 2026-08-11 at the Campaign OP OP2 rework (fix round after a double REJECT). `UiTabPanel` (dat Type 8, formerly `UiTabControl`) does NOT perform retail's automatic tab-table wiring / default-page activation at construction. Retail `UIElement_Panel::SetupTabPageHash @0x0046C2E0` + `::Update @0x0046BD00` unconditionally activate the authored default page for ANY instance that carries a tab table. `UiTabPanel` instead stays DORMANT — no click binding, no page-visibility flip, no tab Open/Closed write — until a controller explicitly calls `ActivateTabBehavior()`. | `src/AcDream.App/UI/UiTabPanel.cs` (`ActivateTabBehavior`); factory site `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (Type-8 arm) | Four already-shipped Type-8 hosts author a tab table today — character sheet root `0x10000227`, spellbook root `0x100002A8`, and vendor `0x100000B8` already implement this exact switching in their own C# controllers (`CharacterStatController`/`SpellbookWindowController`/`VendorUiController`); activating `UiTabPanel`'s own copy unconditionally would double-drive the same page-visibility/tab-state writes those controllers already own. Combat `0x100000A2` has no controller at all and is INTENTIONALLY left inert (its 8 stance pages have no switching UI yet) rather than have `UiTabPanel` silently take ownership. Only newly-authored hosts opt in (Options panel, Campaign OP slice OP3+; Configure Keyboard, OP8). This is what let the unconditional Type-8 factory mapping become safe after the OP2 REJECT (`docs/research/2026-08-11-op2-review-blast.md`, `docs/research/2026-08-11-op2-review-mechanism.md`). | A future panel that authors a Type-8 tab table but never gets a controller call to `ActivateTabBehavior()` renders with every tab button at its authored default (Closed) and every page slot at its default `Visible=true` — i.e. every page overlapping, no single active page — instead of retail's exactly-one-visible-page behavior. This is silent unless the diagnostic `UnresolvedEntries`/`BehaviorActive` surface is checked; a controller author who forgets the activation call will see a visually broken tab host, not a crash. | `UIElement_Panel::SetupTabPageHash @0x0046C2E0`; `UIElement_Panel::Update @0x0046BD00`; `UIElement_Panel::OpenTab @0x0046BE20`. ADDENDUM (2026-08-11, re-review closure): `UiTemplateListBox` additionally reports `ConsumesDatChildren = true` where the pre-rework fallback did not — inert against every shipped layout because no Type-5 element in any of the 32 fixtures authors children (now conformance-PINNED in `OP2ReworkBlastRadiusConformanceTests`, so an authored child appearing in a future DAT regeneration fails the build instead of silently vanishing) | | ~~AD-53~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `Transition.CliffSlide` now consumes only `collision_info.last_known_contact_plane.N`, exactly as retail does. The invented `LastWalkablePlane -> LastKnownContactPlane -> UnitZ` fallback chain is gone; invalid/default or parallel data takes retail's degenerate `OK_TS` return. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CliffSlide`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::cliff_slide` pc:272397 (0050a6d0); `last_known_contact_plane` maintenance pc:272659-272668 (~0050ad07) | diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 5eb7b80a..f723414a 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -978,14 +978,22 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory AllegianceSetUpdateSubscription: on => late.GameRuntime.AllegianceSetUpdateSubscription(on), Trade: d.Runtime.Trade), - // Batch C (overnight hover/UI round): HousePosition/ - // HouseLines/HouseShown are left unwired (their bindings - // default to "no house"/empty/no-op) — the House wire - // groundwork (RuntimeHouseState) lands separately; the - // panel mounts and the Map tab works standalone either way. + // Batch C (overnight hover/UI round, 2026-08-17): HouseLines/ + // HouseShown 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. MapHouse: new MapHouseRuntimeBindings( CurrentCalendar: d.CurrentCalendar, - PlayerCellId: () => d.PlayerController.Controller?.CellId ?? 0u), + PlayerCellId: () => d.PlayerController.Controller?.CellId ?? 0u, + HouseLines: () => d.Runtime.HouseOwner.Lines, + // Not a ported retail call site — neither gmHouseUI:: + // PostInit nor gmMapUI::PostInit sends an outbound + // HouseQuery; this is the acdream "fire when the House + // tab is shown" convenience HousePageController.Bindings. + // OnShown's own doc already documents. + HouseShown: () => late.Session.CurrentSession?.SendHouseQuery()), StackSplitQuantity: d.StackSplitQuantity, Plugins: d.UiRegistry, Persistence: persistence, diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 6af15653..c3d90c13 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -327,7 +327,8 @@ 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, diff --git a/src/AcDream.App/UI/Layout/HousePageController.cs b/src/AcDream.App/UI/Layout/HousePageController.cs index 9ceb219d..da2849e7 100644 --- a/src/AcDream.App/UI/Layout/HousePageController.cs +++ b/src/AcDream.App/UI/Layout/HousePageController.cs @@ -25,19 +25,22 @@ namespace AcDream.App.UI.Layout; /// /// /// -/// Scope (Batch C, 2026-08-17) — see ISSUES #413 for the full ledger. -/// This session shipped the mount (this class) and the wire PARSING +/// Scope — see ISSUES #413 for the full ledger. Batch C +/// (2026-08-17) shipped the mount (this class) and the wire PARSING /// groundwork (GameEvents.ParseHouseData/ParseHouseStatus/ /// ParseUpdateRentTime/ParseUpdateRentPayment, /// GameEventWiring's four delegate holes, the outbound HouseQuery -/// action). / are -/// NOT yet wired to real data — no RuntimeHouseState owner exists, -/// and none of the seven Display* line builders -/// DisplayHouseData calls (including -/// DisplayPurchaseTimeText @0x004a3110's two fully-recovered -/// literal strings) are ported. Until #413 closes, this page mounts with -/// genuinely empty content — matching retail's own PostInit, which -/// never calls Update/DisplayHouseData either. +/// action). The House-tab ownership-text closer session (also 2026-08-17) +/// wired / to the +/// minimal RuntimeHouseState owner and ported +/// DisplayPurchaseTimeText @0x004a3110's expired branch — a fresh +/// houseless character's House tab now shows the single decomp-verified +/// line "You may buy another house immediately." after the tab is opened, +/// live-connected-gate-verified (screenshot + structural UI-tree dump +/// against the real +Acdream character on a local ACE server). The +/// other six Display* line builders DisplayHouseData calls +/// (owned-house-only content: buy/rent payments and times, location, +/// warning text) remain unported — ISSUES #413's surviving scope. /// /// public sealed class HousePageController @@ -52,7 +55,19 @@ public sealed class HousePageController // see ISSUES #413. NOT a ported retail call site (PostInit never // triggers a query) — an acdream convention, documented as such // (recon doc open item). - Action? OnShown = null); + 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? TemplateResolver = null); private readonly UiTemplateListBox _listBox; private readonly Bindings _bindings; @@ -76,6 +91,7 @@ public sealed class HousePageController return null; } + listBox.TemplateResolver = bindings.TemplateResolver; var controller = new HousePageController(listBox, bindings); controller.Refresh(bindings.Lines()); return controller; diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index a42d1441..f8712a41 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -3339,7 +3339,11 @@ public sealed class RetailUiRuntime : IDisposable TemplateResolver: ResolveHotspotTemplate), House: new Layout.HousePageController.Bindings( Lines: mh.HouseLines ?? (static () => Array.Empty()), - OnShown: mh.HouseShown)); + 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) diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index 6f5defb9..4226fb0b 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -1087,7 +1087,14 @@ public static class GameEvents /// RecvNotice_FailedHouseTransaction family (also the "no house /// owned" reply to a HouseQuery — ACE Player_House.cs /// HandleActionQueryHouse's new GameEventHouseStatus(Session) - /// defaults to WeenieError.None, not a "failure"). + /// defaults to WeenieError.BadParam (corrected 2026-08-17; an + /// earlier note here said WeenieError.None, which is not what + /// GameEventHouseStatus's own constructor default reads). The + /// value is moot either way — decomp-confirmed retail's own + /// gmHouseUI::Update(uint32_t)/gmMapUI:: + /// RecvNotice_FailedHouseTransaction never read this field + /// (AcDream.Runtime.Gameplay.RuntimeHouseState.ApplyHouseStatus + /// accepts and discards it for the same reason). public static uint? ParseHouseStatus(ReadOnlySpan payload) { if (payload.Length < 4) return null; diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index ecb985a8..f89374fd 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -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 /// Secure trade (2026-08-14): third sibling J-owner. public RuntimeTradeState TradeOwner { get; } + + /// Batch C (2026-08-17): House tab minimal owner — see + /// 's own class doc for the sizing + /// rationale. + public RuntimeHouseState HouseOwner { get; } public RuntimeActionState ActionOwner { get; } public RuntimeLocalPlayerMovementState MovementOwner { get; } internal RuntimeLocalPlayerPhysicsPublicationState diff --git a/src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs b/src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs new file mode 100644 index 00000000..0675e29e --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs @@ -0,0 +1,180 @@ +using AcDream.Core.Items; +using AcDream.Core.Net.Messages; +using AcDream.Core.Properties; + +namespace AcDream.Runtime.Gameplay; + +/// +/// Canonical presentation-independent owner for the House tab of retail's +/// two-tab Map/House panel (gmHouseUI). Deliberately MINIMAL — +/// "houseless-status only" per ISSUES #413's own sizing note: a full +/// RuntimeTradeState-weight owner (construction-transaction +/// Fault() 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. +/// +/// +/// +/// Retail behavior, exhaustively verified against the decomp before +/// writing this class (all seven line builders read, not just the one +/// ported here): gmHouseUI::PostInit @0x004a2710 never calls +/// Update/DisplayHouseData — the House ListBox +/// (0x100001e6) starts genuinely empty (live-DAT-confirmed, +/// MapHousePanelSlotProbeTests: children=0, and the whole +/// House page 0x100001F7 has NO other static content besides that +/// one empty ListBox). Content appears only after a server notice +/// (0x0225-0x0228) arrives and Update/DisplayHouseData runs +/// the seven Display* builders in order. SIX of them +/// (DisplayBuyPayment, DisplayRentPayment, +/// DisplayBuyTime, DisplayRentTimes, DisplayLocation, +/// DisplayWarningText) open with if (this->m_pHouseData != 0) +/// and emit NOTHING when houseless — those remain unported, ISSUES #413 +/// item 3. +/// +/// +/// The SEVENTH, gmHouseUI::DisplayPurchaseTimeText @0x004a3110, does +/// NOT gate on m_pHouseData — it always runs, reading the LOCAL +/// PLAYER's own PropertyInt.HousePurchaseTimestamp (0xC7 = 199 +/// decimal) via CBaseQualities::InqInt and +/// HouseSystem::HasPurchaseWaitPeriodExpired +/// (@0x005bb1d0: (Timer::get_real_time() - timestamp) > +/// 0x278d00; Timer::get_real_time = time(0), Unix epoch +/// seconds; 0x278d00 = 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 +/// m_pHouseData == 0 (still houseless) and emits the ONE literal +/// string at data_7ab7f0: "You may buy another house +/// immediately." 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 +/// data_7ab818, 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 ("You do not own a house!", +/// WeenieError 0x45E/0x45F, and "You must own a house to +/// use this command.", WeenieError 0x47F) routed through the +/// GENERIC WeenieError chat dispatcher, never through gmHouseUI's +/// own notice handlers (which discard the wire WeenieError entirely — see +/// ). +/// +/// +/// The NOT-yet-expired branch of DisplayPurchaseTimeText (a +/// strftime-formatted future date plus a BN-truncated suffix) is +/// left unported per ISSUES #413 item 2's own scoping — its format string +/// is unrecoverable from this decomp dump. +/// +/// +public sealed class RuntimeHouseState +{ + /// HouseSystem::HasPurchaseWaitPeriodExpired's + /// literal, 0x278d00 = 2,592,000 seconds = 30 days. + 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 _lines = Array.Empty(); + + /// Borrows the canonical object table (optional for bare + /// fixtures) to read the local player's own + /// PropertyInt.HousePurchaseTimestamp — the same borrowed-owner + /// shape uses for its own object-table + /// read. + public RuntimeHouseState( + ClientObjectTable? objects = null, TimeProvider? timeProvider = null) + { + _objects = objects; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// The House tab's exact ListBox content — empty until the + /// first server notice arrives, matching retail's own PostInit (never + /// calls Update/DisplayHouseData). + public IReadOnlyList Lines + { + get { lock (_gate) return _lines; } + } + + /// Whether any of the four House notices (0x0225-0x0228) has + /// arrived this session. + public bool HasReceivedNotice + { + get { lock (_gate) return _hasReceivedNotice; } + } + + /// 0x0225 HouseData — RecvNotice_UpdateHouseData + /// (owned-house case). Only is consumed today; + /// the owned-house payload itself (buy/rent payments, times, location) + /// feeds ISSUES #413's remaining six builders, not yet ported. + public void ApplyHouseData(GameEvents.HouseData data, uint selfGuid) + { + lock (_gate) + { + _hasReceivedNotice = true; + _ownsHouse = true; + Recompute(selfGuid); + } + } + + /// 0x0226 HouseStatus — RecvNotice_FailedHouseTransaction + /// (the "no house owned" reply to a HouseQuery, per ACE's + /// HandleActionQueryHouse). is + /// accepted for wire-shape completeness but intentionally UNUSED: + /// decomp-confirmed retail's own Update(uint32_t) overload never + /// reads its arg2 parameter — the wire WeenieError is discarded, + /// not surfaced as chat or panel text. + public void ApplyHouseStatus(uint weenieError, uint selfGuid) + { + _ = weenieError; + lock (_gate) + { + _hasReceivedNotice = true; + _ownsHouse = false; + Recompute(selfGuid); + } + } + + /// Generation reset — a fresh login must not show a previous + /// character's house-query result. Restores the exact pre-notice + /// "genuinely empty" state. + public void ResetSession() + { + lock (_gate) + { + _hasReceivedNotice = false; + _ownsHouse = false; + _lines = Array.Empty(); + } + } + + /// gmHouseUI::DisplayPurchaseTimeText @0x004a3110's + /// expired branch, ported faithfully. Must hold . + 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: strftime-formatted future date + a + // BN-truncated suffix, unrecoverable from this decomp dump. + // ISSUES #413 item 2 — deferred, not guessed. + _lines = Array.Empty(); + return; + } + + _lines = new[] + { + _ownsHouse + ? "You may buy another house immediately after you abandon this one." + : "You may buy another house immediately.", + }; + } +} diff --git a/src/AcDream.Runtime/RuntimeGenerationReset.cs b/src/AcDream.Runtime/RuntimeGenerationReset.cs index 786cc772..28441e7c 100644 --- a/src/AcDream.Runtime/RuntimeGenerationReset.cs +++ b/src/AcDream.Runtime/RuntimeGenerationReset.cs @@ -60,15 +60,24 @@ public enum RuntimeGenerationResetStage /// beside its fellowship/allegiance precedents. /// Trade = 14, - BeginEntityRetirement = 15, - RetireEntities = 16, - DrainHostProjection = 17, - CompleteCanonicalEntities = 18, - CompleteHostProjection = 19, - ChatIdentity = 20, - PlayerSnapshots = 21, - PlayerIdentity = 22, - Complete = 23, + /// + /// 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 's class doc for why this + /// owner is lighter-weight than its three siblings (no disposal, no + /// construction-transaction Fault() point). + /// + 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 diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index 7cd25774..53179aae 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -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); /// /// 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(); diff --git a/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs index f57a97ae..f42d7b1c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs @@ -37,6 +37,14 @@ public sealed class MapHousePanelControllerTests DatElementId = elementId, }; + /// The House ListBox's own row template resolves to a + /// in the live DAT (MapHousePanelSlotProbeTests: + /// "row template type=12" — UIElement_Text), unlike the Map tab's + /// UiButton hotspots above — a fresh instance per call, matching + /// production's real resolver. + private static UiElement? FakeHouseRowTemplate(uint layoutId, uint elementId) + => new UiText { Width = 280f, Height = 28f }; + private static MapHousePanelController.Callbacks MakeCallbacks( List? calls = null, Func? currentCalendar = null, @@ -54,7 +62,8 @@ public sealed class MapHousePanelControllerTests TemplateResolver: FakeHotspotTemplate), House: new HousePageController.Bindings( Lines: houseLines ?? (static () => Array.Empty()), - OnShown: () => calls.Add("house-shown"))); + OnShown: () => calls.Add("house-shown"), + TemplateResolver: FakeHouseRowTemplate)); } [Fact] @@ -186,4 +195,41 @@ public sealed class MapHousePanelControllerTests var listBox = Assert.IsType(box); Assert.Equal(0, listBox.ContentHeight); } + + /// + /// Batch C House-ownership-text closer (2026-08-17): the ONE + /// decomp-verified gmHouseUI::DisplayPurchaseTimeText @0x004a3110 + /// line a houseless character's HouseQuery response renders — proves + /// 's revision-gated + /// poll actually rebuilds the + /// authored row template with real text end-to-end, the same way + /// proves the text composition in + /// isolation. + /// + [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(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( + listBox.ViewportForTest); + Assert.Single(viewport.Children); + var row = Assert.IsType(viewport.Children[0]); + Assert.Equal( + "You may buy another house immediately.", + Assert.Single(row.LinesProvider()).Text); + } } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs new file mode 100644 index 00000000..683a8e55 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs @@ -0,0 +1,161 @@ +using AcDream.Core.Items; +using AcDream.Core.Net.Messages; +using AcDream.Core.Properties; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Gameplay; + +/// +/// House-tab conformance (Batch C, 2026-08-17): the ONE decomp-verified +/// line gmHouseUI::DisplayPurchaseTimeText @0x004a3110 emits for a +/// houseless/fresh character, and the wait-period-not-expired case that +/// stays empty (unrecoverable strftime format, ISSUES #413 item 2). +/// +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_RendersNoLine() + { + // HouseSystem::HasPurchaseWaitPeriodExpired: (now - timestamp) > + // 0x278d00 (2,592,000 s = 30 days). Inside the window, retail takes + // the strftime-formatted branch this session leaves unported + // (ISSUES #413 item 2) — must render nothing, not a guess. + 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); + + Assert.Empty(house.Lines); + } + + [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(), + Rent: Array.Empty(), + 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; + + public void Advance(TimeSpan elapsed) => _now += elapsed; + } +}