fix(ui): morning gate — House tab renders retail's TWO houseless lines, not one

User finding 2 (retail screenshot, houseless character): the House tab
shows "You do not currently own a house." ABOVE "You may buy another
house immediately." — ours showed only the second line, and the prior
session had REFUTED the first line outright ("no such string exists
anywhere in the 2013 dump").

Re-derivation: the string exists in the binary at data_7ab688 — it is
gmHouseUI::DisplayBuyPayment @0x004a2b30's HOUSELESS branch. Two
compounding misreads hid it: (a) DisplayBuyPayment was mislabeled
houseless-silent, but its m_pHouseData gate only selects WHICH text
(jne 0x4a2b63) — the ListBox emit (@0x004a2b80 onward,
AddItemFromTemplateList + SetTextWithFont) runs in BOTH branches; and
(b) BN's pseudo-C renders both push-literal operands as spurious
&vftable.RecvNotice_* symbol matches (the TS-85/F3 artifact class), so
text sweeps of the dump find nothing — capstone byte-decode of the
PDB-paired binary resolves houseless @0x004a2b57 push 0x7ab688 =
"You do not currently own a house." and owned @0x004a2b63 push
0x7ab65c = "The purchase price for this dwelling is:\n" (+
HousePaymentList::ComposeText, still #413 item-3 scope). The morning
brief's alternate DAT-string-table hypothesis was checked and is NOT
the mechanism — plain exe string-pool literal.

RuntimeHouseState.Recompute now renders the houseless case as retail's
exact two lines in gmHouseUI::Update's fixed builder order
(DisplayBuyPayment first, DisplayPurchaseTimeText last); the owned case
is unchanged (its DisplayBuyPayment content needs ComposeText, #413
item 3). Class doc + ISSUES #413 corrected honestly — the user's retail
evidence supersedes the earlier refutation. Runtime house tests updated
to pin both lines; 9/9 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 08:54:55 +02:00
parent 9d9280a069
commit ef567bfa20
3 changed files with 139 additions and 76 deletions

View file

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

View file

@ -17,26 +17,42 @@ namespace AcDream.Runtime.Gameplay;
/// </summary>
/// <remarks>
/// <para>
/// <b>Retail behavior, exhaustively verified against the decomp before
/// writing this class (all seven line builders read, not just the one
/// ported here):</b> <c>gmHouseUI::PostInit @0x004a2710</c> never calls
/// <c>Update</c>/<c>DisplayHouseData</c> — the House ListBox
/// <b>Retail behavior:</b> <c>gmHouseUI::PostInit @0x004a2710</c> never
/// calls <c>Update</c>/<c>DisplayHouseData</c> — the House ListBox
/// (<c>0x100001e6</c>) starts genuinely empty (live-DAT-confirmed,
/// <c>MapHousePanelSlotProbeTests</c>: <c>children=0</c>, and the whole
/// House page <c>0x100001F7</c> has NO other static content besides that
/// one empty ListBox). Content appears only after a server notice
/// (0x0225-0x0228) arrives and <c>Update</c>/<c>DisplayHouseData</c> runs
/// the seven <c>Display*</c> builders in order. SIX of them
/// (<c>DisplayBuyPayment</c>, <c>DisplayRentPayment</c>,
/// <c>DisplayBuyTime</c>, <c>DisplayRentTimes</c>, <c>DisplayLocation</c>,
/// the seven <c>Display*</c> builders in fixed order. FIVE of them
/// (<c>DisplayRentPayment</c>, <c>DisplayBuyTime</c>,
/// <c>DisplayRentTimes</c>, <c>DisplayLocation</c>,
/// <c>DisplayWarningText</c>) open with <c>if (this-&gt;m_pHouseData != 0)</c>
/// and emit NOTHING when houseless — those remain unported, ISSUES #413
/// item 3.
/// </para>
/// <para>
/// The SEVENTH, <c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>, does
/// NOT gate on <c>m_pHouseData</c> — it always runs, reading the LOCAL
/// PLAYER's own <c>PropertyInt.HousePurchaseTimestamp</c> (0xC7 = 199
/// <b>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):</b> the first builder, <c>gmHouseUI::DisplayBuyPayment
/// @0x004a2b30</c>, is NOT houseless-silent — its <c>m_pHouseData</c> gate
/// only selects WHICH text, and the ListBox emit
/// (<c>AddItemFromTemplateList</c> + <c>SetTextWithFont</c>,
/// <c>@0x004a2b80</c> onward) runs in BOTH branches. The houseless
/// <c>else</c> branch (<c>@0x004a2b57</c>: <c>push 0x7ab688</c>,
/// byte-decoded from the PDB-paired binary — Binary Ninja's pseudo-C
/// rendered the operand as a spurious <c>&amp;vftable.RecvNotice_*</c>
/// symbol match, the same TS-85/F3 artifact class, which is how the night
/// round's exe-pool sweep missed it) is the literal <b>"You do not
/// currently own a house."</b> — exactly the user's retail screenshot. The
/// owned branch (<c>@0x004a2b63</c>, <c>data_7ab65c</c>: <c>"The purchase
/// price for this dwelling is:\n"</c> + <c>HousePaymentList::ComposeText</c>)
/// remains #413 item-3 scope.
/// </para>
/// <para>
/// The SEVENTH builder, <c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>,
/// also does NOT gate on <c>m_pHouseData</c> — it always runs, reading the
/// LOCAL PLAYER's own <c>PropertyInt.HousePurchaseTimestamp</c> (0xC7 = 199
/// decimal) via <c>CBaseQualities::InqInt</c> and
/// <c>HouseSystem::HasPurchaseWaitPeriodExpired</c>
/// (<c>@0x005bb1d0</c>: <c>(Timer::get_real_time() - timestamp) &gt;
@ -44,22 +60,12 @@ namespace AcDream.Runtime.Gameplay;
/// seconds; <c>0x278d00</c> = 2,592,000 s = 30 days). For a fresh/houseless
/// character with no timestamp ever set (absent property reads as 0), this
/// is trivially true, taking the "expired" branch, which reads
/// <c>m_pHouseData == 0</c> (still houseless) and emits the ONE literal
/// string at <c>data_7ab7f0</c>: <b>"You may buy another house
/// immediately."</b> That is the exact, decomp-verified, single line of
/// content a houseless character's House tab shows once queried — this
/// class ports exactly that (and its owns-a-house sibling at
/// <c>data_7ab818</c>, unreachable by a fresh character but faithfully
/// ported alongside it). No other function, WeenieError-to-chat mapping,
/// or authored LayoutDesc content anywhere in the decomp/live DAT produces
/// a second line for the houseless case — a broader search for chat-scroll
/// strings mentioning house ownership found only unrelated, differently
/// worded command-error text (<c>"You do not own a house!"</c>,
/// WeenieError <c>0x45E</c>/<c>0x45F</c>, and <c>"You must own a house to
/// use this command."</c>, WeenieError <c>0x47F</c>) routed through the
/// GENERIC WeenieError chat dispatcher, never through <c>gmHouseUI</c>'s
/// own notice handlers (which discard the wire WeenieError entirely — see
/// <see cref="ApplyHouseStatus"/>).
/// <c>m_pHouseData == 0</c> (still houseless) and emits the literal at
/// <c>data_7ab7f0</c>: <b>"You may buy another house immediately."</b>
/// (owns-a-house sibling at <c>data_7ab818</c>, 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.
/// </para>
/// <para>
/// The NOT-yet-expired branch of <c>DisplayPurchaseTimeText</c> is now
@ -169,10 +175,27 @@ public sealed class RuntimeHouseState
}
}
/// <summary><c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>,
/// both branches now ported. Must hold <see cref="_gate"/>.</summary>
/// <summary>The ported share of <c>gmHouseUI::Update</c>'s fixed
/// seven-builder order: <c>DisplayBuyPayment @0x004a2b30</c>'s
/// houseless branch (first) and <c>DisplayPurchaseTimeText
/// @0x004a3110</c> (last), both branches. The five houseless-silent
/// builders between them, and DisplayBuyPayment's owned branch, are
/// ISSUES #413 item 3. Must hold <see cref="_gate"/>.</summary>
private void Recompute(uint selfGuid)
{
var lines = new List<string>(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;
}
}

View file

@ -6,10 +6,13 @@ using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
/// <summary>
/// House-tab conformance (Batch C, 2026-08-17): the ONE decomp-verified
/// line <c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c> emits for a
/// houseless/fresh character, and the wait-period-not-expired case that
/// stays empty (unrecoverable strftime format, ISSUES #413 item 2).
/// 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 — <c>gmHouseUI::DisplayBuyPayment
/// @0x004a2b30</c>'s houseless branch ("You do not currently own a
/// house.", byte-decoded <c>data_7ab688</c> — the m_pHouseData gate only
/// selects WHICH text; the emit is unconditional) followed by
/// <c>DisplayPurchaseTimeText @0x004a3110</c>'s wait-period line.
/// </summary>
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(