Skip to content

fix: send the world.input payload verbatim instead of wrapping it - #46

Merged
Taure merged 3 commits into
mainfrom
fix/world-input-payload
Aug 14, 2026
Merged

Taure merged 3 commits into
mainfrom
fix/world-input-payload

Conversation

@Taure

@Taure Taure commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Split out of #45, which is now documentation only. This is the behaviour half:
a wire-format fix, so it carries a fix: title and cuts its own release.

The bug

WorldInputAsync wrapped the caller's JSON in WsMatchInputPayload, so a
world.input frame went out as:

{"type":"world.input","seq":412,"payload":{"data":"{\"kind\":\"move\",\"x\":600}"}}

The server's world.input clause (src/ws/asobi_ws_handler.erl) takes the
payload verbatim as the input map:

InputData =
    case maps:get(~"data", Payload, undefined) of
        undefined when is_map(Payload) -> Payload;
        Other when is_map(Other) -> Other;
        _ -> #{}
    end,

An inner data is only honoured when it is itself an object. Unlike
match.input, which JSON-decodes a data string, world.input has no such
clause, so a data string falls to _ -> #{}. Every world input this SDK
sent reached handle_input/3 as an empty map.

It failed silently, in the worst way for anyone building prediction on it:
asobi_zone:apply_inputs/4 records the ack before it calls handle_input, so
world.ack kept advancing while nothing in the zone moved.

The fix

WorldInputAsync now sends the caller's JSON as the payload, and the decision
about what that payload is lives in WsFrame.WorldInputPayload:

  • null, empty or blank becomes {}, so the frame stays valid JSON
  • an object goes out byte for byte, never wrapped or re-encoded
  • anything else throws ArgumentException

That last one is new. The old guard was IsNullOrEmpty, which let " " and
"not json" splice onto the wire as a malformed frame, and "[1,2,3]" and
"null" reach the server as valid JSON it rejects with a badmap. The
JsonUtility.ToJson path this replaced was structurally immune to all four.
world.input is fire-and-forget with no cid, so the server has nothing to
correlate a rejection to and none of it could ever be reported back: the game
would just fail silently for its whole life. Failing loudly on the developer's
first frame is the only place the mistake is visible. Checked by first
non-whitespace character rather than parsed, because this is a per-input hot
path.

Coverage

The outbound envelope text lives in WsFrame, which has no UnityEngine
dependency, so the wire shape is asserted by the licence-free .NET tests rather
than only by the Unity smoke run. SendAsync's cid-carrying envelope was still
hand-built inline, which is most of the 24 send methods; it now goes through
WsFrame.Request and is pinned too.

125 tests pass (dotnet test Tests/AsobiCore.NET/Asobi.Core.Tests.csproj),
15 of them new:

  • cid and seq are siblings of payload, never fields inside it, and seq
    is omitted entirely when unstamped
  • an absent world.input is an empty map; an object is verbatim; a top-level
    data key is still sent verbatim rather than quietly rewritten
  • "not json", "[1,2,3]", " [1,2,3]", "null", "42" and "\"move\""
    are all rejected

Mutation-checked: reverting WorldInputPayload to the old wrapping body fails
13 of them, and dropping cid from WsFrame.Request fails 2. The earlier
revision of this PR was green under the same revert.

Known gap. Reverting WorldInputAsync itself, rather than the decision it
delegates to, still keeps the suite green: AsobiRealtime.cs depends on
UnityEngine.JsonUtility and cannot be linked into the .NET project at all, so
no headless test can see the one-line call. The decision is pinned; the
delegation to it is not. Closing that needs a seam over ClientWebSocket so a
test can read the frame that was written, which is a bigger change than this
PR. Recorded in Tests/AsobiCore.NET/README.md next to the reconnect-loop gap.

The reserved key

data is reserved at the top level of a world.input payload: the server
unwraps it when it is a map, dropping every sibling key, and discards the whole
input when it is not (asobi_ws_handler.erl:849-854). Tracked as
widgrensit/asobi#478.

WorldInputAsync's // comment block is now /// <summary> XML docs so that
contract reaches IntelliSense. This is the one method where a Unity consumer
most needs the rules at the call site: the payload is passed through untouched,
so every constraint on it is the caller's to respect.

