diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs
index 395ec406..a2295608 100644
--- a/src/AcDream.Core.Net/GameEventWiring.cs
+++ b/src/AcDream.Core.Net/GameEventWiring.cs
@@ -124,7 +124,14 @@ public static class GameEventWiring
Action? onTradeDecline = null,
Action? onTradeReset = null,
Action? 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? onHouseData = null,
+ Action? onHouseStatus = null,
+ Action? onHouseUpdateRentTime = null,
+ Action>? onHouseUpdateRentPayment = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
@@ -402,6 +409,43 @@ public static class GameEventWiring
onTradeClearAcceptance());
}
+ // ── House panel (0x0225–0x0228) ───────────────────────────
+ // 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 =>
diff --git a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs
index 6b50c3a2..2acd6b42 100644
--- a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs
+++ b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs
@@ -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];
diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs
index 23545771..6f5defb9 100644
--- a/src/AcDream.Core.Net/Messages/GameEvents.cs
+++ b/src/AcDream.Core.Net/Messages/GameEvents.cs
@@ -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).
+
+ /// One house purchase/maintenance line item. ACE
+ /// HousePaymentExtensions.Write: Num(int) + Paid(int) + WeenieID(uint) +
+ /// Name(String16L) + PluralName(String16L).
+ public readonly record struct HousePayment(
+ int Num, int Paid, uint WeenieID, string Name, string PluralName);
+
+ /// 0x0225 HouseData: the owned-house panel snapshot. ACE
+ /// HouseDataExtensions.Write: BuyTime(uint) + RentTime(uint) +
+ /// Type(uint HouseType enum) + MaintenanceFree(uint bool) +
+ /// Buy(List<HousePayment>) + Rent(List<HousePayment>) +
+ /// Position (the same Cell+Pos.XYZ+Rotation.WXYZ 32-byte shape
+ /// already parses
+ /// elsewhere).
+ public readonly record struct HouseData(
+ uint BuyTime,
+ uint RentTime,
+ uint Type,
+ bool MaintenanceFree,
+ IReadOnlyList Buy,
+ IReadOnlyList Rent,
+ CreateObject.ServerPosition Position);
+
+ public static HouseData? ParseHouseData(ReadOnlySpan 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? buy = ReadHousePaymentList(payload, ref pos);
+ if (buy is null) return null;
+ List? 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? ReadHousePaymentList(ReadOnlySpan payload, ref int pos)
+ {
+ if (payload.Length - pos < 4) return null;
+ uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
+ var list = new List((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;
+ }
+
+ /// 0x0226 HouseStatus: a single WeenieError u32 — retail's
+ /// RecvNotice_FailedHouseTransaction family (also the "no house
+ /// owned" reply to a HouseQuery — ACE Player_House.cs
+ /// HandleActionQueryHouse's new GameEventHouseStatus(Session)
+ /// defaults to WeenieError.None, not a "failure").
+ public static uint? ParseHouseStatus(ReadOnlySpan payload)
+ {
+ if (payload.Length < 4) return null;
+ return BinaryPrimitives.ReadUInt32LittleEndian(payload);
+ }
+
+ /// 0x0227 UpdateRentTime: a single uint (when the current
+ /// maintenance period began, Unix timestamp). ACE
+ /// GameEventHouseUpdateRentTime.cs is a STUB that always writes
+ /// 0u — captured here for completeness, not exercised by any
+ /// live ACE install today.
+ public static uint? ParseUpdateRentTime(ReadOnlySpan payload)
+ {
+ if (payload.Length < 4) return null;
+ return BinaryPrimitives.ReadUInt32LittleEndian(payload);
+ }
+
+ /// 0x0228 UpdateRentPayment: a List<HousePayment> (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.
+ public static IReadOnlyList? ParseUpdateRentPayment(ReadOnlySpan payload)
+ {
+ int pos = 0;
+ return ReadHousePaymentList(payload, ref pos);
+ }
+
// ── Shared string reader (matches LoginRequest.ReadString16L) ───────────
private static string ReadString16L(ReadOnlySpan source, ref int pos)
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index e135a6cf..357e9069 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -2517,6 +2517,15 @@ public sealed class WorldSession : IDisposable
SendGameAction(ClientCommandRequests.BuildMansionRecall(seq));
}
+ /// Query the local player's house info — either owned house
+ /// data (0x0225) or a no-house status (0x0226) comes back
+ /// (0x021E).
+ public void SendHouseQuery()
+ {
+ uint seq = NextGameActionSequence();
+ SendGameAction(ClientCommandRequests.BuildHouseQuery(seq));
+ }
+
/// Query the local character's played time (0x01C2).
public void SendQueryAge()
{
diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
index 5f7fd456..f093d5d2 100644
--- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
+++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
@@ -1771,6 +1771,48 @@ public sealed class GameEventWiringTests
Assert.True(observed.Value.IsLoggedIn);
}
+ /// Batch C (Map/House toolbar panel, 2026-08-17): all four
+ /// house wire opcodes (0x0225-0x0228) reach their registered
+ /// callbacks.
+ [Fact]
+ public void WireAll_HouseFamily_ReachesTheirCallbacks()
+ {
+ var dispatcher = new GameEventDispatcher();
+ GameEvents.HouseData? data = null;
+ uint? status = null;
+ uint? rentTime = null;
+ IReadOnlyList? rentPayment = null;
+ GameEventWiring.WireAll(
+ dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(),
+ onHouseData: d => data = d,
+ onHouseStatus: code => status = code,
+ onHouseUpdateRentTime: t => rentTime = t,
+ onHouseUpdateRentPayment: p => rentPayment = p);
+
+ byte[] houseDataWire = new AceWireWriter()
+ .Write(0u).Write(0u).Write(0u).Write(0u)
+ .Write(0).Write(0)
+ .Write(0x00120001u)
+ .Write(0f).Write(0f).Write(0f)
+ .Write(1f).Write(0f).Write(0f).Write(0f)
+ .ToArray();
+ dispatcher.Dispatch(GameEventEnvelope.TryParse(
+ WrapEnvelope(GameEventType.HouseData, houseDataWire))!.Value);
+ dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope(
+ GameEventType.HouseStatus, new AceWireWriter().Write(0u).ToArray()))!.Value);
+ dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope(
+ GameEventType.UpdateRentTime, new AceWireWriter().Write(1_700_000_000u).ToArray()))!.Value);
+ dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope(
+ GameEventType.UpdateRentPayment, new AceWireWriter().Write(0).ToArray()))!.Value);
+
+ Assert.NotNull(data);
+ Assert.Equal(0x00120001u, data!.Value.Position.LandblockId);
+ Assert.Equal(0u, status);
+ Assert.Equal(1_700_000_000u, rentTime);
+ Assert.NotNull(rentPayment);
+ Assert.Empty(rentPayment);
+ }
+
private static byte[] BuildEnchantment(
ushort spellId,
ushort layer,
diff --git a/tests/AcDream.Core.Net.Tests/Messages/HouseEventsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/HouseEventsTests.cs
new file mode 100644
index 00000000..bf15f67e
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/HouseEventsTests.cs
@@ -0,0 +1,156 @@
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+///
+/// Batch C (overnight hover/UI round, Map/House toolbar panel, 2026-08-17):
+/// golden-byte coverage for the House panel's four inbound events
+/// (0x0225-0x0228, gmHouseUI::PostInit's registered notice handlers) and the
+/// outbound HouseQuery action (0x021E). Wire shapes cross-checked against
+/// ACE's HouseDataExtensions/HousePaymentExtensions
+/// (references/ACE/Source/ACE.Server/Network/Structure/HouseData.cs,
+/// HousePayment.cs) — see docs/research/2026-08-17-map-house-recon.md.
+///
+public sealed class HouseEventsTests
+{
+ [Fact]
+ public void BuildHouseQuery_WritesEnvelopeSequenceOpcodeOnly()
+ {
+ byte[] body = ClientCommandRequests.BuildHouseQuery(9);
+
+ Assert.Equal(12, body.Length);
+ Assert.Equal(ClientCommandRequests.HouseQueryOpcode,
+ System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ }
+
+ [Fact]
+ public void ParseHouseStatus_ReadsWeenieError()
+ {
+ byte[] wire = new AceWireWriter().Write(0u).ToArray();
+
+ Assert.Equal(0u, GameEvents.ParseHouseStatus(wire));
+ }
+
+ [Fact]
+ public void ParseHouseStatus_TruncatedPayload_ReturnsNull()
+ {
+ Assert.Null(GameEvents.ParseHouseStatus(System.Array.Empty()));
+ }
+
+ [Fact]
+ public void ParseUpdateRentTime_ReadsTimestamp()
+ {
+ // The real ACE writer always sends 0u (a stub) — parsed at whatever
+ // value arrives, not hardcoded to that stub's output.
+ byte[] wire = new AceWireWriter().Write(1_700_000_000u).ToArray();
+
+ Assert.Equal(1_700_000_000u, GameEvents.ParseUpdateRentTime(wire));
+ }
+
+ [Fact]
+ public void ParseUpdateRentPayment_EmptyList_RoundTrips()
+ {
+ // The real ACE writer always sends an empty list (a stub).
+ byte[] wire = new AceWireWriter().Write(0).ToArray();
+
+ var payments = GameEvents.ParseUpdateRentPayment(wire);
+
+ Assert.NotNull(payments);
+ Assert.Empty(payments);
+ }
+
+ [Fact]
+ public void ParseUpdateRentPayment_OneEntry_RoundTrips()
+ {
+ byte[] wire = new AceWireWriter()
+ .Write(1)
+ .Write(400) // Num
+ .Write(150) // Paid
+ .Write(273u) // WeenieID (pyreal)
+ .WriteString16L("Pyreal")
+ .WriteString16L("Pyreals")
+ .ToArray();
+
+ var payments = GameEvents.ParseUpdateRentPayment(wire);
+
+ Assert.NotNull(payments);
+ GameEvents.HousePayment payment = Assert.Single(payments);
+ Assert.Equal(400, payment.Num);
+ Assert.Equal(150, payment.Paid);
+ Assert.Equal(273u, payment.WeenieID);
+ Assert.Equal("Pyreal", payment.Name);
+ Assert.Equal("Pyreals", payment.PluralName);
+ }
+
+ [Fact]
+ public void ParseHouseData_NoHouseOwned_EmptyListsAndZeroFields()
+ {
+ byte[] wire = new AceWireWriter()
+ .Write(0u) // BuyTime
+ .Write(0u) // RentTime
+ .Write(0u) // Type (Undef)
+ .Write(0u) // MaintenanceFree
+ .Write(0) // Buy.Count
+ .Write(0) // Rent.Count
+ // Position: Cell + Pos.XYZ + Rotation.WXYZ
+ .Write(0x00120001u)
+ .Write(10f).Write(20f).Write(30f)
+ .Write(1f).Write(0f).Write(0f).Write(0f)
+ .ToArray();
+
+ GameEvents.HouseData? data = GameEvents.ParseHouseData(wire);
+
+ Assert.NotNull(data);
+ Assert.Equal(0u, data!.Value.BuyTime);
+ Assert.Empty(data.Value.Buy);
+ Assert.Empty(data.Value.Rent);
+ Assert.Equal(0x00120001u, data.Value.Position.LandblockId);
+ Assert.Equal(10f, data.Value.Position.PositionX);
+ Assert.Equal(30f, data.Value.Position.PositionZ);
+ Assert.Equal(1f, data.Value.Position.RotationW);
+ }
+
+ [Fact]
+ public void ParseHouseData_OwnedHouse_ReadsBuyAndRentLists()
+ {
+ byte[] wire = new AceWireWriter()
+ .Write(1_650_000_000u) // BuyTime
+ .Write(1_699_000_000u) // RentTime
+ .Write(1u) // Type (Cottage)
+ .Write(0u) // MaintenanceFree = false
+ .Write(1) // Buy.Count
+ .Write(1).Write(1).Write(273u)
+ .WriteString16L("Pyreal").WriteString16L("Pyreals")
+ .Write(2) // Rent.Count
+ .Write(300).Write(300).Write(273u)
+ .WriteString16L("Pyreal").WriteString16L("Pyreals")
+ .Write(1).Write(0).Write(1049u)
+ .WriteString16L("Writ of the Chosen").WriteString16L("Writs of the Chosen")
+ // Position
+ .Write(0x00340002u)
+ .Write(-15f).Write(45f).Write(0f)
+ .Write(0.7071f).Write(0f).Write(0f).Write(0.7071f)
+ .ToArray();
+
+ GameEvents.HouseData? data = GameEvents.ParseHouseData(wire);
+
+ Assert.NotNull(data);
+ Assert.Equal(1_650_000_000u, data!.Value.BuyTime);
+ Assert.Equal(1_699_000_000u, data.Value.RentTime);
+ Assert.Equal(1u, data.Value.Type);
+ Assert.False(data.Value.MaintenanceFree);
+ Assert.Single(data.Value.Buy);
+ Assert.Equal(2, data.Value.Rent.Count);
+ Assert.Equal("Writ of the Chosen", data.Value.Rent[1].Name);
+ Assert.Equal(0x00340002u, data.Value.Position.LandblockId);
+ }
+
+ [Fact]
+ public void ParseHouseData_TruncatedPayload_ReturnsNull()
+ {
+ byte[] wire = new AceWireWriter().Write(0u).Write(0u).ToArray();
+
+ Assert.Null(GameEvents.ParseHouseData(wire));
+ }
+}