diff --git a/SVSim.BattleNode/Protocol/EmitCategory.cs b/SVSim.BattleNode/Protocol/EmitCategory.cs
new file mode 100644
index 0000000..87477c3
--- /dev/null
+++ b/SVSim.BattleNode/Protocol/EmitCategory.cs
@@ -0,0 +1,11 @@
+namespace SVSim.BattleNode.Protocol;
+
+/// The "cat" field on the msg envelope.
+public enum EmitCategory
+{
+ Battle = 1,
+ Matching = 2,
+ Room = 3,
+ Watch = 11,
+ General = 99,
+}
diff --git a/SVSim.BattleNode/Protocol/MsgEnvelope.cs b/SVSim.BattleNode/Protocol/MsgEnvelope.cs
new file mode 100644
index 0000000..a45d665
--- /dev/null
+++ b/SVSim.BattleNode/Protocol/MsgEnvelope.cs
@@ -0,0 +1,89 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace SVSim.BattleNode.Protocol;
+
+///
+/// The shared envelope on every encrypted msg / synchronize frame. Body is opaque
+/// (Dictionary<string, object?>) because shape varies per Uri — typed register-action
+/// models come in a later slice.
+///
+public sealed record MsgEnvelope(
+ NetworkBattleUri Uri,
+ long ViewerId,
+ string Uuid,
+ string? Bid,
+ int Try,
+ EmitCategory Cat,
+ long? PubSeq,
+ long? PlaySeq,
+ Dictionary Body)
+{
+ private static readonly JsonSerializerOptions Options = CreateOptions();
+
+ private static JsonSerializerOptions CreateOptions()
+ {
+ var opt = new JsonSerializerOptions
+ {
+ // Wire-key casing here is bare camelCase — NOT EmulatedEntrypoint's snake_case policy.
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ };
+ opt.Converters.Add(new JsonStringEnumConverter());
+ return opt;
+ }
+
+ public static string ToJson(MsgEnvelope env)
+ {
+ var doc = new Dictionary
+ {
+ ["uri"] = env.Uri.ToString(),
+ ["viewerId"] = env.ViewerId,
+ ["uuid"] = env.Uuid,
+ ["try"] = env.Try,
+ ["cat"] = (int)env.Cat,
+ };
+ if (env.Bid is not null) doc["bid"] = env.Bid;
+ if (env.PubSeq.HasValue) doc["pubSeq"] = env.PubSeq.Value;
+ if (env.PlaySeq.HasValue) doc["playSeq"] = env.PlaySeq.Value;
+ foreach (var (k, v) in env.Body) doc[k] = v;
+ return JsonSerializer.Serialize(doc, Options);
+ }
+
+ public static MsgEnvelope FromJson(string json)
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ var uri = Enum.Parse(root.GetProperty("uri").GetString()!);
+ var viewerId = root.GetProperty("viewerId").GetInt64();
+ var uuid = root.GetProperty("uuid").GetString()!;
+ var bid = root.TryGetProperty("bid", out var bidEl) ? bidEl.GetString() : null;
+ var @try = root.TryGetProperty("try", out var tryEl) ? tryEl.GetInt32() : 0;
+ var cat = root.TryGetProperty("cat", out var catEl) ? (EmitCategory)catEl.GetInt32() : EmitCategory.Battle;
+ var pubSeq = root.TryGetProperty("pubSeq", out var psEl) ? psEl.GetInt64() : (long?)null;
+ var playSeq = root.TryGetProperty("playSeq", out var plsEl) ? plsEl.GetInt64() : (long?)null;
+
+ var body = new Dictionary();
+ var reserved = new HashSet { "uri", "viewerId", "uuid", "bid", "try", "cat", "pubSeq", "playSeq" };
+ foreach (var prop in root.EnumerateObject())
+ {
+ if (reserved.Contains(prop.Name)) continue;
+ body[prop.Name] = ToObject(prop.Value);
+ }
+
+ return new MsgEnvelope(uri, viewerId, uuid, bid, @try, cat, pubSeq, playSeq, body);
+ }
+
+ private static object? ToObject(JsonElement el) => el.ValueKind switch
+ {
+ JsonValueKind.String => el.GetString(),
+ JsonValueKind.Number => el.TryGetInt64(out var l) ? l : el.GetDouble(),
+ JsonValueKind.True => true,
+ JsonValueKind.False => false,
+ JsonValueKind.Null => null,
+ JsonValueKind.Array => el.EnumerateArray().Select(ToObject).ToList(),
+ JsonValueKind.Object => el.EnumerateObject().ToDictionary(p => p.Name, p => ToObject(p.Value)),
+ _ => el.GetRawText(),
+ };
+}
diff --git a/SVSim.BattleNode/Protocol/NetworkBattleUri.cs b/SVSim.BattleNode/Protocol/NetworkBattleUri.cs
new file mode 100644
index 0000000..b5420e5
--- /dev/null
+++ b/SVSim.BattleNode/Protocol/NetworkBattleUri.cs
@@ -0,0 +1,46 @@
+namespace SVSim.BattleNode.Protocol;
+
+///
+/// Discriminator for every msg/synchronize envelope. Wire form is the bare member name
+/// (case-sensitive). See docs/api-spec/in-battle/enums.md.
+///
+public enum NetworkBattleUri
+{
+ None,
+ Resume,
+ Retry,
+ InitNetwork,
+ InitBattle,
+ InitRoomBattle,
+ Matched,
+ Loaded,
+ Deal,
+ Swap,
+ Ready,
+ TurnStart,
+ TurnEndActions,
+ TurnEnd,
+ TurnEndFinal,
+ PlayActions,
+ BattleStart,
+ BattleFinish,
+ ChatStamp,
+ Gungnir,
+ Echo,
+ Retire,
+ OppoDisconnect,
+ End,
+ Judge,
+ Touch,
+ SelectSkill,
+ SelectObject,
+ SlideObject,
+ TurnEndReady,
+ RecoveryStart,
+ RecoveryEnd,
+ JudgeResult,
+ Maintenance,
+ ReplayFinish,
+ Kill,
+ Watch,
+}
diff --git a/SVSim.BattleNode/Protocol/ReceiveNodeResultCode.cs b/SVSim.BattleNode/Protocol/ReceiveNodeResultCode.cs
new file mode 100644
index 0000000..2c1a3a9
--- /dev/null
+++ b/SVSim.BattleNode/Protocol/ReceiveNodeResultCode.cs
@@ -0,0 +1,16 @@
+namespace SVSim.BattleNode.Protocol;
+
+/// The "resultCode" field on synchronize pushes. 1=Success, else error.
+public enum ReceiveNodeResultCode
+{
+ None = 0,
+ Success = 1,
+ Different_UUID = 30001,
+ RedisReplyError = 30002,
+ UnexistUserinfoError = 30003,
+ MatchingTimeOut = 30201,
+ UnmatchedError = 30211,
+ CurrentBattleError = 30212,
+ UnexpectedPhaseError = 30213,
+ // Other codes from spec/enums.md added as needed; v1 only triggers the four above.
+}
diff --git a/SVSim.UnitTests/BattleNode/Protocol/MsgEnvelopeTests.cs b/SVSim.UnitTests/BattleNode/Protocol/MsgEnvelopeTests.cs
new file mode 100644
index 0000000..04e66e9
--- /dev/null
+++ b/SVSim.UnitTests/BattleNode/Protocol/MsgEnvelopeTests.cs
@@ -0,0 +1,66 @@
+using System.Text.Json;
+using NUnit.Framework;
+using SVSim.BattleNode.Protocol;
+
+namespace SVSim.UnitTests.BattleNode.Protocol;
+
+[TestFixture]
+public class MsgEnvelopeTests
+{
+ [Test]
+ public void Roundtrip_PreservesEnvelopeAndBody()
+ {
+ var env = new MsgEnvelope(
+ Uri: NetworkBattleUri.InitNetwork,
+ ViewerId: 906243102,
+ Uuid: "udid-1234",
+ Bid: "597830888107",
+ Try: 0,
+ Cat: EmitCategory.General,
+ PubSeq: null,
+ PlaySeq: null,
+ Body: new Dictionary { ["foo"] = 42 });
+
+ var json = MsgEnvelope.ToJson(env);
+ var back = MsgEnvelope.FromJson(json);
+
+ Assert.That(back.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
+ Assert.That(back.ViewerId, Is.EqualTo(906243102));
+ Assert.That(back.Uuid, Is.EqualTo("udid-1234"));
+ Assert.That(back.Bid, Is.EqualTo("597830888107"));
+ Assert.That(back.Cat, Is.EqualTo(EmitCategory.General));
+ Assert.That(back.Body["foo"], Is.EqualTo(42L)); // JsonElement → int64
+ }
+
+ [Test]
+ public void ToJson_OmitsNullEnvelopeFields()
+ {
+ var env = new MsgEnvelope(
+ Uri: NetworkBattleUri.Ready,
+ ViewerId: 1,
+ Uuid: "u",
+ Bid: null,
+ Try: 0,
+ Cat: EmitCategory.Battle,
+ PubSeq: null,
+ PlaySeq: 5,
+ Body: new Dictionary());
+
+ var json = MsgEnvelope.ToJson(env);
+
+ Assert.That(json, Does.Not.Contain("\"bid\""));
+ Assert.That(json, Does.Not.Contain("\"pubSeq\""));
+ Assert.That(json, Does.Contain("\"playSeq\":5"));
+ }
+
+ [Test]
+ public void FromJson_DispatchesUriToEnum()
+ {
+ const string json = "{\"uri\":\"PlayActions\",\"viewerId\":1,\"uuid\":\"u\",\"try\":0,\"cat\":1}";
+
+ var env = MsgEnvelope.FromJson(json);
+
+ Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.PlayActions));
+ Assert.That(env.Cat, Is.EqualTo(EmitCategory.Battle));
+ }
+}