diff --git a/SVSim.BattleNode/Protocol/MsgPayloadCodec.cs b/SVSim.BattleNode/Protocol/MsgPayloadCodec.cs
new file mode 100644
index 0000000..5e5095c
--- /dev/null
+++ b/SVSim.BattleNode/Protocol/MsgPayloadCodec.cs
@@ -0,0 +1,26 @@
+using MessagePack;
+using SVSim.BattleNode.Wire;
+
+namespace SVSim.BattleNode.Protocol;
+
+///
+/// Full chain between an envelope and the bytes that ride as a SocketIO binary attachment.
+/// Inbound: bytes → msgpack-string → NodeCrypto.Decrypt → JSON → MsgEnvelope
+/// Outbound: MsgEnvelope → JSON → NodeCrypto.Encrypt → msgpack-bytes
+///
+public static class MsgPayloadCodec
+{
+ public static MsgEnvelope Decode(byte[] msgpackBytes)
+ {
+ var encryptedString = MessagePackSerializer.Deserialize(msgpackBytes);
+ var json = NodeCrypto.DecryptForNode(encryptedString);
+ return MsgEnvelope.FromJson(json);
+ }
+
+ public static byte[] Encode(MsgEnvelope envelope, string key)
+ {
+ var json = MsgEnvelope.ToJson(envelope);
+ var encryptedString = NodeCrypto.EncryptForNode(json, key);
+ return MessagePackSerializer.Serialize(encryptedString);
+ }
+}
diff --git a/SVSim.UnitTests/BattleNode/Protocol/MsgPayloadCodecTests.cs b/SVSim.UnitTests/BattleNode/Protocol/MsgPayloadCodecTests.cs
new file mode 100644
index 0000000..afbfb75
--- /dev/null
+++ b/SVSim.UnitTests/BattleNode/Protocol/MsgPayloadCodecTests.cs
@@ -0,0 +1,53 @@
+using NUnit.Framework;
+using SVSim.BattleNode.Protocol;
+
+namespace SVSim.UnitTests.BattleNode.Protocol;
+
+[TestFixture]
+public class MsgPayloadCodecTests
+{
+ private static string FreshKey()
+ {
+ var seq = 0;
+ return SVSim.BattleNode.Wire.NodeCrypto.GenerateKey(() => (seq++ * 7) % 16);
+ }
+
+ [Test]
+ public void Roundtrip_PreservesEnvelope()
+ {
+ var env = new MsgEnvelope(
+ Uri: NetworkBattleUri.Loaded,
+ ViewerId: 906243102,
+ Uuid: "udid",
+ Bid: "1234",
+ Try: 0,
+ Cat: EmitCategory.Battle,
+ PubSeq: 3,
+ PlaySeq: null,
+ Body: new Dictionary());
+
+ var bytes = MsgPayloadCodec.Encode(env, key: FreshKey());
+ var back = MsgPayloadCodec.Decode(bytes);
+
+ Assert.That(back.Uri, Is.EqualTo(NetworkBattleUri.Loaded));
+ Assert.That(back.PubSeq, Is.EqualTo(3));
+ Assert.That(back.Bid, Is.EqualTo("1234"));
+ }
+
+ [Test]
+ public void Decode_KnownEnvelope_ReturnsExpectedUriAndBody()
+ {
+ // The captures only contain decoded JSON, so we build the encrypted-msgpack representation
+ // ourselves with the same JSON and a known key — this confirms the full chain end-to-end.
+ var key = FreshKey();
+ var originalJson = "{\"uri\":\"InitNetwork\",\"viewerId\":1,\"uuid\":\"u\",\"try\":0,\"cat\":99,\"resultCode\":1}";
+ var encrypted = SVSim.BattleNode.Wire.NodeCrypto.EncryptForNode(originalJson, key);
+ var bytes = MessagePack.MessagePackSerializer.Serialize(encrypted);
+
+ var env = MsgPayloadCodec.Decode(bytes);
+
+ Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
+ Assert.That(env.Cat, Is.EqualTo(EmitCategory.General));
+ Assert.That(env.Body["resultCode"], Is.EqualTo(1L));
+ }
+}