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.

HandlerFires 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:

WatchEventsRun time
$FFFD and $BFFD01.02×
$243B, the nextreg select port66,6611.52×
every port827,5841.93×

Event maps

The richer events pass one map, d, holding what was true when the event happened.

FieldEventsMeaning
d.src, d.destDMAsource and destination address, or port where the matching _io field is 1
d.countDMAbytes in this block
d.src_io, d.dest_ioDMA1 for a port, 0 for memory
d.src_mode, d.dest_modeDMAaddress step: 1 up, 0 down, 2 or 3 fixed
d.prescalerDMAthe zxnDMA prescaler
d.pattern_indexDMAthe sprite pattern upload position; ÷ 256 is the slot
d.spriteDMAthe sprite attribute position
d.sourceinterrupt0 ULA frame, 1 CTC, 2 line interrupt
d.vectorinterruptthe byte on the bus; $FF in legacy mode
d.im2interrupt1 when the Next’s IM2 hardware mode is on
d.tsinterruptthe frame T-state at acceptance
d.spinterruptSP after the return address was pushed
d.slot, d.bankpagingthe MMU slot, and the 8K page it now maps
d.sourcepaging0 nextreg $50–$57, 1 a 128K paging port, 2 a .nex load
d.retrstthe return address pushed
d.dm_activerst1 when DivMMC is mapped at the RST
d.automap, d.conmem, d.mapramdivmmc1 when engaged
d.bankdivmmcthe DivMMC RAM bank at $2000–$3FFF
d.pc, d.lineall of thesethe 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

FunctionReturns
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.

KeyPresent when
addrthe symbol is a location: the address the CPU uses
bankthe SLD named a page: the page the bytes live in
physas bank: the physical address
valuethe symbol is an EQU constant
kindalways: "location" or "constant"
file, linethe 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.rregisters
s.af_ s.bc_ s.de_ s.hl_the alternate set
s.sf s.zf s.hf s.pf s.nf s.cfflags
s.iff1 s.iff2 s.im s.haltinterrupt state
s.port_ffthe 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.scanlinethe 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

FunctionEffect
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]}`); }
}

← Developer Guide