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

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