flybrain/docs/feed-protocol.md
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

16 KiB

Feed protocol v1 (flysim to flystage)

The simulation service publishes snapshots over a WebSocket on ws://127.0.0.1:7400/feed. The page is a display: it never sends anything but a one-time hello. Every consumer (stage, bridge, tests) uses the same message shape, defined in TypeScript in packages/feed and in Rust with serde in services/flysim/crates/flysim. A JSON schema test in each side pins the shape.

Framing

Each snapshot is ONE binary WebSocket message:

u32 LE headerLength | header JSON (UTF-8) | attachments...

Attachments follow the header in the order listed by header.attachments, each as u32 LE byteLength | bytes, so a consumer can skip attachments it does not want. The header is small (under 4 KB); attachments carry the bulk.

Cadence: 30 snapshots per second wall-clock while running, 2 per second while paused or booting (header only). The service drops snapshots rather than queueing when the socket is slow.

Header

interface FeedHeader {
  protocol: 1;
  seq: number;                 // monotonically increasing
  wallMs: number;              // Date.now() at publish
  status: 'booting' | 'running' | 'paused' | 'recovering' | 'error';
  realtimeFactor: number;      // simulated ms per wall ms over the last second
  uptimeSeconds: number;       // since service start
  runSeconds: number;          // total simulated seconds across restores (from checkpoint)
  brainMs: number;             // simulation clock
  frame: number;               // emulator frame counter
  buttons: number;             // bitmask, GAMEBOY_BUTTON_BITS order (up,down,left,right,a,b,start,select)
  rates: Record<string, number>;   // Hz per tracked role: command_0..7, steer_left, steer_right,
                                   // forward, backward, proboscis, reward_pam, and in macros
                                   // mode the bound macro types' macro_* roles
  populationRate: number;      // Hz
  spikeCount: number;          // set bits in the spikes attachment (0 when omitted)
  learning: { enabled: boolean; updates: number; changed: number; synapses: number; signal: number };
  game: {
    mode: 'BOOT' | 'OVERWORLD' | 'BATTLE' | 'TRANSITION' | 'DEMO' | 'SAFARI' | 'UNKNOWN';
                               // closed set; adapters fold onto it (see below)
    semanticRewards: boolean;  // false when the ROM hash is not the audited one
    map: number | null;        // current map id when known
    badges: number;            // 0..8
    uniqueLocations: number;   // exploration coverage
    rewardTotal: number;
    rewardCounts: Record<RewardKind, number>;
    // The macro buttons (docs/design/macros.md section 12). Added 2026-09-16, additive,
    // optional: a producer that predates them omits all five and a consumer falls back to
    // raw-mode rendering.
    scene?: Scene;             // closed set; the scene whose macro buttons are bound
    macroMode?: 'raw' | 'macros';
    palette?: PaletteSlot[];   // the scene's bound macro buttons, ascending by slot,
                               // one per macro type at most; [] in raw mode
    macro?: RunningMacro | null;       // the macro that owns the buttons
    macroOutcome?: MacroOutcome | null; // the one that finished most recently
    padEmptyMs?: number;       // BRAIN ms the pad has had nothing on it in a playable
                               // scene; 0 otherwise. Report only.
  };
  milestone: {
    rank: number;              // position on the ratchet ladder, 0..total-1
    label: string;             // human label, e.g. "Left the bedroom"
    next: string;              // label of the rung the fly is going for (see below)
    sinceSeconds: number;      // simulated seconds at current rank (the stuck-o-meter)
    attempts: number;          // game rollbacks since reaching this rank
    total?: number;            // rungs the game's ladder has (>= 1); omitted by older producers
  };
  sugar: {
    active: boolean;           // a stimulation pulse is currently applied
    remainingMs: number;
    cooldownMs: number;        // until the next viewer sugar is accepted
    lastBy: string | null;     // display name of the last redeemer
    todayCount: number;
  };
  events: FeedEvent[];         // events since the previous snapshot (usually empty)
  chat?: ChatLine[];           // last N (<= 12, `[chat] ring`) lines accepted by the control API,
                               // oldest first. ABSENT ENTIRELY (not an empty array) while
                               // `[chat] enabled = false`, so a page can tell "chat is off" from
                               // "nobody has said anything yet". Chat never reaches the simulation.
  attachments: AttachmentKind[];
}

type RewardKind = 'story' | 'explore' | 'area' | 'pokedex' | 'trainer' | 'wildwin' | 'badge';

type Scene =
  | 'title' | 'overworld' | 'dialog' | 'menu'
  | 'battle' | 'battle-switch' | 'shop' | 'pc' | 'unknown';

