From b644ed6d23bbcac4e187b6135a8b3ce0a254e2ab Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Thu, 11 Jun 2026 20:21:54 +1000 Subject: [PATCH] feat: declared extra controls in GameMeta (wire revision 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trailing presence-guarded meta section listing inputs beyond the canonical control vocabulary — a printable rune or a named key, each with a short display label — so front ends on devices without the corresponding physical key (touch) can surface each declaration as a tappable affordance sending exactly the declared input. Presentation metadata only; declarations change no input interpretation. Go: GameMeta.Controls + RuneControl/KeyControl, wire.ControlDecl + ValidateControls, named key-code constants. Rust: Meta::controls + ControlDecl, Key::to_wire, byte-identical encoding (golden-pinned, incl. a new controls golden and the regenerated scalar vectors). ABI.md §4.2 declared-controls section + revision ledger entry 6; GUIDE.md mobile-friendly-controls authoring section. Co-Authored-By: Claude Fable 5 --- .changeset/meta-declared-controls.md | 18 ++++ ABI.md | 26 +++++- GUIDE.md | 29 ++++++ crossverify/Cargo.lock | 2 +- internal/game/codec.go | 13 +++ internal/game/controls_meta_test.go | 46 ++++++++++ internal/game/types.go | 29 ++++++ kit.go | 9 ++ rust/Cargo.lock | 2 +- rust/src/input.rs | 17 ++++ rust/src/lib.rs | 13 +-- rust/src/types.rs | 19 ++++ rust/src/wire.rs | 99 ++++++++++++++++++-- rust/tests/golden/scalars.txt | 5 +- wire/controls_test.go | 131 +++++++++++++++++++++++++++ wire/largeroom_test.go | 19 ++-- wire/scalar_golden_test.go | 27 ++++-- wire/wire.go | 124 ++++++++++++++++++++++++- 18 files changed, 594 insertions(+), 34 deletions(-) create mode 100644 .changeset/meta-declared-controls.md create mode 100644 internal/game/controls_meta_test.go create mode 100644 wire/controls_test.go diff --git a/.changeset/meta-declared-controls.md b/.changeset/meta-declared-controls.md new file mode 100644 index 0000000..edbb63b --- /dev/null +++ b/.changeset/meta-declared-controls.md @@ -0,0 +1,18 @@ +--- +"kit": minor +--- + +Declared extra controls in `GameMeta` (wire revision 6): a new trailing +presence-guarded meta section listing inputs beyond the canonical control +vocabulary — a printable rune or a named key, each with a short display +label — so front ends on devices without the corresponding physical key +(touch) can surface each declaration as a tappable affordance that sends +exactly the declared input. Presentation metadata only: declarations change +no input interpretation, and games fully served by the canonical vocabulary +need none. Go SDK: `GameMeta.Controls` + `kit.RuneControl` / +`kit.KeyControl`; Rust SDK: `Meta::controls` + `ControlDecl`, encoded +byte-identically (golden-pinned). Validation (`wire.ValidateControls`, +enforced at `meta()` encode time): printable rune or assigned key code, +non-empty label ≤16 runes, no duplicate inputs, ≤32 declarations. ABI.md +§4.2 documents the section; GUIDE.md gains a mobile-friendly-controls +authoring section. diff --git a/ABI.md b/ABI.md index 57f308c..80ef80f 100644 --- a/ABI.md +++ b/ABI.md @@ -188,6 +188,10 @@ u32 ctxFeatures trailing large-room section (see below); bit 0 = CtxFeatRo 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) +u16 controlCount trailing declared-controls section (see below) + per control: u8 kind (0 rune · 1 key) + · if rune: u32 rune · if key: u8 key (the input key codes, §2) + · str label ``` `slug` must be non-empty; the host refuses artifacts whose slug or version it @@ -259,7 +263,7 @@ 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 · `5` per-member ctx character section behind -`CtxFeatCharacter`. `0` means unknown: the meta predates the field +`CtxFeatCharacter` · `6` declared-controls section. `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 @@ -279,6 +283,26 @@ deploy-order rule: without it a too-new artifact surfaces only as every delta container being rejected (a frozen screen), not as a diagnosable version skew. +**Declared-controls section (minor addition).** A trailing section after the +wire-revision field, presence-guarded under the same rules (absent = no +declarations; encoders that know the section always write it, count `0` when +nothing is declared): the game's **declared extra controls** — inputs beyond +the canonical control vocabulary (Up/Down/Left/Right via arrows-or-hjkl, +Confirm via Enter/Space, Back via Esc/q), each paired with a short display +label. A front end on a device that cannot produce the declared key (a touch +screen without a physical keyboard) surfaces each declaration as a tappable +affordance that sends **exactly the declared input**, indistinguishable from +the key itself. Declarations are presentation metadata only: they change no +input interpretation, and a game fully served by the canonical vocabulary +needs none. Each entry is `u8 kind` (`0` rune · `1` key) followed by the +input value (`u32 rune` for kind 0; `u8 key` for kind 1, the same key codes +as the `input` export) and `str label`. An entry's size depends on its kind, +so decoders MUST fail on an unknown kind rather than skip it (it cannot be +framed past). Declared controls must satisfy (`wire.ValidateControls`, the +shared rule set): a printable rune (≥ U+0020) or an assigned key code (1–9); +a non-empty label of at most 16 runes; no duplicate inputs; at most 32 +declarations. SDKs enforce these at `meta()` encode time. + ### 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 d7b801e..1544641 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -224,6 +224,35 @@ Declare your input context once (`r.SetInputContext`): `CtxNav` for menus and move-around games, `CtxCommand` when letters are commands (blackjack's h/s/d), `CtxText` for typing games. +### Mobile-friendly controls: declare what's beyond the vocabulary + +Players on phones reach the arcade through a touch deck that produces exactly +the canonical vocabulary — arrows, Confirm, Back — plus the OS soft keyboard +when your game publishes `CtxText`. A game that resolves everything through +`Resolve` is fully playable on touch with zero work. + +If your game reads inputs **beyond** the vocabulary — letter commands like +`r` to resign, or a named key like Backspace to undo — declare them in +`GameMeta.Controls` so touch front ends can surface each one as a tappable +button carrying your label: + +```go +Controls: []kit.ControlDecl{ + kit.RuneControl('r', "RESIGN"), + kit.RuneControl('d', "DRAW"), + kit.KeyControl(kit.KeyBackspace, "UNDO"), +}, +``` + +A declaration is presentation metadata only — tapping the button delivers the +exact declared input to `OnInput`, indistinguishable from the key, and +declarations never change how any input resolves. Keep labels short (≤16 +runes; they render as small chips), declare at most the handful of inputs +that matter (≤32), and don't declare what the vocabulary already covers. +Expect players whose only inputs are the canonical actions, your declared +controls, and (in `CtxText`) free text — if that set can't play your game, +neither can a phone. + ## Action games: held keys, raw input, geometry Terminals (and SSH) deliver only discrete key events — **there is no key-up**, diff --git a/crossverify/Cargo.lock b/crossverify/Cargo.lock index 5e5478b..8b08735 100644 --- a/crossverify/Cargo.lock +++ b/crossverify/Cargo.lock @@ -311,7 +311,7 @@ dependencies = [ [[package]] name = "shellcade-kit" -version = "2.1.1" +version = "2.9.0" dependencies = [ "extism-pdk", ] diff --git a/internal/game/codec.go b/internal/game/codec.go index 52eafcc..5c6fbf8 100644 --- a/internal/game/codec.go +++ b/internal/game/codec.go @@ -211,6 +211,19 @@ func encodeMeta(m GameMeta) []byte { if err := wire.ValidateLifecycle(wm.Lifecycle, wm.MinPlayers); err != nil { panic("kit: invalid GameMeta: " + err.Error()) } + // Declared-controls trailer, validated under the same fail-fast posture + // as config specs. + for _, cd := range m.Controls { + wm.Controls = append(wm.Controls, wire.ControlDecl{ + Kind: uint8(cd.Input.Kind), + Rune: cd.Input.Rune, + Key: uint8(cd.Input.Key), + Label: cd.Label, + }) + } + if err := wire.ValidateControls(wm.Controls); err != nil { + panic("kit: invalid GameMeta.Controls: " + err.Error()) + } // Stamp the wire revision this kit was built against — not // author-settable; the host uses it to warn on or refuse artifacts // declaring a revision above its own (deploy-order enforcement and diff --git a/internal/game/controls_meta_test.go b/internal/game/controls_meta_test.go new file mode 100644 index 0000000..dda737c --- /dev/null +++ b/internal/game/controls_meta_test.go @@ -0,0 +1,46 @@ +package game + +import ( + "reflect" + "testing" + + "github.com/shellcade/kit/v2/wire" +) + +// TestEncodeMetaMapsControls pins the authoring → wire mapping for declared +// controls: RuneControl/KeyControl literals land as the wire kinds/values, in +// declaration order. +func TestEncodeMetaMapsControls(t *testing.T) { + m := GameMeta{ + Slug: "chess", Name: "Chess", MinPlayers: 2, MaxPlayers: 2, + Controls: []ControlDecl{ + RuneControl('r', "RESIGN"), + KeyControl(KeyBackspace, "UNDO"), + }, + } + out, err := wire.DecodeMeta(encodeMeta(m)) + if err != nil { + t.Fatal(err) + } + want := []wire.ControlDecl{ + {Kind: wire.InputRune, Rune: 'r', Label: "RESIGN"}, + {Kind: wire.InputKey, Key: wire.KeyCodeBackspace, Label: "UNDO"}, + } + if !reflect.DeepEqual(out.Controls, want) { + t.Fatalf("controls mismatch:\n got=%+v\nwant=%+v", out.Controls, want) + } +} + +// TestEncodeMetaPanicsOnInvalidControls pins the fail-fast posture: an +// invalid declaration panics at meta() encode time, not at load time. +func TestEncodeMetaPanicsOnInvalidControls(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("encodeMeta accepted an empty control label") + } + }() + encodeMeta(GameMeta{ + Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 1, + Controls: []ControlDecl{RuneControl('r', "")}, + }) +} diff --git a/internal/game/types.go b/internal/game/types.go index 658a0b6..a87e454 100644 --- a/internal/game/types.go +++ b/internal/game/types.go @@ -223,6 +223,35 @@ type GameMeta struct { // resident-with-MinPlayers>1 are authoring bugs and panic at meta // encode time. Lifecycle Lifecycle + + // Controls optionally declares the game's extra controls: inputs beyond + // the canonical vocabulary (a raw rune like 'r', or a named key like + // KeyBackspace), each with a short display label. Front ends on devices + // without the corresponding physical key (touch) surface each + // declaration as a tappable affordance that sends exactly the declared + // input — presentation metadata only; declarations change no input + // interpretation. Nil/empty means no declarations, and a game fully + // served by the canonical vocabulary needs none. Invalid declarations + // are an authoring bug and panic at meta encode time. + Controls []ControlDecl +} + +// ControlDecl declares one extra control: the exact Input it sends (a +// printable rune or a named key) and a short display label of at most 16 +// runes. Build with RuneControl / KeyControl. +type ControlDecl struct { + Input Input + Label string +} + +// RuneControl declares a printable-rune control, e.g. RuneControl('r', "RESIGN"). +func RuneControl(r rune, label string) ControlDecl { + return ControlDecl{Input: Input{Kind: InputRune, Rune: r}, Label: label} +} + +// KeyControl declares a named-key control, e.g. KeyControl(KeyBackspace, "UNDO"). +func KeyControl(k Key, label string) ControlDecl { + return ControlDecl{Input: Input{Kind: InputKey, Key: k}, Label: label} } // Lifecycle is the room end-of-life declaration. diff --git a/kit.go b/kit.go index 8e51aa6..3579f71 100644 --- a/kit.go +++ b/kit.go @@ -63,6 +63,14 @@ const ( // platform's canonical control vocabulary, reimplemented locally. func Resolve(in Input, ctx InputContext) Action { return game.Resolve(in, ctx) } +// RuneControl declares a printable-rune extra control for GameMeta.Controls, +// e.g. RuneControl('r', "RESIGN"). +func RuneControl(r rune, label string) ControlDecl { return game.RuneControl(r, label) } + +// KeyControl declares a named-key extra control for GameMeta.Controls, +// e.g. KeyControl(KeyBackspace, "UNDO"). +func KeyControl(k Key, label string) ControlDecl { return game.KeyControl(k, label) } + // ---- rooms & results ------------------------------------------------------------- type ( @@ -72,6 +80,7 @@ type ( GameMeta = game.GameMeta LeaderboardSpec = game.LeaderboardSpec ConfigKeySpec = game.ConfigKeySpec + ControlDecl = game.ControlDecl Lifecycle = game.Lifecycle ConfigType = game.ConfigType Direction = game.Direction diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7902963..12b173d 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -304,7 +304,7 @@ dependencies = [ [[package]] name = "shellcade-kit" -version = "2.6.0" +version = "2.9.0" dependencies = [ "extism-pdk", ] diff --git a/rust/src/input.rs b/rust/src/input.rs index c83fa42..659c9a7 100644 --- a/rust/src/input.rs +++ b/rust/src/input.rs @@ -38,6 +38,23 @@ impl Key { _ => return None, }) } + + /// Key → wire value (ABI.md §2): the inverse of `from_wire`, used when + /// the SDK itself encodes an input (declared controls in the meta + /// payload). Internal key additions must update both directions. + pub(crate) fn to_wire(self) -> u8 { + match self { + Key::Enter => 1, + Key::Backspace => 2, + Key::Esc => 3, + Key::Tab => 4, + Key::Up => 5, + Key::Down => 6, + Key::Left => 7, + Key::Right => 8, + Key::CtrlC => 9, + } + } } /// The SDK-neutral input event (Go's `kit.Input`, as a sum type). diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 104d289..5d4e567 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -88,8 +88,8 @@ pub use frame::{ pub use input::{Action, Input, InputContext, Key}; pub use room::Room; pub use types::{ - Aggregation, Character, ConfigKeySpec, ConfigType, Direction, Kind, Leaderboard, MergeRule, - Meta, MetricFormat, Mode, Outcome, Player, PlayerResult, RoomConfig, Status, + Aggregation, Character, ConfigKeySpec, ConfigType, ControlDecl, Direction, Kind, Leaderboard, + MergeRule, Meta, MetricFormat, Mode, Outcome, Player, PlayerResult, RoomConfig, Status, CTX_FEAT_CHARACTER, CTX_FEAT_ROSTER_EPOCH, Lifecycle, }; @@ -127,10 +127,11 @@ pub trait Handler { /// Everything a game file needs: `use shellcade_kit::prelude::*;` pub mod prelude { pub use crate::{ - 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, + character_cell, Action, Aggregation, Cell, Character, Color, ControlDecl, 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, }; } diff --git a/rust/src/types.rs b/rust/src/types.rs index b3f836b..4efc832 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -251,6 +251,24 @@ pub struct Meta { /// per slug (takes effect only when the platform grants it). /// Resident with `min_players > 1` panics at `meta()` encode time. pub lifecycle: Lifecycle, + + /// Declared extra controls (`&[]` = none declared): inputs beyond the + /// canonical vocabulary — a printable rune or a named key — each with a + /// short display label, surfaced by touch front ends as tappable + /// affordances that send exactly the declared input. Presentation + /// metadata only; declarations change no input interpretation. Validated + /// at `meta()` encode time — an invalid declaration is an authoring bug + /// and panics there. + pub controls: &'static [ControlDecl], +} + +/// One declared extra control ([`Meta::controls`]): the exact input it sends +/// and a short display label of at most 16 chars, e.g. +/// `ControlDecl { input: Input::Char('r'), label: "RESIGN" }`. +#[derive(Clone, Copy, Debug)] +pub struct ControlDecl { + pub input: crate::input::Input, + pub label: &'static str, } /// Room end-of-life declaration (wire values 0/1/2). @@ -298,6 +316,7 @@ impl Meta { config: &[], ctx_features: 0, heartbeat_ms: 0, + controls: &[], lifecycle: Lifecycle::Resumable, }; } diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 4f94aaa..d681600 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -204,7 +204,7 @@ fn decode_members(r: &mut Rd<'_>, n: usize, features: u32) -> 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 = 5; +pub(crate) const WIRE_REVISION: u16 = 6; /// Pack a [`Meta`] for the `meta` export — the single SDK-owned serializer. pub(crate) fn encode_meta(m: &Meta) -> Vec { @@ -267,9 +267,64 @@ pub(crate) fn encode_meta(m: &Meta) -> Vec { // ignore the bytes, and the host uses it to warn on or refuse artifacts // declaring a revision above its own. w.u16(WIRE_REVISION); + // Trailing declared-controls section (ABI.md §4.2, spec minor): always + // written, count 0 when nothing is declared; validated under the same + // fail-fast posture as config specs. + if let Err(e) = validate_controls(m.controls) { + panic!("shellcade-kit: invalid Meta.controls: {e}"); + } + w.u16(m.controls.len().min(0xffff) as u16); + for cd in m.controls { + match cd.input { + crate::input::Input::Char(c) => { + w.u8(0); + w.u32(c as u32); + } + crate::input::Input::Key(k) => { + w.u8(1); + w.u8(k.to_wire()); + } + } + w.str(cd.label); + } w.b } +/// The authoring rules for declared controls (ABI.md §4.2), mirroring Go's +/// `wire.ValidateControls`: a printable rune (no control characters), a +/// non-empty label of at most 16 chars, no duplicate inputs, at most 32 +/// declarations. (Input kinds and key codes are total by construction in +/// Rust.) +pub(crate) fn validate_controls(decls: &[crate::types::ControlDecl]) -> Result<(), String> { + use crate::input::Input; + if decls.len() > 32 { + return Err(format!("{} control decls (max 32)", decls.len())); + } + let mut seen: Vec<(u8, u32)> = Vec::with_capacity(decls.len()); + for (i, cd) in decls.iter().enumerate() { + let id = match cd.input { + Input::Char(c) => { + if (c as u32) < 0x20 { + return Err(format!("control decl {i} declares non-printable rune {c:?}")); + } + (0u8, c as u32) + } + Input::Key(k) => (1u8, k.to_wire() as u32), + }; + if seen.contains(&id) { + return Err(format!("control decl {i} duplicates an earlier declared input")); + } + seen.push(id); + if cd.label.is_empty() { + return Err(format!("control decl {i} has an empty label")); + } + if cd.label.chars().count() > 16 { + return Err(format!("control decl {i} label {:?} exceeds 16 chars", cd.label)); + } + } + Ok(()) +} + /// 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. @@ -552,11 +607,34 @@ mod tests { ], ..Meta::DEFAULT }; - let golden = "0600676f6c64656e0600476f6c64656e0e00676f6c64656e206669787475726501000400020001006101006200000000000001050073636f726501000202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e0000000000000000000000000500"; + let golden = "0600676f6c64656e0600476f6c64656e0e00676f6c64656e206669787475726501000400020001006101006200000000000001050073636f726501000202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e00000000000000000000000006000000"; 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"); } + /// Byte-identity with the Go reference for the declared-controls section: + /// the hex is Go `wire.EncodeMeta` output for this exact declaration + /// (one rune control, one named-key control). + #[test] + fn meta_controls_matches_go_golden() { + use crate::input::{Input, Key}; + use crate::types::ControlDecl; + let m = Meta { + slug: "ctlg", + name: "CtlG", + min_players: 1, + max_players: 2, + controls: &[ + ControlDecl { input: Input::Char('r'), label: "RESIGN" }, + ControlDecl { input: Input::Key(Key::Backspace), label: "UNDO" }, + ], + ..Meta::DEFAULT + }; + let golden = "040063746c67040043746c47000001000200000000000000000000000000000000000000060002000072000000060052455349474e01020400554e444f"; + let got: String = encode_meta(&m).iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(got, golden, "Rust controls encoding diverges from the Go golden"); + } + #[test] fn config_spec_validation_rejects_authoring_mistakes() { use crate::types::{ConfigKeySpec, ConfigType}; @@ -684,9 +762,10 @@ 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 5 LE - // = "01000000" + "6400" + "00" + "0500" - assert!(got.ends_with("0000010000006400000500"), "trailer bytes diverge from the Go encoding: ...{}", &got[got.len()-22..]); + // trailer = u32 1 LE + u16 100 LE + u8 lifecycle + u16 revision 6 LE + // + u16 controls count 0 + // = "01000000" + "6400" + "00" + "0600" + "0000" + assert!(got.ends_with("00000100000064000006000000"), "trailer bytes diverge from the Go encoding: ...{}", &got[got.len()-26..]); } } @@ -714,9 +793,11 @@ mod tests { #[cfg(test)] mod scalar_golden { use super::*; + use crate::input::{Input, Key}; use crate::types::{ - Aggregation, ConfigKeySpec, ConfigType, Direction, Kind, Leaderboard, Lifecycle, Meta, - MetricFormat, Mode, Outcome, Player, PlayerResult, Status, CTX_FEAT_ROSTER_EPOCH, + Aggregation, ConfigKeySpec, ConfigType, ControlDecl, Direction, Kind, Leaderboard, + Lifecycle, Meta, MetricFormat, Mode, Outcome, Player, PlayerResult, Status, + CTX_FEAT_ROSTER_EPOCH, }; const VECTORS: &str = include_str!("../tests/golden/scalars.txt"); @@ -802,6 +883,10 @@ mod scalar_golden { ctx_features: CTX_FEAT_ROSTER_EPOCH, heartbeat_ms: 250, lifecycle: Lifecycle::Ephemeral, + controls: &[ + ControlDecl { input: Input::Char('r'), label: "RESIGN" }, + ControlDecl { input: Input::Key(Key::Backspace), label: "UNDO" }, + ], }; assert_eq!( hex(&encode_meta(&m)), diff --git a/rust/tests/golden/scalars.txt b/rust/tests/golden/scalars.txt index ed63970..484123a 100644 --- a/rust/tests/golden/scalars.txt +++ b/rust/tests/golden/scalars.txt @@ -14,9 +14,10 @@ # 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 = 070064656661756c7400000000010001000000000000000000000000000000000000000500 -meta_full = 0b00676f6c64656e2d66756c6c0b00476f6c64656e2046756c6c170065766572792073656374696f6e20706f70756c6174656402000800020005006d756c74690400636172640a004465616c206d6520696e080050726163746963650d004a6f696e206d79207461626c65010500636869707301010202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e000000000001000000fa00010500 +meta_default = 070064656661756c74000000000100010000000000000000000000000000000000000006000000 +meta_full = 0b00676f6c64656e2d66756c6c0b00476f6c64656e2046756c6c170065766572792073656374696f6e20706f70756c6174656402000800020005006d756c74690400636172640a004465616c206d6520696e080050726163746963650d004a6f696e206d79207461626c65010500636869707301010202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e000000000001000000fa0001060002000072000000060052455349474e01020400554e444f meta_trunc_pre_config = 05007472756e6305005472756e63000001000400000000000000000001050073636f7265010000 +meta_trunc_pre_controls = 05007472756e6305005472756e63000001000400000000000000000001050073636f72650100000000010000006400010600 meta_trunc_pre_largeroom = 05007472756e6305005472756e63000001000400000000000000000001050073636f72650100000000 meta_trunc_pre_lifecycle = 05007472756e6305005472756e63000001000400000000000000000001050073636f72650100000000010000006400 meta_trunc_pre_revision = 05007472756e6305005472756e63000001000400000000000000000001050073636f7265010000000001000000640001 diff --git a/wire/controls_test.go b/wire/controls_test.go new file mode 100644 index 0000000..7855176 --- /dev/null +++ b/wire/controls_test.go @@ -0,0 +1,131 @@ +package wire + +import ( + "reflect" + "strings" + "testing" +) + +func controlsMeta() Meta { + return Meta{ + Slug: "ctl", + Name: "Ctl", + MinPlayers: 1, + MaxPlayers: 2, + Controls: []ControlDecl{ + {Kind: InputRune, Rune: 'r', Label: "RESIGN"}, + {Kind: InputRune, Rune: '✓', Label: "OK"}, // non-ASCII rune rides the u32 + {Kind: InputKey, Key: KeyCodeBackspace, Label: "UNDO"}, + }, + } +} + +// Declared controls round-trip field-exact through the meta payload, for both +// rune and named-key inputs. +func TestMetaControlsRoundTrip(t *testing.T) { + m := controlsMeta() + got, err := DecodeMeta(EncodeMeta(m)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if !reflect.DeepEqual(got.Controls, m.Controls) { + t.Fatalf("controls round-trip mismatch:\n got %+v\nwant %+v", got.Controls, m.Controls) + } +} + +// A payload that ends after the wire-revision field (a pre-controls artifact) +// decodes as a valid meta with no declared controls. +func TestMetaPreControlsBytesDecode(t *testing.T) { + m := controlsMeta() + enc := EncodeMeta(m) + // The controls section is the trailing 10+5+8+2 bytes here; rather than + // hand-count, re-encode without controls and confirm it is a strict + // prefix, then decode that prefix. + m2 := m + m2.Controls = nil + pre := EncodeMeta(m2) + if !strings.HasPrefix(string(enc), string(pre[:len(pre)-2])) { + t.Fatal("controls section is not a trailing addition") + } + got, err := DecodeMeta(pre[:len(pre)-2]) // drop even the empty u16 count + if err != nil { + t.Fatalf("decode pre-controls payload: %v", err) + } + if got.Controls != nil { + t.Fatalf("pre-controls payload decoded controls: %+v", got.Controls) + } +} + +// An empty Controls list still writes the section (count 0) and decodes back +// to no declarations — the always-write discipline every trailer follows. +func TestMetaZeroCountControlsSection(t *testing.T) { + m := controlsMeta() + m.Controls = nil + got, err := DecodeMeta(EncodeMeta(m)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Controls) != 0 { + t.Fatalf("zero-count section decoded controls: %+v", got.Controls) + } +} + +// An unknown input kind cannot be skipped (the entry size depends on the +// kind), so it fails the decode rather than corrupting the framing. +func TestMetaUnknownControlKindFailsDecode(t *testing.T) { + m := controlsMeta() + m.Controls = []ControlDecl{{Kind: 9, Rune: 'x', Label: "X"}} + if _, err := DecodeMeta(EncodeMeta(m)); err == nil { + t.Fatal("unknown control kind decoded without error") + } +} + +func TestValidateControls(t *testing.T) { + ok := []ControlDecl{ + {Kind: InputRune, Rune: 'r', Label: "RESIGN"}, + {Kind: InputKey, Key: KeyCodeBackspace, Label: "UNDO"}, + {Kind: InputKey, Key: KeyCodeCtrlC, Label: "KILL"}, + } + if err := ValidateControls(ok); err != nil { + t.Fatalf("valid decls rejected: %v", err) + } + if err := ValidateControls(nil); err != nil { + t.Fatalf("nil decls rejected: %v", err) + } + + bad := []struct { + name string + decls []ControlDecl + }{ + {"unknown kind", []ControlDecl{{Kind: 7, Rune: 'x', Label: "X"}}}, + {"non-printable rune", []ControlDecl{{Kind: InputRune, Rune: 0x1b, Label: "ESC"}}}, + {"key code zero", []ControlDecl{{Kind: InputKey, Key: 0, Label: "NONE"}}}, + {"key code past CtrlC", []ControlDecl{{Kind: InputKey, Key: 10, Label: "NEW"}}}, + {"empty label", []ControlDecl{{Kind: InputRune, Rune: 'r', Label: ""}}}, + {"label too long", []ControlDecl{{Kind: InputRune, Rune: 'r', Label: strings.Repeat("x", 17)}}}, + {"duplicate rune", []ControlDecl{ + {Kind: InputRune, Rune: 'r', Label: "A"}, + {Kind: InputRune, Rune: 'r', Label: "B"}, + }}, + {"duplicate key", []ControlDecl{ + {Kind: InputKey, Key: KeyCodeBackspace, Label: "A"}, + {Kind: InputKey, Key: KeyCodeBackspace, Label: "B"}, + }}, + } + for _, tc := range bad { + if err := ValidateControls(tc.decls); err == nil { + t.Errorf("%s: accepted", tc.name) + } + } + + many := make([]ControlDecl, MaxControls+1) + for i := range many { + many[i] = ControlDecl{Kind: InputRune, Rune: rune('A' + i), Label: "K"} + } + if err := ValidateControls(many); err == nil { + t.Error("over-cap list accepted") + } + if err := ValidateControls(many[:MaxControls]); err != nil { + t.Errorf("at-cap list rejected: %v", err) + } +} diff --git a/wire/largeroom_test.go b/wire/largeroom_test.go index 1d613db..3f0bab8 100644 --- a/wire/largeroom_test.go +++ b/wire/largeroom_test.go @@ -115,9 +115,9 @@ func TestMetaTrailerRoundTrip(t *testing.T) { t.Fatalf("trailer round-trip = features %#x heartbeat %d", got.CtxFeatures, got.HeartbeatMS) } - // Pre-large-room payload: chop the trailing 9 bytes - // (u32 + u16 large-room, u8 lifecycle, u16 wireRevision). - old := b[:len(b)-9] + // Pre-large-room payload: chop the trailing 11 bytes + // (u32 + u16 large-room, u8 lifecycle, u16 wireRevision, u16 controls). + old := b[:len(b)-11] got, err = DecodeMeta(old) if err != nil { t.Fatalf("pre-trailer decode: %v", err) @@ -126,10 +126,10 @@ func TestMetaTrailerRoundTrip(t *testing.T) { t.Fatalf("pre-trailer payload decoded nonzero trailer: %#x %d %d", got.CtxFeatures, got.HeartbeatMS, got.Lifecycle) } - // Pre-lifecycle payload (kit v2.6.0 era): chop the lifecycle byte and the - // wire-revision u16 — the large-room section decodes, lifecycle defaults - // to resumable. - v26 := b[:len(b)-3] + // Pre-lifecycle payload (kit v2.6.0 era): chop the lifecycle byte, the + // wire-revision u16, and the controls u16 — the large-room section + // decodes, lifecycle defaults to resumable. + v26 := b[:len(b)-5] got, err = DecodeMeta(v26) if err != nil { t.Fatalf("pre-lifecycle decode: %v", err) @@ -155,8 +155,9 @@ func TestMetaWireRevisionRoundTrip(t *testing.T) { t.Fatalf("wire revision round-trip = %d, want %d", got.WireRevision, Revision) } - // Pre-revision payload (kit v2.7.x era): chop the trailing u16. - v27 := b[:len(b)-2] + // Pre-revision payload (kit v2.7.x era): chop the trailing u16 and the + // controls u16 that follows it. + v27 := b[:len(b)-4] got, err = DecodeMeta(v27) if err != nil { t.Fatalf("pre-revision decode: %v", err) diff --git a/wire/scalar_golden_test.go b/wire/scalar_golden_test.go index d24ebac..68cf4f3 100644 --- a/wire/scalar_golden_test.go +++ b/wire/scalar_golden_test.go @@ -82,6 +82,10 @@ func scalarMetaFull() Meta { HeartbeatMS: 250, Lifecycle: LifecycleEphemeral, WireRevision: Revision, + Controls: []ControlDecl{ + {Kind: InputRune, Rune: 'r', Label: "RESIGN"}, + {Kind: InputKey, Key: KeyCodeBackspace, Label: "UNDO"}, + }, } } @@ -106,6 +110,7 @@ func scalarMetaTrunc() Meta { HeartbeatMS: 100, Lifecycle: LifecycleEphemeral, WireRevision: Revision, + Controls: []ControlDecl{{Kind: InputKey, Key: KeyCodeBackspace, Label: "UNDO"}}, } } @@ -146,13 +151,15 @@ type scalarVector struct { } // Trailing meta section widths measured from the END of an encoding with zero -// config specs: the u16 spec count (2) + large-room u32+u16 (6) + lifecycle -// u8 (1) + wireRevision u16 (2). +// config specs and ONE declared key control (u16 count + u8 kind + u8 key + +// str "UNDO" = 10): config 2 | large-room 6 | lifecycle 1 | wireRevision 2 | +// controls 10. const ( - truncPreRevision = 2 - truncPreLifecycle = 2 + 1 - truncPreLargeRoom = 2 + 1 + 6 - truncPreConfig = 2 + 1 + 6 + 2 + truncPreControls = 10 + truncPreRevision = 10 + 2 + truncPreLifecycle = 10 + 2 + 1 + truncPreLargeRoom = 10 + 2 + 1 + 6 + truncPreConfig = 10 + 2 + 1 + 6 + 2 ) func scalarVectors() []scalarVector { @@ -169,6 +176,7 @@ func scalarVectors() []scalarVector { {"meta_default", EncodeMeta(scalarMetaDefault())}, {"meta_full", EncodeMeta(scalarMetaFull())}, {"meta_trunc_pre_config", trunc[:len(trunc)-truncPreConfig]}, + {"meta_trunc_pre_controls", trunc[:len(trunc)-truncPreControls]}, {"meta_trunc_pre_largeroom", trunc[:len(trunc)-truncPreLargeRoom]}, {"meta_trunc_pre_lifecycle", trunc[:len(trunc)-truncPreLifecycle]}, {"meta_trunc_pre_revision", trunc[:len(trunc)-truncPreRevision]}, @@ -265,15 +273,22 @@ func TestScalarGoldenMetaDecode(t *testing.T) { {"meta_trunc_pre_config", truncPreConfig, func(m *Meta) { m.ConfigSpecs = nil m.CtxFeatures, m.HeartbeatMS, m.Lifecycle, m.WireRevision = 0, 0, 0, 0 + m.Controls = nil }}, {"meta_trunc_pre_largeroom", truncPreLargeRoom, func(m *Meta) { m.CtxFeatures, m.HeartbeatMS, m.Lifecycle, m.WireRevision = 0, 0, 0, 0 + m.Controls = nil }}, {"meta_trunc_pre_lifecycle", truncPreLifecycle, func(m *Meta) { m.Lifecycle, m.WireRevision = 0, 0 + m.Controls = nil }}, {"meta_trunc_pre_revision", truncPreRevision, func(m *Meta) { m.WireRevision = 0 + m.Controls = nil + }}, + {"meta_trunc_pre_controls", truncPreControls, func(m *Meta) { + m.Controls = nil }}, } for _, tc := range cases { diff --git a/wire/wire.go b/wire/wire.go index e1f529d..0c72d7d 100644 --- a/wire/wire.go +++ b/wire/wire.go @@ -13,6 +13,7 @@ import ( "encoding/json" "errors" "fmt" + "unicode/utf8" ) // Version is the ABI major version. @@ -36,6 +37,8 @@ const Version uint32 = 2 // 5 — per-member character section behind CtxFeatCharacter (str glyph · // u8 ink RGB · u8 bg RGB · u8 asciiFallback after kind, both // member-bearing forms) +// 6 — declared-controls meta section (u16 count of {u8 kind · +// u32 rune | u8 key · str label} after wireRevision) // // 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 @@ -47,7 +50,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 = 5 +const Revision uint16 = 6 // Guest export names. const ( @@ -249,6 +252,17 @@ type Meta struct { // not author-settable); hosts use it to warn on or refuse artifacts // declaring a revision above their own (ABI.md §4.2, §5). WireRevision uint16 + + // Controls is the trailing declared-controls section (spec minor + // addition; u16 count after WireRevision): the game's extra controls — + // inputs beyond the canonical vocabulary, each paired with a short + // display label so a front end on a device without the corresponding + // physical key (touch) can surface a tappable affordance that sends + // exactly the declared input. Presentation metadata only: a declaration + // changes no input interpretation. Encoders always write the section + // (count 0 when empty); decoders treat a payload ending after the + // wire-revision field as a valid older meta with no declarations. + Controls []ControlDecl } // Lifecycle values for the meta trailer. @@ -266,6 +280,30 @@ const ( ConfigJSON uint8 = 3 ) +// Named key codes carried in input payloads and ControlDecl (the InputKey +// value space; match the SDK Key enum). +const ( + KeyCodeEnter uint8 = 1 + KeyCodeBackspace uint8 = 2 + KeyCodeEsc uint8 = 3 + KeyCodeTab uint8 = 4 + KeyCodeUp uint8 = 5 + KeyCodeDown uint8 = 6 + KeyCodeLeft uint8 = 7 + KeyCodeRight uint8 = 8 + KeyCodeCtrlC uint8 = 9 +) + +// ControlDecl is one declared extra control in the meta payload: the exact +// input it sends — a printable rune or a named key, the same value space as +// input events — plus a short display label (e.g. "RESIGN"). +type ControlDecl struct { + Kind uint8 // InputRune or InputKey + Rune rune // the printable rune (Kind == InputRune) + Key uint8 // the named key code (Kind == InputKey; KeyCodeEnter..KeyCodeCtrlC) + Label string // short display label, 1..16 runes +} + // ConfigSpec is one declared admin-settable config key in the meta payload. type ConfigSpec struct { Key string // the config_get key the game reads @@ -594,6 +632,18 @@ func EncodeMeta(m Meta) []byte { // round-trips field-exact — SDK encode paths stamp Revision, and a zero // value means "no declaration". w.U16(m.WireRevision) + // Trailing declared-controls section (spec minor addition). Always + // written; older decoders ignore the bytes. + w.U16(uint16(len(m.Controls))) + for _, cd := range m.Controls { + w.U8(cd.Kind) + if cd.Kind == InputKey { + w.U8(cd.Key) + } else { + w.U32(uint32(cd.Rune)) + } + w.Str(cd.Label) + } return w.B } @@ -650,6 +700,27 @@ func DecodeMeta(b []byte) (Meta, error) { if !r.Bad && r.Off < len(r.B) { m.WireRevision = r.U16() } + // Trailing declared-controls section, presence-guarded: absent = no + // declarations. An unknown input kind FAILS the decode rather than being + // skipped — the entry's size depends on its kind, so an unknown kind + // breaks the framing of everything after it. + if !r.Bad && r.Off < len(r.B) { + n := int(r.U16()) + for i := 0; i < n && !r.Bad; i++ { + var cd ControlDecl + cd.Kind = r.U8() + switch cd.Kind { + case InputRune: + cd.Rune = rune(r.U32()) + case InputKey: + cd.Key = r.U8() + default: + return Meta{}, fmt.Errorf("wire: control decl %d has unknown input kind %d", i, cd.Kind) + } + cd.Label = r.Str() + m.Controls = append(m.Controls, cd) + } + } if err := r.Err(); err != nil { return Meta{}, err } @@ -698,6 +769,57 @@ func ValidateConfigSpecs(specs []ConfigSpec) error { return nil } +// MaxControls caps the declared-controls list: a control surface for the +// handful of inputs beyond the canonical vocabulary, not a keymap dump. +const MaxControls = 32 + +// ControlLabelMaxRunes caps a declared control's display label. +const ControlLabelMaxRunes = 16 + +// ValidateControls enforces the authoring rules for declared controls, shared +// by guest SDK encoders and host/CLI decoders (the same fail-fast posture as +// ValidateConfigSpecs): a known input kind; a printable rune or an assigned +// named-key code; a non-empty label of at most ControlLabelMaxRunes runes; no +// duplicate inputs; at most MaxControls declarations. +func ValidateControls(decls []ControlDecl) error { + if len(decls) > MaxControls { + return fmt.Errorf("wire: %d control decls (max %d)", len(decls), MaxControls) + } + type inputID struct { + kind uint8 + val uint32 + } + seen := make(map[inputID]bool, len(decls)) + for i, cd := range decls { + var id inputID + switch cd.Kind { + case InputRune: + if cd.Rune < 0x20 { + return fmt.Errorf("wire: control decl %d declares non-printable rune %q", i, cd.Rune) + } + id = inputID{InputRune, uint32(cd.Rune)} + case InputKey: + if cd.Key < KeyCodeEnter || cd.Key > KeyCodeCtrlC { + return fmt.Errorf("wire: control decl %d declares unassigned key code %d", i, cd.Key) + } + id = inputID{InputKey, uint32(cd.Key)} + default: + return fmt.Errorf("wire: control decl %d has unknown input kind %d", i, cd.Kind) + } + if seen[id] { + return fmt.Errorf("wire: control decl %d duplicates an earlier declared input", i) + } + seen[id] = true + if cd.Label == "" { + return fmt.Errorf("wire: control decl %d has an empty label", i) + } + if utf8.RuneCountInString(cd.Label) > ControlLabelMaxRunes { + return fmt.Errorf("wire: control decl %d label %q exceeds %d runes", i, cd.Label, ControlLabelMaxRunes) + } + } + return nil +} + // Heartbeat declaration envelope (mirrors the host's clamp range). const ( HeartbeatMinMS uint16 = 20