Runtime
The runtime is what an agent drives: a scene under a manual clock, with a seeded random generator, snapshots,
a state hash, a component registry with JSON Schemas, an event queue and the studio rules (mute, data root).
Nothing needs a window, a GPU or the wall clock. Design background: docs/AGENT_FIRST.md sections 3.1, 3.4,
3.5, 3.8 and 3.9. Code: crates/sparklight-engine/src/runtime.rs, registry.rs, snapshot.rs, schemas.rs;
foundations in crates/sparklight-core (error, events, rng, hash, clock, studio).
Quick start
Section titled “Quick start”use sparklight_engine::{Runtime, RuntimeConfig};
let mut rt = Runtime::from_scene_file("samples/platformer/scenes/main.scene.json".as_ref(), RuntimeConfig::headless(42))?;rt.load_assets("samples/platformer/assets".as_ref()); // clips, sounds (muted: no device), scripts resolve lazilyrt.set_action("move_x", 1.0); // input as scripts see itrt.step(600); // exactly 600 fixed steps of 1/60 slet x = rt.get("/Hero/Transform2D/position/0")?; // read by JSON pathrt.set("/Hero/Transform2D/position/0", 3.5.into())?; // write by JSON path (validated, atomic)let snap = rt.snapshot()?; // save everything that evolvesrt.step(100);rt.restore(&snap)?; // go back, bit for bitassert_eq!(rt.hash(), snap.hash());From a shell, cargo run -q -p sparklight-engine --example headless_run -- <scene> <assets> [seed] [frames] prints
{"seed", "frames", "hash"} as JSON. The same arguments print the same hash in every process.
| Call | Effect |
|---|---|
step(n) |
exactly n fixed steps (fixed_dt = 1/60 s by default); try_step(n) returns the error instead of reporting it as an event |
advance(seconds) |
as many whole steps as fit; the remainder is carried to the next call. Negative and non-finite values do nothing (try_advance returns E_RUNTIME_ARG) |
step_with(n, hook) |
like step, calling hook(&mut Scene) before every step (per-step input, camera) |
advance_realtime(wall_dt, hook) |
only for realtime = true runtimes (the windowed game); clamps a frame to 0.25 s. A deterministic runtime returns E_RUNTIME_NOT_REALTIME |
reset() |
back to the state at load time: clock 0, RNG reseeded, events and named snapshots cleared |
reset_with_seed(seed) |
reset() under another seed: the runtime RNG, the math.random of every script and the particle emitters are seeded from seed as if the scene had been loaded with it (equal hash), and later reset() calls keep it. No rebuild of the runtime |
frame(), elapsed() |
steps taken, simulated seconds (frame * dt, no accumulation error) |
Ceiling. One step / advance call may take at most RuntimeConfig::max_steps_per_call steps (default 1,000,000). A
larger request, such as advance(1e9), is refused: nothing runs, a try_* call returns E_RUNTIME_ARG, and the plain
call returns a report with 0 steps and queues an E_RUNTIME_ARG error event. A refused call changes nothing, not even the
carried remainder. rt.cancel_handle() returns an AtomicBool; setting it (from another thread or a step hook) stops
the running call after the current step. RuntimeConfig::validate() checks fixed_dt (finite, in (0, 1] s), the ceiling
and the event capacity; Runtime::try_with_scene refuses a bad config, Runtime::new / with_scene fall back to the
defaults and queue an E_RUNTIME_ARG event. Scene::update ignores a step that is not finite, not positive or longer
than one second (Scene::try_update returns the error). RuntimeConfig::event_level (default Debug) drops events
below it before they are built; Info skips the per-frame contact and animation events.
Script limits. RuntimeConfig::script_limits (sparklight_script::ScriptLimits: 2,000,000 instructions per callback, 16 MiB per
script state, 2 s wall-clock backstop by default) applies to every script of the runtime, scripts loaded later and scripts rebuilt by
restore included. Runtime::set_script_limits(limits) changes them at run time: instructions at least 1, memory from
MIN_SCRIPT_MEMORY (1 MiB) to MAX_SCRIPT_MEMORY (4 GiB), a wall time above zero, and no script may already hold more memory than the
new cap, otherwise E_RUNTIME_ARG and nothing changes. Limits are not part of snapshots or of the state hash. The control plane sets
them with run.reset {script_limits}.
Evaluating Lua. Scene::eval_script(entity, code) runs a snippet in the context of the entity’s script (self, the script’s
functions and globals, the sparklight API) under the same sandbox and limits as its callbacks and returns the value as JSON
(LuaScript::eval in sparklight-script). It loads a script that still waits behind its ScriptPath; E_COMPONENT_MISSING when
the entity has no script, then E_SCRIPT_LOAD (does not compile), E_SCRIPT_RUNTIME, E_SCRIPT_BUDGET, E_SCRIPT_MEMORY. Changes to
self and the world stay; global assignments of the snippet do not. The control plane exposes it as script.eval.
RuntimeConfig::headless(seed) is the agent configuration: deterministic, muted, no wall clock.
RuntimeConfig::windowed(&studio, seed) is what the game window uses (real frame times, audio unless muted).
The window, the editor viewport and any future shell are only ways to look at a runtime and to feed it input.
Determinism contract
Section titled “Determinism contract”A run is (scene, seed, inputs). The same triple gives the same hash() after the same number of frames.
Covered: entity iteration (slot order), component order (registration order), the physics step (rapier, same
binary), animation time, particle pools, math.random of every script (a per-script generator seeded from the
runtime seed and the entity id), particle emitters (seeded the same way), Runtime::rng_mut().
Verified by tests: two runs of the platformer sample with the same seed and inputs have equal hashes after 600
frames; so do the topdown and breakout samples, also across separate processes (headless_run).
Lua pairs() order over string keys is the same in every process: the repository’s .cargo/config.toml builds the
vendored Lua with a fixed string-hash seed (luai_makeseed), and a cross-process test (tests/determinism.rs) checks
it. (If your environment sets CFLAGS, that setting wins and the test fails; unset it.) Iterating arrays with ipairs
is still the clearest way to be order-independent.
The first script or sandbox state of a process runs a self-test of that seed (pairs order of a probe table). What a failed self-test
does depends on the runtime: a deterministic runtime (RuntimeConfig::headless, every headless run, scenarios, snapshot replay)
refuses to create scripts with E_SCRIPT_NONDETERMINISTIC (SeedPolicy::Deterministic); a real-time runtime (RuntimeConfig::windowed,
the game window) logs one warning on stderr and runs (SeedPolicy::Warn), because nothing replays it. Runtime::with_scene,
try_with_scene and from_scene_file set the policy from config.realtime (sparklight_engine::apply_seed_policy), the windowed runner
sets it before it reads its scene, and the policy is per process: the runtime made last wins. spark doctor reports the self-test as the
lua_seed check either way.
Physics is bit-identical on the same binary and machine. For results that replay on any CPU and operating system,
build with rapier’s enhanced determinism instead of SIMD: cargo test -p sparklight-engine --no-default-features --features deterministic (the default simd feature is faster and is what the windowed game uses; the two cannot be
combined). tests/determinism.rs holds a golden hash recorded with it.
Not covered (known, deliberate):
- Floating-point results can differ between CPU architectures and builds unless the
deterministicfeature is on. Compare hashes of the same binary on the same platform otherwise. Lua’smath.sinand friends call the platform’s libm, so scripts that use them are only reproducible on one platform. - Files read at run time (scripts, clips) must be identical between runs.
State hash
Section titled “State hash”hash() is a 64-bit FNV-1a digest over: clock (frame, carry), runtime RNG, scene seed, camera, input actions, every
live entity (id and every registered component as canonical JSON, floats by bit pattern), particle pools and the
physics bodies (pose, velocities, sleeping). hash_hex() is the same as 16 hex digits. Equal hash after equal
frames means equal runs; a different hash means the runs diverged (find the frame with a binary search over
snapshot/restore).
The digest of each entity is cached until the entity changes (World::entity_version), so hashing a scene in which
little changed is cheap. Components that read more than the entity’s own data (PhysicsBody, Parent) are
ComponentOps::volatile and are re-read on every call; a plugin component that reads outside state must be volatile too.
Scene files
Section titled “Scene files”A scene file is lossless for everything the registry marks persistent: every live entity becomes an object (an entity
with no name, no transform or no persistent component at all is saved too), fixed fields carry the engine’s components, and
components carries the other persistent components of the registry by name (Scene::to_json_with(®istry),
Scene::from_json_with). Gravity is saved; the random seed and the assets root belong to the runtime, not the scene.
Transient particle bursts are not saved. A property test saves and reloads random scenes and compares registry dumps.
Loading validates everything and answers E_SCENE_SCHEMA with the path of the bad value (/objects/3/body): non-finite or
zero scale, non-positive half, negative friction, a parent index out of range, a parent cycle, a shearing hierarchy;
an unknown extra component is E_COMPONENT_UNKNOWN. Scene, prefab and data files are written atomically (temporary file,
flush, rename), so a crash never leaves a half-written file. A script that fails to load keeps its ScriptPath and is
retried when the file changes on disk.
Snapshots
Section titled “Snapshots”snapshot() captures: the world (every component of every entity, entity ids and the free list, so entities created
later get the same ids), physics (bodies, colliders, joints, solver warm-start data, pending contacts), Lua scripts
(name, source, on_start flag, math.random state, the script’s own self fields), animator and particle state,
camera, input actions, pending output lists, clock and RNG. restore() puts all of it back and checks that
hash() equals the snapshot’s hash (E_INTERNAL otherwise). A snapshot can be restored any number of times.
snapshot_named / restore_named / snapshot_names / drop_snapshot keep named snapshots inside the runtime.
Not in a snapshot: assets (textures, sounds, animation clips), the audio device, assets_root, the event queue
(events are an output stream and are not rewound; a snapshot event marks the restore), the renderer.
Limits: a Snapshot is an in-memory object and has no file form. Files that bring a state back are built on top of the runtime:
the control plane’s snapshot.save / snapshot.load write and replay a recipe (seed, replayable command log, state hash) for the 2D
runtime and for the session’s 3D scene (docs/agent/commands.md, docs/agent/space3d.md), and sparklight_scenario::SnapshotFile stores a state dump for scenarios. A script that stores a function, userdata or a cyclic table in self makes
snapshot fail with E_SNAPSHOT_UNSUPPORTED and a path; so does a component type that was never registered as
clonable (World::register_clone).
Component registry
Section titled “Component registry”rt.registry (ComponentRegistry) lists every component: name, doc, access (read_write / read_only),
persistence, JSON Schema and a default value (describe()). entity_schema() is the JSON Schema of a whole entity.
| Component | Value | Notes |
|---|---|---|
Name |
string | how paths and scripts address the entity |
Transform2D |
{position:[x,y], rotation, scale:[x,y]} |
local to the parent; setting it moves the physics body and the bodies below it; finite numbers, no zero scale, no shear |
Parent |
unique entity name or handle (#7, #7.2) |
cycles and shearing links are rejected |
Sprite |
{size:[w,h], color:{r,g,b,a}} |
|
SpriteTexture |
{key, uv:[u0,v0,u1,v1]} |
|
BodySpec |
{dynamic, half:[hx,hy], restitution, lock_rotation?, friction?} |
setting a different one recreates the body at the entity’s world pose; the collider is half times the world scale; half positive, friction and restitution not negative |
PhysicsBody |
read-only: handle, position, angle, linvel, angvel, sleeping | |
TileLayer |
{map, palette} (tile map and palette files) |
setting it rebuilds the static bodies |
Animator |
{clip} writable; playing, frame, time observable |
|
ScriptPath |
assets-relative path | setting it loads and attaches the script (E_SCRIPT_LOAD on failure) |
Script |
read-only: {name, started, state} |
state is the script’s self fields |
Emitter |
emitter file (schemas/emitter.schema.json) |
|
TransientEmitter |
true |
marker: entity is deleted when its effect ends |
Internal bookkeeping (ScriptStamp, the hot-reload timestamp) is not exposed.
A path is a JSON Pointer (RFC 6901): /<entity>/<Component>/<field>/.... Escape / as ~1 and ~ as ~0.
Vectors are arrays, so position/0 is x.
| Call | Path | Does |
|---|---|---|
get |
"" |
list entities: [{id, name, components}] |
get |
/Hero |
all components of the entity |
get |
/Hero/Transform2D/position/0 |
one value |
set |
/Hero/Transform2D/position/0 |
change one field; the whole component is re-validated |
set |
/Hero/Sprite |
replace the component (adds it if missing) |
set |
/Hero with {component: value, ...} |
several components; all or none |
patch |
/Hero/BodySpec |
RFC 7396 merge patch; null deletes an optional field; a missing component starts from its default |
remove |
/Hero/Sprite |
remove a component (BodySpec also removes the body) |
remove |
/Hero/BodySpec/friction |
remove an optional field |
remove |
/Hero |
despawn the entity and its descendants |
spawn(name, {component: value}) |
new entity; removed again if any component is invalid |
Entity handles
Section titled “Entity handles”An entity is identified by a handle: a slot index plus a generation (sparklight_core::Entity). Its text form is
#<index> for generation 0 and #<index>.<generation> after the slot was reused; its opaque form is
Entity::to_bits() (generation << 32 | index). A handle never starts to mean another entity: once the entity is
removed the handle is stale and every lookup answers E_ENTITY_STALE (even if a new entity took the slot). #7
means exactly generation 0 of slot 7, never “whatever is in slot 7 now”. Scripts and commands pass handles as text
(Scene::resolve, resolve_entity) or as bits.
Names are a convenience, not an identity: a name path works only while exactly one entity has that name (several make
it E_ENTITY_AMBIGUOUS, none E_ENTITY_UNKNOWN). Inside a script, the script’s own object name always means the script’s
own entity, even if other objects share the name. Scripts hold objects as opaque handles (sparklight.find(name), self);
a handle to a removed object is an error (“stale object handle”), never the object that reused the slot. find answers
only for a unique name; a handle text such as #7.2 is accepted in place of a name. on_contact(self, other, ...) receives
the other object’s name, also when that object was removed in the same step.
A write that fails changes nothing: every value of a multi-component set or spawn is checked before the first is
applied (a bad ScriptPath cannot restart the body of the same call), and invalid data is refused with a coded error:
non-finite numbers, zero scale, non-positive half, negative friction or restitution, and a non-uniform scale above a
rotated object (it would shear; E_COMPONENT_VALUE).
Hierarchy and bodies
Section titled “Hierarchy and bodies”Transform2D is local to the Parent. A physics body always lives in world space: its pose is the entity’s world
transform and its collider is BodySpec.half times the world scale. Setting a transform or a parent re-poses the body
and the bodies below it; after each step the dynamic bodies’ poses flow back into local position and rotation
(physics never rewrites scale), and non-dynamic child bodies follow a parent that physics moved. When a body or its
entity is removed, the contacts it was in end with a normal started: false contact event on the next step.
Errors
Section titled “Errors”Every agent-facing API returns SparkError: {code, message, path?, hint?} (SparkError::to_value()).
code is stable; match on it, not on the message.
| Code | Meaning | Hint |
|---|---|---|
E_COMPONENT_UNKNOWN |
No component with this name is registered. | List component names with the registry (describe) and use one of them verbatim. |
E_COMPONENT_VALUE |
The value does not match the component schema. | Read the component schema and send a value that validates against it. |
E_COMPONENT_READONLY |
The component cannot be written through the generic API. | Use the dedicated command for this component instead. |
E_COMPONENT_MISSING |
The entity does not have this component. | Set the whole component first, or check the entity with a get on its path. |
E_COMPONENT_DUPLICATE |
A component with this name is already registered. | Choose a unique component name. |
E_ENTITY_UNKNOWN |
No live entity matches this name or id. | List entities first; names are case sensitive, ids look like #7. |
E_ENTITY_STALE |
The entity handle refers to an entity that was removed; its slot may hold another entity now. | Handles carry a generation (#7.2); list entities again and use a current handle. |
E_ENTITY_AMBIGUOUS |
Several entities share this name. | Address the entity by handle (#<index>.<generation>) instead of by name. |
E_PATH_INVALID |
The path is not a valid JSON Pointer. | Paths look like /Hero/Transform2D/position/x; escape / as ~1 and ~ as ~0. |
E_PATH_NOT_FOUND |
Nothing exists at this path. | Get the parent path to see which keys exist. |
E_SCENE_SCHEMA |
The scene data does not match the scene format. | Validate the file against schemas/scene.schema.json. |
E_SCENE_VERSION |
The scene format version is not supported. | Migrate the file to the current format version. |
E_SCENE_IO |
The scene file could not be read or written. | Check the path and the file permissions. |
E_ASSET_IO |
An asset file could not be read. | Check that the file exists under the assets root. |
E_SNAPSHOT_UNKNOWN |
No snapshot with this name exists. | List the stored snapshots and use one of their names. |
E_SNAPSHOT_UNSUPPORTED |
The world holds state that cannot be snapshotted. | Register the component type as clonable, or remove it before taking the snapshot. |
E_RUNTIME_ARG |
An argument of a runtime call is out of range. | Check the documented range of the argument. |
E_RUNTIME_NOT_REALTIME |
Wall-clock stepping was requested on a deterministic runtime. | Use step(frames) or advance(seconds), or create the runtime with realtime = true. |
E_SCRIPT_LOAD |
A script could not be loaded or compiled. | Open the script file and fix the reported line. |
E_SCRIPT_RUNTIME |
A script callback raised an error. | Fix the script at the reported line; the object keeps running. |
E_SCRIPT_BUDGET |
A script callback exceeded its instruction budget. | Remove unbounded loops from the callback. |
E_SCRIPT_MEMORY |
A script exceeded its memory limit. | Keep fewer or smaller values in the script’s own tables and strings. |
E_SCRIPT_NONDETERMINISTIC |
The vendored Lua was built without the fixed string-hash seed, so replay is not deterministic. | Run cargo clean -p mlua-sys -p lua-src and rebuild; do not set CFLAGS in the environment (it overrides .cargo/config.toml). |
E_PHYSICS |
The physics world rejected the operation. | Check that the entity has a body and valid dimensions. |
E_DATA_ROOT_REFUSED |
A candidate data root was refused by the agent data rules. | Pass a data root that is absolute and outside the real player data folder. |
E_RENDERER_UNAVAILABLE |
No offscreen renderer is attached to the runtime. | Attach a renderer before requesting frame capture. |
E_LOCALE_LOAD |
A localisation file could not be read or parsed. | Check that the file exists and is a flat JSON object of string keys to string texts. |
E_LOCALE_KEY_MISSING |
A localisation key has no text in the active language. | Add the key to the language file; the English text (or the key itself) is shown meanwhile. |
E_CMD_UNKNOWN |
No command with this id is registered. | List commands with system.commands (or spark schema commands) and use an id verbatim. |
E_PARAMS_INVALID |
The command parameters do not match its input schema. | Read the command’s input schema (system.commands) and send parameters that validate against it. |
E_PARSE |
The message is not valid JSON. | Send one JSON-RPC 2.0 object per line. |
E_PROTOCOL |
The message is valid JSON but not a valid JSON-RPC 2.0 or MCP request. | Send {“jsonrpc”:“2.0”,“id”:1,“method”:“ |
E_TXN_NONE |
There is no open transaction. | Open one with txn.begin first. |
E_TXN_UNKNOWN |
No open transaction has this id. | List open transactions with txn.status; ids are returned by txn.begin. |
E_TXN_ORDER |
Transactions must be closed innermost first. | Commit or roll back the most recent transaction before the outer one. |
E_TXN_BLOCKED |
The command cannot run while a transaction is open. | Commit or roll back the transaction first; commands that replace the whole world are not transactional. |
E_SUBSCRIPTION_UNKNOWN |
No event subscription has this id. | List subscriptions with events.status. |
E_ASSERT_FAILED |
An assertion did not hold. | Compare data.expected with data.actual in the error. |
E_NOT_IMPLEMENTED |
The command exists in the surface but its backend is not available in this build. | See docs/agent/commands.md for what the backend needs. |
E_EDITOR_NOT_ATTACHED |
No editor is attached to this session. | Attach to a running editor’s control socket, or start the editor with a control server. |
E_PLUGIN_HOST_MISSING |
No plugin host is attached to this session. | Plugins are available once the plugin host is attached (task plugins). |
E_PLUGIN_ERROR |
A plugin operation failed. | Read the message for the plugin’s own error; the previous version keeps running. |
E_IO |
A file could not be read or written. | Check the path and the file permissions; outputs belong under .gwdev/out/. |
E_OUTPUT_REFUSED |
The output path is outside the allowed output folder. | Write below .gwdev/out or .gwdev/out/; the path must be relative and without ... |
E_BIND_REFUSED |
The listen address is not a loopback address. | Servers bind only to 127.0.0.1 (or ::1); pass –listen 127.0.0.1: |
E_SEEK_UNAVAILABLE |
No keyframe at or before the requested frame exists. | Seek forward, restore a named snapshot first, or reset and step. |
E_IMAGE_INVALID |
An image file could not be decoded or the sizes do not match. | Pass PNG files of the same size. |
E_SNAPSHOT_FILE |
A snapshot file could not be read, written or replayed. | Check the path; the file must come from snapshot.save of the same project. |
E_SNAPSHOT_MISMATCH |
Replaying a snapshot file did not reproduce its state hash. | The scene, assets or engine changed since the file was saved; save it again. |
E_SCENARIO_FAILED |
A test script step did not meet its expectation. | Read the report: the failing step, the expectation and the actual result are listed. |
E_SCENARIO_IO |
A scenario file could not be read or its output folder could not be written. | Check the scenario path and that the output folder is writable. |
E_SCENARIO_SCHEMA |
A scenario file does not match the scenario format. | Validate the file against schemas/scenario.schema.json; the message lists each JSON path. |
E_SCENARIO_STEP |
A scenario step cannot run. | Read the message for the step: a bad snapshot path or an until on a screenshot, for example. |
E_GOLDEN_MISSING |
A golden image does not exist. | The capture is saved as <out>/<scenario>/<name>.actual.png; review it and approve it as the golden. |
E_GOLDEN_MISMATCH |
A capture differs from its golden image beyond the tolerance. | Compare the .actual.png with the golden; update the golden only when the change is intended. |
E_GOLDEN_SIZE |
A capture and its golden image have different sizes. | Capture at the golden’s size, or approve a new golden. |
E_IMAGE_IO |
A PNG file could not be read or written. | Check the path and the file permissions; the file must be a PNG. |
E_IMPORT_UNSUPPORTED |
The file is not an asset kind the import pipeline processes. | Import a PNG, JPEG, glTF or GLB file; assets.report lists what the pipeline handles. |
E_IMPORT_SETTINGS |
The import settings in the .meta are not valid. |
Fix the import object of the .meta; the message names the field (see schemas/commands/assets.import.schema.json). |
E_IMPORT_FAILED |
The asset could not be processed. | Read the message: the source file is damaged or uses a feature the importer does not support. |
E_SYSTEM_ORDER |
The declared order of the systems has a cycle, or two systems have the same name. | Fix the after / before lists; the message names the systems involved. |
E_SYSTEM_ACCESS |
A system touched a component it did not declare. | Declare the component in the system’s reads or writes (a write needs writes). |
E_INTERNAL |
Internal engine error. | Report this with the run id and the frame number. |
Events and logs
Section titled “Events and logs”Everything the runtime wants to report is an Event on the queue (drain_events(), events_mut()):
{seq, frame, level, kind, target, code?, message, fields}. seq is never reused, so a gap shows dropped events
(EventQueue::dropped()). frame is the frame counter after the step that produced the event.
kind |
target |
fields |
|---|---|---|
script_log |
script |
object |
script_error |
script |
object; code is E_SCRIPT_RUNTIME, E_SCRIPT_BUDGET, E_SCRIPT_MEMORY or E_SCRIPT_LOAD (also for a failed hot reload, reload_scripts()) |
contact |
physics |
a, b (entity names or ids), started |
animation |
animation |
object, event, frame |
particle_finished |
particles |
object |
snapshot |
runtime |
frame |
log |
any | warnings from asset loading (E_ASSET_IO) and from other crates |
Other crates push their own events through events_mut().push(frame, EventSpec::new(...)).
set_event_log(writer, min_level) writes events as JSON lines:
{"run", "frame", "level", "target", "kind", "code"?, "msg", "fields"}. The game binary does this on stderr for
agent runs and for --log-json; sparklight_core::events::init_json_logging does the same for log:: records.
Schemas
Section titled “Schemas”schemas/*.schema.json (JSON Schema 2020-12) describe every file format: scene, prefab, clip, tilemap,
palette, input-map, emitter and components (one entity). They are generated from the Rust types; a test fails
when the committed files are stale: cargo run -p sparklight-engine --example write_schemas. Formats owned by other
crates (clip, tile map, palette, input map, emitter) are described by mirror types in schemas.rs; tests check the
mirrors against the real parsers, the presets and every sample file. schemas::validate(kind, &value) validates a
value in code and returns one line per violation with its JSON path.
Frame capture hook
Section titled “Frame capture hook”Runtime::attach_renderer(Box<dyn FrameRenderer>) plugs an off-screen renderer in; render_frame(w, h) returns an
RGBA Frame or E_RENDERER_UNAVAILABLE. Renderers that ship: sparklight_scenario::GpuFrameRenderer (wgpu, off-screen; the
spark binary attaches it for the first capture.frame when an adapter exists) and the flat-colour software renderer of
sparklight-control (every sprite as a rectangle; the fallback, and the one --no-gpu selects). capture.frame and the golden
commands of the control plane (docs/agent/commands.md) sit on top; a headless runtime without a renderer reports
E_RENDERER_UNAVAILABLE.