fix(ui): night-round review — F8 House not-expired purchase-restriction text

gmHouseUI::DisplayPurchaseTimeText @0x004a3110's not-yet-expired
branch was wrongly marked "unrecoverable from this decomp dump" — a
direct capstone disassembly resolves all three concatenated pieces:
prefix "You may buy another landscape house at " @0x7ab790 (pushed
@0x004a3265), the strftime "%c" format literal @0x7ab7ec (pushed
@0x004a321d) applied to localtime(timestamp + 0x278d00) — the expiry
moment, 30 days after the purchase timestamp — and suffix ". This
restriction does not apply to apartments." @0x7ab7b8 (pushed
@0x004a3235).

Ported in RuntimeHouseState.Recompute, substituting .NET's
culture-default DateTime.ToString() for the CRT's strftime("%c", ...)
(different formatting engine, same "process locale, full date+time"
intent) — filed as register row IA-23 (an approximation, not a gap).
TimeProvider.LocalTimeZone (overridable, defaulting to
TimeZoneInfo.Local in production) keeps the conversion deterministically
testable while matching retail's own localtime() call.

Updated RuntimeHouseStateTests: the not-expired case now asserts the
composed prefix/suffix structure and the exact expiry instant (pinned
via a UTC-fixed test TimeProvider), replacing the old "renders nothing"
assertion. Un-claimed "unrecoverable" in ISSUES #413 item 2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 04:50:36 +02:00
parent ab84b54dfa
commit df062d2eda
4 changed files with 110 additions and 22 deletions

View file

@ -1,3 +1,4 @@
using System.Globalization;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Properties;
@ -61,10 +62,26 @@ namespace AcDream.Runtime.Gameplay;
/// <see cref="ApplyHouseStatus"/>).
/// </para>
/// <para>
/// The NOT-yet-expired branch of <c>DisplayPurchaseTimeText</c> (a
/// <c>strftime</c>-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.
/// The NOT-yet-expired branch of <c>DisplayPurchaseTimeText</c> is now
/// ported (night-round review F8) — its literals were NOT unrecoverable;
/// the "BN-truncated suffix" was another instance of Binary Ninja's
/// operator-overload plumbing obscuring plain pushed string constants (the
/// same artifact class TS-85/F3 hit). A direct capstone disassembly of the
/// raw bytes resolves all three pieces retail concatenates: prefix
/// <c>"You may buy another landscape house at "</c> @<c>0x7ab790</c>
/// (pushed <c>@0x004a3265</c>), the <c>strftime</c> format literal
/// <c>"%c"</c> @<c>0x7ab7ec</c> (pushed <c>@0x004a321d</c>) applied to
/// <c>localtime(timestamp + 0x278d00)</c> — the expiry moment, not "now" —
/// and suffix <c>". This restriction does not apply to apartments."</c>
/// @<c>0x7ab7b8</c> (pushed <c>@0x004a3235</c>). <c>strftime</c>'s
/// <c>"%c"</c> is the C runtime's locale-default full date+time
/// representation; this port's honest analogue is .NET's own
/// culture-default <c>DateTime.ToString()</c> (no explicit format) — NOT a
/// byte-identical reproduction of the CRT's locale table, since .NET and
/// the CRT do not share a formatting engine, but the same "whatever the
/// process's own locale says" intent. Register row IA-23
/// (<c>docs/architecture/retail-divergence-register.md</c>) records this
/// approximation.
/// </para>
/// </remarks>
public sealed class RuntimeHouseState
@ -152,8 +169,8 @@ public sealed class RuntimeHouseState
}
}
/// <summary><c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>'s
/// expired branch, ported faithfully. Must hold <see cref="_gate"/>.</summary>
/// <summary><c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>,
/// both branches now ported. Must hold <see cref="_gate"/>.</summary>
private void Recompute(uint selfGuid)
{
int timestamp = _objects?.Get(selfGuid)?.Properties
@ -163,10 +180,30 @@ public sealed class RuntimeHouseState
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<string>();
// Not-yet-expired branch, byte-decoded (night-round review
// F8): retail computes the EXPIRY moment (timestamp + 30 days,
// @0x004a3212's `var_42c += 0x278d00`), formats it through
// `localtime` + `strftime("%c", ...)` (@0x004a322c), and
// concatenates prefix + date + suffix
// (@0x004a3265/@0x004a321d/@0x004a3235). .NET's
// culture-default DateTime.ToString() is the honest %c
// analogue (see the class doc's own note — not byte-identical
// to the CRT's locale table, same "process locale" intent).
// TimeProvider.LocalTimeZone (not the ambient system zone
// directly) keeps this deterministically testable while
// matching retail's own `localtime()` (process-local time) in
// production, where TimeProvider.System.LocalTimeZone IS
// TimeZoneInfo.Local.
DateTimeOffset expiryUtc = DateTimeOffset.FromUnixTimeSeconds(
timestamp + PurchaseWaitPeriodSeconds);
DateTime expiryLocal = TimeZoneInfo.ConvertTime(
expiryUtc, _timeProvider.LocalTimeZone).DateTime;
_lines = new[]
{
"You may buy another landscape house at "
+ expiryLocal.ToString(CultureInfo.CurrentCulture)
+ ". This restriction does not apply to apartments.",
};
return;
}