interface PaletteSlot {
  slot: number;                // the macro TYPE's own index, 0..30; unbound types are
                               // omitted, and a type's index never changes
  name: string;                // the macro's name, at most 14 characters
  gloss: string;               // two or three words of what it does here ("nearest door")
  channel: string;             // the type's short channel tag, e.g. "MB·GO", "MB·ATK"
}

interface RunningMacro {
  slot: number;
  name: string;
  sinceMs: number;             // BRAIN milliseconds since it started (a duration, not a clock)
}

interface MacroOutcome {
  slot: number;
  name: string;
  outcome: 'done' | 'blocked' | 'timeout' | 'refused';
  atMs: number;                // the BRAIN clock at which it finished (a timestamp)
}

interface FeedEvent {
  id: number;                  // monotonically increasing across the run
  wallMs: number;
  brainMs: number;
  kind: 'reward' | 'sugar' | 'milestone' | 'recovery' | 'checkpoint' | 'viewer' | 'system'
      | 'macro';
  label: string;               // short human text, template-generated, never raw chat
  value?: number;              // reward value, milestone rank, or macro slot
  rewardKind?: RewardKind;
  by?: string;                 // viewer display name for sugar/viewer events
}

interface ChatLine {
  id: number;                  // the event id of the accepted line
  wallMs: number;
  by: string;                  // validated display name (packages/feed names.ts rules)
  text: string;                // sanitized: <= 200 code points, letters/digits/space and a fixed
                               // punctuation allowlist only — no control, zero-width or bidi
                               // characters, no combining marks, no emoji, no URLs, deny-list
                               // filtered; the service refuses anything else whole, never trimmed.
                               // The rules live in packages/feed/src/chat.ts and, identically, in
                               // services/flysim/crates/flysim/src/chat.rs, pinned to each other
                               // by packages/feed/tests/fixtures/chat-cases.json
  bot?: boolean;               // true for the bridge's own template replies
}

type AttachmentKind = 'frame' | 'audio' | 'spikes';

game.mode, game.scene and RewardKind are closed sets, because every consumer switches exhaustively on them. An adapter with other states or other reward kinds folds onto these names rather than extending them; the per-game config in apps/stage/src/games/ supplies the words on screen.

Adapter mode game.mode Why
Pokémon BOOT / OVERWORLD / BATTLE / TRANSITION / DEMO / SAFARI as named —
Platformer IN LEVEL <world>-<stage> OVERWORLD Both mean "controllable in the world". The level itself is game.map, so nothing is lost.
Platformer GAME OVER TRANSITION The run is over and the fly is not controllable. The recovery event says what happened.
Platformer DEMO (attract demo) DEMO —
UNSUPPORTED ROM · SEMANTIC REWARDS OFF UNKNOWN game.semanticRewards already carries it.

The platformer's nine reward kinds share the seven published counters: band -> explore, coin -> wildwin, score -> area, powerup -> pokedex, life -> trainer, level -> story, world -> badge. started and clear map to nothing: each pays once in a lifetime, so a counter for them is noise, and their events carry their own labels.

The macro buttons (2026-09-16)

docs/design/macros.md section 12 is the design; these are the fields, and where the two differ this file wins. Additive: protocol stays 1, the five fields are optional in packages/feed/src/schema.json, and a consumer that ignores them renders exactly what it rendered before. The Rust producer always sends all five, in both modes.

  • game.scene is the scene whose macro buttons are bound, from the closed set above. It is unknown in raw mode — nothing is on the pad but the eight buttons, so no scene is claimed — and title through the intro, where nothing is bound by design and the readout's boot variant applies.

  • game.macroMode is raw or macros, straight from flysim.toml's [macros] mode. A consumer that does not know a mode name renders the raw layout rather than publishing a word it cannot explain.

  • game.palette carries the scene's bound macro buttons, ascending by slot, at most one per macro type. slot is the macro type's own index since docs/design/macros.md section 14 (2026-09-17) — not a Game Boy button, not a rank, and no longer a position among the bound ones, which is what let a cell move between two frames as a precondition came and went. A type that this scene does not bind is omitted rather than sent as a null, and the page draws its cell dim. Always [] in raw mode.

  • game.macro is the macro that owns the buttons, or null. sinceMs is a duration in brain milliseconds, so a page can show how long it has been running without doing arithmetic against brainMs.

  • game.macroOutcome is the macro that finished most recently and is kept until the next one starts, so the page can light a cell's result for a beat rather than racing a single frame. atMs is a brain-clock timestamp, so it can be compared with brainMs.

  • game.padEmptyMs is how long the pad has had nothing on it in a playable scene, in brain milliseconds, and 0 otherwise — in raw mode, on the title screen, when something is bound, and while a macro is running, because a running macro owns the pad. It is report only: no producer acts on it and no consumer should, and nothing in the loop reads it back. It exists because an empty pad is the doctrine working — nothing presses for the fly, so a scene with no button waits — and on screen that is indistinguishable from a hang (docs/design/macros.md section 13.1). fly-watchdog exports it as fly_pad_empty_seconds.

