diff --git a/SVSim.BattleNode/Wire/SocketIoFrame.cs b/SVSim.BattleNode/Wire/SocketIoFrame.cs
new file mode 100644
index 0000000..7001598
--- /dev/null
+++ b/SVSim.BattleNode/Wire/SocketIoFrame.cs
@@ -0,0 +1,171 @@
+using System.Text;
+using System.Text.Json;
+
+namespace SVSim.BattleNode.Wire;
+
+///
+/// Socket.IO v2 packet. Wire form: <type><N>-<ackId?>[json-args] where
+/// <N>- appears only on binary types (5/6). For binary events/acks, the JSON contains
+/// placeholders {"_placeholder":true,"num":N} that index into .
+///
+public sealed class SocketIoFrame
+{
+ public SocketIoPacketType Type { get; }
+ public int? AckId { get; }
+ public int AttachmentCount { get; }
+ public string? EventName { get; }
+ public JsonElement[] RawArgs { get; }
+ public IReadOnlyList BinaryAttachments { get; }
+
+ public SocketIoFrame(
+ SocketIoPacketType type,
+ int? ackId,
+ int attachmentCount,
+ string? eventName,
+ JsonElement[] rawArgs,
+ IReadOnlyList binaryAttachments)
+ {
+ Type = type;
+ AckId = ackId;
+ AttachmentCount = attachmentCount;
+ EventName = eventName;
+ RawArgs = rawArgs;
+ BinaryAttachments = binaryAttachments;
+ }
+
+ ///
+ /// Parse the text portion of a SIO frame. For binary events the attachments arrive as separate
+ /// WS frames after the text — the caller wires them up via .
+ ///
+ public static SocketIoFrame Parse(string raw)
+ {
+ if (string.IsNullOrEmpty(raw))
+ throw new ArgumentException("Empty SIO payload", nameof(raw));
+
+ var type = (SocketIoPacketType)(raw[0] - '0');
+ var cursor = 1;
+
+ var attachmentCount = 0;
+ if (type is SocketIoPacketType.BinaryEvent or SocketIoPacketType.BinaryAck)
+ {
+ var dashIdx = raw.IndexOf('-', cursor);
+ if (dashIdx < 0)
+ throw new ArgumentException("Binary frame missing '-' separator", nameof(raw));
+ if (!int.TryParse(raw.AsSpan(cursor, dashIdx - cursor), out attachmentCount))
+ throw new ArgumentException("Binary frame attachment count not parseable", nameof(raw));
+ cursor = dashIdx + 1;
+ }
+
+ // Skip namespace (only present if a '/' starts here, terminated by ',').
+ if (cursor < raw.Length && raw[cursor] == '/')
+ {
+ var commaIdx = raw.IndexOf(',', cursor);
+ cursor = commaIdx >= 0 ? commaIdx + 1 : raw.Length;
+ }
+
+ int? ackId = null;
+ if (cursor < raw.Length && char.IsDigit(raw[cursor]))
+ {
+ var start = cursor;
+ while (cursor < raw.Length && char.IsDigit(raw[cursor])) cursor++;
+ ackId = int.Parse(raw.AsSpan(start, cursor - start));
+ }
+
+ var argsJson = cursor < raw.Length ? raw.Substring(cursor) : string.Empty;
+ var allElements = string.IsNullOrEmpty(argsJson)
+ ? Array.Empty()
+ : JsonDocument.Parse(argsJson).RootElement.EnumerateArray().ToArray();
+
+ string? eventName = null;
+ JsonElement[] rawArgs;
+ if (type is SocketIoPacketType.Event or SocketIoPacketType.BinaryEvent && allElements.Length > 0)
+ {
+ eventName = allElements[0].GetString();
+ // RawArgs excludes the leading event-name element so callers index args from 0.
+ rawArgs = allElements.Length > 1 ? allElements[1..] : Array.Empty();
+ }
+ else
+ {
+ rawArgs = allElements;
+ }
+
+ return new SocketIoFrame(type, ackId, attachmentCount, eventName, rawArgs, Array.Empty());
+ }
+
+ ///
+ /// Return a new frame with the given binary attachments attached. Throws if the count doesn't
+ /// match the header's declared attachment count.
+ ///
+ public SocketIoFrame WithAttachments(IReadOnlyList attachments)
+ {
+ if (attachments.Count != AttachmentCount)
+ throw new ArgumentException(
+ $"Attachment count mismatch: header says {AttachmentCount}, got {attachments.Count}");
+ return new SocketIoFrame(Type, AckId, AttachmentCount, EventName, RawArgs, attachments);
+ }
+
+ ///
+ /// Build a binary event frame for the given event name + binary attachments.
+ /// The JSON args become [eventName, {_placeholder:true,num:0}, {_placeholder:true,num:1}, ...].
+ ///
+ public static SocketIoFrame BinaryEventWithAttachments(string eventName, IReadOnlyList attachments)
+ {
+ // Build the placeholder-only portion (without the event name) — event name is stored separately.
+ var sb = new StringBuilder();
+ sb.Append('[');
+ for (var i = 0; i < attachments.Count; i++)
+ {
+ if (i > 0) sb.Append(',');
+ sb.Append("{\"_placeholder\":true,\"num\":").Append(i).Append('}');
+ }
+ sb.Append(']');
+ var args = JsonDocument.Parse(sb.ToString()).RootElement.EnumerateArray().ToArray();
+
+ return new SocketIoFrame(
+ SocketIoPacketType.BinaryEvent,
+ ackId: null,
+ attachmentCount: attachments.Count,
+ eventName: eventName,
+ rawArgs: args,
+ binaryAttachments: attachments);
+ }
+
+ /// Build an ack response with a single int argument (the spec's pubSeq echo).
+ public static SocketIoFrame AckResponse(int ackId, int arg)
+ {
+ var args = JsonDocument.Parse($"[{arg}]").RootElement.EnumerateArray().ToArray();
+ return new SocketIoFrame(
+ SocketIoPacketType.Ack, ackId, 0, null, args, Array.Empty());
+ }
+
+ ///
+ /// Encode to the wire form: (text payload, ordered list of binary attachments).
+ /// The caller is responsible for sending the text frame first then each binary attachment frame.
+ ///
+ public (string Text, IReadOnlyList Binaries) Encode()
+ {
+ var sb = new StringBuilder();
+ sb.Append((int)Type);
+ if (Type is SocketIoPacketType.BinaryEvent or SocketIoPacketType.BinaryAck)
+ {
+ sb.Append(AttachmentCount).Append('-');
+ }
+ if (AckId.HasValue) sb.Append(AckId.Value);
+ // Re-serialize args — for event/binary-event types, re-prepend the event name.
+ sb.Append('[');
+ var prependEventName = Type is SocketIoPacketType.Event or SocketIoPacketType.BinaryEvent
+ && EventName is not null;
+ if (prependEventName)
+ {
+ sb.Append('"').Append(EventName).Append('"');
+ if (RawArgs.Length > 0) sb.Append(',');
+ }
+ for (var i = 0; i < RawArgs.Length; i++)
+ {
+ if (i > 0) sb.Append(',');
+ sb.Append(RawArgs[i].GetRawText());
+ }
+ sb.Append(']');
+ return (sb.ToString(), BinaryAttachments);
+ }
+}
diff --git a/SVSim.BattleNode/Wire/SocketIoPacketType.cs b/SVSim.BattleNode/Wire/SocketIoPacketType.cs
new file mode 100644
index 0000000..11394ad
--- /dev/null
+++ b/SVSim.BattleNode/Wire/SocketIoPacketType.cs
@@ -0,0 +1,12 @@
+namespace SVSim.BattleNode.Wire;
+
+public enum SocketIoPacketType
+{
+ Connect = 0,
+ Disconnect = 1,
+ Event = 2,
+ Ack = 3,
+ Error = 4,
+ BinaryEvent = 5,
+ BinaryAck = 6,
+}
diff --git a/SVSim.UnitTests/BattleNode/Wire/SocketIoFrameTests.cs b/SVSim.UnitTests/BattleNode/Wire/SocketIoFrameTests.cs
new file mode 100644
index 0000000..841d731
--- /dev/null
+++ b/SVSim.UnitTests/BattleNode/Wire/SocketIoFrameTests.cs
@@ -0,0 +1,88 @@
+using System.Text.Json;
+using NUnit.Framework;
+using SVSim.BattleNode.Wire;
+
+namespace SVSim.UnitTests.BattleNode.Wire;
+
+[TestFixture]
+public class SocketIoFrameTests
+{
+ [Test]
+ public void Parse_ConnectPacket_HasNoPayload()
+ {
+ var frame = SocketIoFrame.Parse("0");
+ Assert.That(frame.Type, Is.EqualTo(SocketIoPacketType.Connect));
+ Assert.That(frame.AckId, Is.Null);
+ Assert.That(frame.AttachmentCount, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void Parse_EventWithAck_ExtractsAckIdAndArgs()
+ {
+ var frame = SocketIoFrame.Parse("27[\"msg\",42]");
+ Assert.That(frame.Type, Is.EqualTo(SocketIoPacketType.Event));
+ Assert.That(frame.AckId, Is.EqualTo(7));
+ Assert.That(frame.EventName, Is.EqualTo("msg"));
+ Assert.That(frame.RawArgs[0].GetInt32(), Is.EqualTo(42));
+ }
+
+ [Test]
+ public void Parse_BinaryEvent_RecordsAttachmentCount_WithAttachments_Assembles()
+ {
+ var attachment = new byte[] { 0x01, 0x02, 0x03 };
+ var header = SocketIoFrame.Parse("51-[\"msg\",{\"_placeholder\":true,\"num\":0}]");
+ var assembled = header.WithAttachments(new[] { attachment });
+
+ Assert.That(assembled.Type, Is.EqualTo(SocketIoPacketType.BinaryEvent));
+ Assert.That(assembled.AckId, Is.Null);
+ Assert.That(assembled.AttachmentCount, Is.EqualTo(1));
+ Assert.That(assembled.BinaryAttachments[0], Is.EqualTo(attachment));
+ Assert.That(assembled.EventName, Is.EqualTo("msg"));
+ }
+
+ [Test]
+ public void Parse_BinaryEventWithAckId_ExtractsBoth()
+ {
+ var frame = SocketIoFrame.Parse("51-3[\"msg\",{\"_placeholder\":true,\"num\":0}]");
+ Assert.That(frame.Type, Is.EqualTo(SocketIoPacketType.BinaryEvent));
+ Assert.That(frame.AckId, Is.EqualTo(3));
+ Assert.That(frame.AttachmentCount, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void Parse_AckResponse_ExtractsIdAndIntArg()
+ {
+ var frame = SocketIoFrame.Parse("37[123]");
+ Assert.That(frame.Type, Is.EqualTo(SocketIoPacketType.Ack));
+ Assert.That(frame.AckId, Is.EqualTo(7));
+ Assert.That(frame.RawArgs[0].GetInt32(), Is.EqualTo(123));
+ }
+
+ [Test]
+ public void Encode_BinaryEventWithAttachment_EmitsCountDashAndPlaceholder()
+ {
+ var attachment = new byte[] { 0xff };
+ var frame = SocketIoFrame.BinaryEventWithAttachments("synchronize", new[] { attachment });
+
+ var (text, bins) = frame.Encode();
+ Assert.That(text, Is.EqualTo("51-[\"synchronize\",{\"_placeholder\":true,\"num\":0}]"));
+ Assert.That(bins.Single(), Is.EqualTo(attachment));
+ }
+
+ [Test]
+ public void Encode_AckResponse_IsTypeIdAndArrayOfArgs()
+ {
+ var frame = SocketIoFrame.AckResponse(ackId: 7, arg: 123);
+ var (text, bins) = frame.Encode();
+
+ Assert.That(text, Is.EqualTo("37[123]"));
+ Assert.That(bins, Is.Empty);
+ }
+
+ [Test]
+ public void WithAttachments_CountMismatch_Throws()
+ {
+ var header = SocketIoFrame.Parse("51-[\"msg\",{\"_placeholder\":true,\"num\":0}]");
+ Assert.Throws(() => header.WithAttachments(Array.Empty()));
+ }
+}