feat(net): Map/House panel — slice 4a, House wire parsing groundwork

Adds the outbound HouseQuery action (0x021E, ClientCommandRequests.
BuildHouseQuery / WorldSession.SendHouseQuery — ACE GameActionHouseQuery.
Handle reads no payload) and inbound parsers for all four House wire
opcodes GameEventType already defined (0x0225-0x0228, gmHouseUI::PostInit's
registered notice handlers): GameEvents.ParseHouseData (BuyTime/RentTime/
Type/MaintenanceFree/Buy list/Rent list/Position — the Position field
reuses CreateObject.ServerPosition's existing 32-byte Cell+Pos.XYZ+
Rotation.WXYZ shape rather than a new type), ParseHouseStatus (WeenieError
u32), ParseUpdateRentTime, ParseUpdateRentPayment. Wire shapes verified
against ACE's HouseDataExtensions/HousePaymentExtensions (references/ACE/
Source/ACE.Server/Network/Structure/HouseData.cs, HousePayment.cs) — noted
that ACE's own UpdateRentTime/UpdateRentPayment writers are stubs (always
0u / always an empty list), captured as such rather than assumed live.

GameEventWiring.WireAll gets four new optional delegate holes
(onHouseData/onHouseStatus/onHouseUpdateRentTime/onHouseUpdateRentPayment)
following the exact trade-family precedent — registered only when non-null,
every existing caller compiles unchanged.

This is the "enum/parser groundwork" half of Slice 4's pre-authorized
fallback. NOT included (filed as an ISSUES entry): a RuntimeHouseState
GameRuntime owner (construction-transaction ceremony, fault-injection
points, disposal/convergence tracking — the same weight as
RuntimeTradeState's integration, judged disproportionate for tonight
alongside the completed Map tab), HousePageController's real Lines/
OnShown wiring, the DisplayPurchaseTimeText port, and the six other
Display* line builders. The House tab currently mounts with genuinely
empty content, matching retail's own PostInit (verified via
MapHousePanelSlotProbeTests' live-DAT probe, not assumed).

9 new HouseEventsTests (parser round-trips + truncation), 1 new
GameEventWiringTests case (all four opcodes reach their callbacks).
Core.Net.Tests: 1004/1004 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 02:09:34 +02:00
parent 6b0fa4ff0d
commit dd6c7e09f0
6 changed files with 381 additions and 1 deletions

View file

