Changelog
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
TUI Logs Tab Enhancements
- Text filter (
/) in the Logs tab — hides non-matching lines case-insensitively across message, source, and structured fields.Entercommits,Escclears and exits - Search (
Ctrl+F) highlights matches in place without hiding lines;n/Nnavigate 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 showsPAUSED (N buffered)while paused; pressingpagain drains the backlog in order. Pause is independent of autoscroll - Source column populated from
sloggroups —logger.WithGroup("audio").WithGroup("mixer")now renders the source asaudio.mixerin the TUI and in copied output. Explicitslog.With("source", ...)attrs still take precedence over the group path - Cleaner clipboard output — the
Alt+Ccopy 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
Gameroot; expand withlorSpaceto 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 toEnter, and the hint bar showsenter: show morewhen 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 newenabledfield that declaratively disables a device without removing its declaration - New switch subtypes
dropandstandup, distinct from the existing generictargetsubtype:switch "drop"— falling targets with areset { coil, pulse_ms, debounce_ms }sub-block and an implicitup → hit → resetting → upstate machineswitch "standup"— upright hit-only targets (no reset semantics), commonly banked under a shared tag
- New
positions {}block onmotor "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. Supportswhen,action,guard, andcooldown_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_behaviordispatch through the platform driver, drop-target state machines emithit/state_changed/restedas reset pulses complete and debounce elapses, and symbolic servo positions are resolved from the device’spositions {}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 abehavior {}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
synthblock for declaring oscillator-based sound effects in.cadeconfigs — 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:
on_eventblocks can target synth patches withsynth { name = "..." pitch = ... velocity = ... }— voices are automatically registered with the mixer and returned to the pool when the envelope completes - 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
AudioSourceinterface as clip-based audio, feeding directly into the existing mixer pipeline with no backend changes - Named envelopes:
envelopeblocks 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 viamodulateblocks. Newdepthattribute specifies modulation range in Hz. Unlabeledenvelope { }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
Runtime Expression Bindings
- Synth parameter bindings (
pitch,velocity, and other numeric attributes onsynth { }triggers) now accept full expressions in addition to literal numbers andfrom:event.<field>lookups - Expression namespace available in bindings:
event.<key>(event payload),score(current player score),var.<name>(variable registry),signal.<name>(1.0if signal active,0.0otherwise) - Example — combo-reactive pitch:
pitch = "440 + var.combo_count * 55" - Example — multiball-reactive velocity:
velocity = "signal.multiball_active ? 1.0 : 0.6" - Example — score-reactive brightness:
pitch = "220 + min(score / 10000, 2000)" - 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 — invalid expressions, unknown variables, and unknown signals are reported as config errors, not runtime failures
- Shared infrastructure designed for reuse: lighting and future runtime features will consume the same binding surface
See Runtime Bindings for the full namespace reference and Synthesis for synth-specific binding examples.
Exhaustive Automated Testing of Cade Config Language
- Scenario corpus parser now accepts all
.cade.testfiles without skipping — theknownParseFailuresskip list has been eliminated entirely initial_statevariables support nested objects and mixed-value-type maps (e.g.,variables = { player = { combo_count = 0, last_shot = "none" } })- Event
dataattributes support arbitrary key-value payloads (e.g.,data = { target = "left_orbit", multiplier = 2 }) - Mode
stateattributes ininitial_stateaccept nested object values for pre-setting mode-specific state - Chain transition
conditionblocks now parse correctly, enabling conditional transition tests withsuccess,min_events, andmax_eventsfields - Self-enforcing corpus test: adding a new
.cade.testfile todocs/examples/that fails to parse will break CI immediately — there is no mechanism to skip it
Scenario Testing Infrastructure
- New
.cade.testfile 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 runnow accepts directories with--recursiveto batch-run all.cade.testfiles; supports--format jsonfor CI integration- Four-layer CI test coverage for the config language:
- Corpus validation — every
.cadefile indocs/examples/is automatically parsed and validated on every PR, under both default andstrictpragma modes - Scenario execution — every
.cade.testfile indocs/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
.cadegeneration viatask fuzzstresses the parse-validate-execute pipeline for panics and unbounded memory; runs nightly in CI
- Corpus validation — every
- 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
sessionscope for variables that reset at game boundaries but are shared across all players within a game — fills the gap betweenglobal(persists forever) andplayer(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, andsessionscoped 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>.changedevents whenever a variable’s value changes, regardless of the mutation source - Event payload includes
name,old,new,scope, andreasonfields - 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 = "l6_lit == true"
actions {
set_light "light" { device = "l6"; state = "on" }
}
}
Player-Switch Variable Re-Sync
- When the active player changes,
variable.<name>.changedevents 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 fuzztarget 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 goroutine 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
Changed
Rollover Light Examples Updated to Player Scope
- All three full-example-table variants now declare rollover light variables with
scope = "player"instead ofscope = "global", so each player maintains independent rollover progress during multiplayer games - Added sync handlers listening to
variable.<name>.changedevents 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 asdevice "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_triggercoexisting with clip-basedplay_soundactions in the same event handler 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_poppatch 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_thudpatch 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 > 0on a one-shot voice (bumpers never issue a note-off), so the filter envelope would have held indefinitely. The retuned patch usessustain = 0so 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 audioDEBUG 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
TriggerEventnow 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
Variable-Driven Outputs Not Clearing at Game End or Engine Stop
- Rollover lights and other outputs driven by
variable.<name>.changedsync handlers would stay lit after the last ball of a game, and again after the engine was stopped — the reset chain described in Variable-Driven State Synchronization was not wired into the running game-session manager, sovariable.*.changedevents withreason = "reset"never reached handlers in a real game - Game end now drives the same reset path that scenario tests already exercised:
system.game.endfires first, thenplayer,ball, andsessionscoped variables reset and emitvariable.*.changedwithreason = "reset", and pairedsync_offhandlers 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_lightcalls 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
updateblock 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 setcounterto10instead of1 - 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>.activein handler or scoring conditions but declared nomodule {}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>.activeevaluates tofalse— 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/Dispatchpaths 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 canonicalvariable "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.cadefile
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 (useblock)tags, and device-leveltagswithin 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_controltag 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 fromgame_mode/modeblocks to the canonicalmodule "name" { mode { } }form - Explicit
condition = "module.self.active"/game_activeconditions removed from module-scoped scoring modifiers in design examples — the loader auto-injectsmodule.<name>.activeguards, making manual conditions redundant - Example files now demonstrate
active_on_startup = trueandstop_eventsmode attributes within the module wrapper
Event Aggregation System
- New
aggregationHCL block type with three primitive variants for composite event detection:- Burst detection (
aggregation "burst"): per-device rapid-fire counting within a tumbling window, emitscount+rate_hzon threshold - Cross-device coincidence (
aggregation "coincidence"): multi-group activation within a shared window, emitsdevice_count - Rate-aware scoring (
aggregation "rate"): EWMA-smoothed event density withrate_tiersblock containingtiersub-blocks (min_rate,label,multiplier); 10% hysteresis on downgrades;tier.labelandtier.multiplieravailable in trigger data
- Burst detection (
- Each rule accepts
devicesordevice_groups,window,cooldown, and a trigger/tier-change emit target; aggregation signals feed existingscore "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 withSpaceto 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.805sinstead of+805msfor 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 = trueattribute onmode {}blocks; when set, the mode is automatically started after the first ball launches — no explicitstart_modeaction 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.joinedwith"mid_game": true - Player IDs generated sequentially as plain integers (
"1","2","3") - Removed
--playersCLI 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" { }, baremode "x" { }, andgame_modes { }container blocks are no longer valid; any of these now returns a parse error directing authors tomodule "x" { mode { } } - New
device_control { }block inside module and mode contexts for declarative enable/suppress rules; uses a unifiedtarget "<type>" { select { tags = [...] } apply { enabled/suppress } }pattern with an optionalconditionexpression - 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
tagspropagate additively to all devices expanded from auseblock, sodevice_controltag selectors work uniformly across all instances - Module loader auto-injects
module.<name>.activeas 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 (nomodeblock) are not affected - Documentation examples updated:
game_modeblocks converted tomodule { mode { } }; explicitcondition = "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/applypattern for addressing devices, modules, or scoring rules by tag glob rather than by name — usable both imperatively inside event handler actions and declaratively insidedevice_controlblocks device_controlblock: declarative device/scoring inhibition that auto-applies on mode activation (mode-scoped) or reacts to variable changes (module-level withcondition); owner-tracked so multiple concurrent inhibitors don’t accidentally re-enable each other on partial releasesuppressed_byfield 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
tagsfield: tags declared on an assembly definition propagate additively to all device blocks expanded from that assembly; ensures consistent tag coverage fordevice_controltargeting across all instances of hardware assemblies (e.g., every flipper fromuse "flipper"carries["player_controlled", "flipper"]automatically) - Updated
infrastructure-module.cadetilt example to use declarativedevice_control(condition-based onvar.tilted) replacing bareemit "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 doesCreateBall() + Kick()for trough/launcher flows):kick_ballwrapsKicker::Kick()only, with no ball creation — resolving the gate-to-kicker phantom-ball bug whereeject_ballspawned a new ball on every gate hit - The
kick_ballaction carries only adeviceattribute; 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 flippersettings { strength, hold_time }pattern — values that any driver can interpret - The VPX bridge wraps
Kicker::Kick()withoutCreateBall(); 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_contexttop-level HCL block for declaring named noise generation contexts in.cadefiles - 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) withdecay_toas the floor value - Interval decay:
auto_decay = truewithdecay_interval(tick period),decay_amount(per-tick subtraction), anddecay_condition(optional expression guard that pauses decay when false)
- Continuous decay:
- Variables now support two growth models:
- Continuous growth:
growth_rate(amount added per second) withgrowth_toas the ceiling value - Interval growth:
auto_grow = truewithgrow_interval(tick period) andgrow_amount(per-tick addition)
- Continuous growth:
- 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_showsandstop_showsfields inevent_handleraction 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_signalreplacestriggerin action blocks:fire_signal = "mystery_award"imperatively activates a named signal; the oldtrigger =keyword is no longer acceptedwhenreplacestriggerin score blocks:when = "shot.orbit.complete"declares which event activates a scoring rule, matching theevent_handlerconvention; the oldtrigger =keyword is no longer accepted in score blocksfire_signalroutes 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 = Nin 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 thedata = {}block contents through to subscribers (previously the payload was dropped)- No configuration schema changes —
points,emit, anddatafields already existed; they now produce runtime effects where previously they were silent no-ops
Mode Control from Event Handler Action Blocks
- New
start_modeandend_modescalar fields on action blocks inevent_handlerconfiguration 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 firesend_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_windowfield onsignalblocks (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 existingstring.length,string.concat,string.from_int, andstring.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 existinglist.length,list.append, andlist.random string.splitreturns aListValueofStringValueelements;string.joinandlist.joinaccept a list of strings and a separatorstring.matchesuses Go’sregexppackage for full regular expression supportlist.shuffleuses 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-wordsflag oncade validateto opt in to identifier conflict detection (enabled automatically when--strictis set) - Reserved keyword validation stage wired into the integration validation pipeline as a post-
validateVariablesstep; issues appear intext,json, andyamloutput with category and suggested alternatives - Tiered severity controlled by
--pragma-mode:stricttreats all reserved names as errors;normal(default) allows contextual and future keywords as warnings;relaxedonly warns on strict keywords - Suggestion engine generates context-aware alternative names (e.g., block names prefixed with
my_,game_, orcustom_; variable names suffixed with_val,_count, or_total) - New
cade migrate --reserved-wordstop-level command for scanning.cadefiles and reporting reserved word conflicts with file, line, column, category, and suggestions cade migrate --reserved-words --auto-fixrewrites conflicting identifiers using the first suggestion, creating a.cade.bakbackup of each modified filecade migrate --reserved-wordssupports-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 * 2→2000,(2 + 3) * 4→20); division by zero defers to runtime - Dead code elimination: ternary branches with compile-time-known conditions are pruned (e.g.,
true ? 1000 : 500→1000) - Operation fusion: recognizes
variable ± constantandvariable × variablepatterns and emits specialized fast-path operations
- Constant folding: binary and unary expressions with all-literal operands are evaluated at compile time (e.g.,
- 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
.cadeconfiguration 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, andcade scenariocommands - Clean shutdown sequencing with optional context deadline
- Bug fix: the root
cadecommand now correctly recompiles scoring on configuration file change (previously detected changes but did not recompile)
Assembly System
- New
assemblyblock for defining reusable, parameterized groups of configuration blocks (devices, variables, scoring rules, event handlers, modes, audio) - New
useblock 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_eachandcountmeta-arguments onuseblocks 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
moduleblock 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
stackingsub-block to declare module coexistence rules (allow_multiple,conflicts_with) - Config-time composition via
composesub-block withincludeand 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
modeblocks
Replay Analyze Command
- New
cade replay analyzesubcommand 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 Nfiltering, 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 Nto 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_hashandconfig_namefields 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
-dflag 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, andfade_msoptions set_lightandflash_lightaction 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
requiresgraph OnMaxpolicy validation (reject,queue,replace_oldest)
Event Tree Viewer
- Overflow nodes (
... N earlier events) are now expandable — pressEnteron 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 (
sto activate): constrainsj/kto siblings at the same tree depth, withg/Gjumping to first/last sibling andEscto 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
qandEnterpass 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
StatusSectioninterface — 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_flipperorright_flipper/staged_right_flipper) compress to a single entry, eliminating per-flip noise - Game start and end events are merged into a single
Gamespan in the event tree, replacing the previousGame Started/Game Endedsibling 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
ptoggle in both event views expands or collapses squashed event pairs inline without modifying the underlying event data
Event Handler Mode Actions
start_modeandend_modeaction 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_modeworks symmetrically, stopping a running mode by name
Validate Command Path Scoping
cade validatenow respects a path argument or-d / --dirflag when determining which.cadefiles to validate, preventing unrelated files from being scanned- New
-r / --recursiveflag (defaultfalse) 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-rto recurse);cade validatewith no argument preserves existing behaviour (recursive scan from the current working directory)
Other Recent Additions
- HCL parser support for
assemblyanduseblock schemas - HCL parser support for
moduleblocks and all sub-block types (mode, stacking, compose, variable, constant, score, event_handler, audio, shows) - Fuzz testing tasks:
fuzz:resilienceandfuzz:allmeta-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-tuiflag to force pipe mode even with terminal input- Examples:
echo "eval 2+2" | cade console- Simple evaluationcat commands.txt | cade console- Run commands from filecade console < script.debug- File redirectionecho "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.endis now emitted on all terminal drain branches (extra-ball, last-ball/game-over, last-ball/rotation) with{player, ball, final_score, drain_device, timestamp}payloadsystem.ball.endis not emitted on intermediate multiball drains (whenballs_in_play > 1), so modes withstop_events = ["system.ball.end"]stay active through the multiball sequence- Strict event ordering within each drain:
system.ball.drain→system.play.end(if present) →system.ball.end→ rotation/endGame - End-to-end multiball lifecycle scenario at
docs/examples/scenarios/multiball_lifecycle.hclverifies mode start/stop, phase preservation, and ball count correctness
Module Active Condition in Scoring Engine
module.self.activeandmodule.<name>.activeconditions now evaluate correctly inside scoring rules — previously these silently returnedfalse- Correct evaluation is preserved across hot-reload recompiles
Mode Event Bridge in Console
- Modes with
stop_eventsnow automatically end when those events fire during acade consolesession - Previously, modes started via
start_modeaction blocks in the console never stopped on their declared stop events
Other Recent Additions
- Autoscroll toggle to events view with
akeybind - Autoscroll toggle to logs view with
akeybind - Support for
signal,condition, andaudioinevent_handlerblocks - 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 + instanceinstead of name alone, preventing duplicate stats entries when the same mode completes multiple times in a session - Separate
historyandlifecyclesections in ball spans unified into a singlemode lifecyclesection; each entry is enriched with duration, score delta, and event/cascade counts fromCompletedModeData
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
WARNlog 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 atDEBUGlevel; 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 usekick_ball { device = "Kicker1" }in thegate_hithandler, replacing the prioreject_ballworkaround 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
conditionfield now correctly gate execution, supportingmode.<name>.activepatterns - 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_starthardware events were incrementing ball counters — hardware/driver events no longer drive phase transitions
- Mode lifecycle events (
- 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
InfotoDebug - 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.endwas never emitted by the session manager, makingstop_events = ["system.ball.end"]a dead reference — multiball modes ran indefinitely across ball boundaries - Multiball ball count:
eject_ballsent the gRPC command but never calledAddBallToPlay()on the session manager, soballsInPlaystayed 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:
isDrainEventpreviously matched"unhit"events because of astrings.Containscheck 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 Startedspan 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/fromrelease:localrsync - Use
build:crossfor Windows inrelease:localtask - 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 runnow 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, andDefaultDrainDeviceKeyscontains only the uppercase fallback - Scenario fallback: only the absence of
.cadefiles 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 brieflyeject_ball { device="Kicker1" }(phantom ball creation); now corrected tokick_ball { device = "Kicker1" }with kicker physics on the device declaration’ssettings { kick_angle = 190 kick_strength = 10 }block — properly fires the existing held ball without creating a new one or incrementing ball-in-play count