Changelog

changelog releases reference

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

Added

Owner-Addressed Audio Routing and Selection

  • Two new top-level blocks replace every per-owner audio { } sub-block. A route { } lists the candidate sounds an owner can make — one or more source "<kind>" "<name>" { } sub-blocks, where kind is "audio_clip" or "synth" — and a selection { } decides which candidate plays when the owner fires
  • Both blocks are addressed with owners =, written bare. An owner is a device.<name>, score.<name>, modifier.<name>, accumulator.<name>, or event_handler.<name>, so a jackpot fanfare and a bumper pop are now authored exactly the same way. owners also takes a bankowners = [device.pop_1, device.pop_2, device.pop_3] — and the whole bank shares one candidate pool and one decision
  • Source attributes: weight (relative routing weight) on any source; pitch, velocity, duration, sustain, and a nested position { x y z } on "synth" sources only. duration gives a timed note-off, sustain = true holds the voice until the owning switch opens, and the two are mutually exclusive. An optional mix { channel = "<name>" } sub-block sends the route’s output to a named mixer channel
  • Selection attributes: selection_method (random, weighted, rotate, shuffle, balanced, probability, coordinated; default random), no_repeat, shuffle_memory, clip_probability, and a bare condition expression that keeps the owner silent while false
  • route and selection can be authored inside an assembly definition, using owners = device.self.<name> to address a block the template itself declares — so each expanded instance gets its own correctly-prefixed route. An instance that wants different sound overrides just the block it cares about
route {
  owners = device.drop_target_1

  source "audio_clip" "target_1" { weight = 3 }
  source "synth"      "zap"      {
    weight   = 1
    pitch    = 880
    velocity = 0.4 + score / 200000
  }

  mix { channel = "fx" }
}

selection {
  owners           = device.drop_target_1
  selection_method = "random"
  no_repeat        = 1
}

See Routing & Selection for the full guide.

Explicit Synth Signal Graph

  • A synth { } patch now wires its signal path explicitly instead of having it inferred from node names. Exactly one output { } block names what the voice sounds like: in = node.<name>.out is the node that produces the sound, and the optional gain = node.<name>.out names the envelope acting as the amplitude VCA
  • Port-wires reference a node’s current sample as node.<name>.out and its previous sample as node.<name>.z1, which is how feedback paths are authored
  • New node kinds inside a patch: a mixer node sums several sources into one control or signal port (multi-carrier FM, multi-envelope filter cutoff), with per-input gains, and a delay node provides a feedback comb. Cycles through non-z1 ports are rejected at load with an actionable message
  • The amp envelope is now optional. A minimal patch — one oscillator wired to output.in — makes sound; cade supplies a default amplitude gate (near-instant attack, full sustain, short release) so the voice still releases cleanly instead of droning. Author an amp envelope when you want to shape amplitude yourself
  • A patch that is structurally incomplete (an output with no in wire, an unwired filter, an unconsumed mixer) now parses and is stored, and is rejected only when it has to make sound. A single bad patch no longer fails audio startup for the whole table — it is skipped with a diagnostic and its voices stay silent, while every other patch plays

Sustained and Timed Synth Notes

  • A synth source with duration = "0.5s" holds the note for that long and then releases, so the envelope’s release tail plays instead of being cut short by the default gate
  • A synth source with sustain = true holds the voice for as long as the owning switch stays closed and releases it on the open edge — a held flipper button or a captive-ball switch can now sound for exactly as long as it is held
  • Both are dispatched identically from a device, a scoring owner, or an event handler. Where a host cannot honor a capability, it now says so with a warning rather than failing silently

Generative Light Shows (Node-Graph Lighting)

  • New top-level light_layout "<name>" { } block maps your physical light devices onto a normalized 0–1 playfield space, so shows can address lights by position instead of by hardware id. Place lights individually with place "<device>" { at = [x, y] }, lay out a regular grid with matrix { rows cols origin size }, and group placements into named region "<name>" { } sub-blocks
  • New top-level lightshow "<name>" { } block declares an animated show as a small graph of node "<type>" "<name>" { } generators wired to one or more output { } layers. Each output names what it draws (in = node.<name>.out), where it draws (target = layout.<name>), and how it combines with the layers beneath it (blend = "over")
  • Show-level attributes: mode (e.g. "ambient"), duration, loop, and seed
  • Multiple shows composite through a priority stack with blend modes, so a high-priority mode show layers cleanly over an always-on ambient show. Direct set_light / flash_light actions route through the same compositor as an override band, so imperative light commands always win over whatever generative shows are running underneath
  • Shows evaluate per frame at the 30 Hz light tick and are fully deterministic for a given seed — the same show produces the same frames every run, with bit-for-bit parity between native and in-browser (wasm) playback, so a recorded light sequence replays identically everywhere
  • An output layer’s in can take several wires in list form — in = [node.a.out, node.b.out] — so multiple generators feed one layer without an intermediate node
  • An editor { } sub-block carries canvas metadata (node positions) that round-trips through the file untouched and has no effect at runtime
light_layout "strip" {
  place "self.l0" { at = [0.0, 0.0] }
  place "self.l1" { at = [0.5, 0.0] }
  place "self.l2" { at = [1.0, 0.0] }
}

lightshow "ambient_glow" {
  mode = "ambient"

  node "solid" "base" {
    color = "#ff0000"
  }

  output {
    in     = node.base.out
    target = layout.strip
    blend  = "over"
  }
}

Authorable Audio Mix Buses

  • New channel "<name>" { } and bus "<name>" { } blocks make the mixer’s routing authorable in HCL — previously the mixer’s channels and their per-channel effects were fixed. A channel/bus is a named mixer strip with an optional volume (0.0–1.0), a pan / balance (-1.0 full-left … 1.0 full-right), and an ordered chain of effect "<type>" { } insert effects applied in source order
  • channel and bus are structurally identical — use whichever keyword reads better for the role. Omitting volume / pan leaves the mixer default (full volume, centered) rather than forcing a zero
  • Insert effect types: reverb (feedback, wet_level), compressor (threshold, ratio, makeup_gain), and equalizer (low_gain, mid_gain, high_gain, low_freq, high_freq). Effect parameters are numeric literals
bus "music" {
  volume = 0.8
  pan    = 0.0

  effect "reverb" {
    feedback  = 0.4
    wet_level = 0.25
  }
}

Weighted Clip Selection with Reproducible Seeding

  • A device’s audio { } block accepts a distribution attribute (alias clip_probability) that names a weighted probability distribution controlling which clip is drawn from the clip_pool — a bumper can favor its “meaty” hits over its light taps, for example. It reuses the same weighted sampler the scoring engine already compiles
  • Clip selection is now fully reproducible from a seed: with the audio sequencing seed set, a table produces identical clip sequences across runs, and native and in-browser (wasm) playback pick the same clips. An explicitly authored random_seed = 0 is now honored as a real seed rather than being mistaken for “unseeded,” so 0 behaves like any other fixed seed

Bare Expressions in .cade Config

  • Expressions are now written bare (unquoted) wherever an attribute expects one — condition = var.tilted, points = var.bumper_value * 10, update { score = var.score + 100 }. What you write is what the engine evaluates, with no field-dependent rewriting
  • String literals inside an expression accept either double or single quotes interchangeably — condition = var.mode == "wizard" and condition = var.mode == 'wizard' parse to the same expression. Single quotes are handy for avoiding escapes when the expression itself sits inside a double-quoted HCL value (e.g. a list: clip_pool = "['a', 'b']")
  • The fully quoted form (condition = "var.x > 5") still parses and is treated as equivalent, so existing configs keep loading — but bare is now canonical across the docs
  • Breaking: the ${...} interpolation form is now rejected in expression fields. Rewrite "${var.score} + 100" as var.score + 100
  • The bare form now also covers more attributes beyond scoring condition/points/update: device audio (condition, clip_pool), signal enabled, set_variable value, device_control condition, fragment definition fields (condition, points, updates), and a route synth source’s pitch/velocity — quotes are optional on every expression-bearing attribute, not just the original three
  • Expression fields inside an assembly definition block now accept the bare form too. Previously a bare runtime expression such as condition = var.self.targets_down >= param.target_count was silently dropped inside a definition and only the quoted form took effect

See Writing Expressions for the full rules.

TUI Logs Tab Enhancements

  • Text filter (/) in the Logs tab — hides non-matching lines case-insensitively across message, source, and structured fields. Enter commits, Esc clears and exits
  • Search (Ctrl+F) highlights matches in place without hiding lines; n / N navigate to the next / previous match and wrap at the ends. All occurrences on a line are now highlighted (previously only the first)
  • Pause (p) freezes the visible feed while continuing to buffer new entries in the background (FIFO-capped at 10,000). The status bar shows PAUSED (N buffered) while paused; pressing p again drains the backlog in order. Pause is independent of autoscroll
  • Source column populated from slog groupslogger.WithGroup("audio").WithGroup("mixer") now renders the source as audio.mixer in the TUI and in copied output. Explicit slog.With("source", ...) attrs still take precedence over the group path
  • Cleaner clipboard output — the Alt+C copy formatter no longer emits empty [] brackets when a log record has no source; the bracket segment is included only when a source is present, matching the on-screen rendering

See Logs Panel for the full keyboard reference.

Event Tree Viewer — Scrollback and Overflow Expansion

  • Archived-game scrollback in the Events panel — finished games are retained as compact summaries above the live game so you can scroll back and review them without leaving the TUI. Up to three archived games are kept; starting a fourth game evicts the oldest
  • Archived games are collapsed by default and render as siblings above the live Game root; expand with l or Space to inspect the per-player leaderboard, per-ball summaries, completed-mode history, and lifecycle entries
  • Raw per-event leaves are dropped on archive — each archived ball shows its total event count (Events: N) but no event children. This keeps memory bounded across long sessions (~95% reduction per archived game)
  • Overflow placeholders (... N earlier ...) are now reachable with right-arrow (l) in addition to Enter, and the hint bar shows enter: show more when the cursor is on an overflow row
  • Overflow expansion applies to history and lifecycle sections on both live and archived games
  • The live game is never archived while in progress — it only enters the ring when the next game starts or when the current game ends

See Events Panel for the full navigation reference.

