Rhai Scripting
A Rhai script attached to the debugger defines handlers for events in the machine, such as a breakpoint, a frame or a write to a port, and acts on them. A handler reads the machine’s state directly and returns ADP commands to run, so a whole debugging session can be one file instead of a shell loop.
fn on_breakpoint(id, pc) {
if reg("i") != 0xBD { return "continue"; }
log(`crash: sp=${reg("sp")} ix=${reg("ix")} @frame ${frame()}`);
return ["state", "bt"];
}Load a script at startup with --script FILE, or into a running session with the ADP command script load FILE. It works in a window, in a --headless server and in a --screenshot capture. The language itself is documented at rhai.rs.
Script lifecycle
The top level of a script runs once, when it loads. Each event then calls its handler, and only that handler. Variables declared at the top level with let keep their values across calls, so a counter or a flag set in one handler is there in the next.
A call is stopped after 5,000,000 operations, so a runaway loop cannot hang the emulator.
Handler results
A handler returns nothing, one ADP command as a string, or an array of them. The commands run in order once the handler returns.
fn on_menu_idle(pc) { return "enter"; } // launch the highlighted entry
fn on_frame(n) { if n == 1000 { return "savestate /tmp/f1000.bzst"; } }Events
Every event fires at the instruction boundary that produced it, so the state a handler reads is the state at that moment. on_frame and on_menu_idle fire at frame boundaries.
| Handler | Fires when |
|---|---|
on_breakpoint(id, pc) | a breakpoint stops the machine |
on_frame(n) | each frame boundary |
on_menu_idle(pc) | the program counter at the frame boundary has not moved for 50 frames, as when NextZXOS’s browser waits for a key |
on_nextreg(reg, val) | a Next register is written, through $253B or NEXTREG |
on_io_write(port, val) | a watched port is written |
on_io_read(port, val) | a watched port is read; val is the value returned |
on_mem_write(addr, val, pc) | a watched address is written |
on_sd_command(cmd) | an SD card command completes; 24 is a single-block write |
on_dma_xfer_start(d) | a zxnDMA transfer begins |
on_dma_xfer_end(d) | a zxnDMA transfer ends |
on_interrupt(d) | the CPU accepts a maskable interrupt |
on_reti(d) | the CPU executes RETI |
on_paging(d) | an MMU slot changes what it maps |
on_rst(vector, d) | an RST instruction is fetched |
on_divmmc(d) | the DivMMC paging state changes |
Each event interrupts the frame to call its handler, so an event that fires thousands of times a frame slows the run in proportion. on_breakpoint on a tight loop is the usual case; pick an address reached a bounded number of times.
Watches
on_mem_write, on_io_read and on_io_write fire only for what a watch names, because memory and port traffic runs to thousands of accesses a frame.
watch_mem(0xBD00, 0xBE00); // an inclusive address range
watch_io(0xFFFD); watch_io(0xBFFD); // ports accumulate
fn on_mem_write(addr, val, pc) { log(`${addr} = ${val} @pc ${pc}`); }watch_mem holds one range, and a second call replaces the first. watch_io() with no port watches every port. A watch called in a handler applies from that point in the frame.
A handler with no watch never fires, and Bizmuth warns when the script loads. Filtering inside the handler does not reduce the cost, which is entering the handler at all. Measured over a 300-frame Next boot, against the same run with no script:
| Watch | Events | Run time |
|---|---|---|
$FFFD and $BFFD | 0 | 1.02× |
$243B, the nextreg select port | 66,661 | 1.52× |
| every port | 827,584 | 1.93× |
Event maps
The richer events pass one map, d, holding what was true when the event happened.
| Field | Events | Meaning |
|---|---|---|
d.src, d.dest | DMA | source and destination address, or port where the matching _io field is 1 |
d.count | DMA | bytes in this block |
d.src_io, d.dest_io | DMA | 1 for a port, 0 for memory |
d.src_mode, d.dest_mode | DMA | address step: 1 up, 0 down, 2 or 3 fixed |
d.prescaler | DMA | the zxnDMA prescaler |
d.pattern_index | DMA | the sprite pattern upload position; ÷ 256 is the slot |
d.sprite | DMA | the sprite attribute position |
d.source | interrupt | 0 ULA frame, 1 CTC, 2 line interrupt |
d.vector | interrupt | the byte on the bus; $FF in legacy mode |
d.im2 | interrupt | 1 when the Next’s IM2 hardware mode is on |
d.ts | interrupt | the frame T-state at acceptance |
d.sp | interrupt | SP after the return address was pushed |
d.slot, d.bank | paging | the MMU slot, and the 8K page it now maps |
d.source | paging | 0 nextreg $50–$57, 1 a 128K paging port, 2 a .nex load |
d.ret | rst | the return address pushed |
d.dm_active | rst | 1 when DivMMC is mapped at the RST |
d.automap, d.conmem, d.mapram | divmmc | 1 when engaged |
d.bank | divmmc | the DivMMC RAM bank at $2000–$3FFF |
d.pc, d.line | all of these | the program counter and raster line at the event |
on_reti carries only d.pc and d.line. It fires for interrupt routines that end in RETI, which NextZXOS’s frame routine does not.
fn on_dma_xfer_start(d) {
if d.dest_io != 0 && (d.dest & 0xFF) == 0x5B {
log(`pattern upload to slot ${d.pattern_index / 256}, ${d.count} bytes @pc ${d.pc}`);
}
}Machine state
| Function | Returns |
|---|---|
reg(NAME) | a register. Names as in Breakpoints and Conditions, im, iff1, iff2 and halt included |
flag(NAME) | a flag as true or false: fs fz fh fpv fn fc |
mem(addr) | the byte the CPU would read at addr |
pmem(page, off) | a byte from MMU page page, mapped or not; page 10 is bank 5 |
sram(bank, off) | a byte from physical 8K bank bank, the ROM banks 0 to 7 included |
nr(n), nrraw(n) | a Next register as software reads it back, and as last written |
slot(i) | the page in MMU slot i |
dm(FIELD) | DivMMC state: automap, conmem, mapram, active, bank, hold_on, hold_off |
rompg(FIELD) | ROM paging: rom_bank, rom1ffd, rom7ffd, config, altrom |
frame(), scanline() | the frame counter and the beam’s line |
cycles() | T-states since power-on |
sym(NAME) | a symbol, as a map; see Symbols and source lines |
src(addr) | the source line addr was assembled from: file, line, and col, col_end where recorded |
annotation() | in on_breakpoint, the assertion this stop was; see Assertions |
get_machine_state() | every register, flag and Next register through one handle; see State handle |
The ADP command script eval reads the live machine too. The top level of a script, which runs as it loads, has no machine attached, and the functions return -1 there.
An unknown name also returns -1 (false for flag), so reg, flag, dm and rompg log a warning listing the names they accept, once per name.
Ports
get_machine_state().port[addr] is what a read of the port would return, worked out without performing the read. The UART’s FIFO is not emptied, its error bits are not cleared, the SD card is not clocked and the DMA’s read sequence does not advance, so a program behaves the same whether or not a script is looking.
get_machine_state().portraw[addr] answers for the write-only paging ports $7FFD, $1FFD, $DFFD, $EFF7 and $BF3B, which have no read decode, so a guest reading one gets the open bus. portraw returns the value last written, in that port’s bit layout. It is what the program asked for, where slot(i) is the paging that resulted. A bit the hardware discards on write reads 0: $7FFD keeps all eight, $1FFD bits 0 to 2, $DFFD bits 0 to 3 and $EFF7 bits 2 and 3. For $BF3B the index is the last one latched, which the hardware does only in mode 0. Any other port answers -1.
Symbols and source lines
sym(NAME) returns a map. An unknown name gives an empty one, so using a field of it is an error rather than a plausible number.
| Key | Present when |
|---|---|
addr | the symbol is a location: the address the CPU uses |
bank | the SLD named a page: the page the bytes live in |
phys | as bank: the physical address |
value | the symbol is an EQU constant |
kind | always: "location" or "constant" |
file, line | the SLD recorded where it was defined |
⚠ mem(sym(x).addr) reads through the current paging, so it reads the symbol only while its page is mapped. For a symbol in a page that may be out, read pmem(sym(x).bank, sym(x).addr % 0x2000).
State handle
get_machine_state() returns a handle whose fields read the machine as they are used, so asking for .pc reads one register.
| Field | |
|---|---|
s.pc s.sp s.af s.bc s.de s.hl s.ix s.iy, s.a to s.l, s.i s.r | registers |
s.af_ s.bc_ s.de_ s.hl_ | the alternate set |
s.sf s.zf s.hf s.pf s.nf s.cf | flags |
s.iff1 s.iff2 s.im s.halt | interrupt state |
s.port_ff | the Timex $FF latch |
s.nr[n], s.nrraw[n] | a Next register, read back and as written |
s.slots[i] | MMU slot i |
s.port[addr], s.portraw[addr] | as described in Ports |
s.frame s.scanline | the frame and the beam’s line |
A handle is valid only inside the handler that made it. Kept in a global and read in a later event, it reads that event’s machine. Keep the values instead.
Actions
| Function | Effect |
|---|---|
log(text) | print a [script] line |
bt(n) | log the last n instructions as (pc, sp), oldest first: how control reached this point |
breakpoint(addr), clear_breakpoint(addr) | set or clear a PC breakpoint |
watch_mem(lo, hi), watch_io(port) | see Watches |
map_page(start, end, page), unmap_page(start) | declare or withdraw a page window; see Breakpoints and Conditions |
mouse(dx, dy[, buttons[, wheel]]) | move the mouse, as the ADP mouse command does |
esp_drop_next(), esp_drop(n), esp_stall_ms(n), esp_delay_ms(n), esp_framing_error(), esp_break(on), esp_faults_clear() | inject a serial fault, as the ADP serial command does |
stop() | end a --screenshot capture at the end of this frame |
mouse and the esp_ functions queue, so several calls from a loop or a branch apply in order before the next frame. bt records history only while a script is loaded.
stop() ends the capture at the end of the current frame. The screenshot, --state-json, --savestate and the claims report are written from that frame, and a --video is padded to its declared length with it. Called from an event handler, the rest of the frame still runs, on_frame included. A second call does nothing, and a window or a --headless server ignores it. If on_frame(n) calls stop() when n is 612 in a 1000-frame run, the screenshot shows frame 612.
Examples
Count bytes received from the ESP and drop the fortieth:
let seen = 0;
watch_io(0x143B);
fn on_io_read(port, val) {
seen += 1;
if seen == 40 { esp_drop_next(); }
}Drive the mouse from a capture and read the counter back:
fn on_frame(n) {
if n >= 520 && n < 530 { mouse(4, 0, 0, 0); }
if n == 530 { mouse(0, 0, 1, 0); }
if n == 535 { log(`x=${get_machine_state().port[0xFBDF]}`); }
}