From 9052e5f4f49f13392f651e7837378c7948eb0d9b Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Thu, 11 Jun 2026 07:17:52 +1000 Subject: [PATCH 1/9] wire: per-member character section behind CtxFeatCharacter str glyph + ink/bg RGB + ascii fallback after each member's kind byte, in both member-bearing forms, iff the guest's meta declares the feature. EncodeCtx/EncodeCtxEpoch/DecodeCtx stay frozen features=0 wrappers. Co-Authored-By: Claude Fable 5 --- wire/character_test.go | 98 ++++++++++++++++++++++++++++++++++++++++++ wire/wire.go | 90 ++++++++++++++++++++++++++++++++------ 2 files changed, 176 insertions(+), 12 deletions(-) create mode 100644 wire/character_test.go diff --git a/wire/character_test.go b/wire/character_test.go new file mode 100644 index 0000000..466e496 --- /dev/null +++ b/wire/character_test.go @@ -0,0 +1,98 @@ +package wire + +import ( + "bytes" + "testing" +) + +func charPlayers() []Player { + return []Player{ + {Handle: "ana", AccountID: "a-1", Conn: "c-1", Kind: KindMember, + Character: Character{Glyph: "~", InkR: 0x39, InkG: 0xFF, InkB: 0x14, BgR: 0x2D, BgG: 0x1B, BgB: 0x4E, Fallback: '~'}}, + {Handle: "bob", AccountID: "a-2", Conn: "c-2", Kind: KindGuest, + Character: Character{Glyph: "@", InkR: 1, InkG: 2, InkB: 3, BgR: 4, BgG: 5, BgB: 6, Fallback: '@'}}, + } +} + +// Feature-off encoding must be byte-identical to the legacy encoder even when +// Character fields are populated (non-declaring guests see v2.8 bytes). +func TestCtxFeatureOffBytesUnchanged(t *testing.T) { + c := Ctx{NowUnixNanos: 9, Seed: 7, SeedSet: true, Mode: 1, Capacity: 4, MinPlayers: 2, + Members: charPlayers(), Settled: false} + var legacy, feat0 Buf + EncodeCtx(&legacy, c) + EncodeCtxFeat(&feat0, c, 0) + if !bytes.Equal(legacy.B, feat0.B) { + t.Fatal("EncodeCtxFeat(features=0) is not byte-identical to EncodeCtx") + } + stripped := c + stripped.Members = append([]Player(nil), c.Members...) + for i := range stripped.Members { + stripped.Members[i].Character = Character{} + } + var plain Buf + EncodeCtx(&plain, stripped) + if !bytes.Equal(legacy.B, plain.B) { + t.Fatal("populated Character leaked into the feature-off encoding") + } +} + +// Round trip with the feature declared, in both member-bearing forms, plus the +// unchanged sentinel. +func TestCtxCharacterRoundTrip(t *testing.T) { + c := Ctx{NowUnixNanos: 9, Seed: 7, SeedSet: true, Mode: 1, Capacity: 4, MinPlayers: 2, + Members: charPlayers(), Settled: true} + + var w Buf + EncodeCtxFeat(&w, c, CtxFeatCharacter) + got := DecodeCtxFeat(&Rd{B: w.B}, CtxFeatCharacter) + if len(got.Members) != 2 || got.Members[0].Character != c.Members[0].Character || + got.Members[1].Character != c.Members[1].Character { + t.Fatalf("legacy-form round trip lost character data: %+v", got.Members) + } + + feats := CtxFeatCharacter | CtxFeatRosterEpoch + var we Buf + EncodeCtxEpochFeat(&we, c, 42, true, feats) + gote := DecodeCtxFeat(&Rd{B: we.B}, feats) + if !gote.RosterEpochSet || gote.RosterEpoch != 42 { + t.Fatalf("epoch lost: %+v", gote) + } + if len(gote.Members) != 2 || gote.Members[1].Character != c.Members[1].Character { + t.Fatalf("epoch-form round trip lost character data: %+v", gote.Members) + } + + var wu Buf + EncodeCtxEpochFeat(&wu, c, 42, false, feats) + gotu := DecodeCtxFeat(&Rd{B: wu.B}, feats) + if !gotu.RosterUnchanged || len(gotu.Members) != 0 { + t.Fatalf("unchanged-form wrong: %+v", gotu) + } +} + +// A declaring decode of a feature-off payload must fail loudly (short read / +// Bad flag), never silently misparse — host-fault contract. +func TestCtxCharacterDecodeMismatchSetsBad(t *testing.T) { + c := Ctx{Members: charPlayers()} + var w Buf + EncodeCtx(&w, c) + r := &Rd{B: w.B} + DecodeCtxFeat(r, CtxFeatCharacter) + if r.Err() == nil { + t.Fatal("mismatched decode did not set the error state") + } +} + +// The meta trailer accepts the new known bit and still rejects unknown bits. +func TestMetaTrailerAcceptsCharacterFeature(t *testing.T) { + if err := ValidateMetaTrailer(CtxFeatCharacter, 0); err != nil { + t.Fatalf("CtxFeatCharacter rejected by ValidateMetaTrailer: %v", err) + } + if err := ValidateMetaTrailer(CtxFeatCharacter|CtxFeatRosterEpoch, 100); err != nil { + t.Fatalf("CtxFeatCharacter|CtxFeatRosterEpoch rejected: %v", err) + } + // Unknown bit must still be rejected. + if err := ValidateMetaTrailer(1<<7, 0); err == nil { + t.Fatal("unknown bit not rejected by ValidateMetaTrailer") + } +} diff --git a/wire/wire.go b/wire/wire.go index b77169c..98711f7 100644 --- a/wire/wire.go +++ b/wire/wire.go @@ -125,12 +125,27 @@ const ( InputKey uint8 = 1 ) +// Character is the per-player resolved character, encoded after the Kind byte +// in every member-bearing Ctx section iff the guest's meta declares +// CtxFeatCharacter. The zero value means "not declared / not encoded". +type Character struct { + Glyph string // single width-1 glyph (unicode) + InkR uint8 + InkG uint8 + InkB uint8 + BgR uint8 + BgG uint8 + BgB uint8 + Fallback uint8 // ASCII fallback codepoint +} + // Player is one roster entry. type Player struct { Handle string AccountID string Conn string Kind uint8 + Character Character } // Ctx is the CallContext every host→guest callback carries. @@ -172,8 +187,13 @@ const ( // sections otherwise. CtxFeatRosterEpoch uint32 = 1 << 0 + // CtxFeatCharacter opts the guest into per-member character sections + // (ABI.md §4.1): str glyph + ink RGB + bg RGB + ascii fallback, appended + // after each member's Kind byte in both member-bearing forms. + CtxFeatCharacter uint32 = 1 << 1 + // KnownCtxFeatures is the mask of bits this wire revision defines. - KnownCtxFeatures uint32 = CtxFeatRosterEpoch + KnownCtxFeatures uint32 = CtxFeatRosterEpoch | CtxFeatCharacter ) // Meta is the packed GameMeta. @@ -387,11 +407,18 @@ func (r *Rd) Err() error { // 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) { +// byte-identical to all prior wire revisions. Character fields on Player are +// silently ignored. This is a frozen features=0 wrapper around EncodeCtxFeat. +func EncodeCtx(w *Buf, c Ctx) { EncodeCtxFeat(w, c, 0) } + +// EncodeCtxFeat appends the packed CallContext to w in the legacy full-roster +// form, encoding per-member character sections after each Kind byte when +// features&CtxFeatCharacter != 0. For guests that do not declare +// CtxFeatCharacter pass features=0 (or use EncodeCtx). +func EncodeCtxFeat(w *Buf, c Ctx, features uint32) { encodeCtxHeader(w, c) w.U16(uint16(len(c.Members))) - encodeCtxMembers(w, c.Members) + encodeCtxMembers(w, c.Members, features) w.Bool(c.Settled) } @@ -399,14 +426,24 @@ func EncodeCtx(w *Buf, c Ctx) { // 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). +// bytes regardless of roster size, and c.Members is not read). Character +// fields on Player are silently ignored. This is a frozen features=0 wrapper +// around EncodeCtxEpochFeat. func EncodeCtxEpoch(w *Buf, c Ctx, epoch uint32, full bool) { + EncodeCtxEpochFeat(w, c, epoch, full, 0) +} + +// EncodeCtxEpochFeat appends the packed CallContext in roster-epoch mode, +// encoding per-member character sections after each Kind byte when +// features&CtxFeatCharacter != 0. For guests that do not declare +// CtxFeatCharacter pass features=0 (or use EncodeCtxEpoch). +func EncodeCtxEpochFeat(w *Buf, c Ctx, epoch uint32, full bool, features uint32) { encodeCtxHeader(w, c) if full { w.U16(CtxRosterFull) w.U32(epoch) w.U16(uint16(len(c.Members))) - encodeCtxMembers(w, c.Members) + encodeCtxMembers(w, c.Members, features) } else { w.U16(CtxRosterUnchanged) w.U32(epoch) @@ -423,20 +460,39 @@ func encodeCtxHeader(w *Buf, c Ctx) { w.U16(c.MinPlayers) } -func encodeCtxMembers(w *Buf, members []Player) { +func encodeCtxMembers(w *Buf, members []Player, features uint32) { for _, p := range members { w.Str(p.Handle) w.Str(p.AccountID) w.Str(p.Conn) w.U8(p.Kind) + if features&CtxFeatCharacter != 0 { + w.Str(p.Character.Glyph) + w.U8(p.Character.InkR) + w.U8(p.Character.InkG) + w.U8(p.Character.InkB) + w.U8(p.Character.BgR) + w.U8(p.Character.BgG) + w.U8(p.Character.BgB) + w.U8(p.Character.Fallback) + } } } // 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 { +// roster). Character fields are never populated. This is a frozen features=0 +// wrapper around DecodeCtxFeat. +func DecodeCtx(r *Rd) Ctx { return DecodeCtxFeat(r, 0) } + +// DecodeCtxFeat reads a CallContext encoded with the given features bitset, +// populating per-member Character fields when features&CtxFeatCharacter != 0. +// For payloads encoded without CtxFeatCharacter pass features=0 (or use +// DecodeCtx). Passing features=CtxFeatCharacter against a features-0 payload +// will trigger a short-read and set r.Bad — this is intentional (host-fault +// contract: features must match the payload). +func DecodeCtxFeat(r *Rd, features uint32) Ctx { var c Ctx c.NowUnixNanos = r.I64() c.Seed = r.I64() @@ -453,15 +509,15 @@ func DecodeCtx(r *Rd) Ctx { case CtxRosterFull: c.RosterEpoch = r.U32() c.RosterEpochSet = true - c.Members = decodeCtxMembers(r, int(r.U16())) + c.Members = decodeCtxMembers(r, int(r.U16()), features) default: - c.Members = decodeCtxMembers(r, int(count)) + c.Members = decodeCtxMembers(r, int(count), features) } c.Settled = r.Bool() return c } -func decodeCtxMembers(r *Rd, n int) []Player { +func decodeCtxMembers(r *Rd, n int, features uint32) []Player { var members []Player for i := 0; i < n && !r.Bad; i++ { var p Player @@ -469,6 +525,16 @@ func decodeCtxMembers(r *Rd, n int) []Player { p.AccountID = r.Str() p.Conn = r.Str() p.Kind = r.U8() + if features&CtxFeatCharacter != 0 { + p.Character.Glyph = r.Str() + p.Character.InkR = r.U8() + p.Character.InkG = r.U8() + p.Character.InkB = r.U8() + p.Character.BgR = r.U8() + p.Character.BgG = r.U8() + p.Character.BgB = r.U8() + p.Character.Fallback = r.U8() + } members = append(members, p) } return members From 3421f3d85028c2aa43b272522fb33129df773571 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Thu, 11 Jun 2026 07:37:42 +1000 Subject: [PATCH 2/9] wire: revision 5 ledger entry; rust feature-bit constants; test hardening Co-Authored-By: Claude Fable 5 --- rust/src/lib.rs | 3 ++- rust/src/types.rs | 8 +++++++- rust/src/wire.rs | 10 +++++----- rust/tests/golden/scalars.txt | 4 ++-- wire/character_test.go | 37 ++++++++++++++++++++++++++++++----- wire/wire.go | 10 ++++++++-- 6 files changed, 56 insertions(+), 16 deletions(-) diff --git a/rust/src/lib.rs b/rust/src/lib.rs index aecf245..69fc042 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -89,7 +89,8 @@ 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, CTX_FEAT_ROSTER_EPOCH, Lifecycle, + MetricFormat, Mode, Outcome, Player, PlayerResult, RoomConfig, Status, CTX_FEAT_CHARACTER, + CTX_FEAT_ROSTER_EPOCH, Lifecycle, }; // Native-only scriptable host double for `cargo test` of games and the SDK diff --git a/rust/src/types.rs b/rust/src/types.rs index fe49fa6..b26adba 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -250,8 +250,14 @@ pub enum Lifecycle { /// [`Meta::ctx_features`]. pub const CTX_FEAT_ROSTER_EPOCH: u32 = 1 << 0; +/// Opts the game into per-member character sections (ABI.md §4.1): str glyph +/// + ink RGB + bg RGB + ascii fallback, appended after each member's kind +/// byte in both member-bearing ctx forms. Declare it in +/// [`Meta::ctx_features`]. +pub const CTX_FEAT_CHARACTER: u32 = 1 << 1; + /// The feature bits this SDK revision defines. -pub(crate) const KNOWN_CTX_FEATURES: u32 = CTX_FEAT_ROSTER_EPOCH; +pub(crate) const KNOWN_CTX_FEATURES: u32 = CTX_FEAT_ROSTER_EPOCH | CTX_FEAT_CHARACTER; /// Heartbeat declaration envelope (mirrors the host clamp range). pub(crate) const HEARTBEAT_MIN_MS: u16 = 20; diff --git a/rust/src/wire.rs b/rust/src/wire.rs index d5e2235..de45b1c 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -177,7 +177,7 @@ fn decode_members(r: &mut Rd<'_>, n: usize) -> Vec { /// `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; +pub(crate) const WIRE_REVISION: u16 = 5; /// Pack a [`Meta`] for the `meta` export — the single SDK-owned serializer. pub(crate) fn encode_meta(m: &Meta) -> Vec { @@ -490,7 +490,7 @@ mod tests { ], ..Meta::DEFAULT }; - let golden = "0600676f6c64656e0600476f6c64656e0e00676f6c64656e206669787475726501000400020001006101006200000000000001050073636f726501000202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e0000000000000000000000000400"; + let golden = "0600676f6c64656e0600476f6c64656e0e00676f6c64656e206669787475726501000400020001006101006200000000000001050073636f726501000202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e0000000000000000000000000500"; 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"); } @@ -622,9 +622,9 @@ mod tests { ..Meta::DEFAULT }; let got: String = encode_meta(&m).iter().map(|b| format!("{b:02x}")).collect(); - // trailer = u32 1 LE + u16 100 LE + 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..]); + // trailer = u32 1 LE + u16 100 LE + u8 lifecycle + u16 revision 5 LE + // = "01000000" + "6400" + "00" + "0500" + assert!(got.ends_with("0000010000006400000500"), "trailer bytes diverge from the Go encoding: ...{}", &got[got.len()-22..]); } } diff --git a/rust/tests/golden/scalars.txt b/rust/tests/golden/scalars.txt index 270c6fa..ed63970 100644 --- a/rust/tests/golden/scalars.txt +++ b/rust/tests/golden/scalars.txt @@ -14,8 +14,8 @@ # 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_default = 070064656661756c7400000000010001000000000000000000000000000000000000000500 +meta_full = 0b00676f6c64656e2d66756c6c0b00476f6c64656e2046756c6c170065766572792073656374696f6e20706f70756c6174656402000800020005006d756c74690400636172640a004465616c206d6520696e080050726163746963650d004a6f696e206d79207461626c65010500636869707301010202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e000000000001000000fa00010500 meta_trunc_pre_config = 05007472756e6305005472756e63000001000400000000000000000001050073636f7265010000 meta_trunc_pre_largeroom = 05007472756e6305005472756e63000001000400000000000000000001050073636f72650100000000 meta_trunc_pre_lifecycle = 05007472756e6305005472756e63000001000400000000000000000001050073636f72650100000000010000006400 diff --git a/wire/character_test.go b/wire/character_test.go index 466e496..b8f41f7 100644 --- a/wire/character_test.go +++ b/wire/character_test.go @@ -7,8 +7,10 @@ import ( func charPlayers() []Player { return []Player{ + // "λ" is a multi-byte single-code-point width-1 glyph: the str + // length prefix counts UTF-8 bytes (2), not code points. {Handle: "ana", AccountID: "a-1", Conn: "c-1", Kind: KindMember, - Character: Character{Glyph: "~", InkR: 0x39, InkG: 0xFF, InkB: 0x14, BgR: 0x2D, BgG: 0x1B, BgB: 0x4E, Fallback: '~'}}, + Character: Character{Glyph: "λ", InkR: 0x39, InkG: 0xFF, InkB: 0x14, BgR: 0x2D, BgG: 0x1B, BgB: 0x4E, Fallback: 'L'}}, {Handle: "bob", AccountID: "a-2", Conn: "c-2", Kind: KindGuest, Character: Character{Glyph: "@", InkR: 1, InkG: 2, InkB: 3, BgR: 4, BgG: 5, BgB: 6, Fallback: '@'}}, } @@ -38,36 +40,61 @@ func TestCtxFeatureOffBytesUnchanged(t *testing.T) { } // Round trip with the feature declared, in both member-bearing forms, plus the -// unchanged sentinel. +// unchanged sentinel. Each encode appends a trailing event-extra byte and the +// decode must leave the reader exactly on it. func TestCtxCharacterRoundTrip(t *testing.T) { c := Ctx{NowUnixNanos: 9, Seed: 7, SeedSet: true, Mode: 1, Capacity: 4, MinPlayers: 2, Members: charPlayers(), Settled: true} var w Buf EncodeCtxFeat(&w, c, CtxFeatCharacter) - got := DecodeCtxFeat(&Rd{B: w.B}, CtxFeatCharacter) + w.U8(0xAB) // event extra + r := &Rd{B: w.B} + got := DecodeCtxFeat(r, CtxFeatCharacter) if len(got.Members) != 2 || got.Members[0].Character != c.Members[0].Character || got.Members[1].Character != c.Members[1].Character { t.Fatalf("legacy-form round trip lost character data: %+v", got.Members) } + if !got.Settled { + t.Fatalf("legacy-form round trip lost Settled: %+v", got) + } + if b := r.U8(); b != 0xAB || r.Bad { + t.Fatalf("legacy-form event extras misaligned: got %#x bad=%v", b, r.Bad) + } feats := CtxFeatCharacter | CtxFeatRosterEpoch var we Buf EncodeCtxEpochFeat(&we, c, 42, true, feats) - gote := DecodeCtxFeat(&Rd{B: we.B}, feats) + we.U8(0xAB) + re := &Rd{B: we.B} + gote := DecodeCtxFeat(re, feats) if !gote.RosterEpochSet || gote.RosterEpoch != 42 { t.Fatalf("epoch lost: %+v", gote) } if len(gote.Members) != 2 || gote.Members[1].Character != c.Members[1].Character { t.Fatalf("epoch-form round trip lost character data: %+v", gote.Members) } + if !gote.Settled { + t.Fatalf("epoch-form round trip lost Settled: %+v", gote) + } + if b := re.U8(); b != 0xAB || re.Bad { + t.Fatalf("epoch-form event extras misaligned: got %#x bad=%v", b, re.Bad) + } var wu Buf EncodeCtxEpochFeat(&wu, c, 42, false, feats) - gotu := DecodeCtxFeat(&Rd{B: wu.B}, feats) + wu.U8(0xAB) + ru := &Rd{B: wu.B} + gotu := DecodeCtxFeat(ru, feats) if !gotu.RosterUnchanged || len(gotu.Members) != 0 { t.Fatalf("unchanged-form wrong: %+v", gotu) } + if !gotu.Settled { + t.Fatalf("unchanged-form round trip lost Settled: %+v", gotu) + } + if b := ru.U8(); b != 0xAB || ru.Bad { + t.Fatalf("unchanged-form event extras misaligned: got %#x bad=%v", b, ru.Bad) + } } // A declaring decode of a feature-off payload must fail loudly (short read / diff --git a/wire/wire.go b/wire/wire.go index 98711f7..e1f529d 100644 --- a/wire/wire.go +++ b/wire/wire.go @@ -33,6 +33,9 @@ const Version uint32 = 2 // 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 +// 5 — per-member character section behind CtxFeatCharacter (str glyph · +// u8 ink RGB · u8 bg RGB · u8 asciiFallback after kind, both +// member-bearing forms) // // 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 @@ -44,7 +47,7 @@ const Version uint32 = 2 // (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 +const Revision uint16 = 5 // Guest export names. const ( @@ -491,7 +494,10 @@ func DecodeCtx(r *Rd) Ctx { return DecodeCtxFeat(r, 0) } // For payloads encoded without CtxFeatCharacter pass features=0 (or use // DecodeCtx). Passing features=CtxFeatCharacter against a features-0 payload // will trigger a short-read and set r.Bad — this is intentional (host-fault -// contract: features must match the payload). +// contract: features must match the payload). Unlike roster-epoch, whose +// forms are self-describing via the count u16's spare sentinel space, +// per-member trailing bytes carry no in-band discriminator — hence the +// out-of-band features parameter. func DecodeCtxFeat(r *Rd, features uint32) Ctx { var c Ctx c.NowUnixNanos = r.I64() From 5d7ebbae8c340a627a6e3edf11ad3002863c9a66 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Thu, 11 Jun 2026 07:42:50 +1000 Subject: [PATCH 3/9] sdk: decode per-member characters; thread declared ctx features into the roster skim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guest decodes with its own registered meta's CtxFeatures — the character section has no in-band discriminator. The legacy-mode byte-skim cache skips the section per member so its memcmp region stays aligned. Co-Authored-By: Claude Fable 5 --- internal/game/character_test.go | 127 ++++++++++++++++++++++++++++++++ internal/game/codec.go | 27 +++++++ internal/game/largeroom_test.go | 1 + internal/game/run.go | 8 +- internal/game/types.go | 23 ++++++ kit.go | 2 + 6 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 internal/game/character_test.go diff --git a/internal/game/character_test.go b/internal/game/character_test.go new file mode 100644 index 0000000..e650cc4 --- /dev/null +++ b/internal/game/character_test.go @@ -0,0 +1,127 @@ +package game + +import ( + "testing" + + "github.com/shellcade/kit/v2/wire" +) + +func featCtxPayload(features uint32, members ...wire.Player) []byte { + var w wire.Buf + wire.EncodeCtxFeat(&w, wire.Ctx{ + NowUnixNanos: 1, Seed: 7, SeedSet: true, Capacity: 1000, MinPlayers: 1, + Members: members, + }, features) + return w.B +} + +func epochFeatCtxPayload(features, epoch uint32, full bool, members ...wire.Player) []byte { + var w wire.Buf + wire.EncodeCtxEpochFeat(&w, wire.Ctx{ + NowUnixNanos: 1, Seed: 7, SeedSet: true, Capacity: 1000, MinPlayers: 1, + Members: members, + }, epoch, full, features) + return w.B +} + +// Legacy-form decode with CtxFeatCharacter declared: characters decode per +// member, the byte-skim cache stays aligned across the character section +// (identical bytes ⇒ changed=false), and a character-only change re-decodes. +func TestDecodeCtxCharacterSections(t *testing.T) { + resetRosterState() + declaredCtxFeatures = wire.CtxFeatCharacter + defer func() { declaredCtxFeatures = 0 }() + + ada := wire.Player{Handle: "ada", AccountID: "a", Conn: "c1", Kind: 1, + Character: wire.Character{Glyph: "λ", InkR: 10, InkG: 20, InkB: 30, BgR: 1, BgG: 2, BgB: 3, Fallback: 'L'}} + bob := wire.Player{Handle: "bob", AccountID: "b", Conn: "c2", Kind: 1, + Character: wire.Character{Glyph: "@", InkR: 200, InkG: 100, InkB: 50, Fallback: '@'}} + + c1, _, changed := decodeCtx(featCtxPayload(wire.CtxFeatCharacter, ada, bob)) + if !changed || len(c1.members) != 2 { + t.Fatalf("first decode: changed=%v members=%d", changed, len(c1.members)) + } + want0 := Character{Glyph: "λ", InkR: 10, InkG: 20, InkB: 30, BgR: 1, BgG: 2, BgB: 3, Fallback: 'L'} + want1 := Character{Glyph: "@", InkR: 200, InkG: 100, InkB: 50, Fallback: '@'} + if c1.members[0].Character != want0 || c1.members[1].Character != want1 { + t.Fatalf("characters = %+v / %+v", c1.members[0].Character, c1.members[1].Character) + } + + // Same bytes again: the skim must skip the character section exactly so + // the memcmp region matches and the cached roster is reused. + c2, _, changed := decodeCtx(featCtxPayload(wire.CtxFeatCharacter, ada, bob)) + if changed { + t.Fatal("identical payload re-decoded (skim misaligned over character section)") + } + if c2.members[0].Character != want0 || c2.members[1].Character != want1 { + t.Fatalf("cached characters lost: %+v / %+v", c2.members[0].Character, c2.members[1].Character) + } + + // A character-only change must register as a roster change and decode the + // new value (the characters live inside the compared region). + bob.Character.InkR = 201 + c3, _, changed := decodeCtx(featCtxPayload(wire.CtxFeatCharacter, ada, bob)) + if !changed { + t.Fatal("InkR flip not detected as a change") + } + if c3.members[1].Character.InkR != 201 { + t.Fatalf("new InkR not decoded: %+v", c3.members[1].Character) + } +} + +// Roster-epoch full form with both feature bits: characters decode, the epoch +// is honoured, and the unchanged sentinel reuses the cached roster WITH the +// characters intact. +func TestDecodeCtxCharacterEpochForm(t *testing.T) { + resetRosterState() + declaredCtxFeatures = wire.CtxFeatCharacter | wire.CtxFeatRosterEpoch + defer func() { declaredCtxFeatures = 0 }() + + ada := wire.Player{Handle: "ada", AccountID: "a", Conn: "c1", Kind: 1, + Character: wire.Character{Glyph: "λ", InkR: 9, Fallback: 'L'}} + feats := wire.CtxFeatCharacter | wire.CtxFeatRosterEpoch + + c1, _, changed := decodeCtx(epochFeatCtxPayload(feats, 7, true, ada)) + if !changed || len(c1.members) != 1 { + t.Fatalf("full form: changed=%v members=%d", changed, len(c1.members)) + } + want := Character{Glyph: "λ", InkR: 9, Fallback: 'L'} + if c1.members[0].Character != want { + t.Fatalf("character = %+v", c1.members[0].Character) + } + if !rosterCacheEpochSet || rosterCacheEpoch != 7 { + t.Fatalf("cache epoch = %d set=%v", rosterCacheEpoch, rosterCacheEpochSet) + } + + c2, _, changed := decodeCtx(epochFeatCtxPayload(feats, 7, false)) + if changed || len(c2.members) != 1 { + t.Fatalf("unchanged form: changed=%v members=%d", changed, len(c2.members)) + } + if c2.members[0].Character != want { + t.Fatalf("cached character lost on unchanged sentinel: %+v", c2.members[0].Character) + } +} + +// Feature undeclared: legacy bytes decode exactly as before and every +// member's Character is the zero value. +func TestDecodeCtxNoCharacterFeature(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: 0} + var w wire.Buf + wire.EncodeCtx(&w, wire.Ctx{ + NowUnixNanos: 1, Seed: 7, SeedSet: true, Capacity: 8, MinPlayers: 1, + Members: []wire.Player{ada, bob}, + }) + + c, _, changed := decodeCtx(w.B) + if !changed || len(c.members) != 2 { + t.Fatalf("decode: changed=%v members=%d", changed, len(c.members)) + } + for i, m := range c.members { + if m.Character != (Character{}) { + t.Fatalf("member %d Character = %+v, want zero value", i, m.Character) + } + } +} diff --git a/internal/game/codec.go b/internal/game/codec.go index c78eb1f..2acda2b 100644 --- a/internal/game/codec.go +++ b/internal/game/codec.go @@ -51,6 +51,13 @@ var ( epochMismatchLogged bool ) +// declaredCtxFeatures is the registered game's GameMeta.CtxFeatures, set by +// Run. The host encodes per-member character sections iff the guest's meta +// declares CtxFeatCharacter — there is no in-band discriminator — so the +// decoder must know the declaration to read (and skim) the member section +// with the right shape. +var declaredCtxFeatures uint32 + // decodeCtx decodes a CallContext and returns the reader positioned at the // event-specific extra payload, plus whether the roster changed since the // previous callback (true on the first callback). @@ -93,6 +100,16 @@ func decodeCtx(b []byte) (callContext, *wire.Rd, bool) { r.SkipStr() // account id r.SkipStr() // conn r.U8() // kind + if declaredCtxFeatures&wire.CtxFeatCharacter != 0 { + // Character section (host sends it iff our meta declares the + // feature): glyph str + 7 fixed bytes (ink RGB, bg RGB, + // fallback). The skim must skip EXACTLY what + // decodeMembersInto reads or the memcmp region misaligns. + r.SkipStr() // glyph + for j := 0; j < 7; j++ { + r.U8() + } + } } region := r.B[start:r.Off] changed = rosterCacheBytes == nil || !bytes.Equal(region, rosterCacheBytes) @@ -127,6 +144,16 @@ func decodeMembersInto(r *wire.Rd, n int) { p.AccountID = r.Str() p.Conn = r.Str() p.Kind = Kind(r.U8()) + if declaredCtxFeatures&wire.CtxFeatCharacter != 0 { + p.Character.Glyph = r.Str() + p.Character.InkR = r.U8() + p.Character.InkG = r.U8() + p.Character.InkB = r.U8() + p.Character.BgR = r.U8() + p.Character.BgG = r.U8() + p.Character.BgB = r.U8() + p.Character.Fallback = r.U8() + } rosterCache = append(rosterCache, p) } } diff --git a/internal/game/largeroom_test.go b/internal/game/largeroom_test.go index 21fb47d..1e72db3 100644 --- a/internal/game/largeroom_test.go +++ b/internal/game/largeroom_test.go @@ -14,6 +14,7 @@ func resetRosterState() { rosterCacheEpochSet = false epochMismatch = false epochMismatchLogged = false + declaredCtxFeatures = 0 } func epochCtxPayload(epoch uint32, full bool, members ...wire.Player) []byte { diff --git a/internal/game/run.go b/internal/game/run.go index 821efbf..195671c 100644 --- a/internal/game/run.go +++ b/internal/game/run.go @@ -21,7 +21,13 @@ var ( ) // Run installs the game. The instance's Handler is created lazily on start. -func Run(g Game) { theGame = g } +// The declared ctx features are captured here so the callback decoder reads +// member sections with the shape the host encodes for this guest (the +// character section carries no in-band discriminator). +func Run(g Game) { + theGame = g + declaredCtxFeatures = g.Meta().CtxFeatures +} // ExportABI backs the shellcade_abi export. func ExportABI() int32 { diff --git a/internal/game/types.go b/internal/game/types.go index b3c0663..658a0b6 100644 --- a/internal/game/types.go +++ b/internal/game/types.go @@ -20,12 +20,28 @@ const ( KindMember ) +// Character is a player's resolved arcade character (mirrors wire.Character): +// a single width-1 glyph with ink/background colors and an ASCII fallback +// codepoint. The zero value means "no character" — what every member carries +// unless the game declares CtxFeatCharacter in GameMeta.CtxFeatures. +type Character struct { + Glyph string + InkR uint8 + InkG uint8 + InkB uint8 + BgR uint8 + BgG uint8 + BgB uint8 + Fallback uint8 +} + // Player is a value-comparable membership token (mirrors native sdk.Player). type Player struct { AccountID string Handle string Kind Kind Conn string + Character Character } // Guest reports whether the player is a keyless guest. @@ -224,6 +240,13 @@ const ( // path. Declare it in GameMeta.CtxFeatures. const CtxFeatRosterEpoch = wire.CtxFeatRosterEpoch +// CtxFeatCharacter opts the game into per-member character sections: the host +// appends each member's resolved Character to every member-bearing roster +// encoding, and the SDK populates Player.Character. Without the declaration +// Player.Character is always the zero value. Declare it in +// GameMeta.CtxFeatures. +const CtxFeatCharacter = wire.CtxFeatCharacter + // Status is a player's terminal outcome. type Status uint8 diff --git a/kit.go b/kit.go index d6a2038..a34348a 100644 --- a/kit.go +++ b/kit.go @@ -19,6 +19,7 @@ const ABIVersion = game.ABIVersion type ( Player = game.Player + Character = game.Character Kind = game.Kind Input = game.Input InputKind = game.InputKind @@ -95,6 +96,7 @@ const ( LowerBetter = game.LowerBetter CtxFeatRosterEpoch = game.CtxFeatRosterEpoch + CtxFeatCharacter = game.CtxFeatCharacter LifecycleResumable = game.LifecycleResumable LifecycleEphemeral = game.LifecycleEphemeral From 8b71a606600829359c62861ea088df58e66aff22 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Thu, 11 Jun 2026 07:51:16 +1000 Subject: [PATCH 4/9] sdk: CharacterCell helper Co-Authored-By: Claude Fable 5 --- internal/game/character_test.go | 21 +++++++++++++++++++++ internal/game/codec.go | 1 + internal/game/diff_test.go | 1 + internal/game/grid.go | 17 +++++++++++++++++ internal/game/run.go | 4 +++- kit.go | 5 +++++ 6 files changed, 48 insertions(+), 1 deletion(-) diff --git a/internal/game/character_test.go b/internal/game/character_test.go index e650cc4..f235f47 100644 --- a/internal/game/character_test.go +++ b/internal/game/character_test.go @@ -125,3 +125,24 @@ func TestDecodeCtxNoCharacterFeature(t *testing.T) { } } } + +// CharacterCell turns a character into one styled, ready-to-place cell; the +// zero Character (feature undeclared) yields a blank cell. +func TestCharacterCell(t *testing.T) { + c := Character{Glyph: "λ", InkR: 0x39, InkG: 0xFF, InkB: 0x14, BgR: 0x2D, BgG: 0x1B, BgB: 0x4E, Fallback: 'L'} + cell := CharacterCell(c) + if cell.Rune != 'λ' || cell.Cp2 != 0 || cell.Cont { + t.Fatalf("cell shape wrong: %+v", cell) + } + r, g, b := cell.FG.RGBVals() + if !cell.FG.IsSet() || r != 0x39 || g != 0xFF || b != 0x14 { + t.Fatalf("ink wrong: %+v", cell.FG) + } + br, bg2, bb := cell.BG.RGBVals() + if !cell.BG.IsSet() || br != 0x2D || bg2 != 0x1B || bb != 0x4E { + t.Fatalf("bg wrong: %+v", cell.BG) + } + if blank := CharacterCell(Character{}); blank.Rune != ' ' || blank.FG.IsSet() || blank.BG.IsSet() { + t.Fatalf("zero character should be a blank cell: %+v", blank) + } +} diff --git a/internal/game/codec.go b/internal/game/codec.go index 2acda2b..f317748 100644 --- a/internal/game/codec.go +++ b/internal/game/codec.go @@ -144,6 +144,7 @@ func decodeMembersInto(r *wire.Rd, n int) { p.AccountID = r.Str() p.Conn = r.Str() p.Kind = Kind(r.U8()) + // keep the legacy skim in decodeCtx in lockstep with this section if declaredCtxFeatures&wire.CtxFeatCharacter != 0 { p.Character.Glyph = r.Str() p.Character.InkR = r.U8() diff --git a/internal/game/diff_test.go b/internal/game/diff_test.go index 812abde..1ae648d 100644 --- a/internal/game/diff_test.go +++ b/internal/game/diff_test.go @@ -92,6 +92,7 @@ func resetDiffState() { } rosterCache = nil rosterCacheBytes = nil + declaredCtxFeatures = 0 } func frameWith(text string) *Frame { diff --git a/internal/game/grid.go b/internal/game/grid.go index e243d01..06ce491 100644 --- a/internal/game/grid.go +++ b/internal/game/grid.go @@ -215,3 +215,20 @@ func (f *Frame) Fill(r0, c0, r1, c1 int, cell Cell) { } } } + +// CharacterCell returns the one ready-made cell of a member's character tile: +// the glyph styled with the resolved ink and background (player-character +// capability, shellcade — every catalogue glyph is width 1, so games place a +// character with zero width logic). The zero Character (the game's meta does +// not declare CtxFeatCharacter) yields a blank cell. +func CharacterCell(c Character) Cell { + if c.Glyph == "" { + return Cell{Rune: ' '} + } + r := []rune(c.Glyph)[0] + return Cell{ + Rune: r, + FG: RGB(c.InkR, c.InkG, c.InkB), + BG: RGB(c.BgR, c.BgG, c.BgB), + } +} diff --git a/internal/game/run.go b/internal/game/run.go index 195671c..6c66866 100644 --- a/internal/game/run.go +++ b/internal/game/run.go @@ -23,7 +23,9 @@ var ( // Run installs the game. The instance's Handler is created lazily on start. // The declared ctx features are captured here so the callback decoder reads // member sections with the shape the host encodes for this guest (the -// character section carries no in-band discriminator). +// character section carries no in-band discriminator). Meta() is read once at +// registration, so it must return a complete value (the kit.Main scaffold +// pattern). func Run(g Game) { theGame = g declaredCtxFeatures = g.Meta().CtxFeatures diff --git a/kit.go b/kit.go index a34348a..8e51aa6 100644 --- a/kit.go +++ b/kit.go @@ -155,6 +155,11 @@ var ( // throughout the SDK (see ABI.md §6). func NewFrame() *Frame { return game.NewFrame() } +// CharacterCell returns the one ready-made cell of a member's character tile: +// the glyph styled with the resolved ink and background. The zero Character +// (the game's meta does not declare CtxFeatCharacter) yields a blank cell. +func CharacterCell(c Character) Cell { return game.CharacterCell(c) } + // ---- the authoring contract -------------------------------------------------------- type ( From 415d0a80174e36af9fce19528832c54f0d94ef99 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Thu, 11 Jun 2026 08:08:22 +1000 Subject: [PATCH 5/9] abi: document CtxFeatCharacter; conformance + golden ctx fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ABI.md §4.1 gains the per-member character section and the 1<<1 feature bit; kittest covers flag-on and flag-off; a Go-emitted golden ctx fixture lands for the Rust decode-parity test (next commit). Co-Authored-By: Claude Fable 5 --- ABI.md | 36 ++++- internal/game/grid.go | 4 +- kittest/character_conformance_test.go | 195 ++++++++++++++++++++++++++ rust/tests/golden/ctx_character.txt | 22 +++ wire/character_golden_test.go | 163 +++++++++++++++++++++ 5 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 kittest/character_conformance_test.go create mode 100644 rust/tests/golden/ctx_character.txt create mode 100644 wire/character_golden_test.go diff --git a/ABI.md b/ABI.md index e2afc9c..af1ee08 100644 --- a/ABI.md +++ b/ABI.md @@ -153,6 +153,23 @@ 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. +**Per-member character section (minor addition).** For a guest whose meta +declares `CtxFeatCharacter` (§4.2), every member entry — in BOTH +member-bearing forms, the legacy full roster and the `0xFFFE` full-at-epoch +form — carries immediately after its `u8 kind`: + +``` +str glyph · u8 inkR · u8 inkG · u8 inkB · u8 bgR · u8 bgG · u8 bgB · u8 asciiFallback +``` + +The `0xFFFF` unchanged sentinel carries no member data and therefore no +character sections. Unlike the roster-epoch forms, which self-describe via +the count u16's spare sentinel space, per-member trailing bytes have no +in-band discriminator — the meta declaration is the entire negotiation, and +the host MUST encode the section iff the guest declared the feature. Guests +that do not declare `CtxFeatCharacter` receive encodings byte-identical to +revision 4. + ### 4.2 Meta ``` @@ -167,7 +184,7 @@ 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 +u32 ctxFeatures trailing large-room section (see below); bit 0 = CtxFeatRosterEpoch · bit 1 = CtxFeatCharacter 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) @@ -195,7 +212,8 @@ concern). The Go SDK enforces these rules at `meta()` encode time, and **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` +encodings; bit 0 = `CtxFeatRosterEpoch`, bit 1 = `CtxFeatCharacter`, 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 @@ -206,6 +224,17 @@ 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. +`CtxFeatCharacter` (`1<<1`) opts the guest into the per-member character +section (§4.1): each roster entry carries the member's resolved arcade +character. `glyph` is a single Unicode code point that renders at exactly one +terminal cell — a game places it with zero width logic. The ink and +background colours are already-resolved RGB triplets: the host applies any +palette, theme, or unlock resolution before encoding, so the guest never +interprets colour indirectly. `asciiFallback` is the single-byte stand-in the +HOST substitutes for the glyph when a viewer's terminal cannot take UTF-8 — +it rides along so the guest holds the complete character, but the degradation +itself is a host-side rendering concern, never guest logic. + **Lifecycle byte (minor addition).** A trailing `u8` after the large-room section, presence-guarded under the same rules (absent = `0`): the room's end-of-life declaration. `0` resumable — hibernate on abandonment, @@ -229,7 +258,8 @@ monotonic counter of the wire-visible minor additions within an ABI major 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 +`4` this field itself · `5` per-member ctx character section behind +`CtxFeatCharacter`. `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 diff --git a/internal/game/grid.go b/internal/game/grid.go index 06ce491..0906a3d 100644 --- a/internal/game/grid.go +++ b/internal/game/grid.go @@ -4,6 +4,8 @@ package game // outside the grid are clamped (never errored), and the packed frame encoding // preserves the canvas contract host-side. +import "unicode/utf8" + const ( Rows = 24 Cols = 80 @@ -225,7 +227,7 @@ func CharacterCell(c Character) Cell { if c.Glyph == "" { return Cell{Rune: ' '} } - r := []rune(c.Glyph)[0] + r, _ := utf8.DecodeRuneInString(c.Glyph) return Cell{ Rune: r, FG: RGB(c.InkR, c.InkG, c.InkB), diff --git a/kittest/character_conformance_test.go b/kittest/character_conformance_test.go new file mode 100644 index 0000000..52dc8d9 --- /dev/null +++ b/kittest/character_conformance_test.go @@ -0,0 +1,195 @@ +package kittest_test + +// Public ABI-v2 conformance for the per-member ctx character section +// (CtxFeatCharacter, ABI.md §4.1/§4.2), in the same spirit as +// conformance_test.go: only the exported kit/wire/kittest API. +// +// Layer note: the kittest double drives the NATIVE authoring path (callbacks +// with already-built kit.Player values) and never encodes or decodes a Ctx — +// the wasm codec that does is internal. So the ctx-section coverage here +// drives the public `wire` package directly, exactly as the host encodes and +// a from-scratch guest decodes (wire.EncodeCtxFeat / wire.DecodeCtxFeat are +// both exported for that purpose), and then hands the decoded characters to a +// fixture game through kittest to cover the authoring half +// (Player.Character → kit.CharacterCell → a rendered cell). + +import ( + "bytes" + "testing" + + kit "github.com/shellcade/kit/v2" + "github.com/shellcade/kit/v2/kittest" + "github.com/shellcade/kit/v2/wire" +) + +// ---- a fixture game that declares CtxFeatCharacter and draws the roster ------ + +type characterGame struct{} + +func (characterGame) Meta() kit.GameMeta { + return kit.GameMeta{ + Slug: "conformance-character", + Name: "Conformance Character", + MinPlayers: 1, + MaxPlayers: 4, + CtxFeatures: kit.CtxFeatCharacter, + } +} +func (characterGame) NewRoom(kit.RoomConfig, kit.Services) kit.Handler { return &characterHandler{} } + +type characterHandler struct{ kit.Base } + +// OnJoin draws each member's character tile at row 0, one column per roster +// index — the zero-width-logic placement CharacterCell promises. +func (characterHandler) OnJoin(r kit.Room, _ kit.Player) { + f := kit.NewFrame() + for i, m := range r.Members() { + f.Set(0, i, kit.CharacterCell(m.Character)) + } + r.Identical(f) +} + +// ---- the canonical character roster (mirrors wire's golden fixture) ---------- + +func characterCtx() wire.Ctx { + return wire.Ctx{ + NowUnixNanos: 9, + Seed: 7, + SeedSet: true, + Mode: wire.ModePrivate, + Capacity: 4, + MinPlayers: 2, + Settled: true, + Members: []wire.Player{ + {Handle: "ana", AccountID: "a-1", Conn: "c-1", Kind: wire.KindMember, + Character: wire.Character{Glyph: "λ", InkR: 0x39, InkG: 0xFF, InkB: 0x14, BgR: 0x2D, BgG: 0x1B, BgB: 0x4E, Fallback: 'L'}}, + {Handle: "bob", AccountID: "a-2", Conn: "c-2", Kind: wire.KindGuest, + Character: wire.Character{Glyph: "@", InkR: 1, InkG: 2, InkB: 3, BgR: 4, BgG: 5, BgB: 6, Fallback: '@'}}, + }, + } +} + +// ---- conformance: flag-on — characters decode and render --------------------- + +func TestConformanceCharacterFlagOn(t *testing.T) { + // The declaration itself must be a valid meta trailer (the rule set every + // SDK applies at meta() encode time). + if err := wire.ValidateMetaTrailer(characterGame{}.Meta().CtxFeatures, 0); err != nil { + t.Fatalf("CtxFeatCharacter rejected as a meta trailer: %v", err) + } + + want := characterCtx() + + // Both member-bearing forms: the legacy full roster and the full-at-epoch + // sentinel form. Encode as the host does, decode as a declaring guest does. + forms := []struct { + name string + encode func(w *wire.Buf) + }{ + {"legacy", func(w *wire.Buf) { + wire.EncodeCtxFeat(w, want, wire.CtxFeatCharacter) + }}, + {"epoch_full", func(w *wire.Buf) { + wire.EncodeCtxEpochFeat(w, want, 42, true, wire.CtxFeatCharacter|wire.CtxFeatRosterEpoch) + }}, + } + + var decoded []wire.Player + for _, form := range forms { + var w wire.Buf + form.encode(&w) + r := &wire.Rd{B: w.B} + got := wire.DecodeCtxFeat(r, wire.CtxFeatCharacter) + if err := r.Err(); err != nil { + t.Fatalf("%s: decode: %v", form.name, err) + } + if r.Off != len(r.B) { + t.Fatalf("%s: %d bytes left after decode", form.name, len(r.B)-r.Off) + } + if len(got.Members) != len(want.Members) { + t.Fatalf("%s: %d members decoded, want %d", form.name, len(got.Members), len(want.Members)) + } + for i, m := range got.Members { + if m.Character != want.Members[i].Character { + t.Fatalf("%s: member %d character = %+v, want %+v", + form.name, i, m.Character, want.Members[i].Character) + } + } + decoded = got.Members + } + + // The authoring half: a room whose players carry the decoded characters; + // the fixture game draws them via kit.CharacterCell. + players := make([]kit.Player, len(decoded)) + for i, m := range decoded { + players[i] = kit.Player{ + Handle: m.Handle, AccountID: m.AccountID, Conn: m.Conn, Kind: kit.Kind(m.Kind), + Character: kit.Character{ + Glyph: m.Character.Glyph, + InkR: m.Character.InkR, InkG: m.Character.InkG, InkB: m.Character.InkB, + BgR: m.Character.BgR, BgG: m.Character.BgG, BgB: m.Character.BgB, + Fallback: m.Character.Fallback, + }, + } + } + room := kittest.NewRoom(players...) + h := characterGame{}.NewRoom(room.Config(), room.Services()) + h.OnStart(room) + h.OnJoin(room, room.Players[0]) + + f := room.LastFrame(room.Players[0]) + if f == nil { + t.Fatal("no frame captured") + } + ana := f.Cells[0][0] + if ana.Rune != 'λ' || ana.FG != kit.RGB(0x39, 0xFF, 0x14) || ana.BG != kit.RGB(0x2D, 0x1B, 0x4E) { + t.Fatalf("ana's character cell wrong: %+v", ana) + } + bob := f.Cells[0][1] + if bob.Rune != '@' || bob.FG != kit.RGB(1, 2, 3) || bob.BG != kit.RGB(4, 5, 6) { + t.Fatalf("bob's character cell wrong: %+v", bob) + } +} + +// ---- conformance: flag-off — encodings are byte-identical to revision 4 ------ + +// TestConformanceCharacterFlagOff pins the v2.8 compatibility guarantee at the +// conformance level: for a guest that does NOT declare CtxFeatCharacter, a +// roster whose players carry characters encodes byte-identically to the same +// roster with zero-valued characters — the section simply does not exist on +// the wire, in any member-bearing form. +func TestConformanceCharacterFlagOff(t *testing.T) { + withChars := characterCtx() + zeroed := characterCtx() + for i := range zeroed.Members { + zeroed.Members[i].Character = wire.Character{} + } + + encodings := []struct { + name string + encode func(w *wire.Buf, c wire.Ctx) + }{ + {"legacy", func(w *wire.Buf, c wire.Ctx) { wire.EncodeCtx(w, c) }}, + {"epoch_full", func(w *wire.Buf, c wire.Ctx) { wire.EncodeCtxEpoch(w, c, 42, true) }}, + {"epoch_unchanged", func(w *wire.Buf, c wire.Ctx) { wire.EncodeCtxEpoch(w, c, 42, false) }}, + } + for _, enc := range encodings { + var a, b wire.Buf + enc.encode(&a, withChars) + enc.encode(&b, zeroed) + if !bytes.Equal(a.B, b.B) { + t.Fatalf("%s: characters leaked into a features=0 encoding:\n with %x\n zeroed %x", + enc.name, a.B, b.B) + } + } + + // And a non-declaring decode yields zero-valued characters. + var w wire.Buf + wire.EncodeCtx(&w, withChars) + got := wire.DecodeCtx(&wire.Rd{B: w.B}) + for i, m := range got.Members { + if m.Character != (wire.Character{}) { + t.Fatalf("member %d Character = %+v, want zero value", i, m.Character) + } + } +} diff --git a/rust/tests/golden/ctx_character.txt b/rust/tests/golden/ctx_character.txt new file mode 100644 index 0000000..eac75d8 --- /dev/null +++ b/rust/tests/golden/ctx_character.txt @@ -0,0 +1,22 @@ +# Cross-language golden vectors for the per-member ctx character section +# (CtxFeatCharacter, ABI.md §4.1), emitted by the Go reference encoders in +# kit/wire (character_golden_test.go) and decoded by the Rust SDK. A Ctx is +# host-encoded (host→guest only), so the Rust side asserts DECODE parity — +# every field plus the reader position — and never encodes a Ctx of its own. +# +# DO NOT EDIT BY HAND. When the encoding legitimately changes, review the +# wire-visible change, then regenerate and commit: +# +# WIRE_SCALAR_GOLDEN_WRITE=1 go test -run TestCtxCharacterGoldenFresh ./wire/ +# +# Format: one "name = hex" line per vector (the scalars.txt shape). Both +# vectors carry the canonical two-member character roster (member "ana" with +# glyph λ, guest "bob" with glyph @) and end with a trailing u32 event-extra +# (7) standing in for the per-export trailing args. +# +# ctx_character_legacy EncodeCtxFeat — legacy full-roster form, +# features = CtxFeatCharacter +# ctx_character_epoch_full EncodeCtxEpochFeat — 0xFFFE full-at-epoch form, +# epoch 42, features = CtxFeatCharacter|CtxFeatRosterEpoch +ctx_character_legacy = 0900000000000000070000000000000001010400020002000300616e610300612d310300632d31010200cebb39ff142d1b4e4c0300626f620300612d320300632d3200010040010203040506400107000000 +ctx_character_epoch_full = 09000000000000000700000000000000010104000200feff2a00000002000300616e610300612d310300632d31010200cebb39ff142d1b4e4c0300626f620300612d320300632d3200010040010203040506400107000000 diff --git a/wire/character_golden_test.go b/wire/character_golden_test.go new file mode 100644 index 0000000..cef30f7 --- /dev/null +++ b/wire/character_golden_test.go @@ -0,0 +1,163 @@ +package wire + +import ( + "fmt" + "os" + "reflect" + "strings" + "testing" +) + +// Cross-language golden vectors for the per-member ctx character section +// (CtxFeatCharacter), following the scalars.txt discipline exactly: this +// package is the EMITTER and the freshness gate, the Rust SDK replays the +// committed file. Direction-aware like the ctx_* scalar vectors — a Ctx is +// host-encoded, flowing host→guest only, so the Rust side DECODES these +// payloads (asserting every field and the reader position at the trailing +// event-extra) and never encodes a Ctx of its own. +// +// ctxCharacterGoldenPath lives under the Rust crate so its replay test is +// self-contained (include_str!); TestCtxCharacterGoldenFresh regenerates the +// content on every plain `go test` run and fails if the committed file has +// gone stale against the current encoders. +const ctxCharacterGoldenPath = "../rust/tests/golden/ctx_character.txt" + +// ctxCharacterFixture is the canonical character roster, mirrored verbatim by +// the kittest conformance fixture (kittest/character_conformance_test.go) and +// by the Rust replay fixture: two members, one of them a guest, with every +// character field non-zero somewhere across the pair. +func ctxCharacterFixture() Ctx { + return Ctx{ + NowUnixNanos: 9, + Seed: 7, + SeedSet: true, + Mode: ModePrivate, + Capacity: 4, + MinPlayers: 2, + Settled: true, + Members: []Player{ + {Handle: "ana", AccountID: "a-1", Conn: "c-1", Kind: KindMember, + Character: Character{Glyph: "λ", InkR: 0x39, InkG: 0xFF, InkB: 0x14, BgR: 0x2D, BgG: 0x1B, BgB: 0x4E, Fallback: 'L'}}, + {Handle: "bob", AccountID: "a-2", Conn: "c-2", Kind: KindGuest, + Character: Character{Glyph: "@", InkR: 1, InkG: 2, InkB: 3, BgR: 4, BgG: 5, BgB: 6, Fallback: '@'}}, + }, + } +} + +// ctxCharacterVectors emits both member-bearing forms with the character +// section, each followed by the u32 event-extra (the same stand-in for the +// per-export trailing args the scalar ctx vectors carry — decoding must leave +// the reader exactly there). +func ctxCharacterVectors() []scalarVector { + fix := ctxCharacterFixture() + ctx := func(encode func(w *Buf)) []byte { + var w Buf + encode(&w) + w.U32(ctxEventExtra) + return w.B + } + return []scalarVector{ + {"ctx_character_legacy", ctx(func(w *Buf) { + EncodeCtxFeat(w, fix, CtxFeatCharacter) + })}, + {"ctx_character_epoch_full", ctx(func(w *Buf) { + EncodeCtxEpochFeat(w, fix, 42, true, CtxFeatCharacter|CtxFeatRosterEpoch) + })}, + } +} + +const ctxCharacterGoldenHeader = `# Cross-language golden vectors for the per-member ctx character section +# (CtxFeatCharacter, ABI.md §4.1), emitted by the Go reference encoders in +# kit/wire (character_golden_test.go) and decoded by the Rust SDK. A Ctx is +# host-encoded (host→guest only), so the Rust side asserts DECODE parity — +# every field plus the reader position — and never encodes a Ctx of its own. +# +# DO NOT EDIT BY HAND. When the encoding legitimately changes, review the +# wire-visible change, then regenerate and commit: +# +# WIRE_SCALAR_GOLDEN_WRITE=1 go test -run TestCtxCharacterGoldenFresh ./wire/ +# +# Format: one "name = hex" line per vector (the scalars.txt shape). Both +# vectors carry the canonical two-member character roster (member "ana" with +# glyph λ, guest "bob" with glyph @) and end with a trailing u32 event-extra +# (7) standing in for the per-export trailing args. +# +# ctx_character_legacy EncodeCtxFeat — legacy full-roster form, +# features = CtxFeatCharacter +# ctx_character_epoch_full EncodeCtxEpochFeat — 0xFFFE full-at-epoch form, +# epoch 42, features = CtxFeatCharacter|CtxFeatRosterEpoch +` + +func renderCtxCharacterGolden() string { + var b strings.Builder + b.WriteString(ctxCharacterGoldenHeader) + for _, v := range ctxCharacterVectors() { + fmt.Fprintf(&b, "%s = %x\n", v.name, v.payload) + } + return b.String() +} + +// TestCtxCharacterGoldenFresh is the freshness gate, identical in shape to +// TestScalarGoldenFresh: the committed vector file must equal what the +// CURRENT encoders emit. Set WIRE_SCALAR_GOLDEN_WRITE=1 to regenerate after a +// reviewed encoding change. +func TestCtxCharacterGoldenFresh(t *testing.T) { + want := renderCtxCharacterGolden() + if os.Getenv("WIRE_SCALAR_GOLDEN_WRITE") != "" { + if err := os.WriteFile(ctxCharacterGoldenPath, []byte(want), 0o644); err != nil { + t.Fatal(err) + } + t.Logf("wrote %s (%d bytes)", ctxCharacterGoldenPath, len(want)) + return + } + got, err := os.ReadFile(ctxCharacterGoldenPath) + if err != nil { + t.Fatalf("reading committed ctx character golden vectors: %v\nregenerate with:\n WIRE_SCALAR_GOLDEN_WRITE=1 go test -run TestCtxCharacterGoldenFresh ./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 TestCtxCharacterGoldenFresh ./wire/", + ctxCharacterGoldenPath) + } +} + +// TestCtxCharacterGoldenDecode pins DecodeCtxFeat over the emitted vectors, +// including the reader position at the trailing event-extra — the Go twin of +// the Rust replay assertions. +func TestCtxCharacterGoldenDecode(t *testing.T) { + vectors := make(map[string][]byte, len(ctxCharacterVectors())) + for _, v := range ctxCharacterVectors() { + vectors[v.name] = v.payload + } + base := ctxCharacterFixture() + cases := []struct { + name string + want Ctx + }{ + {"ctx_character_legacy", base}, + {"ctx_character_epoch_full", func() Ctx { + c := base + c.RosterEpoch, c.RosterEpochSet = 42, true + return c + }()}, + } + for _, tc := range cases { + r := &Rd{B: vectors[tc.name]} + got := DecodeCtxFeat(r, CtxFeatCharacter) + 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) + } + } +} From 859cc0965ad577589f40b1fc860d52c5fed06772 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Thu, 11 Jun 2026 08:58:40 +1000 Subject: [PATCH 6/9] =?UTF-8?q?rust:=20mirror=20CtxFeatCharacter=20?= =?UTF-8?q?=E2=80=94=20Character=20on=20Player,=20decode,=20character=5Fce?= =?UTF-8?q?ll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decode-side parity with the Go kit: the member character section is read iff the registered meta declares the bit; golden ctx vectors replay byte-identically; character_cell returns the one styled cell. Co-Authored-By: Claude Fable 5 --- rust/src/broadcast.rs | 2 +- rust/src/frame.rs | 55 ++++++++++++ rust/src/lib.rs | 28 +++--- rust/src/room.rs | 2 +- rust/src/rt.rs | 49 ++++++----- rust/src/types.rs | 18 ++++ rust/src/wire.rs | 193 ++++++++++++++++++++++++++++++++++++++---- 7 files changed, 292 insertions(+), 55 deletions(-) diff --git a/rust/src/broadcast.rs b/rust/src/broadcast.rs index 8d93372..c2db12d 100644 --- a/rust/src/broadcast.rs +++ b/rust/src/broadcast.rs @@ -214,7 +214,7 @@ mod tests { } fn player(id: &str) -> Player { - Player { account_id: id.into(), handle: id.into(), conn: "c".into(), kind: Kind::Member } + Player { account_id: id.into(), handle: id.into(), conn: "c".into(), kind: Kind::Member, ..Player::default() } } #[test] diff --git a/rust/src/frame.rs b/rust/src/frame.rs index 248e330..ceb0ca1 100644 --- a/rust/src/frame.rs +++ b/rust/src/frame.rs @@ -13,6 +13,8 @@ //! byte-identity). `pack_into` is the normative enforcer — it always writes //! pad = 0, and an unset color always packs as four zero bytes. +use crate::types::Character; + /// Grid height in rows. Signed so coordinate math (centering, right-aligning) /// never fights sign conversions; negative intermediate columns are legal and /// clamp away. @@ -268,6 +270,25 @@ impl Default for Frame { } } +/// The one ready-made cell of a member's character tile: the glyph styled +/// with the resolved ink and background (player-character capability, +/// shellcade — every catalogue glyph is width 1, so games place a character +/// with zero width logic). The default [`Character`] (the game's meta does +/// not declare [`CTX_FEAT_CHARACTER`]) yields a blank cell. +/// +/// [`CTX_FEAT_CHARACTER`]: crate::types::CTX_FEAT_CHARACTER +pub fn character_cell(c: &Character) -> Cell { + let Some(rune) = c.glyph.chars().next() else { + return Cell::blank(); + }; + Cell { + rune, + fg: Color::rgb(c.ink_r, c.ink_g, c.ink_b), + bg: Color::rgb(c.bg_r, c.bg_g, c.bg_b), + ..Cell::blank() + } +} + fn pack_color(dst: &mut [u8], c: Color) { if c.is_set() { let (r, g, b) = c.rgb_vals(); @@ -361,6 +382,40 @@ mod tests { assert_eq!(f.get(2, COLS - 1).rune, 'i'); } + /// character_cell turns a character into one styled, ready-to-place cell; + /// the default Character (feature not declared) yields a blank. + #[test] + fn character_cell_styles_the_glyph() { + let c = Character { + glyph: "λ".into(), + ink_r: 0x39, + ink_g: 0xFF, + ink_b: 0x14, + bg_r: 0x2D, + bg_g: 0x1B, + bg_b: 0x4E, + fallback: b'L', + }; + let cell = character_cell(&c); + assert_eq!(cell.rune, 'λ'); + assert_eq!(cell.fg, Color::rgb(0x39, 0xFF, 0x14)); + assert_eq!(cell.bg, Color::rgb(0x2D, 0x1B, 0x4E)); + assert_eq!((cell.cp2, cell.cp3), ('\0', '\0')); + assert_eq!(cell.attr, 0); + assert!(!cell.cont); + } + + #[test] + fn character_cell_default_is_blank() { + let blank = character_cell(&Character::default()); + assert_eq!(blank.rune, ' '); + assert!(!blank.fg.is_set()); + assert!(!blank.bg.is_set()); + assert_eq!((blank.cp2, blank.cp3), ('\0', '\0')); + assert_eq!(blank.attr, 0); + assert!(!blank.cont); + } + #[test] fn clear_resets_to_blank() { let mut f = Frame::new(); diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 69fc042..88de8ef 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -82,15 +82,15 @@ pub mod delta; pub mod __rt; pub use frame::{ - Cell, Color, Frame, Style, ATTR_BOLD, ATTR_DIM, ATTR_REVERSE, ATTR_UNDERLINE, COLS, CYAN, - DIM_GRAY, GREEN, RED, ROWS, WHITE, YELLOW, + character_cell, Cell, Color, Frame, Style, ATTR_BOLD, ATTR_DIM, ATTR_REVERSE, ATTR_UNDERLINE, + COLS, CYAN, DIM_GRAY, GREEN, RED, ROWS, WHITE, YELLOW, }; 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, CTX_FEAT_CHARACTER, - CTX_FEAT_ROSTER_EPOCH, Lifecycle, + Aggregation, Character, ConfigKeySpec, ConfigType, Direction, Kind, Leaderboard, MergeRule, + Meta, MetricFormat, Mode, Outcome, Player, PlayerResult, RoomConfig, Status, + CTX_FEAT_CHARACTER, CTX_FEAT_ROSTER_EPOCH, Lifecycle, }; // Native-only scriptable host double for `cargo test` of games and the SDK @@ -123,10 +123,10 @@ pub trait Handler { /// Everything a game file needs: `use shellcade_kit::prelude::*;` pub mod prelude { pub use crate::{ - Action, Aggregation, Cell, Color, Direction, Frame, Game, Handler, Input, InputContext, - Key, Kind, Leaderboard, MergeRule, Meta, MetricFormat, Mode, Outcome, Player, - PlayerResult, Room, RoomConfig, Status, Style, ATTR_BOLD, ATTR_DIM, ATTR_REVERSE, - ATTR_UNDERLINE, COLS, CYAN, DIM_GRAY, GREEN, RED, ROWS, WHITE, YELLOW, + character_cell, Action, Aggregation, Cell, Character, Color, Direction, Frame, Game, + Handler, Input, InputContext, Key, Kind, Leaderboard, MergeRule, Meta, MetricFormat, Mode, + Outcome, Player, PlayerResult, Room, RoomConfig, Status, Style, ATTR_BOLD, ATTR_DIM, + ATTR_REVERSE, ATTR_UNDERLINE, COLS, CYAN, DIM_GRAY, GREEN, RED, ROWS, WHITE, YELLOW, }; } @@ -177,23 +177,23 @@ macro_rules! shellcade_game { } #[unsafe(no_mangle)] extern "C" fn join() -> i32 { - $crate::__rt::join(&__SHELLCADE_HANDLER) + $crate::__rt::join(&__SHELLCADE_HANDLER, &$game) } #[unsafe(no_mangle)] extern "C" fn leave() -> i32 { - $crate::__rt::leave(&__SHELLCADE_HANDLER) + $crate::__rt::leave(&__SHELLCADE_HANDLER, &$game) } #[unsafe(no_mangle)] extern "C" fn input() -> i32 { - $crate::__rt::input(&__SHELLCADE_HANDLER) + $crate::__rt::input(&__SHELLCADE_HANDLER, &$game) } #[unsafe(no_mangle)] extern "C" fn wake() -> i32 { - $crate::__rt::wake(&__SHELLCADE_HANDLER) + $crate::__rt::wake(&__SHELLCADE_HANDLER, &$game) } #[unsafe(no_mangle)] extern "C" fn close() -> i32 { - $crate::__rt::close(&__SHELLCADE_HANDLER) + $crate::__rt::close(&__SHELLCADE_HANDLER, &$game) } }; }; diff --git a/rust/src/room.rs b/rust/src/room.rs index 4fa34f3..3df641a 100644 --- a/rust/src/room.rs +++ b/rust/src/room.rs @@ -185,7 +185,7 @@ mod tests { } fn player(id: &str) -> Player { - Player { account_id: id.into(), handle: id.into(), conn: "c".into(), kind: Kind::Member } + Player { account_id: id.into(), handle: id.into(), conn: "c".into(), kind: Kind::Member, ..Player::default() } } #[test] diff --git a/rust/src/rt.rs b/rust/src/rt.rs index ed6cb94..8c6b7ca 100644 --- a/rust/src/rt.rs +++ b/rust/src/rt.rs @@ -28,8 +28,11 @@ pub const ABI_VERSION: u32 = 2; pub type HandlerCell = LocalKey>>>; /// Decode a callback: CallContext + roster cache/backstop + PRNG seeding. -fn decode_call(input: &[u8]) -> (Room, Rd<'_>) { - let (mut ctx, r) = decode_ctx(input); +/// `features` is the registered game's declared `Meta::ctx_features` — the +/// host shapes the member section by that declaration (per-member character +/// sections iff `CTX_FEAT_CHARACTER`), so the decoder must know it. +fn decode_call(input: &[u8], features: u32) -> (Room, Rd<'_>) { + let (mut ctx, r) = decode_ctx(input, features); STATE.with(|s| { let mut st = s.borrow_mut(); if st.rng.is_none() { @@ -109,16 +112,16 @@ pub fn meta(game: &dyn Game) -> i32 { pub fn start(cell: &'static HandlerCell, game: &dyn Game) -> i32 { let input = read_input(); - let (mut room, _) = decode_call(&input); + let (mut room, _) = decode_call(&input, game.meta().ctx_features); let handler = game.new_room(&room.ctx.cfg); cell.with(|c| *c.borrow_mut() = Some(handler)); with_handler(cell, |h| h.on_start(&mut room)); 0 } -pub fn join(cell: &'static HandlerCell) -> i32 { +pub fn join(cell: &'static HandlerCell, game: &dyn Game) -> i32 { let input = read_input(); - let (mut room, mut r) = decode_call(&input); + let (mut room, mut r) = decode_call(&input, game.meta().ctx_features); let Some(p) = decode_player(&room, &mut r) else { return 0; }; @@ -126,9 +129,9 @@ pub fn join(cell: &'static HandlerCell) -> i32 { 0 } -pub fn leave(cell: &'static HandlerCell) -> i32 { +pub fn leave(cell: &'static HandlerCell, game: &dyn Game) -> i32 { let input = read_input(); - let (mut room, mut r) = decode_call(&input); + let (mut room, mut r) = decode_call(&input, game.meta().ctx_features); let Some(p) = decode_player(&room, &mut r) else { return 0; }; @@ -136,9 +139,9 @@ pub fn leave(cell: &'static HandlerCell) -> i32 { 0 } -pub fn input(cell: &'static HandlerCell) -> i32 { +pub fn input(cell: &'static HandlerCell, game: &dyn Game) -> i32 { let bytes = read_input(); - let (mut room, mut r) = decode_call(&bytes); + let (mut room, mut r) = decode_call(&bytes, game.meta().ctx_features); let Some(p) = decode_player(&room, &mut r) else { return 0; }; @@ -149,16 +152,16 @@ pub fn input(cell: &'static HandlerCell) -> i32 { 0 } -pub fn wake(cell: &'static HandlerCell) -> i32 { +pub fn wake(cell: &'static HandlerCell, game: &dyn Game) -> i32 { let input = read_input(); - let (mut room, _) = decode_call(&input); + let (mut room, _) = decode_call(&input, game.meta().ctx_features); with_handler(cell, |h| h.on_wake(&mut room)); 0 } -pub fn close(cell: &'static HandlerCell) -> i32 { +pub fn close(cell: &'static HandlerCell, game: &dyn Game) -> i32 { let input = read_input(); - let (mut room, _) = decode_call(&input); + let (mut room, _) = decode_call(&input, game.meta().ctx_features); with_handler(cell, |h| h.on_close(&mut room)); 0 } @@ -287,16 +290,16 @@ mod tests { let mut w = ctx_with_members(&["alice"]); w.u32(0); // playerIdx - assert_eq!(run_export(w.b, || join(&CELL)), 0); + assert_eq!(run_export(w.b, || join(&CELL, &TestGame)), 0); let mut w = ctx_with_members(&["alice"]); w.u32(0); w.u8(0); // kind: rune w.u32('5' as u32); w.u8(0); // key - assert_eq!(run_export(w.b, || input(&CELL)), 0); + assert_eq!(run_export(w.b, || input(&CELL, &TestGame)), 0); - assert_eq!(run_export(ctx_with_members(&["alice"]).b, || wake(&CELL)), 0); + assert_eq!(run_export(ctx_with_members(&["alice"]).b, || wake(&CELL, &TestGame)), 0); SEEN.with(|s| { let s = s.borrow(); @@ -317,10 +320,10 @@ mod tests { // out-of-roster index let mut w = ctx_with_members(&["a"]); w.u32(9); - run_export(w.b, || join(&CELL)); + run_export(w.b, || join(&CELL, &TestGame)); // short read: no trailing playerIdx at all - run_export(ctx_with_members(&["a"]).b, || join(&CELL)); + run_export(ctx_with_members(&["a"]).b, || join(&CELL, &TestGame)); // unknown input kind let mut w = ctx_with_members(&["a"]); @@ -328,7 +331,7 @@ mod tests { w.u8(7); // future kind w.u32(0); w.u8(0); - run_export(w.b, || input(&CELL)); + run_export(w.b, || input(&CELL, &TestGame)); // unknown named key (KeyNone / future) let mut w = ctx_with_members(&["a"]); @@ -336,13 +339,13 @@ mod tests { w.u8(1); w.u32(0); w.u8(200); - run_export(w.b, || input(&CELL)); + run_export(w.b, || input(&CELL, &TestGame)); // truncated input event let mut w = ctx_with_members(&["a"]); w.u32(0); w.u8(0); // kind only, no rune/key bytes - run_export(w.b, || input(&CELL)); + run_export(w.b, || input(&CELL, &TestGame)); SEEN.with(|s| { let s = s.borrow(); @@ -361,7 +364,7 @@ mod tests { w.u32('x' as u32); w.u8(0); w.b.extend_from_slice(&[0xde, 0xad, 0xbe, 0xef]); // future growth - run_export(w.b, || input(&CELL)); + run_export(w.b, || input(&CELL, &TestGame)); SEEN.with(|s| assert_eq!(s.borrow().inputs, vec![Input::Char('x')])); } @@ -370,7 +373,7 @@ mod tests { fresh(); let mut w = ctx_with_members(&["a"]); w.u32(0); - run_export(w.b, || join(&CELL)); // no handler yet: drop, return 0 + run_export(w.b, || join(&CELL, &TestGame)); // no handler yet: drop, return 0 SEEN.with(|s| assert!(s.borrow().joins.is_empty())); } } diff --git a/rust/src/types.rs b/rust/src/types.rs index b26adba..b3f836b 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -10,6 +10,23 @@ pub enum Kind { Member, } +/// A player's resolved arcade character (mirrors Go `kit.Character`): a +/// single width-1 glyph with ink/background colors and an ASCII fallback +/// codepoint. The default value means "no character" — what every member +/// carries unless the game declares [`CTX_FEAT_CHARACTER`] in +/// [`Meta::ctx_features`]. +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct Character { + pub glyph: String, + pub ink_r: u8, + pub ink_g: u8, + pub ink_b: u8, + pub bg_r: u8, + pub bg_g: u8, + pub bg_b: u8, + pub fallback: u8, +} + /// A value-comparable membership token (mirrors Go `kit.Player`). #[derive(Clone, PartialEq, Eq, Debug, Default)] pub struct Player { @@ -17,6 +34,7 @@ pub struct Player { pub handle: String, pub kind: Kind, pub conn: String, + pub character: Character, } impl Player { diff --git a/rust/src/wire.rs b/rust/src/wire.rs index de45b1c..5174cd0 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -3,7 +3,7 @@ //! dispatch layer gates on — degraded values are never delivered to a game), //! CallContext decode, and the Meta / Result encoders. Target-agnostic. -use crate::types::{Kind, Meta, Mode, Outcome, Player, RoomConfig}; +use crate::types::{Character, Kind, Meta, Mode, Outcome, Player, RoomConfig, CTX_FEAT_CHARACTER}; // ---- little-endian append encoder ------------------------------------------ @@ -112,7 +112,14 @@ pub(crate) struct CallCtx { /// Decode the CallContext prefix and return it plus the reader positioned at /// the trailing per-export args (e.g. playerIdx for join/leave/input). -pub(crate) fn decode_ctx(input: &[u8]) -> (CallCtx, Rd<'_>) { +/// `features` is the registered game's [`Meta::ctx_features`]: the host +/// encodes per-member character sections iff the guest's meta declares +/// [`CTX_FEAT_CHARACTER`] — there is no in-band discriminator — so the +/// decoder must know the declaration to read the member section with the +/// right shape. +/// +/// [`Meta::ctx_features`]: crate::types::Meta::ctx_features +pub(crate) fn decode_ctx(input: &[u8], features: u32) -> (CallCtx, Rd<'_>) { let mut r = Rd::new(input); let now = r.i64(); let seed = r.i64(); @@ -135,9 +142,9 @@ pub(crate) fn decode_ctx(input: &[u8]) -> (CallCtx, Rd<'_>) { CTX_ROSTER_FULL => { let epoch = r.u32(); let n = r.u16() as usize; - (decode_members(&mut r, n), Some(epoch), false) + (decode_members(&mut r, n, features), Some(epoch), false) } - n => (decode_members(&mut r, n as usize), None, false), + n => (decode_members(&mut r, n as usize, features), None, false), }; let settled = r.u8() != 0; ( @@ -153,17 +160,33 @@ pub(crate) fn decode_ctx(input: &[u8]) -> (CallCtx, Rd<'_>) { ) } -fn decode_members(r: &mut Rd<'_>, n: usize) -> Vec { +fn decode_members(r: &mut Rd<'_>, n: usize, features: u32) -> Vec { let mut members = Vec::with_capacity(n.min(64)); for _ in 0..n { let handle = r.string(); let account_id = r.string(); let conn = r.string(); let kind = if r.u8() == 1 { Kind::Member } else { Kind::Guest }; + // Character section (the host sends it iff our meta declares the + // feature): glyph str + 7 fixed bytes (ink RGB, bg RGB, fallback). + let character = if features & CTX_FEAT_CHARACTER != 0 { + Character { + glyph: r.string(), + ink_r: r.u8(), + ink_g: r.u8(), + ink_b: r.u8(), + bg_r: r.u8(), + bg_g: r.u8(), + bg_b: r.u8(), + fallback: r.u8(), + } + } else { + Character::default() + }; if r.bad() { break; // degrade: keep what decoded cleanly } - members.push(Player { handle, account_id, conn, kind }); + members.push(Player { handle, account_id, conn, kind, character }); } members } @@ -333,7 +356,7 @@ mod tests { #[test] fn ctx_round_trip_and_trailing_args() { let payload = ctx_payload(&[("alice", "acct-a", 1), ("bob", "acct-b", 0)], &7u32.to_le_bytes()); - let (ctx, mut r) = decode_ctx(&payload); + let (ctx, mut r) = decode_ctx(&payload, 0); assert_eq!(ctx.now_unix_nanos, 123_000_000); assert_eq!(ctx.cfg.seed, 42); assert!(ctx.cfg.seed_set); @@ -345,10 +368,23 @@ mod tests { assert!(!r.bad()); } + /// The old path is provably unchanged: a non-declaring guest decoding a + /// feature-off payload yields default (no-)characters for every member. + #[test] + fn feature_off_decode_yields_default_characters() { + let payload = ctx_payload(&[("alice", "acct-a", 1), ("bob", "acct-b", 0)], &7u32.to_le_bytes()); + let (ctx, mut r) = decode_ctx(&payload, 0); + for m in ctx.members.iter() { + assert_eq!(m.character, crate::types::Character::default()); + } + assert_eq!(r.u32(), 7); + assert!(!r.bad()); + } + #[test] fn short_read_degrades_and_latches_bad() { let payload = ctx_payload(&[("alice", "acct-a", 1)], &[]); - let (_, mut r) = decode_ctx(&payload); + let (_, mut r) = decode_ctx(&payload, 0); assert_eq!(r.u32(), 0); // no trailing u32 → degrade to 0 assert!(r.bad()); assert_eq!(r.u32(), 0); // stays degraded @@ -358,7 +394,7 @@ mod tests { fn truncated_ctx_never_panics() { let full = ctx_payload(&[("alice", "acct-a", 1)], &[]); for n in 0..full.len() { - let (_ctx, _r) = decode_ctx(&full[..n]); // must not panic + let (_ctx, _r) = decode_ctx(&full[..n], 0); // must not panic } } @@ -529,8 +565,8 @@ mod tests { #[test] fn outcome_maps_players_to_roster_indices() { let roster = vec![ - Player { handle: "a".into(), account_id: "ia".into(), conn: "c1".into(), kind: Kind::Member }, - Player { handle: "b".into(), account_id: "ib".into(), conn: "c2".into(), kind: Kind::Guest }, + Player { handle: "a".into(), account_id: "ia".into(), conn: "c1".into(), kind: Kind::Member, ..Player::default() }, + Player { handle: "b".into(), account_id: "ib".into(), conn: "c2".into(), kind: Kind::Guest, ..Player::default() }, ]; let res = Outcome { rankings: vec![ @@ -573,7 +609,7 @@ mod tests { w.u8(1); // kind member w.u8(0); // settled w.u8(0xAB); // event extra - let (ctx, mut r) = decode_ctx(&w.b); + let (ctx, mut r) = decode_ctx(&w.b, 0); assert_eq!(ctx.roster_epoch, Some(42)); assert!(!ctx.roster_unchanged); assert_eq!(ctx.members.len(), 1); @@ -591,7 +627,7 @@ mod tests { w.u32(43); w.u8(0); // settled w.u8(0xCD); - let (ctx, mut r) = decode_ctx(&w.b); + let (ctx, mut r) = decode_ctx(&w.b, 0); assert_eq!(ctx.roster_epoch, Some(43)); assert!(ctx.roster_unchanged); assert!(ctx.members.is_empty()); @@ -759,6 +795,7 @@ mod scalar_golden { account_id: account_id.into(), conn: conn.into(), kind, + ..Player::default() } } let roster = vec![ @@ -813,7 +850,7 @@ mod scalar_golden { #[test] fn ctx_legacy_replays_go_bytes() { let b = vector("ctx_legacy"); - let (ctx, mut r) = decode_ctx(&b); + let (ctx, mut r) = decode_ctx(&b, 0); assert_ctx_common(&ctx); assert_eq!(ctx.roster_epoch, None); assert!(!ctx.roster_unchanged); @@ -824,7 +861,7 @@ mod scalar_golden { #[test] fn ctx_epoch_full_replays_go_bytes() { let b = vector("ctx_epoch_full"); - let (ctx, mut r) = decode_ctx(&b); + let (ctx, mut r) = decode_ctx(&b, CTX_FEAT_ROSTER_EPOCH); assert_ctx_common(&ctx); assert_eq!(ctx.roster_epoch, Some(42)); assert!(!ctx.roster_unchanged); @@ -835,7 +872,7 @@ mod scalar_golden { #[test] fn ctx_epoch_unchanged_replays_go_bytes() { let b = vector("ctx_epoch_unchanged"); - let (ctx, mut r) = decode_ctx(&b); + let (ctx, mut r) = decode_ctx(&b, CTX_FEAT_ROSTER_EPOCH); assert_ctx_common(&ctx); assert_eq!(ctx.roster_epoch, Some(43)); assert!(ctx.roster_unchanged); @@ -844,3 +881,127 @@ mod scalar_golden { } } +/// Cross-language golden replay for the per-member ctx character section +/// (CtxFeatCharacter, ABI.md §4.1): the vectors in +/// `rust/tests/golden/ctx_character.txt` are EMITTED by the Go reference +/// encoders (`kit/wire`, `character_golden_test.go` — whose +/// `TestCtxCharacterGoldenFresh` fails the Go test run if the committed file +/// goes stale) and replayed here. A Ctx is host-encoded (host→guest only), so +/// this side asserts DECODE parity — every field of both members plus the +/// reader position at the trailing u32 event-extra — in both member-bearing +/// forms. Lives beside `scalar_golden` because `decode_ctx` is crate-private. +#[cfg(test)] +mod ctx_character_golden { + use super::*; + use crate::types::{Character, Kind, Mode, CTX_FEAT_CHARACTER, CTX_FEAT_ROSTER_EPOCH}; + + const VECTORS: &str = include_str!("../tests/golden/ctx_character.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/ctx_character.txt — regenerate from kit/wire:\n \ + WIRE_SCALAR_GOLDEN_WRITE=1 go test -run TestCtxCharacterGoldenFresh ./wire/" + ); + } + + // Go: ctxCharacterFixture — the canonical two-member character roster. + fn assert_ctx_common(ctx: &CallCtx) { + assert_eq!(ctx.now_unix_nanos, 9); + assert_eq!(ctx.cfg.seed, 7); + assert!(ctx.cfg.seed_set); + assert_eq!(ctx.cfg.mode, Mode::Private); + assert_eq!(ctx.cfg.capacity, 4); + assert_eq!(ctx.cfg.min_players, 2); + assert!(ctx.settled); + } + + fn assert_character_roster(members: &[Player]) { + assert_eq!(members.len(), 2); + assert_eq!(members[0].handle, "ana"); + assert_eq!(members[0].account_id, "a-1"); + assert_eq!(members[0].conn, "c-1"); + assert_eq!(members[0].kind, Kind::Member); + assert_eq!( + members[0].character, + Character { + glyph: "λ".into(), + ink_r: 0x39, + ink_g: 0xFF, + ink_b: 0x14, + bg_r: 0x2D, + bg_g: 0x1B, + bg_b: 0x4E, + fallback: b'L', + } + ); + assert_eq!(members[1].handle, "bob"); + assert_eq!(members[1].account_id, "a-2"); + assert_eq!(members[1].conn, "c-2"); + assert!(members[1].guest()); + assert_eq!( + members[1].character, + Character { + glyph: "@".into(), + ink_r: 1, + ink_g: 2, + ink_b: 3, + bg_r: 4, + bg_g: 5, + bg_b: 6, + fallback: b'@', + } + ); + } + + 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_character_legacy_replays_go_bytes() { + let b = vector("ctx_character_legacy"); + let (ctx, mut r) = decode_ctx(&b, CTX_FEAT_CHARACTER); + assert_ctx_common(&ctx); + assert_eq!(ctx.roster_epoch, None); + assert!(!ctx.roster_unchanged); + assert_character_roster(&ctx.members); + assert_event_extra(&mut r, "ctx_character_legacy"); + } + + #[test] + fn ctx_character_epoch_full_replays_go_bytes() { + let b = vector("ctx_character_epoch_full"); + let (ctx, mut r) = decode_ctx(&b, CTX_FEAT_CHARACTER | CTX_FEAT_ROSTER_EPOCH); + assert_ctx_common(&ctx); + assert_eq!(ctx.roster_epoch, Some(42)); + assert!(!ctx.roster_unchanged); + assert_character_roster(&ctx.members); + assert_event_extra(&mut r, "ctx_character_epoch_full"); + } +} From 414507c9fc79c31593cb412549e13845add4093c Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Thu, 11 Jun 2026 09:42:02 +1000 Subject: [PATCH 7/9] rust: cache declared ctx features once; fuzz the character branch Co-Authored-By: Claude Fable 5 --- rust/src/broadcast.rs | 2 ++ rust/src/lib.rs | 4 ++++ rust/src/rt.rs | 39 ++++++++++++++++++++++++++++++++------- rust/src/wire.rs | 28 +++++++++++++++++++++++++++- 4 files changed, 65 insertions(+), 8 deletions(-) diff --git a/rust/src/broadcast.rs b/rust/src/broadcast.rs index c2db12d..5cd730e 100644 --- a/rust/src/broadcast.rs +++ b/rust/src/broadcast.rs @@ -83,6 +83,8 @@ impl SdkState { /// The roster-change backstop run at every callback decode: a cheap /// FNV-1a fingerprint of the roster (count + account ids + kinds) compared /// to the previous callback's; any change invalidates every baseline. + /// Character bytes are deliberately excluded: a character is fixed per + /// connection, so any change rides a roster event the fingerprint catches. pub fn roster_gate(&mut self, members: &[Player]) { let print = roster_fingerprint(members); if self.last_roster != Some(print) { diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 88de8ef..104d289 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -102,6 +102,10 @@ pub use host::{reset_test_host, with_test_host, SentPayload, TestHost}; /// The module entry: static metadata plus the per-room behavior factory /// (mirrors Go `kit.Game`). pub trait Game { + /// The game's static metadata. Must return a complete value: the SDK + /// reads it once per concern and caches what it needs (the first callback + /// captures [`Meta::ctx_features`] for every later decode) — the same + /// read-once contract as the Go kit's `Run`. fn meta(&self) -> Meta; fn new_room(&self, cfg: &RoomConfig) -> Box; } diff --git a/rust/src/rt.rs b/rust/src/rt.rs index 8c6b7ca..77d4041 100644 --- a/rust/src/rt.rs +++ b/rust/src/rt.rs @@ -8,7 +8,7 @@ //! natively against the test host; the macro only generates the eight //! wasm-gated `#[unsafe(no_mangle)]` trampolines and the room cell. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::thread::LocalKey; use crate::host::{read_input, write_output}; @@ -27,6 +27,31 @@ pub const ABI_VERSION: u32 = 2; /// borrow panic, never UB). pub type HandlerCell = LocalKey>>>; +thread_local! { + /// The game's declared `Meta::ctx_features`, read ONCE from `Game::meta` + /// on the first callback and cached for the instance's life (the Rust + /// counterpart of Go's `Run` capturing `declaredCtxFeatures` at + /// registration). Every decode needs the bits — the host shapes the + /// member section by the declaration — and `wake` runs ~20×/sec, so + /// re-running the author's `meta()` per callback is not an option in the + /// allocation-free steady state. + static CTX_FEATURES: Cell> = const { Cell::new(None) }; +} + +/// The cached declared feature bits, initialized from `game.meta()` on the +/// first callback (whichever export that is — the host always starts first, +/// but the cache does not depend on it). +fn declared_ctx_features(game: &dyn Game) -> u32 { + CTX_FEATURES.with(|c| { + if let Some(f) = c.get() { + return f; + } + let f = game.meta().ctx_features; + c.set(Some(f)); + f + }) +} + /// Decode a callback: CallContext + roster cache/backstop + PRNG seeding. /// `features` is the registered game's declared `Meta::ctx_features` — the /// host shapes the member section by that declaration (per-member character @@ -112,7 +137,7 @@ pub fn meta(game: &dyn Game) -> i32 { pub fn start(cell: &'static HandlerCell, game: &dyn Game) -> i32 { let input = read_input(); - let (mut room, _) = decode_call(&input, game.meta().ctx_features); + let (mut room, _) = decode_call(&input, declared_ctx_features(game)); let handler = game.new_room(&room.ctx.cfg); cell.with(|c| *c.borrow_mut() = Some(handler)); with_handler(cell, |h| h.on_start(&mut room)); @@ -121,7 +146,7 @@ pub fn start(cell: &'static HandlerCell, game: &dyn Game) -> i32 { pub fn join(cell: &'static HandlerCell, game: &dyn Game) -> i32 { let input = read_input(); - let (mut room, mut r) = decode_call(&input, game.meta().ctx_features); + let (mut room, mut r) = decode_call(&input, declared_ctx_features(game)); let Some(p) = decode_player(&room, &mut r) else { return 0; }; @@ -131,7 +156,7 @@ pub fn join(cell: &'static HandlerCell, game: &dyn Game) -> i32 { pub fn leave(cell: &'static HandlerCell, game: &dyn Game) -> i32 { let input = read_input(); - let (mut room, mut r) = decode_call(&input, game.meta().ctx_features); + let (mut room, mut r) = decode_call(&input, declared_ctx_features(game)); let Some(p) = decode_player(&room, &mut r) else { return 0; }; @@ -141,7 +166,7 @@ pub fn leave(cell: &'static HandlerCell, game: &dyn Game) -> i32 { pub fn input(cell: &'static HandlerCell, game: &dyn Game) -> i32 { let bytes = read_input(); - let (mut room, mut r) = decode_call(&bytes, game.meta().ctx_features); + let (mut room, mut r) = decode_call(&bytes, declared_ctx_features(game)); let Some(p) = decode_player(&room, &mut r) else { return 0; }; @@ -154,14 +179,14 @@ pub fn input(cell: &'static HandlerCell, game: &dyn Game) -> i32 { pub fn wake(cell: &'static HandlerCell, game: &dyn Game) -> i32 { let input = read_input(); - let (mut room, _) = decode_call(&input, game.meta().ctx_features); + let (mut room, _) = decode_call(&input, declared_ctx_features(game)); with_handler(cell, |h| h.on_wake(&mut room)); 0 } pub fn close(cell: &'static HandlerCell, game: &dyn Game) -> i32 { let input = read_input(); - let (mut room, _) = decode_call(&input, game.meta().ctx_features); + let (mut room, _) = decode_call(&input, declared_ctx_features(game)); with_handler(cell, |h| h.on_close(&mut room)); 0 } diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 5174cd0..4f94aaa 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -184,7 +184,11 @@ fn decode_members(r: &mut Rd<'_>, n: usize, features: u32) -> Vec { Character::default() }; if r.bad() { - break; // degrade: keep what decoded cleanly + // Degrade: keep what decoded cleanly. Deliberate cross-language + // difference: Go appends the partially-read (zero-padded) member; + // this side drops it — both never deliver it (the dispatch layer + // gates on bad and drops the callback). + break; } members.push(Player { handle, account_id, conn, kind, character }); } @@ -396,6 +400,28 @@ mod tests { for n in 0..full.len() { let (_ctx, _r) = decode_ctx(&full[..n], 0); // must not panic } + // A character-bearing payload decoded WITH the bit: the character + // section's reads must degrade at every truncation point too. + 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(2); // capacity + w.u16(1); // min players + w.u16(1); // count + w.str("ana"); + w.str("a-1"); + w.str("c-1"); + w.u8(1); // kind member + w.str("λ"); // glyph + for b in [1u8, 2, 3, 4, 5, 6, b'L'] { + w.u8(b); // ink RGB, bg RGB, fallback + } + w.u8(1); // settled + for n in 0..w.b.len() { + let (_ctx, _r) = decode_ctx(&w.b[..n], CTX_FEAT_CHARACTER); // must not panic + } } #[test] From 7bf09c6711e3474fdaef7d841a17a0e6e8db4e5e Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Thu, 11 Jun 2026 09:43:03 +1000 Subject: [PATCH 8/9] changeset: CtxFeatCharacter minor Co-Authored-By: Claude Fable 5 --- .changeset/character-ctx-feature.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/character-ctx-feature.md diff --git a/.changeset/character-ctx-feature.md b/.changeset/character-ctx-feature.md new file mode 100644 index 0000000..84cbff9 --- /dev/null +++ b/.changeset/character-ctx-feature.md @@ -0,0 +1,12 @@ +--- +"kit": minor +--- + +Per-member player character in the CallContext behind a new declared +`CtxFeatCharacter` (1<<1) ctx feature: each roster member carries +`str glyph · u8 ink RGB · u8 bg RGB · u8 asciiFallback` after its kind byte, +in both member-bearing forms (wire revision 5). Go and Rust SDKs expose +`Character` on `Player` and a `CharacterCell` / `character_cell` helper +returning the one styled cell — every catalogue glyph is width 1, so games +place a player's character with zero width logic. Non-declaring guests +decode byte-identically. From 1346f864e589c1d31f9d53a078996faa0829a43d Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Thu, 11 Jun 2026 10:02:15 +1000 Subject: [PATCH 9/9] docs: cite ABI.md instead of the private capability name Co-Authored-By: Claude Fable 5 --- internal/game/grid.go | 6 +++--- rust/src/frame.rs | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/internal/game/grid.go b/internal/game/grid.go index 0906a3d..96c64d7 100644 --- a/internal/game/grid.go +++ b/internal/game/grid.go @@ -219,9 +219,9 @@ func (f *Frame) Fill(r0, c0, r1, c1 int, cell Cell) { } // CharacterCell returns the one ready-made cell of a member's character tile: -// the glyph styled with the resolved ink and background (player-character -// capability, shellcade — every catalogue glyph is width 1, so games place a -// character with zero width logic). The zero Character (the game's meta does +// the glyph styled with the resolved ink and background (see ABI.md §4.1 — +// every admitted glyph is width 1, so games place a character with zero +// width logic). The zero Character (the game's meta does // not declare CtxFeatCharacter) yields a blank cell. func CharacterCell(c Character) Cell { if c.Glyph == "" { diff --git a/rust/src/frame.rs b/rust/src/frame.rs index ceb0ca1..7313c33 100644 --- a/rust/src/frame.rs +++ b/rust/src/frame.rs @@ -271,9 +271,8 @@ impl Default for Frame { } /// The one ready-made cell of a member's character tile: the glyph styled -/// with the resolved ink and background (player-character capability, -/// shellcade — every catalogue glyph is width 1, so games place a character -/// with zero width logic). The default [`Character`] (the game's meta does +/// with the resolved ink and background (see ABI.md §4.1 — every admitted +/// glyph is width 1, so games place a character with zero width logic). The default [`Character`] (the game's meta does /// not declare [`CTX_FEAT_CHARACTER`]) yields a blank cell. /// /// [`CTX_FEAT_CHARACTER`]: crate::types::CTX_FEAT_CHARACTER