Repository navigation
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 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:andchangedFields:by name. Verified both ways: with
named arguments the fixture is 17/17; reverted to the positional form against
Shared.GameLogic atsgl-v0.5.0it is 15/17, failing with exactlyExpected: 12, But was: 0andExpected: 5, But was: 0.changedFields: 0uis deliberate —ResolvedEntitydoes not carry the mask yet and0
is specified to mean "every field present". Wiring the real mask through is #158.
Fixed
-
Runtime/Protocol/Generated/Wire.cshad drifted from the backend: it was missing
EntitySnapshot.changed_fields(proto field 13,uint32). The field-level delta
encoding landed inrpg-mmo-serverseparately from gameplay-v2, so this branch carried
every gameplay-v2 message but not the delta mask, and the
Generated Wire.cs matches the backendgate is byte-exact against the backend's committed
copy ondevelop. 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
ChangedFieldsandChangedFieldsFieldNumberadded and nothing removed, the rest of
the diff being the embedded descriptor's base64 re-wrapping.Wire.csis now byte-identical
to the backend (md595f9e2d71ce6efbc596b7a25748abfc0).Note this only makes the binding current.
WireProtocolVersion.Currentis still1
while the backend announces2, and nothing in this package readsChangedFieldsyet —
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_seqand the latch shipped,
and it is the reason the scene exists:GameEventProbealready 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 overwritesAttackingbefore anyone can see it. Measured over a 120 s run:WorldEvery latched, attacks lost no latch (WorldEvery-1)/WorldEvery1 0 % 0 % 0 % 2 0 % 53 % 50 % 4 0 % 72 % 75 % 8 0 % 86 % 88 % WorldEvery = 1reading 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.V1types -- 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'sActionTransitionsand 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'sActionSeqTestsis 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 reachedInputMessageand 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. - A join token is single-use. Two tests sharing one fail the second with "Token already
Changed
-
Resynced
Runtime/Protocol/Generated/Wire.cswith the backend.rpg-mmo-server
#365 addedEntitySnapshot.action_seq(field 12) towire.proto, so the committed
generated file this package carries no longer matched the backend's — which reddens the
Generated Wire.cs matches the backendgate 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
developrather than regenerated locally: the gate is acmp
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_seqthrough the codec, resolver, merger and view is onfeat/gameplay-v2.