@ -124,7 +124,14 @@ public static class GameEventWiring
Action<uint /*whoDeclined*/>? onTradeDecline = null,
Action<uint /*whoReset*/>? onTradeReset = null,
Action<GameEvents.TradeFailure>? onTradeFailure = null,
Action? onTradeClearAcceptance = null)
Action? onTradeClearAcceptance = null,
// House panel (Batch C, Map/House toolbar panel, 2026-08-17): the
// same Runtime-owned delegate-hole shape as trade above —
// RuntimeHouseState (or a lighter equivalent) is the consumer.
Action<GameEvents.HouseData>? onHouseData = null,
Action<uint /*weenieError*/>? onHouseStatus = null,
Action<uint /*rentTime*/>? onHouseUpdateRentTime = null,
Action<IReadOnlyList<GameEvents.HousePayment>>? onHouseUpdateRentPayment = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
@ -402,6 +409,43 @@ public static class GameEventWiring
onTradeClearAcceptance());
}
// ── House panel (0x02250x0228) ───────────────────────────
// Batch C (Map/House toolbar panel, 2026-08-17). gmHouseUI::
// PostInit registers all four; consumers are optional so every
// existing caller compiles unchanged.
if (onHouseData is not null)
{
registrar.Register(GameEventType.HouseData, e =>
{
var p = GameEvents.ParseHouseData(e.Payload.Span);
if (p is not null) onHouseData(p.Value);
});
}
if (onHouseStatus is not null)
{
registrar.Register(GameEventType.HouseStatus, e =>
{
var p = GameEvents.ParseHouseStatus(e.Payload.Span);
if (p is not null) onHouseStatus(p.Value);
});
}
if (onHouseUpdateRentTime is not null)
{
registrar.Register(GameEventType.UpdateRentTime, e =>
{
var p = GameEvents.ParseUpdateRentTime(e.Payload.Span);
if (p is not null) onHouseUpdateRentTime(p.Value);
});
}
if (onHouseUpdateRentPayment is not null)
{
registrar.Register(GameEventType.UpdateRentPayment, e =>
{
var p = GameEvents.ParseUpdateRentPayment(e.Payload.Span);
if (p is not null) onHouseUpdateRentPayment(p);
});
}
if (onConfirmationRequest is not null)
{
registrar.Register(GameEventType.CharacterConfirmationRequest, e =>

View file

@ -58,6 +58,17 @@ public static class ClientCommandRequests
public const uint AddPlayerPermissionOpcode = 0x0219u;
public const uint RemovePlayerPermissionOpcode = 0x021Au;
public const uint AbandonHouseOpcode = 0x021Fu;
// Batch C (Map/House toolbar panel, 2026-08-17): the query the House
// tab needs to populate. ACE GameActionHouseQuery.cs: [GameAction(
// GameActionType.HouseQuery)] (0x021E), Handle reads no payload and
// calls session.Player.HandleActionQueryHouse() — which replies with
// either GameEventHouseStatus (0x0226, no house owned) or
// GameEventHouseData (0x0225, house owned). No known retail client
// call site was found in this session's decomp reading (gmHouseUI::
// PostInit never sends it) — HousePageController fires it when the
// House tab becomes visible, an acdream convention, not a ported
// retail trigger.
public const uint HouseQueryOpcode = 0x021Eu;
// Named-retail anchors:
// CM_Character::Event_TeleToMarketplace @ 0x006A1C20
@ -282,6 +293,11 @@ public static class ClientCommandRequests
public static byte[] BuildAbandonHouse(uint sequence) =>
BuildParameterless(sequence, AbandonHouseOpcode);
// Queries the local player's house info (owned house data, or a
// no-house status) — GameActionHouseQuery.Handle: no payload read.
public static byte[] BuildHouseQuery(uint sequence) =>
BuildParameterless(sequence, HouseQueryOpcode);
private static byte[] BuildParameterless(uint sequence, uint opcode)
{
byte[] body = new byte[12];

View file

@ -1003,6 +1003,119 @@ public static class GameEvents
Guests: guests));
}
// ── House panel (Batch C, Map/House toolbar panel, 2026-08-17) ─────────
// gmHouseUI::PostInit @0x004a2710 registers notice handlers for wire
// opcodes 0x0225-0x0228; the recon doc (docs/research/2026-08-17-map-
// house-recon.md) is the SSOT for the retail-side call sites and the
// two ACE writer stubs (UpdateRentTime always writes 0u; UpdateRentPayment
// always writes an empty list — captured verbatim below, not guessed).
/// <summary>One house purchase/maintenance line item. ACE
/// HousePaymentExtensions.Write: Num(int) + Paid(int) + WeenieID(uint) +
/// Name(String16L) + PluralName(String16L).</summary>
public readonly record struct HousePayment(
int Num, int Paid, uint WeenieID, string Name, string PluralName);
/// <summary>0x0225 HouseData: the owned-house panel snapshot. ACE
/// HouseDataExtensions.Write: BuyTime(uint) + RentTime(uint) +
/// Type(uint HouseType enum) + MaintenanceFree(uint bool) +
/// Buy(List&lt;HousePayment&gt;) + Rent(List&lt;HousePayment&gt;) +
/// Position (the same Cell+Pos.XYZ+Rotation.WXYZ 32-byte shape
/// <see cref="CreateObject.ServerPosition"/> already parses
/// elsewhere).</summary>
public readonly record struct HouseData(
uint BuyTime,
uint RentTime,
uint Type,
bool MaintenanceFree,
IReadOnlyList<HousePayment> Buy,
IReadOnlyList<HousePayment> Rent,
CreateObject.ServerPosition Position);
public static HouseData? ParseHouseData(ReadOnlySpan<byte> payload)
{
try
{
int pos = 0;
if (payload.Length - pos < 16) return null;
uint buyTime = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint rentTime = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
bool maintenanceFree = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)) != 0; pos += 4;
List<HousePayment>? buy = ReadHousePaymentList(payload, ref pos);
if (buy is null) return null;
List<HousePayment>? rent = ReadHousePaymentList(payload, ref pos);
if (rent is null) return null;
if (payload.Length - pos < 32) return null;
var position = new CreateObject.ServerPosition(
LandblockId: BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos + 0)),
PositionX: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 4)),
PositionY: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 8)),
PositionZ: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 12)),
RotationW: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 16)),
RotationX: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 20)),
RotationY: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 24)),
RotationZ: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 28)));
return new HouseData(buyTime, rentTime, type, maintenanceFree, buy, rent, position);
}
catch { return null; }
}
private static List<HousePayment>? ReadHousePaymentList(ReadOnlySpan<byte> payload, ref int pos)
{
if (payload.Length - pos < 4) return null;
uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
var list = new List<HousePayment>((int)Math.Min(count, 4096));
for (uint i = 0; i < count; i++)
{
if (payload.Length - pos < 8) return null;
int num = BinaryPrimitives.ReadInt32LittleEndian(payload.Slice(pos)); pos += 4;
int paid = BinaryPrimitives.ReadInt32LittleEndian(payload.Slice(pos)); pos += 4;
if (payload.Length - pos < 4) return null;
uint weenieId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
string name = ReadString16L(payload, ref pos);
string pluralName = ReadString16L(payload, ref pos);
list.Add(new HousePayment(num, paid, weenieId, name, pluralName));
}
return list;
}
/// <summary>0x0226 HouseStatus: a single WeenieError u32 — retail's
/// <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>
public static uint? ParseHouseStatus(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x0227 UpdateRentTime: a single uint (when the current
/// maintenance period began, Unix timestamp). ACE
/// GameEventHouseUpdateRentTime.cs is a STUB that always writes
/// <c>0u</c> — captured here for completeness, not exercised by any
/// live ACE install today.</summary>
public static uint? ParseUpdateRentTime(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x0228 UpdateRentPayment: a List&lt;HousePayment&gt; (the
/// rent items and how much of each has been paid this period). ACE
/// GameEventHouseUpdateRentPayment.cs is a STUB that always writes an
/// EMPTY list — captured here for completeness, not exercised by any
/// live ACE install today.</summary>
public static IReadOnlyList<HousePayment>? ParseUpdateRentPayment(ReadOnlySpan<byte> payload)
{
int pos = 0;
return ReadHousePaymentList(payload, ref pos);
}
// ── Shared string reader (matches LoginRequest.ReadString16L) ───────────
private static string ReadString16L(ReadOnlySpan<byte> source, ref int pos)

View file

@ -2517,6 +2517,15 @@ public sealed class WorldSession : IDisposable
SendGameAction(ClientCommandRequests.BuildMansionRecall(seq));
}
/// <summary>Query the local player's house info — either owned house
/// data (0x0225) or a no-house status (0x0226) comes back
/// (0x021E).</summary>
public void SendHouseQuery()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildHouseQuery(seq));
}
/// <summary>Query the local character's played time (0x01C2).</summary>
public void SendQueryAge()
{