diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 4cab7d03..4e453877 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -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
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index d06f172e..1be3ae02 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -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` |
---
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs b/src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs
index 0675e29e..2c1ac073 100644
--- a/src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs
@@ -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;
/// ).
///
///
-/// The NOT-yet-expired branch of DisplayPurchaseTimeText (a
-/// strftime-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 DisplayPurchaseTimeText 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
+/// "You may buy another landscape house at " @0x7ab790
+/// (pushed @0x004a3265), the strftime format literal
+/// "%c" @0x7ab7ec (pushed @0x004a321d) applied to
+/// localtime(timestamp + 0x278d00) — the expiry moment, not "now" —
+/// and suffix ". This restriction does not apply to apartments."
+/// @0x7ab7b8 (pushed @0x004a3235). strftime's
+/// "%c" is the C runtime's locale-default full date+time
+/// representation; this port's honest analogue is .NET's own
+/// culture-default DateTime.ToString() (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
+/// (docs/architecture/retail-divergence-register.md) records this
+/// approximation.
///
///
public sealed class RuntimeHouseState
@@ -152,8 +169,8 @@ public sealed class RuntimeHouseState
}
}
- /// gmHouseUI::DisplayPurchaseTimeText @0x004a3110's
- /// expired branch, ported faithfully. Must hold .
+ /// gmHouseUI::DisplayPurchaseTimeText @0x004a3110,
+ /// both branches now ported. Must hold .
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();
+ // 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;
}
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs
index 683a8e55..71133b14 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs
@@ -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;
}
}