From 7044347f2507eb0fd38f699c2d9fbaf41dff3a4a Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Tue, 18 Aug 2026 01:44:18 +0200 Subject: [PATCH 1/2] feat: decode the binary world.tick wire Set `Realtime.RequestBinaryWire = true` and world.tick arrives as a binary frame in roughly a fifth of the bytes, and already decoded - which is the real saving here, since OnWorldTick hands the game raw JSON text and parses none of it. Binary frames reach a new OnWorldTickFrame carrying a typed AsobiWireFrame, never OnWorldTick. Two events on purpose: re-serialising a decoded frame to fire OnWorldTick would hand back exactly the text-parsing cost the wire exists to remove. AsobiWire resolves the wire's 2-byte entity slots to entity ids, per zone, so record.Id is the same id the JSON wire gives. Null only when the add that would have established the binding was lost - a FrameSeq gap, which WorldResyncAsync repairs. Also fixes the receive loop: it UTF8-decoded every frame into a StringBuilder regardless of message type, so a binary frame became mojibake rather than an error. Binary and text now accumulate separately. session.connect keeps its exact frozen shape unless binary is requested - JsonUtility writes every field, so an optional `wire` would have put `"wire": """ on every connect frame. A second payload class avoids that. Tested against asobi's own committed fixture corpus rather than hand-rolled bytes. 143 tests pass in the .NET project. --- README.md | 38 ++ Runtime/Models/RealtimeModels.cs | 10 + Runtime/WebSocket/AsobiDispatcher.cs | 33 + Runtime/WebSocket/AsobiRealtime.cs | 73 ++- Runtime/WebSocket/AsobiWire.cs | 306 ++++++++++ Tests/AsobiCore.NET/Asobi.Core.Tests.csproj | 2 + Tests/AsobiCore.NET/AsobiWireTests.cs | 176 ++++++ .../wire/add_with_all_value_types.bin | Bin 0 -> 134 bytes .../Fixtures/wire/empty_keyframe.bin | Bin 0 -> 29 bytes .../Resources/Fixtures/wire/extremes.bin | Bin 0 -> 40 bytes .../Fixtures/wire/keyframe_all_adds.bin | Bin 0 -> 293 bytes .../Resources/Fixtures/wire/manifest.json | 576 ++++++++++++++++++ .../Resources/Fixtures/wire/removes_only.bin | Bin 0 -> 41 bytes .../Fixtures/wire/steady_state_40_updates.bin | Bin 0 -> 999 bytes .../Fixtures/wire/ungated_leave_removals.bin | Bin 0 -> 37 bytes 15 files changed, 1211 insertions(+), 3 deletions(-) create mode 100644 Runtime/WebSocket/AsobiWire.cs create mode 100644 Tests/AsobiCore.NET/AsobiWireTests.cs create mode 100644 Tests/Runtime/Resources/Fixtures/wire/add_with_all_value_types.bin create mode 100644 Tests/Runtime/Resources/Fixtures/wire/empty_keyframe.bin create mode 100644 Tests/Runtime/Resources/Fixtures/wire/extremes.bin create mode 100644 Tests/Runtime/Resources/Fixtures/wire/keyframe_all_adds.bin create mode 100644 Tests/Runtime/Resources/Fixtures/wire/manifest.json create mode 100644 Tests/Runtime/Resources/Fixtures/wire/removes_only.bin create mode 100644 Tests/Runtime/Resources/Fixtures/wire/steady_state_40_updates.bin create mode 100644 Tests/Runtime/Resources/Fixtures/wire/ungated_leave_removals.bin 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..b3a94d8 --- /dev/null +++ b/Runtime/WebSocket/AsobiWire.cs @@ -0,0 +1,306 @@ +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 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, [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 + 3 > len) return null; + int opByte = b[pos]; + if (opByte >= Ops.Length) return null; + var slot = ReadU16(b, pos + 1); + pos += 3; + + var record = new AsobiWireRecord { Op = Ops[opByte] }; + 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..fa063d7 --- /dev/null +++ b/Tests/AsobiCore.NET/AsobiWireTests.cs @@ -0,0 +1,176 @@ +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())); + + 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 0000000000000000000000000000000000000000..d0e5af33e1419a6d221e2acf17ba9f0c7889077b GIT binary patch literal 134 zcmZQ%fC5GsLj=lTXHCq>EK6m|C}7D;%uQu4E-B5+OHE-bNi0fFEn%!+tOROdXHYRP zOf)bwG)>brH8D@sH8(U#)3q?NOw%HrHBC%1H&0@BP)q?D+r-6Ckds)MTBMMl SmzunUm4U&-k&S^N-~a$Ym>fU= literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8ab2ec006562cee560d2539b2daf2ef9795f45e5 GIT binary patch literal 29 NcmZQ%Km&|Od;kDO00RI3 literal 0 HcmV?d00001 diff --git a/Tests/Runtime/Resources/Fixtures/wire/extremes.bin b/Tests/Runtime/Resources/Fixtures/wire/extremes.bin new file mode 100644 index 0000000000000000000000000000000000000000..8ba1e00634d85c5fdd655fe1f6db92189aab73cf GIT binary patch literal 40 icmZQ%U|?wY|NnnI5Cefc0|Sh~$XLP1z{mhXKmY)-g%GU( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..35af4031e4842cd1b25dba008965a10e419da774 GIT binary patch literal 293 zcmZSl4+Rkn5Wonf8JHL=7%N#B7#JB;4WPhK7sSvt2N4z^0>p&@CZNU!dx&->gdQV8 ldKeradYBPB literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a0376b8b75247cb53da5cbabf44916d583099bbc GIT binary patch literal 999 zcmZ9^FHAyV6b10_z*7-XpNNQxiujM@3?>UtgUR4bBxi7QGm#iJg26~M7>GoJ!Q{JN zGZ>6Sg26~MIFVyKx7@Se-ML6Q=YEI7;dOQtIGH;BcaUf_6OCImaa(bK3#e7z;)HWn z9Ti)zNQg%fAI30^ zrOjxY1jNIq`S;y&)^!qqhz7;-B;f zLr#1^?-&-uf9PF9UOYG+`IBK$JWYQw6vRWi8J5KJ^f$w@c#-~LSP?JNdxoNTh2A%; zito|C3~S;C^nqbr{E+@_D2YdO=dmGvLJvI3;-~bK$ENr>J>#(@UZaN|+u|4WoX3v% T6}{lGD}F;SdQ`;g^pgJrPsx8~ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..df240b13025b9c20a54b3425678952020a86269f GIT binary patch literal 37 ZcmZQ#Wnf?cVNAfpz{JeJz{JVG0006X03QGV literal 0 HcmV?d00001 From a20628d68876740e4d2c56abb10d551e77deeccf Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Tue, 18 Aug 2026 05:57:53 +0200 Subject: [PATCH 2/2] feat: carry the slot generation from the binary wire The wire now puts a generation byte on every record (asobi#505). It advances each time a slot is rebound to a different entity, and it is what lets a client also running the datagram plane keep ONE slot table for both carriers instead of two that can disagree. Redundant if you are only on the WebSocket - that stream is ordered and reliable and frame_seq already bounds the reuse hazard - so the decoder surfaces it and nothing here depends on it yet. --- Runtime/WebSocket/AsobiWire.cs | 20 ++++-- Tests/AsobiCore.NET/AsobiWireTests.cs | 4 ++ .../wire/add_with_all_value_types.bin | Bin 134 -> 135 bytes .../Resources/Fixtures/wire/extremes.bin | Bin 40 -> 41 bytes .../Fixtures/wire/keyframe_all_adds.bin | Bin 293 -> 298 bytes .../Resources/Fixtures/wire/manifest.json | 64 ++++++++++++++++-- .../Resources/Fixtures/wire/removes_only.bin | Bin 41 -> 44 bytes .../Fixtures/wire/steady_state_40_updates.bin | Bin 999 -> 1039 bytes .../Fixtures/wire/ungated_leave_removals.bin | Bin 37 -> 39 bytes 9 files changed, 78 insertions(+), 10 deletions(-) diff --git a/Runtime/WebSocket/AsobiWire.cs b/Runtime/WebSocket/AsobiWire.cs index b3a94d8..dc2e77e 100644 --- a/Runtime/WebSocket/AsobiWire.cs +++ b/Runtime/WebSocket/AsobiWire.cs @@ -25,6 +25,17 @@ public sealed class AsobiWireRecord /// 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(); @@ -89,7 +100,7 @@ public sealed class AsobiWireFrame /// 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, [IdLen:8, Id/utf8]?, FieldCount:8, Fields + /// record Op:8, Slot:16, Gen:8, [IdLen:8, Id/utf8]?, FieldCount:8, Fields /// field Type:3, Idx:5, Value (one header byte) /// /// @@ -199,13 +210,14 @@ AsobiWireFrame DecodeUnsafe(byte[] b, int len) for (var r = 0; r < recCount; r++) { - if (pos + 3 > len) return null; + if (pos + 4 > len) return null; int opByte = b[pos]; if (opByte >= Ops.Length) return null; var slot = ReadU16(b, pos + 1); - pos += 3; + var gen = b[pos + 3]; + pos += 4; - var record = new AsobiWireRecord { Op = Ops[opByte] }; + var record = new AsobiWireRecord { Op = Ops[opByte], Gen = gen }; if (opByte == 0) { if (pos >= len) return null; diff --git a/Tests/AsobiCore.NET/AsobiWireTests.cs b/Tests/AsobiCore.NET/AsobiWireTests.cs index fa063d7..9ca0e32 100644 --- a/Tests/AsobiCore.NET/AsobiWireTests.cs +++ b/Tests/AsobiCore.NET/AsobiWireTests.cs @@ -81,6 +81,10 @@ public void DecodesFixtureToWhatTheManifestDescribes(string name) 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()) 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 index d0e5af33e1419a6d221e2acf17ba9f0c7889077b..b68775a409c6606db451344e4885a02c50f12af1 100644 GIT binary patch delta 9 QcmZo;Y-gO{IMKlk01nFn_y7O^ delta 9 QcmZo?Y-60@IMK-!01nUs`2YX_ diff --git a/Tests/Runtime/Resources/Fixtures/wire/extremes.bin b/Tests/Runtime/Resources/Fixtures/wire/extremes.bin index 8ba1e00634d85c5fdd655fe1f6db92189aab73cf..6d3ba734331f36efd34b964a927e6b89eea2cdef 100644 GIT binary patch delta 12 QcmdPUoS?-1pOFCs01>zX3jhEB delta 11 PcmdPYn4rYQ$N&NW39bPP diff --git a/Tests/Runtime/Resources/Fixtures/wire/keyframe_all_adds.bin b/Tests/Runtime/Resources/Fixtures/wire/keyframe_all_adds.bin index 35af4031e4842cd1b25dba008965a10e419da774..a40366e5d118c14a4ff7e532246e32b3d36b9cd3 100644 GIT binary patch delta 31 ncmZ3=w2Enh3M1o06%$6Li4i7@%oBS|7+EGBFkxh!_{9(af;9-P delta 35 mcmZ3*w3KOr%0yL@iBTpK`%So14WPheqQ2t9_nZ@d8Ug^tk_&AB diff --git a/Tests/Runtime/Resources/Fixtures/wire/manifest.json b/Tests/Runtime/Resources/Fixtures/wire/manifest.json index 2ecd510..192e697 100644 --- a/Tests/Runtime/Resources/Fixtures/wire/manifest.json +++ b/Tests/Runtime/Resources/Fixtures/wire/manifest.json @@ -1,6 +1,6 @@ [ { - "bytes": 134, + "bytes": 135, "frame": { "frame_seq": 1, "kf": false, @@ -16,6 +16,7 @@ "x": 12.5, "y": -3.25 }, + "gen": 0, "id": "01a0115f-547e-714f-829f-408c855ab77b", "op": "add", "slot": 7 @@ -27,7 +28,7 @@ "name": "add_with_all_value_types" }, { - "bytes": 999, + "bytes": 1039, "frame": { "frame_seq": 4711, "kf": false, @@ -40,6 +41,7 @@ "x": 1.5, "y": -0.25 }, + "gen": 0, "op": "update", "slot": 1 }, @@ -50,6 +52,7 @@ "x": 3.0, "y": -0.5 }, + "gen": 0, "op": "update", "slot": 2 }, @@ -60,6 +63,7 @@ "x": 4.5, "y": -0.75 }, + "gen": 0, "op": "update", "slot": 3 }, @@ -70,6 +74,7 @@ "x": 6.0, "y": -1.0 }, + "gen": 0, "op": "update", "slot": 4 }, @@ -80,6 +85,7 @@ "x": 7.5, "y": -1.25 }, + "gen": 0, "op": "update", "slot": 5 }, @@ -90,6 +96,7 @@ "x": 9.0, "y": -1.5 }, + "gen": 0, "op": "update", "slot": 6 }, @@ -100,6 +107,7 @@ "x": 10.5, "y": -1.75 }, + "gen": 0, "op": "update", "slot": 7 }, @@ -110,6 +118,7 @@ "x": 12.0, "y": -2.0 }, + "gen": 0, "op": "update", "slot": 8 }, @@ -120,6 +129,7 @@ "x": 13.5, "y": -2.25 }, + "gen": 0, "op": "update", "slot": 9 }, @@ -130,6 +140,7 @@ "x": 15.0, "y": -2.5 }, + "gen": 0, "op": "update", "slot": 10 }, @@ -140,6 +151,7 @@ "x": 16.5, "y": -2.75 }, + "gen": 0, "op": "update", "slot": 11 }, @@ -150,6 +162,7 @@ "x": 18.0, "y": -3.0 }, + "gen": 0, "op": "update", "slot": 12 }, @@ -160,6 +173,7 @@ "x": 19.5, "y": -3.25 }, + "gen": 0, "op": "update", "slot": 13 }, @@ -170,6 +184,7 @@ "x": 21.0, "y": -3.5 }, + "gen": 0, "op": "update", "slot": 14 }, @@ -180,6 +195,7 @@ "x": 22.5, "y": -3.75 }, + "gen": 0, "op": "update", "slot": 15 }, @@ -190,6 +206,7 @@ "x": 24.0, "y": -4.0 }, + "gen": 0, "op": "update", "slot": 16 }, @@ -200,6 +217,7 @@ "x": 25.5, "y": -4.25 }, + "gen": 0, "op": "update", "slot": 17 }, @@ -210,6 +228,7 @@ "x": 27.0, "y": -4.5 }, + "gen": 0, "op": "update", "slot": 18 }, @@ -220,6 +239,7 @@ "x": 28.5, "y": -4.75 }, + "gen": 0, "op": "update", "slot": 19 }, @@ -230,6 +250,7 @@ "x": 30.0, "y": -5.0 }, + "gen": 0, "op": "update", "slot": 20 }, @@ -240,6 +261,7 @@ "x": 31.5, "y": -5.25 }, + "gen": 0, "op": "update", "slot": 21 }, @@ -250,6 +272,7 @@ "x": 33.0, "y": -5.5 }, + "gen": 0, "op": "update", "slot": 22 }, @@ -260,6 +283,7 @@ "x": 34.5, "y": -5.75 }, + "gen": 0, "op": "update", "slot": 23 }, @@ -270,6 +294,7 @@ "x": 36.0, "y": -6.0 }, + "gen": 0, "op": "update", "slot": 24 }, @@ -280,6 +305,7 @@ "x": 37.5, "y": -6.25 }, + "gen": 0, "op": "update", "slot": 25 }, @@ -290,6 +316,7 @@ "x": 39.0, "y": -6.5 }, + "gen": 0, "op": "update", "slot": 26 }, @@ -300,6 +327,7 @@ "x": 40.5, "y": -6.75 }, + "gen": 0, "op": "update", "slot": 27 }, @@ -310,6 +338,7 @@ "x": 42.0, "y": -7.0 }, + "gen": 0, "op": "update", "slot": 28 }, @@ -320,6 +349,7 @@ "x": 43.5, "y": -7.25 }, + "gen": 0, "op": "update", "slot": 29 }, @@ -330,6 +360,7 @@ "x": 45.0, "y": -7.5 }, + "gen": 0, "op": "update", "slot": 30 }, @@ -340,6 +371,7 @@ "x": 46.5, "y": -7.75 }, + "gen": 0, "op": "update", "slot": 31 }, @@ -350,6 +382,7 @@ "x": 48.0, "y": -8.0 }, + "gen": 0, "op": "update", "slot": 32 }, @@ -360,6 +393,7 @@ "x": 49.5, "y": -8.25 }, + "gen": 0, "op": "update", "slot": 33 }, @@ -370,6 +404,7 @@ "x": 51.0, "y": -8.5 }, + "gen": 0, "op": "update", "slot": 34 }, @@ -380,6 +415,7 @@ "x": 52.5, "y": -8.75 }, + "gen": 0, "op": "update", "slot": 35 }, @@ -390,6 +426,7 @@ "x": 54.0, "y": -9.0 }, + "gen": 0, "op": "update", "slot": 36 }, @@ -400,6 +437,7 @@ "x": 55.5, "y": -9.25 }, + "gen": 0, "op": "update", "slot": 37 }, @@ -410,6 +448,7 @@ "x": 57.0, "y": -9.5 }, + "gen": 0, "op": "update", "slot": 38 }, @@ -420,6 +459,7 @@ "x": 58.5, "y": -9.75 }, + "gen": 0, "op": "update", "slot": 39 }, @@ -430,6 +470,7 @@ "x": 60.0, "y": -10.0 }, + "gen": 0, "op": "update", "slot": 40 } @@ -440,7 +481,7 @@ "name": "steady_state_40_updates" }, { - "bytes": 293, + "bytes": 298, "frame": { "frame_seq": 88, "kf": true, @@ -451,6 +492,7 @@ "x": 1.0, "y": 0.0 }, + "gen": 1, "id": "000000001-0000-7000-8000-000000000000", "op": "add", "slot": 1 @@ -460,6 +502,7 @@ "x": 2.0, "y": 0.0 }, + "gen": 2, "id": "000000002-0000-7000-8000-000000000000", "op": "add", "slot": 2 @@ -469,6 +512,7 @@ "x": 3.0, "y": 0.0 }, + "gen": 3, "id": "000000003-0000-7000-8000-000000000000", "op": "add", "slot": 3 @@ -478,6 +522,7 @@ "x": 4.0, "y": 0.0 }, + "gen": 4, "id": "000000004-0000-7000-8000-000000000000", "op": "add", "slot": 4 @@ -487,6 +532,7 @@ "x": 5.0, "y": 0.0 }, + "gen": 5, "id": "000000005-0000-7000-8000-000000000000", "op": "add", "slot": 5 @@ -498,21 +544,24 @@ "name": "keyframe_all_adds" }, { - "bytes": 41, + "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 } @@ -523,17 +572,19 @@ "name": "removes_only" }, { - "bytes": 37, + "bytes": 39, "frame": { "frame_seq": 0, "kf": false, "kind": "ungated", "records": [ { + "gen": 255, "op": "remove", "slot": 3 }, { + "gen": 255, "op": "remove", "slot": 9 } @@ -556,7 +607,7 @@ "name": "empty_keyframe" }, { - "bytes": 40, + "bytes": 41, "frame": { "frame_seq": 9007199254740991, "kf": false, @@ -564,6 +615,7 @@ "records": [ { "fields": { "x": 0.0 }, + "gen": 255, "op": "update", "slot": 0 } diff --git a/Tests/Runtime/Resources/Fixtures/wire/removes_only.bin b/Tests/Runtime/Resources/Fixtures/wire/removes_only.bin index b4490e3f6842652df6223995b77cb85f8e4c5dc8..646d1ebbb53d3d50f770c77ebb99ca70e62e6c6a 100644 GIT binary patch delta 17 XcmdPYnV`VK&cMXP03`nZXJ-Ha7!m_3 delta 14 VcmdPVoS?wTz{JGB!1Vt=0{{{x0}}uM diff --git a/Tests/Runtime/Resources/Fixtures/wire/steady_state_40_updates.bin b/Tests/Runtime/Resources/Fixtures/wire/steady_state_40_updates.bin index a0376b8b75247cb53da5cbabf44916d583099bbc..f2741cf4033fef5befe9923ef5cc436f8668984d 100644 GIT binary patch literal 1039 zcmZ9}El@&16b0aS5ET*isfehkh<_x* zW-u6u1cQ-iaKt?}&W5{p&NuglU5o@l@H-d`uIGHild0kVPA2M&MEw>G+?E6QEXlY+ zj1xg%6+hX0g>Q!;inua{ufxzHL@|6i3`S_i7**&K+A+oy!XpnBV_cz3=*37WM1;UF zp-?4+hO|PBP&7;`+!6K-8HE-hGE6Bv63z_M3LV0wVMgJZP&0%IMrayl72XN=2LFx2 zuiGWG4D$+~ga<=bp-*TV78HI69YanbdFp>RS-I+PSn2`Pt7g>yp2VN0P( d2pzT+E(lqN9fd1G-eFhahEQ-QE7S-@_Xo_4e`Wvx literal 999 zcmZ9^FHAyV6b10_z*7-XpNNQxiujM@3?>UtgUR4bBxi7QGm#iJg26~M7>GoJ!Q{JN zGZ>6Sg26~MIFVyKx7@Se-ML6Q=YEI7;dOQtIGH;BcaUf_6OCImaa(bK3#e7z;)HWn z9Ti)zNQg%fAI30^ zrOjxY1jNIq`S;y&)^!qqhz7;-B;f zLr#1^?-&-uf9PF9UOYG+`IBK$JWYQw6vRWi8J5KJ^f$w@c#-~LSP?JNdxoNTh2A%; zito|C3~S;C^nqbr{E+@_D2YdO=dmGvLJvI3;-~bK$ENr>J>#(@UZaN|+u|4WoX3v% T6}{lGD}F;SdQ`;g^pgJrPsx8~ diff --git a/Tests/Runtime/Resources/Fixtures/wire/ungated_leave_removals.bin b/Tests/Runtime/Resources/Fixtures/wire/ungated_leave_removals.bin index df240b13025b9c20a54b3425678952020a86269f..b86cd86553c59650e62f38f40069a0e19e499874 100644 GIT binary patch delta 12 TcmY#YpP<10pMi;!;XeZa6OscB delta 10 RcmY#aouI(Vz{JVG000Pn0Sy2E