Driven-Device Abstraction Layer

  • Shared identity surface across every device { } block — id, tags, description, location, part_number, notes, and a new enabled field that declaratively disables a device without removing its declaration
  • New switch subtypes drop and standup, distinct from the existing generic target subtype:
    • switch "drop" — falling targets with a reset { coil, pulse_ms, debounce_ms } sub-block and an implicit up → hit → resetting → up state machine
    • switch "standup" — upright hit-only targets (no reset semantics), commonly banked under a shared tag
  • New positions {} block on motor "servo" devices — assign symbolic names (home, up, down, etc.) to abstract scalar positions; driver-agnostic, each platform driver maps the scalar to its own physical representation
  • New event aliases on mechanical devices: rested (drop target has settled upright after reset + debounce), state_changed (any declared-state transition), moved / moving / reached (servo and stepper motion lifecycle)
  • New behavior {} sub-block declaring hardware-level reflexes alongside the owning device — config-compile-time lowering to driver-local autofire rules, so reactions like pop-bumper auto-fire never enter the scoring hot path. Supports when, action, guard, and cooldown_ms
  • New action verbs usable in behavior {} blocks and event handlers: pulse_coil, move_servo, set_state, trigger_behavior. All verbs are driver-agnostic — the platform driver chooses the physical implementation
  • Performance budgets preserved (50–100 Hz event tick, 30 Hz light tick, <200 ns binding resolve, <1 ms scoring) because behavior {} lowering happens at config compile time, not per tick
  • Runtime wire-up: move_servo / set_state / trigger_behavior dispatch through the platform driver, drop-target state machines emit hit / state_changed / rested as reset pulses complete and debounce elapses, and symbolic servo positions are resolved from the device’s positions {} block at dispatch time
  • Software-fallback path for behavior {} blocks the driver cannot lower to hardware — ineligible behaviors run through the standard event-handler pipeline instead of becoming a load-time error, so a behavior {} block is never silently dropped. Lowered behaviors remain on the hardware fast path

See Driven Devices for the architecture overview and Device API reference for the full schema.

Synthesis Subsystem

  • New synth block for declaring oscillator-based sound effects in .cade configs — generates PCM in real time without audio files
  • Four wave shapes: sine, saw, square, and triangle
  • ADSR amplitude envelope (attack, decay, sustain, release) with per-sample state machine and zero-allocation steady-state processing
  • FM modulation: route one oscillator’s output into another’s frequency for metallic, bell, and complex timbres (modulate { source = "mod" target = "carrier.freq" })
  • 64-voice polyphony per patch (configurable 1–128) with pre-allocated pool, free-list allocation, and LRU voice stealing when saturated
  • Event dispatch: a patch is fired by a top-level route { } whose source "synth" "<patch>" { } names it — voices are automatically registered with the mixer and returned to the pool when the envelope completes (see Routing & Selection)
  • Dynamic pitch and velocity via from:event.<field> bindings that resolve values from event data payloads at trigger time
  • HCL parser and compiler with validation: wave names, oscillator references, modulation routing, cycle detection, duration parsing, and polyphony range checks — all errors include source locations
  • Synth voices implement the same AudioSource interface as clip-based audio, feeding directly into the existing mixer pipeline with no backend changes
  • Named envelopes: envelope blocks now accept an optional label (e.g., envelope "pitch" { ... }) — multiple envelopes per synth, each with an independent ADSR shape. Every synth must have exactly one "amp" envelope; additional envelopes modulate pitch or filter cutoff via modulate blocks. New depth attribute specifies modulation range in Hz. Unlabeled envelope { } defaults to "amp" for backwards compatibility with v1 configs
  • Biquad filter: new optional filter { type cutoff resonance } block — lowpass and highpass biquad filters applied post-oscillator, pre-amplitude-envelope in the voice pipeline. Resonance (0.0–1.0) maps to Q factor internally
  • Filter cutoff modulation: modulate { source = "<envelope>" target = "filter.cutoff" } drives the filter cutoff frequency from a named envelope. Coefficients are recalculated once per audio buffer for efficient per-block sweeps. Modulated cutoff is clamped to [20 Hz, Nyquist]
  • Envelope → pitch modulation: modulate { source = "<envelope>" target = "<osc>.freq" } drives an oscillator’s frequency from a named envelope, producing pitch sweeps controlled by ADSR shape and depth
  • Editing a synth { } patch now hot-swaps it live — previously synth definitions were applied only once at startup, so editing a patch mid-session had no effect until restart. In-flight voices finish on the old patch; new triggers immediately use the new one

Runtime Expression Bindings

  • A route synth source’s parameter bindings (pitch, velocity) accept literal numbers, from:event.<field> lookups, and full expressions evaluated against live game state
  • Expression namespace available in bindings: event.<field> (event payload), score (current player score), var.<name> (variable registry), signal.<name> (1.0 if signal active, 0.0 otherwise)
  • Example — score-reactive pitch: pitch = "440 + (score / 1000000) * 220"
  • Example — multiball-reactive velocity: velocity = "signal.multiball_active ? 1.0 : 0.6"
  • Literal numbers and from:event.* lookups stay on a fast path — no expression evaluation is performed for them
  • Expressions are compiled once at config load; missing variables, signals, and event fields resolve to the zero value at runtime rather than failing the trigger

See Runtime Bindings for the full namespace reference and Synthesis for synth examples.

Exhaustive Automated Testing of Cade Config Language

  • Scenario corpus parser now accepts all .cade.test files without skipping — the knownParseFailures skip list has been eliminated entirely
  • initial_state variables support nested objects and mixed-value-type maps (e.g., variables = { player = { combo_count = 0, last_shot = "none" } })
  • Event data attributes support arbitrary key-value payloads (e.g., data = { target = "left_orbit", multiplier = 2 })
  • Mode state attributes in initial_state accept nested object values for pre-setting mode-specific state
  • Chain transition condition blocks now parse correctly, enabling conditional transition tests with success, min_events, and max_events fields
  • Self-enforcing corpus test: adding a new .cade.test file to docs/examples/ that fails to parse will break CI immediately — there is no mechanism to skip it

Scenario Testing Infrastructure

  • New .cade.test file format for declarative scenario tests — declare initial game state, fire events, and assert scoring outcomes, variable state, and event processing results against the real engine
  • cade scenario run now accepts directories with --recursive to batch-run all .cade.test files; supports --format json for CI integration
  • Four-layer CI test coverage for the config language:
    • Corpus validation — every .cade file in docs/examples/ is automatically parsed and validated on every PR, under both default and strict pragma modes
    • Scenario execution — every .cade.test file in docs/examples/ runs through the scoring engine with assertion checking
    • Canary ground truth — a set of trivially-verifiable scenarios (single hit → exact score) plus their negative counterparts, validating that the test infrastructure itself is reliable
    • Fuzz testing — random .cade generation via task fuzz stresses the parse-validate-execute pipeline for panics and unbounded memory; runs nightly in CI
  • Advanced feature matrix under docs/examples/advanced/ with working scenario pairs for: nested fragments, cross-fragment variables, assembly expansion, mode stacks, cascade events, pragma modes, and expression edge cases
  • New task runner targets: task fuzz, task test:fuzz:config, task test:fuzz:engine, task test:fuzz:resilience, task test:fuzz:platform, task test:determinism, task test:mutation

See Scenario Testing for the full file format reference and usage guide.

Session Variable Scope

  • New session scope for variables that reset at game boundaries but are shared across all players within a game — fills the gap between global (persists forever) and player (per-player turn)
  • Session-scoped variables are not player-keyed: all players read and write the same value during a game
  • Default lifecycle flags: ResetOnGame = true, Persist = false
variable "int" "table_bonus_level" {
  initial = 0
  scope   = "session"
  max     = 5
}

Game-End Variable Reset

  • Variables now reset at game end in addition to game start — player, ball, and session scoped variables return to their initial values during the game-over phase
  • Lights and other outputs driven by variables clear immediately when the game ends, rather than persisting until the next game start

Variable Change Events

  • Cade now emits variable.<name>.changed events whenever a variable’s value changes, regardless of the mutation source
  • Event payload includes name, old, new, scope, and reason fields
  • Reason values: set (explicit set), toggle (toggle action), reset (lifecycle reset), player_switch (active player changed)
  • Event handlers can listen to change events to keep physical outputs synchronized with variable state across lifecycle transitions
event_handler "sync_on" {
  event     = variable.l6_lit.changed
  condition = var.l6_lit == true
  actions {
    set_light "light" {
      device = l6
      state  = "on"
    }
  }
}

Player-Switch Variable Re-Sync

  • When the active player changes, variable.<name>.changed events fire for every player-scoped variable whose value differs between the outgoing and incoming player
  • Enables lights and outputs to automatically reflect the new player’s state without manual wiring

Nightly Fuzz CI Pipeline

  • Fuzz testing suites now run on a nightly schedule in CI with bounded fuzz time, separate from per-PR gates
  • New task fuzz target aggregates all four fuzz suites (platform, engine, resilience, config) for local runs

Async Log Sink With Drop-Oldest Backpressure

  • Log records are now forwarded to the console log file and the TUI Logs panel through bounded background queues, so a slow sink (a paused Logs tab, a stalled terminal, a flushing file) no longer blocks the code path that emitted the log
  • If either queue fills, the oldest record is dropped rather than stalling the caller — the ring-buffer view in the TUI always gets the record regardless, so nothing is lost from the live Logs panel
  • A dropped-log counter is exposed for observability: when non-zero, it indicates the external sink (file or remote) could not keep up with the emission rate. The TUI feed itself is unaffected
  • Matters most during event floods (rapid bumper hits, multiball chaos) where the audio mixer and scoring loops previously serialized behind log I/O

Value-Select switch Expression

  • New switch { case <cond> = <value> ... default = <value> } expression form — a guard-based, first-match-wins value selector usable anywhere an expression is expected
  • Cases are evaluated top-to-bottom; the first case whose condition is true supplies the result. default supplies the value when no case matches
  • Every case condition must be boolean; branch values are unified to a common type (numbers, strings, etc.), mirroring the existing ternary operator
points = switch {
  case var.combo >= 10 = 5000
  case var.combo >= 5  = 2000
  default              = 500
}

Hexadecimal Integer Literals

  • .cade and .cade.test files now accept hex integer literals (0x30, 0XFF) anywhere a number is expected — natural notation for hardware device addresses (id = 0x40)
  • Hex is rewritten to decimal before HCL parsing; hex text inside string literals, comments, and heredocs is left untouched
  • Covers both the main game-config parser and the scenario (.cade.test) parser, including chain files

Structured condition Blocks (all_of / any_of) Wired Into Scoring

  • score "event" rules and score "modifier" blocks now accept a structured condition { all_of = [...] } / condition { any_of = [...] } sub-block as an alternative to the scalar condition = <expr> form — previously this block was silently ignored by the game parser (no error, no effect) even though the expression engine already understood it
  • List elements may be bare expressions or quoted strings, matching the rest of the bare-expression convention
score "event" "guarded_award" {
  when   = device.target.hit
  points = 1000
  condition {
    all_of = [
      var.multiball_active,
      var.balls_in_play >= 3
    ]
  }
}

User-Defined Functions in Modules

  • Functions declared in a mode’s functions { } block are now callable from that mode’s own on_start / on_end / event action bodies — previously calling a declared function always failed with “undefined function”
  • Functions support return <expr> to produce a value usable in the caller’s expression; a function with no return evaluates to 0
  • Parameters are function-local: they no longer leak into surrounding variable state, and a parameter that shadows an existing variable is restored after the call
  • Recursive calls are bounded (max depth 64) and fail with a clear error instead of overflowing the stack
  • Functions are scoped to the mode that declares them — not visible from other modes’ actions