Source-breaking

The parameter rename data -> inputJson breaks callers that pass it by name
(WorldInputAsync(data: json)). That is a compile error, not a silent change,
so it surfaces on the next build rather than at runtime.

Also here

The world.ack comments on WsWorldAckPayload, DispatcherTests and
DispatchTests said the two payload fields deserialise directly. They do, but
OnWorldAck hands over the raw envelope, so payload has to be pulled out
before JsonUtility sees it, or you silently get tick=0, seq=0. Corrected in
place. These are comment-only edits, kept here rather than in #45 so that #45
stays a pure **.md diff and does not trip dispatch.yml or release.yml.

Not merging

Opened for review only.

`WorldInputAsync` wrapped the caller's JSON in `WsMatchInputPayload`, putting
it on the wire as `{"data":"{...}"}`. The `world.input` clause in
`asobi_ws_handler` takes the payload verbatim as the input map and, unlike
`match.input`, does not JSON-decode an inner `data` string: a non-object
`data` degrades to an empty map. Every world input from this SDK therefore
reached the zone empty, and because the ack is recorded before the input is
handled, `world.ack` still advanced and the failure was silent.

Send the caller's JSON as the payload, and move the outbound envelope text
into `WsFrame`, which has no `UnityEngine` dependency, so the wire shape is
covered by the licence-free .NET tests: `seq` beside `payload`, omitted when
unstamped, payload passed through untouched.

The `world.ack` comments on `WsWorldAckPayload` and on the two dispatch tests
claimed the payload fields deserialise directly. `OnWorldAck` hands over the
raw envelope, so `payload` has to be pulled out first; corrected in place.
The fix in the previous commit had no automated coverage. The three tests
added with it exercise `WsFrame.FireAndForget`, which is the envelope
builder moved out verbatim; the changed code was in `WorldInputAsync`, and
`AsobiRealtime.cs` is not linked into the licence-free .NET project, so
reverting the fix left the suite green.

Move the payload decision itself into `WsFrame.WorldInputPayload`, where
the .NET suite can see it, and pin it: absent input becomes an empty map,
an object goes out verbatim, a top-level `data` key is not rewritten.
Reverting that decision to the old wrapping body now fails 13 tests.

Reject an input that is not a JSON object. The old guard was
`IsNullOrEmpty`, so `"   "` and `"not json"` spliced onto the wire as
invalid JSON while `"[1,2,3]"` and `"null"` reached the server as a badmap.
`world.input` carries no `cid`, so none of that can be reported back and
the game would fail silently for its whole life; throw `ArgumentException`
on the developer's first frame instead. Checked by first non-whitespace
character rather than parsed, since this is a per-input hot path.

Route the cid-carrying envelope through `WsFrame.Request` as well. It was
still hand-built inline in `SendAsync`, which is most of the 24 send
methods, and therefore invisible to the licence-free gate.

Document the reserved key. `data` is reserved at the top level of a
`world.input` payload: the server unwraps it when it is a map, dropping
every sibling, and discards the whole input when it is not
(widgrensit/asobi#478). `WorldInputAsync`'s comment block is now XML docs
so the contract reaches IntelliSense at the call site.
The guard checked that the first non-whitespace character was a brace,
which accepts "{},\"seq\":999" - that splices a top-level seq the client
never stamped into the frame, and world.ack then acknowledges a sequence
number that does not exist, corrupting the prediction loop this change
set out to fix. It also accepted unterminated objects, trailing text and
a non-breaking space, all of which the server's decoder rejects with no
cid to correlate the failure to.

Scan the whole string instead: one balanced object, string-aware so a
brace inside a value does not unbalance it, nothing but JSON whitespace
after it. char.IsWhiteSpace is replaced with JSON's own whitespace set,
since U+00A0 is not legal between tokens.
@Taure
Taure force-pushed the fix/world-input-payload branch from d0898eb to 5b3801f Compare August 14, 2026 05:31
@Taure
Taure merged commit 0bf6928 into main Aug 14, 2026
3 checks passed
@Taure
Taure deleted the fix/world-input-payload branch August 14, 2026 05:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant