Repository navigation
Releases: Cuvara/Netcode
Release list
v0.47.0
Realtime gameplay is KCP/UDP only (feat/wire/kcp-only, ADR-32 in
rpg-mmo-server backend/docs/NETWORKING.md). The game-server hop builds only KcpTransport; the gateway hop
stays TCP (optionally TLS). Behaviour change, minor bump: a gateway that still answers
enter_world_resp.transport empty or "tcp" is refused by name instead of dialled over TCP.
Pairs with the server leg (game server always listens KCP, registry and gateway always say
"kcp", optional TRANSPORT_KEY).
Netcode side of the prediction-offset fix (fix/wire/prediction-offset). Pairs with the server's
additive SnapshotMessage.ack_applied_tick (wire field 7, protocol 3 peers only, no protocol
version bump) and with com.cuvara.dots' LocalPredictionSystem, which now drives the APIs below.
Still needs sgl-v0.7.0. Measured on the new headless harness; numbers in
Documentation~/PREDICTION.md.
Added
-
NetworkSettings.TransportKey(64 hex chars, the server'sTRANSPORT_KEY; empty =
plaintext datagrams) for the KCP gameplay hop.RegisterNetworking()now hands it to
DefaultTransportFactory(it passednull), as doNetworkBootstrap(new
NetworkBootstrapConfig.TransportKeyfield) and the DOTS Sample (-cuvara-transport-key/
CUVARA_TRANSPORT_KEY). The E2E / WorldView / ReconnectPolicyDemo samples and the PlayMode
live tests readCUVARA_TRANSPORT_KEY. -
TransportKinds.ParseGameplay/TryParseGameplay/RequireGameplayand
TransportKinds.UnsupportedGameplayTransport("unsupported_gameplay_transport"), a permanent
failure for the join loop and the reconnect policy. -
DefaultTransportFactory.CreateGateway(useTls)/CreateGameplay(),HasTransportKey. -
KcpClientSession(internal, Unity-free): the KCP session stateKcpTransportdrives —
ARQ, crypto, reassembly, idle timeout, dead link — so it runs underdotnet test.
KcpTransportexposesConversation,IsEncrypted,DatagramsReceived,FailureReason. -
Tests.
KcpGameplayTransportTests(strict parsing, random non-zero conversation ids,
IPv4 preference, idle timeout, bounded receive buffer, invalid length, matching/wrong key, a
real loopback KCP/UDP echo with and without a key, a dead UDP port) and
GameplayTransportPolicyTests(Editor: the factory never builds TCP for gameplay, a TCP/TLS
assignment is refused before any factory is asked, the refusal is permanent, the join-timeout
message); twoNetworkingRegistrationTestsfor the key. The headless project now also
compilesKcp,KcpCrypto,KcpClientSession,TransportKind(s),NetworkEndpointand runs
KcpCoreTests,KcpCryptoTests,NetworkEndpointTests,KcpGameplayTransportTests
(352 tests). -
ack_applied_tickend to end.Runtime/Protocol/Generated/Wire.csis a byte copy of the
server's regenerated bindings (protoc 29.3).SnapshotMessage.AckAppliedTick, decoded by both
codecs (ack_applied_tickin JSON; absent = 0);ResolvedSnapshot.AckAppliedTickwith a new
seven-argument constructor (the existing ones pass 0);WorldState.AckAppliedTick, kept paired
withAckTickunder the merger's monotonic ack rule and cleared byReset. -
LocalMovePredictor.Reconcile(Vec2, long ackTick, long serverBaseTick, long ackAppliedTick)
andReconcile(Vec3, float, long, long, long): compare the snapshot at server tickTwith
the history atT + offset, where the offset (client base tick of the acked input minus the
server tick that applied it) is the median of the last 15 measured pairs, moved only when the
window has clearly moved. ZeroackAppliedTickbehaves exactly as the three-/four-argument
overloads. Diagnostics:AckTickOffset,LastMeasuredAckTickOffset,AckOffsetSamples,
AckOffsetChanges,AckOffsetUnresolved,SteerIntegralTicks. -
PredictionClockSteering: the clock half ofWorldViewBinder(tick-rate, staleness and
acknowledgement-floor estimators,RoundTripMs, rate feed-forward, measuredTargetLeadTicks,
phase steering) as its own type, moved unchanged, so a consumer that drives a predictor without
the binder steers with the same code:NoteInputSent,SampleTickRate,OnSnapshot,
TargetLeadTicks,Reset.WorldViewBinder.ClockSteeringexposes the binder's instance. -
Headless prediction tests.
Tests~/Headlessnow compiles the predictor, the binder, world
state and the estimators against Shared.GameLogic (-p:SglDir, default the sibling
rpg-mmo-server checkout; CI checks out thesgl-v*tagpackage.jsonpins) and runs the
prediction, steering and binder suites plus the newPredictionHarness/
AckAppliedTickReconcileTests(285 tests).
Fixed
-
KcpTransport.ConnectAsyncdoes not resolve an IP literal through DNS. The gateway
advertises IP addresses; the lookup only added an async hop that resumes on the player loop. -
A cancelled
KcpTransport.ReadFrameAsyncthrows instead of reporting end-of-stream. It returned
null, so the join's own deadline against an unreachable UDP port surfaced as "game server closed
the connection during the join" instead of the KCP/UDP connect-timeout message. Seen live with a
Windows client against a WSL2-NAT stack advertising 127.0.0.1 (Windows localhost forwarding is
TCP-only). Same contract asTcpTransport. -
KCP conversation id was a process-wide counter starting at 1. Now cryptographically
random and non-zero per session. -
KcpTransport's 60 s idle timeout was declared and never enforced. No inbound datagram
for 60 s now fails the session withKCP idle timeout: no UDP datagram from <host:port> ...,
surfaced as aTransportErrorclose with the reason. A KCP dead link fails the same way
instead of closing as if the peer had hung up. -
The KCP stream buffer grew without bound (it drained the whole ARQ receive queue on every
read). It now drains only while no complete frame is buffered and is hard-bounded at one
maximum frame plus one MTU; exceeding it fails the connection visibly and never drops bytes. -
DNS took
addresses[0]while the game server binds IPv4 Any, so a host resolving to::1
first dialled a port nobody listens on. The first IPv4 address is preferred. -
A dead or firewalled UDP port surfaced as a bare cancellation. A join that times out with
no datagram received now throwsNetworkException("KCP/UDP connect timeout to host:port after N s ... check the firewall and the UDP port mapping ... and the transport key"). -
Removed the misleading "send a tiny payload" comment:
ConnectAsyncsends nothing; the
join_tokenframe is the first datagram. -
Reconcile compared against the wrong history entry. It used the snapshot's own tick, but the
server applies an input on the tick that drains it, so the two tick lines are offset by about the
lead minus one plus clock skew, and every tick of it came back as a correction (rubber-banding on
curves, a whole step at starts and stops). Withack_applied_tick: 59.6 Hz server, 13 Hz jittered
sends, lead 6 - mean correction 0.0208 -> 0.0011 on a circle, largest at start/stop 4 steps -> 1;
a 60 Hz jitter-free run corrects nothing (was 38). -
An input tick's history entry was stale.
RecordInputchanged where the tick ended but the
entryAdvancewrote at the start of the tick was kept, giving +1/-1 step pairs at every start
and stop. It is now re-recorded after the input. -
A new tick dropped the unshown part of the step on screen.
Advancereplaced the current
step without carrying its remainder into the render offset (asRecordInputalready did), so the
rendered position jumped in one frame: 2.32x the normal per-frame motion on a curve and 16.67x at
a start or stop at 1 ms frames, now 1.31x / 1.34x. -
The clock steering drooped against a rate difference.
SteerToServerTickwas proportional
only, so a 0.7% slower server left it about a tick off for the session. It now has a clamped,
anti-windup integral term on the integer tick error; every tested ratio (1.02-1.103) settles at
zero with no fed-forward rate, and an in-step clock is left untouched.
Changed
-
Breaking: gameplay is KCP/UDP only.
TransportKinds.Parseis replaced byParseGameplay:
"kcp"(case-insensitive) is accepted, and empty /"tcp"/ anything else is a failure that
names the value — empty no longer means TCP.GatewayClientturns it into a
NetworkExceptionwithServerError = unsupported_gameplay_transport(not retried, ends a
reconnect).GameSessionClient.JoinAsyncrefuses anyMapAssignment.TransportbutKcp
before asking a factory, and asks forTransportKind.Kcponly.TransportKind.Tcp/
TcpTlsare documented as gateway-hop only. -
WebGL:
new KcpTransport(...)throwsNotSupportedException("KCP/UDP gameplay transport is not available on WebGL ...")in a WebGL player. No fallback. -
Docs:
NETCODE.md(hops table, "KCP transport (the only gameplay transport)", WebGL row,
headless coverage),WIRE-PROTOCOL.md,README.md. -
PredictionClockRateTests.WithoutTheRateTheSteeringDroopsByExactlyTheTextbookAmountis now
WithoutTheRateTheIntegralTermRemovesTheDroopToo: it pinned the proportional loop's droop,
which the integral term removes. -
Samples~/DOTSSample:RenderMotionProbereports step per second of frame (median and
worst/median), the frame time of the worst step and the longest frame, so a render jump and a
long frame can be told apart; the[DOTSNet/health]line addsackOffsetandsteerI.
v0.46.0
Wire protocol version 3 — "Core v3" (ADR-28..31). Needs
com.rpgmmo.shared-gamelogic sgl-v0.7.0 (does not compile against sgl-v0.6.0).
Added
LocalMovePredictor.Reconcile(Vec3, float verticalVelocity, long ackTick): the 3D form of the
two-argument reconcile, for callers that cannot supply the snapshot's base tick (the
com.cuvara.dotsprediction system). Without it the DOTS path could only reconcile the ground
plane, and a 3D motor's height was never corrected.- Wire protocol 3 bindings —
Runtime/Protocol/Generated/Wire.csis a byte copy of the
server's regeneratedGameServer/Net/Generated/RpgMmo/Wire/V1/Wire.cs(protoc 29.3).
MsgType.Command(32),CommandResult(33),ServerPush(34);GameEventType.StatusApplied
(7),StatusRemoved(8),ProjectileHit(9). - Snapshot v3 state —
EntitySnapshot.Z,VelX/VelY/VelZ,Owner(handle) /OwnerId
(its JSON twin),SpawnSeq,Stats/StatsRemoved,Statuses/StatusesRemoved, with new
StatValue/StatusEffectmessage types, decoded by both codecs (the pooled Protobuf decode
clears the lists per entity).ResolvedEntitycarries them through a new constructor taking
in ResolvedEntity coreplus every v3 field (HasVersion3Fields);SnapshotResolverresolves
the projectileownerand each statussourcehandle after the snapshot's own bindings land,
reporting an unbound one as null and counting it inUnresolvedEntityReferenceswithout
aborting the snapshot.WorldState.Applyhands them to Shared.GameLogic's
EntitySnapshotDatav3 constructor, soSnapshotMergermerges the new mask bits
(0x0200-0x2000).GameEvent.EffectId/ResolvedGameEvent.EffectId(new 7-argument
constructor). - Input v3 —
InputMessage.AimZ,RenderTick,RenderAlpha,Jump,SpawnSeq, and
InputMessage.SetRenderTime(double renderTick)to fill the render pair from
WorldViewBinder.RenderTick.GameSessionClient.SendInput(InputMessage)sends a full message.
All v3 fields are elided at zero in both encodings (a v2-shaped input is byte-identical). - Command channel (ADR-30) —
GameSessionClient.SendCommandAsync(uint opcode, byte[] payload, CancellationToken)/NetworkClient.SendCommandAsync(...)returnUniTask<CommandResult>
correlated by a per-connection seq starting at 1;CommandResultReceivedand
ServerPushReceivedevents on both. Channel failures complete withOk == falseand a
CommandChannelErrorsname instead of throwing or hanging:client_not_connected,
client_protocol_too_old(server echoed < 3; checked before sending) and
client_connection_closed(in flight when the connection ended, including a reconnect or
transfer).SupportsCommands,PendingCommandCount,UnmatchedCommandResultson the session.
CommandRequest/CommandResult/ServerPushmessages in both codecs (JSONpayloadis base64,
as Go marshals[]byte). - Character slots (ADR-31) —
NetworkClient.CharacterId(sent as
EnterWorldRequest.character_idon every connect, dungeon entry and transfer; a reconnect
rejoins as the character the lost session played),GameSessionClient.CharacterId/
NetworkClient.ActiveCharacterId(the server'sJoinTokenResponse.character_idecho),
GatewayClient.EnterWorldAsync(mapId, partyId, characterId, ct). Empty is omitted from the wire. - 3D prediction (ADR-28) —
LocalMovePredictor.UseServerProtocol(uint)selects
CharacterMotorfor a protocol 3 server and keeps the planarMovementSystempath for
protocol 2 / unversioned (the default, bit-identical to 0.45.0).SetMapGeometry,
SetMotorParams,RecordInput(tick, x, y, jump),Reconcile(Vec3, float verticalVelocity, long, long),Position3,SimulatedPosition3,VerticalVelocity,IsGrounded,
UsesCharacterMotor,Geometry,MotorParameters. An airborne body takes a passive motor step
on ticks nothing else steps, so gravity does not wait for input.WorldViewBinderreconciles
with height andvel_zwhen the predictor runs the motor. - Projectile prediction (ADR-29) —
ProjectilePredictor:Fireallocates thespawn_seq
and predicts withProjectileLogic;ApplySnapshot/TryHandOverhand over to the
authoritative entity with the samespawn_seq(HandedOver); an unclaimed prediction is
dropped afterHandoverTimeoutSeconds(Unconfirmed). WireProtocolVersion.MinimumServerVersion(2),CommandChannel(3),Motor3D(3),
Supports(serverVersion, feature).JsonValue.AsNumber.- EditMode tests:
CoreV3WireTests,CommandChannelTests,SnapshotV3ResolveTests,
PredictionModelSelectionTests(61 tests).
Changed
WireProtocolVersion.Currentis 3 (was 2), in step with the C# and Go servers.
IsCompatiblenow accepts a server echoing 2 or 3 (or nothing); a server ahead is still
refused. Rollout: a game server still on protocol 2 refuses a protocol 3 client by exact
match, so the server leg must be deployed before a client on this version ships.- DOTS Sample calls
LocalMovePredictor.UseServerProtocol(client.ServerProtocolVersion)after
each join, so it predicts with the motor against a protocol 3 server. - The JSON snapshot reader also reads
changed_fields(absent = 0 = every field present, as
before). GameEvent.ToString()/ResolvedGameEvent.ToString()includeeffect=.x-manualDependenciesand the CI rows pinsgl-v0.7.0(wassgl-v0.5.0inpackage.json,
sgl-v0.6.0in CI). CI fails until that tag exists, and thewirejob until the server's
protocol 3 bindings are on itsdevelop.- CI tests against the Shared.GameLogic the game ships - the package CI's
com.rpgmmo.shared-gamelogicpin movessgl-v0.5.0->sgl-v0.6.0(all three rows), matching
IndieRPGMMOAdventure'spackages-lock.json. sgl-v0.6.0 removes the ten-argument positional
EntitySnapshotDataconstructor (rpg-mmo-server #388);WorldState.Apply, the only call site, already
passesactionSeq:/changedFields:by name, so it compiles unchanged. Thecom.cuvara.dotspin
(v0.29.0) already matches the client. (Superseded by thesgl-v0.7.0move above.)
v0.45.0
Fixed
- The DOTS sample's health line printed the raw fitted skew without saying whether the
estimator applied it (#174).skew=is whatever the last fit read, including a fit the
corroboration or extraordinary-skew guard refused, so a starved-loop artefact of ~90 000 ppm
read like a clock running 9% off-rate. The line now printsskewApplied=(RateCorroborated)
andageFitted=(AgeIsFitted) beside it, anduncorroborated=/extraordinary=(which
guard refused) next to the existingrefusedFits=/refusedSkew=. Sample-only: the estimator's gating was already correct.
Documentation~/PREDICTION.mdsays how to read the pair.
v0.44.0
Fixed
-
The DOTS sample's run cap was indistinguishable from a netcode fault
(Cuvara/rpg-mmo-server#412).runSecondsdefaults to 3600, so the sample disconnects itself after an hour. That is
intended. What was not is how it ended: oneDebug.Logamong 200 fps of probe output, and
then a process that keeps rendering a world frozen at its last known state. The signal a
reader watches — the[DOTSNet/health]line — simply stops, because the health path
returns early when there is no session, and an absence reads as nothing wrong.Two clients stopped reporting at
sinceFirst3598.8s and 3597.9s, and the cause was
diagnosed for a day as the gateway'sconstants.SessionTTLexpiring, which is also 3600
seconds. Two unrelated one-hour numbers. The logs said neither; the only clue that
distinguished them wasLocalClosein[Net] game session closed, which says the client
closed it deliberately — nothing expired and nothing dropped.Four changes, all of them about saying so:
-cuvara-run-seconds/CUVARA_RUN_SECONDSoverrides the cap at launch, with0
meaning no cap. Every other knob in the sample already had a flag; this one was reachable
only by rebuilding. It gets its own parser rather than reusingInt, which clamps to
1-65535 because every other numeric flag is a port —0and86400are both meaningful
here and both would have been silently rejected.- The cap announces itself at run start, naming the wall-clock time it will fire and
that the world will freeze. runEndsIn=on the health line, so the countdown is on the line the reader is already
reading. Downstream of the disconnect, a run cap and a network fault look identical.- The silence names itself. The early-return path now logs on the same cadence instead
of nothing, saying either that the run cap fired (and that this is not an expiry, a drop
or an eviction) or that there is no session and counters have been re-baselined.
Verified two ways. The
Compile samplesCI job imports every sample the manifest declares
into a bootstrapped project and compiles it, which is what covers these files — a claim that
nothing compiled them would have been wrong, and was checked rather than assumed. On top of
that, the parser and every new expression were run outside Unity against a stub, because a
compile says the code is legal and not that it is right: five parser inputs (absent,0,
86400, negative, garbage) and both arms of each expression (capped and uncapped,
run-complete and no-session).86400is the case that matters — the port-clamped helper this
deliberately does not reuse would have rejected it, and a compile cannot see that.Not changed: the 3600 default,
ReconnectPolicynot firing onLocalClose(correct — it is
a deliberate local shutdown, not a fault), and the gateway session's activity refresh, which
was checked and is sound.
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 fals...
v0.42.0
Added
-
Entity counters on
WorldState, and on the DOTS sample's health line (#161):
LastAppliedEntityCount,LastAppliedRemovedCount,LastAppliedWasKeyframeand the
running totalEntitiesApplied.Every client-side counter until now measured frames. A snapshot carrying one entity
and a snapshot carrying eight were therefore indistinguishable in every number the client
reported, so a client rendering 1 of 8 replicated entities produced a health line that
read perfectly:snapshotsApplied=15.0/s framesRx=15.2/s resyncs=0 rejected=0 dropped=0.That cost a day on Cuvara/IndieRPGMMOAdventure#126. "The wire is delivering nine" felt
like an observation and was an inference — nothing measured it — so five instrumented
builds went down the client stack before the cause turned out to be positional and
upstream of all of it. "Snapshots arriving at 15/s carrying two entities" is a different
statement from "snapshots arriving at 15/s", and only the first is actionable.On a delta,
LastAppliedEntityCountis the number of entities that changed, normally
far belowCount, and that is not a fault — which is whyLastAppliedWasKeyframeis
printed beside it. On a keyframe the two should agree, and that is the comparison worth
making. An empty snapshot reports zero rather than holding the previous value: a stale
count reads as "entities are arriving" during exactly the silence this exists to reveal.In the health line as
entities=,snapEnts=,kf=,snapRemoved=andentsTotal=.
Deliberately there rather than in a probe: a probe is opt-in and only present once someone
already suspects something, and the health line is what gets read when nobody does.
Changed
-
The DOTS sample now defers unbracketed spawns by default, opting out with
-cuvara-no-defer-spawnrather than opting in with-cuvara-defer-spawn. The library
default is unchanged and stillfalse.The two defaults answer different questions.
InterpolationConfigserves views that may
render existence — a health bar, a selection ring, an aggro marker wants the entity the
moment the server says it exists — so the library must not change whatSpawnmeans
underneath them. This sample renders position and nothing else, and there the deferral took
an entity's first-quarter-second frozen frames from 53-57% to 0.0%. A sample that
demonstrates the netcode should demonstrate it at its best and let a caller ask for the
artefact, not the other way round.The opt-out flag is also the control arm: the measurement above was taken from one binary
with the flag selecting the arm at runtime.
Added
-
InterpolationConfig.DeferUntilBracketed— hold a newly seen remote entity out of the
view until its buffer can bracket the render instant, instead of drawing it frozen.
Opt-in, defaultfalse; with it off this release is behaviourally identical to the last.The render clock deliberately runs
TargetDelay(100ms) behind the newest snapshot so
that two samples normally straddle the instant being drawn. An entity seen for the first
time has only one, andSnapshotInterpolation.EvaluateAtcorrectly holds it at that
sample rather than extrapolating backwards from nothing. So a new entity is drawn
motionless for as long as it takes the render clock to reach it, and then starts moving.Measured live in the DOTS sample: 38.2% of the frames in an entity's first 0.25s
rendered zero displacement, against 0.42% for the same entities once established and
0.56% for a remote player. The hold itself measured 95-120ms, which isTargetDelayplus
a send interval, as predicted. Enemies show it constantly only because the sample's
scaffolding spawner churns mobs every ~4.2s; a persistent entity pays it once.Why opt-in rather than simply fixed. Spawn-on-first-sight is a contract, not an
accident: nine tests in this repo encode it, and a view that renders health bars, selection
or aggro state may legitimately want an entity present the moment the server says it
exists. Deferring the spawn is a visible change of semantics for those consumers, and the
evidence for it comes from a sample scene whose churn rate is scaffolding rather than
content. The flag lets a view that renders position choose smoothness without imposing it
on a view that renders existence.HoldDeferBudget(0.15s) bounds it. An entity sent once and never again never acquires a
second sample, so the hold condition cannot clear on its own; past the budget the entity is
rendered regardless. Without that bound the deferral would be a permanent disappearance in
exactly the case that looks most like a network fault.Measured with the flag on, one binary and both arms (the sample selects the arm from
-cuvara-defer-spawnat runtime, because a frozen-frame percentage only compares against
the same scene, spawner and observer position): enemyfreshfrozen frames went from
53.0-56.7% on the control to 0.0% on every treatment window, withsteadyat 0.0%
on both and theworst/medianspread unchanged. The fresh sample count stayed in the same
700-1600 band on both arms — the check that matters, since a deferral that had merely
stopped classifying frames as fresh would also read 0.0% over an empty bucket.Evaluate/EvaluateAtgainedout bool holdingoverloads reporting whether a result
was interpolated or held. Existing signatures are untouched.
Changed
-
RenderMotionProbenow reports the observer's distance from the world origin on every
line. A frozen-frame figure without it is not reproducible, and the reason is specific
rather than general.The DOTS sample's server anchors enemy spawning to the world origin — a ring of radius
13 about (0,0) — while the area of interest is a radius of 50 about the player. An
observer beyond roughly 63 units therefore sees zero enemies, permanently and correctly,
with every counter on both sides clean. The probe then prints no enemy rows at all,
which reads as an instrument fault rather than as an empty AOI.That cost a day (Cuvara/IndieRPGMMOAdventure#126, closed as not a defect): a client was
investigated for losing entities while it was faithfully rendering what was in interest,
215 units from the only populated region of the map. Player position is persisted, so
a device id replayed across sessions drifts out of that region and never returns —
SpawnPoint = Vec2.Zeroapplies only to a player the store has never seen.Measured both sides of the threshold, same build, minutes apart: observer at 215.0 saw 0
enemies; a fresh device id at 5.9 saw all 6 immediately, with spawn counts climbing
7 → 13 → 19 → 25 as mobs were reaped and replaced.So the measurement protocol for anything enemy-related is: a fresh device id per run,
which is the only way to get a known anchor, and this number logged beside the counts.
Added
-
Wire protocol version 2: field-level delta.
EntitySnapshot.changed_fields(wire
field 13) is now decoded, carried throughResolvedEntityand handed to
SnapshotMerger, so a delta can suppress individual unchanged fields instead of only
whole entities. The backend measured 43.4% of a delta's payload ashp,max_hp,
speedandtypere-sent identical on every movement tick.Zero means "every field present" — a keyframe, a pre-v2 sender and any complete
entity all send it, and proto3 elides it, so the default is also the safe reading.
Non-zero means the entry is a partial update and every field whose bit is clear keeps
its last known value.
Changed
-
WireProtocolVersion.Current1 → 2, and this was not optional. The game server
refuses any peer whose version is not an exact match — "a peer one version AHEAD is
refused just as firmly as one behind", as its ownCheckProtocolVersionputs it — and
the backend moved to 2 when field-delta landed. A client still announcing 1 was being
refused at the handshake, not quietly served the old wire. The bandwidth saving is
why the field exists; connecting at all is why this constant had to follow. -
ResolvedEntitygainsChangedFieldsand an eleven-argument constructor. The mask is
the eleventh parameter deliberately: adding it as a tenth would have captured every
existing ten-argument call, since both trailing parameters areuintand overload
resolution cannot tell them apart. That is not hypothetical — Shared.GameLogic 0.5.0 added
exactly such a ten-argument overload andWorldState.Applysilently boundactionSeq
intochangedFields(#159).Verified:
FieldDeltaMergeTestsruns two arms against the same delta bytes. With
X|Yflagged, Hp/MaxHp/Speed/Type/Facing/Action/ActionSeq keep their seeded values; with
the mask at 0 every field takes the wire value including the zeros. Dropping the mask on
the way through makes the first arm fail withHp ... Expected: 100, But was: 0— the
exact collapse-to-defaults this pair exists to catch.
Fixed
-
ActionSeqwas silently landing in the field-delta mask instead of the retrigger
counter.WorldState.ApplybuiltEntitySnapshotDatawith ten positional arguments
whose last was auint. Shared.GameLogic 0.5.0 added a ten-argument overload whose tenth
parameter isuint changedFields, so overload resolution bound that one: the counter went
into the mask andactionSeqwas forced to0.Two consequences, and the second is the dangerous one:
ActionSeqarrived as 0, so no view would ever see a repeated action — an entity
renders correctly, carries the right action, and simply never animates a second swing.ChangedFieldsheld the counter's value. A counter of 12 is a mask asserting
Hp|MaxHpare the only fields present, so the next merge would have ...
v0.41.0
Added
- Game events —
SnapshotMessage.events,GameEvent,GameEventType,
ResolvedGameEvent. The edge-triggered channel. Everything the server sent until now was
level-triggered state, which is the right shape for state and the wrong shape for an
occurrence: "took 12 damage" is not recoverable from two HP values a tick apart, because a
heal and a hit in the same tick net out, a delta may omit the entity entirely, and an
entity leaving the AOI simply stops reporting. Events arrive on the existing
SnapshotReceivedcallback asResolvedSnapshot.Events; no second channel was added,
deliberately, because two ways to reach the same events is two ways to consume them twice. - Ability input —
InputMessage.AbilityId/AbilityTargetId/AimX/AimY. Encoded
by both codecs. Not predicted: an ability outcome depends on cooldowns, content and other
entities' state, so a mispredicted cast plays and then un-happens. Do not drive a cooldown
bar off the input — drive it off theAbilityCastevent. EntitySnapshot.ActionSeq/ResolvedEntity.ActionSeq— the retrigger counter.
Actionis level-triggered, so two attacks in a row are identical bytes and an animator
driven from it plays the swing once. Retrigger on inequality, never on increase: the
counter wraps at 2³² and resets on restart or respawn, so a greater-than test stops
retriggering for four billion actions after a single wrap.SnapshotResolver.UnresolvedEventParticipants— counted, never escalated. An
unresolvable EVENT participant is reported as an empty id and the snapshot still resolves,
unlike an unresolvable ENTITY handle which aborts it. A wrong entity state is a wrong
world; a missing damage number is a missing damage number, and escalating it would spend a
keyframe's bandwidth for every observer precisely when the link is already struggling.Samples~/GameEventProbe— offline scene. Two buttons carry the argument: turning
events off leaves HP falling with no damage numbers, and turningaction_seqoff leaves
the attacker attacking with the swing flash fired exactly once.
Changed
Runtime/Protocol/Generated/Wire.csregenerated from the backend'swire.protoand
verified byte-identical to the backend's committed copy. The CI sync gate will be red
until the backend change is ondevelop— the gate fetches from that branch by design,
so a schema change landing there turning this red is the drift it exists to catch.WorldState.ApplycarriesActionSeqinto the merge. Worth its own line because it
was missing when the field was first wired through: the codec decoded it, the resolver
carried it, every test passed, and the value was dropped convertingResolvedEntityto
EntitySnapshotData— so no view ever saw it. The symptom would have been an entity that
renders perfectly, carries the right action, and never animates a second swing.
ActionSeqSurvivesTheMergeIntoWorldStateis the guard.
Requires
com.rpgmmo.shared-gamelogic≥ sgl-v0.5.0, forEntitySnapshotData.ActionSeqand the
ability/event types. An older pin compiles against a struct that has no such field.
v0.39.1
Fixed
APingIsAnsweredWithAPongCarryingTheSameTimestampfailed roughly one CI run
in ten. The wait was bounded by frames — 300yield return null— but what it
waits for is a scheduler, not a renderer. Batchmode renders nothing, so 300
editor updates can elapse in a few milliseconds, before the continuation is
posted; the same 300 frames in an interactive Editor are several seconds, which
is why it passed everywhere except CI. The bound is now ten seconds of real
time. Verified as a race rather than a regression: the failing and passing runs
were the same commit.
Changelog
All notable changes to the Cuvara Netcode package will be documented in this file.
The format is based on Keep a Changelog,
and this project adheres to Semantic Versioning.
v0.39.0
Fixed
-
AckLatencyEstimator.AckIntervalSecondsread the snapshot cadence 25% low; it now measures
over a ring of gaps. 0.35.0 recorded this as measured-and-unfixed and pinned the two wrong
readings as tests so the next attempt had to come past them. It does: 63.889 ms against a true
66.667 on the same jitter fixture (4.2% low, where it read 50.000 ms) and exactly 66.667 on an
ideal cadence. The cadence fallback is now visible and counted rather than silent. -
PredictionLatencyMeasurementgates on the correction RATE, not the max. The max over ~28
corrections is one draw from a tail, and it was the statistic being asserted — a run failed on
max correction 2.00 stepswhilecorrections > ONE STEPread 1 of 28 and passed comfortably.
One event is not a trend, and the assertion that fired could not tell them apart. The max is
still computed and still printed with its full explanatory message, now as a warning: a
reporting threshold rather than a budget.
These five commits sat on
fix/intervalstatfrom 2026-09-09 with no pull request ever
opened, and were found by sweeping every branch unreachable frommain. Five other branches
turned up the same way and none of them was merged — each was verified as already-landed
content on a stale fork. The detailed entries, and the in-place corrections to the 0.35.0/0.36.0
text they falsify, are filed beside the claims they correct, per this file's convention.
v0.38.2
Added
-
The sealed-session probe now answers its own question with no UI, no window and nobody
clicking.RunHeadlessSelfCheckruns before theUIDocumentis required and writes one
line per claim to the log, ending in a single greppableVERDICT:line.This exists because a scene whose only output is labels cannot answer the question the
scene was built for. IL2CPP strips managed code the Editor never strips, so a library
reached through its own registries can vanish from a player while every Editor test stays
green — no compile error, no exception, just a feature that silently stops working. Running a
player is the only way to find out, and until now that meant a person looking at a screen and
reporting what they saw. The checks are the ones with no UI in them: X25519 agreement, two
distinct HKDF direction keys, a ChaCha20-Poly1305 round trip, a refused flipped byte, Ed25519
signing and verification, a refused flipped signature byte, and the conjunction — the same
genuine signature evaluated over an authenticated and an unauthenticated hop, reading as
verified over only one of them.The
catchis part of the answer rather than defensive padding: on a stripped player a
missing type surfaces as aTypeLoadExceptionor a null from a factory, never as a compile
error, so an exception is logged as the finding it is.