module "bonus_mode" {
  mode {
    functions {
      award(points) {
        set var.last_award = points
        return points * 2
      }
    }

    on_start {
      set var.bonus = award(250)
    }
  }
}

Variable constraints{} / operations{} Blocks

  • variable declarations with a nested constraints { } or operations { } block now parse correctly in real .cade files — previously any nested block inside a variable { } body caused the entire variable declaration to fail to parse
  • List-typed variables now enforce min_len (via constraints { min_len = N }), unique_elements, and allow_empty from operations { } at assignment time, in addition to the existing max_len check. Empty lists are permitted by default unless allow_empty = false is set
variable "list" "tags" {
  initial = []
  constraints {
    min_len = 1
  }
  operations {
    unique_elements = true
    allow_empty     = false
  }
}

Fragment Definitions Wired Into Scoring

  • Top-level fragment "static" "..." / fragment "dynamic" "..." blocks and a score "event" rule’s fragments = [...] reference attribute are now recognized by the game config parser and resolved at load time — previously the parser had no schema entry for fragment blocks or the fragments attribute, so both were silently dropped and referencing rules scored using unresolved (zero) values
  • Dynamic fragments compute fields (e.g. points) from bound parameters via expr(...) or HCL-escaped ${...} (as $${...}); a bare ${...} collides with HCL’s own interpolation and is now rejected with a warning pointing at the correct form
  • fragment.<name>.<key> dotted-path references inside a rule’s points, condition, or updates expressions now resolve to the fragment’s value at compile time — previously these paths evaluated to 0
fragment "static" "bonus_config" {
  base_points = 500
  multiplier  = 2
}

score "event" "bonus_ramp" {
  when   = device.ramp.hit
  points = fragment.bonus_config.base_points
  updates = {
    score = score + fragment.bonus_config.multiplier
  }
}

See Fragment reference for the full attribute schema.

Classic Replay Awards (Score-Threshold Credits / Extra Balls)

  • New top-level replay { } block: declare score thresholds that award a credit or extra ball the moment a player’s score crosses them during a live game
  • max_per_game caps how many replay awards a single player can earn per game (omit or 0 for unlimited)
  • Each threshold { score = ... reward = "credit" | "extra_ball" } fires once per game per player when crossed; crossing emits system.replay.award
  • Reward type is validated at load time — any value other than "credit" or "extra_ball" is a config error
replay {
  max_per_game = 3

  threshold {
    score  = 5000000
    reward = "credit"
  }
  threshold {
    score  = 20000000
    reward = "extra_ball"
  }
}

Event Flow Orchestration (flow Blocks)

  • New top-level flow "<type>" "<name>" { } block correlates multiple events into a single higher-level outcome, wired to the live runtime event bus — hits on the listed events actually drive the flow during a real game, and on_complete / on_timeout re-emit an event you can react to from an event_handler
  • Working today: sequence (events must occur in the declared order within window; a wrong-order event fails the flow), parallel (same event set, any order), and conditional (completes on the first listed event — see caveat below)
  • conditional caveat: there is currently no HCL syntax to attach an actual condition to a conditional flow — it behaves identically to a one-event sequence, completing unconditionally on its first listed event
  • Not yet functional: timed and state_machine are accepted by the parser but do not advance or complete from events at all — only a configured window timeout (if present) will fire on_timeout for them. Do not rely on these two types; accumulator parses and tracks progress but never auto-fires on_complete
  • points inside on_complete / on_timeout is parsed but has no runtime effect yet — award points from a separate event_handler reacting to the emitted completion event instead
flow "sequence" "skill_shot" {
  events = ["device.ball_plunged.triggered", "device.upper_loop.cleared", "device.skill_target.hit"]
  window = "5s"

  on_complete {
    emit = flow.skill_shot.made
  }
  on_timeout {
    emit = flow.skill_shot.missed
  }
}

event_handler "skill_shot_reward" {
  when = flow.skill_shot.made
  actions {
    points = 25000
  }
}

Module-Scoped Scoring Now Reaches the Engine

  • Scoring content declared inside a module { } block — variable, score "event", score "modifier", and accumulators — now actually scores during gameplay. Previously this content was parsed but silently dropped before reaching the scoring engine
  • A mode module’s scoring content is automatically gated on module.<name>.active, so it only scores while that mode is running — no need to write a manual condition
  • module.self.active now also resolves correctly for a standalone module declared directly (not just modules produced by compose)
module "multiball" {
  mode {
    priority = 500
  }

  score "event" "multiball_bumper_bonus" {
    when   = "scoring.bumper.bonus"
    points = var.bumper_value
  }
}

Implicit Playfield Multiplier (playfield_multiplier, Base-100)

  • The engine now recognizes one reserved variable, playfield_multiplier, and scales every rule-awarded points value by it before the award is applied — scoring rules need no explicit multiplier reference
  • Uses base-100 fixed point so fractional multipliers stay integers: 100 = 1× (no change), 300 = 3×, 250 = 2.5×, 50 = 0.5×. The award is points × playfield_multiplier / 100 (integer division)
  • A table that never declares playfield_multiplier scores at 1× — fully opt-in and backward compatible
  • A non-positive stored value is treated as the 1× base, so an uninitialized or zeroed variable can’t silently zero out all scoring
variable "int" "playfield_multiplier" {
  initial = 100
  min     = 100
  max     = 1000
  scope   = "ball"
}

Infrastructure Module Content Now Activates at Game Start

  • Variables, scoring rules/modifiers/accumulators, and event handlers declared inside an infrastructure module (a module { } block with no mode { } block — always-on, e.g. a trough or ball-save module) now actually activate at game start. Previously this content parsed cleanly but was silently dropped, so infrastructure-module scoring and handlers never ran
  • This content is always-on and is not gated behind a module.<name>.active condition (infrastructure modules have no mode lifecycle to gate against)
  • An infrastructure module’s unconditional device_control { } block (no condition attribute) now applies at registration, before the first game starts, instead of never applying at all. Condition-bearing device_control { } blocks continue to react to variable.changed events as before

module.<name>.<var> Expression Namespace

  • New expression namespace: module.<name>.<var> resolves a module-scoped variable inside conditions, scoring rules, and event handlers, e.g. condition = module.bumper_frenzy.jackpot > 0
  • Currently resolves to the variable’s declared initial value, seeded once when the module registers at game load — it does not yet track live updates made during gameplay (e.g. via set_variable). Treat it as a per-module constant, not a live binding, until live tracking ships
  • Referencing an unknown module or variable name is a clear evaluation error rather than a silent zero

New Action Verbs

  • New decrement = "<variable>" scalar action, the symmetric peer of increment — subtracts 1 in one step instead of set_variable { name = "x" value = var.x - 1 }
  • New music_control { action = "play"|"switch"|"stop" clip fade } action for one-shot imperative music transitions (a victory sting, an immediate cut on tilt); play/switch require clip, fade is a duration hint. Distinct from declarative background_music (below), which drives ongoing state/mode-based music
  • New display_text "<display>" { text duration priority } action pushes text to a named display for a duration — fully device-agnostic; text may be a bare expression. Commands now reach a real consumer: in cade console they flow into the TUI event stream, in headless cade run they’re logged. No physical DMD/segment-display renderer exists yet — this makes the command observable, not yet rendered on hardware
  • New launch_ball { count = N } action requests N balls served into play without naming a device — the ball/trough subsystem picks the trough/launcher. Distinct from kick_ball (fires an existing ball already held by a named kicker) and eject_ball (creates + kicks a new ball from a named device)
event_handler "track_ball_drain" {
  when = device.trough.active
  actions {
    decrement = "balls_in_play"
    emit      = ball_drained
  }
}

event_handler "wizard_victory" {
  when = signal.wizard_complete.complete
  actions {
    music_control { action = "switch"  clip = "wizard_victory_theme"  fade = "1s" }
    display_text "main_display" { text = "WIZARD MODE"  duration = "3s"  priority = 1000 }
  }
}

event_handler "multiball_lock" {
  when = device.lock.activated
  actions {
    launch_ball { count = 2 }
  }
}

Background Music: Reactive Playback, Envelopes, and Conditions

  • background_music { clip auto_start } now actually plays — previously the block parsed cleanly but the music player was never instantiated at runtime, so no music played in a real session
  • Per-mode audio { music = "<clip>" } blocks now reactively switch tracks: an active mode’s music overrides the base background_music track and the base resumes automatically when the mode ends
  • New envelope = "<name>" attribute on background_music, referencing a config-scope envelope "<name>" { attack decay sustain release } block (same ADSR grammar as synth envelopes) — attack shapes the fade-in, release the fade-out
  • condition = <expr> on background_music now gates playback against live game state — see the condition-evaluator fix below
envelope "mode_swell" {
  attack  = "2s"
  decay   = "0s"
  sustain = 1.0
  release = "1s"
}

audio {
  background_music {
    clip       = "wizard_battle_theme"
    auto_start = true
    envelope   = "mode_swell"
    condition  = mode.wizard.active
  }
}

Expression-Driven Device Audio Clip Pools

  • clip_pool (alias clips) on a device’s audio { } block now accepts a cade expression in addition to a static list — the clip list is re-evaluated against live variable/score state at playback, not fixed at load time
  • Falls back to the static list when no runtime context is bound yet
device "switch" "bumper" "context_bumper" {
  id = 0x40
  audio {
    clip_pool = <<-EXPR
      var.wizard_mode_active ? ["wizard_bumper_1", "wizard_bumper_2"] :
      var.multiball_active   ? ["mb_bumper_1", "mb_bumper_2"] :
      ["bumper_normal_1", "bumper_normal_2"]
    EXPR
  }
}

.cade-replay File Format and CLI

  • New portable single-file binary .cade-replay format for archiving/sharing recorded sessions independent of the SQLite history database
  • New cade replay export <game-id> [output.cade-replay] [--db <path>] — exports a recorded session to a .cade-replay file
  • New cade replay play <file.cade-replay> [--speed N] — replays events in recorded order (--speed 0 = as fast as possible, default 1.0 = real time)
  • New cade replay convert <file.cade-replay> [-o output.hcl] [--with-expectations] — converts a .cade-replay file to an HCL scenario test file (lossy relative to the JSON export used by cade replay to-scenario)

History Storage: Pure-Go SQLite

  • The cascade history store now uses a pure-Go SQLite engine instead of an embedded DuckDB (cgo + Arrow C++) — no native/cgo dependency
  • Default history database filename changed from history.duckdb to history.db
  • Measured on a stripped --no-audio build: binary size ~68.7 MB → ~36.4 MB (-47%), idle RSS ~63 MB → ~37 MB (-42%), thread count 26 → 11

Console Config Persistence

  • New config save [path] console command persists your live REPL settings (prompt, history, display, colors, completion, startup, session, debug) to an HCL file. With no path it writes to ~/.cade_console_config.hcl; an explicit path must end in .hcl
  • The saved file round-trips: what config save writes loads back through the console’s normal config-load path unchanged
cade:debug> config save
cade:debug> config save my-console.hcl

Event Tree Column Alignment

  • Event tree rows now align into fixed time score name columns — e.g. +6.961s +50 device.spinner.hit — instead of a single free-form line, so the event-name column stays put as scores and timestamps change width
  • Time and score columns pad to the widest value currently visible in the ball; scoreless rows still reserve a blank score column so names never drift
  • Collapsed runs adopt the same time score name ×N layout (previously name ×N +pts (last +X))

See Events Panel.

Active Modes Fade-Out and Column Alignment

  • A mode that just ended now lingers in the Active Modes section for 8 seconds (previously 2.5s) before rolling off — greyed out, with its elapsed time and score frozen at the moment it ended, instead of disappearing immediately into History
  • Fading (greyed) rows render above the live active-mode rows, so the live stack’s position stays settled instead of shifting every time a ghost rolls off
  • Active-mode rows now read in time, score, name, priority order — matching the column order Event rows already use — with a placeholder when a mode has no score yet instead of dropping the field
  • Time and score columns are now padded and aligned across all active-mode rows, so mode names no longer drift as those values change width
  • The Lifecycle section adopts the same time → score → name column order and alignment; a mode’s lifecycle entry now collapses into a single “ended” line (with duration and score) instead of separate started/ended entries

See Events Panel for the full reference.

Cascade Viewer Compact Mode and Row Truncation

  • New compact mode (c) in the cascade viewer drops the timing badge, running-total tail, and per-node score-delta suffix for a leaner view; off by default
  • Long tree rows are now clipped to the panel width with a trailing instead of running off the edge
  • Opening the cascade overlay (F7 / Ctrl+E / c from the Events panel) now freezes on the cascade you’re inspecting — it no longer gets overwritten as newer cascades arrive while the overlay is open
  • F7 / Ctrl+E from the Events panel now opens the cascade for the currently selected event, rather than always jumping to the latest live one
  • The [Alt+E] Toggle emojis footer hint now only appears once emojis are turned on (they’re off by default), reducing footer clutter

See Cascade Visualization.

VPX Flipper Autofire Inhibit

  • New set_autofire { device = "..." enabled = true|false } action for event handlers, enabling or disabling a driver autofire rule at runtime by device name — e.g. to inhibit flippers during tilt or between balls
  • VPX flipper button input is now mapped by VPX action rather than raw device key, matching real hardware behavior more closely
event_handler "tilt_flippers_off" {
  when = variable.tilted.changed

  actions {
    set_autofire { device = "RightFlipper001" enabled = false }
  }
}

Platform Debug Log File

  • New debug_log attribute on platform "grpc" blocks writes gRPC platform-client stream diagnostics (connects, reconnects, manifests, errors) to a dedicated file, independent of the TUI’s own log pipeline — useful when the TUI Logs feed itself is under suspicion
  • Equivalent --grpc-platform-debug-log <path> flag on cade console sets the same option without editing the config file
platform "grpc" "vpx" {
  platform_address = "127.0.0.1:55000"
  debug_log         = "/tmp/cade-platform.log"
}

File-Organization Lint Warnings in cade validate

  • New --include-file-org flag on cade validate opts in to advisory file-organization warnings: a device declared in what looks like a module-oriented file (and vice versa), mixed device/module concerns in one file, a top-level variable declared alongside module blocks, and files over 500 lines
  • Warnings-only — they never flip cade validate’s pass/fail result — and surface in text, json, and yaml output alike
  • Individual rules can be silenced with a lint.<rule> = "ignore" pragma, or all of them at once with lint.file_organization = "ignore"
cade validate --include-file-org

Validate-Time Checking of Synth Parameter Bindings

  • cade validate now compiles every synth pitch / velocity binding expression at validate time — including the bindings on a route block’s source "synth" — and reports a malformed one as a validation error, naming the synth and field
  • Previously a bad binding passed validation silently and only surfaced at runtime as a logged warning with the trigger skipped

Changed

Breaking: synth_trigger { } Is Retired

  • The synth_trigger { } action verb is no longer authorable. A .cade file that contains one — nested in actions { } or written bare under an event_handler — no longer loads, and the error names your synth and points at the replacement form
  • A synth voice is now authored only as a top-level route { } with a synth source. Every sound in a table therefore travels one path, so a synth voice honors selection { } decisions, condition gating, weights, and mixer routing exactly like a clip does
# Before — no longer loads
event_handler "bumper_hit_sound" {
  when = device.pop_bumper_1.hit

  synth_trigger {
    synth    = "bumper_hit"
    pitch    = 880
    velocity = 1.0
  }
}

# After
event_handler "bumper_hit_sound" {
  when = device.pop_bumper_1.hit
}

route {
  owners = [event_handler.bumper_hit_sound]

  source "synth" "bumper_hit" {
    weight   = 1
    pitch    = 880
    velocity = 1.0
  }
}

Migration is mechanical: keep the handler (it may now carry no actions at all — that is valid, and the route still fires), and move synth, pitch, velocity, plus any duration / sustain / position, onto a source "synth" in a route owned by the handler. Expression-valued pitch / velocity carry over verbatim.

Breaking: Nested audio { } Blocks No Longer Parse

  • The per-owner audio { } sub-block under device, score, modifier, accumulator, and event_handler has been removed. Authoring one is now a load error naming the owner and the replacement form
  • Move the clip list into a top-level route { } and the decision attributes into a top-level selection { }, both addressed with owners = <owner>. The channels list under the audio system block is likewise replaced by top-level channel / bus blocks

Breaking: Synth Patches Require an output { } Block

  • Signal-path inference from node names (an oscillator called carrier, an envelope called amp) is gone. Every patch must wire its path explicitly with an output { } block, and a patch without one is rejected at compile with an actionable message
  • Existing patches migrate by adding output { in = node.<carrier>.out gain = node.amp.out } — the same chain that used to be assumed

Breaking: Envelope Times Are Plain Milliseconds

  • attack, decay, and release on envelope blocks (both synth envelopes and config-scope music envelopes) are now numbers in milliseconds. Write attack = 5, not attack = "5ms" — the duration-string form no longer parses
  • Values are equivalent one-for-one: "440ms" becomes 440, and audio output is unchanged

Breaking: Node Names Must Be Unique Within a Patch

  • A synth patch’s oscillators, envelopes, and filter share one namespace, so a duplicate label is now rejected at load. In particular the previously documented envelope "filter" convention for a cutoff-modulating envelope collided with the filter node itself — the shipped examples now name it envelope "cutoff_env". Rename any envelope called filter in your own patches

Deleting a Device No Longer Breaks the Whole Config

  • A route, selection, or light_layout place that still points at a device or scoring rule you deleted (or renamed) no longer fails the reload. The dangling reference is dropped with a warning — a route or selection that loses every owner is disabled, a layout just drives fewer lights — so the rest of the table keeps working while you finish the edit
  • Genuine authoring conflicts stay hard errors: two routes claiming the same owner, or a spatial/matrix violation in a layout, still fail the load

Rollover Light Examples Updated to Player Scope

  • All three full-example-table variants now declare rollover light variables with scope = "player" instead of scope = "global", so each player maintains independent rollover progress during multiplayer games
  • Added sync handlers listening to variable.<name>.changed events to keep physical light state synchronized across player switches and game-end resets
  • Uses paired conditioned handlers (on/off) since light state blocks only support literal values, not expressions

Drop- and Standup-Target Examples Migrated to New Subtypes

  • All four full-example-table variants now declare drop targets as device "switch" "drop" "..." and stand-ups as device "switch" "standup" "...", matching the driven-device subtype convention
  • Existing VPX-style event-handler reset logic is preserved; inline comments note the declarative reset { coil = ... } sub-block as the alternative when a physical reset coil is wired
  • No behavior change for existing examples — the migration is naming-only and opt-in for your own configs

New Example: Modes + Synth Bumper Table

  • Added docs/examples/full-example-table-with-modes-synth/ — extends the modes example with two FM synth patches (bumper_pop, bumper_thud) wired across five bumpers at pitches drawn from a C major triad
  • Demonstrates synth sources and clip sources coexisting as candidates in the same route block
  • No audio files required — all bumper sounds are generated from oscillator patches at runtime

Example Bumper Synth Patches Retuned to Sci-Fi Textures

  • The bumper_pop patch in the synth-enabled example tables is now a resonant laser-zap voice — inharmonic FM, a downward pitch “pew,” and a long amp/filter tail — replacing the original clangorous chirp
  • The bumper_thud patch is now a warp-core sub — slow detuned modulation and a deep filter sweep with extended decays so the voice rings out instead of cutting off short
  • Fixes a latent issue in the warp-core filter envelope — the previous patch used sustain > 0 on a one-shot voice (bumpers never issue a note-off), so the filter envelope would have held indefinitely. The retuned patch uses sustain = 0 so the envelope completes naturally
  • Device wiring, scoring, bumper pitch bindings, and modes are unchanged — this is a presentation-only refresh of the example content

Quieter Logs During Event Floods

  • The per-tick Mixing audio DEBUG line is now rate-limited — at most one entry every 250 ms (or every 50th mix tick, whichever fires first) — instead of once per tick at 50–100 Hz. The Logs panel is no longer swamped by mixer output while the audio engine is running
  • Cascade capture start/stop no longer emits narrational INFO lines. The toggle is still observable via events; the redundant log output has been removed
  • Each TriggerEvent now emits one consolidated DEBUG line at finalize time instead of four separate lines (2× INFO, 1× DEBUG, 1× INFO). Overall log volume under gRPC event floods drops by roughly 3×

Fixed

wave = "triangle" Emitted a Sawtooth

  • The triangle oscillator produced a rising ramp identical to saw, so a patch asking for a triangle got a sawtooth’s harmonic content. It is now a real folded triangle — softer odd harmonics, warmer than sine, gentler than saw, as documented. Patches using wave = "triangle" will sound different (correct) after this change

Device-Owned Routes Made No Sound

  • A route { } owned by a device validated and rendered but never played anything, and a synth source under any owner was dispatched as if it were a clip — resolving to a clip id that does not exist, and failing silently. Device-owned routes now dispatch on both the native and in-browser runtimes, with synth sources going down the voice path and unsupported capabilities warning loudly instead of going quiet

Multi-Source Routes Picked Differently Every Run

  • Routed audio selection was seeded from the wall clock, so a route with more than one candidate chose a different source on every run and a replayed table produced a different sound sequence each time. Selection is now seeded deterministically, so the same table and seed replay identically

Playlist track_probability Was Ignored

  • A music playlist naming a distribution in track_probability parsed the attribute but never used it — track order fell back to the playlist’s mode. The distribution is now sampled at selection time and takes precedence over the mode, deterministically for a given seed. An unknown distribution or out-of-range draw still falls back to mode-based selection rather than failing

Variable-Driven Outputs Not Clearing at Game End or Engine Stop

  • Rollover lights and other outputs driven by variable.<name>.changed sync handlers would stay lit after the last ball of a game, and again after the engine was stopped — the reset chain described in Syncing Outputs to Variable State was not wired into the running game-session manager, so variable.*.changed events with reason = "reset" never reached handlers in a real game
  • Game end now drives the same reset path that scenario tests already exercised: system.game.end fires first, then player, ball, and session scoped variables reset and emit variable.*.changed with reason = "reset", and paired sync_off handlers turn lights off immediately
  • Engine stop (leaving the live engine from the console) now triggers the same reset chain at machine-reset granularity, so outputs return to defaults instead of holding their last state across a restart
  • An integration test drives a real game to natural end-of-game and asserts the set_light calls reach the device commander — a regression in the wiring will fail the build rather than silently re-introducing the stale-light behavior

Scoring Compiler Constants Collision in Multi-Update Rules

  • Scoring rules with multiple variable updates in a single update block could produce incorrect values when the compiled sub-expressions used fused arithmetic operations — only one set of constants was retained, causing subsequent operations to read the wrong constant value
  • Example: update = { counter = var.counter + 1, multiplier = var.multiplier + 10 } could incorrectly set counter to 10 instead of 1
  • The fix properly concatenates constants from all sub-expressions and adjusts operation indices so each sub-expression’s constants occupy distinct slots

Synth Silence on Percussive Patches

  • Short percussive synth voices (sustain = 0) accumulated as zombies in the voice pool without ever reaching idle, eventually starving new triggers of free voices — percussive ADSR envelopes now transition directly from decay to idle when sustain is 0
  • The audio pipeline was being initialized twice in some startup paths, causing the first subsystem to stop producing output — initialization is now gated to a single path
  • The ring buffer is simplified to always read mono and duplicate to stereo, removing a stereo-path mismatch that silenced certain patches

Crash When mode.<name>.active Referenced With No module {} Blocks

  • Configs that referenced mode.<name>.active in handler or scoring conditions but declared no module {} blocks would crash on the first event dispatch with a nil pointer dereference inside the mode manager
  • The config is now treated as “no modes configured” and mode.<name>.active evaluates to false — no crash, no dispatched mode lifecycle

Audio Stalls During Event Floods

  • Rapid bumper hits and other high-rate event bursts could produce audible audio stalls of up to ~2.8 seconds before voices started playing — new triggers appeared to queue up and then flood out once the mixer caught up
  • The mixer and synth dispatcher no longer serialize sound-effect triggers behind log I/O or per-tick debug output; the AddSource / Dispatch paths take only brief, map-scoped locks and emit logs outside the critical section
  • Net effect on a tight bumper loop: sound-effect latency stays bounded and consistent, with no perceptible catch-up burst when the scene quiets down

Parser Rejected Canonical Two-Label variable Blocks

  • The scoring-only parser accepted only single-label variable "name" { } declarations and rejected the canonical variable "type" "name" { } form documented in the Variable reference
  • The parser now accepts the two-label form and ignores unrelated top-level blocks (assembly, device, mode, etc.) when parsing scoring from a full .cade file

Assembly- and Instance-Level Tags Not Captured by the Parser

  • The Assembly Tag Propagation — Three-Source Union Merge behavior documented for v0.1.0 only ever worked for device-level tags: the parser’s assembly { } and use { } block schemas had no entry for tags (or description on assembly), so assembly-definition tags and instance (use block) tags were silently dropped before reaching the expander’s union-merge logic — only device-level tags actually propagated
  • assembly { tags = [...] description = "..." } and use "..." "..." { tags = [...] } are now captured by the parser, so all three tag sources genuinely union onto expanded device instances as documented

Legacy Top-Level event / global_event / settings Blocks No Longer Silently Dropped

  • A top-level settings { } block’s contents now reach the running engine (e.g. settings { audio { master_volume = 0.8 } } correctly overrides the audio master volume) — previously the block parsed without error but every value inside it was discarded
  • Legacy singular event { } and global_event { } blocks (older aliases of global_events { }) are now parsed instead of silently dropped
settings {
  audio {
    master_volume = 0.8
  }
  players {
    max = 4
    min = 1
  }
}

Bare Action Verbs in event_handler Blocks Now Fire

  • Action verbs (pulse_coil, set_light, set_variable, eject_ball, etc.) written directly under event_handler { }, without an enclosing actions { } block, now execute. Previously they parsed cleanly but were silently dropped — the handler fired but nothing happened
  • The explicit actions { } wrapper is still supported and can be mixed with bare verbs in the same handler
event_handler "kickback_fire" {
  when = device.Kickback.activated

  pulse_coil "c_kickback" {
    duration = "35ms"
  }
}

Scoring Config Loading Converged With the Table Parser

  • Loading a scoring configuration from a table directory or a standalone scoring file (used by scenario testing and other scoring-only tooling) now goes through the same parser and expansion pipeline as cade run / cade console — module composition, assembly expansion, hex literals, and the full score-rule grammar all apply uniformly
  • Previously a narrower standalone decoder silently failed on constructs the main parser accepts: update = { var = expr, ... } attribute-form updates on score rules, and mixed number/bool value coercion. Configs using either construct would fail to load through the scoring-only path even though they ran fine under cade run

Reactive device_control and background_music Conditions Never Evaluated in Real Sessions

  • The condition expression on reactive device_control rules and on background_music tracks was previously only evaluated inside scenario tests — a live cade run or cade console session had no evaluator wired, so these conditions silently had no effect
  • A shared evaluator (the same live score/variable/signal state used by synth parameter bindings) is now installed on every session, so .cade conditions now behave identically in a real game and in a scenario test

Span State Snapshots Were Always Empty

  • Spans recorded in the history database (ball turns, modes, multiball, etc.) now populate their state_snapshot JSON column with live game state — players, scores, and ball number captured at span boundaries (game start, ball start, ball drain, game end)
  • Previously the column existed in the schema but was always NULL even with recording.state_snapshots enabled (the default)

Active-Mode Scoring Misattribution

  • Points from a scoring rule were credited to every currently active mode, so an always-on infrastructure mode (or a lower-priority mode) would double- or triple-count the same points already attributed to a higher-priority mode
  • Scoring is now attributed to the mode that actually owns the fired rule, resolved from the rule’s module.<name>.active condition; falls back to crediting the highest-priority active mode only for global/ungated rules or an ambiguous condition spanning multiple modules

Cascade History Commands Unreachable in the Console

  • The console’s history command always opened the built-in command-history viewer, so the history games / history scores / history switches / history spans / history cascade / history sql subcommands were implemented but silently unreachable
  • history <subcommand> now routes to the cascade-history command family; bare history (or a session with recording disabled) still falls back to the built-in viewer

Event Tree Could Get Stuck Showing “No Events Yet”

  • On a busy machine, the event tree could miss the first game’s start signal if scoring activity arrived before the tree’s subscriber attached — the tree stayed on “No events yet” for the entire game, only unlocking on the next game’s start
  • The tree now self-heals: if ball or scoring activity arrives with no game-start observed yet, it synthesizes the game span from that activity so the rest of the game displays normally

Event Tree Player Node Showed the Wrong Ball Count

  • The player node’s ball counter (e.g. Ball 1/3) was derived from how many balls had been played so far rather than the table’s configured balls-per-game, so it always read Ball 1/1, 2/2, 3/3
  • It now reads the configured ball count from game-start data, so a 3-ball game correctly shows Ball 1/3, Ball 2/3, Ball 3/3

Command History Recall via Arrow Keys

  • Up / Down on the Console tab previously scrolled the REPL output transcript instead of recalling previous input — command history could not be recalled with the arrow keys
  • Up / Down now recall command history as expected; j / k / PgUp / PgDn / Home / End remain available for scrolling REPL output

VPX Start Button Not Registering

  • Pressing Start did nothing on some VPX tables — VPX emits start_game as its Start input action’s device key, which wasn’t in the engine’s recognized set of start-button keys
  • start_game is now recognized alongside start, start_button, and pause

VPX Bridge Connection Drops and Reconnect Storms

  • The gRPC connection to VPX could drop with a TCP reset shortly after a table loaded — a device-manifest request made on the same already-active stream could starve VPX’s connection handling. The manifest is now pushed through the stream itself instead of fetched with a separate request, eliminating the conflict
  • A connection that reconnects and drops again immediately (a flapping link, or a table that has already exited) no longer forces a fresh config update and engine restart on every attempt — the client now waits for the session to prove stable first, and backs off exponentially across repeated short-lived sessions

cade validate / cade migrate Crashed on -d

  • Both commands crashed on startup with a flag-registration panic, because their -d shorthand for --dir collided with the root command’s persistent -d (--table-directory) shorthand
  • The -d shorthand has been removed from both commands’ --dir flags — use --dir (long form only)

v0.1.0 — 2026-04-14

Initial versioned release of the Cade Runtime. Ships the declarative HCL configuration surface (.cade files, fragments, assemblies, modules), the compile-time-optimised scoring and expression engine, the event processing pipeline with aggregation primitives, the TUI console with Logs/Events/Console tabs, the VPX and FAST drivers, hot reload, scenario and replay tooling, and cross-platform builds for Linux, Windows, and macOS (arm64/amd64). Versioning is now tracked via the cade version command and the --version flag.

Added

Assembly Tag Propagation — Three-Source Union Merge

  • Assembly expansion now unions tags from three sources onto each expanded device instance: assembly definition tags, instance (use block) tags, and device-level tags within the assembly body
  • Merge order is assembly → instance → device; duplicates are deduplicated preserving first-occurrence order
  • Instance tags are strictly additive — they cannot remove tags inherited from the assembly definition
  • Enables device_control tag selectors to work uniformly across all assembly instances while allowing per-instance differentiation (e.g., "left_side" vs "right_side" tags on flipper instances)
assembly "bumper" {
  tags = ["autofire", "scoring_device"]

  parameter "int" "switch_id" { required = true }
  parameter "int" "coil_id"   { required = true }

  device "switch" "bumper" "sensor" {
    id   = param.switch_id
    tags = ["active_switch"]
  }

  device "coil" "bumper" "ring" {
    id = param.coil_id
  }
}

use "bumper" "pop_1" {
  switch_id = 0x40
  coil_id   = 0x30
  tags      = ["upper_playfield"]
  # After expansion:
  #   switch "bumper" "sensor" tags: ["autofire", "scoring_device", "upper_playfield", "active_switch"]
  #   coil   "bumper" "ring"   tags: ["autofire", "scoring_device", "upper_playfield"]
}

Event Aggregation Example Configurations

  • Comprehensive example file demonstrating all three aggregation primitives with real pinball use cases
  • Burst examples: bumper cluster detection (5 hits in 2s), spinner frenzy (10 spins/s), slingshot chain (4 alternating hits in 3s), tag-based bumper flurry, and orbit combo with diminishing-returns cooldown
  • Coincidence examples: hot playfield (bumpers + ramps + targets within 5s), multiball chaos (4 device groups within 3s), and cross-device skill shot (plunger lane + skill targets within 2s)
  • Rate examples: spinner speed tiers (idle → active → fast → frenzy with per-spin multiplier), playfield activity intensity (calm → active → intense), and bumper intensity with progressive value adjustment
  • Combined patterns: three-layer bumper scoring stack (per-hit + burst bonus + rate-adjusted value), multiball scoring stack with coincidence bonuses, and a score "modifier" block that amplifies aggregation-triggered bonuses during multiball mode
  • Cooldown patterns: short (slingshot chains), medium (bumper clusters), long (spinner frenzy), no cooldown (coincidence), and dynamic cooldown via diminishing-returns scoring

Examples Conversion — game_mode to module Syntax

  • All example files in docs/examples/ converted from game_mode / mode blocks to the canonical module "name" { mode { } } form
  • Explicit condition = "module.self.active" / game_active conditions removed from module-scoped scoring modifiers in design examples — the loader auto-injects module.<name>.active guards, making manual conditions redundant
  • Example files now demonstrate active_on_startup = true and stop_events mode attributes within the module wrapper

Event Aggregation System

  • New aggregation HCL block type with three primitive variants for composite event detection:
    • Burst detection (aggregation "burst"): per-device rapid-fire counting within a tumbling window, emits count + rate_hz on threshold
    • Cross-device coincidence (aggregation "coincidence"): multi-group activation within a shared window, emits device_count
    • Rate-aware scoring (aggregation "rate"): EWMA-smoothed event density with rate_tiers block containing tier sub-blocks (min_rate, label, multiplier); 10% hysteresis on downgrades; tier.label and tier.multiplier available in trigger data
  • Each rule accepts devices or device_groups, window, cooldown, and a trigger/tier-change emit target; aggregation signals feed existing score "signal" blocks so scoring logic requires no changes
  • Cooldown tracks state silently and fires immediately at expiry if the condition is still met; per-primitive semantics (burst accumulates, coincidence resets, rate continues EWMA)
  • Aggregation signals inherit cascade context from the first triggering event, so individual and aggregated scores are both attributed to the same cascade
  • Additive by default — individual events continue scoring normally; aggregation adds composite signals on top. Under high load an aggressive mode may coalesce events to protect tick-rate

Event Tree Viewer Improvements

  • Bug fix: runs of the same event now report accurate total count and score regardless of the display window size (previously capped at 16)
  • Bug fix: collapsed-run nodes support scroll-back overflow within their children; runs with more than 16 events show ... N earlier ... markers
  • Bug fix: collapsed-run nodes default to collapsed — the summary label (e.g. device.Bumper1.activated ×50 +50,000 pts (last +56.895s)) is enough at a glance; expand with Space to drill in
  • Event panel now renders with fidelity matching the design prototype: player labels show live score and event count; total-balls-per-player derives from recorded play; device-type coloring, mode routing tags (→ [multiball, base]), held duration, score, and cascade depth indicators appear on event leaves
  • Active-mode lines show elapsed time via since +12.494s; completed modes append accumulated score (e.g. +23,100 pts)
  • Mode history and lifecycle display split into separate sections — history summarises completed modes (super_jackpot pri:600 +0.805s–+1.611s (806ms) 23,100 pts [20 events, 7 cascades]); lifecycle lists individual start/end entries chronologically
  • Sub-second time deltas now render as +0.805s instead of +805ms for visual consistency
  • Cascade timing durations below 0.05 ms are suppressed from the display — these would render as “0.0ms” and add visual noise without conveying useful latency information
  • Collapse state resets automatically when a new game starts — tree keys like player and ball nodes are reused across games, so stale collapsed/expanded state from a previous game no longer carries over
  • Zero overhead when running headless — tree state is only accumulated when the TUI is active

Active-on-Startup Mode Auto-Launch

  • New active_on_startup = true attribute on mode {} blocks; when set, the mode is automatically started after the first ball launches — no explicit start_mode action required
  • Mode lifecycle events flow through the cascade pipeline into the TUI event viewer
module "base" {
  mode {
    priority          = 100
    active_on_startup = true

    events {
      on game.ball_end { end_mode = "base" }
    }
  }
}

Engine-Level Multiplayer and Mid-Game Joins

  • Unified start-button handling across headless, console, and hardware targets with a single 200ms-debounced entry point — auto-launches the game immediately after the first press from idle or game-over state (mirrors real pinball behavior: Start = queue + go)
  • Mid-game joins: pressing Start while a game is in progress inserts a new player at the current ball without disturbing existing players’ scoring state; new player plays only remaining balls and emits system.player.joined with "mid_game": true
  • Player IDs generated sequentially as plain integers ("1", "2", "3")
  • Removed --players CLI flag and pre-seeded player configuration; the engine is now the sole authority over game start and player management

Config Primitive Cleanup — Mode Removal, Passive Mode Primitives, Signal Guidance

  • Top-level game_mode "x" { }, bare mode "x" { }, and game_modes { } container blocks are no longer valid; any of these now returns a parse error directing authors to module "x" { mode { } }
  • New device_control { } block inside module and mode contexts for declarative enable/suppress rules; uses a unified target "<type>" { select { tags = [...] } apply { enabled/suppress } } pattern with an optional condition expression
  • New suppressed_by = [...] attribute on module blocks to name mode modules that suspend the infrastructure module’s event handlers, scoring, and device_control on activation
  • Assembly-level tags propagate additively to all devices expanded from a use block, so device_control tag selectors work uniformly across all instances
  • Module loader auto-injects module.<name>.active as a condition guard on every modifier, event handler, and accumulator inside a mode module — entries with no condition get the guard directly; entries with an existing condition get "module.<name>.active && (<existing>)". Infrastructure modules (no mode block) are not affected
  • Documentation examples updated: game_mode blocks converted to module { mode { } }; explicit condition = "module.self.active" removed (now auto-injected)
# Before
game_mode "stop_and_go" {
  priority = 800

  events {
    on device.left_flipper__button.activated { emit "menu_select_left" }
  }
}

# After
module "stop_and_go" {
  mode {
    priority = 800

    device_control {
      target "device" {
        select { tags = ["flipper"] }
        apply  { enabled = false }
      }
    }

    events {
      on device.left_flipper__button.activated { emit "menu_select_left" }
    }
  }
}

Game-Passive Modes & Device Control Design

  • New design documentation for game-passive mode infrastructure: attract, tilt, stop-and-go, and insert-initials modes with their inhibition contracts
  • Unified target/select/apply pattern for addressing devices, modules, or scoring rules by tag glob rather than by name — usable both imperatively inside event handler actions and declaratively inside device_control blocks
  • device_control block: declarative device/scoring inhibition that auto-applies on mode activation (mode-scoped) or reacts to variable changes (module-level with condition); owner-tracked so multiple concurrent inhibitors don’t accidentally re-enable each other on partial release
  • suppressed_by field on infrastructure modules: names mode modules whose activation suspends the infrastructure module’s event handlers, scoring, and device_control — enables attract to re-enable devices automatically when base gameplay begins, with no explicit wiring
  • Assembly-level tags field: tags declared on an assembly definition propagate additively to all device blocks expanded from that assembly; ensures consistent tag coverage for device_control targeting across all instances of hardware assemblies (e.g., every flipper from use "flipper" carries ["player_controlled", "flipper"] automatically)
  • Updated infrastructure-module.cade tilt example to use declarative device_control (condition-based on var.tilted) replacing bare emit "flippers_disable" / emit "scoring_disable" events that had no backing receiver spec

Kick Ball Action — Driver-Agnostic Kicker-Kick Primitive

  • New kick_ball { device = "X" } action that fires an existing ball held by a kicker device without creating a new ball or incrementing the ball-in-play count
  • Distinct from eject_ball (which does CreateBall() + Kick() for trough/launcher flows): kick_ball wraps Kicker::Kick() only, with no ball creation — resolving the gate-to-kicker phantom-ball bug where eject_ball spawned a new ball on every gate hit
  • The kick_ball action carries only a device attribute; physics parameters (angle, strength) live on the device declaration, keeping the action driver-agnostic
  • Kicker devices accept a settings { kick_angle, kick_strength } block, mirroring the flipper settings { strength, hold_time } pattern — values that any driver can interpret
  • The VPX bridge wraps Kicker::Kick() without CreateBall(); returns an error when the kicker has no ball held
device "switch" "kicker" "Kicker1" {
  id = 41
  settings {
    kick_angle    = 190
    kick_strength = 10
  }
}

event_handler "gate_hit" {
  event = device.Gate1.cleared

  actions {
    kick_ball { device = Kicker1 }
    increment  = "balls_in_play"
    start_mode = "multiball"
  }
}

Noise Context Configuration

  • New noise_context top-level HCL block for declaring named noise generation contexts in .cade files
  • Each context selects an algorithm ("hash", "white", "simplex", "perlin"), a seeding strategy ("time", "random", "deterministic", or an integer literal), an advisory quality hint, and an optional LRU cache size
  • seed_base = "deterministic" produces identical sequences across runs for use in testing and replays; "time" / "random" seed from wall-clock time for non-deterministic production use
  • Multiple named contexts can coexist in the same configuration file, each maintaining independent state
  • Invalid algorithm values are rejected at parse time with a descriptive error
noise_context "scoring" {
  algorithm  = "hash"
  seed_base  = "time"
  quality    = "balanced"
  cache_size = 256
}

noise_context "testing" {
  algorithm = "hash"
  seed_base = "deterministic"
}

Variable Auto-Decay and Auto-Grow

  • Variables now support two decay models configurable directly in HCL:
    • Continuous decay: decay_rate (amount subtracted per second) with decay_to as the floor value
    • Interval decay: auto_decay = true with decay_interval (tick period), decay_amount (per-tick subtraction), and decay_condition (optional expression guard that pauses decay when false)
  • Variables now support two growth models:
    • Continuous growth: growth_rate (amount added per second) with growth_to as the ceiling value
    • Interval growth: auto_grow = true with grow_interval (tick period) and grow_amount (per-tick addition)
  • Decay and grow fields can coexist on the same variable for competing-force mechanics (e.g., passive cooling + event-driven heating)

Show Dispatch from Event Handlers

  • play_shows and stop_shows fields in event_handler action blocks are now dispatched at runtime
  • Previously these fields were parsed but silently dropped; they now correctly activate and deactivate named shows

ActionBlock Naming Fixes and Trigger Wiring

  • fire_signal replaces trigger in action blocks: fire_signal = "mystery_award" imperatively activates a named signal; the old trigger = keyword is no longer accepted
  • when replaces trigger in score blocks: when = "shot.orbit.complete" declares which event activates a scoring rule, matching the event_handler convention; the old trigger = keyword is no longer accepted in score blocks
  • fire_signal routes through the signal processing layer (not raw event emit): the signal’s own completion handlers, cascades, and dependent scoring rules all apply
event_handler "combo_complete" {
  when = shot.left_orbit.complete

  actions {
    fire_signal = "combo_jackpot"   # activates signal through signal processing layer
    emit        = combo_scored    # injects event into event handler layer
    points      = 25000
  }
}

score "jackpot_rule" {
  when   = combo_jackpot.complete
  points = 100000
}

ActionBlock Execution Pipeline — Scoring and Emit Data

  • points = N in action blocks now awards points to the active player at runtime (previously silently dropped in both full-session and debug console paths)
  • emit = "event.name" now forwards the data = {} block contents through to subscribers (previously the payload was dropped)
  • No configuration schema changes — points, emit, and data fields already existed; they now produce runtime effects where previously they were silent no-ops

Mode Control from Event Handler Action Blocks

  • New start_mode and end_mode scalar fields on action blocks in event_handler configuration let event handlers directly start or stop game modes without requiring a separate scripted mode body
  • start_mode = "multiball" activates the named mode on the mode stack when the event handler fires
  • end_mode = "multiball" deactivates the named mode from the mode stack when the event handler fires
  • Both fields are optional and composable with other action block fields (emit, toggle_variable, pulse_coil, etc.)
  • Gracefully no-ops when no mode controller is configured (e.g., console-only sessions)
event_handler "multiball_lock" {
  when = device.lock.activated

  actions {
    start_mode       = "multiball"
    toggle_variable  = "lock_active"
    emit             = multiball_started
  }
}

event_handler "drain_handler" {
  when = game.ball_drain

  actions {
    end_mode = "multiball"
  }
}

Event Router Deduplication

  • Event router now deduplicates identical events within a configurable time window (default 100 ms) to prevent the same physical event from being processed twice when it arrives via both the hardware path and a derived logical signal
  • New dedupe_window field on signal blocks (e.g. dedupe_window = "75ms") overrides the default per-signal; empty means “use the router default”
  • Optional per-processor rate limiting via a token bucket — events that exceed a processor’s configured rate are dropped immediately
  • Deduplicated events surface in routing metrics under reason "deduplicated"; rate-limited events under reason "rate_limited"

Expression String & List Built-in Function Libraries

  • Complete string.* function library: string.contains, string.substring, string.to_upper, string.to_lower, string.split, string.join, string.trim, string.replace, string.matches, string.to_int, string.to_bool — added alongside the existing string.length, string.concat, string.from_int, and string.from_bool
  • Complete list.* function library: list.empty, list.contains, list.index_of, list.first, list.last, list.get, list.slice, list.prepend, list.insert, list.remove, list.remove_at, list.reverse, list.sort, list.shuffle, list.unique, list.join, list.from_element, list.concat, list.difference, list.intersection — added alongside the existing list.length, list.append, and list.random
  • string.split returns a ListValue of StringValue elements; string.join and list.join accept a list of strings and a separator
  • string.matches uses Go’s regexp package for full regular expression support
  • list.shuffle uses the evaluator’s seeded RNG for deterministic results in tests and replays
  • All list modification functions return new lists; existing lists are never mutated

Reserved Keyword Validation

  • New --check-reserved-words flag on cade validate to opt in to identifier conflict detection (enabled automatically when --strict is set)
  • Reserved keyword validation stage wired into the integration validation pipeline as a post-validateVariables step; issues appear in text, json, and yaml output with category and suggested alternatives
  • Tiered severity controlled by --pragma-mode: strict treats all reserved names as errors; normal (default) allows contextual and future keywords as warnings; relaxed only warns on strict keywords
  • Suggestion engine generates context-aware alternative names (e.g., block names prefixed with my_, game_, or custom_; variable names suffixed with _val, _count, or _total)
  • New cade migrate --reserved-words top-level command for scanning .cade files and reporting reserved word conflicts with file, line, column, category, and suggestions
  • cade migrate --reserved-words --auto-fix rewrites conflicting identifiers using the first suggestion, creating a .cade.bak backup of each modified file
  • cade migrate --reserved-words supports -d <dir>, -r (recursive), --pragma-mode, and --format text|json
  • Exit code 0 when no conflicts found; exit code 1 when conflicts are present (dry run) or an error occurs during fix

Expression Compile-Time Optimization

  • Expressions and conditions are now simplified at compile time before emitting runtime operations:
    • Constant folding: binary and unary expressions with all-literal operands are evaluated at compile time (e.g., 1000 * 22000, (2 + 3) * 420); division by zero defers to runtime
    • Dead code elimination: ternary branches with compile-time-known conditions are pruned (e.g., true ? 1000 : 5001000)
    • Operation fusion: recognizes variable ± constant and variable × variable patterns and emits specialized fast-path operations
  • Fully constant expressions emit a zero-work inline constant; partial expressions still benefit from folded sub-trees

Hot Reload and Session Unification

  • Automatic scoring recompilation when .cade configuration files change at runtime, with full player state preservation (scores, balls, variables, accumulators)
  • Rollback to the previous configuration if the new one fails to compile; gameplay continues uninterrupted
  • Consistent session behavior across cade, cade console, and cade scenario commands
  • Clean shutdown sequencing with optional context deadline
  • Bug fix: the root cade command now correctly recompiles scoring on configuration file change (previously detected changes but did not recompile)

Assembly System

  • New assembly block for defining reusable, parameterized groups of configuration blocks (devices, variables, scoring rules, event handlers, modes, audio)
  • New use block for instantiating assemblies with parameter arguments
  • Typed parameters (string, number, bool, list, map) with required/optional support and default values
  • Name prefixing: expanded blocks are automatically prefixed with the instance name (e.g., left_flipper__button)
  • Self-references via self.<block_name> for intra-assembly block references
  • Generator support: for_each and count meta-arguments on use blocks for creating multiple instances
  • Nested assemblies with circular nesting detection and max depth enforcement
  • Comprehensive validation: parameter types, required parameters, duplicate names, and cycle detection

Module System

  • New module block for bundling related game logic (mode, variables, scoring, stacking, audio, shows) into a single organizational unit
  • Mode modules with priority stack participation and infrastructure modules (always-on, no mode)
  • Stacking contracts via stacking sub-block to declare module coexistence rules (allow_multiple, conflicts_with)
  • Config-time composition via compose sub-block with include and override support
  • Module namespace in expression evaluator: module.<name>.active, module.<name>.<variable>
  • Composition expansion with dependency graph construction, cycle detection, and topological sorting
  • Backward compatible with existing bare mode blocks

Replay Analyze Command

  • New cade replay analyze subcommand for comprehensive session analysis of exported replay JSON files
  • Session summary: config name, total duration, player count, total score, balls played
  • Per-ball breakdown: score delta, event count, duration, drain type, events/sec
  • Device scoring aggregation with top-10 device ranking
  • Timing gap detection for gaps >2 seconds between events
  • Anomaly detection with severity levels (info/warning/error): zero-score balls, instant drains, missing events, total inconsistencies
  • Supports --format text|json, --ball N filtering, and stdin input

Replay Tree Command

  • New cade replay tree <file.json> subcommand prints the event hierarchy from a replay export to stdout, mirroring the TUI event viewer’s ball/player tree
  • Supports --ball N to show only events from a specific ball
  • Accepts - as the file argument to read from stdin
  • Intended for headless regression testing: ball labeling and event grouping bugs are visible in CI without running the full TUI

Activity Watchdog

  • Detects physically stuck balls by monitoring hardware event inactivity
  • Configurable inactivity timeout
  • Active only during ball-in-play phase; notifies when no hardware events arrive within the timeout
  • Purely observational — does not take corrective action automatically

Config Hash Validation for Scenarios

  • Replay-generated scenarios now embed config_hash and config_name fields from session exports
  • Scenario runner validates that the correct table configuration is loaded before execution
  • Backward-compatible: legacy scenarios without a hash are skipped gracefully
  • Clear mismatch errors with -d flag hint for diagnostics

Platform Conformance Tests

  • Conformance test suites for all six device types (switch, coil, light, flipper, autofire, servo)
  • Conformance tests for optional platform capabilities (readiness, manifest, config update, web integration)
  • Suites run automatically for any driver that supports the capability; skipped otherwise

