diff --git a/src/AcDream.Core.Net/Messages/SetState.cs b/src/AcDream.Core.Net/Messages/SetState.cs new file mode 100644 index 0000000..a8a2126 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/SetState.cs @@ -0,0 +1,84 @@ +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound SetState GameMessage (opcode 0xF74B). The server +/// broadcasts this whenever a previously-spawned entity's +/// PhysicsState bitmask changes after CreateObject — chiefly +/// when a door opens / closes (server flips ETHEREAL_PS = 0x4) or a +/// spell projectile becomes ethereal post-impact. +/// +/// +/// Wire layout (per +/// references/holtburger/crates/holtburger-protocol/src/messages/object/messages/properties.rs:117-122, +/// matched by every other acdream parser): +/// +/// +/// u32 opcode — 0xF74B +/// u32 objectGuid +/// u32 physicsState — bitmask (acclient.h:2815 / 2819) +/// u16 instanceSequence — stale-packet rejection +/// u16 stateSequence — stale-packet rejection +/// +/// +/// +/// Total body size: 16 bytes from start (opcode + 12-byte payload). +/// +/// +/// +/// Server-side reference: +/// references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageSetState.cs:8-15 +/// (ACE writes the same field order but appears to use uint for the +/// sequence fields; verified against retail format by hex-dump probe in +/// Task 5). Holtburger has been validated against a retail-format server, +/// so its 12-byte payload is the trusted spec. +/// +/// +/// +/// Named-retail anchor: CPhysicsObj::set_state at +/// docs/research/named-retail/acclient_2013_pseudo_c.txt:283044 +/// describes the runtime state-store on the in-memory object +/// (this->state = arg2). The wire format for this opcode is +/// confirmed by holtburger's SetStateData struct; the named-retail +/// decomp does not cover the deserialization path for this opcode. +/// +/// +public static class SetState +{ + public const uint Opcode = 0xF74Bu; + + public readonly record struct Parsed( + uint Guid, + uint PhysicsState, + ushort InstanceSequence, + ushort StateSequence); + + /// + /// Parse a 0xF74B body. must start with the + /// 4-byte opcode (matches the convention used by VectorUpdate / + /// UpdateMotion / UpdatePosition). Returns null on truncation or + /// opcode mismatch. + /// + public static Parsed? TryParse(ReadOnlySpan body) + { + if (body.Length < 16) return null; + try + { + uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(0, 4)); + if (opcode != Opcode) return null; + + uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4)); + uint state = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(8, 4)); + ushort instSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(12, 2)); + ushort stateSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(14, 2)); + + return new Parsed(guid, state, instSeq, stateSeq); + } + catch + { + return null; + } + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs b/tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs new file mode 100644 index 0000000..837ccac --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs @@ -0,0 +1,43 @@ +using System; +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public class SetStateTests +{ + [Fact] + public void TryParse_WellFormedBody_ReturnsParsed() + { + var buf = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0, 4), 0xF74Bu); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4, 4), 0x000F4244u); // door guid + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8, 4), 0x00000004u); // ETHEREAL bit + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(12, 2), (ushort)355); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(14, 2), (ushort)42); + + var parsed = SetState.TryParse(buf); + + Assert.NotNull(parsed); + Assert.Equal(0x000F4244u, parsed.Value.Guid); + Assert.Equal(0x00000004u, parsed.Value.PhysicsState); + Assert.Equal((ushort)355, parsed.Value.InstanceSequence); + Assert.Equal((ushort)42, parsed.Value.StateSequence); + } + + [Fact] + public void TryParse_Truncated_ReturnsNull() + { + var buf = new byte[10]; + Assert.Null(SetState.TryParse(buf)); + } + + [Fact] + public void TryParse_WrongOpcode_ReturnsNull() + { + var buf = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0, 4), 0xF74Cu); + Assert.Null(SetState.TryParse(buf)); + } +}