Skip to content

Releases: Cuvara/Netcode

v0.47.0

Choose a tag to compare

@github-actions github-actions released this 10 Oct 13:09
1971df4

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's TRANSPORT_KEY; empty =
    plaintext datagrams) for the KCP gameplay hop. RegisterNetworking() now hands it to
    DefaultTransportFactory (it passed null), as do NetworkBootstrap (new
    NetworkBootstrapConfig.TransportKey field) and the DOTS Sample (-cuvara-transport-key /
    CUVARA_TRANSPORT_KEY). The E2E / WorldView / ReconnectPolicyDemo samples and the PlayMode
    live tests read CUVARA_TRANSPORT_KEY.

  • TransportKinds.ParseGameplay / TryParseGameplay / RequireGameplay and
    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 state KcpTransport drives —
    ARQ, crypto, reassembly, idle timeout, dead link — so it runs under dotnet test.
    KcpTransport exposes Conversation, 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); two NetworkingRegistrationTests for the key. The headless project now also
    compiles Kcp, KcpCrypto, KcpClientSession, TransportKind(s), NetworkEndpoint and runs
    KcpCoreTests, KcpCryptoTests, NetworkEndpointTests, KcpGameplayTransportTests
    (352 tests).

  • ack_applied_tick end to end. Runtime/Protocol/Generated/Wire.cs is a byte copy of the
    server's regenerated bindings (protoc 29.3). SnapshotMessage.AckAppliedTick, decoded by both
    codecs (ack_applied_tick in JSON; absent = 0); ResolvedSnapshot.AckAppliedTick with a new
    seven-argument constructor (the existing ones pass 0); WorldState.AckAppliedTick, kept paired
    with AckTick under the merger's monotonic ack rule and cleared by Reset.

  • LocalMovePredictor.Reconcile(Vec2, long ackTick, long serverBaseTick, long ackAppliedTick)
    and Reconcile(Vec3, float, long, long, long): compare the snapshot at server tick T with
    the history at T + 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. Zero ackAppliedTick behaves exactly as the three-/four-argument
    overloads. Diagnostics: AckTickOffset, LastMeasuredAckTickOffset, AckOffsetSamples,
    AckOffsetChanges, AckOffsetUnresolved, SteerIntegralTicks.

  • PredictionClockSteering: the clock half of WorldViewBinder (tick-rate, staleness and
    acknowledgement-floor estimators, RoundTripMs, rate feed-forward, measured TargetLeadTicks,
    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.ClockSteering exposes the binder's instance.

  • Headless prediction tests. Tests~/Headless now 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 the sgl-v* tag package.json pins) and runs the
    prediction, steering and binder suites plus the new PredictionHarness /
    AckAppliedTickReconcileTests (285 tests).

