feat(ui): House tab ownership text — DisplayPurchaseTimeText + RuntimeHouseState

Derived the mechanism from the decomp before writing code: neither
gmHouseUI::PostInit @0x004a2710 nor gmMapUI::PostInit @0x004a1c70 sends a
HouseQuery, and six of gmHouseUI's seven Display* builders early-return on
m_pHouseData == 0. The only text a houseless character's House tab shows is
gmHouseUI::DisplayPurchaseTimeText @0x004a3110's expired branch (it doesn't
gate on m_pHouseData) — the local player's PropertyInt.HousePurchaseTimestamp
plus HouseSystem::HasPurchaseWaitPeriodExpired renders exactly "You may buy
another house immediately." for a fresh character. Exhaustive search of the
2013 EoR decomp, ACE, and the live DAT found zero support for a second
"You do not currently own a house." line the task brief described — this
commit ports what the decomp actually shows.

Ships:
- RuntimeHouseState: a minimal (no disposal, no construction-transaction
  Fault() point) Runtime owner per ISSUES #413's own sizing note, wired
  through GameEventWiring's existing HouseData/HouseStatus delegate holes,
  LiveSessionEventRouter, and GameRuntime.HouseOwner. Participates in
  RuntimeGenerationReset (new House stage) since a fresh login must not
  show a stale character's house state.
- HousePageController.Bindings.Lines/OnShown wired to real data; OnShown
  fires WorldSession.SendHouseQuery() on tab-open (AD-107: an acdream
  trigger, not a ported retail call site — filed in the divergence
  register).
- Fixed a real bug found along the way: HousePageController.Bind never
  wired UiTemplateListBox.TemplateResolver, so no row could ever render
  regardless of Lines content. Now reuses the Map tab's generic hotspot
  resolver.

Live-verified against a real local ACE server and the +Acdream character
(--session-config auto-select + a UI automation script): screenshot and
structural UI-tree dump both confirm the House tab renders exactly "You may
buy another house immediately." Graceful logout confirmed both launches.

ISSUES #413 narrowed to its one remaining piece: the six owned-house-only
Display* builders (DisplayBuyPayment/RentPayment/BuyTime/RentTimes/
Location/WarningText), unexercisable without a test character that owns a
house.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 03:38:16 +02:00
parent eb6f3bd8c8
commit 06512f0957
13 changed files with 601 additions and 99 deletions

View file

@ -978,14 +978,22 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
AllegianceSetUpdateSubscription: on =>
late.GameRuntime.AllegianceSetUpdateSubscription(on),
Trade: d.Runtime.Trade),
// Batch C (overnight hover/UI round): HousePosition/
// HouseLines/HouseShown are left unwired (their bindings
// default to "no house"/empty/no-op) — the House wire
// groundwork (RuntimeHouseState) lands separately; the
// panel mounts and the Map tab works standalone either way.
// Batch C (overnight hover/UI round, 2026-08-17): HouseLines/
// HouseShown now wired to the minimal RuntimeHouseState
// owner (see its class doc) — HousePosition (the Map tab's
// house marker) is deferred to #413's remaining owned-house
// work, since it needs HouseData's Position field, not yet
// consumed here.
MapHouse: new MapHouseRuntimeBindings(
CurrentCalendar: d.CurrentCalendar,
PlayerCellId: () => d.PlayerController.Controller?.CellId ?? 0u),
PlayerCellId: () => d.PlayerController.Controller?.CellId ?? 0u,
HouseLines: () => d.Runtime.HouseOwner.Lines,
// Not a ported retail call site — neither gmHouseUI::
// PostInit nor gmMapUI::PostInit sends an outbound
// HouseQuery; this is the acdream "fire when the House
// tab is shown" convenience HousePageController.Bindings.
// OnShown's own doc already documents.
HouseShown: () => late.Session.CurrentSession?.SendHouseQuery()),
StackSplitQuantity: d.StackSplitQuantity,
Plugins: d.UiRegistry,
Persistence: persistence,

View file

