Skip to content

Scripting Engine

DoubleGate edited this page Jul 8, 2026 · 1 revision

Scripting Engine

rusty2600-script adds Lua scripting to Rusty2600, off by default. It's a std-only, unsafe-permitted crate — the exception to the chip crates' no_std + #![forbid(unsafe_code)] house style, since mlua's C FFI requires it (the same reasoning that makes rusty2600-cheevos vendor rcheevos with unsafe). It never touches core or frontend types directly: a host implements a ScriptBus trait over whatever real emulator state it owns, and the scripting crate stays completely host-agnostic.

Why mlua, and why not piccolo yet

The plan called for mlua as the native backend plus a pure-Rust piccolo-backed WebAssembly fallback (mlua's vendored C Lua build cannot target wasm32-unknown-unknown at all). Only mlua shipped initially — piccolo was, at the time, a materially less mature project (fewer standard-library facilities, less battle-tested), and landing a second, non-byte-parity scripting backend alongside a brand-new API surface in the same release risked shipping either half-tested.

This was revisited for real in v2.9.0, not just re-deferred on the same reasoning: piccolo's only published crates.io release at the time implemented almost none of Lua's string/table standard library (ruling out the kind of script this crate's own emu API invites), and its architecture (a gc-arena-based "stackless" VM driven by repeated step() calls inside an arena scope) is a genuine mismatch for mlua's current plain-owned-VM embedding shape — adopting it would mean a real engine rewrite, not a second backend file. The decision: defer again, on a narrower, confirmed-external-blocker basis rather than a scope-discipline judgment call. In-browser Lua scripting remains unsupported on wasm32; the native mlua path is unaffected either way.

The emu API

Function Gated? Backs onto
emu.peek(addr) No read-only bus access
emu.poke(addr, val) Yes (WritesLocked) a real bus write
emu.cpu() No a CPU register snapshot
emu.onFrame(fn) No registers a callback invoked once per real emulated frame
emu.setJoystick(port, direction, pressed) Yes joystick input override
emu.setConsoleSwitch(name, value) Yes console-switch override
emu.drawText(x, y, text) / drawRect(...) / drawPixel(...) No an on-screen overlay compositor
emu.pause() No pauses the host
emu.saveState() / emu.loadState(bytes) No the real, versioned SaveState format

A locked write surfaces as a Lua runtime error to the script — verified by tests that the underlying write never actually reaches the bus, not just that an error gets thrown.

WritesLocked: the determinism gate

Every script-driven write is checked against a lock that folds in every real source of "an unreplicated local write would break something": RetroAchievements hardcore mode, and — once connected — an active rollback netplay session (see Netplay-and-Rollback). The lock deliberately does not carry stub fields for subsystems that don't have a real lock concept yet; a field gets added in the same change that gives its subsystem something real to gate.

Frontend wiring (scripting feature)

A real ScriptBus implementation, a live onFrame hook, and a Tools → Load Script... menu entry exist in rusty2600-frontend. The implementation owns a private System clone synced from the real emulator state at the start of each tick rather than a live pointer into it — a deliberate, documented indirection (not a corner cut) needed because the script engine's Lua VM is fixed to a 'static bound at construction, while the real emulator state lives behind a short-lived mutex guard re-acquired every render pass. Since the entire 2600 game-state is small (128 bytes of RIOT RAM plus a handful of registers), cloning it every tick is cheap and the resulting peek/poke/save-state operations are exact, real operations, not an approximation.

Script-drawn overlay primitives (drawText/drawRect/drawPixel) composite onto the display via an egui foreground layer, piggybacking on the frontend's always-on egui pass rather than adding a new render pipeline of its own — see Frontend-Architecture.

Debugger Lua console panel

Lua's default print writes to the real process stdout, invisible in a GUI app. The scripting engine overrides print to push into a capped, persistent log ring buffer instead, and any onFrame runtime error gets pushed into the same log distinctly marked as an error. The debugger's Lua Console panel renders this log, oldest-first, errors highlighted — output-only, not an interactive REPL (executing arbitrary ad-hoc Lua from the debugger would need its own WritesLocked integration, deliberately out of scope for this panel).

See also

Architecture-Overview · Frontend-Architecture · Netplay-and-Rollback · Deferred-Features

Clone this wiki locally