Rhai scripting
Bizmuth embeds a Rhai scripting layer that reacts to hardware activity and reads live machine state. A single script replaces the ad-hoc bash + adp.py loops you’d otherwise write, and it runs both windowed and fully headless (alongside --screenshot).
The model is simple: you define on_* handler functions; Bizmuth calls them when the matching hardware event fires. Inside a handler you read live state through host functions, log() diagnostics, and return ADP command line(s) for the debugger to run. The scripting surface is the ADP command surface — anything you can type at the ADP prompt, a handler can return.
Loading & running
| How | Effect |
|---|---|
--script <file.rhai> | load at startup — works in the windowed app and headlessly (with --screenshot) |
script load <file> (ADP) | load/replace live → loaded <file>; handlers: [on_frame, …] |
script list (ADP) | list the active handlers |
script eval <expr> (ADP) | evaluate a one-off Rhai expression against live state |
Scripts run with a 5,000,000-operation cap per handler call, so a runaway loop can’t wedge the emulator. The script scope persists across calls — top-level let globals (counters, flags, accumulators) survive from one event to the next.
// The scope is shared across handler calls, so globals keep state.
let hits = 0;
fn on_frame(n) {
hits += 1;
if n == 1000 { return "savestate /tmp/f1000.azst"; } // return an ADP command
}Return values
A handler may return:
()— nothing (the common case: read state,log, done);- a single command string — e.g.
"state","continue","enter"; - an array of command strings — run in order, e.g.
["state", "bt"].
fn on_breakpoint(id, pc) { return ["state", "bt"]; } // dump CPU + backtrace on every hit
fn on_menu_idle(pc) { return "enter"; } // launch the highlighted browser entryEvents
Every handler is optional — define only the ones you need. All events fire at an instruction boundary (never mid-instruction) with live, coherent state, except on_frame / on_menu_idle, which fire once per frame.
| Handler | Fires when |
|---|---|
on_breakpoint(id, pc) | a PC breakpoint (set via breakpoint() or ADP bp) is hit — CPU paused exactly at pc, so reg()/mem() read the precise state |
on_frame(n) | once per video frame; n is the frame counter |
on_menu_idle(pc) | the NextZXOS browser/menu has gone idle (settled, awaiting input) |
on_nextreg(reg, val) | a nextreg write — reg and the byte written |
on_io_write(port, val) | any I/O OUT — port (16-bit) and val |
on_io_read(port, val) | any I/O IN — port and the value returned |
on_mem_write(addr, val, pc) | a watched address is written — requires watch_mem(lo, hi) first |
on_sd_command(cmd) | an SD/SPI command byte is issued |
on_dma_xfer_start(d) / on_dma_xfer_end(d) | a zxnDMA transfer begins / completes — d carries the transfer state |
on_interrupt(d) | a maskable INT or NMI is accepted — d carries source/vector/SP |
on_reti(d) | a RETI / RETN returns from an ISR — d carries pc/line |
on_paging(d) | an MMU slot remap — d carries slot/bank/source |
on_rst(vector, d) | any RST opcode fetch — vector is the restart target |
on_divmmc(d) | the DivMMC $0000-$3FFF overlay state changes |
// Watch who remaps the $C000 slots (6/7) and which interrupt source fires each frame:
fn on_paging(d) { if d.slot >= 6 { log(`slot ${d.slot} = bank ${d.bank} (src ${d.source}) @pc ${d.pc}`); } }
fn on_interrupt(d) { log(`INT src ${d.source} vec ${d.vector} @line ${d.line}`); }The d state-map argument
The richer events (on_dma_*, on_interrupt, on_reti, on_paging, on_rst, on_divmmc) pass a single state-map d whose fields carry the specifics of that event. Read them as d.field.
on_dma_xfer_start(d) / on_dma_xfer_end(d)
| Field | Meaning |
|---|---|
d.src / d.dest | source / destination address |
d.count | byte count for the transfer |
d.src_io / d.dest_io | 1 if the side is an I/O port rather than memory |
d.src_mode / d.dest_mode | address mode (increment / decrement / fixed) |
d.prescaler | the zxnDMA prescaler (transfer-rate divider) |
d.pc / d.line | triggering M1 PC and raster line |
on_interrupt(d) · on_reti(d) · on_paging(d)
| Field | Event | Meaning |
|---|---|---|
d.source | interrupt | firing source: 0=ULA frame, 1=CTC, 2=line interrupt |
d.vector | interrupt | the IM2 vector supplied (or the fixed $38 in IM1) |
d.im2 | interrupt | 1 if the CPU is in IM2 |
d.sp | interrupt | SP after acceptance — the ISR return address was just pushed here (a descending d.sp across hits spots nested/runaway ISRs) |
d.slot / d.bank | paging | MMU slot 0–7 and the 8 KB page it now maps |
d.source | paging | mechanism: 0=nextreg $50-57, 1=$C000/128K ($7FFD/$DFFD/$1FFD), 2=special .nex |
d.pc / d.line | interrupt, reti, paging | triggering M1 PC and raster line (on_reti carries only d.pc/d.line) |
on_rst(vector, d) · on_divmmc(d) — the RST targets are the DivMMC automap entry points, so these pair up to make the esxDOS interrupt-hook / automap timing legible.
| Field | Event | Meaning |
|---|---|---|
vector (1st arg) | rst | restart target 0x00–0x38 |
d.pc / d.ret | rst | the RST’s address and the return address it pushes (pc+1) |
d.dm_active | rst | 1 if DivMMC is currently overriding $0000-$3FFF at the RST |
d.automap | divmmc | 1 = DivMMC automap engaged |
d.conmem | divmmc | 1 = CONMEM ($E3 bit7) forcing the overlay |
d.mapram | divmmc | 1 = MAPRAM (RAM bank 3 read-only at $0000) |
d.bank | divmmc | the DivMMC RAM bank mapped at $2000-$3FFF |
d.pc / d.line | both | the M1 PC at the event and the raster line |
// Trace the esxDOS interrupt hook: each RST 38 + every DivMMC overlay change.
fn on_rst(vector, d) { if vector == 0x38 { log(`RST 38 @pc ${d.pc} dm=${d.dm_active}`); } }
fn on_divmmc(d) { log(`DivMMC automap=${d.automap} conmem=${d.conmem} bank=${d.bank} @pc ${d.pc} line ${d.line}`); }Host functions
Handlers read live machine state through host functions (backed by a thread-scoped debug context installed only for the handler’s duration) and emit diagnostics with log. Reading outside an event (e.g. script eval reg("ix")) returns -1 — no context is installed there yet.
| Function | Returns |
|---|---|
reg("ix"), reg("sp"), reg("i"), … | a register as i64 (any name ADP reg accepts) |
mem(addr) | the byte the CPU would read at addr (paging-resolved) |
pmem(page, off) | a byte from a physical 8K page, MMU-bypassing — the renderer’s view of video RAM (e.g. bank 5 as page 10, readable even when unmapped) |
nr(n) / nrraw(n) | nextreg n — CPU read-back / stored byte (the read-back-vs-raw distinction; see conditions) |
flag("fz"), … | a CPU flag as bool (fs fz fh fpv fn fc) |
slot(i) | the bank in MMU slot i (0–7) — nextreg $50+i |
dm("field") | a DivMMC paging field: automap/conmem/mapram/active/bank/hold_on/hold_off (-1 if unknown) |
sram(bank, off) | a byte from SRAM bank at off (physical, MMU-bypassing) |
rompg("field") | a ROM-paging field (which ROM is mapped at $0000-$3FFF) |
frame() / scanline() | current frame counter / beam scanline |
cycles() | the T-state / cycle counter |
log(msg) | print a [script] … diagnostic line (drained after each handler) |
bt(n) | dump the last n (pc, sp) instruction records (oldest first) — an execution backtrace of how control reached here (corrupt RET, JP (HL) into garbage, stack corruption). The history ring records automatically while a script is loaded. See scripts/backtrace.rhai. |
watch_mem(lo, hi) | (top-level call) arm on_mem_write for the address range lo..=hi |
breakpoint(addr) | (top-level call) set a PC breakpoint; on_breakpoint(id, pc) then fires there with live state |
clear_breakpoint(addr) | remove a PC breakpoint previously set with breakpoint() |
get_machine_state() | a lazy full-state handle — see below |
breakpoint() works in the headless --script path too — the run loop resumes the frame after firing. Each hit runs the handler, so very hot addresses (a tight inner-loop PC) are slow; pick an address hit a bounded number of times.
fn on_breakpoint(id, pc) {
if reg("i") != 0xBD { return "continue"; } // skip the look-alike $0000 pass-throughs
log(`crash: sp=${reg("sp")} ix=${reg("ix")} bdbd=${mem(0xBDBD)} @frame ${frame()}`);
return ["continue", "enter"]; // capture + relaunch
}watch_mem + on_mem_write
on_mem_write only fires for addresses you’ve armed. Call watch_mem(lo, hi) once at top level (it runs when the script loads), then define the handler:
watch_mem(0x5B00, 0x5BFF); // arm the system-variables page
fn on_mem_write(addr, val, pc) {
log(`write ${val} -> ${addr} by pc ${pc}`);
}See scripts/watch-mem.rhai and scripts/determinism.rhai for complete, runnable examples that replace the old bash + adp.py loops.
get_machine_state() — one lazy handle for the whole state surface
When a handler needs a field the event args don’t carry (and the piecemeal functions above feel scattered), get_machine_state() returns a lazy MachineState handle grouping the whole surface under one object. Every field reads live on access — so it’s cheap (.pc reads one register; s.nr[0xC0] reads exactly one nextreg, not all 256) and coherent (the machine is paused for the handler’s duration).
fn on_breakpoint(id, pc) {
let s = get_machine_state();
log(`pc=${s.pc} sp=${s.sp} iff1=${s.iff1} im=${s.im} halt=${s.halt}`);
log(`port_ff=${s.port_ff} C0=${s.nr[0xC0]} slot5=${s.slots[5]}`);
let saved_sp = s.sp; // store the SCALAR to compare at a later breakpoint
}| Field | |
|---|---|
s.pc s.sp s.af s.bc s.de s.hl s.ix s.iy s.a..s.l s.i s.r | CPU registers (i64) |
s.af_ s.bc_ s.de_ s.hl_ | shadow set |
s.sf s.zf s.hf s.pf s.nf s.cf | flag bits (bool) |
s.iff1 s.iff2 (bool) · s.im (0/1/2) · s.halt (bool) | interrupt state — not reachable any other way |
s.port_ff | the Timex/ULA $FF latch |
s.nr[n] / s.nrraw[n] | nextreg n read-back / stored (indexer) |
s.slots[i] | MMU 8K slot i (nextreg $50+i) |
s.port[addr] | a tracked port latch, or -1 (only $FF today; $7FFD/$DFFD/$1FFD are bit-decomposed — use s.slots[..]) |
s.frame s.scanline | video position |
The handle is only valid inside the handler that created it — it reads live through the paused context. Don’t stash s in a global to read at a later event (you’d get the new event’s state); store the queried scalar values instead.
See also
- The ADP debugger — the command surface handlers return into.
- Conditional breakpoints — for a single stateful compare without a full script (
bp add pc <addr> if <cond>). - Headless & automation — running
--scriptwith no window.