Repository navigation
v0.43.0
Added
-
The transport's read path is now tested outside the Editor (Cuvara/IndieRPGMMOAdventure#50):
Tests~/Headless/Cuvara.Netcode.Tests.Headless.csproj, anet10.0project that compiles
package sources directly and runs 30 tests in under a second, plus aHeadless tests (dotnet)
CI job that runs it on every PR.TcpTransportandWireConnectionawait throughUniTask, which needsUnityEngine, so
the whole read/write path was reachable only from inside the Editor or a built player. The
rest of the package is not like this — prediction, interpolation, world state and snapshot
resolution are plain C# — and the transport was the one part with nothing.What that cost is on the record. A client was measured decoding 13.7 snapshots/s from a
server sending 15.0/s, and establishing what it meant took a socket-level probe written in
the server repo, a from-scratch reimplementation of Unity'sSynchronizationContext
semantics in a standalone harness, three rebuild-and-run cycles against a live stack, and a
comparison of the Windows performance counter against the Linux monotonic clock. The answer
was the machine's clock. Every step of that was reasoning about a property a fifty-line test
measures directly: given a socket delivering N frames per second, how many reach the
consumer?It also meant a real ceiling in that path went unnoticed until it was hunted for other
reasons. Everyawaitthere goes throughTask.AsUniTask(), whose continuation is drained
once per player-loop frame, so an await costs a whole frame even when the bytes are already
in the socket buffer — and the read loop caps atplayerLoopHz / awaitsPerFrame. Two awaits
per frame is half the frame rate: 10.0/s at 20 fps, 5.0/s at 10 fps, with the socket
backlog growing without bound below the knee. Harmless on a desktop, squarely in the way on
Android. The buffered read that removes it shipped verified by nothing automated, for
exactly this reason. -
FrameBuffer(Runtime/Transport/FrameBuffer.cs): the receive-side framing state
machine — the growable buffer, the length-prefix parsing, the compaction and the growth rule
— split out ofTcpTransportas plain C# with noUniTaskand no socket.This is not a tidy-up. The property that governs throughput is not in the awaiting, it is in
how many awaits a frame costs, and that is decided entirely by whether a frame can be
produced from bytes already in hand. Splitting the decision out of the awaiting is what puts
it underdotnet test.TcpTransport.ReadFrameAsyncis nowTryTakeFrame/ReserveForRead
/Commitaround the same single socket read; behaviour is unchanged.ReserveForReadalso documents and tests a property the old inline code relied on silently:
it never hands out a zero-length region. A zero-length read comes back from the socket as a
clean EOF, so the failure mode there is a hang or a spurious disconnect rather than an error. -
TransportReadPumpTests: the measurement itself. It drives the realFrameBuffer
through a model of the player loop's drain semantics — at most one socket read per tick,
unlimited synchronous frame extraction per tick — against a virtual server writing at 15/s,
and asserts the shipping reader holds the server's full rate down to 5 fps, with a steady
socket backlog and frames in order.The fixture keeps the defect permanently, as a control.
ExactReadStrategyis the
two-exact-reads-per-frame shape, and it reproduces the live numbers with no socket and no
Unity: 15.00/s at 60 and 30 fps, 10.00 at 20, 5.00 at 10, 2.50 at 5, and a backlog that grows
without bound at 20 fps. Without it a green run here would be indistinguishable from a
measurement that cannot fail.Proven by mutation rather than by assertion: reintroducing the constraint in
FrameBuffer
itself (hand out only the bytes needed to finish the current header-or-body) turned 15 of the
30 tests red, reporting 12.95/s at 26 fps, 9.95 at 20 and 4.95 at 10 — against 13.05, 10.00
and 5.00 measured by hand against the live stack. Restoring it returned 30/30. -
FrameBufferTests: framing coverage that never existed — split frames, split headers, a
partial tail across a compaction, growth for a body larger than the buffer, and rejection of
zero, negative and over-cap length prefixes.
Changed
-
CI fails a headless run that executed zero tests.
dotnet testexits 0 when it matched
no tests at all — an empty filter, a project that compiled to nothing, an adapter that failed
to load — so the new job reads the.trxcounters instead of the exit code. This is the same
rule the Unity job already applies, for the same reason: that job ran green over zero tests
for this repository's entire history. -
Snapshot receive path allocates roughly half of what it did (Cuvara/IndieRPGMMOAdventure#61).
Decoding one snapshot built four copies of the entity list; two of them are now reused.ProtobufWireCodecgainedProtobufWireCodec.CreatePooled(), which pools the decoded
SnapshotMessageand its entity and event objects across calls, andWireConnectionnow
builds its inbound Protobuf decoder that way. The default constructor is unchanged and
still returns a fresh message per decode — the pool invalidates the previous decode,
which is only sound where the frame is consumed before the next one arrives, so it is
opted into rather than inherited.A static factory that sets a field, rather than a constructor overload, and not for style.
RegisterNetworkingregisters the type as
builder.Register<ProtobufWireCodec>(Lifetime.Singleton), and VContainer'sTypeAnalyzer
selects a constructor by reflection, takes the greediest one, and resolves its parameters
out of the container. Adding aProtobufWireCodec(bool)overload therefore broke
RegisterNetworkingat resolve time with "Failed to resolve ProtobufWireCodec : No such
registration of type: System.Boolean" — it compiled, and 691 of 692 EditMode tests still
passed.Making that constructor private does not help, which cost a second red run: VContainer
reflects withBindingFlags.NonPublicincluded, so the private overload was still
selected and the identical failure came back.ProtobufWireCodecmust therefore have
exactly one constructor of any accessibility, and it must be parameterless — hence a
settable private field rather than a constructor argument. A reflection test asserts this
outside Unity, withNonPublicin the mask, so the pure-C# suites catch it.WireConnectionbuilds its own inbound codec even when the outbound codec is already
Protobuf, instead of aliasing it as before. That is a correctness fix riding along: the
outbound codec is a DI singleton shared by the gateway and game-session connections,
so a decode buffer on it would be shared between two read loops.WorldState.Applyreuses itsEntitySnapshotData[]andstring[]conversion buffers,
but only when the new length matches the previous one exactly. The comment that used
to explain why the arrays were allocated fresh was right and still is:SnapshotData
carries an array and no count andSnapshotMergeriterates all of it, so a longer buffer
would replay its tail — entities resurrected at last tick's positions, after a despawn.
Clearing the tail is worse, not better: a zeroedEntitySnapshotDatahas a null id and
the merger would key its dictionary on it. So the win is conditional on the entity count
repeating, which a settled AOI does and a churning one does not.EncodeBodywraps the encoded payload withUnsafeByteOperations.UnsafeWrapinstead of
copying it withByteString.CopyFrom. The buffer is allocated one line above, never
published and never written again, so the aliasing that call normally warns about cannot
arise.Measured with
GC.GetAllocatedBytesForCurrentThread()around 2,000 decode→resolve→apply
iterations, 50 entities per delta, Release, same machine,origin/developand this branch
built into separate output directories:path before after full pipeline, steady entity count 18,208 B 10,512 B −42.3% full pipeline, varying entity count 10,025 B 7,353 B −26.7% DecodeBodyalone12,560 B 7,688 B −38.8% WorldState.Applyalone, steady count2,824 B 0 B −100% EncodeBody(InputMessage), per input frame400 B 360 B −10.0% Two allocations named in the issue were left alone deliberately. The resolver's
List<ResolvedEntity>is published toSnapshotReceivedsubscribers outside this
package, and recycling a list handed to an unknown consumer is not a change that can be
made from inside it. The per-framenew byte[length]on the receive path —
TcpTransport's until #168 moved it intoFrameBuffer.TryTakeFrame— is poolable in
principle, since neither codec retains it (the Protobuf parser copies into its own
ByteStrings and the JSON one goes throughUtf8.GetString). But
ITransport.ReadFrameAsyncreturns abyte[]and no length, so pooling it means changing
that signature and every implementation and caller. That is a wider change than this one,
and it is the smaller win of the two.SnapshotPipelineReuseTestsis not inTests~/Headless, although it is pure C#.
WorldState,ResolvedEntityandMsg.EntitySnapshotall nameShared.GameLogictypes,
and that assembly arrives as a UPM git dependency Unity resolves intoPackageCache, so
dotnet restorehas nothing to fetch — adding the file to that project fails with
CS0246: The type or namespace name 'Shared' could not be found(verified, not assumed).
Giving the headless gate access toShared.GameLogicis its own piece of work.A third claim in the issue was already false:
SnapshotResolver'spendinglist is
lazily allocated and has been since before the issue was filed. It is not allocated when
empty.SnapshotPipelineReuseTestscovers the reuse from the only side that can fail silently —
content, never allocation counts. Each test decodes or applies at least twice, with a
smaller and differently valued second frame, because a single-shot test passes against a
completely broken pool.