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