From 52088afd542720ea37ce98346d7e97d9f824fe69 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Fri, 14 Aug 2026 07:55:22 +0200 Subject: [PATCH 1/2] docs: drop the world.ack running-maximum workaround asobi v0.84.1 (widgrensit/asobi#479) makes world.ack monotonic per connection: asobi_player_session holds a last_ack_seq and drops any ack that does not advance it. The session is per WebSocket and dies with it, so the mark resets exactly when the client's counter does. Remove the client-side high-water filter the docs prescribed against the old per-zone mark, the claim that seq can go backwards, the per-zone ack explanation, and the widgrensit/asobi#477 reference. Reconciliation is now: drop every buffered input at or below ack.seq, then replay the rest. State the floor as asobi >= v0.84.1 rather than v0.84.0, which was superseded the same day. Also narrow the reserved `data` key to its v0.84.1 rule: only a payload whose sole key is `data` holding an object is unwrapped (deprecated); siblings are no longer dropped, and a non-object payload draws an invalid_payload error frame instead of being read as empty input. --- README.md | 73 ++++++++++------------------ Runtime/WebSocket/AsobiDispatcher.cs | 2 +- Runtime/WebSocket/AsobiRealtime.cs | 12 +++-- Runtime/WebSocket/WsFrame.cs | 8 +-- Tests/AsobiCore.NET/WsFrameTests.cs | 10 ++-- 5 files changed, 44 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 49d61e7..87b7f26 100644 --- a/README.md +++ b/README.md @@ -172,9 +172,11 @@ authoritative state from `OnWorldTick`. `WorldInputAsync(string inputJson, long? seq = null)` sends a JSON object that is itself the input map: the field names are your game's, and the server hands -the map to the world script as it stands. One field name is reserved. If the -map has a top-level `data`, the server substitutes it, so an object `data` -becomes the input and a `data` that is anything else leaves the input empty. +the map to the world script as it stands. One field name is reserved: a payload +whose sole key is `data` holding an object is unwrapped to that object. +Deprecated, and removed at the next protocol break, so name your fields +anything else. A `data` alongside other keys, or a `data` holding anything but +an object, is forwarded verbatim. Stamp each input with `seq`, a monotonic counter your client owns, to opt into acknowledgement. Buffer the input under that `seq` and apply it locally at once: @@ -244,38 +246,19 @@ for you as of `tick`: {"type":"world.ack","payload":{"tick":42,"seq":412}} ``` -That mark is held per zone, not per connection, and every zone you are -subscribed to acks you independently. Inputs only ever reach the zone you are -standing in, so once you have crossed a boundary you get more than one -`world.ack` per broadcast: the zone you left keeps emitting the frozen mark it -recorded before you moved, so `payload.seq` can go backwards between -consecutive acks. Nothing in the frame says which zone sent it. Keep a running -maximum and ignore any ack that does not exceed it. Dropping everything at or -below `ack.seq` is only safe against a mark that never moves backwards; prune -straight from the received `seq` and you re-apply inputs the server has already -consumed. Tracked as -[widgrensit/asobi#477](https://github.com/widgrensit/asobi/issues/477), which -also covers the "per-connection" wording the server source and the protocol -guide still carry. - `OnWorldAck` hands you the raw envelope, so pull out `payload` before deserializing into `WsWorldAckPayload` (`long tick`, `long seq`); passing the -envelope straight to `JsonUtility` yields zeros, not an error. Advance the -running maximum, drop every buffered input at or below it, rewind to the -accumulated state, replay the rest: +envelope straight to `JsonUtility` yields zeros, not an error. Drop every +buffered input at or below `ack.seq`, rewind to the accumulated state, replay +the rest: ```csharp -long _acked = -1; - client.Realtime.OnWorldAck += raw => { var ack = JsonUtility.FromJson( JsonHelper.ExtractField(raw, "payload")); - if (ack.seq <= _acked) return; - _acked = ack.seq; - - _pending.RemoveAll(p => p.seq <= _acked); + _pending.RemoveAll(p => p.seq <= ack.seq); ResetTo(_state); foreach (var p in _pending) Predict(p.input); @@ -287,10 +270,10 @@ are your game's. - The ack is a high-water mark, not a receipt per input. A rejected input still advances it, so an input the world script declines never strands the client. -- Prune and replay in the ack handler, never in the tick handler. A zone with - deltas to report sends its `world.tick` first and its `world.ack` second, but - a broadcast with nothing to report skips the tick entirely, so an ack can - arrive with no `world.tick` in front of it. +- Prune and replay in the ack handler, never in the tick handler. A broadcast + with deltas to report sends `world.tick` first and `world.ack` second, but one + with nothing to report skips the tick entirely, so an ack can arrive with no + `world.tick` in front of it. - Acknowledgement is opt-in. The server records a `seq` only for players who stamp one, so a client that never stamps one gets no `world.ack` at all, and no error either. @@ -299,28 +282,26 @@ are your game's. - `seq` must be a non-negative integer below 2^53, narrower than C#'s `long`: count up from zero, never seed from a nanosecond timestamp. An out-of-range `seq` is ignored, but the input is not. It is queued and applied to the world - as normal, and only the acknowledgement is skipped; if you already have a - valid `seq` on record the acks keep arriving every broadcast tick carrying - that older high-water mark rather than falling silent. + as normal, and only the acknowledgement is skipped. - `broadcast_interval` is a world-level value copied into each zone's config, and one ticker per world fans a single shared tick number out to every zone, so zones are not on independent schedules. Every `broadcast_interval` - simulation ticks, `3` by default, each subscribed zone emits its own pair, so - a full 3x3 ring is up to nine `world.tick` frames and nine `world.ack` frames - rather than one of each, and they land together on the same broadcast tick - rather than interleaved across cadences. Subscription snapshots are sent - immediately and ignore the interval. Set it to `1` in the world mode config - for an ack every tick. See the - [world server guide](https://asobi.dev/docs/world-server). -- Needs a server carrying `world.ack`, which is asobi core v0.84.0 or newer. An + simulation ticks, `3` by default, each subscribed zone emits its own + `world.tick`, so a full 3x3 ring is up to nine tick frames rather than one, + and they land together on the same broadcast tick rather than interleaved + across cadences. Subscription snapshots are sent immediately and ignore the + interval. Set it to `1` in the world mode config for an ack every tick. See + the [world server guide](https://asobi.dev/docs/world-server). +- Needs a server carrying `world.ack`, which is asobi core v0.84.1 or newer. An older one sends nothing, and the silence is the only symptom. - `OnWorldAck` and the `seq` parameter arrived in asobi-unity v0.18.0, but on that release the loop above is dead. `WorldInputAsync` still wrapped the - payload as `{"data":"..."}`, which the zone reads as an empty input map, so - acks arrive and `seq` advances while nothing you send moves anything. The loop - needs a release in which `WorldInputAsync` sends the payload verbatim, and - v0.18.0 is not one. Confirm it on the wire: the input frame's `payload` should - be your input map, not an object holding a single `data` string. + payload as `{"data":"..."}`, which the zone receives still wrapped rather than + as your input map, so acks arrive and `seq` advances while nothing you send + moves anything. The loop needs a release in which `WorldInputAsync` sends the + payload verbatim, and v0.18.0 is not one. Confirm it on the wire: the input + frame's `payload` should be your input map, not an object holding a single + `data` string. - `OnWorldAck` fires on a background thread like every other realtime event, so reconciliation that touches `UnityEngine.Object` must marshal first. diff --git a/Runtime/WebSocket/AsobiDispatcher.cs b/Runtime/WebSocket/AsobiDispatcher.cs index 68882f0..b864e02 100644 --- a/Runtime/WebSocket/AsobiDispatcher.cs +++ b/Runtime/WebSocket/AsobiDispatcher.cs @@ -112,7 +112,7 @@ internal void HandleMessage(string raw) // Explicit case before the generic "world." catch-all in // default: reclaims world.ack, which otherwise falls through // to OnWorldEvent as name "ack". Harmless reroute - the frame - // is new in core v0.84.0. + // is new in core v0.84.1. case "world.ack": OnWorldAck?.Invoke(raw); break; diff --git a/Runtime/WebSocket/AsobiRealtime.cs b/Runtime/WebSocket/AsobiRealtime.cs index 935b15c..630478c 100644 --- a/Runtime/WebSocket/AsobiRealtime.cs +++ b/Runtime/WebSocket/AsobiRealtime.cs @@ -266,7 +266,7 @@ public Task WorldLeaveAsync() /// /// The input map itself, as a JSON object. world.input takes the /// payload verbatim and, unlike match.input, does not JSON-decode - /// an inner string, so a wrapped input reaches the zone as an empty map. + /// an inner string, so a wrapped input reaches the zone still wrapped. /// Null or blank sends an empty map. /// /// @@ -281,10 +281,12 @@ public Task WorldLeaveAsync() /// could tell you about it. /// /// - /// data is reserved at the top level of the input map: the - /// server unwraps it when it is a map, dropping every sibling key, and - /// discards the whole input when it is not. Name your fields anything - /// else (widgrensit/asobi#478). + /// data is reserved at the top level of the input map: a payload + /// whose sole key is data holding an object is unwrapped to that + /// object. Deprecated, and removed at the next protocol break, so name + /// your fields anything else (widgrensit/asobi#478). A data + /// alongside other keys, or one holding anything but an object, is + /// forwarded verbatim. /// public Task WorldInputAsync(string inputJson, long? seq = null) { diff --git a/Runtime/WebSocket/WsFrame.cs b/Runtime/WebSocket/WsFrame.cs index 6c91f7c..fb9d3bb 100644 --- a/Runtime/WebSocket/WsFrame.cs +++ b/Runtime/WebSocket/WsFrame.cs @@ -37,10 +37,10 @@ internal static string Request(string type, string payloadJson, string cid) /// /// /// The input is not a JSON object. Non-JSON text splices onto the wire - /// as a malformed frame, and an array or a bare value reaches the - /// server as a badmap; either way world.input carries no - /// cid, so there is nothing to correlate a rejection to and the - /// failure would be silent. Checked by first character rather than + /// as a malformed frame, and an array or a bare value draws an + /// error frame with reason invalid_payload; either way + /// world.input carries no cid, so there is nothing to + /// correlate the rejection to. Checked by first character rather than /// parsed: this runs once per input frame. /// internal static string WorldInputPayload(string inputJson) diff --git a/Tests/AsobiCore.NET/WsFrameTests.cs b/Tests/AsobiCore.NET/WsFrameTests.cs index e5af19c..9b79c9e 100644 --- a/Tests/AsobiCore.NET/WsFrameTests.cs +++ b/Tests/AsobiCore.NET/WsFrameTests.cs @@ -77,9 +77,9 @@ public void AnObjectInputIsPassedThroughVerbatim() } // `data` is reserved at the top level (widgrensit/asobi#478): the - // server unwraps it when it is a map and drops the siblings. The SDK - // still sends it verbatim - quietly renaming or re-nesting it here - // would hide the server's rule instead of letting the game hit it. + // server unwraps a payload whose sole key is `data` holding an object. + // The SDK sends it verbatim either way - quietly renaming or re-nesting + // it here would hide the server's rule instead of letting the game hit it. [Test] public void ATopLevelDataKeyIsStillSentVerbatim() { @@ -96,8 +96,8 @@ public void LeadingWhitespaceBeforeAnObjectIsAccepted() } // Anything that is not an object either splices onto the wire as a - // malformed frame or reaches the server as a badmap. world.input has - // no cid, so neither can be reported back: throw on the developer's + // malformed frame or draws an invalid_payload error frame. world.input + // has no cid, so neither can be correlated back: throw on the developer's // first frame instead of failing silently for the life of the game. [TestCase("not json")] [TestCase("[1,2,3]")] From 1f84d239703a5b62adde31dcb59e6702654c866c Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Fri, 14 Aug 2026 08:10:39 +0200 Subject: [PATCH 2/2] docs: correct the world.ack version floor and invalid-payload story Keep the bare v0.84.1 floor for ack monotonicity and drop the consequence clause: v0.84.0 servers do send acks, so "an older one sends nothing" was false. Revert the dispatcher comment to v0.84.0. It records when world.ack first appeared on the wire, which is provenance, not the monotonicity floor. Align WorldInputAsync with WsFrame: a non-object world.input payload draws an error frame with reason invalid_payload, uncorrelated because the frame carries no cid. --- README.md | 3 +-- Runtime/WebSocket/AsobiDispatcher.cs | 2 +- Runtime/WebSocket/AsobiRealtime.cs | 8 ++++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 87b7f26..3d983b6 100644 --- a/README.md +++ b/README.md @@ -292,8 +292,7 @@ are your game's. across cadences. Subscription snapshots are sent immediately and ignore the interval. Set it to `1` in the world mode config for an ack every tick. See the [world server guide](https://asobi.dev/docs/world-server). -- Needs a server carrying `world.ack`, which is asobi core v0.84.1 or newer. An - older one sends nothing, and the silence is the only symptom. +- Requires asobi core v0.84.1 or later. - `OnWorldAck` and the `seq` parameter arrived in asobi-unity v0.18.0, but on that release the loop above is dead. `WorldInputAsync` still wrapped the payload as `{"data":"..."}`, which the zone receives still wrapped rather than diff --git a/Runtime/WebSocket/AsobiDispatcher.cs b/Runtime/WebSocket/AsobiDispatcher.cs index b864e02..68882f0 100644 --- a/Runtime/WebSocket/AsobiDispatcher.cs +++ b/Runtime/WebSocket/AsobiDispatcher.cs @@ -112,7 +112,7 @@ internal void HandleMessage(string raw) // Explicit case before the generic "world." catch-all in // default: reclaims world.ack, which otherwise falls through // to OnWorldEvent as name "ack". Harmless reroute - the frame - // is new in core v0.84.1. + // is new in core v0.84.0. case "world.ack": OnWorldAck?.Invoke(raw); break; diff --git a/Runtime/WebSocket/AsobiRealtime.cs b/Runtime/WebSocket/AsobiRealtime.cs index 630478c..ceb1286 100644 --- a/Runtime/WebSocket/AsobiRealtime.cs +++ b/Runtime/WebSocket/AsobiRealtime.cs @@ -260,8 +260,8 @@ public Task WorldLeaveAsync() /// /// Send one input to the world you are in. Fire-and-forget: the frame - /// carries no cid, so the server has no way to report a bad - /// input back to you. + /// carries no cid, so a bad input draws an error frame + /// with reason invalid_payload that nothing ties back to it. /// /// /// The input map itself, as a JSON object. world.input takes the @@ -277,8 +277,8 @@ public Task WorldLeaveAsync() /// /// /// is not a JSON object. Thrown on the - /// first offending frame rather than sent, because nothing downstream - /// could tell you about it. + /// first offending frame rather than sent, because the server answers + /// one with an uncorrelated error frame and nothing else. /// /// /// data is reserved at the top level of the input map: a payload