game.macro and game.macroOutcome carry that same slot, so a display lights the cell that ran without matching on the name.

One channel per macro type (2026-09-16)

docs/design/macros.md sections 11 and 12: a macro type is a button pressed by its own neuron population, and the decoder's second exclusive group picks between the types the scene has bound (docs/readout.md, "Macro group"). Two consequences here.

  • game.palette[i].channel is that type's short tag, which the page draws as the cell's glyph — MB·GO, MB·ATK. The rate role behind it is macro_ plus the macro's name lowercased with spaces as underscores (GO ITEM -> macro_go_item), which is how a consumer finds the type's Hz in rates. It is also what orders the cells on screen: the cells are drawn in the contract's fixed type order, which since section 14 is slot. Every entry a dealt mode sends carries it; it is absent only from a producer that predates this paragraph.
  • prior is removed. There is no rank, no ladder and no blend left to publish: whichever bound channel wins the decision starts, and game.macro says which did. A producer still sending prior is stale, and a consumer ignores the field.

rates carries the bound types' macro_* roles alongside command_0..7, which is what the SENSES tab's MACROS row draws. Those populations are shared out over the mushroom body output neurons and the brain motor neurons, so a rate there is the mushroom body's, not a ninth button.

One macro event per start and per finish, so the ticker and events.jsonl both carry them. Labels are exactly <NAME> start on a start and <NAME> done|blocked|timeout|refused on a finish; value is the slot. An unbound type produces no event of any kind: it never entered the decision, so there is nothing to report.

Why macroMode and not mode

docs/design/macros.md asks for game.mode: "raw" | "macros". That name has been taken since protocol v1 by the adapter's own mode string (BOOT, OVERWORLD, …), which is a closed set every consumer switches exhaustively on, and repointing it would be a breaking change to a published field for a feature that is explicitly additive. So the mode is published as game.macroMode, and the design doc's field list is wrong on that one name. macroMode is the name both sides are built against.

milestone.next is the rung the fly is going for (2026-09-17)

next was the label of rank + 1. It is the label of the adapter's own next rung now — GameAdapter::next_rung, the lowest rung the run has not earned — falling back to rank + 1 for an adapter that does not answer. The type is unchanged (a string, never empty: the current label is still the fallback at the top of the ladder), so no client changes and no version bump.

Why: the rank is the maximum over satisfied rungs, and a ladder's rungs are not a chain. The live save of 2026-09-17 had Pokémon Red's rungs 6 and 7 — Oak's parcel delivered, and the Pokédex — unearned while rung 8 (Viridian City, a map) was stood on, so the rank read 8 and the header said "→ VIRIDIAN FOREST" while GO OBJECTIVE was walking two maps south to deliver a parcel. The screen was naming a rung nothing was working toward. rank, label, sinceSeconds, attempts and total are untouched, and so is the ratchet: the rank is still the maximum, because that is what "best reached" means.

Attachments

  • frame: 160 x 144 RGBA, 92,160 bytes, the emulator framebuffer after the last frame.
  • audio: interleaved stereo f32 PCM at 48,000 Hz (Web Audio's native rate on Linux, so the page never resamples), all samples produced since the previous snapshot (about 1,600 frames = 12,800 bytes at 30 Hz). binjgb emits unsigned 8-bit stereo; the service converts. The page keeps a ring buffer with varispeed drift correction.
  • spikes: a bitset of ceil(neurons / 8) bytes (17,407 bytes for 139,255 neurons), bit i (byte i >> 3, mask 1 << (i & 7)) set when neuron i spiked at least once since the previous snapshot. Fixed size, order-free, and exactly what the page's density accumulator consumes. header.spikeCount carries the number of set bits.

Bandwidth at 30 Hz: about 3.7 MB/s on localhost (frame 92 KB, spikes 17 KB, audio 13 KB).

Client hello

On connect the client sends one JSON text message: {"protocol":1,"client":"stage"|"bridge"|"test","wants":["frame","audio","spikes"]}. The service honours wants per connection. The bridge asks for no attachments.

Compatibility

protocol bumps on any breaking change. Adding optional header fields is not breaking.

Audio source note (2026-09-15)

binjgb's mixer emits unsigned 8-bit samples that are unipolar: silence is 0, not 128 (measured range 0..44 over a Pokémon Red boot). The service converts with v / 255 to [0, 1] and then applies a DC-blocking one-pole high-pass (y[n] = x[n] - x[n-1] + 0.995 * y[n-1]) per channel before publishing, so the audio attachment is ordinary bipolar f32 PCM centred on zero. The page does no further offset correction.