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:
parent
ab84b54dfa
commit
df062d2eda
4 changed files with 110 additions and 22 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue