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

HowEffect
--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 entry

Events

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.

HandlerFires 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 OUTport (16-bit) and val
on_io_read(port, val)any I/O INport 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)

FieldMeaning
d.src / d.destsource / destination address
d.countbyte count for the transfer
d.src_io / d.dest_io1 if the side is an I/O port rather than memory
d.src_mode / d.dest_modeaddress mode (increment / decrement / fixed)
d.prescalerthe zxnDMA prescaler (transfer-rate divider)
d.pc / d.linetriggering M1 PC and raster line

on_interrupt(d) · on_reti(d) · on_paging(d)

FieldEventMeaning
d.sourceinterruptfiring source: 0=ULA frame, 1=CTC, 2=line interrupt
d.vectorinterruptthe IM2 vector supplied (or the fixed $38 in IM1)
d.im2interrupt1 if the CPU is in IM2
d.spinterruptSP after acceptance — the ISR return address was just pushed here (a descending d.sp across hits spots nested/runaway ISRs)
d.slot / d.bankpagingMMU slot 0–7 and the 8 KB page it now maps
d.sourcepagingmechanism: 0=nextreg $50-57, 1=$C000/128K ($7FFD/$DFFD/$1FFD), 2=special .nex
d.pc / d.lineinterrupt, reti, pagingtriggering 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.

FieldEventMeaning
vector (1st arg)rstrestart target 0x000x38
d.pc / d.retrstthe RST’s address and the return address it pushes (pc+1)
d.dm_activerst1 if DivMMC is currently overriding $0000-$3FFF at the RST
d.automapdivmmc1 = DivMMC automap engaged
d.conmemdivmmc1 = CONMEM ($E3 bit7) forcing the overlay
d.mapramdivmmc1 = MAPRAM (RAM bank 3 read-only at $0000)
d.bankdivmmcthe DivMMC RAM bank mapped at $2000-$3FFF
d.pc / d.lineboththe 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.

FunctionReturns
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 nCPU 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.rCPU registers (i64)
s.af_ s.bc_ s.de_ s.hl_shadow set
s.sf s.zf s.hf s.pf s.nf s.cfflag bits (bool)
s.iff1 s.iff2 (bool) · s.im (0/1/2) · s.halt (bool)interrupt state — not reachable any other way
s.port_ffthe 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.scanlinevideo 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