Fixed

  • KcpTransport.ConnectAsync does 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.ReadFrameAsync throws 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 as TcpTransport.

  • 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 with KCP idle timeout: no UDP datagram from <host:port> ...,
    surfaced as a TransportError close 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 throws NetworkException("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: ConnectAsync sends nothing; the
    join_token frame 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). With ack_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. RecordInput changed where the tick ended but the
    entry Advance wrote 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. Advance replaced the current
    step without carrying its remainder into the render offset (as RecordInput already 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. SteerToServerTick was 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.Parse is replaced by ParseGameplay:
    "kcp" (case-insensitive) is accepted, and empty / "tcp" / anything else is a failure that
    names the value — empty no longer means TCP. GatewayClient turns it into a
    NetworkException with ServerError = unsupported_gameplay_transport (not retried, ends a
    reconnect). GameSessionClient.JoinAsync refuses any MapAssignment.Transport but Kcp
    before asking a factory, and asks for TransportKind.Kcp only. TransportKind.Tcp /
    TcpTls are documented as gateway-hop only.

  • WebGL: new KcpTransport(...) throws NotSupportedException("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.WithoutTheRateTheSteeringDroopsByExactlyTheTextbookAmount is now
    WithoutTheRateTheIntegralTermRemovesTheDroopToo
    : it pinned the proportional loop's droop,
    which the integral term removes.

  • Samples~/DOTSSample: RenderMotionProbe reports 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 adds ackOffset and steerI.

v0.46.0

Choose a tag to compare

@github-actions github-actions released this 07 Oct 07:19
93441fe

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.dots prediction 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.cs is a byte copy of the
    server's regenerated GameServer/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/StatusEffect message types, decoded by both codecs (the pooled Protobuf decode
    clears the lists per entity). ResolvedEntity carries them through a new constructor taking
    in ResolvedEntity core plus every v3 field (HasVersion3Fields); SnapshotResolver resolves
    the projectile owner and each status source handle after the snapshot's own bindings land,
    reporting an unbound one as null and counting it in UnresolvedEntityReferences without
    aborting the snapshot. WorldState.Apply hands them to Shared.GameLogic's
    EntitySnapshotData v3 constructor, so SnapshotMerger merges 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(...) return UniTask<CommandResult>
    correlated by a per-connection seq starting at 1; CommandResultReceived and
    ServerPushReceived events on both. Channel failures complete with Ok == false and a
    CommandChannelErrors name 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, UnmatchedCommandResults on the session.
    CommandRequest/CommandResult/ServerPush messages in both codecs (JSON payload is base64,
    as Go marshals []byte).
  • Character slots (ADR-31) — NetworkClient.CharacterId (sent as
    EnterWorldRequest.character_id on every connect, dungeon entry and transfer; a reconnect
    rejoins as the character the lost session played), GameSessionClient.CharacterId /
    NetworkClient.ActiveCharacterId (the server's JoinTokenResponse.character_id echo),
    GatewayClient.EnterWorldAsync(mapId, partyId, characterId, ct). Empty is omitted from the wire.
  • 3D prediction (ADR-28) — LocalMovePredictor.UseServerProtocol(uint) selects
    CharacterMotor for a protocol 3 server and keeps the planar MovementSystem path 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. WorldViewBinder reconciles
    with height and vel_z when the predictor runs the motor.
  • Projectile prediction (ADR-29) — ProjectilePredictor: Fire allocates the spawn_seq
    and predicts with ProjectileLogic; ApplySnapshot/TryHandOver hand over to the
    authoritative entity with the same spawn_seq (HandedOver); an unclaimed prediction is
    dropped after HandoverTimeoutSeconds (Unconfirmed).
  • WireProtocolVersion.MinimumServerVersion (2), CommandChannel (3), Motor3D (3),
    Supports(serverVersion, feature). JsonValue.AsNumber.
  • EditMode tests: CoreV3WireTests, CommandChannelTests, SnapshotV3ResolveTests,
    PredictionModelSelectionTests (61 tests).

Changed

  • WireProtocolVersion.Current is 3 (was 2), in step with the C# and Go servers.
    IsCompatible now 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() include effect=.
  • x-manualDependencies and the CI rows pin sgl-v0.7.0 (was sgl-v0.5.0 in package.json,
    sgl-v0.6.0 in CI). CI fails until that tag exists, and the wire job until the server's
    protocol 3 bindings are on its develop.
  • CI tests against the Shared.GameLogic the game ships - the package CI's
    com.rpgmmo.shared-gamelogic pin moves sgl-v0.5.0 -> sgl-v0.6.0 (all three rows), matching
    IndieRPGMMOAdventure's packages-lock.json. sgl-v0.6.0 removes the ten-argument positional
    EntitySnapshotData constructor (rpg-mmo-server #388); WorldState.Apply, the only call site, already
    passes actionSeq:/changedFields: by name, so it compiles unchanged. The com.cuvara.dots pin
    (v0.29.0) already matches the client. (Superseded by the sgl-v0.7.0 move above.)

v0.45.0

Choose a tag to compare

@github-actions github-actions released this 24 Sep 12:10
2eb6ddb

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 prints skewApplied= (RateCorroborated)
    and ageFitted= (AgeIsFitted) beside it, and uncorroborated= / extraordinary= (which
    guard refused) next to the existing refusedFits= / refusedSkew=. Sample-only: the estimator's gating was already correct.
    Documentation~/PREDICTION.md says how to read the pair.

v0.44.0

Choose a tag to compare

@github-actions github-actions released this 24 Sep 02:07
2ed3df1

Fixed

  • The DOTS sample's run cap was indistinguishable from a netcode fault
    (Cuvara/rpg-mmo-server#412).

    runSeconds defaults to 3600, so the sample disconnects itself after an hour. That is
    intended. What was not is how it ended: one Debug.Log among 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 sinceFirst 3598.8s and 3597.9s, and the cause was
    diagnosed for a day as the gateway's constants.SessionTTL expiring, which is also 3600
    seconds
    . Two unrelated one-hour numbers. The logs said neither; the only clue that
    distinguished them was LocalClose in [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_SECONDS overrides the cap at launch, with 0
      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 reusing Int, which clamps to
      1-65535 because every other numeric flag is a port — 0 and 86400 are 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 samples CI 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). 86400 is 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, ReconnectPolicy not firing on LocalClose (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

Choose a tag to compare

@github-actions github-actions released this 21 Sep 11:41
f277514

Added

  • The transport's read path is now tested outside the Editor (Cuvara/IndieRPGMMOAdventure#50):
    Tests~/Headless/Cuvara.Netcode.Tests.Headless.csproj, a net10.0 project that compiles
    package sources directly and runs 30 tests in under a second, plus a Headless tests (dotnet)
    CI job that runs it on every PR.

    TcpTransport and WireConnection await through UniTask, which needs UnityEngine, 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's SynchronizationContext
    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. Every await there goes through Task.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 at playerLoopHz / 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 of TcpTransport as plain C# with no UniTask and 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 under dotnet test. TcpTransport.ReadFrameAsync is now TryTakeFrame / ReserveForRead
    / Commit around the same single socket read; behaviour is unchanged.

    ReserveForRead also 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 real FrameBuffer
    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. ExactReadStrategy is 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 test exits 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 .trx counters 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.

    ProtobufWireCodec gained ProtobufWireCodec.CreatePooled(), which pools the decoded
    SnapshotMessage and its entity and event objects across calls, and WireConnection now
    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.
    RegisterNetworking registers the type as
    builder.Register<ProtobufWireCodec>(Lifetime.Singleton), and VContainer's TypeAnalyzer
    selects a constructor by reflection, takes the greediest one, and resolves its parameters
    out of the container. Adding a ProtobufWireCodec(bool) overload therefore broke
    RegisterNetworking at 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 with BindingFlags.NonPublic included, so the private overload was still
    selected and the identical failure came back. ProtobufWireCodec must 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, with NonPublic in the mask, so the pure-C# suites catch it.

    WireConnection builds 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.Apply reuses its EntitySnapshotData[] and string[] 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 and SnapshotMerger iterates 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 zeroed EntitySnapshotData has 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.

    EncodeBody wraps the encoded payload with UnsafeByteOperations.UnsafeWrap instead of
    copying it with ByteString.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/develop and 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%
    DecodeBody alone 12,560 B 7,688 B −38.8%
    WorldState.Apply alone, steady count 2,824 B 0 B −100%
    EncodeBody(InputMessage), per input frame 400 B 360 B −10.0%

    Two allocations named in the issue were left alone deliberately. The resolver's
    List<ResolvedEntity> is published to SnapshotReceived subscribers 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-frame new byte[length] on the receive path —
    TcpTransport's until #168 moved it into FrameBuffer.TryTakeFrame — is poolable in
    principle, since neither codec retains it (the Protobuf parser copies into its own
    ByteStrings and the JSON one goes through Utf8.GetString). But
    ITransport.ReadFrameAsync returns a byte[] 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.

    SnapshotPipelineReuseTests is not in Tests~/Headless, although it is pure C#.
    WorldState, ResolvedEntity and Msg.EntitySnapshot all name Shared.GameLogic types,
    and that assembly arrives as a UPM git dependency Unity resolves into PackageCache, so
    dotnet restore has 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 to Shared.GameLogic is its own piece of work.

    A third claim in the issue was already fals...

Read more

v0.42.0

Choose a tag to compare

@github-actions github-actions released this 21 Sep 09:31
408a1ed

Added

  • Entity counters on WorldState, and on the DOTS sample's health line (#161):
    LastAppliedEntityCount, LastAppliedRemovedCount, LastAppliedWasKeyframe and the
    running total EntitiesApplied.

    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, LastAppliedEntityCount is the number of entities that changed, normally
    far below Count, and that is not a fault — which is why LastAppliedWasKeyframe is
    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= and entsTotal=.
    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-spawn rather than opting in with -cuvara-defer-spawn. The library
    default is unchanged and still false.

    The two defaults answer different questions. InterpolationConfig serves 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 what Spawn means
    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, default false; 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, and SnapshotInterpolation.EvaluateAt correctly 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 is TargetDelay plus
    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-spawn at runtime, because a frozen-frame percentage only compares against
    the same scene, spawner and observer position): enemy fresh frozen frames went from
    53.0-56.7% on the control to 0.0% on every treatment window, with steady at 0.0%
    on both and the worst/median spread 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 / EvaluateAt gained out bool holding overloads reporting whether a result
    was interpolated or held. Existing signatures are untouched.

Changed

  • RenderMotionProbe now 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.Zero applies 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 through ResolvedEntity and 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 as hp, max_hp,
    speed and type re-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.Current 1 → 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 own CheckProtocolVersion puts 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.

  • ResolvedEntity gains ChangedFields and 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 are uint and overload
    resolution cannot tell them apart. That is not hypothetical — Shared.GameLogic 0.5.0 added
    exactly such a ten-argument overload and WorldState.Apply silently bound actionSeq
    into changedFields (#159).

    Verified: FieldDeltaMergeTests runs two arms against the same delta bytes. With
    X|Y flagged, 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 with Hp ... Expected: 100, But was: 0 — the
    exact collapse-to-defaults this pair exists to catch.

Fixed

  • ActionSeq was silently landing in the field-delta mask instead of the retrigger
    counter.
    WorldState.Apply built EntitySnapshotData with ten positional arguments
    whose last was a uint. Shared.GameLogic 0.5.0 added a ten-argument overload whose tenth
    parameter is uint changedFields, so overload resolution bound that one: the counter went
    into the mask and actionSeq was forced to 0.

    Two consequences, and the second is the dangerous one:

    1. ActionSeq arrived 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.
    2. ChangedFields held the counter's value. A counter of 12 is a mask asserting
      Hp|MaxHp are the only fields present, so the next merge would have ...
Read more

v0.41.0

Choose a tag to compare

@github-actions github-actions released this 20 Sep 07:22
77d16a2

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
    SnapshotReceived callback as ResolvedSnapshot.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 the AbilityCast event.
  • EntitySnapshot.ActionSeq / ResolvedEntity.ActionSeq — the retrigger counter.
    Action is 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 turning action_seq off leaves
    the attacker attacking with the swing flash fired exactly once.

Changed

  • Runtime/Protocol/Generated/Wire.cs regenerated from the backend's wire.proto and
    verified byte-identical to the backend's committed copy. The CI sync gate will be red
    until the backend change is on develop
    — 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.Apply carries ActionSeq into 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 converting ResolvedEntity to
    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.
    ActionSeqSurvivesTheMergeIntoWorldState is the guard.

Requires

  • com.rpgmmo.shared-gamelogic ≥ sgl-v0.5.0, for EntitySnapshotData.ActionSeq and the
    ability/event types. An older pin compiles against a struct that has no such field.

v0.39.1

Choose a tag to compare

@github-actions github-actions released this 14 Sep 09:55
f8db6e7

Fixed

  • APingIsAnsweredWithAPongCarryingTheSameTimestamp failed roughly one CI run
    in ten.
    The wait was bounded by frames — 300 yield 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

Choose a tag to compare

@github-actions github-actions released this 13 Sep 18:17
8cff4c7

Fixed

  • AckLatencyEstimator.AckIntervalSeconds read 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.

  • PredictionLatencyMeasurement gates 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 steps while corrections > ONE STEP read 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/intervalstat from 2026-09-09 with no pull request ever
opened
, and were found by sweeping every branch unreachable from main. 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

Choose a tag to compare

@github-actions github-actions released this 13 Sep 07:29
d5b8ebc

Added

  • The sealed-session probe now answers its own question with no UI, no window and nobody
    clicking.
    RunHeadlessSelfCheck runs before the UIDocument is required and writes one
    line per claim to the log, ending in a single greppable VERDICT: 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 catch is part of the answer rather than defensive padding: on a stripped player a
    missing type surfaces as a TypeLoadException or a null from a factory, never as a compile
    error, so an exception is logged as the finding it is.