Light System

  • Color support for hex (#FF0000), shorthand hex (#F00), and named CSS colors with linear interpolation
  • Light set and flash commands controlling state, intensity (0.0–1.0), color, and fade duration
  • Tick-based light controller running at 30 Hz with batched platform emission
  • Fade transitions with linear and incandescent (thermal-model exponential) curves
  • Pattern-based blink/flash system with configurable on/off sequences and duration
  • Named, ordered light groups — supports explicit group definitions and implicit tag-based group creation from light metadata
  • Batch operations for setting and flashing named groups
  • Timeline-based shows for declarative light animations
  • Priority-based concurrent show playback — higher-priority shows override lower ones
  • Built-in patterns: wave, chase, all-on
  • Show compilation to FAST EXP firmware script strings (LED selection, RGB colors, fade, wait, loop — max 128 chars) for firmware-assisted autonomous show execution
  • Light condition bindings in boolean (on/off) and intensity (float 0.0–1.0 for GI dimming) modes, with on_color, on_intensity, and fade_ms options
  • set_light and flash_light action blocks in event handlers

Stacking Contract Validation

  • Static validation at config load time: no self-conflict, no self-requirement, require-replace overlap, conflict-replace overlap, unknown reference detection
  • Symmetric conflict graph: if A conflicts with B, B automatically conflicts with A
  • Circular dependency detection via DFS on the requires graph
  • OnMax policy validation (reject, queue, replace_oldest)

Event Tree Viewer

  • Overflow nodes (... N earlier events) are now expandable — press Enter on an overflow node to reveal the next 16 hidden events within that span; press repeatedly to page through the full history
  • New sibling-lock navigation mode (s to activate): constrains j/k to siblings at the same tree depth, with g/G jumping to first/last sibling and Esc to unlock; lock is automatically released when the locked parent is evicted or becomes invisible
  • Entry cap (default 10,000) with automatic eviction of the oldest completed game span when the cap is reached — at least two game spans must exist before eviction occurs, so the active game is never removed
  • Collapsed-span rebuild fast path: incoming events for collapsed, non-structural spans skip the full tree rebuild and only increment the span event counter, reducing CPU usage at high event rates (50–100 Hz)

TUI Layout

  • Dynamic registration of UI extension points (tabs, sidebar sections, overlays)
  • Tabbed sidebar layout splitting the screen into a primary tabbed area (~75%) with a collapsible persistent sidebar (~25%)
  • Sidebar auto-collapses below 100 terminal columns; sections stack vertically with proportional height allocation respecting minimum height constraints
  • Tab bar with active tab styling and F-key switching (F1–F12)
  • Context-sensitive help bar with key hints that update per active tab
  • Score and Watch panels adapted for borderless sidebar rendering — bold labels and spacing replace box borders for narrow (~20–25 column) layouts
  • Header simplified to single-line borderless format; breadcrumb deprecated in favor of the tab bar

TUI Header Redesign

  • Two-mode header toggled with F4: compact (2-line, default) shows table name, engine status, platform health, and version; regular (4-line) adds config path, platform address, and connection status on separate lines
  • Sidebar and Metrics/Profiler overlays removed from the layout for simplification
  • Logs panel is now the default tab shown on startup (F1=Logs, F2=Events, F3=Console)
  • Fixed key handling so q and Enter pass through correctly on the Console tab
  • Replaced legacy “OPF” branding with “Cade” throughout the TUI

Game Status Bar

  • New collapsible status bar widget between the header and tab bar, toggled with F5
  • Game state section: displays game phase (● Ball In Play, ○ Starting, ■ Game Over), current player, comma-formatted score, and ball count (e.g., Ball 1/3)
  • Event sparklines: ring buffer tracking 60 seconds of event frequency, rendered as an inline activity chart
  • Extensible StatusSection interface — new rows can be registered without modifying the container
  • Horizontal separator line beneath the status bar when expanded; disappears alongside the bar when toggled off
  • Responsively adapts displayed fields to available terminal width

Event Compression and Viewer Improvements

  • Hit/unhit switch event pairs in the same span are merged into a single tree entry showing held duration (e.g., left_inlane held 234ms); cross-span pairs are not merged
  • Flipper press/release sequences (up to 4 events across left_flipper/staged_left_flipper or right_flipper/staged_right_flipper) compress to a single entry, eliminating per-flip noise
  • Game start and end events are merged into a single Game span in the event tree, replacing the previous Game Started/Game Ended sibling spans; post-game events (e.g., credit switches) now correctly appear at root level
  • Background modes (always-active infrastructure modes) are filtered from the active modes section in ball spans; the active modes section only appears when a feature mode activates mid-ball
  • Mode tracking sections (Mode, Scoring, Multiball) are now correctly visible under ball spans regardless of synthetic Player spans at depth 1
  • New p toggle in both event views expands or collapses squashed event pairs inline without modifying the underlying event data

Event Handler Mode Actions

  • start_mode and end_mode action block attributes now activate and deactivate modes directly from event handlers
  • Previously, start_mode = "multiball" in an action block was silently ignored (fell into the unrecognised-field map); it is now wired through the executor to the mode manager
  • end_mode works symmetrically, stopping a running mode by name

Validate Command Path Scoping

  • cade validate now respects a path argument or -d / --dir flag when determining which .cade files to validate, preventing unrelated files from being scanned
  • New -r / --recursive flag (default false) opts in to subdirectory traversal when a directory target is given
  • Scoping rules: cade validate <file> validates that single file; cade validate <dir> validates files in that directory non-recursively (add -r to recurse); cade validate with no argument preserves existing behaviour (recursive scan from the current working directory)

Other Recent Additions

  • HCL parser support for assembly and use block schemas
  • HCL parser support for module blocks and all sub-block types (mode, stacking, compose, variable, constant, score, event_handler, audio, shows)
  • Fuzz testing tasks: fuzz:resilience and fuzz:all meta-task

Debug Console - Pipe Mode Support

  • Automatic detection of piped input for non-interactive operation
  • Enables scripted debugging and automation workflows
  • Commands can be piped from files, scripts, or other programs
  • Supports command chaining with Unix pipes
  • Clean exit codes for script integration (0=success, non-zero=error)
  • --no-tui flag to force pipe mode even with terminal input
  • Examples:
    • echo "eval 2+2" | cade console - Simple evaluation
    • cat commands.txt | cade console - Run commands from file
    • cade console < script.debug - File redirection
    • echo "inspect score" | cade console | grep "Value:" - Pipeline integration

Multiball Lifecycle Management

  • Ejecting a ball now correctly increments balls-in-play so multiball drains take the intermediate branch instead of prematurely ending the ball
  • system.ball.end is now emitted on all terminal drain branches (extra-ball, last-ball/game-over, last-ball/rotation) with {player, ball, final_score, drain_device, timestamp} payload
  • system.ball.end is not emitted on intermediate multiball drains (when balls_in_play > 1), so modes with stop_events = ["system.ball.end"] stay active through the multiball sequence
  • Strict event ordering within each drain: system.ball.drainsystem.play.end (if present) → system.ball.end → rotation/endGame
  • End-to-end multiball lifecycle scenario at docs/examples/scenarios/multiball_lifecycle.hcl verifies mode start/stop, phase preservation, and ball count correctness

Module Active Condition in Scoring Engine

  • module.self.active and module.<name>.active conditions now evaluate correctly inside scoring rules — previously these silently returned false
  • Correct evaluation is preserved across hot-reload recompiles

Mode Event Bridge in Console

  • Modes with stop_events now automatically end when those events fire during a cade console session
  • Previously, modes started via start_mode action blocks in the console never stopped on their declared stop events

Other Recent Additions

  • Autoscroll toggle to events view with a keybind
  • Autoscroll toggle to logs view with a keybind
  • Support for signal, condition, and audio in event_handler blocks
  • Support for nested action block types in game parser
  • Autofire rule support to gRPC bridge
  • Autofire rule definitions to example table
  • Standup target toggle light handlers for example table
  • Drain auto-serve and game start handlers for example table

Changed

TUI Mode Lifecycle Display

  • Mode started/ended lifecycle entries in the event tree are squashed into single-line pairs — a completed mode shows as one line with duration, score delta, and event count rather than separate “started” and “ended” nodes
  • Stats map keyed by name + instance instead of name alone, preventing duplicate stats entries when the same mode completes multiple times in a session
  • Separate history and lifecycle sections in ball spans unified into a single mode lifecycle section; each entry is enriched with duration, score delta, and event/cascade counts from CompletedModeData

TUI Console Cleanup

  • Removed unused panel factory, middleware, and plugin-loading code
  • Console, Events, and Logs are now exclusively top-level tabs rendered by the chrome tab bar
  • Simplified navigation targets — enable/disable machinery removed; target definitions are kept for breadcrumbs
  • Deprecated breadcrumb widget in favor of the tab bar

VPX Driver

  • Reduced manifest warning noise: 60+ per-device WARN log lines for unconfigured devices replaced with a single summary line (e.g., 49 unconfigured VPX devices: 12 lights, 8 flippers, 20 inputs, 9 other); per-device detail remains at DEBUG level; genuine warnings (category mismatches, configured devices absent from manifest) are preserved

Example Tables

  • All three example tables (full-example-table, full-example-table-with-modes, full-example-table-with-assemblies) updated to use kick_ball { device = "Kicker1" } in the gate_hit handler, replacing the prior eject_ball workaround that caused phantom ball creation
  • Kicker1 device declarations in all three examples now include settings { kick_angle = 190 kick_strength = 10 } for driver-agnostic kick physics

Other Changes

  • Debug console now automatically switches between TUI and pipe modes
  • Help text updated to document both interactive and pipe modes
  • Exit behavior improved for clean script termination

Fixed

  • Mode event handler conditions never evaluated: event handlers with a condition field now correctly gate execution, supporting mode.<name>.active patterns
  • Emitted events never reaching mode bus: events emitted from handler action blocks now reach mode-bridge subscribers, so stop_events = ["multiball.ended"] subscriptions fire as expected
  • TUI events panel state correctness: game, ball, player, and mode state in the tree now derive exclusively from session-manager events (system.game.*, system.ball.*, system.player.*) rather than mixing driver and VPX signals. This fixes four related bugs:
    • Mode lifecycle events (mode.*.started / mode.*.ended) were not reaching the cascade pipeline — they now publish as cascade entries
    • Active-modes sections in ball spans tracked the current ball rather than the ball during which the mode started — mode sections are now pinned to their origin ball
    • Premature ball-close on multiball drains — intermediate multiball drains no longer close the ball span
    • Stray VPX ball_start hardware events were incrementing ball counters — hardware/driver events no longer drive phase transitions
  • Console flipper integration stability:
    • Autofire rules sent FAST hardware addresses instead of the configured switch/coil identifier; rules now carry their logical name, and a redundant gRPC round-trip was removed since VPX handles flipper coils locally
    • Event flood amplification under rapid flipper input: cascade buffer expanded from 100 to 1024 entries, evicted IDs return nil instead of stale data, cascade counting de-duplicated, high-frequency gRPC event log lines downgraded from Info to Debug
    • Logs panel O(N²) re-render eliminated with an insertion-time formatted cache; a retention cap prevents unbounded memory growth
  • Multiball never stopping: system.ball.end was never emitted by the session manager, making stop_events = ["system.ball.end"] a dead reference — multiball modes ran indefinitely across ball boundaries
  • Multiball ball count: eject_ball sent the gRPC command but never called AddBallToPlay() on the session manager, so ballsInPlay stayed at 1 and the first drain rotated players instead of staying in multiball
  • TUI multiball rendering: multiple “Multiball Started” entries rendered across forced ball spans because the mode never ended within its own ball — transitive fix from the above two corrections
  • Drain detection false positive: isDrainEvent previously matched "unhit" events because of a strings.Contains check for "hit"; changed to exact/suffix matching so only genuine drain hit events trigger ball turn advancement
  • History/lifecycle node position: History and lifecycle nodes now render visually inside their Ball N Started span rather than at the bottom of the entire tree after all game-ended events
  • Console properly handles EOF in piped input
  • Exit codes now correctly propagate to calling scripts
  • Store per-entry ball start time to prevent timestamp collapse on rebuildRows
  • Exclude .git/ from release:local rsync
  • Use build:cross for Windows in release:local task
  • Hot-reload: fixed four bugs — session not enabling hot-reload on the engine, file watcher path comparison mismatch, assembly registry stale state on second reload, and a lock-ordering deadlock during reload
  • Startup race: engine setup in cade run now runs synchronously before the shutdown loop, eliminating a data race on shared variables during startup
  • gRPC drain handler: removed stale hardcoded lowercase "drain" key leftover from before device rename; drain device name now comes from config, and DefaultDrainDeviceKeys contains only the uppercase fallback
  • Scenario fallback: only the absence of .cade files now triggers the bare-engine fallback; parse errors, validation failures, and other build errors propagate to the caller instead of being silently swallowed
  • Full-example-table: device identifiers aligned with exact VPX gRPC manifest element names; removed devices, scoring rules, and assemblies that have no representation in the manifest
  • Gate→Kicker flow in example tables: all three example tables originally used pulse_coil "Kicker1" (no-op) then briefly eject_ball { device="Kicker1" } (phantom ball creation); now corrected to kick_ball { device = "Kicker1" } with kicker physics on the device declaration’s settings { kick_angle = 190 kick_strength = 10 } block — properly fires the existing held ball without creating a new one or incrementing ball-in-play count