Skip to content

v0.42.0

Choose a tag to compare

@github-actions github-actions released this 21 Sep 09:31
· 20 commits to develop since this release
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 reconstructed the
      entity from a lie. Nothing checks a mask for plausibility.

    It compiled clean. The only thing that objected was GameEventAndActionSeqTests —
    "decoded and resolved but dropped at the merge", expected 12, got 0.

    Fixed by passing actionSeq: and changedFields: by name. Verified both ways: with
    named arguments the fixture is 17/17; reverted to the positional form against
    Shared.GameLogic at sgl-v0.5.0 it is 15/17, failing with exactly Expected: 12, But was: 0 and Expected: 5, But was: 0.

    changedFields: 0u is deliberate — ResolvedEntity does not carry the mask yet and 0
    is specified to mean "every field present". Wiring the real mask through is #158.

Fixed

  • Runtime/Protocol/Generated/Wire.cs had drifted from the backend: it was missing
    EntitySnapshot.changed_fields (proto field 13, uint32).
    The field-level delta
    encoding landed in rpg-mmo-server separately from gameplay-v2, so this branch carried
    every gameplay-v2 message but not the delta mask, and the
    Generated Wire.cs matches the backend gate is byte-exact against the backend's committed
    copy on develop. A stale binding is the failure this gate exists to catch: the missing
    field decodes cleanly as zero, so a partial-update snapshot would read as "all fields
    present" and the client would silently overwrite live state with defaults, with no
    exception and every test on both sides still green.

    Resynchronised by copying the backend's generated artefact verbatim — not hand-merged.
    The change is purely additive: a public-API diff of the old and new file shows
    ChangedFields and ChangedFieldsFieldNumber added and nothing removed, the rest of
    the diff being the embedded descriptor's base64 re-wrapping. Wire.cs is now byte-identical
    to the backend (md5 95f9e2d71ce6efbc596b7a25748abfc0).

    Note this only makes the binding current. WireProtocolVersion.Current is still 1
    while the backend announces 2, and nothing in this package reads ChangedFields yet —
    both tracked in #158 and deliberately out of scope here.

Added

  • Sample: Action Latch Probe. No server, no network. One synthetic attacker written at
    the server's CRITICAL rate and sampled at its WORLD rate, encoded as real Protobuf
    snapshots and merged by the real resolver and world state -- twice, once with the
    server's one-shot latch and once without it.

    The second column is the server as it behaved before action_seq and the latch shipped,
    and it is the reason the scene exists: GameEventProbe already shows that two attacks in
    a row need a retrigger edge, but it says nothing about the attacks that never reach a
    client at all. Only one base tick in four is sampled at 60/15, and the next tick of
    movement overwrites Attacking before anyone can see it. Measured over a 120 s run:

    WorldEvery latched, attacks lost no latch (WorldEvery-1)/WorldEvery
    1 0 % 0 % 0 %
    2 0 % 53 % 50 %
    4 0 % 72 % 75 %
    8 0 % 86 % 88 %

    WorldEvery = 1 reading 0 % on BOTH arms is the control that says the scene is measuring
    the sampling gap and not something else: a single-rate server samples every tick, so there
    is no gap for a latch to close. Byte counts are identical between the two arms at every
    rate -- the counter is a varint on a message that was being sent anyway.

    The attack tick is jittered by a fixed-seed LCG. A fixed cadence against a fixed world
    period is not a coin flip but a fixed phase, so without jitter the control arm would read
    0 % or 100 % depending on two numbers rather than on the defect.

    What is real: the counter rule (ActionStateLogic.Advance, the same function the
    server calls), the wire bytes (built the way the server builds them, from the generated
    RpgMmo.Wire.V1 types -- the client codec deliberately cannot encode a snapshot, since
    a client never sends one), and the decode/resolve/merge path. What is mirrored: the
    latch, which lives in the server's ActionTransitions and cannot be referenced from a
    client, because a client has no tick schedule to apply it to. That is the scene's honest
    limit and the README says so: if the mirror drifts, the scene keeps looking healthy, and
    the backend's ActionSeqTests is what pins the real one.

    The model is plain C# with no Unity dependency and was run headless before the scene
    existed; the numbers in the table above come from that run, not from the Editor.

  • GameSessionClient.SendAbilityInput. The ability fields reached InputMessage and both
    codecs in 0.40.0 and never reached the public API, so a real client could not cast
    anything
    — found by writing the first live test that tried. A separate overload rather than
    four more optional parameters: the common call sends no ability, and defaults would make the
    ability path look like something that happens by accident.

  • Tests/Runtime/GameplayV2LiveTests.cs — a PlayMode test driving a REAL Unity client
    against a REAL game server. The Go integration test proves the server emits the fields; this
    proves this package's transport, codec, resolver and merger deliver them to a consumer. Either
    alone is the half that was green while the other half was missing.

    It connects straight to the game server with a join token supplied out of band
    (CUVARA_LIVE_GS_ADDR, CUVARA_LIVE_JOIN_TOKEN), skipping the gateway and Nakama, which are
    covered elsewhere. Two details are load-bearing and were each got wrong first:

    • A join token is single-use. Two tests sharing one fail the second with "Token already
      used", for a reason that has nothing to do with what it tests. The harness takes one token
      per test from a comma-separated list.
    • The victim is a second real client, not one of the server's mobs. The first version hunted
      the nearest mob; mobs carry server-side AI and the chase ended 20 units short, which surfaced
      as "no damage event" — the same symptom as the channel being broken. Players do not move
      unless told to and spawn together, so the result cannot be a positioning accident.

    Verified against a live server: damage amount=5, bolt amount=25, action_seq 2 -> 4.

Changed

  • Resynced Runtime/Protocol/Generated/Wire.cs with the backend. rpg-mmo-server
    #365 added EntitySnapshot.action_seq (field 12) to wire.proto, so the committed
    generated file this package carries no longer matched the backend's — which reddens the
    Generated Wire.cs matches the backend gate on every netcode PR until it is fixed,
    regardless of what that PR touches.

    Its own change, on purpose. A resync folded into a feature PR is a diff nobody reviews,
    in the one file where a silent difference means two peers parse the same bytes and
    disagree about what a field means — a stale copy does not fail loudly, it decodes
    cleanly and reads the missing field as its type default.

    Copied byte for byte from develop rather than regenerated locally: the gate is a cmp
    against the backend's committed file, and regenerating here would need protoc and the C#
    plugin pinned to the exact versions the backend used, producing diffs that are not drift.

    This makes the type available; it does not make the package use it. Reading
    action_seq through the codec, resolver, merger and view is on feat/gameplay-v2.