@ -327,7 +327,8 @@ internal sealed class LiveSessionRuntimeFactory
(text, type) => _domain.Communication.AddText(text, type),
Fellowship: _domain.Runtime.FellowshipOwner,
Allegiance: _domain.Runtime.AllegianceOwner,
Trade: _domain.Runtime.TradeOwner));
Trade: _domain.Runtime.TradeOwner,
House: _domain.Runtime.HouseOwner));
return new GraphicalSessionEventRoute(
route,
_domain.Runtime,

View file

@ -25,19 +25,22 @@ namespace AcDream.App.UI.Layout;
/// </para>
///
/// <para>
/// <b>Scope (Batch C, 2026-08-17) — see ISSUES #413 for the full ledger.</b>
/// This session shipped the mount (this class) and the wire PARSING
/// <b>Scope — see ISSUES #413 for the full ledger.</b> Batch C
/// (2026-08-17) shipped the mount (this class) and the wire PARSING
/// groundwork (<c>GameEvents.ParseHouseData</c>/<c>ParseHouseStatus</c>/
/// <c>ParseUpdateRentTime</c>/<c>ParseUpdateRentPayment</c>,
/// <c>GameEventWiring</c>'s four delegate holes, the outbound HouseQuery
/// action). <see cref="Bindings.Lines"/>/<see cref="Bindings.OnShown"/> are
/// NOT yet wired to real data — no <c>RuntimeHouseState</c> owner exists,
/// and none of the seven <c>Display*</c> line builders
/// <c>DisplayHouseData</c> calls (including
/// <c>DisplayPurchaseTimeText @0x004a3110</c>'s two fully-recovered
/// literal strings) are ported. Until #413 closes, this page mounts with
/// genuinely empty content — matching retail's own <c>PostInit</c>, which
/// never calls <c>Update</c>/<c>DisplayHouseData</c> either.
/// action). The House-tab ownership-text closer session (also 2026-08-17)
/// wired <see cref="Bindings.Lines"/>/<see cref="Bindings.OnShown"/> to the
/// minimal <c>RuntimeHouseState</c> owner and ported
/// <c>DisplayPurchaseTimeText @0x004a3110</c>'s expired branch — a fresh
/// houseless character's House tab now shows the single decomp-verified
/// line "You may buy another house immediately." after the tab is opened,
/// live-connected-gate-verified (screenshot + structural UI-tree dump
/// against the real <c>+Acdream</c> character on a local ACE server). The
/// other six <c>Display*</c> line builders <c>DisplayHouseData</c> calls
/// (owned-house-only content: buy/rent payments and times, location,
/// warning text) remain unported — ISSUES #413's surviving scope.
/// </para>
/// </summary>
public sealed class HousePageController
@ -52,7 +55,19 @@ public sealed class HousePageController
// see ISSUES #413. NOT a ported retail call site (PostInit never
// triggers a query) — an acdream convention, documented as such
// (recon doc open item).
Action? OnShown = null);
Action? OnShown = null,
// Batch C House-ownership-text closer (2026-08-17): the ListBox's
// OWN row template (LayoutDesc 0x21000025 element 0x100001E7,
// live-DAT-confirmed by MapHousePanelSlotProbeTests) is resolved
// through the SAME generic (templateLayoutId, templateElementId) ->
// UiElement seam MapPageController.Bindings.TemplateResolver already
// wires for the Map tab's town hotspots — it performs the identical
// LayoutImporter.ImportInfos+Build operation, nothing map-specific
// about it. Without this, UiTemplateListBox.AddItemFromTemplateList
// always returns null (no resolver = no row), so Refresh silently
// produced zero rows regardless of Lines — the gap this session
// closes alongside the text composition itself.
Func<uint, uint, UiElement?>? TemplateResolver = null);
private readonly UiTemplateListBox _listBox;
private readonly Bindings _bindings;
@ -76,6 +91,7 @@ public sealed class HousePageController
return null;
}
listBox.TemplateResolver = bindings.TemplateResolver;
var controller = new HousePageController(listBox, bindings);
controller.Refresh(bindings.Lines());
return controller;

