From 93d1ccfbcad391c649d13738b17ba6555ec45e55 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Sun, 7 Jun 2026 10:41:09 +1000 Subject: [PATCH] =?UTF-8?q?guest=20sdk:=20large-room=20callbacks=20?= =?UTF-8?q?=E2=80=94=20roster-epoch=20ctx=20mode=20+=20declared=20heartbea?= =?UTF-8?q?ts=20+=20Rust=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kit half of the large-room-callbacks OpenSpec change. Three pieces: Ctx roster-epoch mode (negotiated via GameMeta.CtxFeatures bit 0): the member section gains sentinel forms — count 0xFFFE = full roster at a u32 epoch, 0xFFFF = unchanged-since-epoch (6 bytes total) — emitted only to guests that declare the feature; legacy guests stay byte-identical and real counts (<= 0xFFFD) keep the legacy layout, so all four host/guest version pairings are unambiguous. Both SDKs decode the sentinels into an epoch-aware roster cache: zero member allocations on the unchanged form (the byte-skim cache stays for pre-feature hosts), full forms are authoritative, and an epoch mismatch degrades to the cached roster with a single warning, never a trap. ABI major stays 2 (ABI.md §4.1). GameMeta.HeartbeatMS: games declare their wake cadence (validated 0 or 20..1000 at meta encode, the config-specs fail-fast posture). It rides a new always-written, presence-guarded meta trailer with the features bitset (ABI.md §4.2); shellcade-kit play honors it locally; host precedence is admin config > declaration > 50ms default. Rust crate parity: mirrors the meta fields, sentinel decode, and an Rc-shared epoch roster cache — and picks up the v2.5.0 Go-only baseline work it missed: ROSTER_CAP 16 -> 1024 with lazily-allocated per-slot baselines and allocated-only broadcast reconcile. Golden vectors pin Go/Rust meta encodings byte-identical (trailer bytes cross-checked against the Go encoder). GUIDE.md gains the large-room authoring section (declare heartbeat + feature, render-on-change dirty tracking, Members() lifetime). Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/large-room-callbacks.md | 30 ++++++ ABI.md | 41 ++++++++ GUIDE.md | 49 +++++++++ internal/game/codec.go | 110 ++++++++++++++------ internal/game/devrun.go | 11 ++ internal/game/largeroom_test.go | 110 ++++++++++++++++++++ internal/game/run.go | 10 +- internal/game/types.go | 19 ++++ kit.go | 2 + rust/Cargo.lock | 2 +- rust/src/broadcast.rs | 63 +++++++---- rust/src/lib.rs | 2 +- rust/src/room.rs | 4 +- rust/src/rt.rs | 35 ++++++- rust/src/types.rs | 25 +++++ rust/src/wire.rs | 162 ++++++++++++++++++++++++++--- wire/largeroom_test.go | 150 ++++++++++++++++++++++++++ wire/wire.go | 137 ++++++++++++++++++++++-- 18 files changed, 880 insertions(+), 82 deletions(-) create mode 100644 .changeset/large-room-callbacks.md create mode 100644 internal/game/largeroom_test.go create mode 100644 wire/largeroom_test.go diff --git a/.changeset/large-room-callbacks.md b/.changeset/large-room-callbacks.md new file mode 100644 index 0000000..ab27dc2 --- /dev/null +++ b/.changeset/large-room-callbacks.md @@ -0,0 +1,30 @@ +--- +"kit": minor +--- + +Large-room callbacks: negotiated roster-epoch ctx encoding, game-declared +heartbeats, and the Rust mirror of the 1024-player baseline work. + +- **Ctx roster-epoch mode** (`GameMeta.CtxFeatures: kit.CtxFeatRosterEpoch`): + the host sends the full member list only when the roster changes (with a + `u32` epoch) and a 6-byte unchanged marker otherwise — removing the + O(members) encode/decode/copy from every callback (~100KB per input at + 1000 players, the dominant large-room input cost and the residual + `-gc=leaking` leak). Sentinel member-counts `0xFFFE`/`0xFFFF` (ABI.md + §4.1); legacy guests stay byte-identical; ABI major stays 2. Both SDKs + decode the sentinels and keep an epoch-aware roster cache (zero member + allocations on the unchanged form); the legacy byte-skim cache is retained + for pre-feature hosts. +- **`GameMeta.HeartbeatMS`**: declare your wake cadence (0 = default; + validated 20..1000 at meta encode). Host precedence: admin + `host.heartbeat_ms` > declaration > 50ms default. `shellcade-kit play` + honors the declaration locally. +- **Rust crate parity**: mirrors both meta fields + the sentinel decode + + the epoch roster cache (`Rc`-shared members), and ALSO picks up the + v2.5.0 Go-only baseline work it missed: `ROSTER_CAP` 16 → 1024 with + lazily-allocated per-slot baselines and allocated-only broadcast + reconcile. Golden vectors pin the Go/Rust meta encodings byte-identical. +- New trailing meta section (`u32 ctxFeatures` + `u16 heartbeatMS`) after + the config-spec section, presence-guarded both directions; `GUIDE.md` + gains the large-room authoring section (heartbeat + feature declaration + + render-on-change dirty tracking). diff --git a/ABI.md b/ABI.md index c983eb2..6106970 100644 --- a/ABI.md +++ b/ABI.md @@ -119,6 +119,32 @@ u16 memberCount on `leave`, includes the departed entry u8 settled 0/1 ``` +**Roster-epoch member-section forms (minor addition).** For a guest whose +meta declares `CtxFeatRosterEpoch` (§4.2), the host MAY replace the member +section with one of two sentinel forms keyed on `memberCount` (real rosters +are capped far below the sentinels, so the three forms are unambiguous): + +``` +memberCount 0x0000..0xFFFD legacy full roster (exactly the layout above) +memberCount 0xFFFE full roster at an epoch: + u32 rosterEpoch · u16 realCount · members as above +memberCount 0xFFFF roster unchanged since an epoch: + u32 rosterEpoch (no member data) +``` + +Lifecycle: the host holds a per-instance roster epoch, bumped on every roster +mutation (join, leave, index shift). It sends the `0xFFFE` full form when the +epoch differs from the last full form sent to THIS instance — which includes +the **first callback after any instantiation or hibernation restore** (epoch +state is ephemeral host memory, never snapshotted) — and the 6-byte `0xFFFF` +unchanged form otherwise. The guest treats a full form as authoritative +whenever received (re-cache, adopt the epoch); an unchanged form whose epoch +differs from the guest's cached epoch is a host fault — the guest logs once, +keeps its cached roster, and degrades (it must never trap on it). Guests that +do not declare the feature receive ONLY the legacy form, byte-identical to +prior revisions. The roster epoch and the frame-delta epoch (§4.6) are +independent counters. + ### 4.2 Meta ``` @@ -133,6 +159,8 @@ u16 configSpecCount (trailing; see per spec: str key · str title · str description · u8 type (0 text · 1 number · 2 bool · 3 json) · str default ("" = not declared) · str schema ("" = none; json only) +u32 ctxFeatures trailing large-room section (see below); bit 0 = CtxFeatRosterEpoch +u16 heartbeatMS 0 = no declaration ``` `slug` must be non-empty; the host refuses artifacts whose slug or version it @@ -155,6 +183,19 @@ intended to be a JSON Schema document — compilation and enforcement are a host concern). The Go SDK enforces these rules at `meta()` encode time, and `wire.ValidateConfigSpecs` is the shared rule set for decoders. +**Large-room section (minor addition).** A second trailing section after the +config-spec section: `u32 ctxFeatures` (a bitset of negotiated callback +encodings; bit 0 = `CtxFeatRosterEpoch`, see §4.1) then `u16 heartbeatMS` +(the game's preferred wake cadence; 0 = no declaration). Presence-guarded +exactly like the config-spec section: a payload ending after the config-spec +section is a valid older meta with zero values; older hosts ignore the +trailing bytes; hosts ignore feature bits they do not implement. Encoders +that know the section always write it. SDKs reject undefined feature bits +and a `heartbeatMS` outside 0 ∪ [20, 1000] at `meta()` encode time. The host +resolves the wake heartbeat at room creation as: admin `host.heartbeat_ms` +config > declared `heartbeatMS` > platform default (50ms), clamped to +[20ms, 1000ms] — a declaration is authoring intent, never authority. + ### 4.3 Frame (the delta container and its cell) A frame is delivered as a **frame-delta container** (§4.5), a variable-length diff --git a/GUIDE.md b/GUIDE.md index c777455..a1d5385 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -383,6 +383,55 @@ The native runner also rides terminal resizes: shrink the window below 80×24 and it shows a "terminal too small" notice, then repaints your game the moment you grow it back — the same letterboxing the arcade does over SSH. +## 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: + +**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 +override always wins): + +```go +func (Game) Meta() kit.GameMeta { + return kit.GameMeta{ + // ... + HeartbeatMS: 100, // gentle tick: 10 wakes/sec + } +} +``` + +**Declare the roster-epoch feature.** By default every callback payload +carries the full member list. In a 1000-player room that is ~100KB per +input; with the feature, an unchanged roster costs 6 bytes: + +```go +CtxFeatures: kit.CtxFeatRosterEpoch, +``` + +The SDK handles the rest. One contract to know: the slice from +`r.Members()` is valid for the duration of the callback — copy any `Player` +you keep, and key long-lived state by `AccountID` (you already should). + +**Render on change, not on wake.** Composing and sending every player's +frame on every wake is the single largest cost in a big room — and most +frames are identical to the last. Track per-player dirtiness and skip clean +viewports: + +- re-compose a player's view only when THEY moved, something moved within + their visible window, or their HUD line changed; +- throttle ambient HUD clocks to ~1Hz — a per-wake counter in the HUD forces + a nonzero delta for every player on every wake, defeating the delta + encoder; +- at typical input rates expect only ~10–30% of viewports dirty per tick — + a 3–10× cut in wake cost. + +The frame-delta layer already makes CLEAN-but-resent frames cheap on the +wire; dirty tracking makes them free in CPU too. Allocation discipline +matters as much as ever under `-gc=leaking`: compose into a reused +`kit.Frame`, write cells directly, avoid per-player-per-wake string +building. + ## Smoke scripts: scripted screens Every catalog game ships a `smoke.yaml` next to its source: a small diff --git a/internal/game/codec.go b/internal/game/codec.go index 18e9b40..200d46c 100644 --- a/internal/game/codec.go +++ b/internal/game/codec.go @@ -40,6 +40,15 @@ type callContext struct { var ( rosterCache []Player rosterCacheBytes []byte + + // Roster-epoch mode state (games declaring CtxFeatRosterEpoch): the epoch + // the cached roster was decoded at. epochMismatch flags a host-fault + // unchanged-form whose epoch didn't match the cache (decodeCall logs it); + // epochMismatchLogged keeps that warning to one line per instance. + rosterCacheEpoch uint32 + rosterCacheEpochSet bool + epochMismatch bool + epochMismatchLogged bool ) // decodeCtx decodes a CallContext and returns the reader positioned at the @@ -54,39 +63,52 @@ func decodeCtx(b []byte) (callContext, *wire.Rd, bool) { c.cfg.Capacity = int(r.U16()) c.cfg.MinPlayers = int(r.U16()) - // Skim the member section (count + per-member strings) without decoding, - // to find its extent for the cache compare. - start := r.Off - n := int(r.U16()) - for i := 0; i < n && !r.Bad; i++ { - r.SkipStr() // handle - r.SkipStr() // account id - r.SkipStr() // conn - r.U8() // kind - } - region := r.B[start:r.Off] - - changed := rosterCacheBytes == nil || !bytes.Equal(region, rosterCacheBytes) - if changed { - rr := &wire.Rd{B: region} - cnt := int(rr.U16()) - rosterCache = rosterCache[:0] - for i := 0; i < cnt && !rr.Bad; i++ { - var p Player - p.Handle = rr.Str() - p.AccountID = rr.Str() - p.Conn = rr.Str() - p.Kind = Kind(rr.U8()) - rosterCache = append(rosterCache, p) + count := r.U16() + var changed bool + switch count { + case wire.CtxRosterUnchanged: + // Roster-epoch sentinel: epoch only, no member data. Reuse the cache; + // an epoch mismatch is a host fault — degrade (keep the cache), the + // single warning is emitted by decodeCall, which has a Room to log on. + epoch := r.U32() + if !rosterCacheEpochSet || epoch != rosterCacheEpoch { + epochMismatch = true + changed = true // be conservative: invalidate baselines } - if r.Bad { - // Malformed member section: don't prime the cache — every - // malformed callback stays "changed" and decodes what it can - // (the pre-cache behavior). A well-formed region is ≥2 bytes - // (the count), so a primed cache is never nil. - rosterCacheBytes = nil - } else { - rosterCacheBytes = append(rosterCacheBytes[:0], region...) + case wire.CtxRosterFull: + // Roster-epoch sentinel: full roster at an epoch. Authoritative. + epoch := r.U32() + decodeMembersInto(r, int(r.U16())) + rosterCacheEpoch = epoch + rosterCacheEpochSet = true + rosterCacheBytes = nil // bytes cache is legacy-mode state + changed = true + default: + // Legacy full roster (pre-feature hosts): skim the member section's + // extent without decoding, memcmp against the previous callback's + // bytes, and re-decode only on a real change. + start := r.Off - 2 // include the count in the compared region + for i := 0; i < int(count) && !r.Bad; i++ { + r.SkipStr() // handle + r.SkipStr() // account id + r.SkipStr() // conn + r.U8() // kind + } + region := r.B[start:r.Off] + changed = rosterCacheBytes == nil || !bytes.Equal(region, rosterCacheBytes) + if changed { + rr := &wire.Rd{B: region} + decodeMembersInto(rr, int(rr.U16())) + if r.Bad { + // Malformed member section: don't prime the cache — every + // malformed callback stays "changed" and decodes what it can + // (the pre-cache behavior). A well-formed region is ≥2 bytes + // (the count), so a primed cache is never nil. + rosterCacheBytes = nil + } else { + rosterCacheBytes = append(rosterCacheBytes[:0], region...) + } + rosterCacheEpochSet = false // epoch state is sentinel-mode only } } c.members = rosterCache @@ -95,6 +117,20 @@ func decodeCtx(b []byte) (callContext, *wire.Rd, bool) { return c, r, changed } +// decodeMembersInto re-decodes the member list into the shared rosterCache +// backing array (the only place member strings are allocated). +func decodeMembersInto(r *wire.Rd, n int) { + rosterCache = rosterCache[:0] + for i := 0; i < n && !r.Bad; i++ { + var p Player + p.Handle = r.Str() + p.AccountID = r.Str() + p.Conn = r.Str() + p.Kind = Kind(r.U8()) + rosterCache = append(rosterCache, p) + } +} + // encodeMeta packs GameMeta for the meta export. func encodeMeta(m GameMeta) []byte { wm := wire.Meta{ @@ -132,6 +168,16 @@ func encodeMeta(m GameMeta) []byte { if err := wire.ValidateConfigSpecs(wm.ConfigSpecs); err != nil { panic("kit: invalid GameMeta.Config: " + err.Error()) } + // Large-room trailer: ctx-feature bits + declared heartbeat, validated + // under the same fail-fast posture. + if m.HeartbeatMS < 0 || m.HeartbeatMS > 0xFFFF { + panic("kit: invalid GameMeta.HeartbeatMS: out of range") + } + wm.CtxFeatures = m.CtxFeatures + wm.HeartbeatMS = uint16(m.HeartbeatMS) + if err := wire.ValidateMetaTrailer(wm.CtxFeatures, wm.HeartbeatMS); err != nil { + panic("kit: invalid GameMeta: " + err.Error()) + } return wire.EncodeMeta(wm) } diff --git a/internal/game/devrun.go b/internal/game/devrun.go index d689c72..1226acb 100644 --- a/internal/game/devrun.go +++ b/internal/game/devrun.go @@ -34,6 +34,17 @@ func Main(g Game) { flag.Var(cfgFlag(cfgVals), "config", "KEY=VALUE per-game config (repeatable; value may be @file)") flag.Parse() + // Honor a meta-declared heartbeat unless -heartbeat was given explicitly, + // so authors experience their declared cadence locally (the host applies + // the same declaration with admin-config precedence in production). + if hb := g.Meta().HeartbeatMS; hb > 0 { + explicit := false + flag.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "heartbeat" }) + if !explicit { + *heartbeat = time.Duration(hb) * time.Millisecond + } + } + // -seed makes the whole run reproducible: a fixed RNG seed AND a virtual // clock (see below). Distinguish "flag given" from "left at default 0" so a // deliberate -seed 0 still goes deterministic. diff --git a/internal/game/largeroom_test.go b/internal/game/largeroom_test.go new file mode 100644 index 0000000..e96755e --- /dev/null +++ b/internal/game/largeroom_test.go @@ -0,0 +1,110 @@ +package game + +import ( + "strings" + "testing" + + "github.com/shellcade/kit/v2/wire" +) + +func resetRosterState() { + rosterCache = nil + rosterCacheBytes = nil + rosterCacheEpoch = 0 + rosterCacheEpochSet = false + epochMismatch = false + epochMismatchLogged = false +} + +func epochCtxPayload(epoch uint32, full bool, members ...wire.Player) []byte { + var w wire.Buf + wire.EncodeCtxEpoch(&w, wire.Ctx{ + NowUnixNanos: 1, Seed: 7, SeedSet: true, Capacity: 1000, MinPlayers: 1, + Members: members, + }, epoch, full) + return w.B +} + +// Sentinel-form decode: full form caches at its epoch; unchanged form reuses +// the cache with zero member allocations; a new full form re-decodes. +func TestRosterEpochCache(t *testing.T) { + resetRosterState() + ada := wire.Player{Handle: "ada", AccountID: "a", Conn: "c1", Kind: 1} + bob := wire.Player{Handle: "bob", AccountID: "b", Conn: "c2", Kind: 1} + + c1, _, changed := decodeCtx(epochCtxPayload(1, true, ada)) + if !changed || len(c1.members) != 1 || c1.members[0].AccountID != "a" { + t.Fatalf("full form: changed=%v members=%+v", changed, c1.members) + } + if !rosterCacheEpochSet || rosterCacheEpoch != 1 { + t.Fatalf("cache epoch = %d set=%v", rosterCacheEpoch, rosterCacheEpochSet) + } + + allocs := testing.AllocsPerRun(50, func() { + c, _, ch := decodeCtx(epochCtxPayload(1, false)) + if ch || len(c.members) != 1 { + t.Fatalf("unchanged form: changed=%v members=%d", ch, len(c.members)) + } + }) + // The decode path allocates the callContext/reader bookkeeping but must + // not allocate member strings; allow the small fixed overhead. + if allocs > 4 { + t.Fatalf("unchanged-form decode allocates %v/op — member strings are leaking into the hot path", allocs) + } + + c3, _, changed := decodeCtx(epochCtxPayload(2, true, ada, bob)) + if !changed || len(c3.members) != 2 || c3.members[1].AccountID != "b" { + t.Fatalf("mutated full form: changed=%v members=%+v", changed, c3.members) + } +} + +// An unchanged form whose epoch doesn't match the cache is a host fault: +// flagged for a one-line log, cache retained, baselines invalidated. +func TestRosterEpochMismatchDegrades(t *testing.T) { + resetRosterState() + ada := wire.Player{Handle: "ada", AccountID: "a", Conn: "c1", Kind: 1} + + if _, _, changed := decodeCtx(epochCtxPayload(5, true, ada)); !changed { + t.Fatal("seed full form not marked changed") + } + c, _, changed := decodeCtx(epochCtxPayload(9, false)) // wrong epoch + if !changed { + t.Fatal("mismatch must be conservative (changed=true)") + } + if !epochMismatch { + t.Fatal("mismatch not flagged for logging") + } + if len(c.members) != 1 || c.members[0].AccountID != "a" { + t.Fatalf("cache not retained on mismatch: %+v", c.members) + } +} + +// Meta encode: trailer fields ride through; invalid declarations panic. +func TestMetaTrailerEncode(t *testing.T) { + meta := GameMeta{Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 1000, + CtxFeatures: CtxFeatRosterEpoch, HeartbeatMS: 100} + b := encodeMeta(meta) + wm, err := wire.DecodeMeta(b) + if err != nil { + t.Fatalf("decode: %v", err) + } + if wm.CtxFeatures != wire.CtxFeatRosterEpoch || wm.HeartbeatMS != 100 { + t.Fatalf("trailer = %#x %d", wm.CtxFeatures, wm.HeartbeatMS) + } + + assertPanics := func(name string, m GameMeta, want string) { + t.Helper() + defer func() { + r := recover() + if r == nil { + t.Fatalf("%s: no panic", name) + } + if msg, _ := r.(string); !strings.Contains(msg, want) { + t.Fatalf("%s: panic %q, want substring %q", name, r, want) + } + }() + encodeMeta(m) + } + assertPanics("bad heartbeat", GameMeta{Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 2, HeartbeatMS: 5}, "HeartbeatMS") + assertPanics("unknown feature bit", GameMeta{Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 2, CtxFeatures: 1 << 9}, "undefined bit") +} diff --git a/internal/game/run.go b/internal/game/run.go index b036bba..821efbf 100644 --- a/internal/game/run.go +++ b/internal/game/run.go @@ -59,7 +59,15 @@ func decodeCall() (*room, *wire.Rd) { if rosterChanged { invalidateBaselines() } - return &room{ctx: ctx, rng: rng}, r + rm := &room{ctx: ctx, rng: rng} + if epochMismatch && !epochMismatchLogged { + // Host fault: an unchanged-form ctx carried an epoch we don't hold. + // Degraded (cached roster kept, baselines invalidated) — warn once. + epochMismatchLogged = true + rm.Log("kit: ctx roster epoch mismatch (host fault); using cached roster") + } + epochMismatch = false + return rm, r } func decodePlayer(rm *room, r *wire.Rd) (Player, bool) { diff --git a/internal/game/types.go b/internal/game/types.go index adf2fd8..f045da5 100644 --- a/internal/game/types.go +++ b/internal/game/types.go @@ -7,6 +7,8 @@ // eight //go:export trampolines (`shellcade-kit new` scaffolds exactly this). package game +import "github.com/shellcade/kit/v2/wire" + // ABIVersion is the ABI major version this SDK targets. const ABIVersion uint32 = 2 @@ -183,8 +185,25 @@ type GameMeta struct { // generic editor still works). Declarations are validated at meta encode // time — an invalid spec list is an authoring bug and panics there. Config []ConfigKeySpec + + // CtxFeatures optionally opts the game into negotiated callback + // encodings (the CtxFeat* bits; zero = none, today's behavior). + // Undefined bits are an authoring bug and panic at meta encode time. + CtxFeatures uint32 + + // HeartbeatMS optionally declares the game's preferred wake cadence in + // milliseconds. 0 = no declaration (platform default). The host clamps + // to its envelope and an admin config override always wins; out-of-range + // declarations are an authoring bug and panic at meta encode time. + HeartbeatMS int } +// CtxFeatRosterEpoch opts the game into the ctx roster-epoch encoding: the +// host sends the full member list only when the roster changes (with an +// epoch), and a 6-byte unchanged marker otherwise — the large-room callback +// path. Declare it in GameMeta.CtxFeatures. +const CtxFeatRosterEpoch = wire.CtxFeatRosterEpoch + // Status is a player's terminal outcome. type Status uint8 diff --git a/kit.go b/kit.go index f6601ac..7a85525 100644 --- a/kit.go +++ b/kit.go @@ -93,6 +93,8 @@ const ( HigherBetter = game.HigherBetter LowerBetter = game.LowerBetter + CtxFeatRosterEpoch = game.CtxFeatRosterEpoch + ConfigText = game.ConfigText ConfigNumber = game.ConfigNumber ConfigBool = game.ConfigBool diff --git a/rust/Cargo.lock b/rust/Cargo.lock index f2ff8b0..1316212 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -304,7 +304,7 @@ dependencies = [ [[package]] name = "shellcade-kit" -version = "2.2.0" +version = "2.5.0" dependencies = [ "extism-pdk", ] diff --git a/rust/src/broadcast.rs b/rust/src/broadcast.rs index 643fca4..dbb37b6 100644 --- a/rust/src/broadcast.rs +++ b/rust/src/broadcast.rs @@ -15,9 +15,10 @@ use crate::rng::SplitMix64; use crate::types::Player; /// Fixed roster ceiling for per-index baselines. At 24-byte cells the table is -/// (ROSTER_CAP + broadcast) × FRAME_BYTES ≈ 0.78 MB of linear memory, far +/// 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. -pub(crate) const ROSTER_CAP: usize = 16; +pub(crate) const ROSTER_CAP: usize = 1024; /// The broadcast (`identical`) slot index within the baseline table. pub(crate) const BROADCAST_SLOT: usize = ROSTER_CAP; const SLOTS: usize = ROSTER_CAP + 1; @@ -27,34 +28,38 @@ const SLOTS: usize = ROSTER_CAP + 1; /// `RefCell` (fully safe; wasm32-wasip1 is single-threaded and callbacks are /// serial per ABI §1 — a violation is a contained borrow panic, never UB). pub(crate) struct SdkState { - /// SLOTS × FRAME_BYTES, lazily allocated on the first send. - baselines: Vec, - epoch: [u32; SLOTS], - present: [bool; SLOTS], + /// Per-slot baselines (FRAME_BYTES each), lazily allocated on a slot's + /// first commit so memory tracks the active roster, not ROSTER_CAP. + baselines: Vec>, + epoch: Vec, + present: Vec, /// Reused pack scratch for the current frame (FRAME_BYTES). packed: Vec, /// Reused delta-container scratch (KEYFRAME_BYTES worst case). scratch: Vec, pub rng: Option, last_roster: Option, + /// Roster-epoch mode cache: the members decoded at the last full form, + /// shared into each callback's CallCtx with zero member allocations. + pub roster_cache: Option<(u32, std::rc::Rc>)>, } impl SdkState { pub fn new() -> Self { SdkState { - baselines: Vec::new(), - epoch: [0; SLOTS], - present: [false; SLOTS], + baselines: vec![Vec::new(); SLOTS], + epoch: vec![0; SLOTS], + present: vec![false; SLOTS], packed: Vec::new(), scratch: Vec::new(), rng: None, last_roster: None, + roster_cache: None, } } fn ensure_buffers(&mut self) { - if self.baselines.is_empty() { - self.baselines = vec![0u8; SLOTS * FRAME_BYTES]; + if self.packed.is_empty() { self.packed = vec![0u8; FRAME_BYTES]; self.scratch = vec![0u8; KEYFRAME_BYTES]; } @@ -62,14 +67,14 @@ impl SdkState { #[cfg(test)] fn baseline(&self, slot: usize) -> &[u8] { - &self.baselines[slot * FRAME_BYTES..(slot + 1) * FRAME_BYTES] + &self.baselines[slot] } /// Clear every present flag, forcing the next send to each slot to a /// keyframe — called on any roster change (indices renumber; the host /// clears its caches and the guest mirrors). pub fn invalidate_baselines(&mut self) { - self.present = [false; SLOTS]; + self.present.fill(false); } /// The roster-change backstop run at every callback decode: a cheap @@ -102,9 +107,11 @@ impl SdkState { let sent_epoch = self.epoch[slot]; let was_delta = self.present[slot]; - let base = slot * FRAME_BYTES; + // present implies the slot buffer was allocated by a prior commit; + // the keyframe path never reads the baseline, so an empty slice is + // safe when !was_delta. let n = encode( - &self.baselines[base..base + FRAME_BYTES], + &self.baselines[slot], &self.packed, &mut self.scratch, sent_epoch, @@ -116,7 +123,7 @@ impl SdkState { // Rejected delta (hibernation restore, baseline loss): resync to // the host's epoch and retry this same frame as a keyframe. let n = encode( - &self.baselines[base..base + FRAME_BYTES], + &self.baselines[slot], &self.packed, &mut self.scratch, returned, @@ -128,15 +135,25 @@ impl SdkState { // Adopt the baseline + epoch: the host now holds this exact frame. self.commit(slot, returned); if slot == BROADCAST_SLOT { + // Reconcile only ALLOCATED per-index slots — materializing all + // ROSTER_CAP would copy ~45 MiB per broadcast at cap 1024. A + // skipped slot stays not-present and recovers via its first + // per-player send opening with a keyframe (mirrors the host). for i in 0..ROSTER_CAP { - self.commit(i, returned); + if self.baselines[i].is_empty() { + self.present[i] = false; + } else { + self.commit(i, returned); + } } } } fn commit(&mut self, slot: usize, returned_epoch: u32) { - let base = slot * FRAME_BYTES; - self.baselines[base..base + FRAME_BYTES].copy_from_slice(&self.packed); + if self.baselines[slot].is_empty() { + self.baselines[slot] = vec![0u8; FRAME_BYTES]; + } + self.baselines[slot].copy_from_slice(&self.packed); self.epoch[slot] = returned_epoch; self.present[slot] = true; } @@ -250,12 +267,16 @@ mod tests { st.send_slot(0, &frame_with("p0 view")); st.send_slot(1, &frame_with("p1 view")); - // Broadcast: every per-index baseline must now equal the broadcast frame. + // Broadcast: every ALLOCATED per-index baseline must now equal the + // broadcast frame (lazy contract: never-sent slots are NOT + // materialized — they stay not-present and recover via their first + // per-player send opening with a keyframe, mirroring the host). st.send_slot(BROADCAST_SLOT, &frame_with("everyone")); - for slot in 0..ROSTER_CAP { + for slot in [0usize, 1] { assert!(st.slot_baseline_equals_packed(slot), "slot {slot} reconciled"); assert!(st.slot_state(slot).1); } + assert!(!st.slot_state(2).1, "never-sent slot must stay not-present"); // A per-player send after the broadcast diffs against the broadcast // frame: it must be a small delta, not a keyframe. diff --git a/rust/src/lib.rs b/rust/src/lib.rs index bf4b05c..d18d614 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -89,7 +89,7 @@ pub use input::{Action, Input, InputContext, Key}; pub use room::Room; pub use types::{ Aggregation, ConfigKeySpec, ConfigType, Direction, Kind, Leaderboard, MergeRule, Meta, - MetricFormat, Mode, Outcome, Player, PlayerResult, RoomConfig, Status, + MetricFormat, Mode, Outcome, Player, PlayerResult, RoomConfig, Status, CTX_FEAT_ROSTER_EPOCH, }; // Native-only scriptable host double for `cargo test` of games and the SDK diff --git a/rust/src/room.rs b/rust/src/room.rs index 02ba1af..4fa34f3 100644 --- a/rust/src/room.rs +++ b/rust/src/room.rs @@ -176,8 +176,10 @@ mod tests { ctx: CallCtx { now_unix_nanos: 1, cfg: RoomConfig { seed: 99, ..Default::default() }, - members, + members: std::rc::Rc::new(members), settled: false, + roster_epoch: None, + roster_unchanged: false, }, } } diff --git a/rust/src/rt.rs b/rust/src/rt.rs index 453a35c..ed6cb94 100644 --- a/rust/src/rt.rs +++ b/rust/src/rt.rs @@ -27,15 +27,44 @@ pub const ABI_VERSION: u32 = 2; /// borrow panic, never UB). pub type HandlerCell = LocalKey>>>; -/// Decode a callback: CallContext + roster backstop + PRNG seeding. +/// Decode a callback: CallContext + roster cache/backstop + PRNG seeding. fn decode_call(input: &[u8]) -> (Room, Rd<'_>) { - let (ctx, r) = decode_ctx(input); + let (mut ctx, r) = decode_ctx(input); STATE.with(|s| { let mut st = s.borrow_mut(); if st.rng.is_none() { st.rng = Some(SplitMix64::new(ctx.cfg.seed)); } - st.roster_gate(&ctx.members); + match ctx.roster_epoch { + Some(epoch) if ctx.roster_unchanged => { + // Roster-epoch unchanged form: reuse the cached members with + // zero allocations. An epoch mismatch is a host fault — + // degrade to the cached roster, warn once, force keyframes. + match &st.roster_cache { + Some((cached, members)) if *cached == epoch => { + ctx.members = std::rc::Rc::clone(members); + } + Some((_, members)) => { + ctx.members = std::rc::Rc::clone(members); + st.invalidate_baselines(); + crate::host::host_log(2, "kit: ctx roster epoch mismatch (host fault); using cached roster"); + } + None => { + st.invalidate_baselines(); + crate::host::host_log(2, "kit: unchanged roster before any full roster (host fault)"); + } + } + } + Some(epoch) => { + // Full form: authoritative — cache and invalidate baselines. + st.roster_cache = Some((epoch, std::rc::Rc::clone(&ctx.members))); + st.invalidate_baselines(); + } + None => { + // Legacy form: the fingerprint backstop detects changes. + st.roster_gate(&ctx.members); + } + } }); (Room { ctx }, r) } diff --git a/rust/src/types.rs b/rust/src/types.rs index 7423afa..4c76486 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -216,8 +216,31 @@ pub struct Meta { /// at `meta()` encode time — an invalid declaration is an authoring bug /// and panics there. pub config: &'static [ConfigKeySpec], + + /// Negotiated callback-encoding feature bits (`CTX_FEAT_*`; 0 = none, + /// today's behavior). Undefined bits are an authoring bug and panic at + /// `meta()` encode time. + pub ctx_features: u32, + + /// Preferred wake cadence in milliseconds (0 = platform default). The + /// host clamps to its envelope and an admin override always wins; + /// out-of-range declarations panic at `meta()` encode time. + pub heartbeat_ms: u16, } +/// Opts the game into the ctx roster-epoch encoding: the host sends the full +/// member list only when the roster changes (with an epoch) and a 6-byte +/// unchanged marker otherwise — the large-room callback path. Declare it in +/// [`Meta::ctx_features`]. +pub const CTX_FEAT_ROSTER_EPOCH: u32 = 1 << 0; + +/// The feature bits this SDK revision defines. +pub(crate) const KNOWN_CTX_FEATURES: u32 = CTX_FEAT_ROSTER_EPOCH; + +/// Heartbeat declaration envelope (mirrors the host clamp range). +pub(crate) const HEARTBEAT_MIN_MS: u16 = 20; +pub(crate) const HEARTBEAT_MAX_MS: u16 = 1000; + impl Meta { /// The all-defaults Meta for `..Meta::DEFAULT` struct updates /// (`..Default::default()` is not usable in const context). @@ -233,6 +256,8 @@ impl Meta { private_invite_line: "", leaderboard: None, config: &[], + ctx_features: 0, + heartbeat_ms: 0, }; } diff --git a/rust/src/wire.rs b/rust/src/wire.rs index b22eb96..6dcbc96 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -87,14 +87,27 @@ impl<'a> Rd<'a> { } } +// Ctx member-section sentinels (roster-epoch mode, ABI.md §4.1 minor): real +// rosters are capped far below these values, so the count u16 disambiguates. +pub(crate) const CTX_ROSTER_UNCHANGED: u16 = 0xFFFF; +pub(crate) const CTX_ROSTER_FULL: u16 = 0xFFFE; + // ---- CallContext (§4.1) ------------------------------------------------------ -/// The decoded per-callback room state. +/// The decoded per-callback room state. `members` is shared with the SDK's +/// roster cache (`Rc`): on the roster-epoch unchanged form the cached roster +/// is reused with zero member allocations. pub(crate) struct CallCtx { pub now_unix_nanos: i64, pub cfg: RoomConfig, - pub members: Vec, + pub members: std::rc::Rc>, pub settled: bool, + /// Roster-epoch mode: the epoch carried by a sentinel-form member + /// section (None = legacy full-roster form). + pub roster_epoch: Option, + /// True when the sentinel said "unchanged" — `members` is left empty by + /// the decoder and the caller resolves it from the cache. + pub roster_unchanged: bool, } /// Decode the CallContext prefix and return it plus the reader positioned at @@ -111,7 +124,36 @@ pub(crate) fn decode_ctx(input: &[u8]) -> (CallCtx, Rd<'_>) { }; let capacity = r.u16() as usize; let min_players = r.u16() as usize; - let n = r.u16() as usize; + let count = r.u16(); + let (members, roster_epoch, roster_unchanged) = match count { + CTX_ROSTER_UNCHANGED => { + // Sentinel: epoch only — the caller resolves members from the + // SDK roster cache (zero member allocations here). + let epoch = r.u32(); + (Vec::new(), Some(epoch), true) + } + CTX_ROSTER_FULL => { + let epoch = r.u32(); + let n = r.u16() as usize; + (decode_members(&mut r, n), Some(epoch), false) + } + n => (decode_members(&mut r, n as usize), None, false), + }; + let settled = r.u8() != 0; + ( + CallCtx { + now_unix_nanos: now, + cfg: RoomConfig { mode, capacity, min_players, seed, seed_set }, + members: std::rc::Rc::new(members), + settled, + roster_epoch, + roster_unchanged, + }, + r, + ) +} + +fn decode_members(r: &mut Rd<'_>, n: usize) -> Vec { let mut members = Vec::with_capacity(n.min(64)); for _ in 0..n { let handle = r.string(); @@ -123,16 +165,7 @@ pub(crate) fn decode_ctx(input: &[u8]) -> (CallCtx, Rd<'_>) { } members.push(Player { handle, account_id, conn, kind }); } - let settled = r.u8() != 0; - ( - CallCtx { - now_unix_nanos: now, - cfg: RoomConfig { mode, capacity, min_players, seed, seed_set }, - members, - settled, - }, - r, - ) + members } // ---- Meta (§4.2) --------------------------------------------------------------- @@ -178,9 +211,34 @@ pub(crate) fn encode_meta(m: &Meta) -> Vec { w.str(cs.default); w.str(cs.schema); } + // Trailing large-room section (ABI.md §4.2, spec minor): ctx-features + // bitset + declared heartbeat. Always written; validated here under the + // same fail-fast posture as config specs. + if let Err(e) = validate_meta_trailer(m.ctx_features, m.heartbeat_ms) { + panic!("shellcade-kit: invalid Meta: {e}"); + } + w.u32(m.ctx_features); + w.u16(m.heartbeat_ms); w.b } +/// The authoring rules for the large-room meta trailer, mirroring Go's +/// `wire.ValidateMetaTrailer`: no undefined ctx-feature bits; heartbeat 0 or +/// within the platform envelope. +pub(crate) fn validate_meta_trailer(ctx_features: u32, heartbeat_ms: u16) -> Result<(), String> { + use crate::types::{HEARTBEAT_MAX_MS, HEARTBEAT_MIN_MS, KNOWN_CTX_FEATURES}; + let unknown = ctx_features & !KNOWN_CTX_FEATURES; + if unknown != 0 { + return Err(format!("ctx_features declares undefined bit(s) {unknown:#x}")); + } + if heartbeat_ms != 0 && !(HEARTBEAT_MIN_MS..=HEARTBEAT_MAX_MS).contains(&heartbeat_ms) { + return Err(format!( + "heartbeat_ms {heartbeat_ms} outside 0 or [{HEARTBEAT_MIN_MS},{HEARTBEAT_MAX_MS}]" + )); + } + Ok(()) +} + /// The authoring rules for declared config specs (ABI.md §4.2), mirroring Go's /// `wire.ValidateConfigSpecs`: keys non-empty and unique, no reserved `host.` /// prefix, and `schema` only on `Json`-typed keys where it must itself be @@ -405,7 +463,7 @@ mod tests { ], ..Meta::DEFAULT }; - let golden = "0600676f6c64656e0600476f6c64656e0e00676f6c64656e206669787475726501000400020001006101006200000000000001050073636f726501000202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e0000000000"; + let golden = "0600676f6c64656e0600476f6c64656e0e00676f6c64656e206669787475726501000400020001006101006200000000000001050073636f726501000202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e0000000000000000000000"; 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"); } @@ -465,4 +523,80 @@ mod tests { assert_eq!(r.u16(), 2); assert_eq!(r.u8(), 1); // DNF } + + /// Sentinel forms: full carries epoch + members; unchanged carries only + /// the epoch and leaves the reader at the event extras. + #[test] + fn ctx_sentinel_forms_decode() { + // Hand-build a full-form payload (host-side encoding lives in the + // engine; the bytes are pinned by ABI.md §4.1). + let mut w = Buf::new(); + w.i64(9); // now + w.i64(7); // seed + w.u8(1); // seed_set + w.u8(0); // mode quick + w.u16(1000); // capacity + w.u16(1); // min players + w.u16(CTX_ROSTER_FULL); + w.u32(42); // epoch + w.u16(1); // real count + w.str("ada"); + w.str("a"); + w.str("c1"); + w.u8(1); // kind member + w.u8(0); // settled + w.u8(0xAB); // event extra + let (ctx, mut r) = decode_ctx(&w.b); + assert_eq!(ctx.roster_epoch, Some(42)); + assert!(!ctx.roster_unchanged); + assert_eq!(ctx.members.len(), 1); + assert_eq!(ctx.members[0].account_id, "a"); + assert_eq!(r.u8(), 0xAB, "event extras misaligned"); + + let mut w = Buf::new(); + w.i64(9); + w.i64(7); + w.u8(1); + w.u8(0); + w.u16(1000); + w.u16(1); + w.u16(CTX_ROSTER_UNCHANGED); + w.u32(43); + w.u8(0); // settled + w.u8(0xCD); + let (ctx, mut r) = decode_ctx(&w.b); + assert_eq!(ctx.roster_epoch, Some(43)); + assert!(ctx.roster_unchanged); + assert!(ctx.members.is_empty()); + assert_eq!(r.u8(), 0xCD, "event extras misaligned"); + } + + #[test] + fn meta_trailer_validation() { + assert!(validate_meta_trailer(0, 0).is_ok()); + assert!(validate_meta_trailer(crate::types::CTX_FEAT_ROSTER_EPOCH, 100).is_ok()); + assert!(validate_meta_trailer(1 << 9, 0).is_err(), "undefined bit"); + assert!(validate_meta_trailer(0, 5).is_err(), "below envelope"); + assert!(validate_meta_trailer(0, 1500).is_err(), "above envelope"); + } + + /// The large-room trailer golden: Go `wire.EncodeMeta` output for a meta + /// declaring the roster-epoch feature and a 100ms heartbeat. + #[test] + fn meta_trailer_matches_go_encoding() { + let m = Meta { + slug: "lr", + name: "LR", + short_description: "", + min_players: 1, + max_players: 1000, + ctx_features: crate::types::CTX_FEAT_ROSTER_EPOCH, + heartbeat_ms: 100, + ..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("0000010000006400"), "trailer bytes diverge from the Go encoding: ...{}", &got[got.len()-16..]); + } } + diff --git a/wire/largeroom_test.go b/wire/largeroom_test.go new file mode 100644 index 0000000..83fc781 --- /dev/null +++ b/wire/largeroom_test.go @@ -0,0 +1,150 @@ +package wire + +import ( + "bytes" + "testing" +) + +func lrPlayers() []Player { + return []Player{ + {Handle: "ada", AccountID: "a", Conn: "c1", Kind: 1}, + {Handle: "bob", AccountID: "b", Conn: "c2", Kind: 1}, + } +} + +func lrCtx() Ctx { + return Ctx{NowUnixNanos: 9, Seed: 7, SeedSet: true, Mode: 0, Capacity: 1000, MinPlayers: 1, Members: lrPlayers()} +} + +// The full sentinel form round-trips members + epoch and leaves the reader at +// the event extras. +func TestCtxEpochFullRoundTrip(t *testing.T) { + var w Buf + EncodeCtxEpoch(&w, lrCtx(), 42, true) + w.U8(0xAB) // event extra + + r := &Rd{B: w.B} + c := DecodeCtx(r) + if !c.RosterEpochSet || c.RosterUnchanged || c.RosterEpoch != 42 { + t.Fatalf("full form decoded epoch=%d set=%v unchanged=%v", c.RosterEpoch, c.RosterEpochSet, c.RosterUnchanged) + } + if len(c.Members) != 2 || c.Members[1].AccountID != "b" { + t.Fatalf("members = %+v", c.Members) + } + if got := r.U8(); got != 0xAB || r.Bad { + t.Fatalf("event extras misaligned: got %#x bad=%v", got, r.Bad) + } +} + +// The unchanged sentinel form carries only the epoch (6-byte member section) +// and leaves the reader at the event extras. +func TestCtxEpochUnchangedRoundTrip(t *testing.T) { + var w Buf + EncodeCtxEpoch(&w, lrCtx(), 43, false) + w.U8(0xCD) + + r := &Rd{B: w.B} + c := DecodeCtx(r) + if !c.RosterEpochSet || !c.RosterUnchanged || c.RosterEpoch != 43 { + t.Fatalf("unchanged form decoded epoch=%d set=%v unchanged=%v", c.RosterEpoch, c.RosterEpochSet, c.RosterUnchanged) + } + if c.Members != nil { + t.Fatalf("unchanged form decoded members: %+v", c.Members) + } + if got := r.U8(); got != 0xCD || r.Bad { + t.Fatalf("event extras misaligned: got %#x bad=%v", got, r.Bad) + } + + // The member section is exactly count(2) + epoch(4) = 6 bytes: the + // unchanged encoding must not scale with roster size. + var small, large Buf + EncodeCtxEpoch(&small, Ctx{Members: nil}, 1, false) + bigCtx := Ctx{Members: make([]Player, 500)} + EncodeCtxEpoch(&large, bigCtx, 1, false) + if len(small.B) != len(large.B) { + t.Fatalf("unchanged form scales with roster: %d vs %d bytes", len(small.B), len(large.B)) + } +} + +// The legacy form is byte-identical to the pre-sentinel encoding and decodes +// with no epoch fields set. +func TestCtxLegacyUnchangedBytes(t *testing.T) { + var w Buf + EncodeCtx(&w, lrCtx()) + + // Hand-build the historical encoding. + var h Buf + h.I64(9) + h.I64(7) + h.Bool(true) + h.U8(0) + h.U16(1000) + h.U16(1) + h.U16(2) + for _, p := range lrPlayers() { + h.Str(p.Handle) + h.Str(p.AccountID) + h.Str(p.Conn) + h.U8(p.Kind) + } + h.Bool(false) + if !bytes.Equal(w.B, h.B) { + t.Fatal("legacy EncodeCtx is not byte-identical to the historical encoding") + } + + c := DecodeCtx(&Rd{B: w.B}) + if c.RosterEpochSet || c.RosterUnchanged { + t.Fatalf("legacy decode set epoch fields: %+v", c) + } + if len(c.Members) != 2 { + t.Fatalf("legacy decode members = %+v", c.Members) + } +} + +// The meta trailer round-trips, and a payload truncated before it decodes as +// an older meta with zero values. +func TestMetaTrailerRoundTrip(t *testing.T) { + m := Meta{Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 1000, + CtxFeatures: CtxFeatRosterEpoch, HeartbeatMS: 100} + b := EncodeMeta(m) + got, err := DecodeMeta(b) + if err != nil { + t.Fatalf("decode: %v", err) + } + if got.CtxFeatures != CtxFeatRosterEpoch || got.HeartbeatMS != 100 { + t.Fatalf("trailer round-trip = features %#x heartbeat %d", got.CtxFeatures, got.HeartbeatMS) + } + + // Pre-trailer payload: chop the trailing 6 bytes (u32 + u16). + old := b[:len(b)-6] + got, err = DecodeMeta(old) + if err != nil { + t.Fatalf("pre-trailer decode: %v", err) + } + if got.CtxFeatures != 0 || got.HeartbeatMS != 0 { + t.Fatalf("pre-trailer payload decoded nonzero trailer: %#x %d", got.CtxFeatures, got.HeartbeatMS) + } +} + +func TestValidateMetaTrailer(t *testing.T) { + cases := []struct { + features uint32 + hb uint16 + ok bool + }{ + {0, 0, true}, + {CtxFeatRosterEpoch, 0, true}, + {CtxFeatRosterEpoch, 100, true}, + {0, 20, true}, + {0, 1000, true}, + {1 << 7, 0, false}, // undefined feature bit + {0, 5, false}, // below envelope + {0, 1500, false}, // above envelope + } + for _, c := range cases { + err := ValidateMetaTrailer(c.features, c.hb) + if (err == nil) != c.ok { + t.Errorf("ValidateMetaTrailer(%#x, %d) err=%v, want ok=%v", c.features, c.hb, err, c.ok) + } + } +} diff --git a/wire/wire.go b/wire/wire.go index be95376..14efb22 100644 --- a/wire/wire.go +++ b/wire/wire.go @@ -101,8 +101,39 @@ type Ctx struct { MinPlayers uint16 Members []Player Settled bool + + // Roster-epoch mode (spec minor addition; emitted only to guests whose + // meta declares CtxFeatRosterEpoch). RosterEpochSet marks a sentinel-form + // member section: RosterUnchanged means the section carried only the + // epoch (Members is nil — the guest reuses its cached roster); otherwise + // Members is the full roster at RosterEpoch. + RosterEpoch uint32 + RosterEpochSet bool + RosterUnchanged bool } +// Ctx member-section sentinels (roster-epoch mode). Real rosters are capped +// far below these values, so the count u16 disambiguates the three forms: +// 0..CtxRosterMaxCount = legacy full roster (no epoch), CtxRosterFull = u32 +// epoch + u16 real count + members, CtxRosterUnchanged = u32 epoch only. +const ( + CtxRosterUnchanged uint16 = 0xFFFF + CtxRosterFull uint16 = 0xFFFE + CtxRosterMaxCount uint16 = 0xFFFD +) + +// CtxFeatures bits a game may declare in its meta trailer. The host ignores +// bits it does not implement; the SDKs reject bits they do not define. +const ( + // CtxFeatRosterEpoch opts the guest into the ctx member-section sentinel + // forms: full roster only on change (with an epoch), 6-byte unchanged + // sections otherwise. + CtxFeatRosterEpoch uint32 = 1 << 0 + + // KnownCtxFeatures is the mask of bits this wire revision defines. + KnownCtxFeatures uint32 = CtxFeatRosterEpoch +) + // Meta is the packed GameMeta. type Meta struct { Slug string @@ -126,6 +157,17 @@ type Meta struct { // the section (count 0 when empty); decoders treat a payload ending after // the leaderboard block as a valid pre-config meta with no specs. ConfigSpecs []ConfigSpec + + // CtxFeatures + HeartbeatMS form the trailing large-room section (spec + // minor addition) after the config-spec section. CtxFeatures is the + // negotiated-encoding bitset (CtxFeat*); HeartbeatMS is the game's + // declared wake cadence (0 = no declaration; host precedence: admin + // config > declaration > platform default, clamped to the platform + // envelope). Encoders always write the section; decoders treat a payload + // ending after the config-spec section as a valid older meta with zero + // values. + CtxFeatures uint32 + HeartbeatMS uint16 } // Config value type codes (how the admin surface renders/validates a value). @@ -278,25 +320,57 @@ func (r *Rd) Err() error { // ---- CallContext --------------------------------------------------------------- -// EncodeCtx appends the packed CallContext to w. +// EncodeCtx appends the packed CallContext to w in the LEGACY form (full +// roster, no epoch) — the only form pre-roster-epoch guests understand, and +// byte-identical to all prior wire revisions. func EncodeCtx(w *Buf, c Ctx) { + encodeCtxHeader(w, c) + w.U16(uint16(len(c.Members))) + encodeCtxMembers(w, c.Members) + w.Bool(c.Settled) +} + +// EncodeCtxEpoch appends the packed CallContext in roster-epoch mode (only +// for guests whose meta declares CtxFeatRosterEpoch). full=true emits the +// CtxRosterFull sentinel (epoch + real count + members); full=false emits +// the CtxRosterUnchanged sentinel (epoch only — the member section is 6 +// bytes regardless of roster size, and c.Members is not read). +func EncodeCtxEpoch(w *Buf, c Ctx, epoch uint32, full bool) { + encodeCtxHeader(w, c) + if full { + w.U16(CtxRosterFull) + w.U32(epoch) + w.U16(uint16(len(c.Members))) + encodeCtxMembers(w, c.Members) + } else { + w.U16(CtxRosterUnchanged) + w.U32(epoch) + } + w.Bool(c.Settled) +} + +func encodeCtxHeader(w *Buf, c Ctx) { w.I64(c.NowUnixNanos) w.I64(c.Seed) w.Bool(c.SeedSet) w.U8(c.Mode) w.U16(c.Capacity) w.U16(c.MinPlayers) - w.U16(uint16(len(c.Members))) - for _, p := range c.Members { +} + +func encodeCtxMembers(w *Buf, members []Player) { + for _, p := range members { w.Str(p.Handle) w.Str(p.AccountID) w.Str(p.Conn) w.U8(p.Kind) } - w.Bool(c.Settled) } // DecodeCtx reads a CallContext, leaving r positioned at the event extras. +// It recognises all three member-section forms; on the unchanged sentinel +// Members is nil and RosterUnchanged is true (the caller supplies its cached +// roster). func DecodeCtx(r *Rd) Ctx { var c Ctx c.NowUnixNanos = r.I64() @@ -305,17 +379,34 @@ func DecodeCtx(r *Rd) Ctx { c.Mode = r.U8() c.Capacity = r.U16() c.MinPlayers = r.U16() - n := int(r.U16()) + count := r.U16() + switch count { + case CtxRosterUnchanged: + c.RosterEpoch = r.U32() + c.RosterEpochSet = true + c.RosterUnchanged = true + case CtxRosterFull: + c.RosterEpoch = r.U32() + c.RosterEpochSet = true + c.Members = decodeCtxMembers(r, int(r.U16())) + default: + c.Members = decodeCtxMembers(r, int(count)) + } + c.Settled = r.Bool() + return c +} + +func decodeCtxMembers(r *Rd, n int) []Player { + var members []Player for i := 0; i < n && !r.Bad; i++ { var p Player p.Handle = r.Str() p.AccountID = r.Str() p.Conn = r.Str() p.Kind = r.U8() - c.Members = append(c.Members, p) + members = append(members, p) } - c.Settled = r.Bool() - return c + return members } // ---- GameMeta ------------------------------------------------------------------- @@ -354,6 +445,10 @@ func EncodeMeta(m Meta) []byte { w.Str(cs.Default) w.Str(cs.Schema) } + // Trailing large-room section (spec minor addition): ctx-features bitset + // + declared heartbeat. Always written; older decoders ignore the bytes. + w.U32(m.CtxFeatures) + w.U16(m.HeartbeatMS) return w.B } @@ -395,6 +490,12 @@ func DecodeMeta(b []byte) (Meta, error) { m.ConfigSpecs = append(m.ConfigSpecs, cs) } } + // Trailing large-room section, presence-guarded: a payload that ends + // here is a valid older meta declaring no ctx features and no heartbeat. + if !r.Bad && r.Off < len(r.B) { + m.CtxFeatures = r.U32() + m.HeartbeatMS = r.U16() + } if err := r.Err(); err != nil { return Meta{}, err } @@ -443,6 +544,26 @@ func ValidateConfigSpecs(specs []ConfigSpec) error { return nil } +// Heartbeat declaration envelope (mirrors the host's clamp range). +const ( + HeartbeatMinMS uint16 = 20 + HeartbeatMaxMS uint16 = 1000 +) + +// ValidateMetaTrailer is the shared authoring rule set for the large-room +// meta section, enforced at meta() encode time by both SDKs (the same +// fail-fast posture as ValidateConfigSpecs): no undefined ctx-feature bits; +// heartbeat 0 (no declaration) or within [HeartbeatMinMS, HeartbeatMaxMS]. +func ValidateMetaTrailer(ctxFeatures uint32, heartbeatMS uint16) error { + if unknown := ctxFeatures &^ KnownCtxFeatures; unknown != 0 { + return fmt.Errorf("wire: CtxFeatures declares undefined bit(s) %#x", unknown) + } + if heartbeatMS != 0 && (heartbeatMS < HeartbeatMinMS || heartbeatMS > HeartbeatMaxMS) { + return fmt.Errorf("wire: HeartbeatMS %d outside 0 or [%d,%d]", heartbeatMS, HeartbeatMinMS, HeartbeatMaxMS) + } + return nil +} + // ---- Frames ---------------------------------------------------------------------- // PutCell writes one cell at index i (0..FrameCells-1) into a FrameBytes buffer