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/.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/.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/.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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 665295f..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: @@ -25,6 +88,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/.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 ./... diff --git a/ABI.md b/ABI.md index eea618e..e2afc9c 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. @@ -162,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 @@ -212,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 @@ -456,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/GUIDE.md b/GUIDE.md index 23e87c4..f0281ae 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. @@ -366,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() { @@ -409,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 @@ -517,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. @@ -557,7 +577,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/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/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/internal/game/codec.go b/internal/game/codec.go index 16c77ca..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) } @@ -211,13 +216,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/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/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 +} 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 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/rust/src/wire.rs b/rust/src/wire.rs index ce5c71e..d5e2235 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,225 @@ 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..]); + } +} + +/// 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/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/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/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()) + } +} diff --git a/wire/wire.go b/wire/wire.go index e1daa07..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" @@ -57,6 +85,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 @@ -176,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. @@ -467,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 } @@ -518,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 }