diff --git a/docs/ISSUES.md b/docs/ISSUES.md index ff8429be..6e573eba 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,12 +24,14 @@ 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) +## #413 — House tab shows no content (owned-house display, 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. +round (F8); **the houseless case CORRECTED same-day at the morning gate +round (user finding 2 — see the correction note inside item 2)**. Items 1 +and 2 below are DONE; item 3 (owned-house-only builder content) remains +OPEN and is the entire remaining scope. **What's shipped (this session, on top of Batch C's mount + parser groundwork).** @@ -71,23 +73,34 @@ groundwork).** 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. + **CORRECTION (2026-08-17 morning gate round, user finding 2 — the + paragraph this replaces was WRONG):** the earlier session had refuted + the preceding line "You do not currently own a house." with the claim + that "no such string exists anywhere in the 2013 EoR + `acclient_2013_pseudo_c.txt` dump" — but the user's retail screenshot + (their live client, houseless character) shows exactly that line ABOVE + "You may buy another house immediately.", and the user's side-by-side + retail reports are axioms. The re-derivation found the string DOES + exist in the 2013 binary, at `data_7ab688`: it is + `gmHouseUI::DisplayBuyPayment @0x004a2b30`'s HOUSELESS branch. Two + compounding misreads hid it: (a) `DisplayBuyPayment` was mislabeled + houseless-silent — its `m_pHouseData` gate only selects WHICH text + (`jne 0x4a2b63`); the ListBox emit (`AddItemFromTemplateList` + + `SetTextWithFont`, `@0x004a2b80` onward) runs in BOTH branches; and + (b) the pseudo-C dump renders both `push ` operands as + spurious `&gmHouseUI::vftable'.RecvNotice_*` symbol matches (the same + BN artifact class TS-85/F3 documented), so a TEXT sweep of the dump + finds no house strings there — the raw string pool has them + (byte-decoded via capstone this round: houseless `@0x004a2b57` `push + 0x7ab688` = "You do not currently own a house."; owned `@0x004a2b63` + `push 0x7ab65c` = "The purchase price for this dwelling is:\n" + + `HousePaymentList::ComposeText`, still item-3 scope). The morning-gate + task brief's alternate hypothesis (a DAT string-table id) was also + checked and is NOT the mechanism — it is a plain exe string-pool + literal, same as every other gmHouseUI line. `RuntimeHouseState. + Recompute` now renders the houseless case as retail's exact TWO lines + in builder order: "You do not currently own a house." then the + purchase-time line; `RuntimeHouseStateTests` updated to pin both. **Also fixed in the same pass: `HousePageController.Bind` never wired `UiTemplateListBox.TemplateResolver`.** Without it, @@ -101,10 +114,11 @@ groundwork).** **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 +3. **The owned-house-only `Display*` line content** (`DisplayBuyPayment`'s + OWNED branch — its houseless branch shipped at the 2026-08-17 morning + gate correction above — plus `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 diff --git a/src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs b/src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs index 2c1ac073..0c0a91dc 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs @@ -17,26 +17,42 @@ namespace AcDream.Runtime.Gameplay; /// /// /// -/// 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 +/// Retail behavior: 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, +/// the seven Display* builders in fixed order. FIVE of them +/// (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 +/// CORRECTED at the 2026-08-17 morning gate round (user finding 2: the +/// retail House tab shows TWO lines for a houseless character; ours showed +/// one): the first builder, gmHouseUI::DisplayBuyPayment +/// @0x004a2b30, is NOT houseless-silent — its m_pHouseData gate +/// only selects WHICH text, and the ListBox emit +/// (AddItemFromTemplateList + SetTextWithFont, +/// @0x004a2b80 onward) runs in BOTH branches. The houseless +/// else branch (@0x004a2b57: push 0x7ab688, +/// byte-decoded from the PDB-paired binary — Binary Ninja's pseudo-C +/// rendered the operand as a spurious &vftable.RecvNotice_* +/// symbol match, the same TS-85/F3 artifact class, which is how the night +/// round's exe-pool sweep missed it) is the literal "You do not +/// currently own a house." — exactly the user's retail screenshot. The +/// owned branch (@0x004a2b63, data_7ab65c: "The purchase +/// price for this dwelling is:\n" + HousePaymentList::ComposeText) +/// remains #413 item-3 scope. +/// +/// +/// The SEVENTH builder, gmHouseUI::DisplayPurchaseTimeText @0x004a3110, +/// also 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) > @@ -44,22 +60,12 @@ namespace AcDream.Runtime.Gameplay; /// 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 -/// ). +/// m_pHouseData == 0 (still houseless) and emits the literal at +/// data_7ab7f0: "You may buy another house immediately." +/// (owns-a-house sibling at data_7ab818, byte-re-verified this +/// round). So a queried houseless character's House tab shows exactly TWO +/// lines, in builder order: "You do not currently own a house." then the +/// purchase-time line — which this class now renders. /// /// /// The NOT-yet-expired branch of DisplayPurchaseTimeText is now @@ -169,10 +175,27 @@ public sealed class RuntimeHouseState } } - /// gmHouseUI::DisplayPurchaseTimeText @0x004a3110, - /// both branches now ported. Must hold . + /// The ported share of gmHouseUI::Update's fixed + /// seven-builder order: DisplayBuyPayment @0x004a2b30's + /// houseless branch (first) and DisplayPurchaseTimeText + /// @0x004a3110 (last), both branches. The five houseless-silent + /// builders between them, and DisplayBuyPayment's owned branch, are + /// ISSUES #413 item 3. Must hold . private void Recompute(uint selfGuid) { + var lines = new List(2); + + // gmHouseUI::DisplayBuyPayment @0x004a2b30 — NOT houseless-silent + // (2026-08-17 morning gate correction; see the class doc): the + // m_pHouseData gate only selects WHICH text, and the emit runs in + // both branches. Houseless (@0x004a2b57, byte-decoded data_7ab688): + // this exact literal. Owned (@0x004a2b63, data_7ab65c "The purchase + // price for this dwelling is:\n" + HousePaymentList::ComposeText): + // unported, #413 item 3 — the owned case adds nothing here yet. + if (!_ownsHouse) + lines.Add("You do not currently own a house."); + + // gmHouseUI::DisplayPurchaseTimeText @0x004a3110, both branches. int timestamp = _objects?.Get(selfGuid)?.Properties .GetInt((uint)PropertyInt.HousePurchaseTimestamp) ?? 0; long nowEpochSeconds = _timeProvider.GetUtcNow().ToUnixTimeSeconds(); @@ -198,20 +221,18 @@ public sealed class RuntimeHouseState timestamp + PurchaseWaitPeriodSeconds); DateTime expiryLocal = TimeZoneInfo.ConvertTime( expiryUtc, _timeProvider.LocalTimeZone).DateTime; - _lines = new[] - { + lines.Add( "You may buy another landscape house at " - + expiryLocal.ToString(CultureInfo.CurrentCulture) - + ". This restriction does not apply to apartments.", - }; - return; + + expiryLocal.ToString(CultureInfo.CurrentCulture) + + ". This restriction does not apply to apartments."); + } + else + { + lines.Add(_ownsHouse + ? "You may buy another house immediately after you abandon this one." + : "You may buy another house immediately."); } - _lines = new[] - { - _ownsHouse - ? "You may buy another house immediately after you abandon this one." - : "You may buy another house immediately.", - }; + _lines = lines; } } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs index 71133b14..e2868eb6 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs @@ -6,10 +6,13 @@ 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). +/// House-tab conformance (Batch C, 2026-08-17; two-line correction at the +/// same-day morning gate round): the houseless case renders retail's exact +/// TWO lines in builder order — gmHouseUI::DisplayBuyPayment +/// @0x004a2b30's houseless branch ("You do not currently own a +/// house.", byte-decoded data_7ab688 — the m_pHouseData gate only +/// selects WHICH text; the emit is unconditional) followed by +/// DisplayPurchaseTimeText @0x004a3110's wait-period line. /// public sealed class RuntimeHouseStateTests { @@ -28,7 +31,7 @@ public sealed class RuntimeHouseStateTests } [Fact] - public void HouseStatus_FreshCharacterWithNoTimestamp_ShowsBuyImmediatelyLine() + public void HouseStatus_FreshCharacterWithNoTimestamp_ShowsBothHouselessLines() { var objects = new ClientObjectTable(); objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature }); @@ -40,7 +43,16 @@ public sealed class RuntimeHouseStateTests house.ApplyHouseStatus(weenieError: 0u, Self); Assert.True(house.HasReceivedNotice); - Assert.Equal(["You may buy another house immediately."], house.Lines); + // 2026-08-17 morning gate finding 2 (user retail screenshot): + // BOTH lines, in gmHouseUI::Update's fixed builder order — + // DisplayBuyPayment's houseless literal first, then + // DisplayPurchaseTimeText's expired-branch literal. + Assert.Equal( + [ + "You do not currently own a house.", + "You may buy another house immediately.", + ], + house.Lines); } [Fact] @@ -98,7 +110,11 @@ public sealed class RuntimeHouseStateTests clock.Advance(TimeSpan.FromDays(29)); house.ApplyHouseStatus(weenieError: 0u, Self); - string line = Assert.Single(house.Lines); + // Houseless -> DisplayBuyPayment's line precedes the purchase-time + // line; the not-expired branch supplies the second. + Assert.Equal(2, house.Lines.Count); + Assert.Equal("You do not currently own a house.", house.Lines[0]); + string line = house.Lines[1]; Assert.StartsWith("You may buy another landscape house at ", line); Assert.EndsWith(". This restriction does not apply to apartments.", line); } @@ -124,12 +140,14 @@ public sealed class RuntimeHouseStateTests 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); + Assert.Equal(2, house.Lines.Count); + Assert.Contains( + expectedExpiry.ToString(System.Globalization.CultureInfo.CurrentCulture), + house.Lines[1]); } [Fact] - public void HouseStatus_TimestampPastThirtyDayWindow_ShowsBuyImmediatelyLine() + public void HouseStatus_TimestampPastThirtyDayWindow_ShowsBothHouselessLines() { var clock = new ManualTimeProvider(); var objects = new ClientObjectTable(); @@ -143,7 +161,12 @@ public sealed class RuntimeHouseStateTests clock.Advance(TimeSpan.FromDays(31)); house.ApplyHouseStatus(weenieError: 0u, Self); - Assert.Equal(["You may buy another house immediately."], house.Lines); + Assert.Equal( + [ + "You do not currently own a house.", + "You may buy another house immediately.", + ], + house.Lines); } [Fact] @@ -170,7 +193,12 @@ public sealed class RuntimeHouseStateTests house.ApplyHouseStatus(weenieError: 0u, Self); - Assert.Equal(["You may buy another house immediately."], house.Lines); + Assert.Equal( + [ + "You do not currently own a house.", + "You may buy another house immediately.", + ], + house.Lines); } private static GameEvents.HouseData SampleHouseData() => new(