diff --git a/README.md b/README.md index 3d983b6..f30c953 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,44 @@ Frame reference: [client-side prediction](https://asobi.dev/docs/protocols/webso - **Realtime** — WebSocket with events for matches, chat, presence, matchmaking - **Extensions** — Call server extension methods over RPC +## Binary `world.tick` + +Ask for the binary encoding and `world.tick` arrives as a WebSocket binary frame +in roughly a fifth of the bytes - and **already decoded**, which is the real +saving on this SDK, since `OnWorldTick` hands you raw JSON text and parses none of +it. + +```csharp +client.Realtime.RequestBinaryWire = true; +client.Realtime.OnWorldTickFrame += frame => +{ + // frame.ZoneX / ZoneY, frame.FrameSeq, frame.Kf, frame.Tick + foreach (var r in frame.Records) + { + // r.Op is "a" / "u" / "r", r.Id is the entity id, + // r.Fields holds float / int / bool / string / null values + } +}; +await client.Realtime.ConnectAsync(); +``` + +Binary frames reach `OnWorldTickFrame`, not `OnWorldTick`, and never both. The two +are separate events on purpose: re-serialising a decoded frame to fire +`OnWorldTick` would hand back exactly the text-parsing cost the binary wire exists +to remove. Only `world.tick` is affected; everything else stays JSON text on both +wires. + +Entity ids are 2-byte slots on the wire and the SDK resolves them for you, so +`r.Id` is the same id the JSON wire gives. `r.Id` is null only when the `add` that +would have established the binding was lost, which is a `FrameSeq` gap - call +`WorldResyncAsync` and the keyframe rebuilds every binding. + +Requires the server to have `binary_wire` switched on. If it does not, you +silently stay on text - `client.Realtime.Wire` reads `"json"` or `"binary"` once +`OnConnected` has fired, so read it rather than assume. The same fallback happens +per frame for anything the server cannot encode as binary, such as an entity field +holding an array. + ## Build targets | Target | Status | diff --git a/Runtime/Models/RealtimeModels.cs b/Runtime/Models/RealtimeModels.cs index ae0b2d0..676eb5a 100644 --- a/Runtime/Models/RealtimeModels.cs +++ b/Runtime/Models/RealtimeModels.cs @@ -16,6 +16,16 @@ internal class WsConnectPayload public string token; } + // A separate class rather than a nullable field on the one above, because + // JsonUtility writes EVERY field: an optional `wire` would put `"wire": ""` on + // every connect frame a caller never asked to change. + [Serializable] + internal class WsConnectWirePayload + { + public string token; + public string wire; + } + [Serializable] internal class WsMatchInputPayload { diff --git a/Runtime/WebSocket/AsobiDispatcher.cs b/Runtime/WebSocket/AsobiDispatcher.cs index 68882f0..3a2ba79 100644 --- a/Runtime/WebSocket/AsobiDispatcher.cs +++ b/Runtime/WebSocket/AsobiDispatcher.cs @@ -18,6 +18,28 @@ public class AsobiDispatcher public event Action OnVoteResult; public event Action OnVoteVetoed; public event Action OnWorldTick; + + /// + /// A world.tick that arrived on the binary wire, already decoded. + /// + /// + /// Fires instead of for connections that + /// negotiated RequestBinaryWire, and never alongside it. The two are + /// separate events on purpose: hands you raw JSON + /// text and parses none of it, so re-serialising a decoded frame to fire it + /// would hand back exactly the text-parsing cost the binary wire exists to + /// remove. + /// + public event Action OnWorldTickFrame; + + /// The wire the server granted at session.connected, read + /// off the frame itself. is the public + /// spelling. + protected string GrantedWire { get; private set; } = "json"; + + /// Raises . Internal - the receive + /// loop calls this after decoding a binary frame. + protected void RaiseWorldTickFrame(AsobiWireFrame frame) => OnWorldTickFrame?.Invoke(frame); public event Action OnWorldAck; public event Action OnWorldTerrain; public event Action OnWorldJoined; @@ -48,6 +70,10 @@ public class AsobiDispatcher protected void RaiseDisconnected(string reason) => OnDisconnected?.Invoke(reason); + /// Raises for a fault the SDK detected + /// itself, rather than a server error frame. + protected void RaiseError(string message) => OnError?.Invoke(message); + // Fired before each retry, so games can show "Reconnecting (attempt/max)...". protected void RaiseReconnecting(int attempt, int maxAttempts) => OnReconnecting?.Invoke(attempt, maxAttempts); @@ -67,6 +93,13 @@ internal void HandleMessage(string raw) switch (env.Type) { case "session.connected": + // The wire the server GRANTED, which is not always the one + // requested: a server with the binary wire off answers "json". + // Read rather than infer it from the first frame's opcode. + // ExtractField takes the first match in the whole document, + // which is safe here and only here: session.connected carries + // player_id and wire, and no nested object that could shadow it. + GrantedWire = JsonScan.Unquote(JsonScan.ExtractField(raw, "wire")) ?? "json"; OnConnected?.Invoke(); break; case "match.state": diff --git a/Runtime/WebSocket/AsobiRealtime.cs b/Runtime/WebSocket/AsobiRealtime.cs index 76759a4..47419ba 100644 --- a/Runtime/WebSocket/AsobiRealtime.cs +++ b/Runtime/WebSocket/AsobiRealtime.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.IO; using System.Net.WebSockets; using System.Text; using System.Threading; @@ -10,6 +11,27 @@ namespace Asobi { public class AsobiRealtime : AsobiDispatcher, IDisposable { + /// + /// Ask the server for the binary world.tick encoding: roughly a fifth + /// of the bytes, and it arrives already decoded rather than as text you still + /// have to parse. + /// + /// + /// Set it before . Frames then reach + /// instead of + /// ; nothing else changes, and only + /// world.tick is affected. A server with the binary wire switched off + /// answers json and you silently stay on text, so read + /// after OnConnected rather than assuming the + /// request was honoured. + /// + public bool RequestBinaryWire; + + /// The wire the server actually granted: "json" or + /// "binary". Valid once OnConnected has fired. + public string Wire => GrantedWire; + + readonly AsobiWire _wire = new AsobiWire(); readonly AsobiClient _client; ClientWebSocket _ws; CancellationTokenSource _cts; @@ -43,7 +65,13 @@ public async Task ConnectAsync() await _ws.ConnectAsync(new Uri(_client.Config.WsUrl), _cts.Token); _ = ReceiveLoop(); - var payload = JsonUtility.ToJson(new WsConnectPayload { token = _client.AccessToken }); + var payload = RequestBinaryWire + ? JsonUtility.ToJson(new WsConnectWirePayload + { + token = _client.AccessToken, + wire = "binary", + }) + : JsonUtility.ToJson(new WsConnectPayload { token = _client.AccessToken }); await SendAsync("session.connect", payload); _reconnectAttempts = 0; @@ -475,6 +503,23 @@ async Task SendAsync(string type, string payloadJson) return await tcs.Task; } + // A binary frame is a world.tick and nothing else. Decoded here and raised + // as OnWorldTickFrame; a malformed one is dropped with OnError rather than + // throwing, since a throw on the receive loop tears down the socket. + void HandleBinaryTick(byte[] bytes, int length) + { + var frame = _wire.Decode(bytes, length); + if (frame == null) + { + // Losing one frame costs a gap that frame_seq detects and a resync + // repairs; guessing at it would corrupt the caller's entity table + // with no way to notice. + RaiseError("malformed binary world.tick frame"); + return; + } + RaiseWorldTickFrame(frame); + } + async Task SendFireAndForget(string type, string payloadJson, long? seq = null) { var msg = WsFrame.FireAndForget(type, payloadJson, seq); @@ -486,13 +531,20 @@ async Task ReceiveLoop() { var buffer = new byte[8192]; var sb = new StringBuilder(); + // Only world.tick ever arrives as binary; everything else is JSON text + // on both wires. Accumulated separately from `sb` because UTF8-decoding + // binary bytes into a string, which is what this loop used to do to + // every frame regardless, produces mojibake rather than an error. + var binary = new MemoryStream(); try { while (_ws.State == WebSocketState.Open && !_cts.IsCancellationRequested) { sb.Clear(); + binary.SetLength(0); WebSocketReceiveResult result; + var isBinary = false; do { @@ -503,10 +555,25 @@ async Task ReceiveLoop() ScheduleReconnect(); return; } - sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); + if (result.MessageType == WebSocketMessageType.Binary) + { + isBinary = true; + binary.Write(buffer, 0, result.Count); + } + else + { + sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); + } } while (!result.EndOfMessage); - HandleMessage(sb.ToString()); + if (isBinary) + { + HandleBinaryTick(binary.GetBuffer(), (int)binary.Length); + } + else + { + HandleMessage(sb.ToString()); + } } } catch (OperationCanceledException) { } diff --git a/Runtime/WebSocket/AsobiWire.cs b/Runtime/WebSocket/AsobiWire.cs new file mode 100644 index 0000000..dc2e77e --- /dev/null +++ b/Runtime/WebSocket/AsobiWire.cs @@ -0,0 +1,318 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Asobi +{ + /// + /// One entity delta from a decoded binary world.tick frame. + /// + public sealed class AsobiWireRecord + { + /// "a" added, "u" updated, "r" removed - the same short forms the + /// JSON wire uses. + public string Op; + + /// + /// The entity id, or null when the frame's slot has no binding yet. + /// + /// + /// A binding is established by an add, so null means the add that + /// would have established it was lost. The record is still reported rather + /// than dropped, because the frame genuinely says this slot changed; the + /// frame_seq gap that caused it is what drives the resync that + /// repairs the mapping. + /// + public string Id; + + /// + /// The slot's generation, advancing every time it is rebound to a + /// different entity. + /// + /// + /// Redundant on this ordered, reliable wire - the sequencing already + /// bounds the reuse hazard - and carried anyway so a client also running + /// the datagram plane can keep ONE slot table for both carriers. + /// + public byte Gen; + + /// The entity's changed fields. Values are float, int, bool, + /// string or null. + public readonly Dictionary Fields = new Dictionary(); + } + + /// + /// One decoded binary world.tick frame. + /// + public sealed class AsobiWireFrame + { + /// The zone these records belong to. Key your entities on + /// this - see . + public long ZoneX; + + /// The second element of the frame's zone. + public long ZoneY; + + /// + /// Contiguous per zone, advancing only on a frame actually sent, so a jump + /// by more than one means frames were lost. Null on a frame that holds no + /// position in the zone's stream, which is the removal list you get for a + /// zone you are leaving - apply that one ungated. + /// + public long? FrameSeq; + + /// True when this frame is a complete baseline for its zone + /// rather than a delta: replace, do not merge. Adopt it unconditionally, + /// including when moves backwards. + public bool Kf; + + /// The server's sim tick. + public long Tick; + + /// The entity deltas, in wire order. + public readonly List Records = new List(); + } + + /// + /// Decoder for asobi's binary world.tick frame. + /// + /// + /// + /// Same information as the JSON frame in roughly a fifth of the bytes, and it + /// arrives already typed rather than as text you still have to parse - which is + /// the real saving on this SDK, since OnWorldTick hands you a raw JSON + /// string and parses none of it. + /// + /// + /// Entity ids are 2-byte slots on the wire, and this decoder resolves them. + /// A record carries the full id on an add only, which is where the + /// binding is established; update and remove carry the slot alone. Bindings are + /// kept per zone, because slot 5 in one zone has nothing to do with slot 5 in + /// another, and an add REPLACES any binding already there, which is what makes + /// slot reuse safe. + /// + /// + /// Layout, every multi-byte value little-endian - not the usual choice for a + /// wire format, and deliberate: Godot's byte readers have no big-endian + /// counterpart, so the wire follows the runtime with the least room to spare. + /// + /// + /// frame Kind:8, ZX:32, ZY:32, FrameSeq:64, Kf:8, Tick:64, + /// DictLen:8, Dict, RecCount:16, Records + /// dict for each name: Len:8, Name/utf8 (at most 32 names) + /// record Op:8, Slot:16, Gen:8, [IdLen:8, Id/utf8]?, FieldCount:8, Fields + /// field Type:3, Idx:5, Value (one header byte) + /// + /// + public sealed class AsobiWire + { + const byte KindSequenced = 1; + const byte KindUngated = 2; + + static readonly string[] Ops = { "a", "u", "r" }; + + const int TF32 = 0; + const int TI32 = 1; + const int TTrue = 2; + const int TFalse = 3; + const int TStr = 4; + const int TNull = 5; + + // The header alone, before any dictionary or record. + const int MinFrame = 27; + + // Slot -> entity id, one table per zone. A single flat table would alias + // entities across zones - the same corruption that keying entities by zone + // exists to prevent. + readonly Dictionary> _slots = + new Dictionary>(); + + /// + /// Forgets every slot binding, for a reconnect. + /// + /// + /// Bindings are established by the adds THIS connection received, so + /// carrying them over would attach stale ids to slots the server has since + /// handed to different entities. The keyframe that follows a reconnect + /// rebuilds the whole table anyway. + /// + public void Reset() => _slots.Clear(); + + /// + /// Decodes one frame, or returns null if the bytes are malformed. + /// + /// + /// Null rather than an exception: these bytes come off the network and this + /// runs on the receive loop, where a throw would tear down the socket. + /// + public AsobiWireFrame Decode(byte[] bytes, int length) + { + try + { + return DecodeUnsafe(bytes, length); + } + catch (Exception) + { + return null; + } + } + + AsobiWireFrame DecodeUnsafe(byte[] b, int len) + { + if (b == null || len < MinFrame) return null; + + var kind = b[0]; + if (kind != KindSequenced && kind != KindUngated) return null; + + var frame = new AsobiWireFrame + { + ZoneX = ReadI32(b, 1), + ZoneY = ReadI32(b, 5), + Kf = b[17] != 0, + Tick = ReadI64(b, 18), + }; + var frameSeq = ReadI64(b, 9); + if (kind == KindSequenced) + { + frame.FrameSeq = frameSeq; + } + else + { + // An ungated frame holds no position in the zone's stream. Leaving + // FrameSeq null says so, where reporting 0 would have every client + // past its first frame discard the one message that clears its + // ghosts. + frame.Kf = false; + } + + var pos = 26; + int dictLen = b[pos++]; + var names = new string[dictLen]; + for (var i = 0; i < dictLen; i++) + { + if (pos >= len) return null; + int nameLen = b[pos++]; + if (pos + nameLen > len) return null; + names[i] = Encoding.UTF8.GetString(b, pos, nameLen); + pos += nameLen; + } + + if (pos + 2 > len) return null; + int recCount = ReadU16(b, pos); + pos += 2; + + var zoneKey = (frame.ZoneX << 32) ^ (frame.ZoneY & 0xFFFFFFFFL); + if (!_slots.TryGetValue(zoneKey, out var table)) + { + table = new Dictionary(); + _slots[zoneKey] = table; + } + + for (var r = 0; r < recCount; r++) + { + if (pos + 4 > len) return null; + int opByte = b[pos]; + if (opByte >= Ops.Length) return null; + var slot = ReadU16(b, pos + 1); + var gen = b[pos + 3]; + pos += 4; + + var record = new AsobiWireRecord { Op = Ops[opByte], Gen = gen }; + if (opByte == 0) + { + if (pos >= len) return null; + int idLen = b[pos++]; + if (pos + idLen > len) return null; + record.Id = Encoding.UTF8.GetString(b, pos, idLen); + pos += idLen; + // An add ESTABLISHES the binding and replaces whatever was + // there. Slots are reused once freed, so a stale binding + // surviving an add would attach the wrong entity to every + // later update on that slot. + table[slot] = record.Id; + } + else if (table.TryGetValue(slot, out var bound)) + { + record.Id = bound; + } + + if (pos >= len) return null; + int fieldCount = b[pos++]; + for (var f = 0; f < fieldCount; f++) + { + if (pos >= len) return null; + int header = b[pos++]; + var type = header >> 5; + var idx = header & 0x1F; + if (idx >= names.Length) return null; + var key = names[idx]; + switch (type) + { + case TF32: + if (pos + 4 > len) return null; + record.Fields[key] = BitConverter.ToSingle(LittleEndian(b, pos, 4), 0); + pos += 4; + break; + case TI32: + if (pos + 4 > len) return null; + record.Fields[key] = ReadI32(b, pos); + pos += 4; + break; + case TTrue: + record.Fields[key] = true; + break; + case TFalse: + record.Fields[key] = false; + break; + case TStr: + if (pos + 2 > len) return null; + var slen = ReadU16(b, pos); + pos += 2; + if (pos + slen > len) return null; + record.Fields[key] = Encoding.UTF8.GetString(b, pos, slen); + pos += slen; + break; + case TNull: + record.Fields[key] = null; + break; + default: + return null; + } + } + + // Released only AFTER the record is built, so the frame announcing + // an entity's departure still carries its id. + if (opByte == 2) table.Remove(slot); + + frame.Records.Add(record); + } + + // Trailing bytes mean the frame and this decoder disagree about the + // layout, and accepting it would hand the game a half-read frame. + return pos == len ? frame : null; + } + + // BitConverter follows the host, and Unity targets big-endian platforms + // (some consoles) as well as little. Copy-and-reverse is the portable + // answer for the one type with no manual shift path. + static byte[] LittleEndian(byte[] b, int offset, int count) + { + var slice = new byte[count]; + Array.Copy(b, offset, slice, 0, count); + if (!BitConverter.IsLittleEndian) Array.Reverse(slice); + return slice; + } + + static ushort ReadU16(byte[] b, int i) => (ushort)(b[i] | (b[i + 1] << 8)); + + static int ReadI32(byte[] b, int i) => + b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24); + + static long ReadI64(byte[] b, int i) + { + long v = 0; + for (var k = 7; k >= 0; k--) v = (v << 8) | b[i + k]; + return v; + } + } +} diff --git a/Tests/AsobiCore.NET/Asobi.Core.Tests.csproj b/Tests/AsobiCore.NET/Asobi.Core.Tests.csproj index 5200e87..cfb3471 100644 --- a/Tests/AsobiCore.NET/Asobi.Core.Tests.csproj +++ b/Tests/AsobiCore.NET/Asobi.Core.Tests.csproj @@ -26,6 +26,7 @@ + @@ -36,6 +37,7 @@ + diff --git a/Tests/AsobiCore.NET/AsobiWireTests.cs b/Tests/AsobiCore.NET/AsobiWireTests.cs new file mode 100644 index 0000000..9ca0e32 --- /dev/null +++ b/Tests/AsobiCore.NET/AsobiWireTests.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using NUnit.Framework; + +namespace Asobi.Tests +{ + // Unit tests for the binary world.tick decoder (asobi ADR 0013). + // + // Driven entirely by asobi's own committed fixture corpus under + // Fixtures/wire: real bytes from the real encoder, with a manifest saying + // what each one decodes to. Nothing here is hand-rolled test data, which is + // the point - a decoder checked only against a fixture the same author + // invented proves the two agree with each other and nothing about whether + // either matches the server. + public class AsobiWireTests + { + static string WireDir => Path.Combine(AppContext.BaseDirectory, "Fixtures", "wire"); + + static readonly Dictionary OpShort = new Dictionary + { + { "add", "a" }, { "update", "u" }, { "remove", "r" }, + }; + + static byte[] Bytes(string name) => File.ReadAllBytes(Path.Combine(WireDir, name + ".bin")); + + static JsonElement Manifest() => + JsonDocument.Parse(File.ReadAllText(Path.Combine(WireDir, "manifest.json"))).RootElement; + + static IEnumerable FixtureCases() + { + foreach (var entry in Manifest().EnumerateArray()) + { + var name = entry.GetProperty("name").GetString(); + yield return new TestCaseData(name).SetName($"Decodes_{name}"); + } + } + + [Test, TestCaseSource(nameof(FixtureCases))] + public void DecodesFixtureToWhatTheManifestDescribes(string name) + { + var entry = Manifest().EnumerateArray() + .First(e => e.GetProperty("name").GetString() == name); + var expected = entry.GetProperty("frame"); + var bytes = Bytes(name); + Assert.That(bytes.Length, Is.EqualTo(entry.GetProperty("bytes").GetInt32())); + + // A fresh decoder per fixture: the corpus cases are independent frames, + // not a stream, so sharing slot tables between them would be the test + // lying to itself. + var frame = new AsobiWire().Decode(bytes, bytes.Length); + Assert.That(frame, Is.Not.Null, "decoded to nothing"); + + var zone = expected.GetProperty("zone").EnumerateArray().Select(z => z.GetInt64()).ToArray(); + Assert.That(frame.ZoneX, Is.EqualTo(zone[0])); + Assert.That(frame.ZoneY, Is.EqualTo(zone[1])); + Assert.That(frame.Tick, Is.EqualTo(expected.GetProperty("tick").GetInt64())); + + // An ungated frame holds no position in the zone's stream and says so by + // having no frame_seq at all. Reporting 0 instead would have every client + // past its first frame discard the one message that clears its ghosts. + if (expected.GetProperty("kind").GetString() == "sequenced") + { + Assert.That(frame.FrameSeq, Is.EqualTo(expected.GetProperty("frame_seq").GetInt64())); + Assert.That(frame.Kf, Is.EqualTo(expected.GetProperty("kf").GetBoolean())); + } + else + { + Assert.That(frame.FrameSeq, Is.Null, "an ungated frame must carry no frame_seq"); + } + + var records = expected.GetProperty("records").EnumerateArray().ToList(); + Assert.That(frame.Records.Count, Is.EqualTo(records.Count)); + + for (var i = 0; i < records.Count; i++) + { + var want = records[i]; + var have = frame.Records[i]; + Assert.That(have.Op, Is.EqualTo(OpShort[want.GetProperty("op").GetString()])); + if (want.TryGetProperty("id", out var id)) + Assert.That(have.Id, Is.EqualTo(id.GetString())); + // The generation. A decoder that skipped the byte shifts every later + // offset and fails loudly; one that read it from the wrong place + // would not, so pin the value. + Assert.That(have.Gen, Is.EqualTo(want.GetProperty("gen").GetByte())); + + if (!want.TryGetProperty("fields", out var fields)) continue; + foreach (var field in fields.EnumerateObject()) + { + Assert.That(have.Fields.ContainsKey(field.Name), Is.True, field.Name); + var got = have.Fields[field.Name]; + switch (field.Value.ValueKind) + { + case JsonValueKind.Number: + // float32 on the wire against a float64 in the manifest, + // so compare with a tolerance: 12.5 survives exactly, + // 1.5 * 7 does not. + Assert.That(Convert.ToDouble(got), + Is.EqualTo(field.Value.GetDouble()).Within(0.0001), field.Name); + break; + case JsonValueKind.True: + Assert.That(got, Is.True, field.Name); + break; + case JsonValueKind.False: + Assert.That(got, Is.False, field.Name); + break; + case JsonValueKind.Null: + Assert.That(got, Is.Null, field.Name); + break; + default: + Assert.That(got, Is.EqualTo(field.Value.GetString()), field.Name); + break; + } + } + } + } + + // The reason the slot table lives in the decoder: an update carries the slot + // alone, and the caller must still see the entity id it saw on the add. + [Test] + public void SlotBindingsAreScopedPerZone() + { + var decoder = new AsobiWire(); + var kf = decoder.Decode(Bytes("keyframe_all_adds"), Bytes("keyframe_all_adds").Length); + var ids = kf.Records.Select(r => r.Id).ToHashSet(); + Assert.That(ids, Is.Not.Empty); + Assert.That(ids.Contains(null), Is.False, "a keyframe's adds must all carry ids"); + + // The keyframe is zone [-1, -1]. A frame for a DIFFERENT zone must not + // resolve against its table: slot 1 in one zone has nothing to do with + // slot 1 in another, and aliasing them is the corruption per-zone tables + // exist to prevent. + var other = decoder.Decode(Bytes("removes_only"), Bytes("removes_only").Length); + foreach (var record in other.Records) + Assert.That(ids.Contains(record.Id), Is.False, "a slot resolved across zones"); + } + + // Bindings belong to one connection's stream of adds. Kept across a reconnect + // they would attach stale ids to slots the server has since reassigned. + [Test] + public void ResetForgetsEveryBinding() + { + var decoder = new AsobiWire(); + decoder.Decode(Bytes("keyframe_all_adds"), Bytes("keyframe_all_adds").Length); + decoder.Reset(); + var after = decoder.Decode(Bytes("removes_only"), Bytes("removes_only").Length); + foreach (var record in after.Records) + Assert.That(record.Id, Is.Null); + } + + // These bytes come off the network and this runs on the receive loop, where a + // throw would tear down the socket. + [Test] + public void MalformedFramesReturnNullRatherThanThrowing() + { + var good = Bytes("steady_state_40_updates"); + var cases = new Dictionary + { + { "empty", Array.Empty() }, + { "one byte", new byte[] { 1 } }, + { "truncated envelope", good.Take(10).ToArray() }, + { "truncated mid-record", good.Take(good.Length - 2).ToArray() }, + { "trailing junk", good.Concat(new byte[] { 0, 0, 0 }).ToArray() }, + { "unknown kind byte", new byte[] { 9 }.Concat(good.Skip(1)).ToArray() }, + }; + foreach (var kv in cases) + Assert.That(new AsobiWire().Decode(kv.Value, kv.Value.Length), Is.Null, kv.Key); + } + + // The steady state, and the number the whole design turns on: it has to fit + // one datagram, and it has to be decisively smaller than the JSON it replaces. + [Test] + public void TheSteadyStateDeltaFitsADatagram() + { + Assert.That(Bytes("steady_state_40_updates").Length, Is.LessThanOrEqualTo(1200)); + } + } +} diff --git a/Tests/Runtime/Resources/Fixtures/wire/add_with_all_value_types.bin b/Tests/Runtime/Resources/Fixtures/wire/add_with_all_value_types.bin new file mode 100644 index 0000000..b68775a Binary files /dev/null and b/Tests/Runtime/Resources/Fixtures/wire/add_with_all_value_types.bin differ diff --git a/Tests/Runtime/Resources/Fixtures/wire/empty_keyframe.bin b/Tests/Runtime/Resources/Fixtures/wire/empty_keyframe.bin new file mode 100644 index 0000000..8ab2ec0 Binary files /dev/null and b/Tests/Runtime/Resources/Fixtures/wire/empty_keyframe.bin differ diff --git a/Tests/Runtime/Resources/Fixtures/wire/extremes.bin b/Tests/Runtime/Resources/Fixtures/wire/extremes.bin new file mode 100644 index 0000000..6d3ba73 Binary files /dev/null and b/Tests/Runtime/Resources/Fixtures/wire/extremes.bin differ diff --git a/Tests/Runtime/Resources/Fixtures/wire/keyframe_all_adds.bin b/Tests/Runtime/Resources/Fixtures/wire/keyframe_all_adds.bin new file mode 100644 index 0000000..a40366e Binary files /dev/null and b/Tests/Runtime/Resources/Fixtures/wire/keyframe_all_adds.bin differ diff --git a/Tests/Runtime/Resources/Fixtures/wire/manifest.json b/Tests/Runtime/Resources/Fixtures/wire/manifest.json new file mode 100644 index 0000000..192e697 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/wire/manifest.json @@ -0,0 +1,628 @@ +[ + { + "bytes": 135, + "frame": { + "frame_seq": 1, + "kf": false, + "kind": "sequenced", + "records": [ + { + "fields": { + "alive": true, + "hp": 100, + "name": "player one", + "stunned": false, + "target": null, + "x": 12.5, + "y": -3.25 + }, + "gen": 0, + "id": "01a0115f-547e-714f-829f-408c855ab77b", + "op": "add", + "slot": 7 + } + ], + "tick": 20, + "zone": [0,0] + }, + "name": "add_with_all_value_types" + }, + { + "bytes": 1039, + "frame": { + "frame_seq": 4711, + "kf": false, + "kind": "sequenced", + "records": [ + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 1.5, + "y": -0.25 + }, + "gen": 0, + "op": "update", + "slot": 1 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 3.0, + "y": -0.5 + }, + "gen": 0, + "op": "update", + "slot": 2 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 4.5, + "y": -0.75 + }, + "gen": 0, + "op": "update", + "slot": 3 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 6.0, + "y": -1.0 + }, + "gen": 0, + "op": "update", + "slot": 4 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 7.5, + "y": -1.25 + }, + "gen": 0, + "op": "update", + "slot": 5 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 9.0, + "y": -1.5 + }, + "gen": 0, + "op": "update", + "slot": 6 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 10.5, + "y": -1.75 + }, + "gen": 0, + "op": "update", + "slot": 7 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 12.0, + "y": -2.0 + }, + "gen": 0, + "op": "update", + "slot": 8 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 13.5, + "y": -2.25 + }, + "gen": 0, + "op": "update", + "slot": 9 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 15.0, + "y": -2.5 + }, + "gen": 0, + "op": "update", + "slot": 10 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 16.5, + "y": -2.75 + }, + "gen": 0, + "op": "update", + "slot": 11 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 18.0, + "y": -3.0 + }, + "gen": 0, + "op": "update", + "slot": 12 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 19.5, + "y": -3.25 + }, + "gen": 0, + "op": "update", + "slot": 13 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 21.0, + "y": -3.5 + }, + "gen": 0, + "op": "update", + "slot": 14 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 22.5, + "y": -3.75 + }, + "gen": 0, + "op": "update", + "slot": 15 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 24.0, + "y": -4.0 + }, + "gen": 0, + "op": "update", + "slot": 16 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 25.5, + "y": -4.25 + }, + "gen": 0, + "op": "update", + "slot": 17 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 27.0, + "y": -4.5 + }, + "gen": 0, + "op": "update", + "slot": 18 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 28.5, + "y": -4.75 + }, + "gen": 0, + "op": "update", + "slot": 19 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 30.0, + "y": -5.0 + }, + "gen": 0, + "op": "update", + "slot": 20 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 31.5, + "y": -5.25 + }, + "gen": 0, + "op": "update", + "slot": 21 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 33.0, + "y": -5.5 + }, + "gen": 0, + "op": "update", + "slot": 22 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 34.5, + "y": -5.75 + }, + "gen": 0, + "op": "update", + "slot": 23 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 36.0, + "y": -6.0 + }, + "gen": 0, + "op": "update", + "slot": 24 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 37.5, + "y": -6.25 + }, + "gen": 0, + "op": "update", + "slot": 25 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 39.0, + "y": -6.5 + }, + "gen": 0, + "op": "update", + "slot": 26 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 40.5, + "y": -6.75 + }, + "gen": 0, + "op": "update", + "slot": 27 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 42.0, + "y": -7.0 + }, + "gen": 0, + "op": "update", + "slot": 28 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 43.5, + "y": -7.25 + }, + "gen": 0, + "op": "update", + "slot": 29 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 45.0, + "y": -7.5 + }, + "gen": 0, + "op": "update", + "slot": 30 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 46.5, + "y": -7.75 + }, + "gen": 0, + "op": "update", + "slot": 31 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 48.0, + "y": -8.0 + }, + "gen": 0, + "op": "update", + "slot": 32 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 49.5, + "y": -8.25 + }, + "gen": 0, + "op": "update", + "slot": 33 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 51.0, + "y": -8.5 + }, + "gen": 0, + "op": "update", + "slot": 34 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 52.5, + "y": -8.75 + }, + "gen": 0, + "op": "update", + "slot": 35 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 54.0, + "y": -9.0 + }, + "gen": 0, + "op": "update", + "slot": 36 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 55.5, + "y": -9.25 + }, + "gen": 0, + "op": "update", + "slot": 37 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 57.0, + "y": -9.5 + }, + "gen": 0, + "op": "update", + "slot": 38 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 58.5, + "y": -9.75 + }, + "gen": 0, + "op": "update", + "slot": 39 + }, + { + "fields": { + "vx": 0.5, + "vy": -0.5, + "x": 60.0, + "y": -10.0 + }, + "gen": 0, + "op": "update", + "slot": 40 + } + ], + "tick": 94220, + "zone": [3,-2] + }, + "name": "steady_state_40_updates" + }, + { + "bytes": 298, + "frame": { + "frame_seq": 88, + "kf": true, + "kind": "sequenced", + "records": [ + { + "fields": { + "x": 1.0, + "y": 0.0 + }, + "gen": 1, + "id": "000000001-0000-7000-8000-000000000000", + "op": "add", + "slot": 1 + }, + { + "fields": { + "x": 2.0, + "y": 0.0 + }, + "gen": 2, + "id": "000000002-0000-7000-8000-000000000000", + "op": "add", + "slot": 2 + }, + { + "fields": { + "x": 3.0, + "y": 0.0 + }, + "gen": 3, + "id": "000000003-0000-7000-8000-000000000000", + "op": "add", + "slot": 3 + }, + { + "fields": { + "x": 4.0, + "y": 0.0 + }, + "gen": 4, + "id": "000000004-0000-7000-8000-000000000000", + "op": "add", + "slot": 4 + }, + { + "fields": { + "x": 5.0, + "y": 0.0 + }, + "gen": 5, + "id": "000000005-0000-7000-8000-000000000000", + "op": "add", + "slot": 5 + } + ], + "tick": 0, + "zone": [-1,-1] + }, + "name": "keyframe_all_adds" + }, + { + "bytes": 44, + "frame": { + "frame_seq": 12, + "kf": false, + "kind": "sequenced", + "records": [ + { + "gen": 7, + "op": "remove", + "slot": 1 + }, + { + "gen": 7, + "op": "remove", + "slot": 2 + }, + { + "gen": 7, + "op": "remove", + "slot": 65535 + } + ], + "tick": 240, + "zone": [0,0] + }, + "name": "removes_only" + }, + { + "bytes": 39, + "frame": { + "frame_seq": 0, + "kf": false, + "kind": "ungated", + "records": [ + { + "gen": 255, + "op": "remove", + "slot": 3 + }, + { + "gen": 255, + "op": "remove", + "slot": 9 + } + ], + "tick": 0, + "zone": [5,5] + }, + "name": "ungated_leave_removals" + }, + { + "bytes": 29, + "frame": { + "frame_seq": 0, + "kf": true, + "kind": "sequenced", + "records": [], + "tick": 0, + "zone": [0,0] + }, + "name": "empty_keyframe" + }, + { + "bytes": 41, + "frame": { + "frame_seq": 9007199254740991, + "kf": false, + "kind": "sequenced", + "records": [ + { + "fields": { "x": 0.0 }, + "gen": 255, + "op": "update", + "slot": 0 + } + ], + "tick": 9007199254740991, + "zone": [-2147483648,2147483647] + }, + "name": "extremes" + } +] diff --git a/Tests/Runtime/Resources/Fixtures/wire/removes_only.bin b/Tests/Runtime/Resources/Fixtures/wire/removes_only.bin new file mode 100644 index 0000000..646d1eb Binary files /dev/null and b/Tests/Runtime/Resources/Fixtures/wire/removes_only.bin differ diff --git a/Tests/Runtime/Resources/Fixtures/wire/steady_state_40_updates.bin b/Tests/Runtime/Resources/Fixtures/wire/steady_state_40_updates.bin new file mode 100644 index 0000000..f2741cf Binary files /dev/null and b/Tests/Runtime/Resources/Fixtures/wire/steady_state_40_updates.bin differ diff --git a/Tests/Runtime/Resources/Fixtures/wire/ungated_leave_removals.bin b/Tests/Runtime/Resources/Fixtures/wire/ungated_leave_removals.bin new file mode 100644 index 0000000..b86cd86 Binary files /dev/null and b/Tests/Runtime/Resources/Fixtures/wire/ungated_leave_removals.bin differ