CM_House::Event_QueryHouse @0x006aaa00 (opcode 0x21e) tail-calls
unconditionally from the end of CPlayerSystem::InitializePlayer
@0x00563570 — the same once-per-session function
AttemptSendLoginCompleteNotification lives in (both guarded by the
player_initialized flag), right after that notification. Retail never
sends it from gmHouseUI::PostInit or gmMapUI::PostInit on House-tab
activation.
Moved WorldSession.SendHouseQuery() to the direct (non-portal)
first-entry completion edges — the same places acdream already sends
the analogous "initial session bootstrap" LoginComplete:
- graphical: LiveSessionRuntimeFactory's RuntimeFirstEntryDriveController
localPlayerCompleted callback
- headless: HeadlessSessionHost's equivalent callback
- headless content-less direct host: RuntimeLiveEntitySessionController.OnSpawned
Portal-space re-entries (LocalPlayerTeleportController's F751 path,
RuntimeLiveEntitySessionController.TryAdvancePortalCompletion) do NOT
resend it, matching retail's single-shot guard.
Removed the invented House-tab-open -> SendHouseQuery trigger
(InteractionRetainedUiComposition's HouseShown binding) and retired
register row AD-107, which had documented that adaptation.
Updated RuntimeLiveEntitySessionControllerTests' exact game-action
assertions for the content-less path, which now also captures the
HouseQuery send alongside LoginComplete.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
134 lines
6.2 KiB
C#
134 lines
6.2 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Numerics;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Binds the House tab of the retail Map/House panel (<c>gmHouseUI</c>,
|
|
/// class id <c>0x10000025</c>) — <see cref="MapHousePanelController"/>'s
|
|
/// second page.
|
|
///
|
|
/// <para>
|
|
/// Retail references: <c>gmHouseUI::PostInit @0x004a2710</c> resolves ONE
|
|
/// <c>UIElement_ListBox</c> (<c>m_pTextBox = 0x100001e6</c>) and registers
|
|
/// four notice handlers for wire opcodes <c>0x0225-0x0228</c>.
|
|
/// <c>gmHouseUI::AddHousePanelText @0x004a2810</c> is
|
|
/// <c>UIElement_ListBox::AddItemFromTemplateList(this, 0, nullptr)</c> —
|
|
/// live-DAT-confirmed (<c>MapHousePanelSlotProbeTests</c>) as a single
|
|
/// <c>UIElement_Text</c> (Type 12) row template at LayoutDesc
|
|
/// <c>0x21000025</c> element <c>0x100001e7</c>, no scrollbar authored. The
|
|
/// ListBox itself authors ZERO static child rows — the box starts genuinely
|
|
/// empty until the first server notice populates it (retail's own
|
|
/// <c>PostInit</c> never calls <c>Update</c>/<c>DisplayHouseData</c>).
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <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). The House-tab ownership-text closer session (also 2026-08-17)
|
|
/// wired <see cref="Bindings.Lines"/> to the minimal <c>RuntimeHouseState</c>
|
|
/// owner and ported <c>DisplayPurchaseTimeText @0x004a3110</c>'s expired
|
|
/// branch — a fresh houseless character's House tab shows the single
|
|
/// decomp-verified line "You may buy another house immediately.",
|
|
/// 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. The
|
|
/// night-round review (F2, 2026-08-17) moved the outbound HouseQuery send
|
|
/// from a House-tab-open trigger to retail's real login-complete edge (see
|
|
/// <see cref="Bindings.OnShown"/>'s own doc), so by the time a player opens
|
|
/// the House tab the data has usually already arrived.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class HousePageController
|
|
{
|
|
public const uint TextBoxId = 0x100001E6u;
|
|
|
|
public sealed record Bindings(
|
|
Func<IReadOnlyList<string>> Lines,
|
|
// Fires once when the page transitions to visible. Night-round
|
|
// review F2 (2026-08-17): NOT wired to SendHouseQuery any more —
|
|
// retail's HouseQuery (0x021E, CM_House::Event_QueryHouse
|
|
// @0x006aaa00) is byte-decoded confirmed to fire exactly once at the
|
|
// client's login-complete edge (tail-called, unconditionally, from
|
|
// CPlayerSystem::InitializePlayer @0x00563570, right after
|
|
// AttemptSendLoginCompleteNotification — both guarded by the SAME
|
|
// once-per-session player_initialized flag), never from House-tab
|
|
// activation; neither gmHouseUI::PostInit nor gmMapUI::PostInit
|
|
// sends one on tab-open. See WorldSession.SendHouseQuery's own
|
|
// production call sites (the graphical/headless first-entry-
|
|
// completion edges) for where it's actually sent now. This hook
|
|
// remains available for a genuinely page-shown concern, but no
|
|
// current caller wires it.
|
|
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;
|
|
private IReadOnlyList<string> _lastLines = Array.Empty<string>();
|
|
|
|
private HousePageController(UiTemplateListBox listBox, Bindings bindings)
|
|
{
|
|
_listBox = listBox;
|
|
_bindings = bindings;
|
|
}
|
|
|
|
public static HousePageController? Bind(UiElement page, Bindings bindings)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(page);
|
|
ArgumentNullException.ThrowIfNull(bindings);
|
|
|
|
if (UiElement.FindDescendant(page, TextBoxId) is not UiTemplateListBox listBox)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] House tab: ListBox 0x{TextBoxId:X8} not found or not a template list box.");
|
|
return null;
|
|
}
|
|
|
|
listBox.TemplateResolver = bindings.TemplateResolver;
|
|
var controller = new HousePageController(listBox, bindings);
|
|
controller.Refresh(bindings.Lines());
|
|
return controller;
|
|
}
|
|
|
|
/// <summary>Per-frame poll — cheap no-op when the line set hasn't
|
|
/// changed (reference-content compare via SequenceEqual, mirroring the
|
|
/// other social-panel pages' revision-gated rebuild discipline).</summary>
|
|
public void Tick()
|
|
{
|
|
IReadOnlyList<string> lines = _bindings.Lines();
|
|
if (lines.SequenceEqual(_lastLines)) return;
|
|
Refresh(lines);
|
|
}
|
|
|
|
public void OnShown() => _bindings.OnShown?.Invoke();
|
|
|
|
private void Refresh(IReadOnlyList<string> lines)
|
|
{
|
|
_lastLines = lines;
|
|
_listBox.Flush();
|
|
foreach (string line in lines)
|
|
{
|
|
UiElement? row = _listBox.AddItemFromTemplateList(0);
|
|
if (row is UiText text)
|
|
text.LinesProvider = () => [new UiText.Line(line, Vector4.One)];
|
|
}
|
|
}
|
|
}
|