Skip to content

Decoupled High-Frequency Delivery and the Serialization Toolchain β€” ADR-092

Decoupled High-Frequency Delivery and the Serialization Toolchain β€” ADR-092

Date: 2026-08-02 Β· Written in English per the 2026-08-01 convention change.

Provenance: implementing Β§8.2’s per-viewer snapshot. Building the first real wire payload turned three of Β§8.1’s implicit assumptions into decisions that had to be made. Two of them Β§8.1 had answered the wrong way, and the third it had not asked. As with R6 and R7, the title names where the ruling came from rather than implying an eighth review round.


ADR-092 β€” What Rides the 20Hz Packet Is Decided by Measurement, and One Compiler Owns the Wire

Status: Accepted Β· Date: 2026-08-02 Β· Amends: 090 Β· Depends-on: 029 (SignalR is the transport whose message size this bounds) Β· interacts with 058

Context

Β§8.1 assigned formats β€” FlatBuffers for core updates, MessagePack for cartridge payloads β€œembedded opaquely as [ubyte] within the FlatBuffer packet” β€” and Β§9.6 declared Descent.Vtt.Protocol the only source of truth for the wire. Neither statement had ever been executed, because until 2026-08-02 nothing produced a per-viewer snapshot.

Executing them produced three findings.

The embedded payload is unaffordable at tick frequency. Measured on Β§5.2’s reference interest set of 200 entities: a transform-only snapshot is 6,468 bytes, and the same snapshot with every entity carrying Q-060’s 4,096-byte attribute ceiling is 825,668 bytes. At Q-001’s 20Hz that is ~16.5 MB/s per viewer, against a budget ADR-014 exists to protect. The 128Γ— factor is not a tuning problem; it is a category error about what belongs on a tick.

The figures above are as measured on 2026-08-02 and are deliberately not updated. This is a decision-time record, and what was measured when the ruling was made is part of what it records. DisclosedEntity gained raw_z and rotation on 2026-08-09, taking the struct from 32 to 40 bytes; the reference snapshot is now 8,068 bytes, the attributes-at-ceiling comparison 827,268, and the factor ~103Γ— rather than 128Γ—. The ruling is unaffected β€” the argument is about cadence rather than about the size of the ratio, and two orders of magnitude remain two orders of magnitude. The live derivation is Q-056 in Quantity_Registry.md, which is where a reader should build from.

The fog mask has the same shape and was never examined. Q-010 fixes a packed chunk at 16 KiB. Riding the snapshot, one chunk alone is ~320 KiB/s per viewer at 20Hz β€” more than fifty times the entire transform payload it would be attached to.

And the single-source claim had no compiler. Β§9.6 requires one .fbs set to generate both the C# and the TypeScript artefacts. FlatSharp is the convenient .NET answer and emits C# only, so choosing it means the TypeScript artefact is hand-written β€” which is exactly what the claim forbids, arrived at by a route that looks like tooling convenience rather than a wire decision.

Decision

  1. The tick-driven snapshot carries transform state and nothing else. Identity, position, observer flag, the two ticks and their derived disclosure tick. A field for anything else is not merely unused; it is an invitation, and the measurement above is what it costs when accepted.

  2. Cartridge attributes are a separate, event-driven, low-frequency message. Sent when a stat changes or when a client opens a sheet β€” never per tick, and never for every entity in an interest set because one of them was mutated. Β§8.1’s β€œembedded opaquely as [ubyte] within the FlatBuffer packet” is withdrawn: the embedding is what made the frequency coupling invisible.

  3. The fog mask travels out of band, as its own message. Q-010’s chunk is sized for storage and for the geometry crate, not for a tick, and its cadence is already governed separately by Q-068…Q-071’s ladder. Bundling it into a packet delivered at transform frequency would silently re-couple two cadences that Β§5.3 deliberately separates.

  4. flatc is the wire compiler. FlatSharp and hand-written wire types are refused. The single-source property is cross-language or it is nothing: a C#-only generator satisfies the letter of β€œgenerated” while leaving the half the claim exists to protect β€” agreement between the two hosts β€” to review.

  5. flatc and the C# runtime package are version-matched, and the runtime pins the pair. Generated code asserts FlatBufferConstants.FLATBUFFERS_<version>, which a mismatched runtime does not define, so a skew is a compile error rather than a subtle one. The NuGet runtime trails the compiler releases, so the newest runtime, not the newest compiler, decides the version; installing the compiler’s default is what breaks the build.

  6. Generated sources are never committed. They live in obj/ and are produced every build. A committed generated file is a second source of truth: it can be edited, it can go stale against the schema beside it, and neither shows up in review as a wire change.

  7. Q-056 is set at 32,768 and is one decision with clause 1. It admits 1,021 entities, 5.1Γ— Β§5.2’s reference interest set. Raising it without re-examining what the packet carries restores the 128Γ— term it exists to exclude β€” the cap and the payload rule are not independently tunable.

