Rhai Scripting
FantASM embeds the Rhai scripting language, so a build can compute things instead of you typing them: a sine table, a checksum, a jump table, a constant that depends on how big something else turned out.
Scripts run while the source is being read, not while the program runs. Everything a script does has already happened by the time the bytes are written.
Three forms
| Form | What it is | Can it change assembler state? |
|---|---|---|
!rhai { … } | A block of script, inline | Yes |
!rhai "file.rhai" | The same, loaded from a file | Yes |
rhai("fn", …) | A call inside an expression | No |
A block
org $8000
!rhai {
let base = 0x4000;
ctx.define_constant("SCREEN", base);
}
ld hl, SCREEN ; SCREEN == 0x4000The block ends at the matching closing brace. Only a brace in code closes it — one inside a string, a character literal or a comment is text, including the forms that span lines, so a script may use braces freely.
From a file
!rhai "build/tables.rhai"The file is found using the include path, exactly as INCLUDE finds one.
⛔ The .rhai extension is required. It is what distinguishes a filename from the start of a block. !rhai "tables.inc" is not read as a file — it falls through to block collection and fails with Expected ‘{’ after !rhai, which says nothing about the extension.
From an expression
!rhai {
fn checksum(a, b) { (a + b) & 0xFF }
}
db rhai("checksum", 0x12, 0x34) ; emits 0x46The first argument names a function defined in an earlier !rhai block; the rest are passed to it. The result can be a number, or text, which an EQU can hold:
!rhai {
fn banner() { "console v" + version() }
}
BANNER equ rhai("banner")
dz BANNERAssign text first — db rhai("banner") is E1075, since text has no numeric value.
When a script runs
A block runs once, as the source is gathered. That has three consequences worth holding on to:
ctx.passis always1inside a block.- A script sees only the source above it.
ctx.symbol_exists("later")is false for a label defined further down, however far. ctx.peekcan read bytes already assembled, because they exist by then.
Changing state
⛔ rhai(...) expression functions are read-only. Calling anything that changes assembler state from inside one is an error, because evaluating an expression must not have side effects:
Error [E1058]: General error: Rhai call error: Runtime error: define_constant() cannot
change assembler state from inside a rhai() expression function — do it in a !rhai blockEvery write in the tables below is refused there. Do the work in a !rhai block and let the expression read the result.
The ctx object
Inside a block the assembler is ctx, and host functions are methods on it.
Reading state
| Call | Answers |
|---|---|
ctx.pass | The pass; always 1 in a block. |
ctx.pc | The current program counter. |
ctx.bank | The current bank. |
Each has a method spelling that does the same thing — ctx.get_pass(), ctx.get_pc(), ctx.get_bank(), and ctx.set_bank(n) for the write. The property form is the one to reach for; the methods work and are not going anywhere.
Writing state
| Call | Does |
|---|---|
ctx.bank = n | Select the current bank. |
ctx.define_constant(name, value) | Define a constant. value may be a number or text. |
ctx.define_label(name, value) | Define a label. |
Reading symbols
| Call | Answers |
|---|---|
ctx.get_constant(name) | A number, or text for a constant holding text, or () if undefined. |
ctx.get_label(name) | A number, or () if undefined. |
ctx.get_sizeof(name) | A number, or () if unknown. |
ctx.symbol_exists(name) | true or false — constant or label. |
ctx.constant_names() | Every constant name, sorted. |
ctx.label_names() | Every label name, sorted. |
⛔ Use symbol_exists to test for a name, not a comparison against (). It answers a plain bool. The undefined answer used to be an Option, which Rhai has no operators for — so ctx.get_label("x") > 0 evaluated false without complaint, and a build-time guard read as a passing check while checking nothing.
Memory and emission
| Call | Does |
|---|---|
ctx.peek(addr) | Read a byte already emitted. Read-only, so it works in either form. |
ctx.poke(addr, val) | Overwrite a byte. Does not move the PC. |
ctx.emit_byte(val) | Emit a byte and advance. |
ctx.emit_word(val) | Emit 16 bits, little-endian. |
ctx.emit_bytes(array) | Emit each element as a byte. |
ctx.emit_string(s) | Emit a ZX-translated string. |
ctx.emit_string_zero(s) | The same, plus a NUL terminator. |
emit_* advance the program counter, so a label after the block gets the right address. poke patches a byte already written and moves nothing.
Diagnostics and helpers
| Call | Does |
|---|---|
ctx.warn(msg) | Raise a warning and carry on. |
ctx.error(msg) | Raise an error and stop the build. |
to_hex(v) | Format a number as "0x…". A free function, not on ctx. |
What the language gives you
Rhai’s standard maths, string, logic, array, map and blob packages, plus range iteration:
!rhai {
let t = [];
for i in 0..256 { // 0..=255 also works
t.push((i * i) & 0xFF);
}
ctx.emit_bytes(t); // a 256-byte squares table
}Integers only. There is no floating point, so a sine table is built from a lookup or from integer maths rather than from sin().
The sandbox
Scripts have no file, network or system access — nothing beyond ctx and the packages above. A build you have just cloned cannot read your home directory.
Two limits stop a script hanging the build:
- 100,000 operations. A runaway loop is stopped rather than spun on.
- Expression depth 50, on both parsing and evaluation.
Exceeding either is an error naming the position:
Error [E1200]: Rhai script error: Too many operations (line 3, position 29)print files a diagnostic (1081) and goes to stderr. The diagnostic is what makes it visible in an editor; stderr is what keeps a terminal build readable as a log.
It never goes to stdout, and that is not cosmetic: in language-server mode stdout is the JSON-RPC channel, and a script printing there once cost the client its framing and shut the server down.
⛔ Twenty lines a block, then one saying how many more there were. A for loop prints as often as it likes, and a thousand diagnostics against one line is worse than none — stderr still carries all of them.
A block’s prints name the !rhai rather than the line inside it, because Rhai hands the handler the text and no position. A rhai(...) in an operand names the line that called it.
When a script goes wrong
Script faults are E1200, and the position points inside the script — at the line you wrote, not at the !rhai that opened the block:
Error [E1200]: Rhai compilation error: Unexpected ';'
prog.asm:3:13
let x = ;
^ctx.error(...) arrives the same way, carrying your message. See Diagnostics.
Worked examples
A computed constant:
!rhai { ctx.define_constant("HALF", 200 / 2); }
ld a, HALF ; 100Conditional assembly, testing for a name rather than comparing against ():
!rhai {
if ctx.symbol_exists("DEBUG") {
ctx.define_constant("BORDER", 2);
} else {
ctx.define_constant("BORDER", 0);
}
}A data table built from a helper:
!rhai {
fn row(n) { [n, n * 2, n * 3, n * 4] }
for i in 1..5 { ctx.emit_bytes(row(i)); }
}Composing a banner from the project’s own name, which [project] supplies as __NAME__:
!rhai {
let name = ctx.get_constant("__NAME__");
ctx.define_constant("BANNER", name + " ready");
}
dz BANNERSee The Project File for where __NAME__ comes from.