Merge origin/main into hands/variants-c2a (docs and research only)
This commit is contained in:
commit
3157d4b325
17 changed files with 2059 additions and 0 deletions
|
|
@ -31,6 +31,79 @@ copy of the solver and the warm/cold sweep, `suite` a scratch copy of `packages/
|
|||
conditional commit compares that (ruling `aetheria-release:ruling:expect-fix-in-cultcache`). C# only. The
|
||||
release that carries it to Aetheria follows question `cultlib-gaps:question:expect-fix-release-lane`; the
|
||||
Aetheria pin bump is an Aetheria follow-on, not this cut.
|
||||
9. `aot-record-ref-generator` (section A, AOT cut 1): a consumer with MessagePack's source generator on can declare
|
||||
`CultRecordRef<T>` members and dictionary keys. Base: the C2a branch head `a768b821`; Hands starts after C2a merges
|
||||
to main or rebases onto it. Serves `record-ref-breaks-msgpack-generator`, the first cut of
|
||||
`cs-aot-safe-registration` (operator 2026-10-27: "I'm not a fan of requiring reflection. ... AoT is a nice thing to
|
||||
support anyway.").
|
||||
10. Not mapped (AOT cut 2): the generator reaches consumers that reference only `GameCult.Caching.MessagePack` (today its
|
||||
analyzer is `PrivateAssets="all"`, so they run every document on Reflection.Emit).
|
||||
11. Not mapped (AOT cut 3): registration descriptors are generated instead of reflected (`BuildDescriptor`,
|
||||
`DiscoverMembers`, `CultDocumentResolver`, `CultMessagePackSecurity`), and C2a's element-id walk stops reflecting
|
||||
(A6), so a NativeAOT consumer round-trips a ref-keyed dictionary.
|
||||
|
||||
## A. AOT cut 1: CultRecordRef under MessagePack's source generator (cut `aot-record-ref-generator`)
|
||||
|
||||
Imagination pass `imagination-aot-cut1`, session `self-2026-10-27-eureka`, pinned to `origin/hands/variants-c2a` at
|
||||
`a768b821` (C2a's head; merge base with main `02cf04b5`). Starting evidence: `research/cultmesh-garden-path/cultcache-aot-probe-2026-10-27.md`
|
||||
(main `eb6a8f2c`). Probes: the scratch console from that note, copied to the session scratchpad
|
||||
`imagination-aot-cut1/aot-probe/` and committed only as dangling commits on top of `a768b821` (`a0f06b2c`, `6d382961`,
|
||||
`dc6cf9dc`, `99cae039`, `07b89982`), run on Yggdrasil in `mcr.microsoft.com/dotnet/sdk:10.0` (MessagePack 3.1.7, .NET
|
||||
10.0.12). Logs `job1.log` to `job5.log` beside the probe. Variants: G = `CultRecordRef<T>` member only, H = ref-keyed
|
||||
`Dictionary<CultRecordRef<T>, float>` only, B = both. Every variant references `MessagePack` directly (generator on).
|
||||
|
||||
### Body facts
|
||||
|
||||
- A1. The generator does not discover `CultRecordRefFormatter<T>`. For a generic type it does not know, it collects
|
||||
the type's definition as an object and names the formatter it would generate, `<Type>Formatter<T>`, nested in
|
||||
`GeneratedMessagePackResolver`. For a type from another assembly it then generates nothing, so the resolver names
|
||||
`GeneratedMessagePackResolver.GameCult.Caching.CultRecordRefFormatter<T>`, which does not exist (CS0426). The match
|
||||
with our formatter's name is a coincidence. Evidence (job1): `[ExcludeFormatterFromSourceGeneratedResolver]` on the
|
||||
formatter, and an `internal` formatter, both still fail G, H and B with the same line. Upstream issue
|
||||
MessagePack-CSharp#2133 (open, no maintainer reply) reports the same failure for any open-generic type with a
|
||||
formatter in another package.
|
||||
- A2. Consumer MessagePack 3.1.12, the newest stable, fails G, H and B the same way (job1). Upgrading does not fix it.
|
||||
- A3. `[MessagePackFormatter(typeof(CultRecordRefFormatter<>))]` on the `CultRecordRef<T>` struct stops the
|
||||
generator naming a formatter for it (the mangled line is gone), but the generator then reports `MsgPack006` (error)
|
||||
in every consumer: it checks `typeof(X<>)`'s interfaces, which an unbound generic does not list. Adding the
|
||||
non-generic `IMessagePackFormatter` marker to the formatter does not help (job2 `ta2`). With `MsgPack006`
|
||||
suppressed by the consumer, G, H and B build and run (job2). Rejected: it needs the formatter moved into
|
||||
`GameCult.Caching`, which then needs the whole `MessagePack` package instead of `MessagePack.Annotations`, and every
|
||||
generator consumer still has to suppress an error.
|
||||
- A4. `[assembly: MessagePackAssumedFormattable(typeof(CultRecordRef<>))]` in the consumer assembly fixes G, H and B
|
||||
with no library change (job2 `base@ASSUMED`). The generator stops collecting `CultRecordRef<T>`; the generated
|
||||
document formatter asks the resolver, and `CultDocumentResolver` serves the ref. This is the attribute MessagePack
|
||||
documents for "a formatter added through a custom resolver at runtime". The same attribute as an MSBuild
|
||||
`AssemblyAttribute` item in the consumer project (`_Parameter1_IsLiteral`), which is what a package's
|
||||
`buildTransitive` targets injects, does the same (job3 MG, MH, MB). The attribute declared in the library assembly
|
||||
does not reach the consumer's generator (job4 LIBATTR: CS0426).
|
||||
- A5. Generated and dynamic formatters write the same bytes. RefDoc (B) payload is `93A172A17081A170CA3F000000` from
|
||||
the Reflection.Emit fallback with no generator (job3 BASE) and from the generated formatter (job3 MB). The document
|
||||
formatter is `GeneratedMessagePackResolver+Probe+RefDocFormatter` (`dynamic=False`); the ref formatter is the
|
||||
library's `CultRecordRefFormatter<PlainDoc>` through `CultDocumentResolver`.
|
||||
- A6. NativeAOT (`PublishAot`, probe assembly rooted, linux-x64): G round-trips (payload, upsert, flush, reopen) with
|
||||
payload `92A172A170`, identical to JIT (job4 G0). B fails, in C2a code, not in the generator path: upsert fails in
|
||||
`CultElementIds.MapEntries`, whose `MakeGenericMethod(key, value)` has no native code for a struct key (job4 B0). With
|
||||
that walk reading the map through non-generic `IDictionary` instead, upsert passes under AOT and JIT is unchanged
|
||||
(job5 B1), but the payload and flush still throw `TargetInvocationException` from a second reflective call on the
|
||||
serialize path. By source read the candidate is `ResolverClaim`'s `GetFormatterMethod.MakeGenericMethod(type)`
|
||||
(`CultDocumentMessagePackSerialization.cs:84`), not proven. Both are runtime reflection over element-id paths, which is
|
||||
AOT cut 3's ground, so cut 1 proves AOT on the member shape and leaves the key shape's AOT round trip to cut 3.
|
||||
- A7. `GameCult.Geometry` already runs the generator (it references `MessagePack` directly) and carries the per-member
|
||||
workaround on three members (`CultGeometryDocuments.cs:164,239,328`). Under A4 those attributes are redundant.
|
||||
|
||||
### Shape the cut lands, and why
|
||||
|
||||
`GameCult.Caching.MessagePack` ships `buildTransitive/GameCult.Caching.MessagePack.targets`, which adds the A4 assembly
|
||||
attribute to every project that consumes the package. NuGet consumers need no line of their own, which is what a
|
||||
consumer expects from a library whose type breaks the generator. Unity consumers and in-repo `ProjectReference`
|
||||
consumers do not get `buildTransitive`: Unity consumers write the one attribute line (README), and in-repo consumers
|
||||
import the targets file, so they exercise the file that ships. The attribute type comes from `MessagePack.Annotations`,
|
||||
which `GameCult.Caching` already brings, so the injection compiles in a consumer with or without the generator. Wire
|
||||
bytes do not change: no formatter changes, only which code path asks for it (A5).
|
||||
|
||||
Rejected: a type-level formatter attribute (A3); excluding or hiding the formatter (A1); upgrading MessagePack (A2);
|
||||
declaring the attribute in the library (A4); keeping per-member attributes, which cannot reach dictionary keys.
|
||||
|
||||
## B. BoundedLeastSquares stopping
|
||||
|
||||
|
|
|
|||
76
docs/cultmesh-garden-path-map.md
Normal file
76
docs/cultmesh-garden-path-map.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# CultMesh garden path: map
|
||||
|
||||
Campaign `cultmesh-garden-path`, stage 1 (server-authoritative garden path for the
|
||||
orbs .io game). Target: `docs/cultmesh-garden-path-target.md` (typed as
|
||||
`cultmesh-garden-path:target:r1`). Rulings, questions and follow-ups are documents
|
||||
in the mind; this page holds the model page, body facts and the stage 1 cut lines.
|
||||
|
||||
Written by Imagination (`imagination-gp-0b`, session `self-2026-10-27-eureka`)
|
||||
against CultLib `d1bf21b3`.
|
||||
|
||||
## Model page
|
||||
|
||||
One row per kind. "Store" says where it lives: a CultCache document in the room's
|
||||
store, or transient per-tick data that never enters a store. Proposed values are
|
||||
marked as such; they are the design this page offers for the cuts, not landed
|
||||
behaviour. A cell that waits on an operator question names it.
|
||||
|
||||
The governing split, from `state-is-cultcache` and the CultCache scope restraint:
|
||||
every value that crosses a boundary is CultCache-encoded (one serialisation model,
|
||||
no JSON), but only state that outlives a tick is a record in a store. Inputs, ticks
|
||||
and snapshots are typed CultNet message payloads, not store records. The store
|
||||
receives at most one write set per tick (question `tick-state-commit`).
|
||||
|
||||
| Kind | Store | Identity (what names it) | Lifecycle (what happens to it) | Authority (who decides; forbidden writers) |
|
||||
|---|---|---|---|---|
|
||||
| **Game definition** (documents, systems, authority declaration, tick rate) | Proposed: one CultCache document per server process (`gamecult.mesh.game_definition`), read-only to everyone but the server's startup. | Game id string from code (`"orbs"`), namespaced by Verse id. The id alone is not injective (two builds share it), so proposed identity is `(game id, definition hash)`, the hash derived from the lowered definition: document schema ids and versions, system ids, order and marks (`[Predicted]`, later `[Adjudicable]`), tick rate, authority declaration. Schema ids (`orbs.player`) are global strings; the game prefix is convention, not enforced (body fact F9). | Created at server startup from code; never revised at runtime; a new build supersedes it with a new hash. Clients carry their own compiled hash (C# in wasm, ruling `browser-sim-runtime`) and are refused at join on mismatch. A declaration a mode cannot honour is refused at startup (`modes-do-not-leak`). Never replayed. | The game's code, through the builder, at startup; the runtime validates. Forbidden: clients, any runtime mutation, Odin (Odin may read and index it, never write it). |
|
||||
| **Game document** (Player, Pellet, Shot) | CultCache documents in the room store. | `(schema id, record key)`. Today the key is a free string the caller picks; `[CultIndex] Id` in the illustration is an index, not the key. Proposed: the runtime mints keys. Player key derives from the session principal (one Player per principal per room). Spawned documents (Shot, Pellet) key on `(spawner key, tick, spawn ordinal within that tick)`, so a client's predicted spawn and the server's authoritative spawn derive the same key and reconcile instead of duplicating. That is injective as long as spawn order within a system step is deterministic for the predicting client, which the predicted system's own code gives under one runtime (C# both sides). The developer's `Id` field becomes redundant; whether to keep it as a mirror of the key is an API call for the cut, not an operator fork. | Created by a system during a tick, or by the join system (Player). Revised at most once per tick, in that tick's commit. Deleted by a system (pellet eaten) or by the game's leave system after a session ends. Across reconnect within the grace window the Player record is unchanged; only the session's connection binding moves. Across a server restart: waits on `room-durability`. Replayed only on the client, during reconciliation; the server never replays in stage 1. | The server tick runner on the room's primary is the one writer of committed state, and systems write only through the tick's working set. Forbidden writers: client document puts through `CultNetDatabaseServer.HandlePutAsync` (today accepted from any peer, F1); `PutPredictedAsync` and `CultMeshGameSession.PredictAsync` on any networked store; game code calling database APIs; the simulation-fact committer (F7). |
|
||||
| **Player input** | Transient. A CultCache-encoded payload on a CultNet message; never a store record in stage 1. | `(session principal, input sequence)`, the sequence monotonic per session; each input also carries the tick it targets. Namespace: the room. Injective given the principal. The input type is a schema'd CultCache type, so the schema catalog publishes it like any document. | Sampled each client tick (devices read by the TS shell, handed to wasm); sent with the last few unacknowledged inputs repeated, so one loss is absorbed; buffered by the server per session against its target tick; consumed exactly once by the matching tick (proposed policy for late inputs: applied to the next tick and counted); acknowledged in the snapshot as "last processed sequence"; kept by the client until acknowledged, for replay; then discarded. Stage 4 changes this: inputs become signed, hash-chained and retained for a dispute window. | The client's principal authors the content; the tick runner decides which tick consumes it. Forbidden: server code altering input content; other clients; any input that arrives without a bound session. |
|
||||
| **Tick** | Transient counter; proposed: the last committed tick is written into the room record in each tick's batch, so a snapshot or checkpoint knows it. | `(room id, room epoch, tick number)`, the number starting at 0 when the room epoch starts. Injective with the epoch. | Advanced only by the server tick runner at the declared rate (waits on `tick-model`); never revised. The client estimates the server tick from snapshots and runs its predicted tick ahead by its round trip plus a buffer; on reconnect it re-syncs from the next full snapshot. | Server tick runner. Forbidden: client clocks, transport arrival time, wall-clock reads inside systems (systems receive the tick, not the time). Today no tick scheduler exists anywhere in the C# body (F6). |
|
||||
| **Predicted state and reconciliation** | Client-side only. Where its documents live in the browser waits on `browser-client-store`. | Same `(schema id, record key)` as the authoritative document, plus the client tick it was predicted for. | Each client tick: predicted systems run over owned documents and the client's own spawns. On a snapshot for tick T acknowledging input sequence S: predicted documents are replaced by the authoritative ones at T, then inputs after S are replayed through the predicted systems up to the current client tick; predicted spawns are matched by derived key, and an unmatched one is removed once the acknowledgement passes its tick. Today `Reconciled` means "the authoritative put replaced the predicted copy" with no replay (F3); the replay loop does not exist. | Predicted state owns nothing outside the client. It is derived from the last authoritative snapshot plus unacknowledged inputs plus the predicted systems. The snapshot owns truth. Forbidden: sending predicted documents to the server; `CultNetClientAuthorityScope` in server mode (F4); the renderer writing. |
|
||||
| **Session and the player's identity** | Proposed: session membership as a small CultCache document in the room store (`gamecult.mesh.game_session`, low churn, readable by systems and present in snapshots). The connection handle is transient process state. | Principal: waits on `player-identity`. Connection: a server-minted id per transport connection (ephemeral). Logical session: `(principal, room)`, bound across reconnects. Today the client's identity is a self-asserted `runtimeId` string (browser client options; `SourceRuntimeId` on operation envelopes); CultMesh's session identity proves the server to the client and not the reverse (F5). **This cell is empty in the body: there is no authenticated client principal.** | Connect, then join (definition hash check, principal proof), then the session record is created and the join system spawns the Player. Disconnect suspends the session for a grace window; resume binds a new connection to the same session; expiry ends the session, systems see the leave, and the game's leave system removes the Player. On server restart sessions end (unless `room-durability` says otherwise). | The server's join and leave handler on the room primary decides membership; the principal proves itself (how depends on `player-identity`). Forbidden: a client-asserted `runtimeId` as identity; transport peer address; peer catalogs. |
|
||||
| **Authority declaration and the lease it lowers to** | Declaration: inside the game definition. Lease: proposed as a CultCache document (`gamecult.mesh.authority_lease`); today a plain class in an in-memory dictionary (F2). | Declaration: per document type, `(writer, checker, arbiter)`. Lease: `LeaseId`, carrying Verse, peer, roles, shard ids, issuer, validity window, epoch and signature (`CultMeshAuthorityLease`). Namespace: the Verse. | Declaration fixed per build. At startup `Authority.Server` lowers to: the server holds a `ShardPrimary` lease on the room shard, and clients hold no write scope at all. Renewed before expiry; revocation or expiry stops the tick runner from committing; a new primary takes a new epoch. Proposed issuer in stage 1: the server's own deployment key, which clients pin through the browser trust policy, so an indie deployment needs no Odin. Odin-issued leases stay possible. | The game definition decides the declaration; the issuer issues leases; `CultMeshAuthorityResolver` decides whether a peer holds a role on a shard. Today the database's write gate (`CultNetShardDescriptor.IsPrimary`, `CultNetClientAuthorityScope`, both set from options) never consults the resolver: two authority structures (F2, F4). Proposed: the write gate is derived from lease resolution. `CultNetClientAuthorityScope` is no longer an owner; it is derived from the declaration, and in stage 1 it is empty. |
|
||||
| **Shard / room** (stage 1: one) | `CultNetShardDescriptor` (configuration); the room's state is its CultCache store. | Room id `(Verse, game id, room name)`, for example `orbs/main`, carried as the shard id plus the shard epoch. | Created at server start (epoch 1; on restart a new epoch, or a resumed one if `room-durability` says so); never moved in stage 1; ended at shutdown. Join means "join the one room"; matchmaking is out of scope. Interest management is stage 5 per the target, so in stage 1 every client receives the whole room. | The runtime holding the `ShardPrimary` lease. Forbidden: a second primary; clients. |
|
||||
| **Snapshot sent to clients** | Transient. A CultNet message whose payload is CultCache-encoded documents; never stored. The server keeps per-client baselines in memory for deltas. | `(room, epoch, tick, recipient session, baseline tick)`. | Derived after a tick commit at the send rate; a delta against the recipient's last acknowledged snapshot, or a full snapshot on join and resume; carries the last processed input sequence for that session. Discarded once sent; a baseline is dropped when a newer one is acknowledged. Today the nearest things are per-record change pushes from `CultNetDatabaseSubscriptionServer` (no tick, no acknowledgement, no input sequence) and shard catch-up snapshots in `CultMeshSnapshots.cs` (F8). | The snapshot builder decides nothing; it is derived from the committed tick. Forbidden: the per-record subscription stream acting as a second game replication path. That stream stays for non-game observers. |
|
||||
| **Simulation fact / witness observation** (found) | `CultMeshSimulationFact` is a CultCache document; observations are hub memory. | `(shard, epoch, frame, subject, kind)`. | Stage 1: not used. The target replaces it at stage 4. | Today `CultMeshGameSession` serves observations by default (`ServeSimulationObservations = true`) and commits quorum facts through the primary (F7). In server mode there is no checker, so the observation server and the committer must not be wired. Forbidden writer in stage 1. |
|
||||
| **Shard mutation log entry** (found) | `CultNetDatabase` in-memory `SortedList` per shard, plus an optional durable store. | `(shard id, sequence)`. | Today every primary write appends one entry, and the in-memory list is never trimmed (F10). Per-record writes at tick rate would grow it without bound. In stage 1 the tick's write set either bypasses the per-record log or lands as one bounded entry per tick. Waits on `tick-state-commit` and `room-durability`. | `CultNetDatabase` on the primary. Forbidden: per-system-write appends from the tick runner. |
|
||||
|
||||
### Cells not filled
|
||||
|
||||
- Session principal: no authenticated client identity exists (F5). The question is `player-identity`.
|
||||
- Where the browser's predicted and decoded documents live, and what the renderer reads: `browser-client-store`.
|
||||
- Room state across restarts: `room-durability`.
|
||||
- Tick rate model: `tick-model`.
|
||||
- How a tick reaches the store: `tick-state-commit`.
|
||||
- Stage 1 transport: `browser-transport`.
|
||||
- Unknown and not probed here: whether `CultCacheBatch` commit cost holds at tick rate for about 50 players plus pellets. The first cut's measuring probe should include it.
|
||||
|
||||
## Body facts
|
||||
|
||||
All are source reads at CultLib `d1bf21b3` on 2026-10-27 by `imagination-gp-0b`. No
|
||||
runtime probe was run in this pass. Each fact names the read that would falsify it.
|
||||
|
||||
- **F1. The primary applies any peer's document put.** `CultNetDatabaseServer.HandlePutAsync(message, peer)` (`src/GameCult.Networking/CultNetDatabaseServer.cs:307`) calls `ApplyPutAsync(message)` (`CultNetDatabase.cs:1022`), which checks only that this runtime is primary for the shard and the epoch matches (`EnsurePrimary`, `:2003`). `peer` is used only to send errors. Any connected client can overwrite any game document on the server.
|
||||
- **F2. Leases are not typed state and do not gate writes.** `CultMeshAuthorityLease` is a plain class (no `[CultDocument]`); `CultMeshAuthorityLeaseCatalog` is a `Dictionary` (`CultMeshAuthority.cs:242`). The only `[CultDocument]` types in `GameCult.Mesh` are body publication, CDN chunk and manifest, content transfer state, discovery state, entity prefab package and simulation fact. `GameCult.Networking` (the database) references no lease type; the one Networking file naming `Lease` is `CultNetSchemaMessages.cs`.
|
||||
- **F3. Reconciliation is replacement, not replay.** `CultNetDatabase.cs:1247-1252`: a predicted put records a prediction key; the next non-predicting write to that key turns the change into `Reconciled`. No code re-applies inputs after an authoritative state.
|
||||
- **F4. Client write scope is a runtime-id string match from options.** `CultNetClientAuthorityScope.Matches` compares `OwnerRuntimeId` with the database's own `_runtimeId` (`CultNetDatabase.cs:111-153`, `EnsureClientAuthority` `:1934`). It gates local prediction only and has no relation to leases.
|
||||
- **F5. No authenticated client principal.** `CultMeshSessionIdentity.cs` verifies transports for `(verse, authorityRuntimeId, protocol, routeGeneration)`, which proves the server to the client. The browser client's `runtimeId` (`packages/cultmesh-browser/src/index.ts:182`) and the operation envelope's `SourceRuntimeId` (`CultNetOperationServer.cs:39`) are self-asserted.
|
||||
- **F6. No tick scheduler.** A grep of `GameCult.Networking` and `GameCult.Mesh` for tick or timestep names finds none. `Frame` exists only as a witness-asserted number in `CultNetSimulationConsensus` and `CultMeshSimulationFact`.
|
||||
- **F7. The game session serves the witness path by default.** `CultMeshGameSession` constructs `CultNetSimulationObservationServer` when `ServeSimulationObservations` (default `true`) and always constructs a `CultMeshSimulationFactCommitter` (`CultMeshGameSession.cs:70-104`). `PredictAsync` is `PutPredictedAsync` (`:161`).
|
||||
- **F8. The browser can watch and invoke, not put.** The `cultmesh-browser` README says it "does not expose direct document writes". The client offers `leaseRawDocument` (`index.ts:349`) and `invoke` of typed operations (`:370`), over one WebSocket. On C#: `CultNetDatabaseSubscriptionServer` and `CultNetOperationServer` take `ICultNetSchemaServer`, so they run over `CultNetWebSocketSchemaServer`; `CultNetDatabaseServer` (puts) takes the concrete UDP `Server` (`CultNetDatabaseServer.cs:36`). Operations are request and response with receipts and idempotency keys, which is a poor fit for an input stream at tick rate.
|
||||
- **F9. Schema-id namespacing is convention.** `[CultDocument("orbs.player", ...)]` ids are global strings; nothing ties a schema id to a game id.
|
||||
- **F10. The in-memory mutation log is never trimmed.** `RecordMutationLogEntry` (`CultNetDatabase.cs:1610`) inserts into a per-shard `SortedList`; no `Remove` or trim on that list exists in the file. Compaction applies to the durable store and to burned sequences only.
|
||||
- **F11. CultCache has a write-set batch; CultNetDatabase does not expose one.** `CultCacheBatch` with `Commit` and `TryCommit` (`GameCult.Caching/CultCache.cs:1796`, `:2212`) is the 2.0.0 write set (`variants:ruling:write-set-content-version-declared-ownership`). `CultNetDatabase` has no batch method: every write is a per-record door.
|
||||
- **F12. A reflection site on the mesh put path.** `CultMesh.cs:3502` `PutDatabaseDocumentAsync` picks `PutAsync` or `PutPredictedAsync` by `GetMethods` and `MakeGenericMethod`, which matters to the AOT prerequisite (`cultlib-gaps:follow_up:cs-aot-safe-registration`; probe in `research/cultmesh-garden-path/cultcache-aot-probe-2026-10-27.md`).
|
||||
|
||||
## Cut sequence (stage 1)
|
||||
|
||||
One line per cut not yet mapped. None is mapped until the model page has no empty cell.
|
||||
|
||||
- **Wasm runtime measuring probe.** Download size, startup and 60 Hz tick cost of a stub orbs tick under Mono browser-wasm and NetWasm, including `CultCacheBatch` commit cost at tick rate. This is the first cut named by ruling `browser-sim-runtime`, which it can overturn. Its NetWasm and AOT variant waits on `cultlib-gaps:follow_up:cs-aot-safe-registration`.
|
||||
- **Authority collapse.** Leases become CultCache documents, the database write gate derives from lease resolution, the put handler refuses peers without authority (F1), `CultNetClientAuthorityScope` is demoted, and witness services are not wired in server mode (F7). Waits on nothing.
|
||||
- **Game definition and server tick runner.** Builder, document and system registration, definition document and hash, fixed tick, tick-boundary commit. Waits on `tick-model`, `tick-state-commit` and `room-durability`.
|
||||
- **Session and principal.** Join, resume and leave; principal proof; session document. Waits on `player-identity`.
|
||||
- **Input and snapshot path.** Input messages with redundancy, a server input buffer, acknowledgements, delta snapshots. Waits on `browser-transport`.
|
||||
- **Browser client runtime.** Wasm simulation host, TypeScript shell for transport and rendering, prediction with replay reconciliation. Waits on the measuring probe and `browser-client-store`.
|
||||
- **Orbs reference game and garden-path proof.** The game written with no networking code (`garden-path-needs-no-networking`), and a browser client that writes (`browser-is-first-class`). Waits on all of the above.
|
||||
153
docs/cultmesh-garden-path-target.md
Normal file
153
docs/cultmesh-garden-path-target.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# CultMesh garden path: target
|
||||
|
||||
Status: draft target for campaign `cultmesh-garden-path`, 2026-10-27. It holds the
|
||||
ends, not the means. The code below is illustrative: it shows what a developer
|
||||
should have to write, and no name in it is decided.
|
||||
|
||||
## The problem
|
||||
|
||||
Operator, 2026-10-27: "the value is in solving the multiplayer scaling problem
|
||||
for indie developers in an ergonomic fashion. Having one authoritative server is
|
||||
fine, up to a point, but there's no real open source playbook on how to ship an
|
||||
MMO, and existing solutions like SpacetimeDB have a huge ergonomics gap. I want
|
||||
CultMesh to feel like magic: as a garden path, you annotate your domain POCOs and
|
||||
simulation code and declare your authority structure ... and it'll all just work
|
||||
without the developer needing to understand networking deeply."
|
||||
|
||||
Prior art: `research/cultmesh-garden-path/prior-art-2026-10-27.md` (frameworks,
|
||||
ergonomics, scale-out) and `research/witness-authoritative-networking/` (consensus
|
||||
and P2P anti-cheat). What the field shows, in short:
|
||||
|
||||
- One API over several authority modes ships today (Photon Fusion: Server, Host,
|
||||
Shared; Unity NGO: client-server and distributed authority), and in both the
|
||||
modes leak into the developer's code (`IsServer` against `HasAuthority`,
|
||||
features missing in one mode).
|
||||
- SpacetimeDB gives one machine per database. Scaling past it, client
|
||||
prediction, and the reducer-shaped rewrite of game code are left to the
|
||||
developer.
|
||||
- Determinism, where it is required at all, is required of the whole simulation
|
||||
(Croquet/Multisynq, Photon Quantum), and that tax is the main ergonomic cost.
|
||||
- Shipped world-scale designs treat a spatial unit as indivisible and move
|
||||
players between units (EVE, Pax Dei, Folia); nobody publishes an open,
|
||||
end-to-end playbook (Astron, the nearest, is discontinued).
|
||||
|
||||
## What a developer writes (illustrative)
|
||||
|
||||
The reference game is a small browser .io game (operator, 2026-10-27: "the .io
|
||||
game, Aetheria later"): players steer orbs, eat pellets, and shoot.
|
||||
|
||||
```csharp
|
||||
[CultDocument("orbs.player", "orbs.player.v1")]
|
||||
public sealed class Player
|
||||
{
|
||||
[CultIndex] public string Id { get; set; } = "";
|
||||
public Fix2 Position { get; set; }
|
||||
public Fix2 Velocity { get; set; }
|
||||
public int Mass { get; set; }
|
||||
}
|
||||
|
||||
[CultDocument("orbs.shot", "orbs.shot.v1")]
|
||||
public sealed class Shot
|
||||
{
|
||||
[CultIndex] public string Id { get; set; } = "";
|
||||
public string Shooter { get; set; } = "";
|
||||
public Fix2 Origin { get; set; }
|
||||
public Fix2 Direction { get; set; }
|
||||
}
|
||||
|
||||
// Runs every tick. Predicted on the owning client, corrected by authority.
|
||||
public sealed class Movement : CultSystem
|
||||
{
|
||||
[Predicted]
|
||||
public void Step(Player player, PlayerInput input, Tick tick) { /* ... */ }
|
||||
}
|
||||
|
||||
// A non-owner may re-run this to settle a dispute, so it must be
|
||||
// deterministic. The analyzer enforces that on this system only.
|
||||
[Adjudicable]
|
||||
public sealed class Hits : CultSystem
|
||||
{
|
||||
public HitResult Resolve(Shot shot, Rewind<Player> target) { /* ... */ }
|
||||
}
|
||||
|
||||
var game = CultMesh.Game("orbs")
|
||||
.Documents<Player, Pellet, Shot>()
|
||||
.Systems<Movement, Eating, Hits>()
|
||||
.Authority(Authority.Server);
|
||||
// .Authority(Authority.TrustedCoop);
|
||||
// .Authority(Authority.Witnessed(w => w.Challenger(Party.Victim).Quorum(3).Arbiter(Party.Server)));
|
||||
// .Authority(Authority.Regions(r => r.Grid(512).Handoff())); // later: past one server
|
||||
```
|
||||
|
||||
Changing the authority line is the whole migration between modes, unless the new
|
||||
mode re-executes a system on a non-owner and that system is not yet
|
||||
deterministic. Then the analyzer names exactly those systems.
|
||||
|
||||
## Invariants
|
||||
|
||||
- `one-model`: server-authoritative, trusted co-op, witnessed and regioned
|
||||
authority are configurations of one authority declaration, answering per
|
||||
document: who writes, who checks, who arbitrates. No mode has its own API for
|
||||
state, systems or queries.
|
||||
- `modes-do-not-leak`: game code does not branch on the authority mode or ask
|
||||
"am I the server"; a system states what it does and the runtime decides where
|
||||
it runs. Where a mode cannot offer a capability, the declaration is refused at
|
||||
startup with a reason, never degraded silently at runtime.
|
||||
- `determinism-is-per-system`: only code a non-owner may re-execute must be
|
||||
deterministic. That requirement is marked on the system and checked by an
|
||||
analyzer at build time, not by documentation or by desync in production.
|
||||
- `state-is-cultcache`: game state is ordinary CultCache documents; the
|
||||
framework adds no second state model and no JSON on the load-bearing path.
|
||||
- `authority-is-typed-state`: who holds authority over what is CultMesh state
|
||||
(leases, shard primaries), inspectable and revocable, never inferred from
|
||||
transport success or peer catalogs.
|
||||
- `garden-path-needs-no-networking`: the reference game is written without the
|
||||
developer touching sockets, serialisation, tick scheduling, prediction or
|
||||
reconciliation code.
|
||||
- `honest-guarantees`: each mode states its fault model (trusted server, trusted
|
||||
peers, honest-majority sampled witnesses with server arbitration) and its
|
||||
documentation and tests claim nothing past it.
|
||||
- `browser-is-first-class`: the reference game's client runs in a browser and can
|
||||
write (submit inputs, own predicted state), not only watch.
|
||||
- `built-for-load`: what the garden path ships that is database-shaped (the
|
||||
tick commit, the batch door, logs, subscriptions) is architected for
|
||||
unreasonable load from the start (operator 2026-10-27,
|
||||
`cultlib-gaps:ruling:database-shaped-built-for-load`): "if what we're shipping
|
||||
is database shaped, people will use it as such".
|
||||
|
||||
## Sequence (each a campaign or stage of its own; one foundation at a time)
|
||||
|
||||
1. Server-authoritative garden path for the .io game: documents, systems,
|
||||
ticked scheduling, client prediction and reconciliation, browser client that
|
||||
writes. This is the proof of ergonomics.
|
||||
2. Trusted co-op: authority moves to the owning peer; same game code.
|
||||
3. Deterministic dispute kernel (CultMath fixed-point hit tests, cross-runtime
|
||||
vectors) and the `[Adjudicable]` analyzer.
|
||||
4. Witnessed authority: signed, hash-chained inputs; victim challenge; sampled
|
||||
network-near witnesses; server arbitration; browser-to-browser channels.
|
||||
Demonstration: a cheating client caught and corrected.
|
||||
5. Regions: spatial authority handoff and interest management past one server.
|
||||
6. Aetheria on the same declaration.
|
||||
|
||||
## Not in scope
|
||||
|
||||
- Aetheria until stage 6. Eve and Gjallar (deprecated).
|
||||
- A new CultCache storage engine (the paged single-file store stays postponed,
|
||||
`variants:ruling:page-tree-store-postponed`). The garden path fills gaps in
|
||||
their owners, built for load.
|
||||
- Asynchronous Byzantine total ordering, zero-knowledge proofs, and chains of
|
||||
any kind.
|
||||
- The existing witness aggregator (`CultNetSimulationConsensus`, unsigned and
|
||||
weighted by observed votes) as a base for stage 4: it is the trusted co-op
|
||||
first stage by design and will be replaced, not extended.
|
||||
|
||||
## Open forks (to the Fork Desk, each with prior art first)
|
||||
|
||||
1. Where simulation code runs in the browser: C# compiled to WebAssembly (one
|
||||
implementation everywhere, which also shrinks the determinism problem to
|
||||
fixed-point maths, at the cost of runtime download size), C# on the server
|
||||
with TypeScript systems in the browser (two implementations of each predicted
|
||||
system), or another arrangement.
|
||||
2. Server transport for the browser: WebSocket as today, or WebTransport
|
||||
(QUIC datagrams) on the provider side.
|
||||
3. Tick model: fixed tick with a declared rate, or per-system rates.
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
Serves the C2a fork on finding variants:finding:cut-land-c2a-gate.s1.reflist-record-read-only-at-head (campaign variants), 2026-10-23.
|
||||
|
||||
# Prior art: read with an old writer schema, then write back
|
||||
|
||||
Facts only. Pages were fetched 2026-10-23 through a summarising fetcher; "not stated" means the fetched page did not say it, not that no source does. Items marked (memory) are my background knowledge, not verified from a fetched page.
|
||||
|
||||
## 1. Apache Avro
|
||||
- Source: https://avro.apache.org/docs/current/specification/ (Schema Resolution; summarised via apache/avro doc source), Java `DataFileWriter` javadoc (1.11.1).
|
||||
- Model: the writer schema travels with the data. The reader resolves it against its own reader schema. Resolution is a read-time projection; the spec defines no write-back.
|
||||
- Data loss on projection: "the writer's value for that field is ignored" when the reader schema lacks the field. A reader-only field takes its default, or the read errors if it has none.
|
||||
- Write-back: the spec states no rule. Object container files store one schema in the header ("Each datum in a file is of the same schema"). `appendTo` is documented only as "Open a writer appending to an existing file"; it does not say which schema the append uses. (memory: Java `appendTo` reuses the file's header schema, so an append never restamps.)
|
||||
- Stated reason for the read-time design: readers and writers evolve separately (fields added or removed in a different software version).
|
||||
|
||||
## 2. Protocol Buffers (proto3)
|
||||
- Source: https://protobuf.dev/programming-guides/proto3/ (Unknown Fields).
|
||||
- On write: unknown fields are "included during parsing and in the serialized output" (matches proto2). Round-trip keeps fields the current type does not know.
|
||||
- Loss cases the page lists: serializing to JSON, and copying a message field by field into a new message (it recommends `CopyFrom`/`MergeFrom`).
|
||||
- No schema id or version is written. The wire format carries only field numbers, so "restamp" has no meaning here.
|
||||
- Not stated: behaviour in versions before 3.5 (memory: proto3 dropped unknown fields before 3.5, then reversed). The Java reference page says nothing on preservation.
|
||||
- Stated reason: preserve data across a mixed-version fleet.
|
||||
|
||||
## 3. Confluent Schema Registry
|
||||
- Source: https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html
|
||||
- BACKWARD (default): consumers on the new schema read data written with the new or the previous version. FORWARD: data written with the new schema is readable by consumers on the old one. FULL: both.
|
||||
- Write: "Each schema version gets a unique ID and an incremented version number." A producer writes under the schema it holds (its own current version). The id is stamped per message at serialisation, from the producer's schema.
|
||||
- Upgrade order is how the rules prevent loss. BACKWARD means upgrade consumers before producers. FORWARD means upgrade producers first, and keep old-schema data away from consumers until they are upgraded.
|
||||
- Not stated on the page: what a consume-modify-produce app writes. (memory: it re-serialises under its own latest schema, so fields it does not model are lost unless a compatibility rule kept them.)
|
||||
- The registry gates registration. It does not gate individual rewrites.
|
||||
|
||||
## 4. Document DBs and ORMs with stored versions
|
||||
### MongoDB schema-versioning pattern
|
||||
- Sources: https://www.mongodb.com/blog/post/building-with-patterns-the-schema-versioning-pattern ; https://www.mongodb.com/docs/manual/data-modeling/design-patterns/data-versioning/schema-versioning/
|
||||
- Documents carry `schema_version` (absent means 1). The app keeps a handler per version and writes new documents at the current version.
|
||||
- Migration strategy is the app's choice: migrate all, migrate "when accessed" (lazy), or never.
|
||||
- Not stated: whether an update of an old document restamps it. The manual's update example branches on `schemaVersion` and writes into each version's own shape. It notes its `else: null` branch adds stray null fields to the other shape.
|
||||
- Stated reasons: no downtime, failed migrations easier to handle, timing flexibility. Cost: handler code per live version.
|
||||
|
||||
### Realm (Atlas Device SDKs, now deprecated)
|
||||
- Source: https://www.mongodb.com/docs/atlas/device-sdks/sdk/swift/model-data/change-an-object-model.md
|
||||
- Per-file schema version. A changed schema needs an incremented `schemaVersion`, plus a `migrationBlock` for non-trivial changes. The file is migrated whole at open, before any read or write, and Realm "will throw an error" if the version is lower or the same but the schema differs.
|
||||
- No lazy per-object restamp. Writes happen only on a migrated file.
|
||||
- Data loss: the manual says "Deleting a property will not impact existing objects", i.e. removal is automatic and needs no developer step. Required new properties and renames need a manual block. (memory: removed-property values are dropped from the file.)
|
||||
|
||||
### Core Data
|
||||
- Source: web search results only (Apple forum threads 745114 and 785427, WWDC22-10120, "staged migrations" doc). The Apple docs URL I tried returned 404.
|
||||
- A store whose model hash differs from the current model fails to open with an incompatible-version error. Opening migrates the whole store when `NSMigratePersistentStoresAutomaticallyOption` and `NSInferMappingModelAutomaticallyOption` are set. Lightweight migration covers only obvious patterns (add entity, remove relationship, required to optional, rename). Anything else needs a staged or custom migration.
|
||||
- Migration is store-wide at open. It does not happen per record on write.
|
||||
|
||||
### EF Core
|
||||
- Not fetched. (memory: no per-row schema stamp. Migrations are explicit, ordered DDL, tracked in a `__EFMigrationsHistory` table.)
|
||||
|
||||
## 5. Old-schema data held read-only until explicit migration
|
||||
### Elasticsearch archive indices
|
||||
- Sources: https://www.elastic.co/docs/deploy-manage/upgrade/deployment-or-cluster/reading-indices-from-older-elasticsearch-versions ; https://www.elastic.co/guide/en/elasticsearch/reference/current/archive-indices.html ; 8.0 migration notes (7.0+ indices readable, 6.x or earlier must be reindexed).
|
||||
- Indices from an older major are readable through search and field-caps. "They do not support the Get API or any write APIs."
|
||||
- The route to a writable copy is reindex into a new index that is "fully compatible with the current Elasticsearch version". `_source` is readable, so no data is lost on reindex.
|
||||
- Stated reason: the page does not say why they are read-only. It describes archive access as "slower read-only access", with limited query capability. Some legacy mappings cannot be imported (the original is kept in `_meta`), and that is the one place the page shows a lossy translation.
|
||||
- Third-party (Search Guard blog, unverified against Elastic): in 9.x a plain `index.blocks.write` does not satisfy the boot check; `index.verified_read_only` is needed.
|
||||
- This is the closest match to option (b) in the fetched set: an old format, readable but not writable, left until the owner reindexes it.
|
||||
|
||||
## Where restamp-on-write loses data (from the sources above)
|
||||
- Avro: a field present in the writer schema and absent from the reader schema is dropped at read, "ignored". Any rewrite from the resolved value would omit it.
|
||||
- Protobuf: unknown fields survive a binary round trip. They are lost via JSON, or when copied field by field.
|
||||
- Realm: removed properties are deleted by the automatic migration.
|
||||
- MongoDB: a handler that writes only the current shape can lose or relocate old-shape fields (the manual's example adds nulls).
|
||||
- Confluent: loss is prevented by compatibility rules at registration time, not at each write.
|
||||
|
||||
## Pattern across the set
|
||||
- No fetched system restamps a single record on write when the record's writer schema differs from the application's type, on an app-visible, per-record basis.
|
||||
- Avro, protobuf and Confluent carry the schema per file or message.
|
||||
- Realm, Core Data and Elasticsearch move or block the whole store or index at open.
|
||||
- MongoDB leaves it to the app.
|
||||
- Whole-store migration at open is the usual explicit step (Realm, Core Data). Elasticsearch is the one case of read-only-until-reindex.
|
||||
- Not found: a system that declares per-schema-id compatibility, as in option (b). Avro schema resolution and Confluent compatibility rules are the nearest analogues. Both are read-side or registration-side declarations.
|
||||
53
docs/research/2026-10-23-store-lock-user-code-prior-art.md
Normal file
53
docs/research/2026-10-23-store-lock-user-code-prior-art.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
Serves question variants:question:store-hold-user-code (campaign variants), 2026-10-23.
|
||||
|
||||
# Prior-art check: user code under a lock
|
||||
|
||||
Facts and pointers only. Each source was fetched or searched this session; what was not reachable is marked.
|
||||
|
||||
## 1. Goetz et al., Java Concurrency in Practice (Addison-Wesley, 2006), ch. 10 "Avoiding Liveness Hazards"
|
||||
Status: EXISTS, SECTION NUMBER CORRECTED.
|
||||
- "Open Calls" is **10.1.4**, not 10.1.3. 10.1.3 is "Deadlocks Between Cooperating Objects"; 10.1.2 is "Dynamic Lock Order Deadlocks"; 10.1.5 is "Resource Deadlocks". Source: text extracted from a PDF copy of the book (its TOC lists Listing 10.6 "Using Open Calls to Avoiding Deadlock Between Cooperating Objects", p. 133). The copy is an unofficial one at library.megu.edu.ua; a publisher link was not checked.
|
||||
- (a) 10.1.3 gives the warning sign: an "alien method" (defined p. 40 as a method whose behavior the calling class does not fully specify) called while holding a lock. Paraphrase: such a call may take other locks (deadlock risk) or block a long time, stalling threads that need the lock you hold. 10.1.4 defines an open call as a call made with no locks held (credited to Lea, CPJ 2.4.1.3) and says programs restricted to open calls are far easier to analyze for deadlock-freedom than ones that allow alien calls under lock.
|
||||
- Cost it names: moving a call out of a synchronized block makes an atomic operation non-atomic (its getImage example stops being a snapshot). Where atomicity is needed it suggests structuring so only one thread can run the code path after the open call (service shutdown example).
|
||||
- (b), (c): not addressed in this section.
|
||||
- Pointer: JCIP 10.1.3 and 10.1.4, pp. ~132-134 (chapter 10 starts p. 128).
|
||||
|
||||
## 2. Sutter, "Effective Concurrency: Avoid Calling Unknown Code While Inside a Critical Section"
|
||||
Status: VERIFIED, with date caveat.
|
||||
- Dr. Dobb's column in the "Effective Concurrency" series. Sutter's blog teaser is dated 2007-11-06: https://herbsutter.com/2007/11/06/effective-concurrency-avoid-calling-unknown-code-while-inside-a-critical-section/ . Original link ddj.com/architect/202802983; Dr. Dobb's mirror https://drdobbs.com/cpp/avoid-calling-unknown-code-while-inside/202802983 (4 pages). The Dr. Dobb's issue date was not shown in anything fetched; the blog date is the only date confirmed. www.drdobbs.com did not resolve from this machine, so the column body comes from search-result summaries and the blog teaser, not a full first-hand read.
|
||||
- (a) Core claim: locks do not compose; two separately correct modules can deadlock when combined. Trigger: caller inside a critical section, callee enters another critical section or blocks, another thread can take the same two in the opposite order. "Unknown code" listed as: library functions, plug-ins, callbacks/function pointers, virtual methods, generic types.
|
||||
- Mitigations listed: move the call outside the critical section; pass copies instead of shared data; narrow the critical section (finer locks, reader-writer); require callees not to block (documentation/discipline only).
|
||||
- (b), (c): not addressed.
|
||||
|
||||
## 3. Linux lockdep
|
||||
Status: VERIFIED, with a nuance.
|
||||
- Source: kernel docs "Runtime locking correctness validator", https://docs.kernel.org/locking/lockdep-design.html .
|
||||
- (b) Lockdep tracks lock classes, not instances, and records dependencies between classes as sequences are observed. It reports a circular order as a potential deadlock even if the deadlock never happened. Its "Proof of 100% correctness" section says that once each simple single-task locking sequence has occurred at least once, no timing of them can deadlock. Each new lock chain is validated once and hashed; repeats skip validation.
|
||||
- Nuance: it flags the dependency edge that closes a cycle, so both orders must have been exercised at some point (possibly by different, non-concurrent runs). A lone nesting is flagged only if it breaks a single-lock rule (IRQ-safe vs unsafe, same-class recursion). "Refuses on first sight" fits the closing edge or a single-lock rule violation only. It warns; it does not block the acquisition.
|
||||
|
||||
## 4. ConcurrentHashMap re-check after locking
|
||||
Status: VERIFIED.
|
||||
- Source: OpenJDK java/util/concurrent/ConcurrentHashMap.java, `putVal` and `computeIfAbsent`: head node `f` is read unlocked, `synchronized (f)` is taken, then `tabAt(tab, i) == f` is re-checked; on failure nothing is done and the loop retries. The overview comment gives the reason: the locked node can be replaced, so first-in-bin must be confirmed after locking. (Only the first 100k of 269k chars were read; both methods were reported found there.) Empty-bin `computeIfAbsent` uses a ReservationNode plus CAS instead.
|
||||
- (c) This is the pattern: choose the lock from an unlocked read, validate the read after acquiring.
|
||||
|
||||
## 5. LMDB
|
||||
Status: VERIFIED (paraphrase). Not about alien code or lock order.
|
||||
- Source: lmdb.h, `mdb_txn_begin` and `mdb_env_open` (https://github.com/LMDB/lmdb, libraries/liblmdb/lmdb.h). A thread may hold one transaction at a time (plus children); a transaction and its cursors belong to one thread; one write transaction at a time, so writers cannot deadlock each other; MDB_NOTLS ties reader slots to the txn object instead of the thread, and the application must then serialize writers itself because write locking is unaware of user threads.
|
||||
- Relevance: "one hold per thread" as a deliberate rule, parallel to CultCache's one-cache-gate-per-thread.
|
||||
|
||||
## 6. SQLite btmutex.c
|
||||
Status: VERIFIED (summary of trunk source).
|
||||
- Source: https://github.com/sqlite/sqlite/blob/master/src/btmutex.c (read via raw.githubusercontent.com; sqlite.org served a bot check).
|
||||
- (b) Prevention by ordering, not detection: sharable B-trees on a connection sit in a list sorted by BtShared address and are always locked ascending (`sqlite3BtreeEnterAll`). `sqlite3BtreeEnter` try-locks first; on failure it releases later-ordered locks, waits, then reacquires in order. `wantToLock` is a count that makes a non-recursive mutex behave recursively.
|
||||
- No alien-code or callback discussion in these comments.
|
||||
|
||||
## Counter-positions and how they stay safe
|
||||
- Callbacks under lock, documented: Java `ConcurrentHashMap.compute`/`computeIfAbsent`. Javadoc (Java 21): the function should be short and simple because other threads' updates may block meanwhile, and must not modify the map. The javadoc does not say "internal lock"; that it runs under the bin lock is inferred from the source in item 4. Safety rests on documented restrictions, not enforcement.
|
||||
- Opposite choice: .NET `ConcurrentDictionary.GetOrAdd(key, valueFactory)`. Writes use fine-grained locks but valueFactory runs outside them, "to avoid the problems that can arise from executing unknown code under a lock" (doc remarks). Price: not atomic; the factory may run more than once; a recheck decides the winner (another thread's insert wins). https://learn.microsoft.com/en-us/dotnet/api/system.collections.concurrent.concurrentdictionary-2.getoradd . Same shape as option `encode-first` (user code outside the lock, then recheck and commit) and as item 4.
|
||||
- JCIP and Sutter both name the cost of leaving the lock: lost atomicity. Sutter also offers "pass copies" and "callee must not block" as alternatives to leaving.
|
||||
- Not researched (no source fetched): Linux notifier chains, Java `synchronized` event listeners, other systems that call back under lock by design. No verified citation for them.
|
||||
|
||||
## Unverified / not done
|
||||
- JCIP: section numbers and text come from one PDF copy, not the publisher edition.
|
||||
- Sutter: full column body not read first-hand.
|
||||
- No Discord or voidbot content read.
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
# Prior art: a process-global default registry deciding write ownership
|
||||
|
||||
Serves `variants:question:registryless-write-reads-shared` (campaign `variants`). Eyes pass, session `self-2026-10-27b-eureka`, 2026-10-27. Facts only; items marked "summary" rest on search summaries not opened.
|
||||
|
||||
# Prior art: process-global type registries and write/overwrite ownership
|
||||
|
||||
Fork: `variants:question:registryless-write-reads-shared` (viewed in full via eureka-state, status InForce, options `shared-decides` / `written-type-only`, recommended `shared-decides` at ~65%).
|
||||
Facts only. No conclusion. Date 2026-10-27.
|
||||
|
||||
Method note: web pages were read through WebFetch (a small model summarises the page) and WebSearch summaries. Quotes below are what those tools returned; where only a summary was available it is marked "summary". Nothing was run.
|
||||
|
||||
## A. Local mechanics (CultLib, read-only, `C:\cl-wt\c2a-land\src\GameCult.Caching\CultCache.cs`)
|
||||
|
||||
- `CultDocumentRegistry.Shared` is `Lazy<CultDocumentRegistry>` (line 639-645). Its public constructor calls `Refresh()` (647-650), which scans `ReflectionExtensions.GetAttributedDocumentTypes()` (688; defined in `ReflectionExtensions.cs:10`) and rebuilds the indexes under `_registrationGate`. So Shared's initial content is whatever `[CultDocument]` types are in loaded assemblies at first touch of `Shared`.
|
||||
- `GetRequired(Type)` (697-711) registers an unregistered `[CultDocument]` type on demand. So Shared also grows whenever any caller asks for a type. `Refresh()` is public and rebuilds the index from currently loaded assemblies.
|
||||
- `ForTypes(IEnumerable<Type>)` (658-669) builds a registry with `discoverLoadedDocuments:false`, containing only the given types (plus those `GetRequired` pulls in).
|
||||
- `CultCache(registry = null, ...)` sets `_registry = registry ?? CultDocumentRegistry.Shared` (2202-2205).
|
||||
- `WriteDocument<TDocument>(key, doc, storedAt, registry = null)` (4515-4545): `resolvedRegistry = registry ?? Shared`; `catalogEntry = resolvedRegistry.GetRequired<TDocument>().ToCatalogEntry()`; then `Cacheless(resolvedRegistry, ...)` lends that registry to the store for the call. XML doc (4510) says Shared "can only refuse more".
|
||||
- `WriteRecord(..., registry = null)` (4491-4497): XML doc (4489) says that with registry omitted "it decides nothing for copied records and the one entry is the only claimant".
|
||||
- `OwnsOnDisk(upserts)` (4741-4759): when the store is cacheless and upserts are given, ownership of an on-disk record is decided by `CultDocumentRegistry.Owns(id, catalog, declared, new CatalogEntryLookup(writes, AttachedRegistry), ..., claimant => writes.Any(entry => entry.SchemaId == claimant.SchemaId))`. The comment (4731-4735, 4738-4740) says written entries shadow the lent registry's type for their ids, the registry adds other claimants, and the record is owned only when the claimant is a written entry, "a registry can only refuse more". `CatalogEntryLookup` ctor at 604 takes `(entries, CultDocumentRegistry? others)`.
|
||||
- Net: with `registry == null` the lent registry's claimant set is Shared's current contents, which depend on assemblies loaded and on every prior `GetRequired` call in the process.
|
||||
|
||||
## B. Q1. Process-global, lazily filled registries in established libraries
|
||||
|
||||
### MongoDB C# driver, `BsonClassMap`
|
||||
- Global: yes. `BsonClassMap.RegisterClassMap` is process-wide static; unregistered types are auto-mapped on first use. Docs: "You must register a class map _before_ it's needed in your code." and "We recommend registering class maps before initializing a connection with MongoDB." https://www.mongodb.com/docs/drivers/csharp/current/serialization/class-mapping/ (WebFetch; the page itself says nothing on freezing or late-registration errors).
|
||||
- Behaviour depends on what has been touched: EF Core provider issue EF-88 reports "Unknown discriminator value 'List`1'" when the first operation after startup is a read; reporter links it to a class map that is created only on write. Status Closed/Fixed in 8.0.0; page has no maintainer comment. https://jira.mongodb.org/browse/EF-88 (WebFetch). Reporter says explicit `RegisterClassMap` at startup did not fix it for them.
|
||||
- A MongoDB community answer (via search summary, not opened): discriminator mappings persist once registered, so a read after an insert works; fix offered is `[BsonKnownTypes]`. https://mongodb.com/community/forums/t/c-driver-why-is-bsonknowntypes-attribute-needed-for-polymorphism/2183 (summary only).
|
||||
- Register-once contract: CSHARP-4424 states the three `RegisterClassMap` methods are "meant to only be called once for a given type"; requested because several libraries share data models and do not control init order. Closed Done, fix version 2.19.0; the page does not say which API shipped. https://jira.mongodb.org/browse/CSHARP-4424 (WebFetch). Community workaround named in search summary: `BsonClassMap.IsClassMapRegistered`.
|
||||
- CSHARP-1879 (search summary): `RegisterClassMap` accepted an unfrozen map and failed later on use; fixed 2.4.2. https://jira.mongodb.com/browse/CSHARP-1879 (not opened).
|
||||
- CSHARP-5597 (search summary): `BsonSerializer.RegisterDiscriminator` has no effect; discriminator dictionary populated at class-map registration. https://jira.mongodb.org/browse/CSHARP-5597 (not opened).
|
||||
- Polymorphic docs: "You must specify hierarchical discriminators in root-to-leaf order." and the relationship of all classes in a hierarchy must be documented before deserialization. https://www.mongodb.com/docs/drivers/csharp/current/serialization/polymorphic-objects/ (WebFetch).
|
||||
- Particular docs report an order-dependent GuidSerializer failure ("incorrect order of class mappings"). https://docs.particular.net/persistence/upgrades/mongodb-4to5 (search summary, not opened).
|
||||
- Did they move away: not found. Class maps remain global static; mitigation is documentation plus the IfNotRegistered API.
|
||||
|
||||
### protobuf-net `RuntimeTypeModel.Default`
|
||||
- Global: yes. README: "The Serializer.* methods are basically just shortcuts to RuntimeTypeModel.Default.*". https://cdn.jsdelivr.net/gh/protobuf-net/protobuf-net@main/README.md (WebFetch).
|
||||
- Lazy fill, freezing, order-dependent bugs: NOT verified. README is silent; a direct docs fetch (docs.protobuf-net.dev/runtime) was redirected and not followed. No issue found.
|
||||
|
||||
### MessagePack-CSharp
|
||||
- Global mutable default exists: `MessagePackSerializer.DefaultOptions`. README (WebFetch): labelled "Global state; Not recommended when writing library code." Safe if you control all MessagePack code; libraries should pass options explicitly because others "may change this static property." Libraries "must not use StaticCompositeResolver - as it is global state". https://github.com/MessagePack-CSharp/MessagePack-CSharp/blob/master/README.md
|
||||
- Resolvers are an ordered list (`CompositeResolver`): order matters by construction.
|
||||
- Whether it moved away from the global: not found.
|
||||
|
||||
### System.Text.Json
|
||||
- `JsonSerializerOptions.Default`: "Gets a read-only, singleton instance"; remarks give a performance reason: shared instance avoids "redundant recomputation of converters". https://learn.microsoft.com/dotnet/api/system.text.json.jsonserializeroptions.default (WebFetch). The page states no rationale about cross-component interference; any such reading is inference. Design-discussion issue in dotnet/runtime: searched, not found. Whether Default was ever mutable: not verified.
|
||||
- Type-info resolvers: not researched beyond the above.
|
||||
|
||||
### Json.NET
|
||||
- `TypeNameHandling` is a security concern, not an order concern: CA2326 says "An attacker could modify the serialized data to include unexpected types"; mitigation is a custom `ISerializationBinder` restricting types. https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2326 (WebFetch).
|
||||
- `JsonConvert.DefaultSettings` global behaviour/guidance: the Newtonsoft page fetched says nothing about global effects. Not verified.
|
||||
|
||||
### Orleans
|
||||
- Serializer discovery is build-time source generation (`[GenerateSerializer]`, `[Id]`), not a runtime scan; the docs' older code-generation section lists scan of assemblies referencing the core library plus `KnownType`/`KnownAssembly` attributes. Aliases: "Type aliases are globally scoped, and you cannot have two aliases with the same value in an application." https://learn.microsoft.com/en-us/dotnet/orleans/host/configuration-guide/serialization (saved locally: `C:\Users\Meta\.claude\projects\F--Projects-CultLib\0840c7c7-0e31-410e-9012-afb79c3c0742\tool-results\toolu_0148m7no1AbrJzWmNLicn6N9.txt`, lines 134, 839-841).
|
||||
- Type manifest / codec registry runtime behaviour, issues: not researched.
|
||||
|
||||
### EF Core
|
||||
- Model is built per `DbContext` type and cached (IMemoryCache; "the built model has a cache size of 100"); initialised at first operation: "just creating a DbContext instance does not cause the EF model to be initialized." https://learn.microsoft.com/en-us/ef/core/performance/advanced-performance-topics
|
||||
- Assembly scan: `ApplyConfigurationsFromAssembly` — "The order in which the configurations will be applied is undefined, therefore this method should only be used when the order doesn't matter." https://learn.microsoft.com/en-us/ef/core/modeling/ ; and "`EntityTypeConfigurationAttribute` types will not be automatically discovered in an assembly."
|
||||
- So EF's registry is per context type, not process-global; assembly scan is opt-in and documented as order-undefined.
|
||||
|
||||
### Marten
|
||||
- Storage is a table and upsert function per document type, alias `mt_doc_<alias>`; alias collisions are resolved with `DocumentAlias`. https://martendb.io/schema/storage (WebFetch). Hierarchies share one table via `AddSubClass<T>()`; subclass docs are "stored as the parent type" in the identity map. https://www.martendb.io/documents/hierarchies (WebFetch). Schema is configured in `StoreOptions` per store, not a process-global.
|
||||
|
||||
### Java ServiceLoader
|
||||
- "Service providers are loaded and instantiated lazily, that is, on demand." Providers on the class path load in the order `getResources` finds config files; "The ordering of modules in a module layer is not defined." "Instances of this class are not safe for use by multiple concurrent threads." Caches cleared by `reload`. https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/ServiceLoader.html (WebFetch). Visibility is class-loader dependent ("must be visible to the class loader").
|
||||
|
||||
## C. Q2. Write/overwrite decisions against an existing record of another type
|
||||
|
||||
- RavenDB: REST put docs, request body section: updates must "include its collection name in the metadata" and "A document's collection cannot be modified." Server error code for mismatch: the first fetch summary said HTTP 500; not confirmed on a second fetch. https://docs.ravendb.net/7.1/client-api/rest-api/document-commands/put-documents (WebFetch twice). Ayende, RavenDB 4.0 storage: document key namespace is global, same key cannot exist in two collections (summary). https://ayende.com/blog/173954/the-design-of-ravendb-4-0-physically-segregating-collections The check is against the collection named in the write (derived from the writer's type by the client), not a registry; the "client maps entity type to collection" part is inference from the search summary, not a quoted source. `PutDocumentCommand` page: a missing collection means `@empty`; concurrency via `changeVector`, null "to skip the check". https://docs.ravendb.net/client-api/commands/documents/put
|
||||
- Marten: per-type tables, so the same id under two types are two rows in two tables; no cross-type check. Same id across subclasses of one hierarchy table: not covered by docs fetched; unverified. Marten docs: upsert is used so users "don't have to check whether a document is new or existing" (search summary of https://www.martendb.io/documents/storing, not opened).
|
||||
- MongoDB: the server has no type; `_id` unique per collection; the C# driver writes a `_t` discriminator from the written object's class map. Whether the driver checks an existing document's `_t` on replace: not researched; nothing found stating it does.
|
||||
- Couchbase: key unique within a collection; upsert replaces; no server-side type field enforcement; guidance is one type per collection. https://docs.couchbase.com/server/7.0/learn/data/scopes-and-collections.html (search summary, not opened).
|
||||
- Orleans: grain identity is type plus key ("type/key"), so same key under two grain classes is two identities, not a conflict. https://learn.microsoft.com/en-ca/%20dotnet/orleans/grains/grain-identity (search summary, not opened). How storage providers key state: not found.
|
||||
- Result of this pass: no store found in which a process-global type registry decides whether a write may replace an existing record of another type. Found: collection/type fixed by metadata of the writer (RavenDB), type as part of the key (Orleans, Marten tables), or no check (Couchbase upsert). This is a negative search result, not proof of absence. Not searched: Cosmos DB, DynamoDB, EF Core TPH update of an existing row with another discriminator, Realm, LiteDB, Akka.Persistence.
|
||||
|
||||
## D. Q3. Guidance on ambient/global registries and determinism
|
||||
|
||||
- Mark Seemann (ploeh, 2019): after discussion with co-author van Deursen, "Ambient Context really is an anti-pattern." Reasons given: he has never used it and superior alternatives exist; he says the claim is unproven. https://blog.ploeh.dk/2019/01/21/some-thoughts-on-anti-patterns/ (WebFetch). Examples he cites as found in the BCL (search summary, not verified on page): `Thread.CurrentPrincipal`, `CultureInfo.CurrentCulture`, `HttpContext.Current`. DI book 2nd ed. chapter 5 lists it among DI anti-patterns: https://www.oreilly.com/library/view/dependency-injection-principles/9781617294730/OEBPS/Text/c05.xhtml (not opened).
|
||||
- MessagePack README (above) is itself a library-author statement on global mutable defaults. MongoDB CSHARP-4424 (above) states the shared-library init-order problem. EF Core docs (above) state assembly-scan order is undefined.
|
||||
- No .NET guidance specifically about AppDomain-wide/assembly-scan registries and write decisions found.
|
||||
|
||||
## E. Not verified / gaps
|
||||
- protobuf-net lazy registration, freezing and bug history.
|
||||
- Why System.Text.Json made Default immutable; no maintainer statement found.
|
||||
- Json.NET DefaultSettings guidance.
|
||||
- RavenDB exact error text and HTTP status; whether the stored document is unchanged after refusal.
|
||||
- Marten same-id-across-subclasses behaviour.
|
||||
- MongoDB driver behaviour on replacing a document whose `_t` differs.
|
||||
- Several items above rest on WebSearch summaries I did not open (marked "summary").
|
||||
90
docs/research/2026-10-27-structural-schema-ids-prior-art.md
Normal file
90
docs/research/2026-10-27-structural-schema-ids-prior-art.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Prior art: structural versus name-inclusive schema ids
|
||||
|
||||
Serves the store-identity question raised 2026-10-27 in campaign `variants` (operator: member and declared names vary with runtime naming conventions). Eyes pass, session `self-2026-10-27b-eureka`. Facts only; items marked "summary" rest on search summaries not opened.
|
||||
|
||||
# Prior art: structural vs name-inclusive schema identity (campaign variants)
|
||||
|
||||
Eyes pass, 2026-10-27. Facts and evidence pointers only. "(fetched)" = page opened via WebFetch (the answer is a small-model extraction of the page, not raw text). "(summary)" = WebSearch result summary, page not opened.
|
||||
|
||||
## 0. Local facts (read-only)
|
||||
|
||||
- `F:\Projects\CultLib\src\GameCult.Caching\CultCache.cs:717-718` schemaId = Sha256(BuildSemanticFingerprint(...)). `:735-761` fingerprint = `schemaName|schemaVersion` then per member ordered by Slot: `slot:MemberName:TypeName:ref|value:TargetSchemaName:one|many`. `contentHash` (`:716`) is a separate Sha256 of canonical JSON (`:763+`).
|
||||
- `TypeName` = `CultSchemaTypeNames.FromType`: CLR `Type.FullName` (namespace-qualified), generics as `Name<args>`, arrays `T[]` (`src\GameCult.Caching\CultSchemaTypeNames.cs:9-31`). So the fingerprint contains .NET type names as well as member names.
|
||||
- Contract `Contracts\cultcache-persistence-format.md:257-284` lists what canonicalization must preserve: slots, types, required/default, nullability, collection semantics, enum constraints, encoding shape. Member names and declared schema name are not in that list. `:171-179` says the id is not derived from C#/Rust/TS type names and the same shape should give the same id "regardless of origin language". `:180-190` defines schemaId = "same semantic persistence shape?" vs contentHash = "exact same canonical schema document?". Example catalog entry (`:140-170`) carries optional `supersedesSchemaIds`, `migrationFamily`; its example canonicalSchema fields do carry `"name": "Name"` (PascalCase). Soft-migration reader flow `:286-345` (exact_match / soft_migrate / reject).
|
||||
- Runtimes do not derive ids; they emulate C# names:
|
||||
- Python: `packages\cultcache-py\src\cultcache_py\documents.py:326` `member_name=_pascal_case(field.name)`; `:516-517` splits on `_` and capitalises. `schema_id` defaults to `self.type` (`:218`); `compatible_schema_ids` field (`:188,242`).
|
||||
- TypeScript: `packages\cultcache-ts\src\codec.ts:226-232` `pascalCase` (splits on non-alphanumerics), `:267` `memberName: pascalCase(field.name)`; throws if a codec has no C# type name (`:~262`).
|
||||
- Rust: `packages\cultcache-rs\src\lib.rs:295,308` `serde(rename_all = "camelCase")` on envelope structs; `schema_id: Some(T::TYPE.to_string())` at `:2856`. I did not trace how Rust document types obtain a sha256 id.
|
||||
- Python test pins a literal id from shared vectors: `packages\cultcache-py\tests\test_cultcache.py:808` (`tests\vectors\document-variants-c0`).
|
||||
- C# also has `CompatibleSchemaIds` (`CultCache.cs:28,313,570`): a record whose persisted id is in the list is accepted (an alias-list mechanism; I saw only the grep lines, not the logic).
|
||||
- Ruling `variants:ruling:wire-id-is-version` (eureka-state, InForce, ruled 2026-09-30, operator quote "Agreed on schema identity"): wire schema id = declared version string `<name>.vN`; every runtime stamps/resolves it exactly; a wire binding without `.v<digits>` is refused; "Store ids are unchanged." Cited source: document-variants-cut.md:552-555 (not opened).
|
||||
|
||||
## 1. How established formats define identity/compatibility, and whether names participate
|
||||
|
||||
Protocol Buffers (fetched https://protobuf.dev/programming-guides/proto3/ and /json/)
|
||||
- Field number identifies the field in the binary wire; changing it = delete + add. Field names appear only in TextProto/JSON encodings; reserve deleted numbers and, for JSON/text, names. The proto3 page has no explicit safe/unsafe entry for renaming; names matter only for JSON/text.
|
||||
- JSON mapping: names become lowerCamelCase unless `json_name` is set; "Parsers accept both the lowerCamelCase name" and the original proto name (/programming-guides/json/).
|
||||
- Message type identity in `Any` is by name: default type URL `type.googleapis.com/<package>.<message>`, "a globally unique identifier" (proto3 page, #any). Type identity is nominal; compatibility is by field numbers.
|
||||
|
||||
Apache Avro (fetched https://avro.apache.org/docs/1.11.1/specification/)
|
||||
- Parsing Canonical Form (PCF): STRIP keeps only attributes relevant to parsing (type, name, fields, symbols, items, values, size); doc and aliases dropped; FULLNAMES replaces short names with fullnames and drops namespace attributes. Textually equal PCF means "the same" for any reader. Names ARE in the canonical form (record and field names), as is field declaration order. Defaults are absent from the kept list (extractor's inference; spec text not confirmed).
|
||||
- Fingerprints: CRC-64-AVRO in single-object encoding (marker C3 01 + 8-byte LE fingerprint); spec recommends at least a 64-bit Rabin, with SHA-256 / MD5 options for larger sets.
|
||||
- Name rules: `[A-Za-z_][A-Za-z0-9_]*`, dot-separated namespaces, equality on case-sensitive fullname.
|
||||
- Resolution: records match on same unqualified name; "Named types and fields may have aliases"; implementations "may optionally" use aliases to map writer to reader. So in Avro names participate in identity (fingerprint) and resolution; aliases are the rename mechanism and are excluded from the fingerprint.
|
||||
|
||||
Cap'n Proto (fetched https://capnproto.org/language.html)
|
||||
- Files and types carry 64-bit IDs; `capnp id` generates them randomly. Default ID for a declaration without one = first 8 bytes of MD5 of (parent scope ID, declaration name). "You cannot change the name of a type that doesn't have an explicit ID"; symbolic names can change if ID/ordinals stay. Stated rationale for IDs: short, unambiguous, full names waste wire space. The page does not say why structural hashes were rejected; I found no Varda statement (search pointed at a mailing-list thread "Schema Refactoring and Unique IDs", Aug 2023, unopened).
|
||||
- Encoding page (https://capnproto.org/encoding.html, fetched): adding fields is backward compatible because field positions depend only on lower-numbered fields; nothing on type identity.
|
||||
|
||||
FlatBuffers (fetched https://flatbuffers.dev/evolution/ and /schema/)
|
||||
- Field positions by declaration order or explicit `id: n` (contiguous from 0, on all fields if used). Renaming "generally OK" because names are not in the binary. `file_identifier` is exactly 4 chars at bytes 4-7; docs frame it as a file-level check and recommend unions for distinguishing messages.
|
||||
|
||||
Thrift (fetched https://raw.githubusercontent.com/apache/thrift/master/doc/specs/thrift-binary-protocol.md)
|
||||
- Binary protocol: field header = type + field id; "the field name is not encoded". (IDL page gave nothing on omitted ids.)
|
||||
|
||||
MessagePack-CSharp (fetched https://github.com/MessagePack-CSharp/MessagePack-CSharp, first 100k of 105k chars)
|
||||
- `[Key(int)]` on all members => array encoding; string keys => map. Keys should be sequential from 0 (gaps cost nulls); do not reuse indexes of removed members; keep obsolete members until clients update. Union keys are ints, unique, serialized as [key, object]. No type identity in the payload.
|
||||
|
||||
Microsoft Bond (fetched https://microsoft.github.io/bond/manual/bond_cpp.html)
|
||||
- Changing field ordinals breaks wire compat; no explicit versioning; Simple JSON matches by field name so a rename breaks it. No schema hash/GUID documented there. Bebop: wiki page empty (points to docs.bebop.sh, not opened).
|
||||
|
||||
Unison (fetched https://www.unison-lang.org/docs/the-big-idea/ and /docs/language-reference/unique-types; hash size from search summary)
|
||||
- Definitions identified by hash of syntax tree; names are "separately stored metadata"; hash excludes names (512-bit SHA3 per search summary, unopened).
|
||||
- Structural types: "Unison does not uniquely identify a type by its name"; same-shape types are the same type (Suit/Direction, four nullary constructors, interchangeable). `unique` types: compiler generates a UUID that feeds the hash, so identity becomes nominal. Which is the default keyword was not confirmed.
|
||||
|
||||
IPLD (fetched https://ipld.io/docs/schemas/)
|
||||
- Matching is structural: "the names of types aren't expected to be found in the serialized data"; match is by structural outline. The page does not say schemas are themselves content-addressed. Versioning is left to developers; "Migrations" subpage not opened.
|
||||
|
||||
## 2. Failure modes of purely structural identity
|
||||
|
||||
- Unison documents it directly: structurally identical enums are one type unless declared `unique` (unison-lang.org/docs/language-reference/unique-types, fetched). Its mitigation is a per-declaration UUID mixed into the hash (identity re-nominalised).
|
||||
- Language literature (summary, pages not opened): structural typing is prone to accidental conformance/type equivalence; nominal typing prevents it at the cost of rigidity (Wikipedia "Nominal type system"; arXiv 1007.2123 lecture notes; Erlang nominals doc https://www.erlang.org/doc/system/nominals.html says nominal types exist to prevent misuse of same-structure types). TypeScript "brand" = phantom discriminator field. No serialization-specific source found.
|
||||
- Formats that avoid the collision by carrying a name or tag outside the shape: protobuf Any type URL (full name), Avro fullname in PCF, Cap'n Proto explicit IDs, Unison `unique`, MessagePack-CSharp union key ints, FlatBuffers file_identifier/unions.
|
||||
- Not found: a documented incident of two same-shape serialized types colliding under a structural id (one search, nothing).
|
||||
|
||||
## 3. Failure modes of name-inclusive identity across languages
|
||||
|
||||
- Protobuf: default camelCase conversion is "always valid but not necessarily unique"; proto3 compiler rejects JSON-name conflicts; serialized duplicate keys parse differently by runtime (Edition Zero design doc, summary: https://flutter.googlesource.com/third_party/protobuf/+/HEAD/docs/design/editions/edition-zero-json-handling.md). Snake_case vs camelCase JSON mismatches in practice: https://github.com/fullstorydev/grpcurl/issues/105 (summary); https://github.com/open-telemetry/opentelemetry-proto/issues/476 (summary: spec contradicted itself on camelCase vs original names). Protobuf's mitigation: parsers accept both forms.
|
||||
- Serde (fetched https://serde.rs/container-attrs.html): `rename_all` accepts lowercase, UPPERCASE, PascalCase, camelCase, snake_case; per-field `rename`.
|
||||
- Avro restricts names to `[A-Za-z_][A-Za-z0-9_]*` with case-sensitive fullname equality, so snake_case and PascalCase spellings are distinct names; renames go through aliases, which PCF drops.
|
||||
- Cap'n Proto: auto-derived IDs depend on the declaration name, so a rename without an explicit ID changes the ID.
|
||||
- Bond Simple JSON: rename breaks compatibility (name matching).
|
||||
- Local: Python/TS already convert to PascalCase to reproduce C#'s fingerprint (section 0); the fingerprint's TypeName is the CLR full name (`CultSchemaTypeNames.cs:30`) and TS throws when a codec lacks a C# type name.
|
||||
- Registry normalisation (summary): Confluent Schema Registry `normalize.schemas=true` maps semantically-equal schemas to one id (https://www.confluent.io/blog/best-practices-for-confluent-schema-registry/); sources disagree on whether JSON Schema property order is normalised.
|
||||
|
||||
## 4. Migration precedents for changing identity derivation
|
||||
|
||||
- Git SHA-1 -> SHA-256 (fetched https://git-scm.com/docs/hash-function-transition): per-repo format marker (`extensions.objectFormat`, `compatObjectFormat`, `repositoryFormatVersion=1` makes old git refuse); bidirectional SHA-256<->SHA-1 mapping (pack idx v3, `loose-object-idx`); four transition modes (dark launch, early, late, post); conversion at the network boundary. It is a design doc, not a statement of what has shipped.
|
||||
- IPFS CIDv0 -> CIDv1 (fetched https://docs.ipfs.tech/concepts/content-addressing/): v0 (46 chars, "Qm") and v1 (self-describing version/codec/multihash) coexist; `ipfs cid format -v 1` converts; v1->v0 only for dag-pb.
|
||||
- Docker manifest schema 1 -> 2 (summary, mirror pages unopened): digests differ per manifest format; pull-by-digest gets no translation, pull-by-tag may be translated; repush converts. https://pulp.plan.io/issues/2099.
|
||||
- Warpcast API hash v1 -> v2 (summary, https://warpcast.notion.site/Warpcast-API-Hash-Migration-Public-ccdd6de8ac604783b9596cfd623ddbac): two-week dual-hash period, then only v2.
|
||||
- Cap'n Proto explicit-ID escape hatch for renamed/moved declarations; Avro aliases as the rename bridge in resolution; MessagePack-CSharp keep-obsolete-members (all fetched, above).
|
||||
- Local: contract `supersedesSchemaIds` / `migrationFamily` (`cultcache-persistence-format.md:~140-170`) and C#/Py `CompatibleSchemaIds` exist as alias lists.
|
||||
- Not found: Unison changing its hash derivation (one search, no docs); any serialization store that switched from name-inclusive to structural ids.
|
||||
|
||||
## Could not verify / not done
|
||||
- Cap'n Proto designer's rationale against structural hashing (not in docs; mailing list unopened).
|
||||
- Avro: whether PCF drops defaults (spec text) and any JIRA on it; spec page was summarised by the fetch model.
|
||||
- Bebop, Thrift IDL id-omission rule, Unison default struct/unique keyword, IPLD Migrations page, Edition Zero doc (summary only), Confluent blog (summary only), nominal/structural papers (titles only).
|
||||
- Rust runtime's sha256 derivation path; `document-variants-cut.md:552-555` not opened; `CompatibleSchemaIds` logic not read.
|
||||
- eureka-state worked (whoami ok); only the one ruling was queried.
|
||||
143
research/cultmesh-garden-path/browser-sim-runtime-2026-10-27.md
Normal file
143
research/cultmesh-garden-path/browser-sim-runtime-2026-10-27.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# Where simulation code runs in the browser: prior art (C# wasm, dual implementation, other arrangements)
|
||||
|
||||
Date: 2026-10-27. Read-only Eyes pass. Facts with evidence pointers; no recommendation.
|
||||
|
||||
**Serves** open fork 1 of `F:\Projects\CultLib\docs\cultmesh-garden-path-target.md`: "Where simulation code runs in the browser: C# compiled to WebAssembly, C# on the server with TypeScript systems in the browser, or another arrangement." Campaign `cultmesh-garden-path`, session label `self-2026-10-27-eureka`. Companion (not repeated): `research/cultmesh-garden-path/prior-art-2026-10-27.md` (frameworks, scale-out).
|
||||
|
||||
## Tags and method
|
||||
|
||||
- VERIFIED: read in a fetched source this pass (URL given). Fetching went through a summarising tool; quoted figures are as it returned them.
|
||||
- SECONDARY: search-result summaries, forum posts, third-party blogs, one project's own claims.
|
||||
- UNVERIFIED: inferred or not found. Gaps are listed, not filled from memory.
|
||||
- No measurement was run locally (no wasm-tools workload install, no publish). Every size and speed figure below is someone else's.
|
||||
- Search returned no official .NET 10 hello-world size, startup time, or per-call interop number. Those gaps are real.
|
||||
|
||||
---
|
||||
|
||||
## A. C# compiled to WebAssembly
|
||||
|
||||
### A1. Runtime options and their status
|
||||
|
||||
- Shipping runtime for `browser-wasm` is Mono (Blazor WebAssembly runtime; usable without Blazor UI via `[JSImport]`/`[JSExport]` since .NET 7). VERIFIED (https://meziantou.net/using-dotnet-code-from-javascript-using-webassembly.htm: ".NET 7 ... run any .NET method from JavaScript without the full Blazor framework").
|
||||
- Microsoft's MAUI post says Blazor WebAssembly keeps Mono in .NET 11. SECONDARY (search summary of https://devblogs.microsoft.com/dotnet/dotnet-maui-moves-to-coreclr-in-dotnet-11/; page not fetched).
|
||||
- CoreCLR-on-wasm migration: tracking issue dotnet/runtime#121511 targets "an early preview in the .NET 11 timeframe" on the CoreCLR interpreter, with R2R and RyuJIT later and .NET 12 for stability. VERIFIED plan text (https://github.com/dotnet/runtime/issues/121511). The same page's fetch says the maintainer called the work "mostly done" on 2026-08-11 and closed it with debugger and tests outstanding; a search summary said "early, experimental". The two conflict on how far along it is. SECONDARY / unreconciled. A July 2026 PR (dotnet/runtime#131420) fixes publishing for an "experimental CoreCLR wasm SDK". SECONDARY.
|
||||
- NativeAOT-LLVM (dotnet/runtimelab branch `feature/NativeAOT-LLVM`): described as "an experimental fork of the Native AOT toolchain that uses LLVM", WebAssembly the only target, "for experimentation ... may or may not make it into the main dotnet/runtime repo". VERIFIED (https://github.com/dotnet/runtimelab/tree/feature/NativeAOT-LLVM). In dotnet/runtime#113979 a maintainer says the main focus is Blazor, "which is not NativeAOT-compatible, so NativeAOT-LLVM is not the default yet"; another commenter: still experimental with unresolved issues. VERIFIED (https://github.com/dotnet/runtime/issues/113979). SpacetimeDB's C# module build treats NativeAOT-LLVM as its recommended path after the older wasi-experimental workload was removed from .NET 9+. SECONDARY (mirror of a SpacetimeDB PR, dated May 2026).
|
||||
- Godot: C# web export is not in official releases. Forum thread, last post 2026-04-17, quotes the GodotCon May 2025 post ("cannot commit to a specific timeline yet"), draft PR #106125 by raulsntos. VERIFIED (https://forum.godotengine.org/t/is-there-an-update-on-exporting-c-projects-to-web/128821). Community builds exist and one user reported a "null function" error. VERIFIED (same thread).
|
||||
|
||||
### A2. Download size
|
||||
|
||||
- .NET 7 minimal exported method, trimmed: 2.81 MB uncompressed, about 819 kB Brotli; `dotnet.wasm` 342 kB, `System.Private.CoreLib.dll` 407 kB, `dotnet.js` 54 kB, JS interop assembly 14 kB, app DLL 3 kB. Author calls the baseline "quite large"; other assemblies are pay-for-use through the linker. VERIFIED (meziantou URL above).
|
||||
- A .NET 10 figure of 1.3 MB runtime (versus 1.8 MB on .NET 8) comes from a vendor tutorial with no method. SECONDARY, weak (https://dev.to/ironsoftware/webassembly-and-net-10-run-c-in-the-browser-tutorial-4ffo). Whether 1.3 MB is compressed is not stated there.
|
||||
- Official docs: trimming reduces size, AOT increases it, `InvariantGlobalization`/`InvariantTimezone` drop ICU and timezone data, gzip and Brotli assets are published by default. VERIFIED (https://github.com/dotnet/runtime/blob/main/src/mono/wasm/features.md).
|
||||
- AOT cost in a real C# game port (FlatRedBall2 on KNI/BlazorGL): AOT added a ~40 MB `dotnet.native.*.wasm` and about 37 MB to the payload; frame time about 8-12 ms with the IL interpreter kept versus 57-98 ms JIT baseline (their numbers, their game). VERIFIED as reported (https://github.com/vchelaru/FlatRedBall2/issues/1304). Not a minimal app; sets an order of magnitude for AOT of a framework-heavy game.
|
||||
- Avalonia deployment guide: Brotli typically shrinks .wasm by 60-70%; trimming on by default in release. SECONDARY (https://docs.avaloniaui.net/docs/deployment/webassembly, via search summary).
|
||||
- UNVERIFIED: a measured .NET 10 minimal `browser-wasm` publish with Brotli sizes and AOT/non-AOT split. Not found.
|
||||
|
||||
### A3. Startup time
|
||||
|
||||
- Official guidance only: set `EmccInitialHeapSize` to cut memory-growth delay, preload `dotnet.boot.js`, benchmark on real devices; AOT "can slow startup"; the runtime and assemblies are fetched, hashed and cached by the browser (Blazor fingerprints and integrity-checks each file, `blazor.boot.json` removed in .NET 10, manifest inlined in `dotnet.js`). VERIFIED (features.md above; https://learn.microsoft.com/en-us/aspnet/core/blazor/host-and-deploy/webassembly/bundle-caching-and-integrity-check-failures).
|
||||
- No millisecond startup figure found for any .NET version. UNVERIFIED.
|
||||
- Wavedash's C# template (net9.0, `browser-wasm`, `InvariantGlobalization`) boots by `await dotnet.create()` then runs exports; no timing or size stated. VERIFIED (https://docs.wavedash.com/engines/csharp).
|
||||
|
||||
### A4. JS interop cost
|
||||
|
||||
- Docs: interop operations are "roughly an order of magnitude slower" than in-.NET calls but "relatively fast"; `JSObject`, `Exception`, `Task`, `ArraySegment` create a `GCHandle` and proxy and "carry significant performance overhead"; `Span<byte|int|float|double>` marshals to JS as `MemoryView` with no copy, valid only for the call's duration; JS cannot return `Span` to .NET as an import parameter. Search summary of the .NET 9 page; the .NET 10 page was fetched and the table lines (MemoryView rows, GCHandle remark at lines 241-310 of the saved output) agree. VERIFIED (https://learn.microsoft.com/en-us/aspnet/core/client-side/dotnet-interop/?view=aspnetcore-10.0). The "order of magnitude" sentence itself is SECONDARY (search summary).
|
||||
- Benchmark in dotnet/runtime#113979, 100,000 calls each: Rust wasm-bindgen 5.91 ms (echo number); .NET 9 Mono AOT 65.49 ms (about 0.65 us/call); NativeAOT-LLVM 18.85 ms to 45.08 ms depending on machine. Echo of a struct via JSON round-trip: Rust 238 ms, Mono AOT 1082 ms, NativeAOT-LLVM 191-375 ms. Closed "not planned" in January 2026 with no Mono AOT work committed. VERIFIED (https://github.com/dotnet/runtime/issues/113979).
|
||||
- A third-party SDK (NetWasm) measures a synchronous `[JSImport]` at about 1.4 us (ulong return) and 0.9 us (void, three scalars) and about 15 ns for a direct wasm-to-JS import. SECONDARY (https://github.com/zion-sati/NetWasm/issues/115, search summary).
|
||||
- Marshalling pitfalls in a game port: string-only marshalling forced base64 for byte data; `System.Text.Json` reflection disabled on wasm, source-generated contexts required; AOT with IL stripped breaks reflection paths; module-less `[JSImport]` works only with names starting `globalThis`. VERIFIED as reported (FlatRedBall2#1304 above).
|
||||
- UNVERIFIED: any official per-call microsecond figure for .NET 10 JSImport/JSExport.
|
||||
|
||||
### A5. Threading
|
||||
|
||||
- Multithreaded wasm is opt-in: `<WasmEnableThreads>true</WasmEnableThreads>`, separate runtime build, server must send COOP and COEP headers, `[JSExport]`/`[JSImport]` limited to the main thread, blocking the main thread discouraged. VERIFIED (features.md above).
|
||||
- Uno Platform's bootstrap pre-creates 4 web workers for its threading mode and needs the same headers. SECONDARY (https://platform.uno/docs/articles/external/uno.wasm.bootstrap/doc/features-threading.html, search summary).
|
||||
- Celeste browser port (MercuryWorkshop/celeste-wasm) uses "a multithreaded and JIT compiling version of mono-wasm backported from .NET 10 to .NET 9", renders on a "deputy thread", uses about 600 MB memory, "too high for low-end devices". VERIFIED (https://github.com/MercuryWorkshop/celeste-wasm; Hackaday 2025-10-17, https://hackaday.com/tag/net).
|
||||
- Unity Web: managed C# threads unsupported, reason given "WebAssembly's lack of multithreaded garbage collection". VERIFIED (https://docs.unity3d.com/6000.0/Documentation/Manual/webgl-technical-overview.html).
|
||||
|
||||
### A6. GC under a 60 Hz loop
|
||||
|
||||
- .NET wasm docs say nothing on GC behaviour beyond heap growth: growth takes time and can fail on memory-limited devices (hence `EmccInitialHeapSize`/`EmccMaximumHeapSize`); desktop memory 256-512 MB recommended, over 1 GB needs testing, over 2 GB experimental. VERIFIED (features.md).
|
||||
- A dotnet/runtime commenter said the .NET GC has a conservative mode that needs no multi-threading support. VERIFIED as relayed (dotnet/runtime#121511).
|
||||
- Unity Web: "garbage collection runs once at the end of each frame, rather than incrementally". VERIFIED (Unity page above). That is Unity's runtime, not .NET's.
|
||||
- UNVERIFIED: measured GC pause times for Mono-wasm in a 60 Hz loop with a given allocation rate. No source found. Local-allocation guidance (zero allocation in the step) is general practice, not a finding.
|
||||
|
||||
### A7. Numeric determinism: integer and fixed-point versus float
|
||||
|
||||
- WebAssembly spec design note: nondeterminism is "limited, local"; sources are shared-memory thread races, NaN bit patterns and sign bits from arithmetic, relaxed SIMD, resource exhaustion, and host-call order. Integer operations are not in the list. VERIFIED (https://github.com/WebAssembly/design/blob/main/Nondeterminism.md). IEEE-754 basic float operations (+, -, *, /, sqrt) are specified; only NaN payload is free.
|
||||
- Rust crate docs (alice-det-math): +, -, *, /, sqrt give the same bits on SSE2, aarch64, wasm32; sin/exp/ln come from the platform libm and differ in the last ulp across macOS, glibc, MSVC and wasm; `mul_add` fuses on FMA hardware. SECONDARY, single crate's claim (https://docs.rs/alice-det-math).
|
||||
- bevy_ggrs docs: its f32 example is deterministic on the same platform but "may produce different results across different CPU architectures or operating systems". SECONDARY (https://github.com/gschup/bevy_ggrs, search summary).
|
||||
- .NET-specific: a hydrology vendor (eWater) found `Math.Pow`/`Math.Exp` differing across machines in 0.09% of random calls and replaced them. SECONDARY, 2019, old runtime (https://ewater.atlassian.net/wiki/spaces/SC/pages/51652397).
|
||||
- UNVERIFIED: whether RyuJIT contracts `a*b+c` into FMA on x64/arm64 and whether Mono-wasm (interpreter, jiterpreter, AOT) does not, so whether plain float arithmetic is bit-identical between .NET native and .NET wasm. Whether `Math.Sin/Cos/Pow` in .NET wasm route to Emscripten's libm versus the host's CRT. No source found for either.
|
||||
- UNVERIFIED but implied by the above: `int`/`long` arithmetic and shifts are defined by both the CLR and the wasm spec; a fixed-point type built from them has no listed source of divergence between targets.
|
||||
- Local state: `F:\Projects\CultLib\packages\cultmath` is a C# library with GLSL lowering and mirror tests; a search for `Fix64|Fixed64|struct Fix` found nothing in `src` or `packages` C# (one Rust test file matched). `Fix2` in the target document is illustrative, not existing code.
|
||||
|
||||
### A8. Existence proofs and counter-examples
|
||||
|
||||
- Unity WebGL/Web (C# via IL2CPP to wasm): ships widely. Empty-project build 14.7 MB falling to 12.5 MB Brotli with "Disk Size with LTO" (CrazyGames). VERIFIED (https://docs.crazygames.com/resources/optimization-tips/). FRVR: runtime about 5 MB "empty", about 7 MB "minimal", targets about 8 MB initial. SECONDARY (https://docs.frvr.com/engines/unity/optimization/, search summary).
|
||||
- Godot C#: no official web export (A1).
|
||||
- .NET wasm games found: a Celeste port (mono-wasm backport, runs Everest mods; hacky by its own description), an Uno Platform space shooter (hobby), Wavedash's Pong template, FlatRedBall2/KNI BlazorGL, R136.NET text adventure. SECONDARY (search summaries: https://hackaday.com/tag/net, https://platform.uno/?p=9866, https://github.com/rbergen/R136.NET). No commercial .io-class game on .NET wasm found. UNVERIFIED that none exists.
|
||||
- MonoGame has no official web target; KNI is the community route. SECONDARY (https://docs.wavedash.com/engines/monogame, search summary).
|
||||
|
||||
---
|
||||
|
||||
## B. Two implementations (C# server, TypeScript client)
|
||||
|
||||
- Colyseus 0.18: prediction needs the local-player `step` to be "the SAME function the server runs"; the docs' shared module is TypeScript imported by both sides. Prediction is documented "across the official SDKs" with examples for TypeScript, C#, Lua, Haxe, GDScript, Dart; `unreliable` input mode only in the JS/TS SDK over WebTransport; some options missing in GDScript and Flutter. Server must advertise a fixed tick via `setFixedTimestep`. Divergence is detected by `warnOnDivergence` and drift telemetry. VERIFIED (https://docs.colyseus.io/netcode/client-prediction). The 0.18 announcement shows prediction APIs only in the JS/TS SDK and says shared code is imported by both sides. VERIFIED (https://colyseus.io/blog/colyseus-018-is-here/). The server is Node/TypeScript, so a non-TS client's `step` is necessarily a second implementation or a port; the pages do not describe how the C# example gets its step. UNVERIFIED which.
|
||||
- SpacetimeDB TypeScript client: reducers called as promises, subscription cache with `onInsert/onUpdate/onDelete`; the page describes no optimistic update or prediction. VERIFIED (https://spacetimedb.com/docs/clients/typescript). Server modules are Rust or C#, so there is no shared simulation code to predict with.
|
||||
- Nakama: server runtime Go/Lua/TypeScript; developer owns sync and prediction (see companion §8). VERIFIED there.
|
||||
- Test-vector mechanism in the field: only drift telemetry (Colyseus) and desync-debug guides (bevy_ggrs) were found; no framework found that ships a cross-language conformance suite for predicted code. UNVERIFIED that none exists.
|
||||
- Local state (CultLib): runtimes live in `F:\Projects\CultLib\packages\` (`cultcache-ts`, `cultnet-ts`, `cultmesh-ts`, `cultmesh-browser`, `cultcache-rs`, `cultmesh-rs`, `cultmesh-py`, `cultmesh-kotlin`) with wire parity against the C# reference. That parity covers serialisation and protocol, not simulation code; no simulation systems exist in the repo yet.
|
||||
|
||||
---
|
||||
|
||||
## C. Other arrangements
|
||||
|
||||
### C1. Shared Rust (or C++) core compiled to native and wasm
|
||||
|
||||
- Several hobby and small-studio projects claim one deterministic core on server (native) and browser (wasm) with client prediction (a Rust engine for web/iOS/Android/server with rollback for 20-player sessions; a Rust+wasm Pong on Cloudflare; a C++20/Raylib game via Emscripten). SECONDARY, project pages from one search, none read in depth. The C++ project listed native-versus-wasm determinism as a challenge.
|
||||
- Ambient (Rust) runs wasm only on the server, server-authoritative with no prediction. SECONDARY.
|
||||
- bevy_ggrs with matchbox (WebRTC) is the rollback route in Rust; its browser demo is "currently unmaintained". SECONDARY (https://github.com/gschup/bevy_ggrs, search summary).
|
||||
- Interop cost reference point: Rust wasm-bindgen was the fastest in dotnet/runtime#113979 (A4).
|
||||
|
||||
### C2. C# to JavaScript transpilers
|
||||
|
||||
- Bridge.NET: no maintenance data found. UNVERIFIED.
|
||||
- h5: fork of Bridge; NuGet `h5-compiler` 26.3.65450 targets net10.0. SECONDARY (https://www.nuget.org/packages/h5-compiler).
|
||||
- Transpose (Curiosity.ai): "next generation of the h5 compiler, rebuilt around a Roslyn translator and rebranded"; compiler runs on .NET 10; projects target .NET Standard 2.0/2.1; NuGet versions 26.7 to 26.8 in 2026; legacy Bridge/NRefactory pipeline removed; license not stated on the docs page ("All rights reserved" footer). VERIFIED docs text (https://docs.curiosity.ai/transpose/), NuGet versions SECONDARY. The docs page says nothing on integer overflow, `long`, `float`, `decimal` or struct semantics in the JS output. UNVERIFIED. A fetch of the h5 core-concepts page returned 404, so those semantics are unread.
|
||||
- Not examined: CSharp.lua, SharpKit, Duality, Blazor-less `Uno` WebAssembly (Uno uses the .NET wasm runtime, not transpilation).
|
||||
|
||||
---
|
||||
|
||||
## D. How current .io games ship client code, and payload tolerance
|
||||
|
||||
- diep.io: its client loads an Emscripten-built WebAssembly module (community reverse-engineering repo has "emscripten" and "asm.js conversion" sections; source language not stated). VERIFIED the repo lists these sections (https://github.com/ABCxFF/diepindepth); the repo is no longer actively worked on. Whether the wasm holds simulation or prediction: not stated. UNVERIFIED.
|
||||
- agar.io, slither.io, krunker.io: no payload size or client architecture found. UNVERIFIED. DevTools inspection of the live sites was not done.
|
||||
- Poki (2018 W3C games workshop, 174 Poki games, desktop only): Conversion To Play (players finishing load / players starting) at 10 MB initial download is about 0.8 in the US, about 0.7 in Brazil, below 0.6 in Egypt; "below roughly 10 MB every MB really counts"; above 40 MB there is "lots to gain" from post-loading. VERIFIED (https://www.w3.org/2018/12/games-workshop/papers/size-matters.html). Eight years old.
|
||||
- Enclave Games recommends a micro-client under 5 MB with remaining assets streamed. VERIFIED, opinion without data (https://enclavegames.com/p/browser-games-lose.html).
|
||||
- 2026 State of Web Gaming report (2,000 weekly web gamers): 46% had quit a mobile game for slow load, 32% on computers/consoles. SECONDARY (https://www.sentinelassam.com/cities/guwahati-city/web-gamers-quit-slow-apps-as-browser-play-gains-ground-on-social-media-study, search summary).
|
||||
- Abratabia rule of thumb: about 5 MB initial transfer, JS parse under 500 ms. SECONDARY, unmeasured guidance.
|
||||
- CrazyGames gives no numeric size threshold on its optimisation page. VERIFIED (https://docs.crazygames.com/resources/optimization-tips/).
|
||||
|
||||
---
|
||||
|
||||
## E. Gaps worth noting (not recommendations)
|
||||
|
||||
1. No measured .NET 10 minimal `browser-wasm` Brotli size, cold/warm startup, or 60 Hz GC behaviour was found; the closest is .NET 7 at 819 kB Brotli and a vendor claim of 1.3 MB runtime for .NET 10.
|
||||
2. Native-versus-wasm float bit-identity for .NET (FMA contraction, `Math.*` routing) has no source; integer and fixed-point are unlisted as divergence sources by the wasm spec.
|
||||
3. Mono-wasm is the shipping .NET browser runtime; CoreCLR-on-wasm and NativeAOT-LLVM are both pre-default, with conflicting reports on how far CoreCLR-wasm has come.
|
||||
4. Colyseus, the nearest prediction-bearing framework, requires one shared `step` function and documents it in TS with ports in other SDKs.
|
||||
5. No .io-class game on .NET wasm and no published .io payload sizes were found.
|
||||
|
||||
## Addendum (Self, 2026-10-27): NetWasm, and CultCache's reflection
|
||||
|
||||
Source: https://github.com/zion-sati/NetWasm README, fetched 2026-10-27 (operator pointer from HN). Self-reported, not measured by us.
|
||||
|
||||
- An independent compiler, CoreLib, runtime and SDK: CIL to WebAssembly directly, generics specialised, only reachable runtime support linked, wasm-opt always. Not NativeAOT-LLVM, not Mono.
|
||||
- Print-42 workload, Compact GC: 38,919 B raw, 13,213 B Brotli-11 (Rust size profile: 16,963 B). Empty Blazor AOT app for context: about 12.3 MB (.NET 10).
|
||||
- Benchmarks (Wasmtime, M1 Max, 15-run medians): 41-73% less time than Mono AOT on allocation-heavy workloads; parity on n-body and CRC-32. Run in Wasmtime, not a browser.
|
||||
- Outside the profile, refused at compile time: runtime reflection, `dynamic`, runtime assembly loading, managed threading, named-culture globalisation. Expression trees without general reflection; JSImport scalar calls are direct wasm imports, spans cross as typed-array views.
|
||||
- Floating-point determinism: not addressed.
|
||||
- Licence: compiler and tooling under the NetWasm Community License 1.0 (free under 250 employees and USD 10M revenue, paid tiers above); runtime libraries and generated code MIT.
|
||||
- Maturity: 0.10.3, pre-1.0, contracts may change between minors; 269 stars, 301 commits, single vendor.
|
||||
|
||||
CultLib consequence (source read, corrected same day): the C# path reflects at document registration, not necessarily per record. `CultCache.BuildDescriptor`/`DiscoverMembers` (`src/GameCult.Caching/CultCache.cs` :689, :1087; `GetProperties` :1105, :1138; `MakeGenericType` :1330; `ConstructorArity` :1277) walk members and constructors to mirror MessagePack's member rules for the descriptor and schema catalog, and `CultDocumentResolver` (`src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs` :14-30) builds `CultRecordRef<>` formatters with `MakeGenericType` + `Activator.CreateInstance`. Document formatters come from MessagePack's `StandardResolver` (:61), which in MessagePack 3.x prefers source-generated formatters and falls back to the IL-emitting `DynamicObjectResolver` only for types with none. An earlier version of this paragraph said the codec emits IL at runtime; that rested on a code comment (:1274) and was not established. Whether every [CultDocument] type gets a generated formatter, and what NativeAOT and NetWasm say about the registration path, is for a probe to establish.
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
# CultCache C# AOT / source-generator probe, 2026-10-27
|
||||
|
||||
Serves `cultlib-gaps:follow_up:cs-aot-safe-registration`. Probe pass, nothing admitted or committed. Session `self-2026-10-27-eureka`.
|
||||
|
||||
Method: a scratch console (`aot-probe`, kept at `C:\Users\Meta\AppData\Local\Temp\claude\F--Projects-CultLib\dfe49a4b-e0ab-440c-81c7-7325d2eb7405\scratchpad\aot-probe\`) placed at `tests/aot-probe` in a dangling commit on CultLib main (base `e3fac9c2`, never on a branch), built and run on Yggdrasil with `ygg-verify.sh` in `mcr.microsoft.com/dotnet/sdk:10.0` (net10.0, MessagePack 3.1.7, .NET 10.0.12, `apt install clang zlib1g-dev` works in the image, so real NativeAOT linux-x64 publishes ran). Logs: `job2.log`, `job3.log`, `job4.log` beside the probe. Each claim names its probe. Probe documents: `PlainDoc`, `PlainPartialDoc`, `RefDoc` (CultRecordRef<T> member and ref-keyed dictionary), `UnionMemberDoc` ([Union] interface member), `RootDoc`/`ArmDoc` (document that is a union arm), `MathDoc` (CultMath float3/float4), and a nested `internal` `[MessagePackObject(AllowPrivate = true)]` doc as CultLib's tests declare them.
|
||||
|
||||
## 1. Where the document formatter comes from
|
||||
|
||||
| Claim | Probe |
|
||||
|---|---|
|
||||
| A consumer that references only `GameCult.Caching.MessagePack` (as `tests/GameCult.Caching.ConsumerBuild` does) gets **no source generator at all**: 0 generated files. Every document formatter is `MessagePack.Formatters.Probe_*Formatter` in the dynamic assembly `MessagePack.Resolvers.DynamicObjectResolver` (Reflection.Emit). Cause: the library's `MessagePackAnalyzer` reference is `PrivateAssets="all"`, so the generator does not flow to consumers. | job2 "JIT A": `generated files (A): 0`; `Describe(opts.Resolver.GetFormatter<T>())` for all 8 doc types printed `dynamic=True`; `src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj` |
|
||||
| When the consumer itself has `<PackageReference Include="MessagePack" Version="3.1.7">`, MessagePack's generator runs and emits a formatter per `[MessagePackObject]` type (12 files), into `MessagePack.GeneratedMessagePackResolver`, plus `[assembly: GeneratedAssemblyMessagePackResolverAttribute]`. | job3 "JIT E/F": `generated files: 12` |
|
||||
| Those generated formatters are used with **no hook**: CultLib's composite options resolve `PlainDoc`, `PlainPartialDoc`, `RefDoc`, `UnionMemberDoc`, `ArmDoc`, `MathDoc`, and the nested internal doc to `MessagePack.GeneratedMessagePackResolver+Probe+*Formatter` (`dynamic=False`). `StandardResolver` finds them itself through `SourceGeneratedFormatterResolver`. `[assembly: CultCacheFormatterResolver(...)]` is not needed for this. | job3 JIT E/F `composite(...)` lines |
|
||||
| What the type must look like: **not** `partial`, **not** required `public`. A public non-partial sealed class, a partial class, and a nested `internal` class with `[MessagePackObject(AllowPrivate = true)]` and public fields all got generated formatters. `[MessagePackObject]` with `[Key]` is the only requirement. | job3 JIT E/F |
|
||||
| **Generator breakage: any `CultRecordRef<T>` member, or a `CultRecordRef<T>` dictionary key, makes the generated resolver fail to compile** (`CS0426: The type name 'GameCult' does not exist in the type 'GeneratedMessagePackResolver'`). The generator emits `new global::MessagePack.GeneratedMessagePackResolver.GameCult.Caching.CultRecordRefFormatter<PlainDoc>()`, a namespace-mangled name for the library's open-generic formatter. Reproduced with Target member only (G), dictionary key only (H), and both (B). The member attribute `[MessagePackFormatter(typeof(CultRecordRefFormatter<T>))]`, which `src/GameCult.Geometry/CultGeometryDocuments.cs:156,231,316` already carries, fixes a member (E) but a dictionary key cannot take one (D: attribute on Target, dictionary kept, still fails). | job2 B, job3 B/D/E/F, job4 G/H; `gen/.../MessagePack.GeneratedMessagePackResolver.g.cs` line 64-65 and `RefDocFormatter` read in job4 G |
|
||||
| Union interface member, union-root abstract class, document-as-arm: the document formatters are generated; the union type itself resolves to `CultUnionGuard+Formatter<T>` over the generated `IShapeFormatter`/`RootDocFormatter` (the guard wraps whatever the inner chain gives). | job3 JIT E/F |
|
||||
| CultMath members: `MathDoc` gets a generated formatter whose `float3`/`float4` members resolve at runtime through `CultMathResolver` (`GameCult.Caching.MessagePack.CultMathResolver+ArrayShape<float3>`, hand-written, no Emit, no generator). Round trip returned the same values. | job3 JIT F `float3` line, `plain=... math=1,2,3` |
|
||||
| Byte identity: document payloads from dynamic (A) and generated (F) formatters are identical for PlainDoc, PlainPartialDoc, ArmDoc, MathDoc, UnionMemberDoc, nested doc (`93A1700792A161A162`, `92A27070CB3FF8...`, `92A175920091CA40200000`, ...). | job2 A hex vs job3 FR hex |
|
||||
|
||||
## 2. NativeAOT publish (`PublishAot`, `IsTrimmable`, `EnableTrimAnalyzer`, `TrimmerSingleWarn=false`)
|
||||
|
||||
| Claim | Probe |
|
||||
|---|---|
|
||||
| Publish succeeds with warnings (exit 0) in every variant. ~109 `IL` warning lines; 100 are inside MessagePack's `Dynamic*`/Emit machinery (still linked because `StandardResolver` references `DynamicObjectResolver`), 9 distinct CultLib source sites (below). The netstandard2.1 CultLib projects have no source-level trim analyzer (reference assemblies carry no annotations); the warnings come from ILC at publish, with file(line) of the first sequence point. | job3 `--- CultLib-source warnings` |
|
||||
| Default publish, dynamic-fallback formatters (A): the app fails at runtime in `CultDocumentRegistry.Refresh()` -> `DiscoverMembers` with "has no constructor MessagePack can call": ILC trimmed constructor metadata that `ConstructorArity` reads reflectively. Registration is not AOT-safe even before serialization. | job2 AOT A run |
|
||||
| With the probe assembly rooted (`TrimmerRootAssembly`) and dynamic fallback (AR): registration passes, **serialization fails** for every doc (`TargetInvocationException`; `IsDynamicCodeSupported=False`; `DynamicObjectResolver.GetFormatter<T>` throws `TypeInitializationException`), flush fails. | job3 AOT AR run |
|
||||
| With rooted assembly and generated formatters (variants F: no ref members; E: ref member with the attribute workaround): all 7 registrations OK, all payloads, upsert, flush, reopen and read OK. **Payload lines identical to the JIT build** for both variants. Whole-file bytes differ by design (stamps), so identity is on document payloads. | job3 `F vs FR`, `E vs ER`: IDENTICAL |
|
||||
| So registration needs the assembly rooted today, because of the reflection below; formatters need the consumer to reference MessagePack for the generator and to avoid `CultRecordRef` dictionary keys. | job3 |
|
||||
|
||||
## 3. Runtime-reflection sites on the registration and serialization path (CultLib main)
|
||||
|
||||
Warnings (job3, `CultLib-source warnings`) cover rows marked W. Others come from the source read.
|
||||
|
||||
Registry enumeration
|
||||
- `ReflectionExtensions.cs:12-23`: `AppDomain.GetAssemblies()` + `Assembly.GetTypes()` + `GetCustomAttribute<CultDocumentAttribute>()` (W IL2026 at :23). Called by `CultDocumentRegistry.Refresh()` (`CultCache.cs:495`), run from the registry constructor.
|
||||
- `CultCache.cs:511`, `:691`, `:727`: `GetCustomAttribute<CultDocumentAttribute>` / `<CultGlobalAttribute>` in `GetRequired` and `BuildDescriptor` (`BuildDescriptor` is `CultCache.cs:689`).
|
||||
|
||||
`DiscoverMembers` (`CultCache.cs:1087`)
|
||||
- `:1090` `GetCustomAttribute<MessagePackObjectAttribute>(true)`.
|
||||
- `:1098` `GetFields`, `:1105` `GetProperties`, `:1138` non-public `GetFields`/`GetProperties` (W IL2070 x4).
|
||||
- `:1235`, `:1299`, `:1545`, `:1549`, `:1599`, `:1601`, `:1609`, `:1676` attribute reads on members (`CultReference`, `CultIndex`, `IgnoreMember`, `Key`, `CultDocument` of the target, `CultName`); `:1086` `GetCustomAttributes` up the override chain.
|
||||
- `:1268` `OverrideChain` `GetProperty` (W IL2075).
|
||||
- `ConstructorArity` `:1277`, `:1282` `GetConstructors(Public|NonPublic)` + `IsDefined(SerializationConstructor)` (W IL2070; the AOT-A runtime failure).
|
||||
- `:1254` member getters built as `field.GetValue` / `PropertyInfo.GetValue` method groups; `:1649-1650` `GetValue` on dictionary entries at upsert, i.e. reflection stays on the hot path after registration, not only at registration.
|
||||
- `:1306-1330` `IsEnumerableOfRecordRef` / `TryGetDictionaryRefTypes`: `GetInterfaces()` (W IL2070 :1310, :1323), `typeof(KeyValuePair<,>).MakeGenericType` at `:1330` (W IL3050); `:1689-1715` `ResolveReferenceTarget`: `GetInterfaces()`, `GetGenericArguments()` (W IL2070 :1702).
|
||||
- `CultSchemaTypeNames.cs:13-26`: `GetElementType`, `GetGenericTypeDefinition`, `GetGenericArguments` (type-name rendering; shape-only, not flagged).
|
||||
|
||||
MessagePack-side wiring
|
||||
- `CultDocumentMessagePackSerialization.cs:22-25`: `CultDocumentResolver.GetFormatter<T>` does `typeof(CultRecordRefFormatter<>).MakeGenericType(...)` + `Activator.CreateInstance` for every `CultRecordRef<T>` (W IL3050 :24). This one is not covered by the generator; the generator's own attempt at `CultRecordRef<T>` is the broken one in section 1.
|
||||
- `:49` `Assembly.GetCustomAttributes<CultCacheFormatterResolverAttribute>()`; `:69-71` `CreateResolver`: `GetField("Instance")`, `GetProperty("Instance")`, `GetConstructor(Type.EmptyTypes)`, `Activator.CreateInstance(resolverType)` (W IL2067 + IL2070 x3 at :69).
|
||||
- `CultMessagePackSecurity.cs:27-28`: `typeof(RecordRefComparer<>).MakeGenericType` + `Activator.CreateInstance`, for `CultRecordRef<T>` dictionary keys (W IL3050 :27).
|
||||
- `CultUnionGuard.cs:20`: `typeof(T).GetCustomAttributes<UnionAttribute>(false)` per union type (not flagged; attribute read on a closed `T`).
|
||||
|
||||
Not on the registration path but in the library: `CultInspectorModel.cs` (`Activator.CreateInstance`, `MakeGenericType`, `GetMembers` at :37, :165-168, :377, :424-431, :540, :597, :757-821), reached only by the inspector.
|
||||
|
||||
## Shape of the fix these probes support (advice, not a decision)
|
||||
|
||||
- The reflective registration needs a generated descriptor per `[CultDocument]` type (members, slots, constructor arity, getters, attributes), the same shape the generator already proves possible for formatters. A generator is not free, since MessagePack's generator is the consumer's, not the library's.
|
||||
- `CultRecordRef<T>` needs a non-reflective formatter path (`CultDocumentResolver` and `CultMessagePackSecurity` both `MakeGenericType`), and the upstream generator bug with the library's open-generic formatter must be avoided, or the dictionary-key shape will stay uncompilable for generator consumers.
|
||||
- Consumers need to reference MessagePack (generator) directly; the library's `PrivateAssets="all"` analyzer does not reach them.
|
||||
224
research/cultmesh-garden-path/prior-art-2026-10-27.md
Normal file
224
research/cultmesh-garden-path/prior-art-2026-10-27.md
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
# Garden-path multiplayer frameworks: developer ergonomics and scaling architecture (prior art)
|
||||
|
||||
Date: 2026-10-27. Read-only Eyes pass. Facts with evidence pointers; no recommendation.
|
||||
|
||||
**Serves** the CultMesh product direction, operator 2026-10-27, verbatim:
|
||||
|
||||
> "the value is in solving the multiplayer scaling problem for indie developers in an ergonomic fashion. Having one authoritative server is fine, up to a point, but there's no real open source playbook on how to ship an MMO, and existing solutions like SpacetimeDB have a huge ergonomics gap. I want CultMesh to feel like magic: as a garden path, you annotate your domain POCOs and simulation code and declare your authority structure (I see no reason why we couldn't support server-authoritative and trusted co-op as a configurable subset of the much harder problem we're aiming at ...), and it'll all just work without the developer needing to understand networking deeply."
|
||||
|
||||
Companion (not repeated here): `F:\Projects\CultLib\research\witness-authoritative-networking\prior-art-2026-10-26.md` (consensus, BFT, P2P anti-cheat).
|
||||
|
||||
## Tags and method
|
||||
|
||||
- VERIFIED: read in a fetched source this pass (URL given). Fetching was through a summarising fetch tool; code snippets are as that tool returned them and some are short fragments, not whole listings.
|
||||
- SECONDARY: from search-result summaries, forum/HN threads, news, Wikipedia, or vendor-adjacent blogs.
|
||||
- UNVERIFIED: recalled or inferred; no source read this pass.
|
||||
- Many official pages returned only tables of contents or 404 (Unreal replication pages, Nakama concept pages, FishNet prediction, NGO NetworkVariable, SpatialOS docs, Veloren ECS chapter). Those gaps are listed per system and not filled from memory unless tagged UNVERIFIED.
|
||||
- HN comment content is opinion by named commenters, relayed as paraphrase.
|
||||
|
||||
---
|
||||
|
||||
## 1. SpacetimeDB (Clockwork Labs)
|
||||
|
||||
**What the dev writes.**
|
||||
- State: tables are annotated structs. Rust `#[spacetimedb::table(accessor = person, public)]` on `pub struct Person`, with `#[primary_key]`, `#[index(btree)]`. C# `[SpacetimeDB.Table(Accessor = "Person", Public = true)] public partial struct Person`, `[SpacetimeDB.PrimaryKey]`, `[SpacetimeDB.Index.BTree]`. VERIFIED (https://spacetimedb.com/docs/tables).
|
||||
- Logic: reducers are the only way to mutate tables; each is a transaction, rolled back on error. Rust: `pub fn create_user(ctx: &ReducerContext, name: String, email: String) -> Result<(), String>`. TypeScript: `spacetimedb.reducer({ name: t.string(), email: t.string() }, (ctx, {name, email}) => {...})`. VERIFIED (https://spacetimedb.com/docs/functions/reducers).
|
||||
- Client reads: subscriptions; "Query builders are the recommended default across SDKs, with raw SQL available" e.g. `ctx.subscriptionBuilder().subscribe([tables.user, tables.message])`, `tables.shopItems.where(r => r.requiredLevel.lte(5))`. VERIFIED (https://spacetimedb.com/docs/clients/subscriptions).
|
||||
- Modules in Rust, C#, TypeScript, C++; app logic is uploaded into the database and clients connect to it directly. VERIFIED (https://github.com/clockworklabs/SpacetimeDB).
|
||||
|
||||
**Infers vs requires.** Infers: wire sync of subscribed rows, rollback on reducer error, persistence (commit log). Requires: restructuring game/simulation code into tables + reducers; the dev writes all netcode above that (prediction, interpolation, lag masking). Reducer rules: no network or filesystem, global/static variables are undefined behaviour, "Never use external random number generators"; context supplies `ctx.sender`, a per-reducer-consistent timestamp, and a deterministic RNG. VERIFIED (reducers and reducer-context docs).
|
||||
|
||||
**Authority.** One model: the database/module is the sole authority; clients call reducers. No trusted-co-op or client-authority mode in what was read. `ctx.sender` identity is the access hook ("Access checks can be asserted at the start of a function", BitCraft article). VERIFIED (https://bitcraftonline.com/news/spacetimedb-and-bitcraft). Peer-authority: not offered (UNVERIFIED beyond what was read).
|
||||
|
||||
**Scaling.**
|
||||
- "each database is a single-threaded actor"; replication is for availability, "replicated databases achieve the same throughput as unreplicated databases." VERIFIED (https://spacetimedb.com/blog/how-does-spacetime-scale).
|
||||
- Scale-out is by many databases, developer-chosen: "Databases can be created as children of other databases"; "transactions against different databases can execute concurrently without coordination." Same source.
|
||||
- Inter-database calls: async IDC and tiered storage slated 2026-10-31 (planned, not shipped as of this pass); sync IDC with two-phase commit, read replicas, and intra-database partitions are "planned." Same source. Table data lives in memory on the leader. Same source.
|
||||
- BitCraft: "many SpacetimeDB databases which handle a spatial partition in the world"; partitioning is built on top of SpacetimeDB, not within it; each database limited to one machine's memory. VERIFIED (BitCraft article above). Peak concurrency under 5,000 players per an HN commenter (Rohansi) — SECONDARY (https://hn.svelte.dev/item/49563772).
|
||||
- Interest management: the subscription query is the mechanism; docs warn overlapping/non-indexable filters (`ne`) cost extra. VERIFIED (subscriptions doc). No built-in spatial AOI primitive was seen (UNVERIFIED that none exists).
|
||||
- Authority handoff between databases: none in shipped form (see IDC above).
|
||||
|
||||
**Determinism.** Only within reducers (RNG, timestamp from context). No client-side replay requirement because there is no client prediction. VERIFIED.
|
||||
|
||||
**Prediction.** None built in. Open issue "Client-side prediction" (4 reactions/comments) and "testing reducers" in the issue list. VERIFIED (https://github.com/clockworklabs/SpacetimeDB/issues sorted by reactions; the numbers might be comment counts, the tool could not tell). HN: commenter gafferongames called planning lag-masking netcode as a TODO "a bad sign" and said adoption means "restructuring the game server and simulation to fit the database's programming model"; manas96 said networked physics needs prediction. SECONDARY (https://hn.svelte.dev/item/43631822).
|
||||
|
||||
**Persistence / schema evolution.** Persistence default (WAL, in-memory working set; 2.0 holds subscription updates until durable by default, opt out `withConfirmedReads(false)`). VERIFIED (GitHub README). Auto-migration allows adding tables/indexes/reducers, appending columns with defaults; `spacetime publish --break-clients` for breaking changes; no general schema-modifying migrations: docs recommend adding a new table and lazily migrating rows from the old one. SECONDARY (search summary of https://spacetimedb.com/docs/databases/automatic-migrations and /incremental-migrations; pages themselves not read).
|
||||
|
||||
**License.** BSL 1.1, converts to AGPLv3 with linking exception "after a few years" (4-year period cited; change date not on the page; HN commenter magicmicah85 says each release converts 5 years after publication). Commenters: BSL limits production to one instance (themgt, _huayra_) — SECONDARY. README license text VERIFIED; instance-limit claim SECONDARY.
|
||||
|
||||
**Known complaints (all SECONDARY, HN/forums):**
|
||||
- 2.0 thread (https://hn.svelte.dev/item/49378933): reducers run under a lock (cmrdporcupine confirms from source); nemothekid "2015-era React Flux in Rust around a mutex"; Devont: vendor lock-in, feature churn, weak migration tooling, no LTS track; toolslive: user code in the DB raises crash/infinite-loop trust problems; benchmark credibility disputed (slopinthebag, Tostino, sabot90260).
|
||||
- 2025 thread (https://hn.svelte.dev/item/43631822): beders objected to schema defined by struct annotations; hesdeadjim called $0.28/GB egress brutal (gafferongames: non-starter at 100k CCU); LarsDu88/bob1029 on scaling.
|
||||
- Scaling thread (https://hn.svelte.dev/item/49563772): horizontal story is "separate databases the developer chooses" (search summary); durability disputed by rockwotj, rebutted by cloutiertyler.
|
||||
- Issue list top entries: Golang support, QUIC transport, Godot SDK, Dart, Elixir (https://github.com/clockworklabs/SpacetimeDB/issues). VERIFIED.
|
||||
- Which of these the operator means by "ergonomics gap" is not stated in sources; the gap as documented above is: client prediction/interpolation not provided, game code must be re-shaped into reducers, scale-out partitioning is the developer's own design.
|
||||
|
||||
---
|
||||
|
||||
## 2. Croquet / Multisynq
|
||||
|
||||
**What the dev writes.** Model (replicated, deterministic) and View (local) classes:
|
||||
```js
|
||||
class MyModel extends Multisynq.Model { init() { ... } }
|
||||
MyModel.register("MyModel");
|
||||
class MyView extends Multisynq.View { constructor(model){ super(model); } update(time){ ... } }
|
||||
```
|
||||
Events: `publish(scope, event, data)` / `subscribe(scope, event, handler)`; `this.future(100).step()` schedules in simulation time. VERIFIED (https://multisynq.github.io/multisynq-client/).
|
||||
|
||||
**Infers vs requires.** Infers: state replication (no state on the wire; every client runs the Model), snapshots for late joiners, ordering. Requires: Model self-contained, no functions stored (must be snapshot-serialisable), no async, Views never write the Model; `Math.random()` in Model is seeded identically on all replicas. `init()` runs only on first creation; joiners restore from snapshot, so classes must `register()`. VERIFIED (same page; https://github.com/multisynq/multisynq-client).
|
||||
|
||||
**Authority.** Not authority-by-node: the reflector is a clock/sequencer, "timestamps every event and advances simulation time"; heartbeats 20/s by default. VERIFIED (docs page above). All replicas are peers of equal standing; no designated state owner. Cheating: patents/mailing list say replicas stay in sync only if all run identical code (SECONDARY, USPTO filings and squeak-dev).
|
||||
|
||||
**Scaling.** Single session = single replicated island; reflector hosted on a "DePIN network" server near the first user. VERIFIED (README). Large-world / model-size / snapshot-cost limits: not found (UNVERIFIED). Every client must hold and simulate the entire Model (follows from design; VERIFIED as description).
|
||||
|
||||
**Determinism.** The whole Model; enforced by JavaScript VM conventions, not compiler. Origin Croquet used a bit-identical VM (SECONDARY, patents).
|
||||
|
||||
**Prediction.** None needed: input events are sequenced by the reflector, and clients run ahead of the sequence by simulating only known-ordered messages (no rollback; SECONDARY, squeak-dev post says "without need for rollbacks or consensus").
|
||||
|
||||
**Persistence / schema.** Snapshot is the persistence unit (VERIFIED as join mechanism). Schema evolution: not found.
|
||||
|
||||
**License.** `multisynq-client` repo Apache 2.0 and **archived read-only**. VERIFIED (https://github.com/multisynq/multisynq-client). Why archived: not stated in the page.
|
||||
|
||||
**Complaints.** None found in this pass beyond the archival status.
|
||||
|
||||
---
|
||||
|
||||
## 3. Photon Fusion 2 and Photon Quantum
|
||||
|
||||
**Fusion what the dev writes.** Attribute-annotated properties on `NetworkBehaviour`; logic in `FixedUpdateNetwork`; input as `INetworkInput` struct:
|
||||
```csharp
|
||||
public class PlayerBehaviour : NetworkBehaviour {
|
||||
[Networked] public float Health { get; set; } = 100;
|
||||
public override void FixedUpdateNetwork() { Health -= Runner.DeltaTime; }
|
||||
}
|
||||
```
|
||||
RPC: `[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]`. VERIFIED (https://doc.photonengine.com/fusion/current/manual/data-transfer/networked-properties and /fusion-intro). Rules: leave `[Networked]` as auto-property, change state in `FixedUpdateNetwork`, only supported blittable-ish types, collections need `[Capacity]`. VERIFIED.
|
||||
|
||||
**Fusion authority.** Same API, three topologies ("Modes"): Dedicated Server (server has exclusive State Authority), Client Host (host holds it, "price of the state authority's trustworthiness"), Shared Authority (each client has State Authority over what it spawns; the Photon Room "controls changes of state authority and holds a complete copy"). VERIFIED (https://doc.photonengine.com/fusion/current/manual/network-topologies). The page says Server and Host share one programming approach; Shared has its own tutorial series. VERIFIED. Interest-management differences per mode exist: Behaviour Interest `ReplicateTo()` not available in Shared; one AOI per player in Shared. VERIFIED (https://doc.photonengine.com/fusion/current/manual/interest-management). So: one API, behaviour diverges by mode.
|
||||
|
||||
**Fusion scaling/prediction.** Client-side prediction and lag compensation "out of the box"; configurable area-of-interest ("Scheduling and Interest Management" feature flag; `Runner.AddPlayerAreaOfInterest()`; per-object `ObjectInterest` Area/Global/Explicit). VERIFIED. Scale-out past one room/server: not described in pages read (UNVERIFIED). Handoff of state authority within Shared mode is room-controlled (VERIFIED above).
|
||||
|
||||
**Quantum what the dev writes.** Data in `.qtn` DSL generated into C#: `component Resources { Int32 Mana; FP Health; }`; stateless systems `Update(Frame frame)` with `frame.Filter<...>()`. VERIFIED (https://doc.photonengine.com/quantum/current/quantum-intro).
|
||||
- Determinism tax: "Be stateless: no mutable fields should be declared in systems"; "Quantum only guarantees determinism if all (mutable) game state data is fully contained in the Frame instance"; events must not drive gameplay; use provided fixed-point math, RNG, physics, pathfinding; no heap allocation at runtime. VERIFIED (https://doc.photonengine.com/quantum/current/manual/quantum-ecs/systems and intro). Explicit "no floats" sentence not found in fetched text.
|
||||
- Model: input-only exchange, local prediction, rollback by re-simulation, game-agnostic server component syncs clocks; an authoritative "referee" simulation can be added. VERIFIED (intro). Anti-cheat mechanism: not read.
|
||||
|
||||
**License/cost.** Both "free for development", paid beyond a free tier; terms not read. A forum post reports differing free-CCU numbers between web and Asset Store (20 vs 100) — SECONDARY, undated. Source availability of Fusion/Quantum: UNVERIFIED (not stated in pages read).
|
||||
|
||||
**Complaints.** Not researched beyond the above (UNVERIFIED).
|
||||
|
||||
---
|
||||
|
||||
## 4. Unity Netcode for GameObjects (NGO) and Netcode for Entities (N4E)
|
||||
|
||||
**NGO.** What dev writes: `NetworkBehaviour`, `NetworkVariable`, RPCs (code not retrieved; the NGO pages 301-redirected and returned landing text). The following is VERIFIED from https://mp-docs.dl.it.unity3d.com/netcode/current/basics/ownership/: default is client-server where the server holds authority (`IsServer`); an optional **distributed authority** topology where "authority over NetworkObjects isn't bound to a single server, but distributed across clients", "there's no concept of an authoritative server" and `HasAuthority` is the check; state should live in NetworkVariables with owner-write/everyone-read; ownership flags `None`, `Distributable` (auto-redistributed on join/leave), `Transferable`, `RequestRequired`, `SessionOwner`; `SetOwnershipLock`. So: one component API, two topologies chosen at session creation, where code written for one must use `IsServer` vs `HasAuthority` (VERIFIED statement that `IsServer` works only in client-server). A Unity issue-tracker report (SECONDARY) says NetworkVariable values can be lost when ownership is transferred immediately after a write in distributed authority. License: repo points to LICENSE.md; type not read (UNVERIFIED; commonly MIT). Distributed-authority hosting service pricing: not found.
|
||||
|
||||
**N4E.** "a server authoritive with client prediction framework", built on ECS. VERIFIED (landing page). Ghosts: server-simulated entities; `[GhostField(Quantization=1000)] public float MyFloatField;` only marked fields replicate; Supported/Default Ghost Mode (Interpolated, Predicted, Owner predicted); Importance, Static/Dynamic optimisation. VERIFIED (https://docs.unity3d.com/Packages/com.unity.netcode@1.3/manual/ghost-snapshots.html). Interest: **Relevancy** (`GhostRelevancyMode` Disabled/SetIsRelevant/SetIsIrrelevant; "Use Relevancy to avoid replicating entities that the player can neither see, nor interact with") plus distance-based **importance scaling** per chunk; snapshot size caps `NetworkStreamSnapshotTargetSize`, `MaxSendEntities`, etc. VERIFIED (.../optimizations.html). Prediction: the dev opts per ghost via mode and writes predicted systems in ECS (details of the prediction loop not read; UNVERIFIED). Authority handoff between servers: none seen.
|
||||
|
||||
---
|
||||
|
||||
## 5. Mirror and FishNet
|
||||
|
||||
**Mirror.** Dev writes `NetworkBehaviour` with `SyncVar`, `[Command]`, `[ClientRpc]`, `[TargetRpc]`; snippet: an `Enemy` with `[SyncVar] int health = 100`, `CmdShoot` reducing it on the server. VERIFIED (https://mirror-networking.gitbook.io/docs/manual/guides/synchronization/syncvars; listing summarised by tool). Authority: server-authoritative by default; client authority via flags (UNVERIFIED on this page). Interest management components added to the NetworkManager: Spatial Hashing, Hex Spatial Hashing, Distance, Scene, Scene Distance, Team, Match, Custom. VERIFIED (https://mirror-networking.gitbook.io/docs/manual/interest-management). Scaling claims (vendor, self-reported, no methodology): Inferna >1,000 CCU across multiple instances, Another Dungeon 5,000 CCU, benchmarks 400–800 CCU. VERIFIED as claims (https://github.com/MirrorNetworking/Mirror). Prediction listed "Researching", Lag compensation/Cheat detection Beta, Bitpacking researching. VERIFIED (same page). License MIT. VERIFIED. Cross-server handoff: none (multiple instances, no migration documented).
|
||||
|
||||
**FishNet.** Dev writes `[ServerRpc]` / `[ObserversRpc]` (benchmark snippet) plus SyncTypes; server-authoritative by design with host mode; its own movement tutorial uses client-authoritative `NetworkTransform` and warns of speed hacks/teleports. VERIFIED (https://fish-networking.gitbook.io/docs/llms-full.txt, first 100k chars of 427k read). Built-in client-side prediction and rigidbody prediction; AOI with multiple conditions; paid Pro tier lists lag compensation, LOD, code stripping, extrapolation, Yak. VERIFIED. "no limit on concurrent users" for free use. VERIFIED. License type: not shown (UNVERIFIED). Prediction V2 details: not in fetched portion.
|
||||
|
||||
---
|
||||
|
||||
## 6. Unreal replication and Iris
|
||||
|
||||
**What the dev writes.** `UPROPERTY(Replicated)`, `GetLifetimeReplicatedProps`, RPC specifiers Server/Client/NetMulticast, `HasAuthority`, `NetUpdateFrequency`, dormancy (the replication-basics page returned only a table of contents; names VERIFIED as headings). Description: "The server ... holds the one, true, authoritative game state"; replicated, RepNotify properties; actors need `bReplicates`; creation, destruction and basic movement replicate automatically, everything else must be designated. VERIFIED (https://dev.epicgames.com/documentation/en-us/unreal-engine/networking-overview-for-unreal-engine). Authority model: server only; listen-server host is the same model (UNVERIFIED on page).
|
||||
|
||||
**Interest.** Generic system: per-connection **relevancy**, dormancy, priority. Iris: "filtering controls the connections an actor replicates to" plus prioritisation, quantised full copy of replicated state, opt-in, labelled **Experimental**, toggle `-UseIrisReplication=1`; claims larger worlds and higher player counts, built from Fortnite Battle Royale experience (100 players/instance). VERIFIED as claims (https://dev.epicgames.com/documentation/en-us/unreal-engine/introduction-to-iris-in-unreal-engine). Opting in requires game code to use new engine APIs; existing properties/RPCs compatible with minor modifications. VERIFIED.
|
||||
|
||||
**Prediction.** Overview page does not describe it beyond pointing to Character Movement; Network Prediction plugin page returned only headings (UNVERIFIED status). **Scale-out:** no multi-server world in engine; Pax Dei (below) builds zone servers on Kubernetes around it (SECONDARY, vendor FAQ). Licensing: UNVERIFIED this pass (source-available EULA with royalty, from memory).
|
||||
|
||||
---
|
||||
|
||||
## 7. Colyseus
|
||||
|
||||
Dev writes: schema classes and rooms.
|
||||
```ts
|
||||
import { Schema, type } from "@colyseus/schema";
|
||||
export class MyState extends Schema { @type("string") currentTurn: string; }
|
||||
```
|
||||
Room has `maxClients`, `state = new MyState()`, `onCreate/onJoin/onLeave/onDispose`, a `messages` handler object. VERIFIED (https://docs.colyseus.io/state/schema and https://docs.colyseus.io/). Sync: assign state to room, changes sent as tick patches, full state on join; a Schema holds at most 63 synchronisable fields (nest to exceed). VERIFIED. Authority: server-authoritative rooms (page says implied, not stated). Scale: "Rooms are equally distributed across the available processes", each room lives in one process, Redis presence/driver required for multi-process, load balancer entry point; per-process max players left to FAQ. VERIFIED (https://docs.colyseus.io/scalability). No cross-room shared state documented (page silent). No prediction, no spatial interest management (room = interest scope; UNVERIFIED that none exists). MIT; "10 to 10,000+ CCU" is a vendor claim; roadmap to 1.0 linked; sustainability via Colyseus Cloud. VERIFIED (https://github.com/colyseus/colyseus).
|
||||
|
||||
---
|
||||
|
||||
## 8. Nakama (Heroic Labs)
|
||||
|
||||
Dev writes server modules in Go, Lua or TypeScript/JS ("recommends the JavaScript VM") and an authoritative **match handler** with seven functions: Match Init, Join Attempt, Join, Leave, Loop, Terminate, Signal; the loop runs at a developer-set tick rate (e.g. 10/s = 100 ms budget; if loops fall behind the server catches up, then ends the match). VERIFIED (https://heroiclabs.com/docs/nakama/concepts/multiplayer/authoritative/). Dev owns match state and all sync/prediction in code; framework supplies accounts, storage, matchmaking, chat, leaderboards. VERIFIED (README). Scale: "A single Nakama node hosts each match and its state"; clustering "Enterprise only" replicates presences, balances matches, migration needs no code change. VERIFIED (same page). Apache-2.0, Go, requires CockroachDB/Postgres. VERIFIED (https://github.com/heroiclabs/nakama). Interest management, prediction: none documented in what was read.
|
||||
|
||||
---
|
||||
|
||||
## 9. Roblox
|
||||
|
||||
Dev writes Luau scripts against a replicated DataModel; "The Roblox server is the ultimate authority for maintaining the game's state"; replication covers the data model, physics, chat; client-to-server, server-to-client, server-to-all flows. VERIFIED (https://create.roblox.com/docs/projects/client-server). Interest management: `Workspace.StreamingEnabled` (default for new places): `StreamingMinRadius` 64, `StreamingTargetRadius` 1024, models Nonatomic/Atomic/Persistent/PersistentPerPlayer, replication focus defaults to the character; dev must make client scripts tolerate parts streaming in/out (`WaitForChild`, `PersistentLoaded`); `StreamingIntegrityMode` can pause gameplay in unloaded areas. VERIFIED (https://create.roblox.com/docs/workspace/streaming). Network ownership: unanchored parts auto-assigned to nearby clients; `SetNetworkOwner`; "Roblox cannot verify physics calculations when a client has ownership"; server must validate. VERIFIED (https://create.roblox.com/docs/physics/network-ownership). Prediction: not read. Scale-out: each place instance is one server (UNVERIFIED this pass; not in pages read). Not open source (UNVERIFIED).
|
||||
|
||||
---
|
||||
|
||||
## 10. Improbable SpatialOS
|
||||
|
||||
- Concepts (workers that each see and update a subset of the world, entities of components with schema-defined data, authority over components moved between workers, platform handles load balancing and persistence; locality of reference between virtual and datacenter proximity). SECONDARY (gamedev.net thread https://gamedev.net/forums/topic/689484-spatialos-single-shard-mmo, 80.lv, Cambridge talk abstract, a patent on handover). The official docs were not reachable; interest/checkout/zoning terms not confirmed.
|
||||
- Timeline: 2017 open beta, $502m Series B, Worlds Adrift and Lazarus cancelled 2019, Unity ToS dispute 2019 (Improbable said managed services breached Unity's new terms; $25m Epic/Improbable fund), GDK for Unity repo archived 2022-01-18 with existing users pointed to a migration to "SpatialOS running on zeuz". VERIFIED (https://github.com/spatialos/gdk-for-unity) for the archive; timeline VERIFIED from https://en.wikipedia.org/wiki/Improbable_(company) (Wikipedia, so effectively SECONDARY).
|
||||
- Why it failed commercially: no official postmortem found. Candidate factors in sources, all SECONDARY: the Unity ToS block (Variety, HN https://news.ycombinator.com/item?id=18874400); cloud lock-in to Google and unpublished per-use pricing, a developer (Soulbound Studios) moving to its own engine (HN, GDC coverage); forum judgments "almost unusable" and synchronous cross-entity waits being a "deal killer" for real-time (gamedev.net, informal). Improbable later pivoted to studios, defence, and Web3 (Wikipedia).
|
||||
- Handoff/determinism/prediction: not documented in reachable sources.
|
||||
|
||||
---
|
||||
|
||||
## 11. Hadean / MetaGravity
|
||||
|
||||
- Hadean Aether Engine: "world's first distributed simulation engine", each core manages a chunk, neighbouring chunks communicate, runs on Azure; used for EVE: Aether Wars at GDC 2019 (3,852 human players, 14,274 total ships incl. AI clients, 10,412 peak combatants, 88,988 ships destroyed in one hour); partitioning details not given. VERIFIED as claims (https://fenris.com/news/2019/eve-aether-wars-successfully-achieves-groundbreaking-14-000-ship-battle); engine description SECONDARY (search summary of PR).
|
||||
- MetaGravity: London, target 1M+ concurrent users; CEO Rashid Mansoor (earlier Hadean founder) contrasts "spatial partitioning" with its own "causal partitioning" algorithm. SECONDARY (job-board page and press summaries). Technical documentation: not found. Current status of both: UNVERIFIED.
|
||||
|
||||
---
|
||||
|
||||
## 12. EVE Online and Dual Universe
|
||||
|
||||
**EVE Online.** Tranquility "is split into a series of nodes"; solar systems are redistributed across nodes at daily downtime; busy systems (Jita) get dedicated capacity; spare nodes held in reserve for planned battles; a node overloaded by one system slows other systems on it. VERIFIED (https://www.eveonline.com/news/view/fleet-fight-notification-tool). Time dilation: per-node, slows "almost all in-space actions", floor 10% speed, timers like skill training unaffected. SECONDARY (EVE University wiki https://wiki.eveuniversity.org/Time_Dilation). The CCP page does not say "single shard"; the Python/SQL stack and node/proxy split were not confirmed (UNVERIFIED). Handoff: a solar system is the indivisible unit; moving between nodes happens only at downtime (VERIFIED), and a ship jumping between systems moves between their nodes (UNVERIFIED mechanics).
|
||||
|
||||
**Dual Universe.** Server tech called **CSSC** (Continuous Single-Shard Cluster), not "CSSA"; "all players in one shared world, no instancing or zoning"; Lua scripting by players in-world; released 2022-09-27; delisted from Steam 2025-07-25, servers shut down 2025-08-27. VERIFIED (https://en.wikipedia.org/wiki/Dual_Universe) / SECONDARY. No internal architecture write-up found; a French report attributes closure to economy/content problems, not tech (SECONDARY). MyDU player-hosted server exists (SECONDARY).
|
||||
|
||||
---
|
||||
|
||||
## 13. Open-source MMO server playbooks
|
||||
|
||||
| Project | What it is | Evidence | Coherent playbook? |
|
||||
|---|---|---|---|
|
||||
| Astron | Distributed object server suite from Disney's OTP (2001–2013): DistributedObjects with fields, shared "DC file" protocol description with keywords (`ram`, `required`, `db`, `broadcast`, `clsend`, `ownsend`...), roles Message Director, Client Agent, State Server, Database Server, Database-State Server; hierarchy of zones with client "interest" requests. Modified BSD. "Development on Astron has been discontinued." VERIFIED (https://github.com/Astron/Astron). | Declarative state + routing keywords + interest by zone + persistence flag is the closest ancestor to the "annotate your POCOs" idea in this survey. | A coherent architecture, but discontinued and not a shipped-game playbook documented end to end. |
|
||||
| AzerothCore / TrinityCore | WoW 3.3.5a emulators: separate auth server, world server, three MySQL databases (auth, world, characters), DB worker threads, modules. AGPLv3 / GPLv2 mix. SECONDARY (search summaries, https://trinitycore.atlassian.net, localtonet blog). | A forum thread on 15k+ CCU server topology had only speculation. Map-thread design not found. | Architecture of one monolithic realm per world server; reproduces a closed game, not a reusable playbook. |
|
||||
| Ryzom Core | Open-source MMORPG framework with pre-built "shard framework meant to scale", since 2001, powers the commercial Ryzom. SECONDARY (search summary; wiki page returned empty). | | Possibly; not read. |
|
||||
| Folia (Paper) | Minecraft server fork: nearby loaded chunks grouped into independent regions with own 20 TPS tick on a thread pool; no cross-region data access, cross-region work via RegionScheduler/EntityScheduler; plugin API broken (`teleportAsync`). VERIFIED (https://github.com/PaperMC/Folia). | Concrete evidence on the cost of regionised multithreading to existing user code. | A single-process technique, not a playbook. |
|
||||
| Veloren | Open-source Rust voxel RPG; crates `server`, `client`, `common`, `network`. VERIFIED crate list (https://book.veloren.net/contributors/developers/codebase-structure.html); authority and scaling not covered by the page. | | Not shown. |
|
||||
| Pax Dei (commercial, FAQ only) | Shards as independent world copies in one datacenter; zones simulated by dedicated Unreal servers on Kubernetes; zone transition moves connection to the adjacent zone server; ~150 players per zone server; overflow split into instances; no cross-boundary visibility. VERIFIED as claims (https://playpaxdei.com/en-us/faq/tech; the FAQ states conflicting shard-capacity figures). | Real zone-handoff design disclosed. | Not open source. |
|
||||
| Others named in search results | ScalaMo (research), Free-MMORPG Unity framework (community). Project Darkstar/RedDwarf, Atavism, Open Match, Agones: searched, no results read. | | UNVERIFIED. |
|
||||
|
||||
No source found that presents an end-to-end open-source recipe for shipping an MMO (state model, authority, interest, handoff, persistence, deployment). Astron is the closest architectural description; none was found that is current and maintained.
|
||||
|
||||
---
|
||||
|
||||
## 14. Design-question matrix
|
||||
|
||||
"-" = not found in sources read (not a claim of absence).
|
||||
|
||||
| Question | SpacetimeDB | Multisynq | Fusion 2 / Quantum | NGO / N4E | Mirror / FishNet | Unreal / Iris | Colyseus | Nakama | Roblox | SpatialOS | EVE / Astron |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Declaring state | Annotated struct = table (`#[table]`, `[Table]`) | Model class fields, `register()` | `[Networked]` property / `.qtn` DSL component | NetworkVariable / `[GhostField]` | `[SyncVar]` / SyncTypes | `UPROPERTY(Replicated)` | `@type` Schema (max 63 fields) | Dev-defined match state (code) | Instances in DataModel | Schema-defined components (SECONDARY) | Astron: DC file dclass fields with keywords; EVE: - |
|
||||
| Declaring authority | Implicit: DB module; reducer checks `ctx.sender` | None: all replicas equal, reflector orders | Mode: Server / Host / Shared (State Authority, Input Authority) | NGO: client-server or distributed authority + ownership flags; N4E: server | Server default; client-auth flags/NetworkTransform | Server; `HasAuthority` | Room server | Match handler on server | Server; network ownership of physics | Workers hold authority per component (SECONDARY) | Astron: field keywords `clsend/ownsend/broadcast`; EVE: node owns system |
|
||||
| Prediction / rollback opt-in | None; open issue | None needed (sequenced input) | Fusion: built in; Quantum: mandatory rollback | N4E: per-ghost mode + predicted systems; NGO: - | FishNet: built in; Mirror: researching | Character movement; Network Prediction plugin status - | None | None | - | - | - |
|
||||
| Interest management | Subscription queries | None (all state everywhere) | AOI (`ObjectInterest`, `AddPlayerAreaOfInterest`), limited in Shared | N4E: relevancy + importance; NGO: - | Mirror: spatial hash/distance/scene/team; FishNet: observer conditions | Relevancy, Iris filtering | Room = scope | Match presences | StreamingEnabled radii | Worker checkout (not confirmed) | Astron: zone interest |
|
||||
| Authority handoff between shards | Planned IDC (2026-10-31, async); sync IDC planned | None | Shared-mode room reassigns state authority | NGO distributed authority ownership transfer; N4E: - | - | - (Pax Dei: zone transition on connection) | Rooms don't hand off | Match pinned to one node | - | Authority handover (patent, SECONDARY) | EVE: system fixed to node, rebalanced at downtime |
|
||||
| Persistence | Built in WAL, in-memory | Snapshot | - | - | - | - | - | Storage + DB backend | Roblox services (-) | Platform persistence (SECONDARY) | Astron: `db` keyword + DB server |
|
||||
| Schema evolution | Auto-migrate subset, `--break-clients`, new-table pattern | - | - | - | - | - | - | - | - | - | Astron: DC file id order = 16-bit ids (VERIFIED) |
|
||||
| Determinism tax on dev code | Reducers: no I/O, context RNG/time | Whole Model: no I/O/functions/async | Fusion: tick-based code; Quantum: stateless systems, fixed-point, all state in Frame | N4E: prediction systems rerun | Low | Low | Low | Low | Low | - | - |
|
||||
| Past one server | Many DBs, developer partitions | One session | Photon Room / server | Not described | Multiple instances | One instance (Pax Dei adds zones externally) | Processes + Redis, one room per process | Enterprise cluster | One server per place (UNVERIFIED) | Workers across machines | EVE nodes; Hadean chunks per core |
|
||||
| One model or products | One model | One model | Fusion: one API, 3 modes; Quantum separate product | NGO: two topologies in one package; N4E separate stack | Separate libs | Single model | Single | Single | Single | Single | - |
|
||||
|
||||
---
|
||||
|
||||
## 15. Facts bearing on the "configurable subset" claim (server-auth + trusted co-op as subsets)
|
||||
|
||||
- Photon Fusion is a shipped example of server, client-host and shared-authority modes under one API, with documented divergences (Behaviour Interest absent in Shared; one AOI per player; the Shared tutorial series is separate). VERIFIED.
|
||||
- NGO ships client-server and distributed authority in one package; the check you write changes (`IsServer` vs `HasAuthority`), and variable write permissions are configured per mode. VERIFIED.
|
||||
- Multisynq/Croquet has no authority concept; Quantum's authority is input-driven determinism with an optional referee. VERIFIED.
|
||||
- SpacetimeDB and Colyseus offer only the single-authority model. VERIFIED.
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
# Stage 1 forks: prior art for six open questions (2026-10-27)
|
||||
|
||||
Campaign `cultmesh-garden-path`. Fills the gaps named by six questions: `browser-transport`, `tick-model`, `tick-state-commit`, `room-durability`, `player-identity`, `browser-client-store`. Facts with evidence pointers; no recommendation. Already kept and not repeated: `prior-art-2026-10-27.md`, `browser-sim-runtime-2026-10-27.md`, `witness-authoritative-networking/prior-art-2026-10-26.md`.
|
||||
|
||||
Tags: VERIFIED = read on the primary page in this pass. SECONDARY = third-party, forum, or search-engine summary. UNVERIFIED = looked for, not found, or not readable.
|
||||
|
||||
Method note: WebFetch summarises pages with a small model, so quoted numbers are as that tool returned them. Valve's wiki (403), Unreal docs (403), GDC Vault (maintenance redirect) and archive.org were unreachable; those claims are SECONDARY at best.
|
||||
|
||||
---
|
||||
|
||||
## 1. `browser-transport`
|
||||
|
||||
### 1.1 WebTransport browser support (today is 2026-10-27)
|
||||
|
||||
| Browser | WebTransport | `datagrams` | Notes |
|
||||
|---|---|---|---|
|
||||
| Chrome desktop | 97 | 97 | VERIFIED (MDN browser-compat-data `api/WebTransport.json`) |
|
||||
| Edge | 98 | follows Chromium | VERIFIED (caniuse.com/webtransport) |
|
||||
| Firefox desktop | 114 | 114 | VERIFIED (BCD) |
|
||||
| Safari macOS | 26.4 | 26.4 | VERIFIED (BCD; caniuse; MDN says "Baseline 2026, Newly available", since March 2026) |
|
||||
| Safari iOS | 26.4 | 26.4 | VERIFIED (BCD, caniuse) |
|
||||
| Chrome Android | 156 | not separately listed | VERIFIED (caniuse only; BCD lists no Android split) |
|
||||
| Firefox Android | 158 | not separately listed | VERIFIED (caniuse) |
|
||||
| Samsung Internet | 18.0 | | VERIFIED (caniuse) |
|
||||
|
||||
- caniuse reports about 91.6 % global usage. VERIFIED (caniuse.com/webtransport).
|
||||
- Sub-features that matter for a game (BCD, VERIFIED): `serverCertificateHashes` Chrome 100 / Firefox 125 / Safari 26.4. `congestionControl` Chrome none / Firefox 114 / Safari 26.4. `sendOrder` Chrome none / Firefox 119 / Safari 26.4. `reliability` Chrome 157 / Firefox 114 / Safari 26.4. `supportsReliableOnly` Chrome 157 / Firefox 158 / Safari 26.4. `createUnidirectionalStream` Firefox "partial".
|
||||
- Safari 26.4's WebKit feature post does not mention WebTransport in the page read. VERIFIED (absence on webkit.org/blog/17333). Safari 26.4 shipping is confirmed by BCD and caniuse, so treat the blog omission as an incomplete read, not a contradiction.
|
||||
- One third-party page claims Safari's wire implementation sits in Apple's Network.framework (so the floor is OS 26.4, not only Safari), and that WKWebView support is unconfirmed; tested on an iOS 27 beta. SECONDARY, provisional (echo.semantic-ui.com/compat via search).
|
||||
- Runtime feature test: `typeof WebTransport === "undefined"`; `WebTransport.supportsReliableOnly`; instance `reliability` says whether a given session has unreliable datagrams. VERIFIED (MDN WebTransport page).
|
||||
- Self-signed certificates: Kestrel docs say browsers do not allow self-signed certificates on HTTP/3, including the dev certificate. VERIFIED (learn.microsoft.com Kestrel HTTP/3 page). `serverCertificateHashes` (above) is the browser-side escape, with a short certificate validity limit (the Kestrel experimental doc's test certificate uses 14 days; the W3C rule itself was not read, UNVERIFIED).
|
||||
|
||||
### 1.2 Server side on .NET
|
||||
|
||||
- HTTP/3 in Kestrel: fully supported since .NET 7; not enabled by default (`HttpProtocols.Http1AndHttp2AndHttp3` plus HTTPS); depends on MsQuic; falls back silently if MsQuic is missing. The same text applies to the .NET 8, 9, 10 monikers; a .NET 11 moniker exists. VERIFIED (learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/http3, page updated 2026-10-08). The page does not mention WebTransport at all.
|
||||
- System.Net.Quic APIs are stable from .NET 9. SECONDARY (search summary of learn.microsoft.com QUIC overview). Windows ships msquic.dll with the runtime; Linux needs `libmsquic` installed. SECONDARY (same).
|
||||
- Kestrel WebTransport: experimental since .NET 7 RC1 (2022-09). Needs `EnablePreviewFeatures` plus the runtime option `Microsoft.AspNetCore.Server.Kestrel.Experimental.WebTransportAndH3Datagrams`. Implements "most of the WebTransport draft-02 specification, except for datagrams"; datagrams planned later; server cannot initiate sessions. VERIFIED (github.com/dotnet/aspnetcore docs/WebTransport.md). Browsers ship the later WebTransport drafts (draft number the browsers speak was not looked up, UNVERIFIED), so draft-02 interop with current Chrome/Firefox/Safari is not shown by any source read.
|
||||
- Tracking issue dotnet/aspnetcore#42788 is open, milestone Backlog (moved from ".NET 8 Planning" 2024-02-14), no assignee; datagrams are a separate issue #42784. VERIFIED (issue page). dotnet/runtime#43641 "Add support for WebTransport" is open, milestone Future, `api-suggestion`. VERIFIED (issue page). No .NET 10 or 11 announcement of WebTransport graduating was found. UNVERIFIED that none exists.
|
||||
- System.Net.Quic datagram surface: a third-party package note says System.Net.Quic does not surface msquic datagrams. SECONDARY (nuget package page via search). No dotnet/runtime issue for it was found. UNVERIFIED.
|
||||
- Maintained libraries: the only C# WebTransport package found is `Demiurgos.WebTransport.Client` 0.2.1 (2026-06-09), client only, Windows x64 preview, native MsQuic backend, has datagram send/receive; Linux, macOS, WebGL, mobile "not yet". VERIFIED (nuget.org page). No C# WebTransport server library usable with browsers was found. UNVERIFIED that none exists.
|
||||
- Non-.NET reference point: Colyseus ships WebTransport as `@colyseus/h3-transport` on `@fails-components/webtransport` (Node), marked experimental, "hasn't been battle tested", "not widely available yet". VERIFIED (docs.colyseus.io/server/transport/webtransport).
|
||||
|
||||
### 1.3 What shipped browser games use
|
||||
|
||||
- Colyseus: WebTransport is its only transport with an unreliable channel (QUIC datagrams); `.unreliable()` schema fields and `room.input({mode:"unreliable"})` (redundant recent inputs) use it; on WebSocket those fields stop updating after the initial sync, and unreliable input falls back to the reliable channel (redundancy wasted). Client WebTransport is JS/TS SDK only. VERIFIED (same Colyseus page).
|
||||
- Rune (mobile/web multiplayer game platform) engineering post compares WebSockets and WebRTC data channels, says TCP head-of-line blocking needs UDP, "in the current web browser, that means WebRTC", and that WebTransport "isn't ready yet". Its own choice is not stated; no loss measurements. VERIFIED (developers.rune.ai/blog/webrtc-vs-websockets-for-multiplayer-games). The post is undated in the fetch.
|
||||
- Safari 26.0 added WebSocket over HTTP/2 and HTTP/3. VERIFIED (webkit.org features-in-safari-26-4 page, mentioned in its HTTP section).
|
||||
- No source found documenting which transport named .io games (agar.io, slither.io, krunker) use, nor any measured WebSocket stall under loss for them. UNVERIFIED. General guides recommend WebSocket for server-authoritative games needing wide support, WebRTC data channels for unreliable traffic (signalling and TURN cost), WebTransport as narrower-support newcomer. SECONDARY (search summaries of ably.com, openreplay.com, rune).
|
||||
|
||||
---
|
||||
|
||||
## 2. `tick-model`
|
||||
|
||||
Question's own framing: every framework already read uses one fixed simulation tick. Below: what varies per system, entity, or client.
|
||||
|
||||
- Unreal: `NetUpdateFrequency` is a per-actor maximum replication rate in Hz; docs give typical values 10 for important player-controlled actors, 5 for slower AI, 2 for background actors. `MinNetUpdateFrequency` is the floor when adaptive update is on (`net.UseAdaptiveNetUpdateFrequency=1`, off by default in the 4.27 docs; a forum thread says it became default-on at 4.16 then regressed latency for some). Ramp-down happens when no replicated property changed. SECONDARY (search summary of docs.unrealengine.com 4.27 Actor Properties and dev.epicgames.com; the pages themselves returned 403 or off-topic). The server simulation still ticks at one rate; only the replication cadence is per actor. Server tick rate (`NetServerMaxTickRate`) relation: UNVERIFIED.
|
||||
- Netcode for Entities (Unity): snapshots are sent at `NetworkTickRate`, which "can differ from `SimulationTickRate`"; when lower, connections are split into round-robin subsets sent on successive ticks. `MaxSendRate` is per ghost prefab, "only denotes the maximum possible replication frequency, and cannot be enforced in all cases", capped by the tick rate (100 Hz cap on a 60 Hz default stays 60; 45 Hz on 30 Hz rounds down to 30). Importance (per chunk, grows with `ticksSinceLastSent`) picks what fits in bandwidth; `GhostImportance.BatchScaleImportanceFunction` can scale per chunk and per connection. VERIFIED (docs.unity3d.com/Packages/com.unity.netcode@1.8/manual/ghost-snapshots, fetched; plus search summaries of the 1.4 to 1.9 API pages). Fixed: one simulation tick. Varies: per-prefab and per-connection send cadence.
|
||||
- Photon Fusion 2: tick rate and send rate were locked to 32 and 16 in Fusion 2.0; from 2.1 tick rate is selectable 8 to 256 (Shared mode capped at 32); send rate is an integer interval 1 to 8 ("1 sends every tick, 2 every second tick") and only divisors of the tick rate are offered; server and client send rates are separate settings. VERIFIED (doc.photonengine.com/fusion/current/manual/network-project-config). Replication features: scheduling (default) raises a culled object's priority for the next outgoing tick; optional Area of Interest. VERIFIED (same page). One simulation tick; send is a divisor of it. This is the "fixed-with-divisors" shape for sending, applied to snapshots rather than to systems.
|
||||
- Valve Source: server simulates at the tick interval (commonly 66 or 100 Hz in community servers; default 66 for older titles is not verified); client sends usercmds at `cl_cmdrate` (a wiki line says usually 30) and requests snapshots at `cl_updaterate` (default 20); the server "will never send more updates than simulated ticks" nor above the client request or `sv_maxupdaterate`. SECONDARY (search summaries of developer.valvesoftware.com Source Multiplayer Networking; page returned 403; defaults differ between community sources). Fixed: server tick. Varies: per-client command and update rates.
|
||||
- Overwatch GDC 2017 (Timothy Ford): the talk page confirms topic only. A secondary article says the simulation runs on fixed 16 ms command frames, 7 ms in tournament mode; a forum summary says the client runs ahead of the server by about half RTT plus one command frame. SECONDARY (edgegap.com blog; gamedev.net thread; GDC Vault page abstract only). Talk slides not read. UNVERIFIED for any per-system rate.
|
||||
- Nakama and Colyseus (already kept): one developer-set tick.
|
||||
- Per-system independent timelines with prediction and reconciliation: none found in this pass. UNVERIFIED that none exists. In every case read above, what is allowed to vary is the send/replication cadence per object, prefab, or client, derived from one simulation clock.
|
||||
|
||||
---
|
||||
|
||||
## 3. `tick-state-commit`
|
||||
|
||||
- SpacetimeDB commit path: each database is a single-threaded actor; database lock held only for the transaction at the head of the pipeline; table data held in memory on the leader. VERIFIED (spacetimedb.com/blog/how-does-spacetime-scale). It "never exposes any data to users that has not been persisted to disk, unless you specifically enable it", holding responses in a buffer until persisted ("a form of early lock release") and pipelining writes to disk. VERIFIED (spacetimedb.com/blog/benchmarking). Per-transaction fsync vs group commit: not stated in either page. UNVERIFIED.
|
||||
- SpacetimeDB measured throughput (vendor, single node, Intel 14900K): 303,920 ± 4,712 TPS (TypeScript harness, power-law contention) and 265,541 ± 940 (Rust harness); 279,025 uncontended; latency p50 about 7.4 to 8.1 ms, p99 about 11.7 to 12.9 ms. Transactions are balance transfers sent as individual requests with 40 in flight per client; 5 min runs with 30 s warmup. VERIFIED (spacetimedb.com/blog/benchmarking). The vendor says an earlier March 2026 claim of over 100,000 TPS was misleading because of database bugs. SECONDARY (search summary of the same blog's corrections). Competing backends in the same post: Node plus SQLite about 3,100 to 3,200 TPS; Bun plus Postgres 10,730 (uncontended) and 2,773 (contended). VERIFIED (same). These are end-to-end request throughput, not per-tick batch commit cost.
|
||||
- Embedded document store or ECS per-tick commit cost: no measurement found for per-tick commits in an embedded document store or an ECS persistence layer. UNVERIFIED. Nearest numbers: a SQLite forum post on a WAL2 branch, NVMe, 6 inserts per transaction: 26,425 TPS with synchronous=OFF, about 13,000 with NORMAL, 9,082 with FULL. SECONDARY (sqlite.org forum via search; non-stock build). SQLite docs: WAL with synchronous=NORMAL is power-safe but a committed transaction can be lost on power failure. SECONDARY (search summary of sqlite.org).
|
||||
- How game servers persist (never or periodic, none per tick in the sources read):
|
||||
- Minecraft Bukkit-family servers autosave every 6000 ticks (5 minutes); a hosting guide warns shorter intervals strain the server. SECONDARY (shockbyte.com guide; the same guide says vanilla has no automatic save, conflicting with another page).
|
||||
- Factorio: optional non-blocking autosave is Linux-only and works by `fork()`, the child writes the save while the parent keeps simulating. SECONDARY (forums.factorio.com threads).
|
||||
- Colyseus and Nakama: state is in memory; persistence is developer code at dispose or terminate (see section 4).
|
||||
- Photon Quantum: all mutable state lives in one Frame per tick (already kept); persistence not described.
|
||||
|
||||
---
|
||||
|
||||
## 4. `room-durability`
|
||||
|
||||
- Colyseus production: rooms are in-memory; `onDispose()` "suggested to persist data such as player data after a match"; graceful shutdown runs `onBeforeShutdown` (default disconnects all clients, disposes rooms), clients get `CloseCode.SERVER_SHUTDOWN`; no production room restore described. VERIFIED (docs.colyseus.io/room/lifecycle). The only restore hooks, `onCacheRoom`/`onRestoreRoom`, apply only in `devMode`, which caches room state and clients' sessionId seat reservations locally and recreates rooms after a restart. The docs say "Do not use devMode in a production environment" and that it is not optimised for many rooms. VERIFIED (docs.colyseus.io/server/devmode). Crash without planned restart: not covered. UNVERIFIED. Forum answers: rooms are "meant to be ephemeral"; `LocalPresence` persists nothing; cache state in `onDispose` and reload in `onCreate`; a two-node RedisPresence user lost all state when one node crashed. SECONDARY (discuss.colyseus.io posts, 2019-2020, search summary).
|
||||
- Colyseus reconnection after a drop: server holds the seat for `allowReconnection(client, seconds)` (docs suggest about 30 s for fast games, 5 min for turn-based); client persists `room.reconnectionToken` (docs example uses `sessionStorage`) and calls `client.reconnect(token)`; the token only works while the server still holds the seat; page refresh returns a new Room object with a full state. VERIFIED (docs.colyseus.io/room/reconnection). What a process restart does to held seats outside devMode: not stated. UNVERIFIED.
|
||||
- Nakama authoritative matches: Heroic Labs staff (2019-08-09): "When the server shuts down there is no choice but to stop running matches"; pattern is to save state to a system-owned storage object in the terminate hook within the shutdown grace period and rebuild it in match init; 2022 follow-up says size the grace period for the match count and launch Nakama via `exec` so signals arrive; moving matches to another instance needs a roster of instances and an RPC. VERIFIED (forum.heroiclabs.com/t/preserve-match-states-after-server-restart/133). A hard crash skips the terminate window (inference from that thread; the thread does not discuss crashes explicitly). Per-VM crash isolation differs between the Lua runtime (VM per match) and Go (shared). SECONDARY (forum.heroiclabs.com thread via search).
|
||||
- Photon Realtime rejoin: needs a stable UserId and `PlayerTtl` at room creation; `EmptyRoomTTL` should be at least `PlayerTTL` if room state is not saved; reconnect with a fresh anonymous UserId after app restart fails with error 32748. SECONDARY (forum.photonengine.com threads via search). A TTL is held by the Photon server process; whether it survives a Photon server restart: UNVERIFIED.
|
||||
- SpacetimeDB persistence: write-ahead log; table data in memory, restored from log (already kept in `prior-art-2026-10-27.md`).
|
||||
- .io games (agar.io-likes): no source found on restart or crash behaviour. UNVERIFIED. The open-source Agar.io server clones (Ogar, Cigar) were located by search but not read.
|
||||
- Pattern across Colyseus, Nakama and Photon: session-scoped game state is not durable by default; the vendor-documented route is application code that saves at a shutdown hook, plus a reconnect window measured in seconds to minutes held in server memory.
|
||||
|
||||
---
|
||||
|
||||
## 5. `player-identity`
|
||||
|
||||
- Colyseus: each connection gets a `sessionId`; a refreshed `reconnectionToken` is the resume credential, stored by the app in `sessionStorage`; valid only while the seat is held. VERIFIED (docs.colyseus.io/room/reconnection). No account or key-pair concept in these pages; identity beyond that is the developer's, with a separate Auth module not read. UNVERIFIED for the Auth module.
|
||||
- Nakama device auth: client-generated device id, 10 to 128 bytes alphanumeric with dashes; docs say to generate a random UUID on first launch and keep it in app-private storage, not an OS hardware id; unknown id creates an account by default (`create=false` disables); an account can link several device ids (link of an id owned by another account returns 409; unlinking the last returns 403); docs warn device ids "can be unreliable because device identifiers can sometimes change" and suggest Google or Game Center for games. VERIFIED (heroiclabs.com/docs/nakama/concepts/authentication, first 100k characters read). The id is a bearer secret with no proof of possession beyond knowing it (inference from the page; not stated as such).
|
||||
- Photon Realtime `UserId`: server assigns one if the client sends none; rejoin requires the same UserId; a client-chosen UserId without Custom Authentication is unverified, so anyone can claim another player's id; Custom Authentication lets an external provider return the UserId, overriding the client's. SECONDARY (search summaries of doc.photonengine.com Realtime v5 userids pages and forum threads; the current docs URL I tried was 404).
|
||||
- PlayFab `LoginWithCustomID`: the id is chosen by the client; Microsoft's best-practices page limits its published-game use to pairing with a pre-existing backend and recommends moving to another login before launch; "encrypted logins" hardening exists but must be on from the beginning; a community thread asks how to stop clients spamming random ids to create accounts, unanswered in the result. SECONDARY (search summaries of learn.microsoft.com login-basics-best-practices, devdocs.xbox.com encrypted-logins, community.playfab.com). The direct custom-id page returned 404.
|
||||
- SpacetimeDB: `ctx.sender` is the caller identity hook (already kept); how identities are minted and resumed was not read. UNVERIFIED.
|
||||
- Key pair in the browser:
|
||||
- WebCrypto `CryptoKey` has `extractable`; `false` blocks `exportKey`/`wrapKey`. VERIFIED (MDN CryptoKey). CryptoKey is Baseline widely available (since September 2016), secure contexts only, available in workers. VERIFIED (MDN).
|
||||
- Non-extractable keys are structured-cloneable so IndexedDB can hold them without raw bytes reaching script. SECONDARY (MDN page did not state it; search summaries of W3C WebCrypto list and library docs). Historic bugs: Safari 10 `DataCloneError` for CryptoKey in IndexedDB (fixed, WebKit bug 162554); Safari failed on `CryptoKeyPair` objects while Chrome and Firefox worked (WebKit bug 164868, marked duplicate); Firefox bug 1048931 had private-key cloning limits for ECDH in the 55 era. SECONDARY, 2016-2019, current state UNVERIFIED. ECDSA P-256 sign/verify availability per browser: UNVERIFIED in this pass (MDN SubtleCrypto page not read).
|
||||
- Not protected against script running on the same origin: such script can call `sign()` with the stored key. This is a property of the API (extractable=false blocks export only), stated in MDN's definition; the XSS consequence is inference.
|
||||
- Eviction: MDN storage-quotas page: best-effort storage may be evicted under pressure (LRU); `navigator.storage.persist()` upgrades to persistent, which Firefox grants by prompt and Safari and Chromium decide from user interaction history without a prompt; private modes may use different quotas and usually delete data at session end. VERIFIED (MDN Storage quotas and eviction criteria).
|
||||
- Safari ITP: script-writable storage including IndexedDB, LocalStorage, SessionStorage and service-worker registrations is deleted after 7 days of browser use without user interaction (click, tap, keyboard) with the site; home-screen web apps are exempt. VERIFIED (webkit.org/tracking-prevention; MDN gives the same rule with "when cross-site tracking prevention is on"). Server-set cookies are exempt (MDN). So a browser-held key on Safari is lost after seven days of non-interaction.
|
||||
- WebAuthn or passkeys as the alternative key store: not researched in this pass. UNVERIFIED.
|
||||
|
||||
---
|
||||
|
||||
## 6. `browser-client-store`
|
||||
|
||||
- Typed-array views over wasm memory: `Float32Array::view` and similar are zero-copy but "only valid so long as the backing buffer isn't resized"; any allocation in the module (`Box::new`, malloc) can grow memory, which detaches the old `ArrayBuffer` and leaves existing views empty (also when the grow delta is zero); the safe patterns are to create the view immediately before use and not allocate while holding it, or copy into a JS-owned array. VERIFIED (wasm-bindgen discussion #3802 and js-sys docs, via fetch and search). Resizable `ArrayBuffer` (TC39 Stage 4) is said to address this; whether wasm memory uses it today: UNVERIFIED.
|
||||
- Unity WebGL: the emscripten heap is exposed to JavaScript as typed-array views (`HEAP8`, `HEAPU8`, up to 64-bit floats) usable from `.jslib` plugins; textures are reached through emscripten's `GL.textures` mapping; the build runs in its own scope on the page. SECONDARY (Unity manual page for WebGL browser scripting, several versions, via search summary). Unity renders itself inside wasm into its own canvas; the page-side renderer is not the Unity renderer. A JS renderer would read the heap through those views; no source describing that pattern was found. UNVERIFIED.
|
||||
- Godot web export: `JavaScriptBridge` wraps JS objects as references; `eval` returns numbers, booleans, strings and binary types (`ArrayBuffer`, `TypedArray`, `DataView`) to GDScript; floats may lose precision; no performance guidance given. VERIFIED (docs.godotengine.org javascript_bridge page). Engine renders itself; the page does not say how. UNVERIFIED.
|
||||
- Bevy on the web: renders inside wasm through WebGL2 by default or WebGPU opt-in at build time; simulation and renderer are the same wasm module. SECONDARY (bevy.org/news/bevy-webgpu and example pages via search). No JS-side renderer reading Bevy memory was found. UNVERIFIED.
|
||||
- Photon Quantum (Unity): the simulation exposes `Predicted`, `PredictedPrevious`, `PreviousUpdatePredicted` and `Verified` Frames through `Game.Frames`; `PredictedPrevious` is documented as the second latest predicted copy "meant for visual interpolation between that copy and the Predicted frame"; view components read these through `PredictedFrame`/`PredictedPreviousFrame`/`VerifiedFrame`, updated from Unity's late-update by `QuantumEntityViewUpdater`; the entity view's transform source is switched by `InterpolationMode` between predicted frames and snapshot interpolation (`AUTO` picks by whether the entity is prediction-culled). VERIFIED (doc-api.photonengine.com Quantum 3 `QuantumGame.FramesContainer`, `QuantumEntityView`, `QuantumViewComponent` pages, via fetch and search). So the view reads retained whole-state Frame copies by reference in the same process, not a per-frame serialised copy; whether the Frames are read-only or thread-isolated: UNVERIFIED. Photon blog says Quantum WebGL samples exist and a Quantum game won a web game jam. SECONDARY (blog.photonengine.com/photon-multiplayer-webgl-for-game-jams, via search).
|
||||
- Wasm to JS boundary guidance: a struct-of-arrays layout with one typed-array view per attribute lets a renderer read positions without copying; copying a JS typed array into Rust via wasm-bindgen slices costs a copy. SECONDARY (abratabia.com, scour.ing/Medium, reintech.io via search; blog-grade, no measurements read).
|
||||
- Rapier JS (wasm physics) position read-out (view vs copy): not found. UNVERIFIED.
|
||||
- Local, already kept (not re-verified): .NET `Span<byte|int|float|double>` crosses to JS as a `MemoryView` without copy; synchronous `JSImport` about 1 µs (browser-sim-runtime A4).
|
||||
|
||||
---
|
||||
|
||||
## Gaps left
|
||||
|
||||
- Chrome 97 Datagram interop with Kestrel's draft-02 WebTransport: no source either way.
|
||||
- Any measured WebSocket under-loss behaviour for .io games; any .io restart behaviour.
|
||||
- Overwatch talk content, Valve and Unreal primary pages (access blocked).
|
||||
- ECDSA P-256 WebCrypto per-browser matrix; WebAuthn as key store.
|
||||
- A concrete example of a JS renderer reading wasm game state from shared memory in a shipped game.
|
||||
88
research/cultmesh-garden-path/wasm-measure-2026-10-27.md
Normal file
88
research/cultmesh-garden-path/wasm-measure-2026-10-27.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Measuring probe: orbs simulation under Mono browser-wasm and NetWasm
|
||||
|
||||
Date: 2026-10-27. Serves ruling `cultmesh-garden-path:ruling:browser-sim-runtime` (choice `csharp-wasm`: "the first cut is a measuring probe of download size, startup and 60 Hz tick cost on the orbs game under both Mono browser-wasm and NetWasm, and it can overturn the choice"). Campaign `cultmesh-garden-path`, session label `self-2026-10-27-eureka`. Companion: `browser-sim-runtime-2026-10-27.md` (prior art; this probe replaces its "no measurement" gaps 1 and 2 for this workload only).
|
||||
|
||||
Measured, not inferred. Nothing admitted, nothing committed.
|
||||
|
||||
## What was built
|
||||
|
||||
A standalone C# orbs sim with no CultLib dependency: `Fix` (Q16.16 on `int`, products widened to `long`), `Player {Id, Position, Velocity, Mass}`, `Pellet`, `Shot`; `Movement` (integrate, damp, clamp to a 4096-unit arena, scripted LCG steering), `Eating` (orb-pellet overlap, 200 x 1000 tests per tick), `Hits` (ray vs circle over an 8-tick rewind ring, 50 shots x 200 players). 200 players, 1000 pellets, 50 shots. No floating point, no allocation in `Tick`. FNV-1a 64 state hash over all state. Exports: `Init`, `Tick`, `Hash`, `Noop`, `SetInput(int,int,int)`, `GetPositions() -> int[]`, `PushPositions()` (C# calls a JS import with a `Span<int>`).
|
||||
|
||||
Scratch (not in the repo): `C:\Users\Meta\AppData\Local\Temp\claude\F--Projects-CultLib\dfe49a4b-e0ab-440c-81c7-7325d2eb7405\scratchpad\wasm-probe\src\` (a scratch git repo; `shared\` is the sim, `mono\` and `netwasm\` the two hosts, `bench\bench.mjs` the harness, `run.sh` the job).
|
||||
|
||||
## Environment
|
||||
|
||||
- Yggdrasil via `ygg-verify.sh`, image `mcr.microsoft.com/playwright:v1.55.0-noble` (Chromium 140.0.7339.16 headless), container capped at 4 CPUs, niced, sharing the host with other verify jobs. .NET SDK 10.0.401 installed with `dotnet-install` inside the job; Mono runtime pack 10.0.14; `wasm-tools` workload installed for AOT. NetWasm.Sdk/Templates 0.10.3 (restore needed the NuGet index to catch up once; it worked on retry).
|
||||
- Pages served from a local Node server on 127.0.0.1, files pre-compressed Brotli-11 in memory and sent with `Content-Encoding: br`; no network throttling; so startup excludes download time and shows decode, compile and run. Cross-origin isolated (COOP/COEP) so `performance.now()` resolves to about 5 us.
|
||||
- Two full runs of the job (run 3, run 5); the tables use run 5 unless stated. Run 3 agrees within noise on every figure.
|
||||
- Licence: NetWasm's compiler is under its Community License (free under 250 employees / USD 10M); this is internal evaluation use.
|
||||
|
||||
## Native reference
|
||||
|
||||
Native .NET 10 JIT, same `Sim.cs`, `Init(12345)` then 3000 ticks: hash `5de317214d945ca9` (hits 1620, eats 1105), 0.30 ms per tick (Stopwatch, console app, 3000 ticks, JIT warm).
|
||||
|
||||
## Mono browser-wasm (.NET 10, `Microsoft.NET.Sdk.WebAssembly`, trimmed, `InvariantGlobalization`)
|
||||
|
||||
| Measure | Interpreter | AOT (`RunAOTCompilation`) |
|
||||
|---|---|---|
|
||||
| Files downloaded | 10 | 10 |
|
||||
| Raw bytes | 4,476,896 | 7,062,992 |
|
||||
| Brotli-11 bytes | 1,412,481 | 1,625,283 |
|
||||
| Largest file (raw / br) | `dotnet.native.wasm` 3,001,422 / 976,306 | `dotnet.native.wasm` 5,519,143 / 1,267,110 |
|
||||
| `System.Private.CoreLib.wasm` (raw / br) | 1,021,205 / 318,581 | 1,018,133 / 233,418 |
|
||||
| Cold start to first Tick, median of 10 | 227 ms | 207 ms |
|
||||
| Warm start (reload, same context), median of 10 | 104 ms | 131 ms |
|
||||
| Tick median (3,000 ticks, after 600 warm-up) | 1.36 - 1.40 ms | 0.455 ms |
|
||||
| Tick p90 | 1.48 - 1.49 ms | 0.50 - 0.55 ms |
|
||||
| Tick p99 | 11.4 ms | 0.74 - 0.89 ms |
|
||||
| Batch mean (3,000 ticks, one timer) | 1.52 - 1.55 ms | 0.50 - 0.56 ms |
|
||||
| Ticks over 4 ms (of 3,000) | 47 - 48 | 16 - 17 |
|
||||
| Max tick | 12.3 - 13.2 ms | 10.7 - 11.0 ms |
|
||||
| Hash after 3,000 ticks vs native | match | match |
|
||||
|
||||
## NetWasm 0.10.3 (`NetWasm.Sdk`, `JsModule`, page host, Compact GC, `-p:NetWasmPublishTarget=browser`)
|
||||
|
||||
| Measure | Value |
|
||||
|---|---|
|
||||
| Files downloaded | 18 (3 `.mjs` runtime modules, `NetProbe.wasm`, adapters and small json) |
|
||||
| Raw bytes | 302,974 |
|
||||
| Brotli-11 bytes | 68,959 |
|
||||
| Largest files (raw / br) | `netwasm.runtime.*.mjs` 177,895 / 35,052; `NetProbe.wasm` 82,481 / 21,953 |
|
||||
| Cold start to first Tick, median of 10 | 94.5 ms |
|
||||
| Warm start, median of 10 | 31.6 ms |
|
||||
| Tick median | 0.535 - 0.555 ms |
|
||||
| Tick p90 / p99 | 0.56 - 0.96 ms / 0.91 - 1.05 ms |
|
||||
| Batch mean | 0.62 - 0.64 ms |
|
||||
| Ticks over 4 ms (of 3,000) | 18 - 21 |
|
||||
| Max tick | 11.6 - 11.8 ms |
|
||||
| Hash after 3,000 ticks vs native | match |
|
||||
|
||||
Determinism: all three wasm builds produced `5de317214d945ca9` after 3,000 ticks, both in the per-tick-timed pass and the batch pass, in every repetition, equal to the native JIT hash. No mismatch. Integer and Q16.16 only; this says nothing about floating point.
|
||||
|
||||
## Per-call JS interop (microseconds per call, 20,000 calls after one warm call; range over three repetitions in run 5)
|
||||
|
||||
| Call | Mono interp | Mono AOT | NetWasm |
|
||||
|---|---|---|---|
|
||||
| `Noop()` int return | 0.31 - 0.47 | 0.26 - 0.37 | 0.16 - 0.24 |
|
||||
| `SetInput(int,int,int)` (input struct as three scalars) | 0.36 - 1.15 | 0.28 - 0.99 | 0.31 - 0.46 |
|
||||
| `GetPositions()` returns `int[400]` (copy), JS reads two elements | 3.8 - 4.9 | 2.3 - 3.3 | 2.6 - 3.2 |
|
||||
| `PushPositions()`: C# calls a JS import with a `Span<int>` of 400 (no copy), JS sums it | 5.7 - 6.1 | 3.9 - 5.6 | 1.3 - 1.9 |
|
||||
|
||||
A span or array read-out of all 200 positions costs a few microseconds per frame on every toolchain, under 0.1 percent of a 16.7 ms frame. Mono needed `[JSMarshalAs<JSType.MemoryView>]` on the span parameter and `AllowUnsafeBlocks`; NetWasm accepted the same source unchanged apart from `AllowUnsafeBlocks`.
|
||||
|
||||
## How each number was taken
|
||||
|
||||
- Shipped bytes: the set of URLs the server actually served during the first cold page load (every file the browser requested; the 404 favicon excluded). Raw = file size on disk. Brotli-11 = `zlib.brotliCompress` quality 11, window 24, per file, summed. The SDK-produced `.br`/`.gz` siblings were not requested and are not counted. These are floors: the sim uses almost none of the BCL and no CultCache, MessagePack or CultNet.
|
||||
- Cold start: a fresh browser context (empty HTTP and compile caches) per run, `page.goto`, wait for `window.ready`; the page records `performance.now()` (from navigation start) right after `Init(12345)` and the first `Tick()` return. Median of 10. Warm: `page.reload()` in one context, 10 times, same metric.
|
||||
- Tick cost: on the last cold page, `Init(1)` plus 600 ticks of warm-up, `Init(12345)`, then 3,000 `Tick()` calls each wrapped in `performance.now()` (about 5 us resolution), then a second pass of 3,000 ticks under one timer for the batch mean. Repeated three times in the same page. Median, p90 and p99 from the sorted per-tick array.
|
||||
- Determinism: `Hash()` after each 3,000-tick pass compared with the native JIT hash.
|
||||
- Interop: 20,000 consecutive calls from a JS loop in the page, mean per call.
|
||||
|
||||
## Caveats
|
||||
|
||||
- Every variant, including the AOT build and NetWasm, shows the same pattern: 16 - 48 ticks of 3,000 take 10.7 - 13.2 ms against a 0.5 - 1.4 ms median, all near 11 ms. The common value across three different runtimes points to the environment (a niced, CPU-capped container on a shared host, or a Chromium main-thread task), not to the runtimes' GC, but this probe did not isolate the cause. For Mono interpreter p99 it lands at 11.4 ms because 1.6 percent of ticks are hit. A run on a quiet host or on a real client machine is needed before any p99 figure is trusted. At a 60 Hz frame budget of 16.7 ms the spikes still fit, with little left for rendering.
|
||||
- The sim is a probe workload, not CultCache documents; the ruling's prerequisite (AOT-safe CultCache registration) is not tested here. NetWasm refuses reflection and CultCache's registration reflects; whether CultCache can run under NetWasm is a separate cut.
|
||||
- Sizes will grow with real BCL use (collections, LINQ, MessagePack, CultNet) and fastest-growing on Mono; NetWasm links only reachable code, Mono ships a fixed runtime of about 1.4 MB Brotli before any code.
|
||||
- Mono AOT here is the default mixed mode; Mono interpreter has the jiterpreter on by default. Network time was not simulated: a 1.4 MB Brotli payload on a 10 Mbit link adds about 1.1 s that this table does not show; NetWasm's 69 KB adds about 0.05 s.
|
||||
- NetWasm is 0.10.3, pre-1.0, single vendor, compiler under the Community License; the restore failed once because the NuGet index had not yet listed 0.10.3 for the container, then passed.
|
||||
- Single machine, single browser (headless Chromium 140). No Firefox or Safari, no phone.
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
# WebTransport server: protocol stack, .NET gap, reference sizes (2026-10-27)
|
||||
|
||||
Serves ruling `cultmesh-garden-path:ruling:browser-transport` (InForce, Operator, 2026-10-27: WebSocket in stage 1 behind a transport port; a WebTransport server is built in parallel, "sized by how hard it proves"; the size estimate is advice owed to her). Facts with evidence pointers; no recommendation. Browser support table and Kestrel docs status are in `stage1-forks-prior-art-2026-10-27.md` and are not repeated.
|
||||
|
||||
Tags: VERIFIED = read on the primary source in this pass (spec text, source file, issue page, local file). SECONDARY = third-party page or search summary. UNVERIFIED = looked for, not found or not readable.
|
||||
|
||||
Line counts below are `wc -l` over shallow clones taken 2026-10-27 (clones in the session scratchpad, not kept), non-test files unless stated.
|
||||
|
||||
---
|
||||
|
||||
## 1. Protocol stack
|
||||
|
||||
### 1.1 Layers and what each demands of a server
|
||||
|
||||
| Layer | Spec | What the server must do for WebTransport | Tag |
|
||||
|---|---|---|---|
|
||||
| QUIC transport | RFC 9000 | Handshake with ALPN `h3`, streams, flow control, TLS 1.3. | UNVERIFIED (not re-read; standard) |
|
||||
| QUIC datagrams | RFC 9221 | Advertise a non-zero `max_datagram_frame_size` transport parameter. | VERIFIED (draft-16 s3.1 text, via fetch) |
|
||||
| QUIC reliable reset | draft-ietf-quic-reliable-stream-reset | Draft-16: servers MUST send an empty `reset_stream_at` transport parameter; stream resets MUST use RESET_STREAM_AT with a reliable size at least the WebTransport stream header. | VERIFIED (draft-16 s3.1, s4.4, via fetch) |
|
||||
| HTTP/3 | RFC 9114 | Control stream (type 0x00) carrying SETTINGS first; QPACK encoder/decoder uni streams (0x02, 0x03); request streams; GOAWAY. | VERIFIED (type codes from quiche `http_constants.h`) |
|
||||
| QPACK | RFC 9204 | See 1.2. | VERIFIED (RFC text) |
|
||||
| Extended CONNECT | RFC 9220 | Send `SETTINGS_ENABLE_CONNECT_PROTOCOL` (0x08) = 1; accept `:method CONNECT` with `:protocol`. | VERIFIED (quiche constants; draft-16) |
|
||||
| HTTP Datagrams | RFC 9297 | `SETTINGS_H3_DATAGRAM` (0x33) = 1; datagram payload = quarter-stream-ID varint + payload; capsule protocol on the CONNECT stream. | VERIFIED (quiche constants; draft-16 s4.5) |
|
||||
| WebTransport over H3 | draft-ietf-webtrans-http3 | See 1.3 and 1.4. | VERIFIED |
|
||||
|
||||
### 1.2 QPACK for a server
|
||||
|
||||
- A server's maximum dynamic table capacity is 0 until its encoder processes a peer SETTINGS frame with non-zero `SETTINGS_QPACK_MAX_TABLE_CAPACITY` (default 0). So a server that never advertises capacity leaves the browser unable to use the dynamic table toward it. VERIFIED (RFC 9204 s3.2.3 text lines 560-575; s5 default zero). Browser encoders sending to the server obey the capacity the server advertises (decoder side), so advertising 0 (the default) means requests use only the static table plus literals. VERIFIED by implementation: wtransport's decoder returns `DynamicNotSupported` on any dynamic reference (`wtransport-proto/src/qpack.rs` lines 58-110), and the repo ships against browsers.
|
||||
- Huffman is not optional for a decoder. The Huffman flag is per string literal and chosen by the peer encoder; the table is RFC 7541 Appendix B unmodified. VERIFIED (RFC 9204 lines 680-690). wtransport uses the `httlib-huffman` crate for it (`qpack.rs:198-204`, `wtransport-proto/Cargo.toml`). The server's own response encoder may avoid Huffman and dynamic table entirely (literals/static references only); wtransport's `qpack.rs` is 583 lines containing both directions.
|
||||
- Both QPACK uni streams still have to be opened/read by the server (stream-type bytes, instructions ignored when capacity is 0). UNVERIFIED in detail; wtransport has `driver/streams/qpack.rs` (106 lines) for it.
|
||||
|
||||
### 1.3 Current draft and codepoints
|
||||
|
||||
- Current draft: `draft-ietf-webtrans-http3-16`, 2026-07-06, expires 2027-01-07, datatracker state "In WG Last Call", Standards Track. VERIFIED (datatracker). Previous: -15 (2026-03), -14 (2025-10), -13 (2025-07).
|
||||
- Draft-16 codepoints VERIFIED (datatracker fetch):
|
||||
- Uni stream type `0x54`; bidi signal value `0x41` (also reserved frame type WT_STREAM).
|
||||
- `SETTINGS_WT_ENABLED` `0x2c7cf000` (value 1 = "webtransport-h3"; >1 is a connection error); `SETTINGS_WT_INITIAL_MAX_STREAMS_UNI` `0x2b64`; `..._BIDI` `0x2b65`; `SETTINGS_WT_INITIAL_MAX_DATA` `0x2b61`.
|
||||
- The datatracker page says the draft defines no `SETTINGS_WT_MAX_SESSIONS`; session limits use 429 / `H3_REQUEST_REJECTED` and RateLimit headers. But webtransport-go (draft-16) defines `settingsWebTransportMaxSessions = 0x14e9cd29` and sends it with value 2^62-1, with the comment "Safari requires SETTINGS_WT_MAX_SESSIONS >= 1 (draft-ietf-webtrans-http3-14)" (`webtransport-go/protocol.go`, `server.go:46`). Draft-14 search snippets (SECONDARY) also name `SETTINGS_WT_MAX_SESSIONS` with "must advertise > 0". The -16 page and the -14 text disagree; the codepoint webtransport-go uses was not found in the -16 page fetched. UNVERIFIED which text is canonical for the 0x14e9cd29 setting.
|
||||
- Capsules: WT_CLOSE_SESSION `0x2843`; WT_DRAIN_SESSION (provisional) `0x78ae`; WT_MAX_STREAMS bidi/uni `0x190B4D3F`/`0x190B4D40`; WT_STREAMS_BLOCKED `0x190B4D43`/`0x190B4D44`; WT_MAX_DATA `0x190B4D3D`; WT_DATA_BLOCKED `0x190B4D41`. WT_MAX_STREAM_DATA and WT_STREAM_DATA_BLOCKED are prohibited.
|
||||
- Errors: WT_BUFFERED_STREAM_REJECTED `0x3994bd84`, WT_SESSION_GONE `0x170d7b68`, WT_FLOW_CONTROL_ERROR `0x045d4487`, WT_ALPN_ERROR `0x0817b3dd`, WT_REQUIREMENTS_NOT_MET `0x212c0d48`; application error range `0x52e4a40fa8db`..`0x52e5ac983162`, skipping `0x1f*N+0x21`.
|
||||
- Session: extended CONNECT, `:protocol webtransport-h3`, `Origin` required for browser clients (403 on failure, 405 if resource not WT, 2xx accepts); optional `WT-Available-Protocols` / `WT-Protocol` negotiation. Close capsule = 32-bit error + UTF-8 message (prose says 1024 bytes, diagram says 8192: internal inconsistency in the draft text). Datagram = quarter-stream-ID + payload. Peer streams/datagrams may arrive before the CONNECT is accepted and SHOULD be buffered within limits. Flow control is enabled only when both sides send non-zero for one of the three `WT_INITIAL_MAX_*` settings; otherwise a client must not open more than one session.
|
||||
- Draft-02 / draft-07 codepoints (what Chromium's stack uses), from quiche `http_constants.h` and `quic_spdy_session.cc` (VERIFIED): `SETTINGS_WEBTRANS_DRAFT00` = `0x2b603742` (draft-02, "ENABLE_WEBTRANSPORT"); `SETTINGS_WEBTRANS_MAX_SESSIONS_DRAFT07` = `0xc671706a`; `SETTINGS_H3_DATAGRAM_DRAFT04` = `0xffd277`; `SETTINGS_H3_DATAGRAM` = `0x33`.
|
||||
- Draft-02 vs -07 incompatibilities (draft-07 Appendix A.1, VERIFIED): -07 requires `SETTINGS_WEBTRANSPORT_MAX_SESSIONS` and uses it for version negotiation; explicitly requires `SETTINGS_ENABLE_CONNECT_PROTOCOL`; explicitly requires `SETTINGS_H3_DATAGRAM`; WEBTRANSPORT_STREAM only at stream start. Safe back-ports into -02: 32-bit reset codes, SESSION_GONE, GOAWAY handling, DRAIN capsule, no auto-redirects.
|
||||
|
||||
### 1.4 Which draft each browser speaks, and negotiation
|
||||
|
||||
- Negotiation mechanism: each draft version owns a `SETTINGS_WT_ENABLED`-style codepoint; endpoints send one per supported version and the highest common one is used. VERIFIED (draft-16 text). In quiche this is implemented as a bit-set of versions: server sends `0x2b603742` for draft-02 and `0xc671706a` (value 16) for draft-07; it records which peer settings it received (`quic_spdy_session.cc:642-654, 1350-1380`). VERIFIED.
|
||||
- Chromium's shared stack (quiche `main`) supports exactly draft-02 and draft-07 (`kDraft02`, `kDraft07` in `quic_spdy_session.h:137-154`); the header calls draft-02 "the first version to be ever publicly shipped in Chrome". VERIFIED for quiche; that Chrome stable ships quiche's `main` set is SECONDARY (inference; no Chromium release note read).
|
||||
- Firefox: IETF 124 webtrans minutes state "implemented to draft 4" for WebTransport over H3 with draft 14 in progress, flow control and RESET_STREAM_AT in progress. VERIFIED (minutes text as returned by fetch; "draft 4" is as written).
|
||||
- Safari: minutes say its prototype supports "the latest H2 and H3 drafts"; the number is not given. VERIFIED (minutes). Safari 26.4 shipped it (see prior note). Safari "requires SETTINGS_WT_MAX_SESSIONS >= 1" per webtransport-go comment citing draft-14. VERIFIED (source comment). A third-party page says Safari refuses a session when `WT_MAX_SESSIONS` > 1 enables flow control while the three `WT_INITIAL_MAX_*` default to 0, while other engines tolerate it; and that different quic-go generations with identical-looking settings got different Safari results (SECONDARY, echo.semantic-ui.com/compat, low-provenance domain).
|
||||
- `:protocol` token: draft-16 is `webtransport-h3`; webtransport-go accepts both `webtransport-h3` and legacy `webtransport` (`protocol.go:26-38`: "pre-draft-15 value ... for peers that have not yet migrated"). VERIFIED. SECONDARY that Chrome and Safari still send legacy `webtransport` (same low-provenance page).
|
||||
- Server-side draft header: for the draft-02 world, aioquic's example server adds response header `sec-webtransport-http3-draft: draft02` (`aioquic/examples/http3_server.py:291`); a fails-components issue records "Opening handshake failed" in Chrome resolved elsewhere (wrong URL path), citing that header from an aioquic issue. VERIFIED for the aioquic line; the Chrome necessity of the header is SECONDARY.
|
||||
- webtransport-go (draft-16) sends three settings for compatibility: draft-06-era `0x2b603742`, `SETTINGS_WT_ENABLED` `0x2c7cf000`, and `0x14e9cd29` max sessions. It does not send `0xc671706a`. It ships a headless-Chrome interop harness (`interop_chrome/`). VERIFIED (source). Net: a draft-16 server in this repo reaches Chrome through the draft-02 setting.
|
||||
|
||||
### 1.5 Browser requirements the RFCs leave optional or the drafts leave soft
|
||||
|
||||
- `SETTINGS_ENABLE_CONNECT_PROTOCOL` and `SETTINGS_H3_DATAGRAM` are explicit requirements from draft-07 up, and in quiche a draft-07 negotiation with only the draft-04 datagram setting is rejected (`quic_spdy_session.cc:1145-1172`). VERIFIED.
|
||||
- A third-party page reports: if the max-sessions setting is absent `ready` never resolves or rejects; Chrome `maxDatagramSize` fixed at 1024; Safari can silently fall back to HTTP/2 over TCP (datagrams vanish); Safari flow-control credit not released on FIN/RESET (WebKit bug 319818). SECONDARY (single page).
|
||||
- Origin header: required from browsers by draft-16 s3.2. VERIFIED.
|
||||
|
||||
---
|
||||
|
||||
## 2. The .NET gap
|
||||
|
||||
### System.Net.Quic
|
||||
- No datagram API in the shipped surface. The CultLib managed transport itself says so: "System.Net.Quic exposes streams but not QUIC datagrams" (`src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs:399-401`). VERIFIED (local file). That the file targets net10.0 is VERIFIED (`GameCult.Mesh.Quic.csproj`).
|
||||
- dotnet/runtime #53533 "QUIC Datagram API": OPEN, labels `api-suggestion`, `area-System.Net.Quic`, milestone **12.0.0** (as returned 2026-10-27), last activity 2026-07-25. Maintainer ManickaP, 2026-02-25: "we're re-evaluating this for the next .NET. It's mandatory for WebTransport over H/3 AFAIK." nibanks (MsQuic) 2026-01-21 recommends streams over datagrams where one controls both ends. VERIFIED (gh). A duplicate community proposal #123418 (a contributor reports a working fork `wes-sleeman/runtime branch quic-datagrams`) was closed as dupe 2026-01-21 with "we will look into this internally and align the API shape ... No need to put more effort into this atm". VERIFIED (gh).
|
||||
- Proposed API shape in #53533: `QuicConnectionOptions.DatagramReceiveEnabled`, `QuicConnection.DatagramReceived` event, `SendDatagram`, `DatagramMaxSendLength`, send-state callbacks. VERIFIED (issue body). Not shipped in any .NET 8/9/10 API read.
|
||||
- System.Net.Quic is stable from .NET 9. SECONDARY (prior note).
|
||||
|
||||
### Kestrel experimental WebTransport
|
||||
- Implemented: draft-02 only. `docs/WebTransport.md`: "most of the WebTransport draft-02 specification, except for datagrams"; needs `EnablePreviewFeatures` + runtime option `...Experimental.WebTransportAndH3Datagrams`; sample apps and a 14-day ECDSA P-256 test cert generator in the doc. VERIFIED (dotnet/aspnetcore `docs/WebTransport.md`).
|
||||
- Settings it speaks: `Http3SettingType.EnableWebTransport = 0x2b603742` and `H3Datagram = 0xffd277` (the draft-04 value, not RFC 9297's `0x33`); no max-sessions setting. VERIFIED (`Http3SettingType.cs`). So it is draft-02 with draft-04 H3 datagrams: by quiche's rules (1.3) that cannot negotiate draft-07.
|
||||
- Code size: `Internal/WebTransport/` = `WebTransportSession.cs` 6,618 bytes, `WebTransportStream.cs` 4,104, `WebTransportStreamType.cs` 674, plus hooks in `Http3Connection.cs` (39,423 bytes; WebTransport at lines ~43-85, 406-452, 650-667), `Http3Stream.cs` (59,394), `Http3StreamContext.cs`. Public surface: `IHttpWebTransportFeature`, `IWebTransportSession` in `Microsoft.AspNetCore.Http.Features`. VERIFIED (gh api listing/search; sizes in bytes as returned, lines not counted).
|
||||
- Kestrel already owns the whole H3 server stack under it: `Http3/` (control stream, frame reader/writer, SETTINGS, GOAWAY), `Http3/QPack/` (decoder/encoder stream readers, dynamic table), Huffman. VERIFIED (gh listing). dotnet/runtime holds a second copy of QPack for the H3 client at `src/libraries/Common/src/System/Net/Http/aspnetcore/Http3/QPack/` (shared source). VERIFIED (gh search path).
|
||||
- Open issues: aspnetcore #42788 "WebTransport PR follow-ups" (open, updated 2026-09-14); #42784 "Adding datagrams support to WebTransport" (open, updated 2026-02-03); #42785 WT over HTTP/2 (open); #43171 streams may arrive out of order (open; this is the buffered-before-CONNECT case); #65406 "Quic + Datagrams" (open, Backlog; focused by danroth27 2026-05-04 to just QUIC+datagrams). VERIFIED (gh).
|
||||
- Team position, BrennanConroy 2026-02-12 on #65406: QUIC datagrams "would need to be added in dotnet/runtime"; WebTransport "still in draft 4 years later. We aren't likely to put much investment into it until it matures"; he "could definitely see us pointing AI at the current codebase if it's not working with browsers c[urrently]" (truncated in fetch). VERIFIED (gh, quoted portions). Whether an external PR would be accepted: UNVERIFIED (no PR or maintainer statement either way found; `contributing` rules not read).
|
||||
- dotnet/runtime #43641 "Add support for WebTransport": open, `api-suggestion`, `area-System.Net.Http`, no activity since 2023-01. VERIFIED (gh).
|
||||
|
||||
### MsQuic
|
||||
- Latest release v2.6.3 (2026-10-13). VERIFIED (GitHub API). CultLib pins 2.5.9 (`scripts/build-quic-native.ps1:16`; Linux CMake message says "msquic v2.5.9 headers"). VERIFIED (local).
|
||||
- Datagram API in `msquic.h` v2.5.9: `QUIC_SETTINGS.DatagramReceiveEnabled` (line 757/811), params `QUIC_PARAM_CONN_DATAGRAM_RECEIVE_ENABLED` 0x0500000D and `..._SEND_ENABLED` 0x0500000E, `DatagramSend(Connection, Buffers, BufferCount, Flags, ClientSendContext)` (must fit one QUIC packet), events `DATAGRAM_STATE_CHANGED {SendEnabled, MaxSendLength}`, `DATAGRAM_RECEIVED`, `DATAGRAM_SEND_STATE_CHANGED` with states UNKNOWN/SENT/LOST_SUSPECT/LOST_DISCARDED/ACKNOWLEDGED/ACKNOWLEDGED_SPURIOUS/CANCELED, flag `QUIC_SEND_FLAG_DGRAM_PRIORITY`. VERIFIED (header read).
|
||||
- Reliable reset: `QUIC_SETTINGS.ReliableResetEnabled`, `QUIC_PARAM_STREAM_RELIABLE_OFFSET`, event `RELIABLE_RESET_NEGOTIATED`. VERIFIED (header). That its transport-parameter codepoint matches the one browsers send for draft-16's `reset_stream_at`: UNVERIFIED.
|
||||
- Windows ships `msquic.dll` with the runtime; Linux needs `libmsquic`. SECONDARY (prior note).
|
||||
|
||||
---
|
||||
|
||||
## 3. Reference implementations (sizes)
|
||||
|
||||
All sizes are non-test lines. "Under it" names the layer the project does NOT contain.
|
||||
|
||||
| Project | Draft | WT-specific size | Under it | Notes | Tag |
|
||||
|---|---|---|---|---|---|
|
||||
| quic-go/webtransport-go (Go, last commit 2026-10-26) | -16 (README), accepts legacy `webtransport` token, sends draft-06/-16/-14 settings | 3,397 lines in repo root (4,517 incl. interop/example/testdata); tests 4,408. Largest: `server.go` 533, `session.go` 458, `streams_map_outgoing.go` 357, `send_stream.go` 322, `client_conn.go` 289, `capsule.go` 261, `session_manager.go` 190, `receive_stream.go` 185, `streams_map_incoming.go` 179, `flow_control.go` 107, `transport.go` 125 | quic-go v0.62.0 `http3` pkg 5,946 lines + quic-go core ~15.2k + internal ~10.9k; `qpack` v0.6.0 separate module | Client+server. Uses quic-go hooks: additional SETTINGS map, conn context, stream hijack, datagram enable. Own per-session stream maps and flow control. Used by Caddy, Centrifugo, libp2p, MediaMTX. | VERIFIED |
|
||||
| BiagioFesta/wtransport (Rust, last commit 2026-09-22) | README: "still a draft"; settings `0x2b603742` + `0xc671706a` in `wtransport-proto/src/settings.rs:247-248` (draft-02/-07 pair) | `wtransport-proto` 6,072 lines (incl. inline unit tests; `bytes.rs` 1,008, `stream.rs` 1,233, `frame.rs` 718, `session.rs` 485, `stream_header.rs` 509, `qpack.rs` 583, `ids.rs` 455); `wtransport` crate 6,787 lines (`tls.rs` 1,373, `config.rs` 1,287, `endpoint.rs` 855, `driver/mod.rs` 682, `driver/streams/mod.rs` 588) | quinn 0.11 (QUIC + datagrams), rustls, `httlib-huffman`; pure-Rust, QPACK static-only | The closest "H3 from scratch over a QUIC library that exposes only streams+datagrams" model. Sans-io protocol crate separate from async driver. Config/TLS are about 40% of the driver crate. | VERIFIED (counts); draft-pair inferred from codepoints |
|
||||
| aioquic (Python, last commit 2026-10-16) | draft-02 only: `Setting.ENABLE_WEBTRANSPORT = 0x2B603742`, WT stream types `0x41`/`0x54`, requires H3_DATAGRAM | `h3/connection.py` 1,591 lines for all of HTTP/3 + QPACK glue (via `pylsqpack` C extension) + WT hooks; WT hooks are about 15 mentions | aioquic QUIC: `quic/connection.py` 4,266, `packet.py` 665, `packet_builder.py` 413 | Smallest complete reference; no capsule handling, no flow control, no session limits, no draft-07. Example server adds `sec-webtransport-http3-draft: draft02`. | VERIFIED |
|
||||
| Kestrel WebTransport (C#, experimental) | draft-02 + draft-04 H3 datagram setting | ~11 KB in `Internal/WebTransport/`, plus WT branches in `Http3Connection`/`Http3Stream` | Kestrel H3 + QPack + MsQuic via System.Net.Quic | No datagrams, no server-initiated sessions, no capsules. | VERIFIED (bytes, not lines) |
|
||||
| Chromium / quiche (C++) | -02 and -07 | `web_transport_http3.cc` 509 lines (18 KB; header 8.7 KB), `web_transport_only_server_session.cc` 7.8 KB, `web_transport_stream_adapter.cc` 8.3 KB, `web_transport_priority_scheduler.cc` 6 KB, `quic/web_transport` headers (`web_transport.h` 17.6 KB); `quic_spdy_session.cc` 2,075 lines holds the settings/negotiation | Full quiche QUIC (not sized) and `qpack` dir (~201 KB incl. tests) | Test server `web_transport_only_*` exists; Chrome's reference server. Draft support is a runtime bit-set. | VERIFIED (sizes in bytes/lines as shown) |
|
||||
| libwebtransport / other C and C++ libraries on MsQuic, ngtcp2, lsquic | | | | A search for a C/C++ WebTransport server over msquic, ngtcp2 or lsquic returned nothing relevant. | UNVERIFIED that none exists |
|
||||
| Demiurgos.WebTransport.Client (C#) | | client only, Windows x64, preview, native MsQuic backend with datagram send/receive (prior note) | | The only C# WebTransport package found; no server. | VERIFIED (prior note) |
|
||||
|
||||
---
|
||||
|
||||
## 4. CultLib's own starting point
|
||||
|
||||
### `native/GameCult.Mesh.Quic.Native/`
|
||||
- Size: `cultmesh_quic_native.cpp` 1,513 lines, `include/cultmesh_quic_native.h` 371, `tests/cultmesh_quic_native_tests.cpp` 1,305, `CMakeLists.txt` 118. VERIFIED (`wc -l`).
|
||||
- Datagrams: no mention of "datagram" in the .cpp, header, README or CMake (`grep -i datagram` empty). It sets `IdleTimeoutMs`, `KeepAliveIntervalMs`, `PeerUnidiStreamCount`, `HandshakeIdleTimeoutMs` only (`cultmesh_quic_native.cpp:769-777`); no `DatagramReceiveEnabled`, no `DatagramSend`, no `ReliableResetEnabled`. VERIFIED.
|
||||
- Design: runtime-neutral queue ABI v2, no host callbacks, 64-byte event struct, 13 exports; owns registration, configuration, listener, connection and stream handles, TLS credential loading, ALPN `cultmesh-state-v1`, and a 1-byte-kind + 4-byte-LE-length stream framing; "never decodes a CultMesh frame". Two platforms (Windows x64 OpenSSL-flavoured MsQuic NuGet 2.5.9, Linux libmsquic). VERIFIED (README, header). Windows build uses the OpenSSL MsQuic because the Schannel build "refuses" something (build script line 17; not read further).
|
||||
- Consumers: TS via koffi (`packages/cultmesh-ts/src/realtime-quic-native.ts`, native binaries committed under `packages/cultmesh-ts/native/{linux-x64,win32-x64}/`), and C# via P/Invoke (`src/GameCult.Mesh.Quic.Native/CultMeshNativeQuicRealtimeTransport.cs`, 278 lines). VERIFIED (git ls-files).
|
||||
|
||||
### Managed C# QUIC today
|
||||
- `src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs` (700 lines) uses `QuicListener.ListenAsync`, `QuicConnection.ConnectAsync`, `OpenOutboundStreamAsync`, one ALPN list; sending `CultMeshRealtimeDelivery.Unreliable` throws `NotSupportedException("... unreliable delivery requires a native MsQuic connector")` (lines 397-401). VERIFIED (local).
|
||||
- Rust CultMesh (`packages/cultmesh-rs`: `lib.rs` 1,902 lines, `rudp_document_server.rs` 1,082) and cultnet-rs have no QUIC dependency (`Cargo.toml` grep empty). VERIFIED. Existing server transports: RUDP, WebSocket (`GameCult.Networking.WebSockets`), QUIC (above).
|
||||
- `docs/cultmesh-garden-path-target.md:146` lists the transport as an open item; `docs/typescript-quic-realtime-target.md:112` says "Browser QUIC (WebTransport). Browsers keep the verified WebSocket path." VERIFIED (local).
|
||||
|
||||
### Which layer can host H3 + WebTransport
|
||||
Facts bearing on it, not a choice:
|
||||
|
||||
| Layer | Datagrams | H3/QPACK present | Needs written |
|
||||
|---|---|---|---|
|
||||
| Managed C# over System.Net.Quic (net10) | No public API (runtime #53533, milestone 12.0.0) | No public H3 server API; QPack source exists inside runtime/Kestrel as internal | Datagrams impossible without a runtime change or reflection/private MsQuic access; H3 control + QPACK + capsules + WT layer |
|
||||
| Native bridge (MsQuic 2.5.9 C++) | MsQuic has full API; bridge does not enable it | No | Datagram + reliable-reset plumbing in the bridge ABI (new events/exports), H3 + QPACK + WT either in C++ or in C#/TS above a bridge that exposes raw streams, per-stream FIN/reset and datagrams |
|
||||
| Upstream Kestrel | No (needs runtime #53533 first) | Yes: complete H3, QPack, GOAWAY | Datagrams, draft-07+ settings, capsules, server-initiated sessions, flow control; team says low priority |
|
||||
| Rust (wtransport over quinn) | Yes via quinn | Yes, own static-only QPACK | No dependency on the .NET/MsQuic stack; CultLib Rust crates currently use RUDP only |
|
||||
|
||||
---
|
||||
|
||||
## 5. Interop pain reported
|
||||
|
||||
- Draft churn: Chromium stack on -02/-07 while the IETF text is at -16 with different codepoints; Firefox on a "draft 4"/14-in-progress; Safari "latest" (1.4). VERIFIED/SECONDARY as tagged there. webtransport-go keeps three settings plus a legacy `:protocol` token to span them (1.4).
|
||||
- Draft-16 itself marks "draft-only" version rules (client sends draft codepoint, server defers processing until client SETTINGS) as to be removed before publication. VERIFIED.
|
||||
- Certificates: `serverCertificateHashes` rules, W3C WebTransport CR Snapshot, VERIFIED: validity period MUST NOT exceed two weeks; key must be ECDSA secp256r1 (P-256); the 14-day figure matches Kestrel's doc ("14 days is the max duration"). Let's Encrypt community thread confirms public-CA certs (90 days) cannot be pinned this way (SECONDARY). Default Kestrel dev cert does not work with WebTransport (docs), aspnetcore #41762 "Default dev certificate not working with Http3" open, updated 2026-01-26 (VERIFIED, gh). Other pinning gotchas (hash of leaf DER not SPKI, lowercase `"sha-256"`, `allowPooling` incompatible, Chrome requires a public root otherwise `ERR_QUIC_CERT_ROOT_NOT_KNOWN`, Safari ignores user-installed roots): SECONDARY (single low-provenance page). Feisty Duck newsletter and an IPFS issue exist on the same topic, not read. The W3C group discussed removing `serverCertificateHashes` (issue #623) and marked it at-risk: SECONDARY.
|
||||
- Headless Chrome testing: webtransport-go's `interop_chrome/interop.py` drives Chrome through selenium/chromedriver with `--no-sandbox --enable-quic --headless --origin-to-force-quic-on=localhost:12345 --host-resolver-rules='MAP localhost:12345 127.0.0.1:12345'`. VERIFIED (file read). `--webtransport-developer-mode` as the private-CA escape: SECONDARY.
|
||||
- Handshake failures that look like draft mismatch can be a wrong URL path (fails-components issue #358, 2025-04-04): VERIFIED (fetch).
|
||||
- Kestrel sample app issue "ERR_CONNECTION_RESET running WebTransportInteractiveSampleApp" (aspnetcore #52663, open) shows the shipped experimental path failing for at least one user: VERIFIED title/state only; cause not read.
|
||||
- Not found: an engineering blog post from someone who wrote a WebTransport H3 server from scratch describing effort. UNVERIFIED that none exists.
|
||||
|
||||
---
|
||||
|
||||
## Evidence index
|
||||
|
||||
- Spec: datatracker `draft-ietf-webtrans-http3` (-16), `ietf.org/archive/id/draft-ietf-webtrans-http3-16.html`, `...-07.txt`, RFC 9204 text, W3C `webtransport` TR snapshot, IETF 124 webtrans minutes.
|
||||
- Code: `google/quiche` (`http_constants.h`, `quic_spdy_session.{h,cc}`, `web_transport_http3.{h,cc}`), `quic-go/webtransport-go`, `quic-go/quic-go`, `BiagioFesta/wtransport`, `aiortc/aioquic`, `dotnet/aspnetcore` (`docs/WebTransport.md`, Kestrel `Internal/Http3`, `Internal/WebTransport`), `microsoft/msquic` v2.5.9 `src/inc/msquic.h`.
|
||||
- Issues: dotnet/runtime #53533, #123418, #43641; dotnet/aspnetcore #42788, #42784, #42785, #43171, #65406, #41762, #52663.
|
||||
- Local: `F:\Projects\CultLib\native\GameCult.Mesh.Quic.Native\{cultmesh_quic_native.cpp,README.md,include\cultmesh_quic_native.h,CMakeLists.txt}`, `F:\Projects\CultLib\src\GameCult.Mesh.Quic\CultMeshQuicRealtimeTransport.cs`, `F:\Projects\CultLib\scripts\build-quic-native.ps1`.
|
||||
140
research/paged-store/flush-measure-2026-10-27.md
Normal file
140
research/paged-store/flush-measure-2026-10-27.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# CultCache whole-view single-file flush: measured cost on Aetheria's catalog
|
||||
|
||||
Serves ruling `variants:ruling:page-tree-store-research` and follow-up `variants:follow_up:paged-single-file-store` (the eureka-state `view` calls refused the bare ids, `missing field id` / `expected struct PipelineRef`, so the brief's text was the spec). Session `self-2026-10-27-eureka`. Measuring probe: nothing admitted, nothing committed, no repo changed. Date 2026-10-27.
|
||||
|
||||
## Headline
|
||||
|
||||
- Aetheria's real catalog is small: `GameData/Aetheria.cc` is 214,588 bytes and **394 records** in 18 schemas; `aetheria.nakovalnya/ship.cc` is 40,059 bytes and **3 records**. Neither is a store where a flush is slow.
|
||||
- A flush rewrites the whole view whatever changed. 1, 10, 1% and 10% of records changed cost the same (Aetheria.cc on the container's disk: 5.0 to 6.0 ms for 1 to 39 records changed, 214.6 KB written each time, 2.56 MB allocated each time).
|
||||
- Cost per flush on the real catalog: about 5.5 ms on the container disk (serialise 1.8 ms; the other 3.7 ms is record assembly, about 1.1 ms of it CPU, plus the write and fsync), 2.5 ms with disk free. Sustained rate: about 180 one-record flushes per second on disk, about 300 in memory.
|
||||
- The cost is linear in record count. On a synthetic catalog of the same record shape: 3,940 records flush in about 31 ms (about 33 per second), 39,400 records in about 320 ms (3 per second). A 30 Hz writer fits the real catalog (5.5 ms of a 33.3 ms frame) and stops fitting between roughly 4,000 and 5,000 records, whatever the disk.
|
||||
- CPU, not I/O, owns the cost once the store passes a few thousand records: at 39,400 records the in-memory (tmpfs) flush is 300 ms against 320 ms on disk.
|
||||
- The existing `DirectoryMessagePackBackingStore` does not fix this. A 1-record flush rewrites the whole manifest: 113 KB per flush on Aetheria.cc (0.53 of the file), 700 KB at 3,940 records (0.31), and takes as long as the single-file flush (5.8 ms against 5.4 ms; 39 ms against 32 ms at 3,940 records). It accepted all 394 records (no variants in the catalog, so nothing refused).
|
||||
- C2a (`7c7cc799`) cannot open Aetheria's current types at all: its registry throws `Faction.Places: list element type FactionPlace has no [CultElementId] member`. Its serialise step is cheaper (per-record bytes are kept: 0.16 ms against 1.1 ms at 394 records, 74 ms against 128 ms at 39,400), but totals move little (-11 percent at 39,400 records, 5.6 to 3.7 ms at 394) because record assembly and the whole-file write remain O(records). C2a loads slower (2x at 394 records, 2.3x at 39,400). Its numbers are synthetic-only.
|
||||
|
||||
## Method
|
||||
|
||||
Where: Yggdrasil via `ygg-verify.sh` (image `mcr.microsoft.com/dotnet/sdk:10.0`, 4 CPUs by cgroup `cpu.max 400000 100000`, 6 GiB, niced, up to 5 jobs share the host). **Every absolute number is from that shared, capped, niced container** and carries its noise (run-to-run max values show it); the shape (flat in changed records, linear in total records) is the finding, not the milliseconds. Starfire ran nothing but git, file copies and text editing. Container disk is `/dev/vda4` (the host's disk); "shm" is `/dev/shm` tmpfs in the same container, where fsync and write cost almost nothing, so shm separates CPU from I/O.
|
||||
|
||||
Inputs:
|
||||
- Aetheria `GameData/Aetheria.cc` and `GameData/Mods/aetheria.nakovalnya/ship.cc` smudged from the `origin/master` blobs (Aetheria `b375cfc8`) by `git cat-file -p | git lfs smudge` into a scratch directory; the LFS content reached the container as part of the bundled job repo, so no LFS cache question arose. The operator checkout was not touched.
|
||||
- Aetheria's own types: the bundle carries `Aetheria.Shared` (the headless build of `Assets/Scripts/ServerShared`, UniRx, Ink) from `origin/master`, so the real `SingleFileMessagePackBackingStore` hydrates real `ItemData`, `Faction`, `ShipAuthoring` and the rest. Wiring as in `AetheriaStores.Open`: `new CultCache()`, `AddBackingStore(new SingleFileMessagePackBackingStore(path), AetheriaStores.CatalogTypes)`; `Open`'s post-load validations are not timed.
|
||||
- CultLib: `origin/main` `6a20309d`; `origin/hands/variants-c2a` `7c7cc799` (the branch tip when the job ran; it was `0c19d080` when the session began, a commit was added since); Aetheria's pinned CultLib `b8fc5eb` as a third column ("pin"), because that, not main, is what the game compiles today. Aetheria master compiles unchanged against main and against the pin (the `Directory.Build.targets` pin check was left out of the bundle). It does not run against C2a.
|
||||
|
||||
Measurements (harness `Program.cs`, scratchpad `flush-measure\bundle\harness\`):
|
||||
- Load: `new store + new CultCache + AddBackingStore` (attaching reads the store). "Process-cold" = first load in a fresh process, JIT and type discovery included, median of 5 processes; the OS page cache could not be dropped in the container, so it is cold for the process, warm for the file. "Warm" = median of 15 further loads in one process.
|
||||
- Flush: mutate N records through `cache.UpsertAsync(type, doc, key)` (first writable non-Name float, int or string member, deterministic spread over the record list), assert `cache.IsDirty`, then time only `cache.FlushAllBackingStores()` with a `Stopwatch`; 3 discarded warm-up flushes, then 15 timed. N = 1, 10, 1 percent, 10 percent of records. Medians reported; min and max are in the raw log.
|
||||
- Serialise vs rest: the harness subclasses the real store and overrides only its two serialise hooks (`SerializePayload`, `SerializeSnapshot`; on C2a `SerializePayload/Record/CatalogEntry/Store`) to accumulate `Stopwatch` ticks, then calls `base`. "Rest" = total minus serialise: lock sidecar, record assembly and ordering, schema catalog build, and write + `FlushToDisk` + `File.Replace`. It is not pure I/O: the in-memory column shows what remains with I/O free. A separate floor row times the store's exact atomic write (temp file, `WriteThrough`, fsync, `File.Replace`) of a same-size random payload.
|
||||
- Bytes written: the store replaces the file whole, so bytes written = resulting file size (measured). For the directory store, the files whose length or mtime changed or that are new, summed.
|
||||
- Allocation: `GC.GetAllocatedBytesForCurrentThread()` delta across the flush. This is bytes allocated, an upper bound on peak transient managed memory, not a measured high-water mark.
|
||||
- Throughput: loop of (mutate one record + upsert + flush) for 4 s (3 s synthetic); cycles per second, plus the flush-only median.
|
||||
- Directory store: `DirectoryMessagePackBackingStore(manifestPath)` filled by upserting the same documents under the same keys, then the same flush measurements. It refuses variants (`NotSupportedException`); the catalog holds none (single-file format v1), so it refused 0 of 394 and 0 of 3.
|
||||
- Synthetic sweep: a standalone `SynthItem` document (name, description, 4 numbers, a string list, a 30-float list, a nested object; about 570 bytes per record, the real catalog's mean is 545) at 394, 3,940 and 39,400 records, because real `[CultGlobal]` records cannot be cloned to scale and C2a refuses the real types. Same harness code on main and C2a.
|
||||
|
||||
Raw logs: `...\scratchpad\flush-measure\run1.log` (real data, main and pin), `run3.log` (synthetic main and C2a). `run2.log` is the failed first C2a attempt and is evidence of the registry refusal. Re-run: `tables.py` regenerates the tables below from the logs.
|
||||
|
||||
## Tables
|
||||
|
||||
## A. Real Aetheria stores (Aetheria.Shared types, CultLib main 6a20309d and Aetheria's pinned CultLib b8fc5eb)
|
||||
|
||||
### A1. Load
|
||||
| store | rev | process-cold load, ms (median of 5 fresh processes, JIT included) | first load after warm-up, ms | warm load, ms median (min-max, n=15) | dir-store warm load, ms |
|
||||
|---|---|---|---|---|---|
|
||||
| Aetheria.cc | main | 688 (659-716) | 695.56 | 5.42 (4.71-17.23) | 6.22 |
|
||||
| Aetheria.cc | pin | 722 (676-781) | 634.87 | 6.35 (4.36-20.42) | 5.43 |
|
||||
| ship.cc | main | 18 (17-33) | 28.11 | 0.18 (0.16-0.40) | 0.38 |
|
||||
| ship.cc | pin | 17 (16-29) | 17.84 | 0.19 (0.17-0.37) | 0.36 |
|
||||
|
||||
### A2. Single-file flush (disk = Yggdrasil container work dir on host disk; shm = /dev/shm tmpfs, fsync is free)
|
||||
| store | rev | medium | changed | total ms | serialise ms | rest ms (lock, order, write, fsync, replace) | allocated MB | file bytes written |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| Aetheria.cc | main | disk | 1rec (n=1) | 5.97 | 1.85 | 3.72 | 2.56 | 214599 |
|
||||
| Aetheria.cc | main | disk | 1pct (n=3) | 5.16 | 1.79 | 3.26 | 2.56 | 214750 |
|
||||
| Aetheria.cc | main | disk | 10rec (n=10) | 5.01 | 1.75 | 2.98 | 2.56 | 214681 |
|
||||
| Aetheria.cc | main | disk | 10pct (n=39) | 8.46 | 2.41 | 4.80 | 2.56 | 214757 |
|
||||
| Aetheria.cc | main | shm | 1rec (n=1) | 2.65 | 1.06 | 1.14 | 2.54 | 214599 |
|
||||
| Aetheria.cc | main | shm | 1pct (n=3) | 2.27 | 1.09 | 1.13 | 2.54 | 214750 |
|
||||
| Aetheria.cc | main | shm | 10rec (n=10) | 2.83 | 1.11 | 1.17 | 2.54 | 214681 |
|
||||
| Aetheria.cc | main | shm | 10pct (n=39) | 2.60 | 1.21 | 1.18 | 2.54 | 214757 |
|
||||
| Aetheria.cc | pin | disk | 1rec (n=1) | 5.25 | 1.78 | 3.35 | 2.56 | 214599 |
|
||||
| Aetheria.cc | pin | disk | 1pct (n=3) | 6.51 | 2.38 | 3.94 | 2.56 | 214750 |
|
||||
| Aetheria.cc | pin | disk | 10rec (n=10) | 8.07 | 2.53 | 4.02 | 2.56 | 214681 |
|
||||
| Aetheria.cc | pin | disk | 10pct (n=39) | 8.82 | 4.26 | 5.01 | 2.56 | 214757 |
|
||||
| Aetheria.cc | pin | shm | 1rec (n=1) | 2.13 | 1.01 | 1.06 | 2.54 | 214599 |
|
||||
| Aetheria.cc | pin | shm | 1pct (n=3) | 2.18 | 1.03 | 1.07 | 2.54 | 214750 |
|
||||
| Aetheria.cc | pin | shm | 10rec (n=10) | 2.15 | 1.02 | 1.04 | 2.54 | 214681 |
|
||||
| Aetheria.cc | pin | shm | 10pct (n=39) | 2.27 | 1.08 | 1.11 | 2.54 | 214757 |
|
||||
| ship.cc | main | disk | 1pct (n=1) | 2.29 | 0.19 | 2.11 | 0.21 | 28642 |
|
||||
| ship.cc | main | disk | 1rec (n=1) | 1.68 | 0.08 | 1.59 | 0.21 | 28642 |
|
||||
| ship.cc | main | disk | 10pct (n=1) | 1.76 | 0.10 | 1.66 | 0.21 | 28642 |
|
||||
| ship.cc | main | disk | 10rec (n=10) | 1.76 | 0.11 | 1.66 | 0.21 | 28641 |
|
||||
| ship.cc | main | shm | 1pct (n=1) | 0.23 | 0.09 | 0.14 | 0.21 | 28642 |
|
||||
| ship.cc | main | shm | 1rec (n=1) | 0.23 | 0.09 | 0.14 | 0.21 | 28642 |
|
||||
| ship.cc | main | shm | 10pct (n=1) | 0.23 | 0.09 | 0.14 | 0.21 | 28642 |
|
||||
| ship.cc | main | shm | 10rec (n=10) | 0.23 | 0.09 | 0.14 | 0.21 | 28641 |
|
||||
| ship.cc | pin | disk | 1pct (n=1) | 1.96 | 0.18 | 1.78 | 0.21 | 28642 |
|
||||
| ship.cc | pin | disk | 1rec (n=1) | 1.75 | 0.08 | 1.67 | 0.21 | 28642 |
|
||||
| ship.cc | pin | disk | 10pct (n=1) | 1.77 | 0.10 | 1.62 | 0.21 | 28642 |
|
||||
| ship.cc | pin | disk | 10rec (n=10) | 1.72 | 0.08 | 1.60 | 0.21 | 28641 |
|
||||
| ship.cc | pin | shm | 1pct (n=1) | 0.17 | 0.06 | 0.11 | 0.21 | 28642 |
|
||||
| ship.cc | pin | shm | 1rec (n=1) | 0.18 | 0.06 | 0.12 | 0.21 | 28642 |
|
||||
| ship.cc | pin | shm | 10pct (n=1) | 0.18 | 0.06 | 0.12 | 0.21 | 28642 |
|
||||
| ship.cc | pin | shm | 10rec (n=10) | 0.17 | 0.06 | 0.11 | 0.21 | 28641 |
|
||||
|
||||
### A3. Throughput of 1-record change + flush (4 s loop)
|
||||
| store | rev | medium | single-file cycles/s | single-file flush ms median | directory-store cycles/s | raw atomic write of same size, ms median |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Aetheria.cc | main | disk | 180 | 5.41 | 129 | 1.795 |
|
||||
| Aetheria.cc | main | shm | 297 | 2.36 | 264 | 0.115 |
|
||||
| Aetheria.cc | pin | disk | 167 | 5.37 | 126 | 1.722 |
|
||||
| Aetheria.cc | pin | shm | 275 | 2.55 | 263 | 0.119 |
|
||||
| ship.cc | main | disk | 468 | 1.93 | 281 | 1.427 |
|
||||
| ship.cc | main | shm | 4809 | 0.17 | 3393 | 0.043 |
|
||||
| ship.cc | pin | disk | 497 | 1.71 | 355 | 1.458 |
|
||||
| ship.cc | pin | shm | 4607 | 0.17 | 3347 | 0.053 |
|
||||
|
||||
### A4. DirectoryMessagePackBackingStore, same records
|
||||
| store | rev | medium | initial full write ms | files | bytes on disk | 1-record flush ms | 1-record bytes written (new+changed files) | files touched | 10-record flush ms | 10-record bytes |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Aetheria.cc | main | disk | 564 | 396 | 284792 | 5.83 | 113484 | 2 | 20.98 | 117092 |
|
||||
| Aetheria.cc | main | shm | 32 | 396 | 284791 | 3.24 | 113484 | 2 | 4.67 | 117094 |
|
||||
| Aetheria.cc | pin | disk | 618 | 396 | 284788 | 8.18 | 113484 | 2 | 25.18 | 117093 |
|
||||
| Aetheria.cc | pin | shm | 36 | 396 | 284781 | 2.63 | 113483 | 2 | 3.71 | 117092 |
|
||||
| ship.cc | main | disk | 5 | 5 | 29235 | 3.58 | 11168 | 2 | 6.73 | 29235 |
|
||||
| ship.cc | main | shm | 0 | 5 | 29234 | 0.25 | 1782 | 1 | 0.42 | 19849 |
|
||||
| ship.cc | pin | disk | 5 | 5 | 29234 | 3.24 | 11167 | 2 | 5.40 | 29234 |
|
||||
| ship.cc | pin | shm | 0 | 5 | 29235 | 0.23 | 1783 | 1 | 0.43 | 19850 |
|
||||
|
||||
## B. Synthetic catalog scale sweep (SynthItem, about 570 B per record; main vs c2a; 1/10/10% of records changed)
|
||||
|
||||
| records | medium | rev | file MB | load warm ms | 1-rec flush total / serialise ms | 10-rec flush total / serialise ms | 10% flush total / serialise ms | alloc MB per 1-rec flush | 1-rec cycles/s | dir 1-rec flush ms | dir 1-rec bytes written | dir 10-rec flush ms | dir cycles/s |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 394 | disk | main | 0.23 | 4.67 | 5.63 / 1.12 | 5.81 / 1.45 | 5.49 / 1.58 | 2.68 | 196 | 7.94 | 72655 | 20.30 | 148 |
|
||||
| 394 | disk | c2a | 0.23 | 10.63 | 3.69 / 0.16 | 3.79 / 0.49 | 4.24 / 0.56 | 2.11 | 206 | 7.08 | 72655 | 24.59 | 141 |
|
||||
| 394 | shm | main | 0.23 | 1.48 | 2.81 / 1.31 | 1.67 / 0.59 | 1.78 / 0.65 | 2.65 | 469 | 2.91 | 72655 | 3.13 | 339 |
|
||||
| 394 | shm | c2a | 0.23 | 4.77 | 1.51 / 0.19 | 0.81 / 0.09 | 0.97 / 0.12 | 2.11 | 693 | 2.70 | 72655 | 3.32 | 321 |
|
||||
| 3940 | disk | main | 2.25 | 27.34 | 31.84 / 13.48 | 30.11 / 12.92 | 34.20 / 12.55 | 29.43 | 35 | 38.83 | 700297 | 44.11 | 26 |
|
||||
| 3940 | disk | c2a | 2.25 | 53.70 | 30.15 / 7.50 | 27.31 / 7.51 | 38.03 / 8.24 | 28.25 | 33 | 34.48 | 700297 | 46.68 | 25 |
|
||||
| 3940 | shm | main | 2.25 | 16.78 | 27.11 / 11.22 | 28.50 / 11.86 | 29.08 / 11.50 | 29.43 | 34 | 36.00 | 700297 | 34.33 | 29 |
|
||||
| 3940 | shm | c2a | 2.25 | 58.77 | 15.13 / 1.02 | 16.09 / 1.16 | 20.43 / 1.74 | 28.25 | 56 | 28.02 | 700297 | 37.51 | 30 |
|
||||
| 39400 | disk | main | 22.67 | 251.42 | 319.89 / 127.74 | 317.56 / 130.91 | 373.04 / 154.01 | 268.25 | 3 | - | - | - | - |
|
||||
| 39400 | disk | c2a | 22.67 | 591.61 | 283.51 / 74.10 | 335.84 / 79.71 | 383.73 / 85.22 | 252.89 | 3 | - | - | - | - |
|
||||
| 39400 | shm | main | 22.67 | 248.32 | 299.82 / 131.95 | 326.44 / 137.70 | 369.78 / 167.95 | 268.25 | 3 | - | - | - | - |
|
||||
| 39400 | shm | c2a | 22.67 | 653.54 | 281.16 / 70.06 | 280.78 / 72.06 | 363.50 / 84.19 | 252.89 | 3 | - | - | - | - |
|
||||
|
||||
## What the numbers say
|
||||
|
||||
- Flat in changed records, linear in total records. One record changed costs what ten percent changed costs, because `PushAll` serialises every entry and writes the whole file (`SingleFileBackingStore.PushAll` in `CultCache.cs`). On the real catalog the work per flush is 394 payload serialisations and a 214 KB write for a 600-byte edit.
|
||||
- At the real catalog's size the cost is small: 5.5 ms on a congested container disk, 2.5 ms in memory, 1.7 ms for the 3-record mod store (0.2 ms in memory). An editor saving one record never notices it. The case for paging is not this catalog today.
|
||||
- A 30 Hz writer (33.3 ms frame) spends about 16 percent of the frame on the real catalog on this disk, 7 percent with I/O free. The knee is the record count: 3,940 records already take 30 to 32 ms (a whole frame, 33 per second sustained), 39,400 take 300 ms. The same holds in memory (27 and 300 ms), so a faster disk does not move the knee; record assembly and serialisation do.
|
||||
- The existing directory store is not the paged store. It is not cheaper per flush (manifest rewritten whole, 0.3 to 0.5 of the single-file bytes, same milliseconds), costs 396 files and 285 KB for the catalog, and its first full write took 560 to 620 ms on disk against about 70 ms for the single file.
|
||||
- C2a's per-record byte reuse removes most of the serialise cost, not the O(records) flush. Whole-file writes and per-flush record assembly remain; at 39,400 records the total moved from 320 to 284 ms on disk (shm 300 to 281). Load got slower (394 records 4.7 to 10.6 ms; 39,400 records 251 to 592 ms). That load cost is the price of the format that C2a introduces and is worth a look separately.
|
||||
- C2a is a breaking change for the game's catalog: every list-of-objects member needs an element id member, and the existing stores have none. That is a migration question for the C2a cut, not a flush number.
|
||||
|
||||
## Caveats
|
||||
|
||||
- Container noise: one shared, niced, CPU-capped job among up to five; medians of 15 (8 at 39,400 records) with wide max values. Treat differences under about 20 percent as noise (main against pin at the same size are the control: they agree within that).
|
||||
- Process-cold load of Aetheria.cc is about 690 ms, almost all JIT and registry/type discovery; the file part is the 5 ms warm figure.
|
||||
- C2a figures are synthetic, one record shape, one run per medium. Main and C2a synthetic runs came from one job, one after the other.
|
||||
- Allocation is bytes allocated per flush, not peak working set.
|
||||
- A real-type scale sweep failed (`[CultGlobal]` records cannot be duplicated: "aetheria.verse_grammar is a global and already has record"); the synthetic sweep replaces it.
|
||||
146
research/paged-store/prior-art-2026-10-27.md
Normal file
146
research/paged-store/prior-art-2026-10-27.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# Prior art: paged / log-structured single-file stores for CultCache
|
||||
|
||||
Serves ruling `variants:ruling:page-tree-store-research` (Operator 2026-10-27: prior-art pass and flush measurement; "the page-tree preference stands"; whether and when to build is decided on the research, after C2a and the AOT cuts) and follow-up `variants:follow_up:paged-single-file-store`. Read with ruling `cultlib-gaps:ruling:database-shaped-built-for-load` (database-shaped things are built for unreasonable load).
|
||||
Facts with evidence pointers; no recommendation. Tags: VERIFIED (read in the fetched source or code this session), SECONDARY (search-result summary or third party), UNVERIFIED (from memory or inference, not read).
|
||||
Gathered 2026-10-27 by Eyes. Tool note: the fetch tool summarises through a small model, so numbers are VERIFIED only where I also read the text (PDFs were converted with pdftotext and grepped).
|
||||
|
||||
## 0. CultLib today (code, not research)
|
||||
|
||||
- Single-file write path: `SingleFileBackingStore` ... `WriteSnapshotAtomically(FileInfo.FullName, SerializeStore(format, catalog, fileRecords))` at `src\GameCult.Caching\CultCache.cs` :4522 on branch `hands/variants-c2a`; the helper at :4831 writes `.{name}.{guid}.tmp` with `FileOptions.WriteThrough`, `Flush(true)` (one fsync of the new file), then `File.Replace` or `File.Move`. VERIFIED (read). Write cost per flush is the whole file plus one fsync; no directory fsync appears in that helper. VERIFIED.
|
||||
- cultcache-rs already ships a redb-backed store: `RedbMessagePackBackingStore` and `OwnedRedbMessagePackBackingStore`, `packages\cultcache-rs\src\lib.rs` :1596-1660, :1975, :2348, :2483; `redb = "4.1.0"` in its `Cargo.toml` :21. Each polymorphic `(type, key)` is one redb row whose value is the MessagePack of its `CultCacheEnvelope`; one table `cultcache_envelopes`; opens a fresh redb handle per operation under the CultCache external lock because redb permits one writable handle per path. VERIFIED (read). Other runtimes have no reader for that file (no redb in C#/TS/Python): UNVERIFIED by search, but nothing in `src\GameCult.Caching` references redb.
|
||||
- Kept research to cite, not repeat: `docs\research\2026-10-23-store-lock-user-code-prior-art.md` (user code under a store lock; names LMDB one-txn-per-thread and SQLite btmutex); `docs\research\cross-process-file-locking-prior-art.md` (sidecar `.lock`, atomic replace per runtime); `docs\research\windows-store-replace-prior-art.md` (Windows `ReplaceFile` lets lock-free readers see an empty store: 19% of opens on .NET 10, 0 with `MoveFileExW`); `docs\research\2026-10-23-schema-drift-write-back-prior-art.md`; `research\store-writes-behind-cache\prior-art-2026-10-27.md` (redb `DatabaseAlreadyOpen`, second handle refusal, section B). The trigger (a whole-view flush re-asserting records a writer does not own) is not covered by any of them.
|
||||
- Size check: the ruling text says Aetheria.cc is 12.7 MB. The file at `F:\Projects\Aetheria\GameData\Aetheria.cc` in the operator checkout is 195,939 bytes. VERIFIED (ls). Not explained; the 12.7 MB figure may be another file or an LFS-resolved copy. Flush measurement not done here.
|
||||
|
||||
## 1. Page-tree single-file stores
|
||||
|
||||
### 1.1 SQLite (pager + B-tree)
|
||||
|
||||
- Page size: power of two 512..65536, one size per file, 2-byte field at offset 16; header integers big-endian; magic `"SQLite format 3\000"`. VERIFIED (sqlite.org/fileformat2.html).
|
||||
- Table b-trees: 64-bit rowid key, data in leaves; index b-trees: arbitrary keys. Overflow: payload over threshold X (table leaf X = U-35) spills into a linked list of overflow pages, each starting with a 4-byte next-page number; local bytes M = ((U-12)*32/255)-23 minimum. Freelist: trunk pages listing leaf page numbers. VERIFIED (fileformat2).
|
||||
- Rollback journal commit (synchronous=FULL): original page images to `-journal`, fsync; page count written into journal header, fsync again; changed pages written to the db file, fsync; delete (or zero/truncate) the journal = commit. Three flush points, plus directory sync on Unix for journal creation. NORMAL: one journal flush, protected by a per-page 32-bit checksum. Hot-journal recovery rolls back under an exclusive lock. VERIFIED (sqlite.org/atomiccommit.html). Assumptions listed there: 512-byte sector writes not atomic, linear writes, powersafe overwrite, atomic file delete, fsync honoured.
|
||||
- WAL: commits append to `-wal`; FULL syncs the WAL on every commit, NORMAL only at checkpoint; auto-checkpoint at 1000 pages (about 4 MB at default page size) copies pages back, syncing WAL then db; one writer; readers see an end-mark snapshot; checkpoint starvation lets the WAL grow without bound; read cost grows with WAL size; the wal-index is an mmap'd `-shm` file, so all processes must be on one host and no network FS; page size cannot change in WAL mode; WAL transactions beyond about 100 MB are often faster in rollback mode (statement predates 3.11). VERIFIED (sqlite.org/wal.html).
|
||||
- One-record update cost: root-to-leaf path pages (each a full page) written once to journal and once to db (rollback) or once to WAL plus once at checkpoint (WAL). Inferred from the above; UNVERIFIED as a measurement.
|
||||
- Blob storage vs files: at about 10 KB blobs, 4 KiB pages, SQLite read about 35% faster than separate files and used about 20% less space; blob sizes 100 KB and 1 MB not measured on that page; the page cites Gray et al. (SQL Server) that database blob reads win below roughly 250 KiB to 1 MiB. VERIFIED (sqlite.org/fasterthanfs.html).
|
||||
- Format stability: every release reads and writes files from 3.0.0 (2004); files portable across 32/64-bit and endianness; support planned to 2050; Library of Congress lists it as a preservation format. VERIFIED (sqlite.org/onefile.html, lts.html).
|
||||
- Size: library about 155.8 KSLOC of C (3.42.0); test code about 590 times that; crash testing by a VFS that reorders and corrupts unsynced writes and by whole-filesystem snapshot replay. VERIFIED (sqlite.org/testing.html).
|
||||
- Multi-process: file locks, one writer at a time in either journal mode. VERIFIED (wal.html, atomiccommit.html).
|
||||
|
||||
### 1.2 LMDB (copy-on-write B+tree, mmap)
|
||||
|
||||
- Design statement: copy-on-write pages, no WAL, no crash-recovery procedure, no compaction; one write transaction at a time, readers lock-free MVCC; freed pages tracked and reused; file bounded in normal use. VERIFIED (lmdb.h top-of-file).
|
||||
- Two meta pages (`NUM_METAS 2`); commit writes dirty pages, fdatasync (`mdb_env_sync0`), then pwrite of the meta page body through a separately opened O_DSYNC handle (`me_mfd`), so the meta write syncs itself. Two sync points per commit; `MDB_NOMETASYNC` / `MDB_NOSYNC` drop them. VERIFIED (`mdb.c` :4205-4225, :4385-4470, from raw.githubusercontent.com/LMDB/lmdb/mdb.master; 11,552 lines in `mdb.c`).
|
||||
- Large values: `P_OVERFLOW` pages are contiguous runs recorded by `mp_pages`; comment at :658-664 about the threshold at which items go into overflow pages. VERIFIED (mdb.c). Key limit 511 bytes (`MDB_MAXKEYSIZE`). VERIFIED (mdb.c :701).
|
||||
- Caveats from the header: long-lived read transactions block page reuse and the file grows; stale readers left by aborted processes need `mdb_reader_check`; do not use on network filesystems; do not use an env after `fork()`; do not open the same db twice in one process (fcntl locks); one transaction per thread; readers need write access to the lock file. VERIFIED (lmdb.h).
|
||||
- Portability: structs are native layout; the magic number "will reflect any byte order mismatches" (`mdb.c` :671); data version constant `MDB_DATA_VERSION 1`; TensorFlow docs say files differ between big- and little-endian hosts and an LMDB developer says 32- and 64-bit files are incompatible by default. VERIFIED for the magic comment, SECONDARY for the endianness and bitness statements (search snippets).
|
||||
- Concurrency across processes: shared lock file with a reader table; single writer via lock-file mutex. VERIFIED (header caveats).
|
||||
|
||||
### 1.3 redb (Rust, copy-on-write B+tree)
|
||||
|
||||
- Layout: 512-byte super-header (64-byte db header plus two 128-byte commit slots), regions with buddy allocator pages (`PAGE_SIZE` constant 4096 in `header.rs` :85), little-endian integers; XXH3-128 checksums in a Merkle arrangement (each branch stores child checksums). VERIFIED (docs/design.md; header.rs).
|
||||
- Commit: the "god byte" flips the primary slot. Default 1-phase-plus-checksum: write data and checksums under a new higher txn id, flip, one fsync; recovery keeps the primary if txn id is newer and checksums validate, else falls back to the secondary slot. 2-phase: fsync data, flip, fsync again (no reliance on checksums; design.md warns XXH3 is not collision resistant against a deliberate attacker). Non-durable commits kept in memory. VERIFIED (design.md).
|
||||
- Free pages: pending-free until no reader can reference them; format v3 stores them in system tables; quick-repair saves allocator state each commit, otherwise full repair walks the trees. VERIFIED (design.md).
|
||||
- Large values: `MAX_VALUE_LENGTH = 3 GiB` (`page_store/base.rs` :17); pages of higher order are power-of-two multiples of the base page; design.md does not say how an oversized value is stored. VERIFIED for the constant, UNVERIFIED for the mechanism (I did not read btree_base.rs).
|
||||
- Multi-process: three modes (exclusive writer, single writer with read-only readers, multi-writer) using byte-range file locks; non-durable commits unavailable in multi-process modes; second writable handle refused by default. VERIFIED (design.md; README features "Multi-process access to one database, with a single or several writer processes").
|
||||
- Format stability: README "file format is stable, and a reasonable effort will be made to provide an upgrade path"; versions 1 to 3 exist. VERIFIED. Pure Rust only (`no_std` build supported with a `StorageBackend` trait). VERIFIED (README).
|
||||
- Size: `src` is 41,882 lines of Rust at `16c7107` (2026-10-25), of which `tree_store/page_store` is 12,606. VERIFIED (counted from a shallow clone; includes comments and in-file tests).
|
||||
|
||||
### 1.4 bbolt (Go, derived from Bolt)
|
||||
|
||||
- B+tree, mmap, copy-on-write, one read-write transaction at a time, MVCC readers; README says the code base is "<5KLOC". VERIFIED (README). Non-test Go under the repo excluding `cmd` and `tests` is 8,232 lines at `a4ef197` (2026-10-16). VERIFIED (counted).
|
||||
- Commit: dirty pages written and fsynced, then a new meta page with incremented txn id written and fsynced (two fsyncs); meta pages carry checksums. VERIFIED (README).
|
||||
- Caveats: files cannot shrink (free list reuse only); long read transactions block reclamation; a single exclusive file lock so no multi-process writers; endian-specific files because structures are memory-mapped; random writes slow, `Batch()` or a WAL suggested; mmap problematic on 32-bit. VERIFIED (README).
|
||||
- Overflow: `Page.Overflow()` / `SetOverflow` in `internal/common/page.go` :206-210, large values span contiguous pages. VERIFIED that the field exists; the mechanism is UNVERIFIED beyond that.
|
||||
|
||||
## 2. B-epsilon trees and write-optimised designs
|
||||
|
||||
- Structure: B-tree whose internal nodes use B^e slots for pivots and the rest for a message buffer; messages (insert, delete tombstone, upsert) sorted by key then timestamp; flushing a full buffer moves messages one level down. e=1 is a B-tree; e=0 is a buffered repository tree. VERIFIED (Bender et al., ";login:" Oct 2015, usenix.org/system/files/login/articles/login_oct15_05_bender.pdf, read via pdftotext).
|
||||
- Cost: batching divides insert cost by B^(1-e); with B=1024 and e=1/2 the article gives 16 times faster inserts than a B-tree; point-query cost has the same asymptotics as a B-tree (the article says queries "are no faster than in a B-tree"); nodes of a few hundred KB to a few MB are practical because insert bandwidth cost grows slowly with B. VERIFIED (same).
|
||||
- Upserts: a message encoding "read-modify-write without the read"; applied at flush or on a query that finds the message in transit. LSM upserts defeat Bloom filters because a pending message may sit in every level. VERIFIED (same).
|
||||
- Versus LSM: article claims Bloom-filter LSMs match B^e-trees for some workloads, B^e-trees asymptotically dominate for small range queries and upsert-heavy point queries. VERIFIED (same; it is an authors' claim).
|
||||
- Measured: BetrFS (TokuDB underneath), 3 million 200-byte file creates: steady-state an order of magnitude faster than ext4/XFS/Btrfs/ZFS, 4 to 10 times fewer bytes written. Small random writes into a large file: "almost two orders of magnitude" better; TokuDB node typically 4 MB. VERIFIED (same article; BetrFS FAST'15 text cs.unc.edu/~porter/pubs/fast15-final.pdf, grep hits at lines 416, 643). Sequential large-file I/O is a place BetrFS is not ahead; the paper lists "few slower operations". SECONDARY (journal abstract snippet: "requires additional data-structure tuning to match ... on some operations").
|
||||
- PerconaFT/TokuDB: node-size setting default 4 MiB (in-memory size; compressed on disk), leaves split into 128 KB default basement nodes (read block) so a point query reads a basement node rather than a 4 MB leaf; buffers serialised to disk with per-message sequence numbers. SECONDARY (Percona blog, slides, docs mirror; the PerconaFT source not read). TokuDB is deprecated in Percona Server (UNVERIFIED; not checked).
|
||||
- The regime where the sources place B-trees ahead: queries (no asymptotic gain for the B^e-tree), small data that fits in cache, and read-modify-write patterns bound by the query. VERIFIED as the article's statements ("Avoid query-before-update"); no source read gives a data-size threshold below which the gain vanishes.
|
||||
|
||||
## 3. Log-structured alternatives
|
||||
|
||||
### 3.1 LSM (LevelDB, RocksDB, WiscKey)
|
||||
|
||||
- LevelDB: writes go to a log, then memtable, then a level-0 table at about 4 MB; level L limit 10^L MB; level-0 compacts at 4 files; output files about 2 MB; CURRENT names the latest MANIFEST (a log of file-set edits); recovery converts the leftover log into a level-0 table. The page does not state fsync behaviour. VERIFIED (github.com/google/leveldb doc/impl.md).
|
||||
- Amplification (WiscKey paper, read via pdftotext): LevelDB write amplification 3.1 at 1 GB and 14 at 100 GB (16-byte keys, 1 KB values); worst case over 50; read amplification 8.2 at 1 GB and 327 at 100 GB; "I/O amplification in typical LSM-trees can reach a factor of 50x or higher". VERIFIED (Lu et al., FAST'16).
|
||||
- RocksDB tuning guide: leveled multiplier 10; example write amplification about 33; space amplification about 1.14 for leveled; universal compaction trades lower write amplification for higher read and always higher space amplification (default 200% max size amplification), advised against above about 100 GB. VERIFIED (RocksDB wiki).
|
||||
- WiscKey (key-value separation): keys in the LSM, values in an append-only value log with (key size, value size, key, value) tuples; effective write amplification worked example (10*16+1024)/(16+1024) = 1.14; GC reads from the tail and rewrites live values at the head, fsyncs the vLog, then updates the tree; crash consistency relies on appended bytes surviving as a prefix; range scans pay random reads and lose to LevelDB for small random-order values read sequentially. VERIFIED (same paper).
|
||||
- Multi-process: LevelDB takes a LOCK file for one process (UNVERIFIED, impl.md lists LOCK only as a file name).
|
||||
|
||||
### 3.2 Bitcask
|
||||
|
||||
- A directory; one OS process writes; one active append-only data file rolled at a size threshold; records of CRC, timestamp, key size, value size, key, value (the field list is in a diagram I could not read; VERIFIED only that entries are a "linear sequence"). Tombstone value for delete. VERIFIED (Basho 2010 intro, riak.com/assets/bitcask-intro.pdf).
|
||||
- In-memory keydir: hash map from every key to file id, offset, size; whole keydir must fit in RAM ("well under a GB" for many millions of keys); one disk seek per read. VERIFIED.
|
||||
- Merge rewrites the immutable files keeping latest versions and writes hint files (key plus position and size, no values) so startup scans hints instead of data. Sync-on-put is an option. VERIFIED.
|
||||
- Space reclaim only at merge; no compression. VERIFIED.
|
||||
|
||||
### 3.3 Append-only B-tree files (CouchDB / couchstore)
|
||||
|
||||
- Updates appended, never overwritten; commit is two steps: data and index flushed, then the header written as two consecutive identical copies in the first 4 KB and flushed (two fsync points). VERIFIED for the docs.couchdb.org description of the two steps. A Couchbase bug report (MB-10280) describes extending the file before the first fsync so the second can be an `fdatasync`. SECONDARY.
|
||||
- couchstore format notes: header re-written at the tail on each change; 4096-byte blocks so the header can be located; headers larger than 4090 bytes split across blocks with a prefix byte. SECONDARY (search snippets of couchstore file_format.md and a couch_file commit message).
|
||||
- Reclaim: compaction copies live data into a new file and swaps. VERIFIED (docs.couchdb.org).
|
||||
|
||||
### 3.4 Single-file log with periodic compaction
|
||||
|
||||
- No separate system read. The Bitcask hint-file idea and WiscKey's vLog/GC are the closest sources above. SQLite WAL is a bounded log with checkpoint (section 1.1). rkv "SafeMode" (Mozilla) keeps the whole database in memory and writes transactions synchronously to disk on commit; the sources do not say the file is rewritten whole. SECONDARY (rkv README, docs.rs snippets).
|
||||
|
||||
## 4. Per-family summary
|
||||
|
||||
Crash mechanism and syncs per commit:
|
||||
|
||||
| Family | Mechanism | Sync points per commit |
|
||||
|---|---|---|
|
||||
| Whole-file replace (CultLib today) | temp file + rename/replace | 1 data fsync (Flush(true)); no dir fsync in the C# helper. VERIFIED (code) |
|
||||
| SQLite rollback journal | journal + hot-journal rollback | 3 (FULL), 2 (NORMAL). VERIFIED |
|
||||
| SQLite WAL | append, checkpoint later | 1 per commit on FULL, 0 on NORMAL, plus checkpoint syncs. VERIFIED |
|
||||
| LMDB | CoW + alternating meta pages | 2 (data fdatasync, then meta via O_DSYNC fd). VERIFIED |
|
||||
| bbolt | CoW + checksummed meta | 2. VERIFIED |
|
||||
| redb | CoW + god-byte slot flip | 1 (1PC+checksum, default) or 2 (2PC). VERIFIED |
|
||||
| LSM | WAL + memtable + sorted tables + manifest | WAL sync optional per write; manifest edits on compaction. VERIFIED for structure; WAL sync policy not stated in impl.md |
|
||||
| Bitcask | append + CRC | optional sync-on-put. VERIFIED |
|
||||
| CouchDB | append + dual header | 2. VERIFIED |
|
||||
|
||||
Bytes written for a one-record update (all inferred from the page/tree structure; none measured here, UNVERIFIED as numbers):
|
||||
- Whole-file replace: the whole file, every flush.
|
||||
- CoW B-tree (LMDB, bbolt, redb): one new page per tree level from leaf to root (at 4 KiB pages, the tree depth times 4 KiB) plus the meta slot; larger record adds its overflow pages.
|
||||
- SQLite: same path of pages, written twice (journal then db, or WAL then checkpoint).
|
||||
- B^e-tree: one message into the root buffer, amortised flush cost.
|
||||
- LSM: one WAL record, with 3 to 14 write amplification for the compaction later (measured on LevelDB, 1 GB and 100 GB respectively). VERIFIED (WiscKey).
|
||||
- Bitcask/CouchDB-style append: the record (CouchDB adds tree path nodes plus the header).
|
||||
|
||||
Read path: B+tree families root-to-leaf, with mmap giving pointers into the file (LMDB, bbolt) or an internal page cache (redb, SQLite). LSM checks memtable then up to 14 files worst case (LevelDB, WiscKey §2.3). Bitcask is one seek via the in-memory keydir. VERIFIED per family above.
|
||||
|
||||
Space reclaim: LMDB and redb reuse freed pages once no reader holds them; bbolt reuses but cannot truncate; SQLite has a freelist plus VACUUM (VACUUM not read); LSM and Bitcask and CouchDB reclaim only by compaction/merge rewriting data; LevelDB leveled space amplification about 1.14 by RocksDB's example and universal compaction can need up to three times the data temporarily. VERIFIED except VACUUM (UNVERIFIED).
|
||||
|
||||
Multi-process / multi-writer: LMDB shared lock file, single writer, caveats around stale readers; bbolt exclusive file lock, no multi-process writers; redb three modes via byte-range locks; SQLite file locks, one writer, WAL needs same-host shared memory; Bitcask one writer process per directory; CouchDB serialises updates in one server. None of these give a real multi-writer on one file except redb's multi-writer mode, which requires quick-repair on each write. VERIFIED (design.md).
|
||||
|
||||
Variable-size values: SQLite overflow page chains (4-byte next pointer); LMDB contiguous overflow runs; bbolt contiguous overflow count on the page header; redb higher-order (power-of-two-sized) pages up to a 3 GiB value cap; B^e-trees use nodes of 0.25 to 4 MB with 128 KB basement nodes; LSM puts values in sorted tables (WiscKey moves values above roughly 1 KB to a vLog); Bitcask and CouchDB write values inline in the log. VERIFIED for SQLite, LMDB, redb cap, WiscKey; UNVERIFIED for bbolt and redb mechanism beyond the field names.
|
||||
|
||||
Mechanism size, small faithful implementations: bbolt about 5.6K lines root non-test Go (8.2K with internal packages); redb 41.9K lines total, 12.6K in `page_store`; LMDB `mdb.c` 11.6K lines of C; SQLite 155.8 KSLOC; LevelDB and RocksDB not counted. VERIFIED for counts stated; each counts differ in whether comments, tests and CLI are included.
|
||||
|
||||
## 5. Cross-runtime, one on-disk format
|
||||
|
||||
- SQLite: parity achieved by one C implementation embedded or wrapped in other languages, with a documented, big-endian, frozen format; the lts.html page does not itself name the C implementation as reference. VERIFIED (fileformat2, lts.html). Independent re-implementations I found: SQLJet (pure Java, reads and writes SQLite files, low-level API, no SQL execution beyond the schema, GPL with a commercial licence; Debian/Fedora packages at 1.1.10, upstream activity unclear) and Turso/Limbo (Rust rewrite; announcement says compatible at the language and file-format level; status labels conflict between sources). SECONDARY (search snippets). Their completeness was not tested.
|
||||
- LMDB: bindings wrap the C library (lmdb-js, heed, Lightning.NET, lmdb-go and others named in the search; the search returned no pure re-implementation). SECONDARY/UNVERIFIED. Native-endian, pointer-sized structures make files non-portable across architectures. VERIFIED (mdb.c comment) and SECONDARY (TensorFlow docs, PowerDNS thread).
|
||||
- bbolt: endian-specific because structures are mmap'd (README). VERIFIED. Pure Go only.
|
||||
- redb: little-endian explicit; Rust only; `no_std` backend trait. VERIFIED. No other-language reader found. UNVERIFIED (negative search).
|
||||
- LevelDB: its table format has a short spec (`doc/table_format.md`: fixed footer with magic `0xdb4775248b80fb57`, varint block handles, index and metaindex blocks) sufficient, in the fetch summariser's words, to implement a reader from the spec. Independent implementations named by search: syndtr/goleveldb (pure Go), kezhuw/leveldb (claims format conformance, "not production", last pushed 2020). On-disk compatibility of goleveldb with LevelDB was not confirmed by the search. VERIFIED for the spec content; SECONDARY for the ports.
|
||||
- CouchDB/couchstore: Couchbase notes say its format is similar to but not identical to CouchDB 1.2's; i.e. two implementations diverged. SECONDARY.
|
||||
- Reported hardness of parity (from the sources, not from any parity post-mortem I found): native struct layout and endianness (LMDB, bbolt); a format defined by one implementation's code with the spec secondary; checksums and crash recovery logic that must match, not just the layout (redb god byte plus txn id plus checksum rules; SQLite sector and hot-journal rules); platform persistence properties (Pillai et al. OSDI'14 find 60 crash vulnerabilities across 11 applications including LevelDB, LMDB, SQLite, PostgreSQL and ordering/atomicity assumptions that differ by file system; VERIFIED, read via pdftotext). I found no document on porting a page-tree engine across four runtimes with a parity test suite. UNVERIFIED (negative search).
|
||||
- mmap: CMU CIDR 2022 "Are You Sure You Want to Use MMAP in Your DBMS?" (db.cs.cmu.edu/mmap-cidr2022/): authors argue mmap raises correctness and performance problems and several systems moved away from it after "significant engineering costs"; the page excerpt names no systems or four-way list. VERIFIED for those statements only. Python, TypeScript and C# each have different mmap availability (UNVERIFIED, not researched).
|
||||
|
||||
## 6. Published comparisons of whole-file rewrite vs paged/log stores at 10-100 MB
|
||||
|
||||
- None found. Searches for a benchmark of atomic whole-file replacement against SQLite or an LSM at 1, 10 and 100 MB returned nothing relevant; the closest are a SQLite forum thread where a maintainer says changing part of a JSON value means rewriting the whole row (SECONDARY), a VLDB paper on write amplification of small random SQLite updates on mobile flash (SECONDARY, vldb.org/pvldb/vol8/p1454-oh.pdf, not read), and the SQLite 10 KB blob benchmark (section 1.1). Mozilla's rkv SafeMode is a deployed in-memory store with synchronous writes on commit (section 3.4) but no published rewrite-vs-paged measurement was found.
|
||||
- Measurement is therefore ours to take: whole-view flush cost on a real store (see the follow-up's own measurement item and section 0 size discrepancy).
|
||||
|
||||
## 7. Not done
|
||||
|
||||
- WiscKey, BetrFS, Bender, Bitcask and Pillai were read via pdftotext; BetrFS only grepped. SQLite pages were read through the fetch summariser.
|
||||
- redb `btree_base.rs` large-value path, bbolt `tx.go` commit, PerconaFT source, RocksDB WAL sync documentation, SQLite VACUUM and incremental autovacuum, Windows-specific fsync and mmap behaviour for LMDB and bbolt, and the VLDB mobile-SQLite paper were not read.
|
||||
- No code was run and nothing was benchmarked.
|
||||
- Shallow clones used for line counts are in the session scratchpad (`...\scratchpad\repos\redb`, `...\bbolt`), not in the repo.
|
||||
72
research/store-writes-behind-cache/prior-art-2026-10-27.md
Normal file
72
research/store-writes-behind-cache/prior-art-2026-10-27.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Prior art: direct writes to a store that a cache sits in front of
|
||||
|
||||
Serves fork `variants:follow_up:public-store-writes-behind-cache` (campaign `variants`, session `self-2026-10-27-eureka`).
|
||||
Facts with evidence pointers; no recommendation. Status tags: VERIFIED (read in the fetched source this session), SECONDARY (search-result summary or third-party), UNVERIFIED (not read).
|
||||
|
||||
## A. Code today (worktree `C:\cl-wt\c2a-land`, branch hands/variants-c2a, HEAD 193cf1c3). File: `src\GameCult.Caching\CultCache.cs` unless noted
|
||||
|
||||
1. Public abstract surface of `CacheBackingStore`: `Push` :3717, `Delete` :3718, `PushAll` :3719, `CommitBatch` :3732 (all `public abstract`). `PullAll` :3716. `IsDirty` public getter, protected setter :3576.
|
||||
2. Cache-routed writes reach the store through the same methods: upsert/variant `home?.Push(docs[0])` :2387, :2401, :2666; `Remove` `home?.Delete(existing)` :2425; batch `home.CommitBatch(request, wait)` :2726; flush `store.PushAll()` :2347 (`FlushAllBackingStores`). All admissions go through `Admit(...)` (~:2750), which validates, then calls the `land` lambda.
|
||||
3. A direct store call is not refused when attached. `Gate<T>` :3656 / `Held` :3687 only chooses which lock to take: attached, the call runs under the cache's gate (comment :3647-3655); detached, under the store's gate. Only the cacheless writes refuse: `WriteRecord` :4354 and `WriteDocument` :4378 throw `AttachedRefusal` (const :3600, text "The store is attached to a cache, write through the cache.") via `Gate(..., cacheless:true)`. `Push/Delete/PushAll/CommitBatch` pass `cacheless:false`.
|
||||
4. What a direct `Push` does (:4224-4232): under the gate, `Entries[key] = entry` (the store's own dictionary, :3571, separate from the cache's `_entries`), `_staged.Add(key)`, `IsDirty = true`. `Delete` :4235 mirrors it. Nothing is handed to the cache: the cache's `_entries` and change feed are untouched. (c2a-land-map.md:284-290: Soul s4 probe `other-thread-push`, file `a,b`, cache `a`.)
|
||||
5. Effects on later operations:
|
||||
- `PushAll` :4251 / `CommitBatch` :4276 write the store's `_staged` keys read from the store's `Entries` (`StagedWrites`, ~:4300), so a directly staged record is written to disk with the cache's next flush or batch; then store `Wrote` :4661 updates store entries and calls `Cache?.Wrote` (:2081), which only updates versions of keys the cache already holds (skips others).
|
||||
- `PullAllCore` :4170-4175 returns early `if (IsDirty)` (comment: pulling with staged mutations would let an older snapshot erase them). While a direct stage is pending, a cache pull of that store is a no-op.
|
||||
- Conditional batch: :2724 throws if `home.IsDirty` ("has staged writes; flush before a conditional commit").
|
||||
- Attach: :2273 refuses a dirty store ("attach it clean").
|
||||
6. A direct `PushAll`/`CommitBatch` writes the file under the file lock with the store's staged set plus the batch (:4276-4296); `CommitBatch` sets store `Entries` from `request.Upserts/Deletes` (:4290-4293) without telling the cache.
|
||||
7. Lock order comment: a cache's gate, then a store's (:3706). `InStoreHold` :2830 refuses a second store's gate inside a hold.
|
||||
|
||||
## B. Overlap with kept research (cite, do not repeat)
|
||||
|
||||
- `F:\Projects\CultLib\docs\research\2026-10-23-store-lock-user-code-prior-art.md` (commit 7b1cd00c): user code under a lock (JCIP ch.10, Sutter, lockdep, ConcurrentHashMap re-check, LMDB one txn per thread, SQLite btmutex). Bears on why the store gate refuses re-entry; says nothing about cache/store write routing.
|
||||
- `F:\Projects\CultLib\docs\research\2026-10-23-schema-drift-write-back-prior-art.md` (commit 217a9480): old-schema write-back (Avro, protobuf, Schema Registry, Mongo/Realm/Core Data/EF Core stored versions). Not about identity-map coherence.
|
||||
- `F:\Projects\CultLib\docs\research\windows-store-replace-prior-art.md`, `cross-process-file-locking-prior-art.md`: file-level lock/replace; not read in full. A grep for "identity map|write-through|ExecuteUpdate|stale cache" over docs\research and research\ found no hits.
|
||||
- `F:\Projects\CultLib\research\cultnet-distributed-database\summary.md` (+ dynamo, firebase offline, rethinkdb changefeeds) and `research\witness-authoritative-networking\prior-art-2026-10-26.md`: multi-writer/replication; only listed, not read this session.
|
||||
|
||||
## C. Established systems
|
||||
|
||||
### C1. ORMs / identity maps
|
||||
|
||||
- **Hibernate / JPA bulk HQL and native SQL.** Bulk UPDATE/DELETE runs in the database and does not touch loaded entities; per the JPA spec text relayed by in.relation.to, bulk operations "may result in inconsistencies between the database and the entities in the active persistence context"; advice: run them first or in a separate transaction. Hibernate invalidates the affected second-level cache regions after a bulk op (in.relation.to). Manual escape hatches exist (refresh, evict/clear: user guide TOC sections 6.11, 6.14, 14.7.1; section bodies not read, page truncated). Known issues: HHH-15247 (2nd-level collection cache not invalidated after HQL delete); Red Hat KB 5589111 (native update invalidated unrelated query-cache results); empty query space means whole-cache invalidation (blog, SECONDARY). Policy: hazard documented, manual refresh/clear provided, L2 cache invalidated automatically.
|
||||
Sources: https://in.relation.to/2005/07/20/bulk-operations/ (VERIFIED, short), https://hibernate.atlassian.net/browse/HHH-15247 (SECONDARY), https://access.redhat.com/solutions/5589111 (SECONDARY).
|
||||
- **EF Core `ExecuteUpdate`/`ExecuteDelete`.** Documented as "completely unaware of EF's change tracker, and have no interaction with it whatsoever"; example: a tracked entity with a stale value is later saved by `SaveChanges` and "the change performed by ExecuteUpdate is overwritten". Guidance: "usually a good idea to avoid mixing" them; no implicit transaction; no automatic concurrency check, rows-affected returned for manual checks. Policy: separate API surface, hazard documented, no coherence. VERIFIED: https://learn.microsoft.com/en-us/ef/core/saving/execute-insert-update-delete
|
||||
- **SQLAlchemy 2.0 ORM bulk UPDATE/DELETE with WHERE.** `synchronize_session`: default `'auto'` (`'fetch'` where RETURNING works, else `'evaluate'`); `'fetch'` finds affected rows and refreshes or expunges matching in-session objects; `'evaluate'` evaluates the WHERE in Python (can fail on complex criteria); `False` leaves session state unchanged. Legacy session bulk methods lack session synchronization. Policy: the write path itself takes a sync mode, default synchronizes. VERIFIED: https://docs.sqlalchemy.org/en/20/orm/queryguide/dml.html (page did not state how newer bulk insert/update-by-PK treat the identity map).
|
||||
|
||||
### C2. Embedded stores
|
||||
|
||||
- **SQLite.** Cache is per connection; coherence by a file change counter at header offset 24 that other processes monitor ("a process that sees the counter change should flush its page cache"); in WAL mode detection is via the wal-index instead. Automatic, not caller-managed. Shared-cache mode (one cache across a process's connections) is called "an obsolete feature" on the project's page; WAL recommended. VERIFIED: https://sqlite.org/fileformat2.html, https://sqlite.org/sharedcache.html
|
||||
- **redb.** Default `ConcurrencyMode::ExclusiveWriter`: a writer "excludes every other process" by whole-file lock; a second open for write fails with `DatabaseError::DatabaseAlreadyOpen`; `ReadOnlyDatabase` may not coexist with a `Database` in that mode; one write transaction at a time (`begin_write` blocks); page cache internal (`set_cache_size`, `cache_stats`). Policy: forbid by refusing the second handle. VERIFIED (first 100k of 190k chars of src/db.rs): https://raw.githubusercontent.com/cberner/redb/master/src/db.rs
|
||||
- **LMDB.** No user-space cache (mmap). Header warns against opening one environment twice in a process ("close()ing it breaks fcntl() advisory locking"); do not mix MDB_WRITEMAP and non-WRITEMAP processes; with MDB_NOLOCK "the caller must manage all concurrency itself"; another process growing the map surfaces as MDB_MAP_RESIZED at txn begin. Policy: document the hazard, forbid some mixes. VERIFIED: https://raw.githubusercontent.com/LMDB/lmdb/mdb.master/libraries/liblmdb/lmdb.h
|
||||
- **RocksDB.** A secondary instance sees primary writes only by manual `TryCatchUpWithPrimary()` (applies MANIFEST changes, rebuilds memtables from WAL); "lags until the next manual catch-up call"; writes with WAL disabled are invisible; read-only instances never catch up; page does not address caches or the LOCK file. Policy: second reader is explicit, read-only, stale by design. VERIFIED: https://github.com/facebook/rocksdb/wiki/Read-only-and-Secondary-instances
|
||||
|
||||
### C3. Write-through / write-behind caches (the cache is the front door)
|
||||
|
||||
- **Caffeine.** `CacheWriter` deprecated in 2.9, removed in 3.0 in favor of `Map` compute methods; eviction handled by `evictionListener`. Wiki lists limits of the writer: loads/refreshes/computes not reported to it; writer failure leaves the mapping unchanged; write-back can leave invalid cache state on failure. No stated reason for removal. Policy: store writes happen inside the cache's per-key atomic compute. VERIFIED: https://github.com/ben-manes/caffeine/wiki/Writer; deprecation notice SECONDARY: javadoc.io Caffeine 2.9.2 deprecated list.
|
||||
- **Ehcache 3 `CacheLoaderWriter`.** Write-through is synchronous (put calls `write`); write-behind queues, batches, coalesces (only the last mutation per key reaches the writer); a read-through-only cache "risks making it inconsistent with the underlying system of record" if mutated, so no-op or throw in `write*/delete*`; page does not address other writers to the SoR. VERIFIED: https://www.ehcache.org/documentation/3.1/writers.html
|
||||
- **Apache Ignite.** Write-through persists updates to the external store; write-behind: "some updates could be lost due to node failures", only latest value per key written; page does not address external changes by others. VERIFIED: https://ignite.apache.org/docs/latest/persistence/external-storage
|
||||
- **Hazelcast MapStore.** Write-through is `write-delay-seconds` 0; MapLoader fires on a miss only, so loaded entries persist until changed or evicted (search-result summary of 5.x docs; no doc page found addressing database-side edits; the stale consequence is inferred). SECONDARY/UNVERIFIED: https://docs.hazelcast.com/hazelcast/5.5/mapstore/implement-a-mapstore
|
||||
- **Memcache at Facebook (Nishtala et al., NSDI 2013).** Writes go to the database, then the cache entry is deleted (invalidation, via mcsqueal); leases stop a stale `set` after a newer write; MIT 6.824 FAQ: a window of stale reads exists by design. SECONDARY (paper not read): https://pdos.csail.mit.edu/6.824/papers/memcache-fb.pdf, https://pdos.csail.mit.edu/6.824/papers/memcache-faq.txt
|
||||
|
||||
### C4. Paper/design note on single-writer embedded-store cache coherence
|
||||
|
||||
- None found this session (searches covered Memcache only; none targeted embedded stores directly). Not exhaustive.
|
||||
|
||||
## D. Pattern table (facts only)
|
||||
|
||||
| System | Direct write path next to the cache | What the API does |
|
||||
|---|---|---|
|
||||
| Hibernate | bulk HQL/native SQL | exists; L2 invalidated; L1 not; hazard documented; refresh/clear provided |
|
||||
| EF Core | ExecuteUpdate/Delete | exists; tracker unaware; hazard documented |
|
||||
| SQLAlchemy | bulk UPDATE/DELETE | exists; `synchronize_session` default `auto` refreshes/expunges |
|
||||
| SQLite | second connection | allowed; change counter invalidates the other cache |
|
||||
| redb | second handle | refused (`DatabaseAlreadyOpen`) |
|
||||
| LMDB | second env in process | documented as prohibited |
|
||||
| RocksDB | secondary instance | allowed read-only, manual catch-up |
|
||||
| Caffeine / Ehcache / Ignite | writer inside the cache | store written only via cache operations; direct SoR writes not addressed in pages read |
|
||||
| Hazelcast | direct DB edits | not addressed in docs found |
|
||||
| CultCache today | public Push/Delete/PushAll/CommitBatch | allowed attached; cache not told; refused only for WriteRecord/WriteDocument |
|
||||
|
||||
## E. Not done
|
||||
|
||||
Hibernate user-guide sections 13.3 / 14.7 bodies not read; Hazelcast official page empty on fetch; Nishtala paper and the Dynamo/Firebase/RethinkDB files not read; no GitHub-issues search for redb/RocksDB/SQLite incident reports; Soul s4 probe not run.
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
# Witness-authoritative networking: prior art (2026-10-26)
|
||||
|
||||
## Framing (operator, 2026-10-26)
|
||||
|
||||
The concept began as witness-authoritative networking; the consensus literature is the closest existing body of work, not the origin. The goal: no centralized authoritative server should be needed to resolve local disputes such as whether Alice shot Bob first or Bob shot Alice first. A quorum of nearby clients reaches agreement on the ordering, and whoever is determined to be wrong is corrected exactly as in a server-authoritative model with client-side prediction, except that the correction can happen before the server hears about it.
|
||||
|
||||
**Why the current implementation looks as it does (operator, 2026-10-26).** CultMesh was deliberately built for trusted co-op first, for Aetheria: Starbridge, with the witness-authority problem explicitly deferred. Starbridge itself collapsed under the weight of building Eve at the same time. The trusted-primary, unsigned-witness shape described below is therefore the intended first stage, not a failed attempt at Byzantine tolerance; hardening against adversarial peers is the deferred second stage this document prepares.
|
||||
|
||||
The report below was produced by a read-only Eyes pass. It describes the CultMesh implementation as read from source on 2026-10-26 and compares it with prior art; it does not recommend a design. Items marked as from memory were not re-fetched.
|
||||
|
||||
---
|
||||
|
||||
# CultMesh "witness consensus": source reading and prior art
|
||||
|
||||
Eyes pass, 2026-10-26. Read-only. Eureka mind (eureka-state) not used. No design recommendation is made here.
|
||||
|
||||
Evidence labels: [SRC] read from the CultLib source this pass. [WEB] confirmed by a web search result this pass (title/venue/authors seen in a result). [MEM] from model memory, NOT re-fetched this pass; verify before citing.
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
## STEP 1. What CultMesh witness consensus is today (from source)
|
||||
|
||||
All paths under `F:\Projects\CultLib\`.
|
||||
|
||||
### 1.1 The machine, as implemented [SRC]
|
||||
|
||||
Three pieces plus a facade:
|
||||
|
||||
1. **Observation** `CultNetSimulationObservation`, `src\GameCult.Networking\CultNetSimulationConsensus.cs:15-69`. One witness report: `witnessRuntimeId`, `shardId`, `shardEpoch`, `frame`, `subjectId`, `claimKind` ("hit, input, collision, or ownership", line 39), `claimHash` (SHA-256 of caller-chosen parts joined by U+001F, lines 61-68), optional `claimSummary`, `weight` (default 1, "Normal clients should use 1", lines 50-52), `observedAt`. There is no signature field, no nonce, no reference to the witness's own state hash or prior observations.
|
||||
2. **Aggregator** `CultNetSimulationConsensus.BuildCandidates`, same file lines 177-293.
|
||||
- Drops invalid observations (empty ids/hash or weight <= 0; lines 257-266).
|
||||
- De-duplicates per (witness, shard, epoch, frame, subject, kind), keeping the highest weight (lines 196-203, 273-282). So a witness that reports two different claim hashes for the same subject/frame keeps only one (heaviest, then ordinal-smallest witness id), not both.
|
||||
- Groups by (shard, epoch, frame, subject, kind) (lines 205-206, 284-292). Per group: total weight = sum of all weights; best claim = the claimHash with greatest summed weight, ties broken by smallest hash ordinal (lines 219-230).
|
||||
- `HasQuorum = distinctWitnesses >= MinimumWitnesses && bestWeight >= MinimumWeight && bestWeight/totalObservedWeight >= QuorumRatio` (lines 238-241). Defaults: MinimumWitnesses 1, MinimumWeight 1.0, QuorumRatio 0.5 (lines 79-87).
|
||||
- The denominator is weight actually OBSERVED, not weight of an enrolled witness set.
|
||||
3. **Hub** `CultNetSimulationObservationHub`, lines 298-398. In-memory `List<>` of every observation; each `Submit` appends, re-runs `BuildCandidates` over the whole list (line 347) and emits candidates for that subject on an R3 `Subject`. No eviction, no persistence, not thread-safe (plain List).
|
||||
4. **Wire bridge** `src\GameCult.Networking\CultNetSimulationObservationServer.cs`: receives `cultnet.simulation_observation.v0` (`CultNetSchemaMessages.cs:133, 1255-1268`), submits to the hub, replies to the sending peer with candidate messages. In the lines read (61-70) the handler submits without comparing `WitnessRuntimeId` to the authenticated peer identity [SRC, partial read; verify the rest of the handler].
|
||||
5. **Commit** `src\GameCult.Mesh\CultMeshSimulationFacts.cs:12-188`. `CultMeshSimulationFact` is a CultCache document (`gamecult.mesh.simulation_fact.v1`). `FactId` = SHA-256 over shard|epoch|frame|subject|kind, deliberately NOT over claimHash (lines 114-126). `CultMeshSimulationFactCommitter.CommitAsync` throws unless `HasQuorum` (lines 179-182) then `PutAsync` into `CultNetDatabase`, which enforces the shard's primary and epoch (`CultNetDatabase.cs:1034, 1054` call `EnsurePrimary(shard, schemaId, key, message.ShardEpoch)`; stale-epoch errors at 829-834, 1086-1091).
|
||||
6. **Session facade** `src\GameCult.Mesh\CultMeshGameSession.cs:60-224`. `SubmitAndCommitAsync` submits, then `CommitQuorumCandidatesAsync` commits each quorum candidate once per session (`_committedFactIds` HashSet, then a DB `GetAsync` existence check, lines 190-221). The session constructs an `AuthorityResolver` (line 77) but nothing in the witness path calls it (grep of the file: only lines 35, 77, 128).
|
||||
|
||||
### 1.2 What the design says it is for [SRC]
|
||||
|
||||
- `src\GameCult.Mesh\README.md:3-4`: "distributed realtime database and simulation-consensus layer". README "Witness Consensus" section lines 360-378: observations are "the fast mesh opinion layer that can feed an authoritative shard commit path"; candidates "are not committed world state by themselves"; "The central server does not need to decide what every witness saw. It only needs to accept the quorum result through the shard log." Example "who shot first" (`contracts\cultnet\cultnet-distributed-database.md` ~line 175).
|
||||
- `src\GameCult.Mesh\docs\research.md:21-41`: live model is primary-shard authority plus replica catch-up; steps 4-6: witnesses publish immutable observations, nodes aggregate to candidates, "Authoritative world state is still committed through shard logs". Next research cut includes "add observation fanout across peers", "simulation rollback/resimulation helpers", then "revisit membership, failover, and consensus protocol depth".
|
||||
- `src\GameCult.Mesh\docs\public-api.md:528-531, 574-583`: "Each shard has one primary writer for now. Non-primary writes are rejected or explicitly forwarded. Stale epochs fail loudly." Candidates are "opinions".
|
||||
- Verse authority models enum, `src\GameCult.Mesh\CultMeshVerse.cs:14-32`: OperatorCluster, FederatedCluster, PeerToPeer ("Peers participate directly in authority decisions"), SubscribedOverlay. Only descriptors; no peer-to-peer authority code found.
|
||||
- `src\GameCult.Mesh\docs\cross-runtime-primitives-roadmap.md:80`: "Authority scope: Runtime claim, lease, witness, or delegated simulation ownership expressed as typed policy state. First consumers: Trusted co-op, future quorum/witness modes". The words "trusted co-op" mark the current trust model.
|
||||
- `research\cultnet-distributed-database\summary.md` (193 lines): borrowed Raft (primary per shard first, election deferred), Dynamo, SWIM (later), CRDTs (only with declared merge law), Hashgraph. Hashgraph was explicitly REJECTED for now: "CultNet does not currently need asynchronous Byzantine total ordering"; keep only gossip-about-gossip as a provenance idea (summary lines on Hashgraph; also `docs\research.md:15-16`). Stored PDFs: hashgraph-swirlds-tr-2016-01.pdf, raft-extended.pdf, dynamo-sosp2007.pdf, swim.pdf, crdt-arxiv-1805.06358.html, plus changefeed/Firebase pages.
|
||||
|
||||
### 1.3 Who simulates, who witnesses, what is agreed
|
||||
|
||||
- **Who simulates:** the code does not say. Nothing in CultLib implements a simulation, input exchange, rollback or resimulation. The only simulation-shaped objects are the observation fields. `docs\research.md` lists "simulation rollback/resimulation helpers" as NOT yet built; `contracts\cultnet\cultnet-distributed-database.md` lists cross-runtime rollback/resimulation helpers as "standardize ... beyond Python's local pending-prediction rollback surface" (Python only).
|
||||
- **Who witnesses:** any runtime that submits a `CultNetSimulationObservation`. No witness registry, selection rule, enrollment, stake or lease check exists in the witness path.
|
||||
- **What is agreed:** a per-(shard, epoch, frame, subject, claimKind) claim HASH; not inputs, not state hashes of a whole tick, not an ordered log. The summary text is carried but not part of the vote. Agreement is a weighted plurality over whatever observations arrived, evaluated incrementally.
|
||||
- **Ordering / time:** `frame` is a number the witness asserts; no clock, no tick schedule, no ordering across subjects, no finality rule. Commit fires the first time a candidate has quorum (session-local `_committedFactIds` plus DB existence check). Because FactId excludes claimHash, a second, different quorum-winning claim for the same subject/frame/kind later would be silently skipped (line 205-215 `GetAsync != null -> continue`).
|
||||
- **Disagreement:** the minority claim is only reflected in `Confidence` (= support/total) and `TotalWeight`. No dispute, challenge, slashing, re-simulation or escalation path.
|
||||
|
||||
### 1.4 Fault model (as built) [SRC, inferred from code]
|
||||
|
||||
- Not Byzantine-tolerant. Reasons visible in code: (a) observations are unsigned and the witness id is a self-asserted string; (b) `weight` is supplied by the observer (line 52); (c) quorum denominator is observed weight, so n observers out of an unknown population can reach "quorum"; with defaults, one witness alone commits (MinimumWitnesses=1, ratio 0.5); (d) no Sybil bound; (e) the commit authority is the single shard primary, so a crashed or malicious primary is outside the model (no election, no failover: contract doc "Not implemented yet: leader election or automatic shard failover").
|
||||
- The effective model is "trusted co-op / operator-cluster": the shard primary is trusted; witnesses are an advisory vote the primary chooses to commit. Cheating resistance is whatever the primary's trust gives.
|
||||
- Authority leases (separate subsystem) are the only signature-backed element: `CultMeshAuthorityLease` (`CultMeshAuthority.cs:8-60`) binds verse, peer, roles, shard ids, issuer, validity window, optional epoch, signature; decisions belong to `CultMeshAuthorityResolver` with signature verifier and revocation source (`docs\reliability-migration-plan.md:120-175`: "Forbidden writers: peer catalogs, transport success, caches, and UI state must never confer authority"). `CultMeshAuthorityProof.cs` verifies P-256 (IEEE P1363, 64-byte) Odin route certificates and provider session proofs (lines 111-143, 305-316). Leases cover roles like `ShardPrimary` and `ReadReplica`; no "Witness" role was found in the witness path [SRC grep, verify].
|
||||
|
||||
### 1.5 Implemented vs planned
|
||||
|
||||
Implemented [SRC]: observation/candidate types, deterministic aggregation, schema-v0 messages, in-memory hub, server bridge that replies with candidates, fact document and committer through primary shard with epoch check, session facade, durable shard logs and snapshot fallback, primary-shard authority, client prediction scopes (`Predicted`/`Reconciled`), authority lease/resolver, Python parity for consensus types, committer and session.
|
||||
Planned / not implemented [SRC docs]: observation fanout across peers ("standardize cross-runtime scheduled peer-to-peer fanout for observation gossip and candidate propagation", `cultnet-distributed-database.md` Next Coherent Slice), rollback/resimulation helpers (C#), SWIM membership, leader election/failover, witness selection, signatures on observations, "future quorum/witness modes" (roadmap line 80).
|
||||
|
||||
### 1.6 Tests [SRC]
|
||||
|
||||
`tests\GameCult.Networking.Tests\NetworkingTests.cs`: `CultNetSimulationConsensus_Builds_QuorumCandidate_FromWitnesses` (6647), `..._DeduplicatesWitness_AndBreaksTiesDeterministically` (6702), `CultNetSimulationObservationHub_Emits_ReactiveCandidates` (6760), `CultNetSimulationObservationServer_Creates_CandidateMessages` (6806), `CultMeshSimulationFactCommitter_Commits_QuorumCandidate_ToShardLog` (6856), `CultMeshGameSession_SubmitsWitnesses_AndCommitsQuorumFact` (6902), `CultMeshSimulationFactCommitter_Rejects_CandidateWithoutQuorum` (6970); message round-trips at 3908-3955. Python: `packages\cultmesh-py\tests\test_cultmesh.py` ~1866-2019 (committer, game session commits quorum once). TS interop test mentions simulation messages (`packages\cultnet-ts\test\interop\cultnet-interop.test.ts`, not read in detail). No test found for: Byzantine/lying witnesses, forged witness ids, weight inflation, conflicting claims for one FactId, network partition, latency, or a real simulation. All tests are in-process, happy-path or arithmetic.
|
||||
|
||||
### 1.7 Name collision
|
||||
|
||||
`CultWitnessArtifactBundle` (`src\GameCult.Networking\CultWitnessArtifactBundle.cs`, schema `cultnet.witness_artifact_bundle`) is a build/pipeline evidence manifest (subject pin, contract pins, artifact URIs, timing "witnesses"), unrelated to simulation witnesses. "Witness" in the postmortem docs (cold-release witness) means the same pipeline-evidence sense. Do not conflate.
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
## STEP 2. Prior art
|
||||
|
||||
### A. Crash-fault consensus
|
||||
|
||||
| Item | Core idea | Fault model | Agreed | Latency / throughput | Group size | Implementations | Relation to CultMesh |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Paxos: Lamport, "The Part-Time Parliament", ACM TOCS 1998; "Paxos Made Simple", 2001 [MEM] | Quorum-based single-value agreement with proposal numbers | Crash, message loss, asynchronous; needs majority (n >= 2f+1) | One value per instance | 2 round trips for a fresh proposer, 1 with stable leader | 3-7 | Chubby, Spanner (Multi-Paxos) [MEM] | Different: agrees on an ordered log, no observed-weight plurality; majority is of a fixed membership |
|
||||
| Multi-Paxos [MEM] | Stable leader skips phase 1 per slot | same | Ordered log | ~1 RTT to a majority, high throughput | 3-7 | Spanner, many | Similar shape to CultMesh's primary-shard log; CultMesh has no replication quorum on the log yet |
|
||||
| Raft: Ongaro & Ousterhout, "In Search of an Understandable Consensus Algorithm", USENIX ATC 2014 [MEM; PDF is in `research\...\raft-extended.pdf`] | Leader election by term, log replication, majority commit | Crash (non-Byzantine), partitions | Replicated log | 1 RTT commit, ms-level in a LAN, 10^4-10^5 ops/s | 3-7 | etcd, Consul, TiKV, CockroachDB [MEM] | Similar: CultMesh docs explicitly call primary-per-shard "Raft-shaped" (`docs\research.md:11`); epoch ~ term (`stale_epoch` errors). Different: no election, no majority ack of writes, witnesses are not voters on the log |
|
||||
| Viewstamped Replication: Oki & Liskov PODC 1988; Liskov & Cowling, "VR Revisited", MIT TR 2012 [MEM] | Primary + view changes, state-machine replication | Crash | Ordered ops | like Raft | 3-5 | few production | Same family as Raft; view number ~ shard epoch |
|
||||
| EPaxos: Moraru, Andersen, Kaminsky, SOSP 2013 [MEM] | Leaderless, commit non-conflicting commands in 1 RTT fast path | Crash | Partial order by dependency | Fast path 1 WAN RTT; conflicts slow | 3-5 | research/few prod | Different; relevant only to "avoid a single primary" |
|
||||
| ZooKeeper/ZAB: Junqueira, Reed, Serafini, DSN 2011 [MEM] | Primary-order atomic broadcast | Crash | Ordered broadcast | ms | 3-7 | ZooKeeper | Same family; coordination service for leases |
|
||||
| Leases: Gray & Cheriton, SOSP 1989 [MEM] | Time-bounded grants | Crash plus bounded clock drift | Exclusive holder | low | any | Chubby, etcd leases | Same idea as `CultMeshAuthorityLease` (validFrom/expiresAt, `CultMeshAuthority.cs:14-60`); CultMesh adds signed issuer and epoch |
|
||||
|
||||
Group sizes in A are small (3-7) because every write waits on a majority. Throughput characteristics of etcd/ZooKeeper are written for control-plane use, not 20-60 Hz game state; no frame-rate use of Raft quorum was surveyed this pass.
|
||||
|
||||
### B. Convergence without consensus
|
||||
|
||||
| Item | Core idea | Fault model | Notes |
|
||||
|---|---|---|---|
|
||||
| CRDTs: Shapiro, Preguica, Baquero, Zawirski, "Conflict-free Replicated Data Types", SSS 2011 (INRIA RR-7687 [MEM]); survey arXiv 1805.06358 (local copy in research dir) | Merge function is commutative, associative, idempotent so replicas converge with no coordination | Crash/partition, NOT Byzantine; every replica trusted | Cannot enforce cross-object invariants (e.g. "hp cannot go below 0 and only one player gets the last item", exclusive ownership) without coordination. Related: Bailis et al., "Coordination Avoidance in Database Systems" (invariant confluence), VLDB 2015 [MEM] |
|
||||
| Operational Transformation: Ellis & Gibbs, SIGMOD 1989 [MEM] | Transform concurrent ops against each other | Trusted peers, usually central server in practice | Text editing; correctness proofs notoriously hard |
|
||||
| Automerge, Yjs [MEM] | Op-based JSON/text CRDTs; Yjs derives from YATA (Nicolaescu et al. 2016) | Trusted peers | Document sync, not game rules; Automerge has no authority/permission model |
|
||||
| Local-first software: Kleppmann, Wiggins, van Hardenberg, McGranaghan, Onward! 2019 [MEM] | Seven ideals; CRDT sync, user-owned data | Trusted collaborators | Explicitly collaboration documents |
|
||||
|
||||
Relation to CultMesh: the research summary already says CRDTs only for documents with declared merge laws and "generic automatic merge ... is a language cop with a nicer hat" (`research\...\summary.md`). CultMesh's witness layer is NOT a CRDT: it is a vote over claim hashes, not a merge. Game rules (hit validity, ownership transfer) are invariant-bearing, which is the standard limit of CRDTs.
|
||||
|
||||
### C. Byzantine fault tolerance and witness-style constructs
|
||||
|
||||
| Item | Core idea | Fault model | What is agreed | Latency / throughput | Group size | Implementations | Relation to CultMesh |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| PBFT: Castro & Liskov, OSDI 1999 [MEM] | 3-phase (pre-prepare, prepare, commit) with signed/MAC'd messages and view change | Byzantine, n >= 3f+1, partial synchrony | Total order of requests | O(n^2) messages; ms in LAN; throughput falls with n | 4-~20 | BFT-SMaRt, Hyperledger Fabric (older) [MEM] | Different: quorum is of a FIXED membership (2f+1 of 3f+1) with authenticated messages; CultMesh's quorum is of observed weight and unauthenticated |
|
||||
| Tendermint: Buchman, Kwon, Milosevic, arXiv 1807.04938 (2018) [MEM] | PBFT-like rounds with proposer rotation, locked blocks | Byzantine, partial sync | Block per height | seconds per block | tens-~150 validators | Cosmos | Different; shows round time ~ seconds |
|
||||
| HotStuff: Yin, Malkhi, Reiter, Gueta, Abraham, PODC 2019 [MEM] | Linear view change via chained quorum certificates | Byzantine, partial sync | Ordered blocks | O(n) per view | tens-100s | Libra/Diem, Aptos lineage | Different; its "quorum certificate" (n-f signatures on a value) is the formal object CultMesh's `HasQuorum` resembles without signatures |
|
||||
| Hashgraph: Baird, "The Swirlds Hashgraph Consensus Algorithm", SWIRLDS-TR-2016-01 (local PDF in CultLib research dir) | Gossip-about-gossip builds a DAG; virtual voting; "witnesses" are the first events of each round per member, "famous witnesses" decide order | Asynchronous BFT, n >= 3f+1 | Total order + consensus timestamp | seconds | ~tens-100s; Hedera runs 30-ish councils [MEM] | Hedera | NAME ONLY: a Hashgraph witness is a DAG vertex, not an attester of a simulation fact. CultLib already evaluated and rejected adopting it |
|
||||
| Narwhal and Tusk: Danezis, Kokoris-Kogias, Sonnino, Spiegelman, EuroSys 2022 [WEB confirmed: EuroSys '22 pp 34-50; ~160k tx/s at ~3 s latency, 600k tx/s with workers] | Separate reliable dissemination (DAG mempool) from ordering | Async BFT | Ordered txs | seconds | 10-50 | Sui/Mysten lineage [MEM] | Different latency class |
|
||||
| Bullshark: Spiegelman, Giridharan, Sonnino, Kokoris-Kogias, ACM CCS 2022, arXiv 2201.05677 [WEB confirmed; DOI 10.1145/3548606.3559361; abstract: partially-synchronous variant ~125k tx/s at ~2 s latency, 50 parties] | DAG BFT with fast path, no extra messages beyond the DAG | Async liveness, partial sync fast path | Ordered vertices | ~2 s | ~50 | Sui | Different latency class |
|
||||
| DAG-Rider: Keidar, Kokoris-Kogias, Naor, Spiegelman, "All You Need is DAG", PODC 2021 [MEM, not confirmed this pass] | First DAG-based async BFT with optimal amortized communication | Async BFT | Ordered | seconds | tens | foundation of Narwhal/Tusk | Different |
|
||||
| Certificate Transparency / transparency-log witnesses: C2SP `tlog-cosignature` and `tlog-witness`; Sigsum; litewitness [WEB: spec names and purpose confirmed via search; spec pages not opened] | Independent witnesses verify a log's new checkpoint is consistent with the previous one (Merkle consistency proof) and cosign it; clients require a quorum of cosignatures to defeat split-view attacks | Byzantine LOG, but witnesses only check append-only consistency; they do not execute the application | A signed checkpoint (tree head) | Seconds to minutes; sync problems reported when logs update faster than witnesses | handful to dozens of witnesses | Sigsum, Go checksum DB witnesses, Let's Encrypt/Boulder adoption in progress [WEB, partial] | CLOSEST use of the word "witness": independent parties attest a statement (checkpoint hash) with a signature and a client-side quorum policy. Similar to a candidate with signed support; different in that CT witnesses verify a cryptographic proof, not an independent re-execution |
|
||||
| Ethereum sync committee / light-client attestations (Altair, 2021) [MEM] | 512-validator committee signs headers; light clients accept with 2/3 signatures | Honest-majority of a randomly sampled committee | Block header | ~12 s slots | 512 | Ethereum | Relates to witness SELECTION by random sampling |
|
||||
| Ouroboros: Kiayias, Russell, David, Oliynykov, CRYPTO 2017 [MEM] | Stake-weighted leader/committee selection by verifiable randomness | Honest-majority stake, Byzantine | Chain | slot-based | thousands | Cardano | Relates to weight = stake and Sybil resistance via a scarce resource |
|
||||
| Sybil attack: Douceur, IPTPS 2002 [MEM] | Without a trusted identity authority, cheap identities defeat voting | n/a | n/a | n/a | n/a | n/a | Defines the open problem for CultMesh's self-assigned `weight` and `witnessRuntimeId` |
|
||||
|
||||
Group-size/latency pattern in C: all classical BFT agrees on an ordered log, authenticated, with quorum 2f+1 of n >= 3f+1 over a fixed or sampled membership. Latencies from ms (small PBFT in LAN) to seconds (WAN, large n). I found no BFT protocol in this group specialised to a 20-60 Hz tick budget.
|
||||
|
||||
### D. Game networking
|
||||
|
||||
| Item | Core idea | Fault model | What is agreed/authoritative | Latency | Scale | Notes / relation |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Deterministic lockstep (RTS): Bettner & Terrano, "1500 Archers on a 28.8", GDC 2001 [MEM]; Fiedler, "Deterministic Lockstep", gafferongames.com [MEM] | All peers exchange only inputs and run a bit-identical deterministic simulation | Honest peers; desync = bug (state hash compare detects it) | Inputs per turn | Input delay of a turn (~100-200 ms), stalls on slowest peer | 2-8 (RTS), hundreds of units | Related: agreement on INPUTS, detect divergence by state hash. A cheater who modifies local state is detected only by hash comparison; map-hack cannot be prevented because every peer has full state. CultMesh agrees on claim hashes about facts, not inputs |
|
||||
| Rollback netcode / GGPO: Tony Cannon, 2009 [MEM] | Predict remote inputs, run ahead, rewind and resimulate on late inputs | Honest peers | Inputs; resimulate | Hides latency up to ~8 frames | 2-4 (fighting games) | Requires fast deterministic save/load and resimulate; CultMesh docs list "rollback/resimulation helpers" as planned, not built |
|
||||
| Photon Quantum (Exit Games) [MEM; docs not fetched] | Deterministic ECS with fixed-point math, predict/rollback, a server-side input-relay and optional authoritative verification | Server-trusted; can run a replay/verification on the server | Inputs and checkpoints | Predict/rollback hides latency | Up to ~hundreds in some modes [unverified] | A commercial instance of "peers simulate, server verifies" |
|
||||
| Client prediction with server authority (Quake/Source): Gambetta, "Fast-Paced Multiplayer" series, gabrielgambetta.com [MEM]; Valve Source networking / lag compensation doc [MEM]; Bernier, "Latency Compensating Methods", GDC 2001 [MEM] | Server simulates authoritatively; client predicts own input, reconciles to server snapshots; server rewinds for hit validation (lag compensation) | Cheating clients; server trusted | Server state | Tick 20-128 Hz, 50-100 ms perceived budget met via prediction | tens to ~100 per server | Direct match to CultMesh's current LIVE model (primary shard + `PutPredictedAsync`/`Reconciled`); the witness layer is an addition to it |
|
||||
| Fiedler networked physics: "Snapshot Interpolation", "State Synchronization", "Deterministic Lockstep" [MEM] | Compare interpolation, delta compression, priority accumulators | n/a | n/a | n/a | n/a | Engineering reference for bandwidth/ticks |
|
||||
| Interest management / AOI: Colyseus (Bharambe et al., NSDI 2006) [MEM]; Donnybrook (Bharambe et al., SIGCOMM 2008) [MEM]; Boulanger, Kienzle, Verbrugge survey (NetGames 2006) [MEM] | Send each client only entities near it; distribute zones/objects over servers or peers | Mostly trusted servers/peers | Authority per object/zone | Same as client-server | 100s-10,000s | Relates to CultMesh `shardId` per zone; shards are the authority unit |
|
||||
| SpatialOS (Improbable) [partly WEB] | Cloud runtime that shards a world across worker servers by entity authority and AOI | Trusted operator | Per-entity authority delegated to workers | n/a | thousands of entities/clients per world claimed [MEM] | WEB: Jan 2019 Unity license dispute threatened SpatialOS-on-Unity games; Improbable sold Inflexion (Tencent, Feb 2022) and Midwinter (Behaviour, 2022) and sold its gaming unit in Dec 2023; pivoted to "Morpheus". The WEB results did NOT confirm an official SpatialOS end-of-life date or GDK deprecation (UNVERIFIED) |
|
||||
|
||||
Pattern in D: realtime games meet 50-100 ms either by a trusted authoritative server with prediction, or by lockstep/rollback with an input delay among trusted peers. None of the mainstream techniques use a voting quorum among untrusted witnesses on the tick path.
|
||||
|
||||
### E. Decentralised / P2P anti-cheat and verification
|
||||
|
||||
| Item | Core idea | Fault model | Known limits |
|
||||
|---|---|---|---|
|
||||
| Lockstep with commitments: Baughman & Levine, "Cheat-Proof Playout for Centralized and Distributed Online Games", IEEE INFOCOM 2001, pp 104-113; revised as Baughman, Liberatore, Levine, "Cheat-Proof Playout for Centralized and Peer-to-Peer Gaming", IEEE/ACM ToN 2007 [WEB confirmed] | Each round, players first commit (hash) their move, then reveal; prevents look-ahead cheating. "Asynchronous synchronization" variant removes the slowest-player penalty | Cheating peers (protocol-level cheats: fixed delay, timestamp, suppressed update, inconsistency) | Round time bounded by the slowest player; needs all players; no defence against collusion or against a compromised client reading state (information exposure) [MEM for the limits] |
|
||||
| NEO: GauthierDickey, Zappala, Lo, Marr, "Low latency and cheat-proof event ordering for peer-to-peer games", NOSSDAV 2004, pp 134-139 [WEB confirmed]; also NetGames 2004 paper (distributed games) | Rounds whose duration bounds latency to a MAJORITY of players, not the slowest; cheat-proof event ordering | Protocol-level cheats | WEB: a 2008 paper reports Corman et al. showed NEO cannot prevent all cheats as claimed and proposed Secure Event Agreement (SEA); full details UNVERIFIED (not opened) |
|
||||
| Watchmen: Yahyavi, Huguenin, Gascon-Samson, Kienzle, Kemme, "Watchmen: Scalable Cheat-Resistant Support for Distributed Multi-player Online Games", IEEE ICDCS 2013, pp 134-144 [WEB confirmed] | Randomized dynamic proxy scheme for dissemination AND verification of actions; players verify each other's actions via proxies | Cheating players, including collusion (per abstract) | WEB abstract: prototype on Quake III, scales to hundreds of players, "significantly reducing" cheating "even under collusion" (not eliminating). Full evaluation numbers not read |
|
||||
| RACS: Webb, Soh, Lau, "RACS: A Referee Anti-Cheat Scheme for P2P Gaming", NOSSDAV 2007 [MEM, search hit "Referee-based architectures for MMOGs" Curtin repo suggests Webb/Soh thesis; not confirmed] | A referee node (trusted/semitrusted) validates state updates; peers do the rest | Cheating players, trusted referee | Referee is single point of trust |
|
||||
| Kabus, Terpstra, Cilia, Buchmann, "Addressing Cheating in Distributed MMOGs", NetGames 2005 [MEM] | Peers replicate objects and a subset acts as witnesses/validators checking each other's updates | Cheating peers | Reliant on honest majority in the object's replica set |
|
||||
| Mutual checking / peer verification, Mönch et al., "Protecting online games against cheating", NetGames 2006 [MEM]; Corman et al. SEA, 2006 [MEM] | See above | | |
|
||||
| Trusted execution (Intel SGX etc.) for cheat prevention [MEM] | Run authoritative logic in an enclave | Hardware-rooted trust | Repeated side-channel breaks of SGX; dependence on vendor attestation |
|
||||
| Replay/verification servers (server re-simulates client input stream) [MEM] | Server accepts client-reported results only if re-simulation matches | Cheating clients | Costs server CPU equal to the simulation |
|
||||
| Server-side authoritative simulation | The common industrial answer | Server trusted | Operator pays CPU/bandwidth |
|
||||
|
||||
Known limitations across E (recurring in the literature) [MEM unless noted]: collusion (witnesses/peers that are friends), Sybil identities with no costly identity, latency of verification vs 50-100 ms budgets, information exposure (every peer holding full state can map-hack), network-level DoS on proxies/referees, and the need for deterministic simulation across heterogeneous clients (floating point, JS engines). WEB-confirmed for Watchmen: collusion is explicitly in scope and results are "significantly reduced", not eliminated.
|
||||
|
||||
### F. Other close matches to "peers simulate, witnesses attest"
|
||||
|
||||
| Item | Core idea | Relation |
|
||||
|---|---|---|
|
||||
| Optimistic rollups, fraud proofs: Arbitrum (Kalodner et al., "Arbitrum: Scalable, private smart contracts", USENIX Security 2018 [MEM]); Optimism [MEM] | Sequencer posts results; anyone re-executes; a challenge game (bisection over execution steps) proves one step on-chain; economic bond slashed | Closest structural match to "one party simulates, others independently re-simulate and dispute": needs deterministic VM, a challenge window (days on mainnet), and bonds. Latency class is far outside 50-100 ms; CultMesh has no challenge path today |
|
||||
| Refereed delegation: Canetti, Riva, Rothblum, "Practical Delegation of Computation using Multiple Servers", CCS 2011 [MEM]; TrueBit (Teutsch & Reitwiessner, 2017/2019) [MEM] | Multiple servers compute; disagreement resolved by referee via bisection | Same family; assumes at least one honest server |
|
||||
| Verifiable computation / zk (SNARKs, STARKs; zkVMs) [MEM] | Prover returns a proof the computation was done correctly | Removes trust in witnesses; proving cost far above real-time tick budget for a general sim (no real-time game case surveyed this pass) |
|
||||
| N-version / replicated execution (BFT state-machine replication, Schneider 1990 "Implementing Fault-Tolerant Services Using the State Machine Approach", ACM Computing Surveys [MEM]) | Replicas execute the same deterministic ops and vote on outputs | The textbook ancestor of "peers independently simulate and vote on results"; the standard requires deterministic execution and agreed input order, which CultMesh does not yet provide |
|
||||
| Output/response voting in BFT clients (PBFT client waits for f+1 matching replies) [MEM] | Client accepts a result when f+1 replicas agree | Structurally the same as accepting a candidate once enough matching claim hashes arrive; differs in that f is fixed and replicas are authenticated |
|
||||
| Transparency-log witness cosigning (see C) | Quorum of independent signed attestations over a hash | Closest to CultMesh's "claim hash + quorum" at the data-structure level |
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
## STEP 3. Synthesis
|
||||
|
||||
### (1) Comparison across A-F
|
||||
|
||||
"Realtime .io fit" = tens to hundreds of players, 50-100 ms budgets. Ratings are descriptive judgments from the facts above, with confidence noted.
|
||||
|
||||
| Family | Fault model | What is agreed | Latency class | Group size | Cheating resistance | Realtime .io fit (descriptive) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| A. Crash consensus (Paxos/Raft/VR/ZAB) | Crash only | Ordered log | ms (LAN) to 100s of ms (WAN) per commit | 3-7 | None (trusts members) | Fine as a coordination/lease/failover backend for shards, not per-tick; high confidence |
|
||||
| B. CRDTs/OT | Crash/partition, trusted replicas | Converged state, no order | Zero coordination latency | Any | None | Fits presence/cosmetic state; cannot enforce game invariants; high confidence |
|
||||
| C. BFT (PBFT/HotStuff/Tendermint/DAG) | Byzantine, n>=3f+1 | Ordered log of signed requests | ~100 ms-seconds, grows with n | 4-100ish | Strong against <1/3 faulty identities; none against Sybil without identity scarcity | Latency/size mismatch for tick-rate; plausible for low-frequency events; medium confidence |
|
||||
| C'. Transparency-log witnesses | Byzantine log, honest-enough witnesses | Signed checkpoint hash | seconds-minutes | handful-dozens | Detects equivocation (split view) | Suited to auditing/after-the-fact, not tick path; medium confidence |
|
||||
| D1. Authoritative server + prediction | Server trusted | Server state | one tick, 50-100 ms perceived hidden by prediction | tens-100s per server | Strong vs clients if server honest | The incumbent for the stated target |
|
||||
| D2. Lockstep/rollback | Honest peers | Inputs | input delay 100-200 ms (lockstep) or hidden by rollback | 2-8 typical | Detects desync; no information hiding | Small-group only; high confidence |
|
||||
| E. P2P anti-cheat (NEO, Watchmen, RACS, Kabus) | Cheating peers, limited collusion | Event order / verified actions via proxies/referees | Designed for realtime (Watchmen: Quake III prototype) | up to hundreds (Watchmen abstract) | Reduced, not eliminated; collusion and Sybil are known gaps | Closest in goal; research prototypes, not shipped infrastructure (to my knowledge, unverified) |
|
||||
| F. Fraud proofs / VC | Byzantine with >=1 honest verifier or crypto proof | Execution result | Challenge window (minutes-days) or proving time | Open | Strong given determinism + bonds/proofs | Not on the tick path; plausible for deferred settlement |
|
||||
| CultMesh today (witness plurality + primary shard commit) | Trusted primary; witnesses unauthenticated | Claim hash per (shard, epoch, frame, subject, kind) | In-process aggregation O(total observations) per submit | Unspecified | None against a lying witness or primary (see 1.4) | Latency is not the problem (no network round in aggregator); the trust model is |
|
||||
|
||||
### (2) Design questions CultMesh's witness consensus must answer, tied to prior art
|
||||
|
||||
| # | Question | Where prior art answered it | State in CultMesh source |
|
||||
|---|---|---|---|
|
||||
| 1 | What exactly is agreed: inputs, per-tick state hash, or sparse facts? | Lockstep/state-machine replication agree on inputs (Schneider 1990; Bettner & Terrano); rollups agree on state roots; CT on tree heads; CultMesh agrees on sparse fact hashes | Sparse facts (claimHash). No input log or state hash |
|
||||
| 2 | Determinism requirement: can independent peers compute the same claim bits? | Lockstep (fixed-point, ordered inputs), Photon Quantum (fixed-point ECS), rollups (deterministic VM) | Not addressed; `ComputeClaimHash` hashes caller-chosen strings. CultMath exists in CultLib as parity-oriented math (`packages\cultmath`) but no link to witnesses found |
|
||||
| 3 | Who may witness; selection and enrollment | BFT: fixed membership; Ethereum: random committee sampling; Ouroboros: stake-weighted VRF; Watchmen: randomized dynamic proxies; Sigsum: operator-chosen witness policy | None. Any runtime may submit; `AuthorityLease` exists for roles but isn't applied to witnesses |
|
||||
| 4 | Authenticated identity of an observation | PBFT signed messages; CT cosignatures; HotStuff QCs | Unsigned, witness id self-asserted; bridge does not bind to peer identity in lines read |
|
||||
| 5 | Sybil resistance / weight | Douceur 2002; stake (Ouroboros); invite-only membership (BFT) | `weight` is observer-supplied (line 52) |
|
||||
| 6 | Quorum arithmetic: threshold over a fixed membership vs over observed votes | PBFT/HotStuff 2f+1 of 3f+1; Raft majority of configured members | Ratio of observed weight; MinimumWitnesses is the only count floor; default 1 |
|
||||
| 7 | Collusion | Watchmen (random proxies per round), NEO/SEA, "honest majority of a randomly sampled set" (Ethereum) | Not addressed |
|
||||
| 8 | Time/ordering: what is frame N, who orders facts, when is a fact final | Lockstep rounds; Raft log index; BFT sequence numbers; rollup challenge window finality | `frame` asserted by witness; commit at first quorum; no finality/retraction |
|
||||
| 9 | Disagreement handling | Fraud proofs / bisection (Arbitrum, TrueBit); view change (PBFT); desync detection by state hash (lockstep); slashing | Minority recorded only as lower `Confidence`; later conflicting quorum silently skipped (FactId excludes claimHash) |
|
||||
| 10 | Equivocation (same witness, two claims) | PBFT/Tendermint treat as Byzantine evidence; CT witnesses detect split views | Aggregator keeps one of them by weight then id, no evidence retained |
|
||||
| 11 | Commit authority and its failure | Raft/Paxos leader election; BFT view change; Verse `PeerToPeer` is only a descriptor | Single shard primary, no election/failover (documented "Not implemented") |
|
||||
| 12 | Cost per tick | Narwhal/Bullshark: seconds; Raft: 1 RTT; lockstep: input exchange each turn | Aggregator is O(N) rescans of the full observation list per submit; list is unbounded (lines 300, 347) |
|
||||
| 13 | Bandwidth: observation fanout topology | Gossip (SWIM, Hashgraph), interest management (Colyseus/Donnybrook), proxies (Watchmen) | Fanout listed as "not implemented"; peer exchange exists for contact discovery only |
|
||||
| 14 | Information exposure (peers hold full state) | Known limit of lockstep/P2P (Baughman & Levine; Kabus; Watchmen); server authority hides state | Not addressed |
|
||||
| 15 | Replay/audit: can a third party verify a committed fact later | CT consistency proofs; rollup state roots; replay verification servers | Facts store counts and a hash only; no witness signatures or evidence archived |
|
||||
|
||||
### (3) Novelty vs known constructions
|
||||
|
||||
Judgement is descriptive; all of it rests on what was read, and I did not search the academic literature exhaustively (see unverified list).
|
||||
|
||||
Known constructions under another name:
|
||||
- Primary-per-shard with epochs and log catch-up = Raft/VR primary/term structure (the docs say so themselves).
|
||||
- Authority lease with validity window, signed issuer, epoch = classical leases (Gray & Cheriton) with signed capabilities.
|
||||
- "Quorum candidate" with claim hash and support count = result voting in replicated state machines (f+1 matching replies) or a CT-style quorum of cosignatures, minus signatures and fixed membership.
|
||||
- Predicted/Reconciled input documents = client-side prediction with server reconciliation (Quake/Source; Gambetta).
|
||||
- Shard = zone-based authority partition (Colyseus/SpatialOS-style).
|
||||
- Fact keyed by (shard, epoch, frame, subject, kind) = idempotent event id.
|
||||
- Peers checking each other's claimed events = the E family (Kabus, RACS, Watchmen), whose designs add selection, authentication and verification rules that CultMesh lacks.
|
||||
|
||||
Possibly distinctive in this codebase (not shown to be novel in the literature):
|
||||
- Combining a typed-document database (CultCache) with a witness-fact document type so witness facts travel through the same shard log, schema catalog and replication path as all other state (`CultMeshSimulationFact` is an ordinary `[CultDocument]`). I found no direct counterpart in the sources I looked at; this is an integration choice rather than a protocol.
|
||||
- Treating witness opinion as an explicitly non-authoritative "fast mesh opinion layer" that feeds, rather than replaces, a primary commit. The literature has analogues (referee schemes with a trusted referee, RACS), so this is a framing difference more than a new construction.
|
||||
|
||||
Nothing in the implemented witness path is a new consensus algorithm; it is a deterministic weighted-plurality aggregator (the tie-break and dedup rules are the only protocol content).
|
||||
|
||||
### (4) Ten most relevant primary sources
|
||||
|
||||
1. Yahyavi, Huguenin, Gascon-Samson, Kienzle, Kemme. Watchmen. IEEE ICDCS 2013, pp 134-144. https://hal.archives-ouvertes.fr/hal-00797352 [WEB-confirmed bibliographic data]
|
||||
2. Baughman & Levine. Cheat-Proof Playout for Centralized and Distributed Online Games. IEEE INFOCOM 2001. https://www.comp.nus.edu.sg/~bleong/hydra/related/baughman01cheatproof.pdf and ToN 2007 version https://people.cs.umass.edu/~liberato/home/publication/cheat-proof-playout-for-centralized-and [WEB]
|
||||
3. GauthierDickey, Zappala, Lo, Marr. NEO, NOSSDAV 2004. https://www.cs.du.edu/~chrisg/publications/chrisg-nossdav04.pdf [WEB]; read with the later critique/SEA (Corman et al.) cited at https://staff.csie.ncu.edu.tw/%7Ejrjiang/network2008/EASES.pdf [WEB result; content not opened]
|
||||
4. Castro & Liskov. Practical Byzantine Fault Tolerance. OSDI 1999 [MEM; link not fetched: https://pmg.csail.mit.edu/papers/osdi99.pdf]
|
||||
5. Yin et al. HotStuff. PODC 2019, arXiv 1803.05069 [MEM]
|
||||
6. Ongaro & Ousterhout. Raft. USENIX ATC 2014; local copy `F:\Projects\CultLib\research\cultnet-distributed-database\raft-extended.pdf`
|
||||
7. Baird. Swirlds Hashgraph TR-2016-01; local copy `F:\Projects\CultLib\research\cultnet-distributed-database\hashgraph-swirlds-tr-2016-01.pdf`
|
||||
8. Schneider. Implementing Fault-Tolerant Services Using the State Machine Approach. ACM Computing Surveys 1990 [MEM]
|
||||
9. C2SP tlog-cosignature and tlog-witness specs, https://github.com/C2SP/C2SP (tlog-cosignature at https://github.com/C2SP/C2SP/blob/tlog-cosignature/v1.0.0/tlog-cosignature.md) [WEB, specs not opened]; Sigsum https://www.sigsum.org
|
||||
10. Kalodner et al. Arbitrum, USENIX Security 2018 [MEM] (fraud proof / bisection), paired with Spiegelman et al. Bullshark, CCS 2022, https://arxiv.org/abs/2201.05677 [WEB] as the current DAG-BFT state of the art. Honourable mentions: Shapiro et al. CRDT survey arXiv 1805.06358 (local copy); Gambetta, Fast-Paced Multiplayer https://www.gabrielgambetta.com/client-server-game-architecture.html [MEM]; Fiedler, gafferongames.com [MEM]; Douceur, The Sybil Attack, IPTPS 2002 [MEM]; Narwhal and Tusk arXiv 2105.11827 [WEB].
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
## Unverified / gaps
|
||||
|
||||
- Everything tagged [MEM] was not re-fetched this pass: Paxos/Raft/VR/EPaxos/ZAB publication details, CRDT/OT/Automerge/Yjs/local-first details, PBFT, Tendermint, HotStuff, DAG-Rider, Ouroboros, Ethereum sync committees, GGPO, Photon Quantum, Gambetta/Fiedler/Valve pages, Colyseus/Donnybrook, RACS, Kabus et al., Mönch et al., Arbitrum/TrueBit/Canetti, Douceur, Schneider.
|
||||
- WEB-confirmed only at citation/abstract level, not full text: Watchmen, Baughman & Levine, NEO, Narwhal/Tusk, Bullshark, C2SP witness specs, Improbable corporate events. NEO "cannot prevent all cheats" claim comes from a snippet of a 2008 paper, not read.
|
||||
- SpatialOS end-of-life/GDK deprecation: NOT confirmed by the searches (they returned the 2019 Unity dispute and 2022-2023 divestments only).
|
||||
- Photon Quantum scale figures ("hundreds") and its verification mode are from memory; no docs fetched.
|
||||
- In CultLib: I read the observation server only to line ~70 (no peer-identity check seen), did not open the TS interop test body, `cultnet-py` simulation module, or `CultNetSchemaRegistry`. No "Witness" lease role was found by grep but the Authority resolver internals (`CultMeshAuthority*.cs`) were read only partially. Did not search Aetheria/StreamPixels design docs for witness-consensus intent beyond a filename hit list (`F:\Projects\Aetheria\docs\research\ew-prior-art.md` uses "compliance witness" in an unrelated game-fiction sense). `.claude\worktrees\agent-a1917e987955dada5` under CultLib duplicates some files; ignored.
|
||||
- Latency/throughput numbers for A, C families are typical-literature figures from memory except where marked WEB.
|
||||
- No empirical measurement of CultMesh aggregator cost was made.
|
||||
|
||||
---
|
||||
|
||||
## Design notes from discussion (operator and Self, 2026-10-26)
|
||||
|
||||
These are working directions from a design conversation, not rulings. Points marked "operator" are the operator's stated intent; the rest are analysis offered in that conversation.
|
||||
|
||||
### Shape of the system
|
||||
|
||||
- **Goal (operator):** a quorum of clients resolves local ordering disputes (who shot first) without a central authoritative server. The loser of a dispute is corrected exactly as under server authority with client-side prediction, but before the server has heard of the dispute.
|
||||
- **Shape this implies:** an optimistic fast path among peers, with the server reduced to a slow path: audit and arbitration in the rare case where the quorum fails, splits, or is later shown to have lied. The server can overturn a peer decision after the fact and penalise provably dishonest witnesses. This is the structure of optimistic rollups (fraud proofs), applied at realtime latency; the closest game-networking prior art is Watchmen and NEO.
|
||||
- **Cost (operator):** every client carries the lag-compensation machinery that is normally only on the server.
|
||||
|
||||
### Prerequisites before any quorum can agree
|
||||
|
||||
1. **Shared timeline.** Each party sees the other through latency, so "first" is undefined without a common reference: tick numbers plus signed, tick-stamped inputs. Without it, witnesses vote on their own latency.
|
||||
2. **Commit before reveal.** Without it, a client can wait for the opponent's input and then claim an earlier action. Baughman and Levine (2001) solved this with commit-reveal lockstep.
|
||||
3. **Escalation when the quorum fails or splits,** handled by the server slow path above.
|
||||
|
||||
### Witness selection: network proximity over game proximity
|
||||
|
||||
The operator questioned whether game-space proximity (area of interest) adds much over geographic or network proximity. Analysis in favour of network proximity:
|
||||
|
||||
- **Neutrality.** Network-near clients are statistically disinterested in the dispute and rarely on either party's team. Game-near clients are disproportionately the parties' teammates, which makes collusion the main threat rather than an edge case.
|
||||
- **Latency.** Low RTT among witnesses is what the quorum round actually needs. Game-near players can be 200 ms apart on the network.
|
||||
- **Calculable risk.** Random selection from a network-near pool makes the probability of a captured quorum a binomial function of the colluding fraction. Game-near selection makes it depend on team composition.
|
||||
|
||||
### Dispute-scoped verification keeps witnessing cheap
|
||||
|
||||
A witness does not simulate the world, only the dispute. A dispute packet carries the last committed state of the entities involved and both parties' signed inputs with tick numbers. The witness rewinds the relevant hitboxes over a few ticks and signs its verdict: lag compensation in miniature, small in both CPU and bandwidth. Watchmen's randomly assigned proxies are the nearest precedent.
|
||||
|
||||
### Problems this introduces
|
||||
|
||||
- **Information leakage.** A witness learns positions it would not otherwise see; a witness colluding with a third party could act as a wallhack relay. Possible mitigations: pseudonymous entity identities in dispute packets, and adjudicating only past ticks.
|
||||
- **History provenance.** If one party supplies the history, it can lie. Both parties' inputs must be signed by their authors and checked against previously committed state, which makes commit-before-reveal mandatory.
|
||||
- **Cross-runtime determinism.** A witness may run a different runtime from the parties (for example a browser TypeScript client adjudicating between native C# clients). Every hit test must then produce bit-identical results across runtimes, which requires deterministic arithmetic (fixed-point or strictly specified floating point). CultLib's cross-runtime parity, and CultMath's HLSL-parity math, become security properties here rather than conveniences. This is also the strongest argument that witness-authoritative networking depends on a substrate like CultLib.
|
||||
|
||||
### Gap to the current implementation (from the report above)
|
||||
|
||||
The implemented path is a weighted plurality over self-asserted, unsigned claim hashes, committed by a trusted shard primary, with no fixed witness set, no failover, no determinism contract tied to witnesses, and happy-path tests only. A proof of concept (for example a browser .io game, which first needs CultMesh browser clients that can write) would need, at minimum:
|
||||
|
||||
- signed observations
|
||||
- an authenticated witness set whose weights are not self-declared
|
||||
- explicit handling of disagreement and of a later contradicting quorum
|
||||
- primary failover
|
||||
- the shared timeline and commit-reveal described above
|
||||
- a determinism contract across runtimes
|
||||
|
||||
Its demonstration case is a cheating client being caught and corrected by witnesses.
|
||||
|
||||
## Product direction (operator, 2026-10-27, verbatim)
|
||||
|
||||
"the value is in solving the multiplayer scaling problem for indie developers in an ergonomic fashion. Having one authoritative server is fine, up to a point, but there's no real open source playbook on how to ship an MMO, and existing solutions like SpacetimeDB have a huge ergonomics gap. I want CultMesh to feel like magic: as a garden path, you annotate your domain POCOs and simulation code and declare your authority structure (I see no reason why we couldn't support server-authoritative and trusted co-op as a configurable subset of the much harder problem we're aiming at, feel free to correct me if that's a silly notion), and it'll all just work without the developer needing to understand networking deeply."
|
||||
|
||||
Witness authority is therefore one authority policy inside a garden-path framework, not the product. The ergonomics prior-art pass is `research/cultmesh-garden-path/`.
|
||||
|
||||
Reference game (operator, 2026-10-27): "the .io game, Aetheria later". The garden-path target is proved first on a deliberately small browser .io game; Aetheria is the later proof under real load.
|
||||
Loading…
Reference in a new issue