Alternatives Considered and Why Rejected

  • Keep attributes embedded and cap the snapshot instead. Rejected: a cap large enough for the attribute-bearing case is ~825 KB, which does not bound anything useful; a cap small enough to be a bound refuses the packet. The size is a symptom of the coupling, not the problem.
  • Send attributes embedded but only for changed entities. Rejected β€” it makes the packet size depend on mutation rate, so the worst case is a busy combat round and the measurement that sized Q-056 would describe the quiet case. A bound that holds only when nothing is happening is not a bound.
  • FlatSharp for C#, flatc for TypeScript. Rejected: two generators reading one schema is two interpretations of it, and their disagreements would appear as wire bugs between hosts β€” the precise failure the single-source rule exists to prevent, reintroduced one layer down.
  • Pin the compiler and let the runtime float. Rejected: the runtime is the scarcer artefact, and inverting the pin means the build breaks whenever Google ships a compiler, which is the direction nobody notices until CI is red.

Consequences (including negative)

  • Negative: a second delivery path exists now. Attributes and the fog mask each need their own message, ordering story and loss behaviour. That is three paths where Β§8.1 described one, and each is a place a disclosure filter must be applied again β€” Β§14.7’s per-viewer property covers the snapshot and does not automatically cover the other two.
  • Negative: a build-time binary dependency. flatc must be installed on every developer machine and in CI, pinned. This is the cost of the cross-language claim, accepted deliberately.
  • Negative: the version pin now lives in three places. FLATC_VERSION in CI, FlatcExpectedVersion and the PackageReference in Descent.Vtt.Protocol.csproj. The csproj fails the build when the compiler and the runtime disagree, so drift between them is caught rather than silent β€” but the three must be bumped in one commit, and the standing instruction to keep them synced is recorded at the TODO(flatbuffers-version-bump) beside them. (Superseded note, kept because the reasoning still applies: this clause originally recorded that the pin had no enforcement point, .github/workflows/ in the backend being an empty directory. Backend CI landed 2026-08-02.)
  • Positive: the snapshot’s size is now a property of the interest set alone, so Q-056 bounds something stable rather than something that moves with cartridge design.

Enforcement

  • schemas/snapshot.fbs has no attributes vector, so clause 1 is structural rather than reviewed.
  • SnapshotSizeMeasurementTests asserts the measured tail stays inside Q-056, so a wider default AOI, a fatter entity struct, or attributes returning to the packet fails against the registry.
  • Descent.Vtt.Protocol.csproj errors with a named message when flatc is absent, rather than surfacing a shell β€œcommand not found” that reads like a malformed schema.
  • Backend CI, live since 2026-08-02 (.github/workflows/ci.yml). It installs flatc at the pinned version from the release binary β€” not apt, whose flatbuffers-compiler on ubuntu-latest is 23.x and cannot satisfy clause 5 β€” verifies the reported version before building, and runs the .NET suites and the Rust gates against the crate revision this repository pins. Clauses 4–6 no longer rest on developer discipline, which ADR-045 says is not enforcement.

Β§11 Index Line

ADR-092: The tick-driven snapshot carries transform state only β€” cartridge attributes and the Q-010 fog mask each travel as their own event-driven message, because embedding them costs 128Γ— and 50Γ— respectively at 20Hz; flatc is the sole wire compiler (FlatSharp is refused for emitting C# only), the NuGet runtime pins the compiler version, generated sources are never committed, and Q-056's 32,768 is one decision with the payload rule rather than an independent cap. (Amends ADR-090.)