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