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

@ -26,9 +26,10 @@ What does NOT go here:
## #413 — House tab shows no content (owned-house display, six Display* line builders unported)
**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.
**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.
**What's shipped (this session, on top of Batch C's mount + parser
groundwork).**
@ -55,9 +56,20 @@ groundwork).**
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.
`data_7ab7f0` in the decomp. **The not-expired `strftime`-formatted
branch is now ALSO ported (night-round review, F8, 2026-08-17) — the
"BN-unrecoverable format string" claim was wrong.** A direct capstone
disassembly of the raw bytes at `gmHouseUI::DisplayPurchaseTimeText`'s
not-expired branch resolves all three literal pieces retail
concatenates: prefix `"You may buy another landscape house at "`
(`data_7ab790`), the `strftime("%c", ...)`-formatted expiry moment
(`timestamp + 0x278d00`, i.e. 30 days after the purchase timestamp), and
suffix `". This restriction does not apply to apartments."`
(`data_7ab7b8`). `RuntimeHouseState.Recompute` now renders this exactly,
substituting .NET's culture-default `DateTime.ToString()` for the CRT's
`strftime("%c", ...)` (a different formatting engine, same "process
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

View file

@ -37,7 +37,7 @@ accepted-divergence entries (#96, #49, #50).
---
## 1. Intentional architecture (IA) — 19 active rows (IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default)
## 1. Intentional architecture (IA) — 20 active rows (IA-23 filed 2026-08-17 at the night-round review fix round (F8) — the House tab's not-yet-expired purchase-restriction line renders .NET's culture-default `DateTime.ToString()` where retail renders the C runtime's `strftime("%c", localtime(...))`, a different formatting engine producing a different-shaped (but equivalent-intent) date string; IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -60,6 +60,7 @@ accepted-divergence entries (#96, #49, #50).
| IA-20 | The basic combat bar keeps dark-red media `0x0600715E` visible as the centered middle baseline. Retail skill-gates field `0x100005EF` to trained Recklessness; the separate bright child remains faithful live `SetPowerbarLevel` feedback from the absolute left edge. | `src/AcDream.App/UI/UiScrollbar.cs`; child-policy extraction in `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` | Explicit connected visual direction: the dark middle track remains present behind live attack charge; the exact skill-gated treatment remains tracked by AP-112 | Untrained characters retain the dark-red baseline where retail may leave only the gray track; trained/untrained Recklessness presentation is not distinguishable | `gmCombatUI::RecvNotice_SetPowerbarLevel @ 0x004CC0E0`; `gmCombatUI::ListenToElementMessage @ 0x004CC430`; LayoutDesc `0x21000073` |
| IA-21 | When ACE sends player BoolProperty `68` (`SpellComponentsRequired`) false, acdream presents the retail scarab/prismatic-taper formula even without a directly carried school focus. With component enforcement enabled, retail's exact focus/infusion versus account-customized selection remains intact. | `src/AcDream.App/Spells/SpellComponentRequirementService.cs` | A component-disabled server has no actionable legacy recipe; explicit product direction is that this client/server mode uses the modern scarab/taper component presentation | A custom server could expect retail's legacy recipe to remain visible even though casting consumes no components | `ClientMagicSystem::AreSpellComponentsRequired @ 0x00567B90`; `ClientMagicSystem::GetAppropriateSpellFormula @ 0x00567D50`; `CSpellBase::InqScarabOnlyFormula @ 0x00597050` |
| IA-22 | **Filed 2026-08-13 (#391, user-directed: "we should only support modern resolutions. Not any old format").** The Config Resolution dropdown offers a CURATED list — the monitor's real mode enumeration filtered to modern widescreen families (16:9/16:10/21:9/32:9, ≥1280 wide, fitting the desktop; `DisplayModeCatalog.Curate`) — and its Defaults value is the desktop's own mode. Retail offered the adapter's complete enumeration including 4:3 legacy modes and authored `800x600` as the row default (`gmConfigUI::InitOptions SetDefaultValue(0x03200258)`; `gmClient::Init @0x004047af` `Device::ForceDisplayResolution(1, 0x320, 0x258)`). | `src/AcDream.App/Rendering/DisplayModeCatalog.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (Resolution row); fixture fallback `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs` (`AvailableResolutions`, 800x600 removed) | Explicit product direction. **Amended 2026-08-16 (#407, Campaign CC gate round 1):** the dropdown now offers `DisplayModeCatalog.WindowedResolutions` — the curated hardware modes UNIONed with the static modern-ladder sizes that fit the desktop — because a WINDOWED pick is a plain Size write needing no video mode, and remote/RDP virtual displays advertise almost no modes (the live RDP display exposed only 1920x1080 + the 2056x1290 desktop, starving the dropdown). The original "an offered mode is supported by construction" invariant now holds for the FULLSCREEN half only: the fullscreen apply still validates against the hardware `Resolutions` list plus `GlfwDisplayModeSwitcher`'s monitor-mode-list hard guard, so a fullscreen pick of a windowed-only entry refuses safely (log-and-stay, #388; the #392 apply-result seam is that family's open follow-up) — "Graphics mode not supported" crashes remain unreachable from the dropdown. | A user wanting a genuine legacy 4:3 mode cannot pick it; retail-parity comparisons of the Config tab's list/default will show the deviation. | decomp sites in the Divergence column; ISSUES #391 |
| IA-23 | **Filed 2026-08-17 at the night-round review fix round (F8).** `gmHouseUI::DisplayPurchaseTimeText @0x004a3110`'s not-yet-expired branch renders `"You may buy another landscape house at " + strftime("%c", localtime(timestamp + 0x278d00)) + ". This restriction does not apply to apartments."` — byte-decoded from raw pushed literals at `@0x004a3265`/`@0x004a321d`/`@0x004a3235` (all three text pieces confirmed; a prior filing had wrongly called this "unrecoverable"). This port renders the SAME three pieces, in the same order, with the same expiry-timestamp math, but formats the middle date/time piece with .NET's culture-default `DateTime.ToString()` (no explicit format string) rather than the C runtime's `strftime("%c", ...)` — the two engines do not share a format table, so the RENDERED SHAPE of the date/time differs (e.g. .NET's short numeric date+time vs the CRT's `Ddd Mon DD HH:MM:SS YYYY`-style locale string) even though both express "the process's own locale's full date+time" and use the SAME underlying instant (local time, matching retail's `localtime()`). | `src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs` (`Recompute`'s not-expired branch) | Both are "whatever the process locale says" full date+time strings; no game-logic reads or parses this text back, it is pure chat-scroll presentation, so a differently-shaped (but equally legible) date string carries no functional risk | A retail-side-by-side visual comparison will show a differently formatted date/time (not a byte-identical `strftime("%c")` reproduction) — cosmetic only | `gmHouseUI::DisplayPurchaseTimeText @0x004a3110`; `strftime`/`localtime` CRT calls at `@0x004a322c`/`@0x004a3216` |
---

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

View file

@ -75,12 +75,17 @@ public sealed class RuntimeHouseStateTests
}
[Fact]
public void HouseStatus_TimestampWithinThirtyDayWindow_RendersNoLine()
public void HouseStatus_TimestampWithinThirtyDayWindow_ShowsExpiryDateLine()
{
// 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.
// 0x278d00 (2,592,000 s = 30 days). Inside the window, retail's
// gmHouseUI::DisplayPurchaseTimeText composes prefix + strftime("%c")
// of (timestamp + 30 days) + suffix (night-round review F8 — the
// "unrecoverable strftime branch" from ISSUES #413 item 2 was
// byte-decoded and is now ported). The exact date substring is
// locale/timezone-formatted (.NET's honest %c analogue), so this
// pins the STRUCTURE (prefix/suffix, non-empty middle), not the
// exact rendered date text.
var clock = new ManualTimeProvider();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature });
@ -93,7 +98,34 @@ public sealed class RuntimeHouseStateTests
clock.Advance(TimeSpan.FromDays(29));
house.ApplyHouseStatus(weenieError: 0u, Self);
Assert.Empty(house.Lines);
string line = Assert.Single(house.Lines);
Assert.StartsWith("You may buy another landscape house at ", line);
Assert.EndsWith(". This restriction does not apply to apartments.", line);
}
[Fact]
public void HouseStatus_TimestampWithinThirtyDayWindow_ExpiryDateIsTimestampPlusThirtyDays()
{
// Pins the actual computed expiry moment (retail: timestamp +
// 0x278d00 = 2,592,000 s, formatted via localtime — this fixture's
// LocalTimeZone is UTC, so the rendered date is exactly the UTC
// expiry instant with no offset ambiguity).
var clock = new ManualTimeProvider();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature });
DateTimeOffset purchaseTime = clock.GetUtcNow();
var bundle = new PropertyBundle();
bundle.Ints[(uint)PropertyInt.HousePurchaseTimestamp] =
(int)purchaseTime.ToUnixTimeSeconds();
objects.UpsertProperties(Self, bundle);
var house = new RuntimeHouseState(objects, clock);
clock.Advance(TimeSpan.FromDays(29));
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);
}
[Fact]
@ -156,6 +188,12 @@ public sealed class RuntimeHouseStateTests
public override DateTimeOffset GetUtcNow() => _now;
// F8: pin LocalTimeZone to UTC so the not-yet-expired branch's
// TimeZoneInfo.ConvertTime call is deterministic across machines —
// the real production TimeProvider.System.LocalTimeZone is
// TimeZoneInfo.Local, matching retail's own localtime() call.
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
public void Advance(TimeSpan elapsed) => _now += elapsed;
}
}