View file

@ -3339,7 +3339,11 @@ public sealed class RetailUiRuntime : IDisposable
TemplateResolver: ResolveHotspotTemplate),
House: new Layout.HousePageController.Bindings(
Lines: mh.HouseLines ?? (static () => Array.Empty<string>()),
OnShown: mh.HouseShown));
OnShown: mh.HouseShown,
// Same generic template resolver the Map tab's town
// hotspots use — see HousePageController.Bindings.
// TemplateResolver's own doc for why reusing it is correct.
TemplateResolver: ResolveHotspotTemplate));
Layout.MapHousePanelController? controller;
lock (_bindings.Assets.DatLock)

View file

@ -1087,7 +1087,14 @@ public static class GameEvents
/// <c>RecvNotice_FailedHouseTransaction</c> family (also the "no house
/// owned" reply to a HouseQuery — ACE Player_House.cs
/// HandleActionQueryHouse's <c>new GameEventHouseStatus(Session)</c>
/// defaults to WeenieError.None, not a "failure").</summary>
/// defaults to <c>WeenieError.BadParam</c> (corrected 2026-08-17; an
/// earlier note here said <c>WeenieError.None</c>, which is not what
/// <c>GameEventHouseStatus</c>'s own constructor default reads). The
/// value is moot either way — decomp-confirmed retail's own
/// <c>gmHouseUI::Update(uint32_t)</c>/<c>gmMapUI::
/// RecvNotice_FailedHouseTransaction</c> never read this field
/// (<c>AcDream.Runtime.Gameplay.RuntimeHouseState.ApplyHouseStatus</c>
/// accepts and discards it for the same reason).</summary>
public static uint? ParseHouseStatus(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;

View file

@ -109,6 +109,7 @@ internal enum GameRuntimeConstructionPoint
FellowshipCreated,
AllegianceCreated,
TradeCreated,
HouseCreated,
MovementCreated,
ActionsCreated,
EnvironmentCreated,
@ -127,6 +128,7 @@ internal sealed class GameRuntimeConstructionContext
public RuntimeFellowshipState? Fellowship { get; set; }
public RuntimeAllegianceState? Allegiance { get; set; }
public RuntimeTradeState? Trade { get; set; }
public RuntimeHouseState? House { get; set; }
public RuntimeLocalPlayerMovementState? Movement { get; set; }
public RuntimeActionState? Actions { get; set; }
public GameRuntimeEventHub? Events { get; set; }
@ -282,6 +284,17 @@ public sealed class GameRuntime
context,
faultInjection);
// House tab (Batch C, Map/House toolbar panel, 2026-08-17):
// deliberately minimal owner (ISSUES #413's own sizing note) —
// no live-object side effects, nothing to dispose, so no
// construction.Own() (unlike Trade above, which owns staged
// items' TradeState flags on live objects).
context.House = new RuntimeHouseState(context.EntityObjects.Objects);
Fault(
GameRuntimeConstructionPoint.HouseCreated,
context,
faultInjection);
context.Movement = new RuntimeLocalPlayerMovementState();
// Campaign CH slice CH2: local jump refusals (CommenceJump/
// DoJump's WeenieError family — research doc §4.2/§6.4) reach
@ -337,7 +350,8 @@ public sealed class GameRuntime
context.PlayerIdentity,
context.Fellowship,
context.Allegiance,
context.Trade);
context.Trade,
context.House);
context.Movement.AttachPhysicsPublication(
new RuntimeLocalPlayerPhysicsPublicationState(
@ -393,6 +407,7 @@ public sealed class GameRuntime
FellowshipOwner = context.Fellowship;
AllegianceOwner = context.Allegiance;
TradeOwner = context.Trade;
HouseOwner = context.House;
MovementOwner = context.Movement;
ActionOwner = context.Actions;
EnvironmentOwner = environment;
@ -501,6 +516,11 @@ public sealed class GameRuntime
/// <summary>Secure trade (2026-08-14): third sibling J-owner.</summary>
public RuntimeTradeState TradeOwner { get; }
/// <summary>Batch C (2026-08-17): House tab minimal owner — see
/// <see cref="RuntimeHouseState"/>'s own class doc for the sizing
/// rationale.</summary>
public RuntimeHouseState HouseOwner { get; }
public RuntimeActionState ActionOwner { get; }
public RuntimeLocalPlayerMovementState MovementOwner { get; }
internal RuntimeLocalPlayerPhysicsPublicationState

View file

@ -0,0 +1,180 @@
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Properties;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Canonical presentation-independent owner for the House tab of retail's
/// two-tab Map/House panel (<c>gmHouseUI</c>). Deliberately MINIMAL —
/// "houseless-status only" per ISSUES #413's own sizing note: a full
/// <c>RuntimeTradeState</c>-weight owner (construction-transaction
/// <c>Fault()</c> injection point, disposal ordering, convergence tracking)
/// is disproportionate for what this slice needs, since (unlike Trade) this
/// owner holds no live-object side effects and nothing that requires
/// disposal.
/// </summary>
/// <remarks>
/// <para>
/// <b>Retail behavior, exhaustively verified against the decomp before
/// writing this class (all seven line builders read, not just the one
/// ported here):</b> <c>gmHouseUI::PostInit @0x004a2710</c> never calls
/// <c>Update</c>/<c>DisplayHouseData</c> — the House ListBox
/// (<c>0x100001e6</c>) starts genuinely empty (live-DAT-confirmed,
/// <c>MapHousePanelSlotProbeTests</c>: <c>children=0</c>, and the whole
/// House page <c>0x100001F7</c> has NO other static content besides that
/// one empty ListBox). Content appears only after a server notice
/// (0x0225-0x0228) arrives and <c>Update</c>/<c>DisplayHouseData</c> runs
/// the seven <c>Display*</c> builders in order. SIX of them
/// (<c>DisplayBuyPayment</c>, <c>DisplayRentPayment</c>,
/// <c>DisplayBuyTime</c>, <c>DisplayRentTimes</c>, <c>DisplayLocation</c>,
/// <c>DisplayWarningText</c>) open with <c>if (this-&gt;m_pHouseData != 0)</c>
/// and emit NOTHING when houseless — those remain unported, ISSUES #413
/// item 3.
/// </para>
/// <para>
/// The SEVENTH, <c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>, does
/// NOT gate on <c>m_pHouseData</c> — it always runs, reading the LOCAL
/// PLAYER's own <c>PropertyInt.HousePurchaseTimestamp</c> (0xC7 = 199
/// decimal) via <c>CBaseQualities::InqInt</c> and
/// <c>HouseSystem::HasPurchaseWaitPeriodExpired</c>
/// (<c>@0x005bb1d0</c>: <c>(Timer::get_real_time() - timestamp) &gt;
/// 0x278d00</c>; <c>Timer::get_real_time = time(0)</c>, Unix epoch
/// seconds; <c>0x278d00</c> = 2,592,000 s = 30 days). For a fresh/houseless
/// character with no timestamp ever set (absent property reads as 0), this
/// is trivially true, taking the "expired" branch, which reads
/// <c>m_pHouseData == 0</c> (still houseless) and emits the ONE literal
/// string at <c>data_7ab7f0</c>: <b>"You may buy another house
/// immediately."</b> That is the exact, decomp-verified, single line of
/// content a houseless character's House tab shows once queried — this
/// class ports exactly that (and its owns-a-house sibling at
/// <c>data_7ab818</c>, unreachable by a fresh character but faithfully
/// ported alongside it). No other function, WeenieError-to-chat mapping,
/// or authored LayoutDesc content anywhere in the decomp/live DAT produces
/// a second line for the houseless case — a broader search for chat-scroll
/// strings mentioning house ownership found only unrelated, differently
/// worded command-error text (<c>"You do not own a house!"</c>,
/// WeenieError <c>0x45E</c>/<c>0x45F</c>, and <c>"You must own a house to
/// use this command."</c>, WeenieError <c>0x47F</c>) routed through the
/// GENERIC WeenieError chat dispatcher, never through <c>gmHouseUI</c>'s
/// own notice handlers (which discard the wire WeenieError entirely — see
/// <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.
/// </para>
/// </remarks>
public sealed class RuntimeHouseState
{
/// <summary><c>HouseSystem::HasPurchaseWaitPeriodExpired</c>'s
/// literal, <c>0x278d00</c> = 2,592,000 seconds = 30 days.</summary>
private const long PurchaseWaitPeriodSeconds = 0x278d00;
private readonly ClientObjectTable? _objects;
private readonly TimeProvider _timeProvider;
private readonly object _gate = new();
private bool _hasReceivedNotice;
private bool _ownsHouse;
private IReadOnlyList<string> _lines = Array.Empty<string>();
/// <summary>Borrows the canonical object table (optional for bare
/// fixtures) to read the local player's own
/// <c>PropertyInt.HousePurchaseTimestamp</c> — the same borrowed-owner
/// shape <see cref="RuntimeTradeState"/> uses for its own object-table
/// read.</summary>
public RuntimeHouseState(
ClientObjectTable? objects = null, TimeProvider? timeProvider = null)
{
_objects = objects;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>The House tab's exact ListBox content — empty until the
/// first server notice arrives, matching retail's own PostInit (never
/// calls Update/DisplayHouseData).</summary>
public IReadOnlyList<string> Lines
{
get { lock (_gate) return _lines; }
}
/// <summary>Whether any of the four House notices (0x0225-0x0228) has
/// arrived this session.</summary>
public bool HasReceivedNotice
{
get { lock (_gate) return _hasReceivedNotice; }
}
/// <summary>0x0225 HouseData — <c>RecvNotice_UpdateHouseData</c>
/// (owned-house case). Only <see cref="_ownsHouse"/> is consumed today;
/// the owned-house payload itself (buy/rent payments, times, location)
/// feeds ISSUES #413's remaining six builders, not yet ported.</summary>
public void ApplyHouseData(GameEvents.HouseData data, uint selfGuid)
{
lock (_gate)
{
_hasReceivedNotice = true;
_ownsHouse = true;
Recompute(selfGuid);
}
}
/// <summary>0x0226 HouseStatus — <c>RecvNotice_FailedHouseTransaction</c>
/// (the "no house owned" reply to a HouseQuery, per ACE's
/// <c>HandleActionQueryHouse</c>). <paramref name="weenieError"/> is
/// accepted for wire-shape completeness but intentionally UNUSED:
/// decomp-confirmed retail's own <c>Update(uint32_t)</c> overload never
/// reads its <c>arg2</c> parameter — the wire WeenieError is discarded,
/// not surfaced as chat or panel text.</summary>
public void ApplyHouseStatus(uint weenieError, uint selfGuid)
{
_ = weenieError;
lock (_gate)
{
_hasReceivedNotice = true;
_ownsHouse = false;
Recompute(selfGuid);
}
}
/// <summary>Generation reset — a fresh login must not show a previous
/// character's house-query result. Restores the exact pre-notice
/// "genuinely empty" state.</summary>
public void ResetSession()
{
lock (_gate)
{
_hasReceivedNotice = false;
_ownsHouse = false;
_lines = Array.Empty<string>();
}
}
/// <summary><c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>'s
/// expired branch, ported faithfully. Must hold <see cref="_gate"/>.</summary>
private void Recompute(uint selfGuid)
{
int timestamp = _objects?.Get(selfGuid)?.Properties
.GetInt((uint)PropertyInt.HousePurchaseTimestamp) ?? 0;
long nowEpochSeconds = _timeProvider.GetUtcNow().ToUnixTimeSeconds();
bool expired = (nowEpochSeconds - timestamp) > PurchaseWaitPeriodSeconds;
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>();
return;
}
_lines = new[]
{
_ownsHouse
? "You may buy another house immediately after you abandon this one."
: "You may buy another house immediately.",
};
}
}

View file

@ -60,15 +60,24 @@ public enum RuntimeGenerationResetStage
/// beside its fellowship/allegiance precedents.
/// </summary>
Trade = 14,
BeginEntityRetirement = 15,
RetireEntities = 16,
DrainHostProjection = 17,
CompleteCanonicalEntities = 18,
CompleteHostProjection = 19,
ChatIdentity = 20,
PlayerSnapshots = 21,
PlayerIdentity = 22,
Complete = 23,
/// <summary>
/// Batch C (Map/House toolbar panel, 2026-08-17): the House tab's
/// query result is session-scoped like fellowship/allegiance/trade
/// above — a fresh login must not show a previous character's house
/// data. See <see cref="RuntimeHouseState"/>'s class doc for why this
/// owner is lighter-weight than its three siblings (no disposal, no
/// construction-transaction Fault() point).
/// </summary>
House = 15,
BeginEntityRetirement = 16,
RetireEntities = 17,
DrainHostProjection = 18,
CompleteCanonicalEntities = 19,
CompleteHostProjection = 20,
ChatIdentity = 21,
PlayerSnapshots = 22,
PlayerIdentity = 23,
Complete = 24,
}
public readonly record struct RuntimeGenerationResetSnapshot(
@ -119,6 +128,7 @@ public sealed class RuntimeGenerationReset
private readonly RuntimeFellowshipState _fellowship;
private readonly RuntimeAllegianceState _allegiance;
private readonly RuntimeTradeState _trade;
private readonly RuntimeHouseState _house;
private ResetState? _state;
private RuntimeGenerationToken _lastCompletedGeneration;
private bool _hasCompletedGeneration;
@ -136,7 +146,8 @@ public sealed class RuntimeGenerationReset
RuntimeLocalPlayerIdentityState identity,
RuntimeFellowshipState fellowship,
RuntimeAllegianceState allegiance,
RuntimeTradeState trade)
RuntimeTradeState trade,
RuntimeHouseState house)
{
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
_communication = communication
@ -157,6 +168,7 @@ public sealed class RuntimeGenerationReset
_allegiance = allegiance
?? throw new ArgumentNullException(nameof(allegiance));
_trade = trade ?? throw new ArgumentNullException(nameof(trade));
_house = house ?? throw new ArgumentNullException(nameof(house));
}
public RuntimeGenerationToken? ActiveRetiringGeneration =>
@ -333,6 +345,9 @@ public sealed class RuntimeGenerationReset
case RuntimeGenerationResetStage.Trade:
Advance(state, _trade.Clear);
break;
case RuntimeGenerationResetStage.House:
Advance(state, _house.ResetSession);
break;
case RuntimeGenerationResetStage.BeginEntityRetirement:
_ = _entityObjects.BeginSessionClear();
state.Retirements = _entityObjects

View file

@ -88,7 +88,11 @@ public sealed record LiveSocialSessionBindings(
RuntimeAllegianceState? Allegiance = null,
// Secure trade (2026-08-14): the third sibling J-owner, same
// trailing/optional compatibility convention.
RuntimeTradeState? Trade = null);
RuntimeTradeState? Trade = null,
// Batch C (Map/House toolbar panel, 2026-08-17): same trailing/optional
// compatibility convention — a minimal owner (RuntimeHouseState's own
// class doc), not a full sibling J-owner.
RuntimeHouseState? House = null);
/// <summary>
/// Owns every inbound subscription for one exact live session. Domain state
@ -322,6 +326,14 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
: null,
onTradeClearAcceptance: social.Trade is { } tradeClear
? tradeClear.ApplyClearAcceptance
: null,
// Batch C (Map/House toolbar panel, 2026-08-17): same
// conditional delegate-hole discipline as trade above.
onHouseData: social.House is { } houseData
? data => houseData.ApplyHouseData(data, inventory.PlayerGuid())
: null,
onHouseStatus: social.House is { } houseStatus
? weenieError => houseStatus.ApplyHouseStatus(weenieError, inventory.PlayerGuid())
: null));
ConstructionCheckpoint();