From f840af2e5ded178724b39c400f53bb9df9ec78fc Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 10 Jun 2026 01:48:48 +0000 Subject: [PATCH 1/6] K1: promote roster ceiling to wire.RosterCap with a Rust cross-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1024 roster cap was a protocol invariant hand-mirrored as literals in three codebases (Go SDK codec, Rust SDK broadcast.rs, and the host's gameabi), tied together only by comments. Define wire.RosterCap = 1024 in the contract package with lockstep docs, adopt it in internal/game's rosterCap, document the bound normatively in ABI.md §3, and add wire.TestRustRosterCapMatchesWire, which parses rust/src/broadcast.rs's pub(crate) ROSTER_CAP and asserts it equals wire.RosterCap — runnable by plain `go test` (no Rust toolchain), complementing the crossverify golden vectors that guard the encodings. Also repair broadcast.rs's truncated doc comment while pointing it at the contract constant. The host's gameabi/delta.go adopts wire.RosterCap after the next kit release. Co-Authored-By: Claude Fable 5 --- .changeset/wire-rostercap.md | 12 +++++++++ ABI.md | 8 ++++++ internal/game/codec.go | 9 ++++--- rust/src/broadcast.rs | 5 +++- wire/rostercap_crosscheck_test.go | 41 +++++++++++++++++++++++++++++++ wire/wire.go | 14 +++++++++++ 6 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 .changeset/wire-rostercap.md create mode 100644 wire/rostercap_crosscheck_test.go diff --git a/.changeset/wire-rostercap.md b/.changeset/wire-rostercap.md new file mode 100644 index 0000000..42ca84f --- /dev/null +++ b/.changeset/wire-rostercap.md @@ -0,0 +1,12 @@ +--- +"kit": minor +--- + +`wire.RosterCap` — the roster ceiling for per-index frame baselines (1024) is +now a contract constant in the `wire` package instead of a hand-mirrored +literal, and ABI.md §3 documents the bound. The Go guest SDK's internal +`rosterCap` adopts it directly; the Rust SDK's `ROSTER_CAP` is asserted equal +by a new Go cross-check test (`wire.TestRustRosterCapMatchesWire`, which +parses the Rust source so no Rust toolchain is needed). No encoding or +behavior change — purely promoting an existing protocol invariant into the +contract package both sides compile against. diff --git a/ABI.md b/ABI.md index eea618e..6b1396b 100644 --- a/ABI.md +++ b/ABI.md @@ -99,6 +99,14 @@ reserved-zero** and a guest MUST read only the low 32 bits (§4.6, §5). therefore **survives hibernation** (§8) — a guest does not re-issue it on resume. +Roster indices addressed by `send` are bounded by **`RosterCap` = 1024** +(`wire.RosterCap`): a guest SDK sizes its per-index baseline table to +`RosterCap` slots plus the broadcast slot and silently drops `Send` for an +index ≥ `RosterCap`, and the host bounds-checks the index and sizes its +per-slot cache (§4.6) the same way. The cap is a shared protocol invariant: +raising it is a coordinated change to `wire`, every guest SDK, and the host — +never to one of them alone. + Scoping is host-side: the guest names only a roster index and a key — the account and the game's namespace are derived by the host. A guest cannot address another game's data or a non-member account. diff --git a/internal/game/codec.go b/internal/game/codec.go index 16c77ca..11577c5 100644 --- a/internal/game/codec.go +++ b/internal/game/codec.go @@ -211,13 +211,14 @@ func encodeFrame(f *Frame) []byte { // ---- frame diffing state (ABI v2) ------------------------------------------- -// rosterCap is the fixed compile-time roster ceiling for per-index baselines. -// 1024 supports large-room games (the SDK SILENTLY DROPS Send for an index -// >= rosterCap, so the cap must comfortably exceed any real roster). +// rosterCap is the fixed compile-time roster ceiling for per-index baselines, +// adopted from the contract constant wire.RosterCap (1024 supports large-room +// games; the SDK SILENTLY DROPS Send for an index >= rosterCap, so the cap +// must comfortably exceed any real roster). // Guest linear memory stays proportional to the ACTIVE roster, not the cap: // per-slot baselines are lazily allocated on first commit — ~45 KiB per // actively-sent-to consumer instead of a ~47 MiB static table. -const rosterCap = 1024 +const rosterCap = wire.RosterCap // Per-consumer SDK baseline state, allocated once and reused forever (leaking-GC // safe; one room per instance + serial callbacks ⇒ no locking). Each slot holds diff --git a/rust/src/broadcast.rs b/rust/src/broadcast.rs index dbb37b6..8d93372 100644 --- a/rust/src/broadcast.rs +++ b/rust/src/broadcast.rs @@ -14,7 +14,10 @@ use crate::host; use crate::rng::SplitMix64; use crate::types::Player; -/// Fixed roster ceiling for per-index baselines. At 24-byte cells the table is +/// Fixed roster ceiling for per-index baselines, mirroring the contract +/// constant `wire.RosterCap` in the kit's Go `wire` package (the Go test +/// `TestRustRosterCapMatchesWire` parses this declaration and asserts the +/// values are equal — change both in lockstep, never one alone). /// Per-slot baselines are lazily allocated (~45 KiB per actively-sent-to /// consumer), so linear memory tracks the ACTIVE roster, not the cap — far /// under the 32 MiB cap. diff --git a/wire/rostercap_crosscheck_test.go b/wire/rostercap_crosscheck_test.go new file mode 100644 index 0000000..78e2052 --- /dev/null +++ b/wire/rostercap_crosscheck_test.go @@ -0,0 +1,41 @@ +package wire + +import ( + "os" + "path/filepath" + "regexp" + "strconv" + "testing" +) + +// TestRustRosterCapMatchesWire is the crossverify-side guard for the roster +// ceiling: RosterCap is a protocol invariant (it bounds both the host's +// per-slot baseline cache and the guest's send-index validity), and the Rust +// guest SDK carries its own copy as broadcast.rs's ROSTER_CAP. The constant is +// pub(crate), so no compiled artifact exposes it; instead this test parses the +// Rust source directly and asserts it equals wire.RosterCap — runnable by +// plain `go test ./wire/...` with no Rust toolchain, alongside the byte-level +// crossverify golden vectors that guard the encodings themselves. +func TestRustRosterCapMatchesWire(t *testing.T) { + path := filepath.Join("..", "rust", "src", "broadcast.rs") + src, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading Rust SDK source %s: %v", path, err) + } + re := regexp.MustCompile(`(?m)^pub\(crate\) const ROSTER_CAP: usize = (\d+);$`) + matches := re.FindAllSubmatch(src, -1) + if len(matches) != 1 { + t.Fatalf("expected exactly one ROSTER_CAP declaration in %s, found %d — "+ + "if the declaration moved or was reworded, update this test's pattern "+ + "so the cross-SDK assertion keeps holding", path, len(matches)) + } + got, err := strconv.Atoi(string(matches[0][1])) + if err != nil { + t.Fatalf("parsing ROSTER_CAP value %q: %v", matches[0][1], err) + } + if got != RosterCap { + t.Fatalf("Rust SDK ROSTER_CAP = %d, wire.RosterCap = %d — these are one "+ + "protocol constant and must change in lockstep (see wire.RosterCap docs)", + got, RosterCap) + } +} diff --git a/wire/wire.go b/wire/wire.go index e1daa07..12427b5 100644 --- a/wire/wire.go +++ b/wire/wire.go @@ -57,6 +57,20 @@ const ( RowBytes = Cols * CellBytes // 1920 ) +// RosterCap is the contract-wide roster ceiling for per-index frame +// baselines: an SDK sizes its baseline table to RosterCap slots (plus the +// broadcast slot, conventionally at index RosterCap) and silently drops Send +// for a roster index >= RosterCap, and the host bounds-checks the send index +// and sizes its per-slot cache the same way (ABI.md §3, §4.6). 1024 since the +// large-room scale work (kit v2.5.0 Go / v2.7.0 Rust). +// +// This is a protocol invariant shared by every implementation — the Go guest +// SDK (internal/game), the Rust guest SDK (rust/src/broadcast.rs ROSTER_CAP, +// asserted equal to this constant by TestRustRosterCapMatchesWire in this +// package), and the host adapter — so changing it is ABI-affecting and must +// land in all of them in lockstep. +const RosterCap = 1024 + // Player kind codes. const ( KindGuest uint8 = 0 From defd4c23c33ce7c3e3a963ae2a5225e6a76be038 Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 10 Jun 2026 02:26:00 +0000 Subject: [PATCH 2/6] K2: anchor the deploy-order rule with a meta wire-revision trailer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reject-unknown evolution strategy (ABI.md §5 rule 5) rested on an operational rule — "the host always upgrades before artifacts" — with no mechanical anchor: no artifact, catalog row, or handshake carried any minor-level contract provenance, so a host could not detect, warn, or refuse when an artifact assumed a wire revision newer than it implements (mid-rolling-deploy that means every container dropped and a frozen screen with no fault). Add a presence-guarded trailing u16 wireRevision to the meta payload, following the thrice-used additive-trailer pattern (config-specs, large-room, lifecycle): wire.Revision is a single monotonic constant (4, with a documented ledger of past wire-visible minors) bumped in the same change as every future wire-visible minor; both guest SDKs stamp it automatically (Go: internal/game encodeMeta; Rust: wire.rs WIRE_REVISION, pinned to wire.Revision by the new source-parsing wire.TestRustWireRevisionMatchesWire, no Rust toolchain needed). Old hosts ignore the bytes; pre-field artifacts decode as revision 0 = unknown. ABI.md §4.2 documents the field, the ledger, and the normative host semantics (refuse at verify time / skip-with-warning at load time for revisions above the host's own); §5 rule 5 now points at the anchor and mandates the bump-with-the-minor rule. Rust meta goldens regenerated from the Go encoder; new round-trip/truncation tests cover the trailer in both the wire package and the SDK encode path. Host-side enforcement (FetchAndVerify refuse, loadOne/RefreshLive skip, game_versions provenance column) lands in shellcade after the next kit release, like the wire.RosterCap adoption. Rust changes are CI-verified (no local Rust toolchain). Co-Authored-By: Claude Fable 5 --- .changeset/meta-wire-revision.md | 17 +++++++++++ ABI.md | 38 ++++++++++++++++++++++++- internal/game/codec.go | 5 ++++ internal/game/codec_meta_test.go | 14 ++++++++++ rust/src/wire.rs | 27 ++++++++++++++++-- wire/largeroom_test.go | 48 ++++++++++++++++++++++++++++---- wire/revision_crosscheck_test.go | 42 ++++++++++++++++++++++++++++ wire/wire.go | 46 ++++++++++++++++++++++++++++++ 8 files changed, 228 insertions(+), 9 deletions(-) create mode 100644 .changeset/meta-wire-revision.md create mode 100644 wire/revision_crosscheck_test.go diff --git a/.changeset/meta-wire-revision.md b/.changeset/meta-wire-revision.md new file mode 100644 index 0000000..0b44d13 --- /dev/null +++ b/.changeset/meta-wire-revision.md @@ -0,0 +1,17 @@ +--- +"kit": minor +--- + +Wire-revision provenance: the packed Meta gains a trailing presence-guarded +`u16 wireRevision` — a single monotonic counter of wire-visible minor +additions (`wire.Revision`, currently 4; ledger in its docs and ABI.md §4.2), +stamped automatically by both the Go and Rust guest SDKs and never set by +the author. Old hosts ignore the bytes; artifacts built with older kits +decode as revision 0 (unknown). This gives the deploy-order rule (ABI.md §5) +its mechanical anchor: a host can now warn on or refuse artifacts declaring +a revision above the one it implements instead of loading them blind, and +record per-artifact contract provenance. Pure additive trailer following the +established pattern — ABI major stays 2. The Rust SDK's `WIRE_REVISION` is +pinned to `wire.Revision` by a new Go cross-check test +(`wire.TestRustWireRevisionMatchesWire`), and the Go/Rust meta goldens are +updated in lockstep. diff --git a/ABI.md b/ABI.md index 6b1396b..e2afc9c 100644 --- a/ABI.md +++ b/ABI.md @@ -170,6 +170,7 @@ u16 configSpecCount (trailing; see u32 ctxFeatures trailing large-room section (see below); bit 0 = CtxFeatRosterEpoch u16 heartbeatMS 0 = no declaration u8 lifecycle trailing (see below); 0 resumable · 1 ephemeral · 2 resident +u16 wireRevision trailing (see below); 0 = unknown (the meta predates the field) ``` `slug` must be non-empty; the host refuses artifacts whose slug or version it @@ -220,6 +221,34 @@ resident room runs with zero members — see the zero-member wake rule in §4.1's roster-epoch notes; `start` precedes the first `join` universally, so an empty roster is already legal in every callback). +**Wire-revision field (minor addition).** A trailing `u16` after the +lifecycle byte, presence-guarded under the same rules (absent = `0`): the +**wire revision** of the kit the artifact was built against — a single +monotonic counter of the wire-visible minor additions within an ABI major +(§5), `wire.Revision` in code. It is stamped automatically by SDK encoders, +never set by the author, and declares the newest wire feature the artifact +may assume the host understands. The ledger so far: `1` config-spec section +· `2` large-room section + roster-epoch sentinels · `3` lifecycle byte · +`4` this field itself. `0` means unknown: the meta predates the field +(revisions 1–3 existed before it, so artifacts of those eras cannot declare +them — only `0` or values ≥ `4` are ever observed). A hand-rolled guest +(§4.7) SHOULD stamp the revision whose features it actually uses; omitting +the field (= declaring `0`) is always safe but forfeits the skew protection +below. + +Host semantics (normative): a host compares an artifact's declared revision +against the revision it was itself built against. An artifact declaring a +revision **at or below** the host's — or `0`, the legacy value — loads +normally. An artifact declaring a revision **above** the host's MUST NOT be +loaded blind: at publish/verify time the host SHOULD refuse it with a +diagnostic naming both revisions (the author rebuilt against a newer kit +than the host runs), and at catalog/boot load time it SHOULD skip the +artifact with a warning rather than fail, so a fleet mid-upgrade self-heals +once the lagging host catches up. This is the mechanical anchor for §5's +deploy-order rule: without it a too-new artifact surfaces only as every +delta container being rejected (a frozen screen), not as a diagnosable +version skew. + ### 4.3 Frame (the delta container and its cell) A frame is delivered as a **frame-delta container** (§4.5), a variable-length @@ -464,7 +493,14 @@ following rules are normative: understands every feature of the kit version it was built against, and the host advertises nothing. That ordering is what lets a flag-gated feature ship as a minor — every prior host already rejected the flag while it was - unassigned — without resurrecting a capability gate. + unassigned — without resurrecting a capability gate. The rule is no longer + merely operational: the meta's trailing `wireRevision` (§4.2) is its + mechanical anchor. Every wire-visible minor addition appends an entry to the + `wire.Revision` ledger and bumps the constant **in the same change**; SDKs + stamp it into every artifact, and a host warns on or refuses (at verify + time) / skips (at load time) artifacts declaring a revision above its own — + so a violated deploy order degrades into a diagnosable, self-healing + per-artifact skip instead of a silently frozen room. Consciously rejected (so they are not relitigated): **>3 code points per cell** (family ZWJ emoji — the future path, if ever needed, is a flag-gated side table, diff --git a/internal/game/codec.go b/internal/game/codec.go index 11577c5..c78eb1f 100644 --- a/internal/game/codec.go +++ b/internal/game/codec.go @@ -182,6 +182,11 @@ func encodeMeta(m GameMeta) []byte { if err := wire.ValidateLifecycle(wm.Lifecycle, wm.MinPlayers); err != nil { panic("kit: invalid GameMeta: " + err.Error()) } + // Stamp the wire revision this kit was built against — not + // author-settable; the host uses it to warn on or refuse artifacts + // declaring a revision above its own (deploy-order enforcement and + // per-artifact provenance, ABI.md §4.2 / §5). + wm.WireRevision = wire.Revision return wire.EncodeMeta(wm) } diff --git a/internal/game/codec_meta_test.go b/internal/game/codec_meta_test.go index 35684d1..a510cb6 100644 --- a/internal/game/codec_meta_test.go +++ b/internal/game/codec_meta_test.go @@ -45,3 +45,17 @@ func TestEncodeMetaPanicsOnInvalidConfig(t *testing.T) { Config: []ConfigKeySpec{{Key: "host.heartbeat_ms", Type: ConfigNumber}}, }) } + +// TestEncodeMetaStampsWireRevision pins that the SDK stamps the compiled-in +// wire revision into every meta — it is not author-settable, so every +// artifact built with this kit declares the contract revision it may assume +// (the host's deploy-order anchor, ABI.md §4.2 / §5). +func TestEncodeMetaStampsWireRevision(t *testing.T) { + out, err := wire.DecodeMeta(encodeMeta(GameMeta{Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 2})) + if err != nil { + t.Fatal(err) + } + if out.WireRevision != wire.Revision { + t.Fatalf("meta declares wire revision %d, want wire.Revision = %d", out.WireRevision, wire.Revision) + } +} diff --git a/rust/src/wire.rs b/rust/src/wire.rs index ce5c71e..b5690c8 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -170,6 +170,15 @@ fn decode_members(r: &mut Rd<'_>, n: usize) -> Vec { // ---- Meta (§4.2) --------------------------------------------------------------- +/// The wire revision this SDK implements (ABI.md §5): a monotonic counter of +/// wire-visible minor additions within the ABI major, stamped into the meta +/// trailer so hosts can warn on or refuse artifacts built against a newer +/// wire revision than they implement. The Rust mirror of Go's +/// `wire.Revision` — one protocol constant, asserted equal in lockstep by +/// the Go cross-check test `wire.TestRustWireRevisionMatchesWire` (which +/// parses this source line; keep the declaration on one line). +pub(crate) const WIRE_REVISION: u16 = 4; + /// Pack a [`Meta`] for the `meta` export — the single SDK-owned serializer. pub(crate) fn encode_meta(m: &Meta) -> Vec { let mut w = Buf::new(); @@ -226,6 +235,11 @@ pub(crate) fn encode_meta(m: &Meta) -> Vec { panic!("shellcade-kit: invalid Meta: lifecycle Resident cannot require min_players {}", m.min_players); } w.u8(m.lifecycle as u8); + // Trailing wire-revision u16 (ABI.md §4.2, spec minor): the SDK stamps + // the revision it was built against — not author-settable; old hosts + // ignore the bytes, and the host uses it to warn on or refuse artifacts + // declaring a revision above its own. + w.u16(WIRE_REVISION); w.b } @@ -383,6 +397,12 @@ mod tests { assert_eq!(r.u8(), 1); // LowerBetter assert_eq!(r.u8(), 0); // BestResult assert_eq!(r.u8(), 2); // Duration + // Trailing presence-guarded sections, always written by the encoder. + assert_eq!(r.u16(), 0); // config-spec count + assert_eq!(r.u32(), 0); // ctxFeatures + assert_eq!(r.u16(), 0); // heartbeatMS + assert_eq!(r.u8(), 0); // lifecycle Resumable + assert_eq!(r.u16(), WIRE_REVISION); // SDK-stamped wire revision assert!(!r.bad()); } @@ -470,7 +490,7 @@ mod tests { ], ..Meta::DEFAULT }; - let golden = "0600676f6c64656e0600476f6c64656e0e00676f6c64656e206669787475726501000400020001006101006200000000000001050073636f726501000202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e000000000000000000000000"; + let golden = "0600676f6c64656e0600476f6c64656e0e00676f6c64656e206669787475726501000400020001006101006200000000000001050073636f726501000202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e0000000000000000000000000400"; let got: String = encode_meta(&m).iter().map(|b| format!("{b:02x}")).collect(); assert_eq!(got, golden, "Rust meta encoding diverges from the Go golden"); } @@ -602,8 +622,9 @@ mod tests { ..Meta::DEFAULT }; let got: String = encode_meta(&m).iter().map(|b| format!("{b:02x}")).collect(); - // trailer = u32 1 LE + u16 100 LE = "01000000" + "6400" - assert!(got.ends_with("000001000000640000"), "trailer bytes diverge from the Go encoding: ...{}", &got[got.len()-18..]); + // trailer = u32 1 LE + u16 100 LE + u8 lifecycle + u16 revision 4 LE + // = "01000000" + "6400" + "00" + "0400" + assert!(got.ends_with("0000010000006400000400"), "trailer bytes diverge from the Go encoding: ...{}", &got[got.len()-22..]); } } diff --git a/wire/largeroom_test.go b/wire/largeroom_test.go index 7712e19..1d613db 100644 --- a/wire/largeroom_test.go +++ b/wire/largeroom_test.go @@ -115,8 +115,9 @@ func TestMetaTrailerRoundTrip(t *testing.T) { t.Fatalf("trailer round-trip = features %#x heartbeat %d", got.CtxFeatures, got.HeartbeatMS) } - // Pre-large-room payload: chop the trailing 7 bytes (u32 + u16 + u8). - old := b[:len(b)-7] + // Pre-large-room payload: chop the trailing 9 bytes + // (u32 + u16 large-room, u8 lifecycle, u16 wireRevision). + old := b[:len(b)-9] got, err = DecodeMeta(old) if err != nil { t.Fatalf("pre-trailer decode: %v", err) @@ -125,9 +126,10 @@ func TestMetaTrailerRoundTrip(t *testing.T) { t.Fatalf("pre-trailer payload decoded nonzero trailer: %#x %d %d", got.CtxFeatures, got.HeartbeatMS, got.Lifecycle) } - // Pre-lifecycle payload (kit v2.6.0 era): chop only the lifecycle byte — - // the large-room section decodes, lifecycle defaults to resumable. - v26 := b[:len(b)-1] + // Pre-lifecycle payload (kit v2.6.0 era): chop the lifecycle byte and the + // wire-revision u16 — the large-room section decodes, lifecycle defaults + // to resumable. + v26 := b[:len(b)-3] got, err = DecodeMeta(v26) if err != nil { t.Fatalf("pre-lifecycle decode: %v", err) @@ -137,6 +139,42 @@ func TestMetaTrailerRoundTrip(t *testing.T) { } } +// The wire-revision trailer: SDK-stamped values round-trip; a payload encoded +// by a pre-revision kit (v2.7.x era — chop the trailing u16) decodes as 0 = +// unknown; the bare wire encoder never stamps a revision on its own (the +// field rides through verbatim, so re-encoding a decoded meta cannot +// fabricate provenance). +func TestMetaWireRevisionRoundTrip(t *testing.T) { + m := Meta{Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 8, WireRevision: Revision} + b := EncodeMeta(m) + got, err := DecodeMeta(b) + if err != nil { + t.Fatalf("decode: %v", err) + } + if got.WireRevision != Revision { + t.Fatalf("wire revision round-trip = %d, want %d", got.WireRevision, Revision) + } + + // Pre-revision payload (kit v2.7.x era): chop the trailing u16. + v27 := b[:len(b)-2] + got, err = DecodeMeta(v27) + if err != nil { + t.Fatalf("pre-revision decode: %v", err) + } + if got.WireRevision != 0 { + t.Fatalf("pre-revision payload decoded revision %d, want 0", got.WireRevision) + } + + // An unstamped meta declares 0 (unknown), never the package constant. + got, err = DecodeMeta(EncodeMeta(Meta{Slug: "g", Name: "G"})) + if err != nil { + t.Fatalf("unstamped decode: %v", err) + } + if got.WireRevision != 0 { + t.Fatalf("unstamped meta declared revision %d, want 0", got.WireRevision) + } +} + func TestLifecycleRoundTripAndValidation(t *testing.T) { m := Meta{Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 8, Lifecycle: LifecycleEphemeral} got, err := DecodeMeta(EncodeMeta(m)) diff --git a/wire/revision_crosscheck_test.go b/wire/revision_crosscheck_test.go new file mode 100644 index 0000000..e99eeb1 --- /dev/null +++ b/wire/revision_crosscheck_test.go @@ -0,0 +1,42 @@ +package wire + +import ( + "os" + "path/filepath" + "regexp" + "strconv" + "testing" +) + +// TestRustWireRevisionMatchesWire is the crossverify-side guard for the wire +// revision: Revision is a protocol constant (it is what an artifact's meta +// declares as the newest wire feature it may assume, and what a host compares +// its own compiled-in revision against), and the Rust guest SDK carries its +// own copy as wire.rs's WIRE_REVISION. The constant is pub(crate), so no +// compiled artifact exposes it; instead this test parses the Rust source +// directly and asserts it equals wire.Revision — runnable by plain +// `go test ./wire/...` with no Rust toolchain, alongside the byte-level +// crossverify golden vectors that guard the encodings themselves. +func TestRustWireRevisionMatchesWire(t *testing.T) { + path := filepath.Join("..", "rust", "src", "wire.rs") + src, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading Rust SDK source %s: %v", path, err) + } + re := regexp.MustCompile(`(?m)^pub\(crate\) const WIRE_REVISION: u16 = (\d+);$`) + matches := re.FindAllSubmatch(src, -1) + if len(matches) != 1 { + t.Fatalf("expected exactly one WIRE_REVISION declaration in %s, found %d — "+ + "if the declaration moved or was reworded, update this test's pattern "+ + "so the cross-SDK assertion keeps holding", path, len(matches)) + } + got, err := strconv.Atoi(string(matches[0][1])) + if err != nil { + t.Fatalf("parsing WIRE_REVISION value %q: %v", matches[0][1], err) + } + if got != int(Revision) { + t.Fatalf("Rust SDK WIRE_REVISION = %d, wire.Revision = %d — these are one "+ + "protocol constant and must change in lockstep (see wire.Revision docs)", + got, Revision) + } +} diff --git a/wire/wire.go b/wire/wire.go index 12427b5..b77169c 100644 --- a/wire/wire.go +++ b/wire/wire.go @@ -18,6 +18,34 @@ import ( // Version is the ABI major version. const Version uint32 = 2 +// Revision is the wire revision: a monotonic counter of the wire-visible +// minor additions within ABI major Version, bumped in the same change that +// lands each addition (ABI.md §5). SDK encoders stamp it automatically into +// the meta payload's trailing wireRevision field, giving hosts a per-artifact +// declaration of the newest wire feature the artifact may assume — the +// mechanical anchor for the deploy-order rule (a host warns on or refuses +// artifacts declaring a revision above its own compiled-in Revision). +// +// Ledger (the revision that INTRODUCED each wire-visible minor): +// +// 0 — unknown: the meta predates the wireRevision field (kit ≤ v2.7.x) +// 1 — config-spec meta section (kit v2.3.0) +// 2 — large-room meta section + ctx roster-epoch sentinels (kit v2.6.0) +// 3 — lifecycle meta byte (kit v2.7.0) +// 4 — the wireRevision meta field itself +// +// Revisions 1–3 predate the field, so artifacts of those eras decode as 0; +// only 0 or values ≥ 4 are ever observed on the wire. Any future change that +// adds a wire-visible encoding (a new trailing meta section, a ctx-feature +// bit, a delta flag bit, a new host function the guest calls, …) MUST append +// a ledger entry and bump this constant in the same change. +// +// Like RosterCap, this is a protocol constant mirrored by the Rust guest SDK +// (rust/src/wire.rs WIRE_REVISION, asserted equal to this constant by +// TestRustWireRevisionMatchesWire in this package) — the two must change in +// lockstep. +const Revision uint16 = 4 + // Guest export names. const ( ExpABI = "shellcade_abi" @@ -190,6 +218,14 @@ type Meta struct { // long-lived granted room per slug). Hosts treat values they do not // implement as resumable. Lifecycle uint8 + + // WireRevision is the trailing wire-revision declaration (spec minor + // addition; u16 after the lifecycle byte): the wire.Revision of the kit + // the artifact was built against. 0 = unknown — the artifact predates + // the field. SDK encode paths stamp wire.Revision automatically (it is + // not author-settable); hosts use it to warn on or refuse artifacts + // declaring a revision above their own (ABI.md §4.2, §5). + WireRevision uint16 } // Lifecycle values for the meta trailer. @@ -481,6 +517,11 @@ func EncodeMeta(m Meta) []byte { // Trailing lifecycle byte (spec minor addition). Always written; older // decoders ignore it. w.U8(m.Lifecycle) + // Trailing wire-revision (spec minor addition). Always written; older + // decoders ignore the bytes. The field rides through verbatim so a meta + // round-trips field-exact — SDK encode paths stamp Revision, and a zero + // value means "no declaration". + w.U16(m.WireRevision) return w.B } @@ -532,6 +573,11 @@ func DecodeMeta(b []byte) (Meta, error) { if !r.Bad && r.Off < len(r.B) { m.Lifecycle = r.U8() } + // Trailing wire-revision, presence-guarded: absent = 0 (unknown — the + // artifact predates the field). + if !r.Bad && r.Off < len(r.B) { + m.WireRevision = r.U16() + } if err := r.Err(); err != nil { return Meta{}, err } From 49681c9f45d2ac46d086b4e1cf6b1389836aeccd Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 10 Jun 2026 03:15:15 +0000 Subject: [PATCH 3/6] K3: close the crossverify reference chain with freshness gates and scalar golden vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crossverify harness proved Go/Rust byte-identity only for the delta encoder, and even there against a SNAPSHOT: the committed .dgld vectors were never re-emitted in CI, so a Go encoder byte-output change would leave the Rust golden job green against historical bytes. Meanwhile the rest of the wire surface (meta with its positional presence-guarded trailers, ctx sentinel forms, results) was pinned only by hand-pasted hex in Rust unit tests, which catch Rust drifting from its own past but never a Go-side change. Close both links of the delta reference chain: - CI test job re-emits the .dgld vectors from the current Go reference via TestEmitGolden into a temp dir and diffs against the committed crossverify/tests/golden, failing with a regenerate instruction (emission is deterministic, so this is non-flaky). - internal/diffbench/parity_test.go asserts the emitter's encodeRunList / encodeKeyframe / encodeRunListOrKeyframe are byte-identical to the production wire.BuildFrameDelta / wire.BuildKeyframe (epoch 0) across every committed capture and synthetic — the gate can only police the production encoder if the two copies agree. Extend the generated-vector approach to the scalar encodings, direction-aware (wire/scalar_golden_test.go emits and freshness-gates the committed rust/tests/golden/scalars.txt; rust/src/wire.rs mod scalar_golden replays it in the existing rust CI job): - guest-encoded payloads: a default-valued meta (Meta::DEFAULT + slug) and a fully-populated one (tags, labels, leaderboard, config specs incl. JSON + schema, ctx features, heartbeat, lifecycle, stamped wireRevision), plus a mixed-status multi-player result — Rust asserts encode_meta / encode_outcome byte-identity (encode_outcome previously had no Go golden at all). - host-encoded ctx payloads: legacy, full-sentinel, and unchanged-sentinel forms with trailing event-extra bytes — Rust runs decode_ctx over the Go bytes asserting every field and the reader position (previously those tests hand-built bytes with Rust's own Buf, verifying nothing cross-language). - decoder presence guards: truncated older-form meta vectors (pre-config, pre-large-room, pre-lifecycle, pre-revision) pin DecodeMeta's absent-trailer tolerance; the Rust SDK has no meta decoder, so these are Go-side only. TestScalarGoldenFresh regenerates the vector content on every plain go test run and fails if the committed file is stale, converting each future trailing-section addition from "remember to regenerate hex by hand" into a failing test on whichever side moved. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 17 ++ crossverify/README.md | 15 ++ internal/diffbench/parity_test.go | 65 ++++++ rust/src/wire.rs | 216 +++++++++++++++++++ rust/tests/golden/scalars.txt | 26 +++ wire/scalar_golden_test.go | 345 ++++++++++++++++++++++++++++++ 6 files changed, 684 insertions(+) create mode 100644 internal/diffbench/parity_test.go create mode 100644 rust/tests/golden/scalars.txt create mode 100644 wire/scalar_golden_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 665295f..6c5907a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,23 @@ jobs: - run: go build ./... - run: go vet ./... - run: go test ./... + # The committed crossverify .dgld vectors are a snapshot of the Go + # reference encoder; without this gate a Go-side byte-output change + # leaves them silently representing a HISTORICAL encoder (the rust job's + # golden step keeps passing against the stale bytes). Re-emit from the + # current encoder and require an exact match. Emission is deterministic + # (no RNG; committed .fseq inputs), so this is non-flaky. + - name: crossverify golden vectors are fresh (byte-identity vs the CURRENT Go reference) + run: | + set -euo pipefail + out="$(mktemp -d)" + DIFFBENCH_GOLDEN_DIR="$out" go test -run TestEmitGolden ./internal/diffbench/ + if ! diff -r "$out" crossverify/tests/golden; then + echo "::error::committed crossverify golden vectors are STALE: the Go reference encoder's byte output changed." + echo "Review the change (it is wire-visible), then regenerate and commit:" + echo " DIFFBENCH_GOLDEN_DIR=crossverify/tests/golden go test -run TestEmitGolden ./internal/diffbench/" + exit 1 + fi - name: no private imports (the module must build from ABI.md alone) run: | ! grep -rn "shellcade/shellcade" --include="*.go" . || (echo "FORBIDDEN private import" && exit 1) diff --git a/crossverify/README.md b/crossverify/README.md index 473e700..81bf580 100644 --- a/crossverify/README.md +++ b/crossverify/README.md @@ -61,6 +61,21 @@ DIFFBENCH_GOLDEN_DIR=/path/to/out rustup run stable cargo test --release --test emitter `kit/internal/diffbench/golden_test.go`. The input frame is stored as an encoder-independent changed-cell list so verification is not circular.) +Two CI gates keep the committed vectors a live reference rather than a +historical snapshot: the kit `test` job re-emits them from the current Go +encoder and diffs against `tests/golden` (so a Go byte-output change cannot +silently strand this harness on old bytes), and +`kit/internal/diffbench/parity_test.go` asserts the emitter is byte-identical +to the production `wire.BuildFrameDelta`/`wire.BuildKeyframe` encoders Go +guests actually ship. + +The same generated-vector discipline covers the scalar encodings (meta / ctx / +result): `kit/wire/scalar_golden_test.go` emits and freshness-gates +`kit/rust/tests/golden/scalars.txt`, which the SDK crate replays in +`rust/src/wire.rs` (`mod scalar_golden`) — byte-identity for guest-encoded +payloads (meta, result), field + reader-position assertions over the +host-encoded ctx forms. + ## Perf sanity ```sh diff --git a/internal/diffbench/parity_test.go b/internal/diffbench/parity_test.go new file mode 100644 index 0000000..ef3efd6 --- /dev/null +++ b/internal/diffbench/parity_test.go @@ -0,0 +1,65 @@ +package diffbench + +import ( + "bytes" + "testing" + + "github.com/shellcade/kit/v2/wire" +) + +// TestReferenceEncoderMatchesProductionWire pins the middle link of the +// crossverify reference chain. The committed golden vectors +// (crossverify/tests/golden/*.dgld) are emitted from THIS package's +// encodeRunList / encodeKeyframe / encodeRunListOrKeyframe, but what a Go +// guest actually ships is wire.BuildFrameDelta / wire.BuildKeyframe (via +// internal/game's codec, with the host-issued epoch). CI's golden-freshness +// gate re-emits the vectors from this package and diffs them against the +// committed set, so it can only police drift in the PRODUCTION encoder if the +// two encoders are byte-identical — which this test asserts, frame by frame, +// across every committed real capture and every synthetic scenario (epoch 0, +// the value the emitter models; the field is fixed-width, so identity at +// epoch 0 plus the header layout covers the wire form). +func TestReferenceEncoderMatchesProductionWire(t *testing.T) { + real, err := realScenarios() + if err != nil { + t.Fatalf("loading real scenarios: %v", err) + } + scenarios := append(real, synthScenarios()...) + + benchDst := make([]byte, MaxEncoded) + wireDst := make([]byte, wire.MaxDeltaBytes) + for _, s := range scenarios { + prev := blankFrame() + for fi, next := range s.Frames { + n := encodeRunList(prev, next, benchDst) + wn := wire.BuildFrameDelta(prev, next, wireDst, 0) + if !bytes.Equal(benchDst[:n], wireDst[:wn]) { + t.Fatalf("%s frame %d: encodeRunList (%d B) != wire.BuildFrameDelta (%d B) — "+ + "the golden emitter has drifted from the production encoder; reconcile "+ + "them (and regenerate the .dgld vectors if the production bytes are the "+ + "intended ones)", s.Name, fi, n, wn) + } + + n = encodeKeyframe(prev, next, benchDst) + wn = wire.BuildKeyframe(next, wireDst, 0) + if !bytes.Equal(benchDst[:n], wireDst[:wn]) { + t.Fatalf("%s frame %d: encodeKeyframe (%d B) != wire.BuildKeyframe (%d B)", + s.Name, fi, n, wn) + } + + // The fallback golden must equal the production budget rule: + // run-list, degrading to the keyframe form at >= KeyframeBytes. + n = encodeRunListOrKeyframe(prev, next, benchDst) + wn = wire.BuildFrameDelta(prev, next, wireDst, 0) + if wn >= wire.KeyframeBytes { + wn = wire.BuildKeyframe(next, wireDst, 0) + } + if !bytes.Equal(benchDst[:n], wireDst[:wn]) { + t.Fatalf("%s frame %d: encodeRunListOrKeyframe (%d B) != production budget rule (%d B)", + s.Name, fi, n, wn) + } + + prev = next + } + } +} diff --git a/rust/src/wire.rs b/rust/src/wire.rs index b5690c8..d5e2235 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -628,3 +628,219 @@ mod tests { } } +/// Cross-language golden replay: the vectors in `rust/tests/golden/scalars.txt` +/// are EMITTED by the Go reference encoders (`kit/wire`, +/// `scalar_golden_test.go` — whose `TestScalarGoldenFresh` fails the Go test +/// run if the committed file goes stale against the current Go encoders) and +/// replayed here, direction-aware: +/// +/// - guest-encoded payloads (meta, result): this SDK's `encode_meta` / +/// `encode_outcome` must be BYTE-IDENTICAL to the Go bytes; +/// - host-encoded payloads (ctx): `decode_ctx` runs over the Go bytes, +/// asserting every field AND the reader position at the trailing u32 +/// event-extra (7); +/// - the `meta_trunc_*` vectors pin the HOST-side decoder's presence guards +/// (Go `wire.DecodeMeta`) and are not consumed here — this SDK carries no +/// meta decoder. +/// +/// Together with the freshness gate this closes the regeneration loop the +/// hand-pasted hex tests above cannot: a wire-visible change on EITHER side +/// (a new trailing meta section, a sentinel change, a revision bump) fails CI +/// until the vectors are deliberately regenerated and both fixtures reviewed. +/// The fixtures below mirror kit/wire's scalar fixtures verbatim — keep them +/// describing the same logical payloads. +#[cfg(test)] +mod scalar_golden { + use super::*; + use crate::types::{ + Aggregation, ConfigKeySpec, ConfigType, Direction, Kind, Leaderboard, Lifecycle, Meta, + MetricFormat, Mode, Outcome, Player, PlayerResult, Status, CTX_FEAT_ROSTER_EPOCH, + }; + + const VECTORS: &str = include_str!("../tests/golden/scalars.txt"); + + /// The u32 appended after every ctx vector (stand-in for per-export + /// trailing args): decode must leave the reader exactly there. + const CTX_EVENT_EXTRA: u32 = 7; + + fn vector(name: &str) -> Vec { + for line in VECTORS.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (n, hex) = line.split_once(" = ").expect("malformed vector line"); + if n != name { + continue; + } + assert!(hex.len() % 2 == 0, "{name}: odd hex length"); + return (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("bad hex")) + .collect(); + } + panic!( + "vector {name} not found in tests/golden/scalars.txt — regenerate from kit/wire:\n \ + WIRE_SCALAR_GOLDEN_WRITE=1 go test -run TestScalarGoldenFresh ./wire/" + ); + } + + fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() + } + + #[test] + fn meta_default_byte_identical_to_go() { + // Go: scalarMetaDefault — Meta::DEFAULT plus a slug (the encoder + // stamps WIRE_REVISION, so this also pins revision lockstep on bytes). + let m = Meta { slug: "default", ..Meta::DEFAULT }; + assert_eq!( + hex(&encode_meta(&m)), + hex(&vector("meta_default")), + "encode_meta diverges from the Go reference for the default-valued meta" + ); + } + + #[test] + fn meta_full_byte_identical_to_go() { + // Go: scalarMetaFull — every section populated. + let m = Meta { + slug: "golden-full", + name: "Golden Full", + short_description: "every section populated", + min_players: 2, + max_players: 8, + tags: &["multi", "card"], + quick_mode_label: "Deal me in", + solo_mode_label: "Practice", + private_invite_line: "Join my table", + leaderboard: Some(Leaderboard { + metric_label: "chips", + direction: Direction::LowerBetter, + aggregation: Aggregation::SumResults, + format: MetricFormat::Duration, + }), + config: &[ + ConfigKeySpec { + key: "odds-variant", + title: "Odds variant", + description: "PAR sheet.", + config_type: ConfigType::Json, + default: r#"{"name":"Default"}"#, + schema: r#"{"type":"object"}"#, + }, + ConfigKeySpec { + key: "motd", + title: "Banner", + description: "Floor banner.", + config_type: ConfigType::Text, + ..ConfigKeySpec::DEFAULT + }, + ], + ctx_features: CTX_FEAT_ROSTER_EPOCH, + heartbeat_ms: 250, + lifecycle: Lifecycle::Ephemeral, + }; + assert_eq!( + hex(&encode_meta(&m)), + hex(&vector("meta_full")), + "encode_meta diverges from the Go reference for the fully-populated meta" + ); + } + + #[test] + fn outcome_byte_identical_to_go() { + // Go: scalarResult — indices 2, 0, 1 with mixed statuses; here they + // are produced by encode_outcome's player→index mapping over a + // three-player roster, so the mapping itself is under test too. + fn player(handle: &str, account_id: &str, conn: &str, kind: Kind) -> Player { + Player { + handle: handle.into(), + account_id: account_id.into(), + conn: conn.into(), + kind, + } + } + let roster = vec![ + player("ada", "acct-ada", "c1", Kind::Member), + player("bo", "acct-bo", "c2", Kind::Guest), + player("cyd", "acct-cyd", "c3", Kind::Member), + ]; + let res = Outcome { + rankings: vec![ + PlayerResult { player: roster[2].clone(), metric: 9000, rank: 1, status: Status::Finished }, + PlayerResult { player: roster[0].clone(), metric: -1, rank: 2, status: Status::Dnf }, + PlayerResult { player: roster[1].clone(), metric: 512, rank: 2, status: Status::Finished }, + ], + }; + assert_eq!( + hex(&encode_outcome(&res, &roster)), + hex(&vector("result_mixed")), + "encode_outcome diverges from the Go reference" + ); + } + + // Go: scalarCtx — the fields every ctx vector carries. + fn assert_ctx_common(ctx: &CallCtx) { + assert_eq!(ctx.now_unix_nanos, 1_718_000_000_123_456_789); + assert_eq!(ctx.cfg.seed, -42); + assert!(ctx.cfg.seed_set); + assert_eq!(ctx.cfg.mode, Mode::Private); + assert_eq!(ctx.cfg.capacity, 8); + assert_eq!(ctx.cfg.min_players, 2); + assert!(ctx.settled); + } + + fn assert_ctx_roster(members: &[Player]) { + assert_eq!(members.len(), 2); + assert_eq!(members[0].handle, "ada"); + assert_eq!(members[0].account_id, "acct-ada"); + assert_eq!(members[0].conn, "c1"); + assert_eq!(members[0].kind, Kind::Member); + assert_eq!(members[1].handle, "guest-7"); + assert_eq!(members[1].account_id, ""); + assert_eq!(members[1].conn, "c2"); + assert!(members[1].guest()); + } + + fn assert_event_extra(r: &mut Rd<'_>, name: &str) { + assert_eq!(r.u32(), CTX_EVENT_EXTRA, "{name}: reader not positioned at the event extras"); + assert!(!r.bad(), "{name}: reader went bad reading the event extras"); + assert_eq!(r.u8(), 0, "{name}: trailing bytes after the event extras"); + assert!(r.bad(), "{name}: payload longer than fields + event extras"); + } + + #[test] + fn ctx_legacy_replays_go_bytes() { + let b = vector("ctx_legacy"); + let (ctx, mut r) = decode_ctx(&b); + assert_ctx_common(&ctx); + assert_eq!(ctx.roster_epoch, None); + assert!(!ctx.roster_unchanged); + assert_ctx_roster(&ctx.members); + assert_event_extra(&mut r, "ctx_legacy"); + } + + #[test] + fn ctx_epoch_full_replays_go_bytes() { + let b = vector("ctx_epoch_full"); + let (ctx, mut r) = decode_ctx(&b); + assert_ctx_common(&ctx); + assert_eq!(ctx.roster_epoch, Some(42)); + assert!(!ctx.roster_unchanged); + assert_ctx_roster(&ctx.members); + assert_event_extra(&mut r, "ctx_epoch_full"); + } + + #[test] + fn ctx_epoch_unchanged_replays_go_bytes() { + let b = vector("ctx_epoch_unchanged"); + let (ctx, mut r) = decode_ctx(&b); + assert_ctx_common(&ctx); + assert_eq!(ctx.roster_epoch, Some(43)); + assert!(ctx.roster_unchanged); + assert!(ctx.members.is_empty()); + assert_event_extra(&mut r, "ctx_epoch_unchanged"); + } +} + diff --git a/rust/tests/golden/scalars.txt b/rust/tests/golden/scalars.txt new file mode 100644 index 0000000..270c6fa --- /dev/null +++ b/rust/tests/golden/scalars.txt @@ -0,0 +1,26 @@ +# Cross-language scalar-encoding golden vectors (meta / ctx / result), emitted +# by the Go reference encoders in kit/wire (scalar_golden_test.go) and replayed +# by the Rust SDK (rust/src/wire.rs, mod scalar_golden). +# +# DO NOT EDIT BY HAND. When an encoding legitimately changes (a new trailing +# meta section, a wire.Revision bump, ...), review the wire-visible change, +# then regenerate and commit: +# +# WIRE_SCALAR_GOLDEN_WRITE=1 go test -run TestScalarGoldenFresh ./wire/ +# +# Format: one "name = hex" line per vector. meta_* are guest-encoded payloads +# (Rust asserts byte-identity from its own encoders); ctx_* are host-encoded +# payloads with a trailing u32 event-extra (7) (Rust decodes them, asserting +# fields and reader position); meta_trunc_* are truncated older-form metas +# pinning the host-side decoder's presence guards (Go-only — the guest SDKs +# carry no meta decoder). +meta_default = 070064656661756c7400000000010001000000000000000000000000000000000000000400 +meta_full = 0b00676f6c64656e2d66756c6c0b00476f6c64656e2046756c6c170065766572792073656374696f6e20706f70756c6174656402000800020005006d756c74690400636172640a004465616c206d6520696e080050726163746963650d004a6f696e206d79207461626c65010500636869707301010202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e000000000001000000fa00010400 +meta_trunc_pre_config = 05007472756e6305005472756e63000001000400000000000000000001050073636f7265010000 +meta_trunc_pre_largeroom = 05007472756e6305005472756e63000001000400000000000000000001050073636f72650100000000 +meta_trunc_pre_lifecycle = 05007472756e6305005472756e63000001000400000000000000000001050073636f72650100000000010000006400 +meta_trunc_pre_revision = 05007472756e6305005472756e63000001000400000000000000000001050073636f7265010000000001000000640001 +ctx_legacy = 15cd7ad3e58fd717d6ffffffffffffff010108000200020003006164610800616363742d6164610200633101070067756573742d37000002006332000107000000 +ctx_epoch_full = 15cd7ad3e58fd717d6ffffffffffffff010108000200feff2a000000020003006164610800616363742d6164610200633101070067756573742d37000002006332000107000000 +ctx_epoch_unchanged = 15cd7ad3e58fd717d6ffffffffffffff010108000200ffff2b0000000107000000 +result_mixed = 030002000000282300000000000001000000000000ffffffffffffffff020001010000000002000000000000020000 diff --git a/wire/scalar_golden_test.go b/wire/scalar_golden_test.go new file mode 100644 index 0000000..d24ebac --- /dev/null +++ b/wire/scalar_golden_test.go @@ -0,0 +1,345 @@ +package wire + +import ( + "fmt" + "os" + "reflect" + "strings" + "testing" +) + +// Cross-language scalar-encoding golden vectors: the generated-vector +// discipline the crossverify .dgld harness applies to the delta encoder, +// extended to the rest of the wire surface (meta with its presence-guarded +// trailing sections, the three ctx member-section forms, results). This +// package is the EMITTER and the freshness gate; the Rust SDK replays the +// committed vectors (rust/src/wire.rs, mod scalar_golden) so a wire-visible +// change on either side fails CI instead of relying on hand-pasted hex being +// regenerated in two places. +// +// Direction-aware coverage: +// - guest-encoded payloads (meta, result): Rust asserts its encoders are +// byte-identical to these Go-emitted bytes; +// - host-encoded payloads (ctx): Rust runs decode_ctx over them, asserting +// every field AND the reader position at the trailing event-extra; +// - decoder presence-guards: the meta_trunc_* vectors are truncated +// OLDER-FORM meta payloads (each ends exactly at a section boundary) +// pinning DecodeMeta's absent-trailer tolerance (the Rust SDK has no meta +// decoder — meta decode is host-side — so only Go consumes these). +// +// scalarGoldenPath lives under the Rust crate so its replay test is +// self-contained (include_str!); TestScalarGoldenFresh regenerates the +// content on every plain `go test` run and fails if the committed file has +// gone stale against the current encoders. +const scalarGoldenPath = "../rust/tests/golden/scalars.txt" + +// ctxEventExtra is the u32 appended after every ctx vector, standing in for +// the per-export trailing args (e.g. playerIdx): decoding must leave the +// reader exactly there. +const ctxEventExtra uint32 = 7 + +// ---- fixtures (mirrored verbatim in rust/src/wire.rs mod scalar_golden) ---- + +// scalarMetaDefault is the default-valued fixture: Rust's Meta::DEFAULT with +// only a slug (MinPlayers/MaxPlayers default to 1 there). WireRevision is the +// SDK-stamped constant, so this vector also pins Go/Rust revision lockstep at +// the byte level. +func scalarMetaDefault() Meta { + return Meta{Slug: "default", MinPlayers: 1, MaxPlayers: 1, WireRevision: Revision} +} + +// scalarMetaFull populates every section: tags, mode labels, leaderboard, +// config specs (incl. a JSON-typed key with a schema), ctx features, +// heartbeat, a non-default lifecycle, and the stamped revision. +func scalarMetaFull() Meta { + return Meta{ + Slug: "golden-full", + Name: "Golden Full", + ShortDescription: "every section populated", + MinPlayers: 2, + MaxPlayers: 8, + Tags: []string{"multi", "card"}, + QuickModeLabel: "Deal me in", + SoloModeLabel: "Practice", + PrivateInviteLine: "Join my table", + HasLeaderboard: true, + MetricLabel: "chips", + Direction: 1, // lower-better + Aggregation: 1, // sum-results + Format: 2, // duration + ConfigSpecs: []ConfigSpec{ + { + Key: "odds-variant", + Title: "Odds variant", + Description: "PAR sheet.", + Type: ConfigJSON, + Default: `{"name":"Default"}`, + Schema: `{"type":"object"}`, + }, + {Key: "motd", Title: "Banner", Description: "Floor banner.", Type: ConfigText}, + }, + CtxFeatures: CtxFeatRosterEpoch, + HeartbeatMS: 250, + Lifecycle: LifecycleEphemeral, + WireRevision: Revision, + } +} + +// scalarMetaTrunc is the basis for the truncated older-form vectors: a +// populated leaderboard block but NO config specs, so the trailing-section +// widths measured from the end are fixed (config 2 | large-room 6 | +// lifecycle 1 | wireRevision 2) and each truncation point is exact. The +// trailer values are non-zero so each successive form demonstrably decodes +// one more section. +func scalarMetaTrunc() Meta { + return Meta{ + Slug: "trunc", + Name: "Trunc", + MinPlayers: 1, + MaxPlayers: 4, + HasLeaderboard: true, + MetricLabel: "score", + Direction: 1, + Aggregation: 0, + Format: 0, + CtxFeatures: CtxFeatRosterEpoch, + HeartbeatMS: 100, + Lifecycle: LifecycleEphemeral, + WireRevision: Revision, + } +} + +func scalarCtx() Ctx { + return Ctx{ + NowUnixNanos: 1718000000123456789, + Seed: -42, + SeedSet: true, + Mode: ModePrivate, + Capacity: 8, + MinPlayers: 2, + Members: []Player{ + {Handle: "ada", AccountID: "acct-ada", Conn: "c1", Kind: KindMember}, + {Handle: "guest-7", AccountID: "", Conn: "c2", Kind: KindGuest}, + }, + Settled: true, + } +} + +// scalarResult is a three-player roster with out-of-roster-order rankings and +// mixed statuses; the Rust side reproduces it through encode_outcome's +// player→index mapping, so the indices below are what that mapping must +// yield. (StatusFlagged is host-assigned, never guest-encoded, so the guest +// statuses are Finished/DNF.) +func scalarResult() Result { + return Result{Rankings: []Ranking{ + {PlayerIdx: 2, Metric: 9000, Rank: 1, Status: StatusFinished}, + {PlayerIdx: 0, Metric: -1, Rank: 2, Status: StatusDNF}, + {PlayerIdx: 1, Metric: 512, Rank: 2, Status: StatusFinished}, + }} +} + +// ---- vector generation ------------------------------------------------------- + +type scalarVector struct { + name string + payload []byte +} + +// Trailing meta section widths measured from the END of an encoding with zero +// config specs: the u16 spec count (2) + large-room u32+u16 (6) + lifecycle +// u8 (1) + wireRevision u16 (2). +const ( + truncPreRevision = 2 + truncPreLifecycle = 2 + 1 + truncPreLargeRoom = 2 + 1 + 6 + truncPreConfig = 2 + 1 + 6 + 2 +) + +func scalarVectors() []scalarVector { + trunc := EncodeMeta(scalarMetaTrunc()) + + ctx := func(encode func(w *Buf)) []byte { + var w Buf + encode(&w) + w.U32(ctxEventExtra) + return w.B + } + + return []scalarVector{ + {"meta_default", EncodeMeta(scalarMetaDefault())}, + {"meta_full", EncodeMeta(scalarMetaFull())}, + {"meta_trunc_pre_config", trunc[:len(trunc)-truncPreConfig]}, + {"meta_trunc_pre_largeroom", trunc[:len(trunc)-truncPreLargeRoom]}, + {"meta_trunc_pre_lifecycle", trunc[:len(trunc)-truncPreLifecycle]}, + {"meta_trunc_pre_revision", trunc[:len(trunc)-truncPreRevision]}, + {"ctx_legacy", ctx(func(w *Buf) { EncodeCtx(w, scalarCtx()) })}, + {"ctx_epoch_full", ctx(func(w *Buf) { EncodeCtxEpoch(w, scalarCtx(), 42, true) })}, + {"ctx_epoch_unchanged", ctx(func(w *Buf) { EncodeCtxEpoch(w, scalarCtx(), 43, false) })}, + {"result_mixed", EncodeResult(scalarResult())}, + } +} + +const scalarGoldenHeader = `# Cross-language scalar-encoding golden vectors (meta / ctx / result), emitted +# by the Go reference encoders in kit/wire (scalar_golden_test.go) and replayed +# by the Rust SDK (rust/src/wire.rs, mod scalar_golden). +# +# DO NOT EDIT BY HAND. When an encoding legitimately changes (a new trailing +# meta section, a wire.Revision bump, ...), review the wire-visible change, +# then regenerate and commit: +# +# WIRE_SCALAR_GOLDEN_WRITE=1 go test -run TestScalarGoldenFresh ./wire/ +# +# Format: one "name = hex" line per vector. meta_* are guest-encoded payloads +# (Rust asserts byte-identity from its own encoders); ctx_* are host-encoded +# payloads with a trailing u32 event-extra (7) (Rust decodes them, asserting +# fields and reader position); meta_trunc_* are truncated older-form metas +# pinning the host-side decoder's presence guards (Go-only — the guest SDKs +# carry no meta decoder). +` + +func renderScalarGolden() string { + var b strings.Builder + b.WriteString(scalarGoldenHeader) + for _, v := range scalarVectors() { + fmt.Fprintf(&b, "%s = %x\n", v.name, v.payload) + } + return b.String() +} + +// TestScalarGoldenFresh is the freshness gate: the committed vector file must +// equal what the CURRENT encoders emit, so an encoder byte-output change +// cannot silently strand the Rust replay tests on historical bytes. Runs on +// plain `go test` (CI's test job); set WIRE_SCALAR_GOLDEN_WRITE=1 to +// regenerate the file after a reviewed encoding change. +func TestScalarGoldenFresh(t *testing.T) { + want := renderScalarGolden() + if os.Getenv("WIRE_SCALAR_GOLDEN_WRITE") != "" { + if err := os.WriteFile(scalarGoldenPath, []byte(want), 0o644); err != nil { + t.Fatal(err) + } + t.Logf("wrote %s (%d bytes)", scalarGoldenPath, len(want)) + return + } + got, err := os.ReadFile(scalarGoldenPath) + if err != nil { + t.Fatalf("reading committed scalar golden vectors: %v\nregenerate with:\n WIRE_SCALAR_GOLDEN_WRITE=1 go test -run TestScalarGoldenFresh ./wire/", err) + } + if string(got) != want { + t.Fatalf("%s is STALE against the current Go encoders.\n"+ + "An encoding's byte output changed — review the change (it is wire-visible\n"+ + "and may need a wire.Revision bump and an ABI.md entry), then regenerate,\n"+ + "commit, and make sure the Rust replay fixtures still describe the same\n"+ + "logical payloads:\n WIRE_SCALAR_GOLDEN_WRITE=1 go test -run TestScalarGoldenFresh ./wire/", + scalarGoldenPath) + } +} + +// TestScalarGoldenMetaDecode pins DecodeMeta over the emitted vectors: the +// populated fixtures round-trip field-exact, and each truncated older form +// decodes with exactly the absent trailers zero-valued — the presence-guard +// behavior every future trailing section must preserve. +func TestScalarGoldenMetaDecode(t *testing.T) { + for _, fix := range []struct { + name string + m Meta + }{ + {"meta_default", scalarMetaDefault()}, + {"meta_full", scalarMetaFull()}, + } { + got, err := DecodeMeta(EncodeMeta(fix.m)) + if err != nil { + t.Fatalf("%s: decode: %v", fix.name, err) + } + if !reflect.DeepEqual(got, fix.m) { + t.Errorf("%s does not round-trip:\n got %+v\n want %+v", fix.name, got, fix.m) + } + } + + full := scalarMetaTrunc() + enc := EncodeMeta(full) + cases := []struct { + name string + cut int + want func(m *Meta) // zero the fields absent from this older form + }{ + {"meta_trunc_pre_config", truncPreConfig, func(m *Meta) { + m.ConfigSpecs = nil + m.CtxFeatures, m.HeartbeatMS, m.Lifecycle, m.WireRevision = 0, 0, 0, 0 + }}, + {"meta_trunc_pre_largeroom", truncPreLargeRoom, func(m *Meta) { + m.CtxFeatures, m.HeartbeatMS, m.Lifecycle, m.WireRevision = 0, 0, 0, 0 + }}, + {"meta_trunc_pre_lifecycle", truncPreLifecycle, func(m *Meta) { + m.Lifecycle, m.WireRevision = 0, 0 + }}, + {"meta_trunc_pre_revision", truncPreRevision, func(m *Meta) { + m.WireRevision = 0 + }}, + } + for _, tc := range cases { + got, err := DecodeMeta(enc[:len(enc)-tc.cut]) + if err != nil { + t.Fatalf("%s: decode: %v", tc.name, err) + } + want := full + tc.want(&want) + if !reflect.DeepEqual(got, want) { + t.Errorf("%s presence guards broken:\n got %+v\n want %+v", tc.name, got, want) + } + } +} + +// TestScalarGoldenCtxDecode pins DecodeCtx over the three emitted ctx forms, +// including the reader position at the trailing event-extra — the Go twin of +// the Rust replay assertions. +func TestScalarGoldenCtxDecode(t *testing.T) { + vectors := make(map[string][]byte, len(scalarVectors())) + for _, v := range scalarVectors() { + vectors[v.name] = v.payload + } + base := scalarCtx() + cases := []struct { + name string + want Ctx + }{ + {"ctx_legacy", base}, + {"ctx_epoch_full", func() Ctx { + c := base + c.RosterEpoch, c.RosterEpochSet = 42, true + return c + }()}, + {"ctx_epoch_unchanged", func() Ctx { + c := base + c.RosterEpoch, c.RosterEpochSet, c.RosterUnchanged = 43, true, true + c.Members = nil + return c + }()}, + } + for _, tc := range cases { + r := &Rd{B: vectors[tc.name]} + got := DecodeCtx(r) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("%s:\n got %+v\n want %+v", tc.name, got, tc.want) + } + if extra := r.U32(); extra != ctxEventExtra { + t.Errorf("%s: reader not at the event extras: got u32 %d, want %d", tc.name, extra, ctxEventExtra) + } + if err := r.Err(); err != nil { + t.Errorf("%s: %v", tc.name, err) + } + if r.Off != len(r.B) { + t.Errorf("%s: %d bytes left after the event extras", tc.name, len(r.B)-r.Off) + } + } +} + +// TestScalarGoldenResultDecode pins DecodeResult over the result vector. +func TestScalarGoldenResultDecode(t *testing.T) { + got, err := DecodeResult(EncodeResult(scalarResult())) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, scalarResult()) { + t.Errorf("result_mixed does not round-trip:\n got %+v\n want %+v", got, scalarResult()) + } +} From 98cde80116ca0bff62f9456fd1eb2d21ef7b8db5 Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 10 Jun 2026 03:51:06 +0000 Subject: [PATCH 4/6] K4: add kittest KVUnavailable knob mirroring host KV degradation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kittest double's KV always succeeded, so no game author could simulate the real host behavior where a transient store failure makes Get report a saved key as missing and silently drops Set/Delete — the production path that turns the natural 'Get -> missing -> initialize -> Set' wallet pattern into a silent state reset. Add an opt-in Room.KVUnavailable flag with exactly those semantics (Get returns nil,false,nil; Set/Delete return nil without persisting; the backing maps survive the blip), pin them with a degradation test plus an example demonstrating the read-absent-reinit hazard, and document the hazard and conservative-missing-read guidance in GUIDE.md. Co-Authored-By: Claude Fable 5 --- .changeset/kittest-kv-unavailable.md | 14 ++++ GUIDE.md | 17 ++++- kittest/kittest.go | 26 ++++++++ kittest/kvunavailable_test.go | 96 ++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 .changeset/kittest-kv-unavailable.md create mode 100644 kittest/kvunavailable_test.go diff --git a/.changeset/kittest-kv-unavailable.md b/.changeset/kittest-kv-unavailable.md new file mode 100644 index 0000000..475f18d --- /dev/null +++ b/.changeset/kittest-kv-unavailable.md @@ -0,0 +1,14 @@ +--- +"kit": minor +--- + +`kittest.Room` gains an opt-in `KVUnavailable` chaos knob that replays the +production host's KV degradation exactly: while set, `Get` reports every key +as missing (`nil, false, nil`) and `Set`/`Delete` return `nil` without +persisting — the ABI has no error channel, so a real store outage never +surfaces a Go error. This lets authors test the read-absent-reinit hazard +(the natural `Get → missing → initialize → Set` wallet pattern silently +resets saved state during a store blip), previously impossible to simulate +because the double's KV always succeeded. GUIDE.md's Durable state section +now documents the degradation semantics and the conservative-missing-read +guidance, and `ExampleRoom_kvUnavailable` demonstrates the failing pattern. diff --git a/GUIDE.md b/GUIDE.md index 23e87c4..f02e34b 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -341,6 +341,18 @@ scores, `keep-winner` (default) for everything else. **`sum`/`max` values MUST be base-10 ASCII integers** (e.g. `strconv.Itoa` — `"990"`), within int64; anything unparsable falls back to keep-winner at merge time. +**The store can degrade, and you won't see an error.** The ABI gives your game +no error channel for KV: when the host's store has a transient failure, `Get` +reports the key as **missing** (`nil, false, nil`) and `Set`/`Delete` return +`nil` without persisting. That makes the natural +`Get → missing → initialize starting balance → Set` wallet pattern a trap — a +store blip reads a veteran's wallet as absent, and your "new player" write can +clobber the durable value. Treat a missing read conservatively (defer the +initializing write, or use `sum`/`max` rules so a blip-era write cannot win at +merge time), and test the scenario with `kittest`: set +`r.KVUnavailable = true` and your suite sees exactly those production +semantics (see `ExampleRoom_kvUnavailable` in the `kittest` package). + Per-game configuration (tunable by arcade admins without your involvement) arrives through `r.Services().Config.Get(ctx, "key")` — read it on a slow cadence in `OnWake` and fall back to compiled defaults when absent. @@ -557,7 +569,10 @@ code, same verdict. For unit tests, `github.com/shellcade/kit/v2/kittest` is an in-memory `Room` + `Services` with a virtual clock, seeded RNG, and recorded -frames/posts/settles — drive your `Handler` directly and assert. +frames/posts/settles — drive your `Handler` directly and assert. Its +`KVUnavailable` knob replays the host's KV degradation (reads come back +missing, writes silently drop — see Durable state) so you can prove your +wallet code survives a store blip. ## What your game can't do (on purpose) diff --git a/kittest/kittest.go b/kittest/kittest.go index 8b91efc..ccfdacd 100644 --- a/kittest/kittest.go +++ b/kittest/kittest.go @@ -45,6 +45,23 @@ type Room struct { KV map[string]map[string][]byte // accountID -> key -> value KVRules map[string]map[string]kit.MergeRule ConfigVals map[string][]byte + + // KVUnavailable simulates the production host's KV degradation — the ABI + // gives the guest no error channel, so when the host's store fails it does + // NOT surface a Go error; it degrades exactly like this: + // + // - Get reports the key as missing (nil, false, nil) + // - Set and Delete return nil without persisting anything + // + // While true, the same happens here, and the backing maps keep whatever + // they held (the outage is transient, not a wipe). Use it to test the + // read-absent-reinit hazard: a game that does the natural + // `Get → missing → initialize starting balance → Set` resets a player's + // saved wallet during a store blip. Robust games treat "missing" wallet + // reads conservatively (e.g. re-read on a later wake before overwriting, + // or write with kit.MergeMax/kit.MergeSum so a blip-era write cannot + // clobber the durable value at merge time). + KVUnavailable bool } // NewRoom returns a Room with the given members, a seeded RNG (seed 1), and a @@ -137,11 +154,17 @@ type kv struct { } func (k kv) Get(_ context.Context, key string) ([]byte, bool, error) { + if k.r.KVUnavailable { + return nil, false, nil // host degradation: a store error reads as key-missing + } v, ok := k.r.KV[k.id][key] return v, ok, nil } func (k kv) Set(_ context.Context, key string, value []byte, rule kit.MergeRule) error { + if k.r.KVUnavailable { + return nil // host degradation: the write is silently dropped + } if k.r.KV[k.id] == nil { k.r.KV[k.id] = map[string][]byte{} k.r.KVRules[k.id] = map[string]kit.MergeRule{} @@ -152,6 +175,9 @@ func (k kv) Set(_ context.Context, key string, value []byte, rule kit.MergeRule) } func (k kv) Delete(_ context.Context, key string) error { + if k.r.KVUnavailable { + return nil // host degradation: the delete is silently dropped + } delete(k.r.KV[k.id], key) return nil } diff --git a/kittest/kvunavailable_test.go b/kittest/kvunavailable_test.go new file mode 100644 index 0000000..1cbfceb --- /dev/null +++ b/kittest/kvunavailable_test.go @@ -0,0 +1,96 @@ +package kittest_test + +// KVUnavailable mirrors the production host's KV degradation byte-for-byte: +// the ABI has no error channel, so a failing store never surfaces a Go error — +// Get reports the key as missing and Set/Delete silently drop the write. These +// tests pin those exact semantics and demonstrate the hazard the knob exists +// to expose: the natural `Get → missing → initialize → Set` wallet pattern +// resets a player's saved balance during a store blip. + +import ( + "context" + "fmt" + "strconv" + "testing" + + kit "github.com/shellcade/kit/v2" + "github.com/shellcade/kit/v2/kittest" +) + +func TestKVUnavailableDegradationSemantics(t *testing.T) { + ctx := context.Background() + r := kittest.NewRoom(kittest.Player("p1")) + store := r.Services().Accounts.For(r.Players[0]).Store() + + // Persist a wallet while the store is healthy. + if err := store.Set(ctx, "balance", []byte("990"), kit.MergeSum); err != nil { + t.Fatalf("Set: %v", err) + } + + // During the outage: Get is (nil, false, nil) — missing, NOT an error. + r.KVUnavailable = true + v, ok, err := store.Get(ctx, "balance") + if v != nil || ok || err != nil { + t.Fatalf("unavailable Get = (%v, %v, %v), want (nil, false, nil)", v, ok, err) + } + + // Set returns nil but persists nothing; the durable value is untouched. + if err := store.Set(ctx, "balance", []byte("1000"), kit.MergeSum); err != nil { + t.Fatalf("unavailable Set must return nil, got %v", err) + } + if got := string(r.KV["p1"]["balance"]); got != "990" { + t.Fatalf("unavailable Set persisted: balance = %q, want %q", got, "990") + } + + // Delete returns nil but drops nothing. + if err := store.Delete(ctx, "balance"); err != nil { + t.Fatalf("unavailable Delete must return nil, got %v", err) + } + if _, still := r.KV["p1"]["balance"]; !still { + t.Fatal("unavailable Delete removed the key") + } + + // The blip ends: the durable value reads back intact. + r.KVUnavailable = false + v, ok, err = store.Get(ctx, "balance") + if err != nil || !ok || string(v) != "990" { + t.Fatalf("post-outage Get = (%q, %v, %v), want (\"990\", true, nil)", v, ok, err) + } +} + +// ExampleRoom_kvUnavailable shows the read-absent-reinit hazard: a wallet +// loader that treats "missing" as "new player" silently resets saved state +// during a store blip — kittest can now make that test fail before production +// does. +func ExampleRoom_kvUnavailable() { + ctx := context.Background() + r := kittest.NewRoom(kittest.Player("p1")) + store := r.Services().Accounts.For(r.Players[0]).Store() + + // The naive pattern: Get → missing → initialize starting balance → Set. + loadWallet := func() int { + v, ok, _ := store.Get(ctx, "balance") + if !ok { + store.Set(ctx, "balance", []byte("1000"), kit.MergeMax) + return 1000 + } + n, _ := strconv.Atoi(string(v)) + return n + } + + store.Set(ctx, "balance", []byte("9500"), kit.MergeMax) // a veteran's wallet + fmt.Println("healthy:", loadWallet()) + + r.KVUnavailable = true // a transient store blip… + fmt.Println("blip: ", loadWallet()) + + r.KVUnavailable = false // …but here the reinit Set was dropped too, so the + fmt.Println("after: ", loadWallet()) // durable 9500 survives. In production + // the blip can end between the Get and the Set — and then the 1000 + // overwrites 9500. Don't persist a starting balance from a missing read. + + // Output: + // healthy: 9500 + // blip: 1000 + // after: 9500 +} From 6c42e7aec4fcb05def8ca07143f5eaa8dddfe564 Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 10 Jun 2026 04:12:36 +0000 Subject: [PATCH 5/6] K5: add MIT LICENSE and align player-bounds docs with the 1..1024 reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship an MIT LICENSE at the repo root (rust/Cargo.toml already declared license = "MIT"; the holder/choice is flagged for maintainer confirmation). GUIDE.md now states the platform player bound explicitly as 1..1024 (wire.RosterCap) in the Multiplayer and Large rooms sections, and documents that smoke scripts drive at most 8 seats — the runner clamps MinPlayers to the seat count, so large-room games still pass smoke, with large-room behavior covered by check's budget gates. The Rust README notes that a directory-accepting shellcade-kit play lands in the next shellcade-kit release, collapsing the Rust iterate loop to 'shellcade-kit play .'. Co-Authored-By: Claude Fable 5 --- .changeset/license-and-player-bounds-docs.md | 14 +++++++++++++ GUIDE.md | 16 +++++++++++---- LICENSE | 21 ++++++++++++++++++++ rust/README.md | 6 ++++++ 4 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 .changeset/license-and-player-bounds-docs.md create mode 100644 LICENSE diff --git a/.changeset/license-and-player-bounds-docs.md b/.changeset/license-and-player-bounds-docs.md new file mode 100644 index 0000000..7fe7384 --- /dev/null +++ b/.changeset/license-and-player-bounds-docs.md @@ -0,0 +1,14 @@ +--- +"kit": patch +--- + +The repo now ships an MIT `LICENSE` file at the root — `rust/Cargo.toml` +already declared `license = "MIT"`, but the module itself carried no license +text, leaving authors without usage terms. Doc consistency fixes ride along: +GUIDE.md now states the platform player bound explicitly as 1..1024 +(`wire.RosterCap`) in both the Multiplayer and Large rooms sections, and the +smoke-script section documents that smoke scripts drive at most 8 seats (the +runner clamps `MinPlayers` to the seat count, so large-room games still pass; +large-room behavior is covered by `shellcade-kit check`'s budget gates). The +Rust README notes that the next shellcade-kit release makes `shellcade-kit +play` accept a game directory and run the cargo wasm build itself. diff --git a/GUIDE.md b/GUIDE.md index f02e34b..f0281ae 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -378,8 +378,10 @@ the editor, they don't gate the store. ## Multiplayer -Rooms hold 1–N players (your `GameMeta` declares the range). Render -**per-player views** by composing a frame per member: +Rooms hold 1–N players: your `GameMeta` declares the range, and the platform +bound is **1..1024** (`wire.RosterCap` — the same constant that sizes the +frame-delta roster on both sides of the ABI). Render **per-player views** by +composing a frame per member: ```go for _, p := range r.Members() { @@ -421,8 +423,9 @@ leaves: ## Large rooms: 100+ players in one room -The SDK supports rooms of up to 1024 players, but a large room only stays -inside the wake budget if the game follows three disciplines: +The SDK supports rooms of up to 1024 players (`wire.RosterCap`, the platform's +hard ceiling), but a large room only stays inside the wake budget if the game +follows three disciplines: **Declare your heartbeat.** A roguelike or board game does not need the 50ms default. Declare your real cadence and the platform honors it (an admin @@ -529,6 +532,11 @@ Authoring tips: reveal) so the preview shows what each seat sees. - `advance` must be a whole number of heartbeats — the parser rejects ambiguous durations rather than rounding. +- **Smoke scripts drive at most 8 seats** — a screen-preview tool, not a + load harness. Large-room games (up to the platform's 1..1024 bound) still + pass smoke: the runner clamps your `MinPlayers` to the scripted seat count, + and large-room behavior is exercised by `shellcade-kit check`'s budget + gates, not by smoke screens. The `smoke` package exposes the same machinery as Go API (`smoke.Parse`, `smoke.Run`, `smoke.RenderANSI`) if you want shots inside your own tests. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fc7e2e0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Brandon Cook + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rust/README.md b/rust/README.md index 574313a..f2a642e 100644 --- a/rust/README.md +++ b/rust/README.md @@ -28,6 +28,12 @@ shellcade-kit play target/wasm32-wasip1/release/mygame.wasm > `my-game` builds `target/wasm32-wasip1/release/my_game.wasm`. The scaffolded > README carries your exact path. +> **Coming in the next shellcade-kit release**: `shellcade-kit play` accepts +> a game *directory* and runs the cargo wasm build itself, collapsing the +> iterate loop above to the one command `shellcade-kit play .`. Until then, +> the Rust inner loop is `cargo test` for logic plus the explicit wasm build +> to see your game on screen. + Game logic tests run natively — no wasm runtime needed: ```sh From 867ac89b3ccb5020f95613eb792eaae5ccb2a2d5 Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 10 Jun 2026 04:37:07 +0000 Subject: [PATCH 6/6] K6: guard the shellcade-kit toolchain pin and add govulncheck CI Bump SHELLCADE_KIT_VERSION 2.2.0 -> 2.3.0 (the newest published binary release) with the matching linux/amd64 sha256, and add a kit-pin job that keeps the pin honest mechanically: it parses the kit line of 'shellcade-kit version' (the embedded kit module version, not the binary's own version stream) and fails on a tag/embed lockstep violation, and it compares the pin against the newest release carrying shellcade-kit binaries, failing with a bump instruction when the pin falls behind. Also add a govulncheck workflow (push/PR plus a nightly schedule, since new vuln-DB entries arrive without commits). Verified clean locally. The changesets for the wire additions this release carries (wire.RosterCap, wire.Revision, kittest KVUnavailable) already landed with K1/K2/K4. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 67 ++++++++++++++++++++++++++++++- .github/workflows/govulncheck.yml | 21 ++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/govulncheck.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c5907a..5d08c9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,11 +11,74 @@ on: # Pinned shellcade-kit toolchain: the author binary the arcade ships, attached # to this repo's matching release. Bump the version AND the linux/amd64 sha # (from that release's checksums.txt) together when adopting a new toolchain. +# The kit-pin job below fails CI when this pin falls behind the newest +# published binary release, so drift can't accumulate silently again. env: - SHELLCADE_KIT_VERSION: "2.2.0" - SHELLCADE_KIT_SHA256: "eab71d43085ce894a0075af0c3af71c9e23c8f3150cc50e46d0df3668742eff7" + SHELLCADE_KIT_VERSION: "2.3.0" + SHELLCADE_KIT_SHA256: "79fbc6d7290c52fa1c0b9a513e08599195af5af17d8d945926520763d7ac62bb" jobs: + # Toolchain pin lockstep: the SHELLCADE_KIT_VERSION pin above is a manual + # mirror of the newest published shellcade-kit binary release, and it has + # drifted before (2.2.0 lingered after v2.3.0 shipped). Two mechanical + # checks keep it honest: + # 1. the binary fetched at tag vX must EMBED kit vX — parse the `kit` + # line of `shellcade-kit version` (the kit module version baked in via + # debug.ReadBuildInfo, NOT the binary's own version line); + # 2. the pin must equal the newest release that actually carries + # shellcade-kit binaries (binaries attach to existing kit releases, so + # bare module tags without assets don't count). + # Runs on the nightly schedule too: staleness appears without a push. + kit-pin: + runs-on: ubuntu-latest + steps: + - name: fetch pinned shellcade-kit (sha256-verified) + run: | + set -euo pipefail + ver="${SHELLCADE_KIT_VERSION}" + asset="shellcade-kit_${ver}_linux_amd64.tar.gz" + base="https://github.com/shellcade/kit/releases/download/v${ver}" + curl -fsSL -o "${asset}" "${base}/${asset}" + echo "${SHELLCADE_KIT_SHA256} ${asset}" | sha256sum -c - + tar -xzf "${asset}" shellcade-kit + sudo install shellcade-kit /usr/local/bin/shellcade-kit + - name: pinned binary embeds the pinned kit version (lockstep) + run: | + set -euo pipefail + shellcade-kit version + embedded="$(shellcade-kit version | awk '$1 == "kit" { print $2 }')" + if [ "${embedded}" != "v${SHELLCADE_KIT_VERSION}" ]; then + echo "::error::lockstep violation: the v${SHELLCADE_KIT_VERSION} shellcade-kit binary embeds kit ${embedded}." + echo "A binary released at tag vX must be built against kit vX (see CLAUDE.md 'Lockstep')." + echo "Bump SHELLCADE_KIT_VERSION and SHELLCADE_KIT_SHA256 in ci.yml to a release whose binary matches its tag." + exit 1 + fi + - name: pin is the newest published binary release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Newest release carrying shellcade-kit binaries — NOT releases/latest + # blindly, and not bare module tags: the private repo attaches the + # binaries to this repo's existing releases after a kit tag, so only + # releases with a shellcade-kit linux/amd64 asset are candidates. + newest="$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=30" --jq \ + '[.[] | select(.draft or .prerelease | not) + | select(any(.assets[].name; startswith("shellcade-kit_") and endswith("_linux_amd64.tar.gz"))) + ][0].tag_name')" + echo "pin: v${SHELLCADE_KIT_VERSION} newest binary release: ${newest}" + if [ -z "${newest}" ] || [ "${newest}" = "null" ]; then + echo "::error::could not determine the newest shellcade-kit binary release" + exit 1 + fi + if [ "${newest}" != "v${SHELLCADE_KIT_VERSION}" ] && \ + [ "$(printf '%s\n' "v${SHELLCADE_KIT_VERSION}" "${newest}" | sort -V | tail -n1)" = "${newest}" ]; then + echo "::error::SHELLCADE_KIT_VERSION (${SHELLCADE_KIT_VERSION}) is stale: the newest published shellcade-kit binary release is ${newest}." + echo "Bump SHELLCADE_KIT_VERSION to ${newest#v} and SHELLCADE_KIT_SHA256 to the" + echo "linux/amd64 entry of that release's checksums.txt in .github/workflows/ci.yml." + exit 1 + fi + test: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml new file mode 100644 index 0000000..658b4f0 --- /dev/null +++ b/.github/workflows/govulncheck.yml @@ -0,0 +1,21 @@ +name: govulncheck +on: + push: + branches: [main] + pull_request: + schedule: + # Nightly (07:30 UTC, after CI's cron): new vulns land in the Go vuln DB + # without any commit here, so a schedule — not just push/PR — is the gate + # that actually catches them. + - cron: "30 7 * * *" + +jobs: + govulncheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: { go-version-file: go.mod } + # Deliberately unpinned: vuln scanning wants the current tool and the + # current database, unlike the reproducible build toolchain above. + - run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...