acdream/src/AcDream.App/UI/Layout/HousePageController.cs
Erik 353ae3bb0c fix(ui): night-round review — F2 HouseQuery fires at login, not tab-open
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>
2026-08-17 04:28:46 +02:00

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