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