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

FormWhat it isCan it change assembler state?
!rhai { … }A block of script, inlineYes
!rhai "file.rhai"The same, loaded from a fileYes
rhai("fn", …)A call inside an expressionNo

A block

    org $8000
    !rhai {
        let base = 0x4000;
        ctx.define_constant("SCREEN", base);
    }
    ld hl, SCREEN        ; SCREEN == 0x4000

The 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 0x46

The 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  BANNER

Assign 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.pass is always 1 inside 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.peek can 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 block

Every 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

CallAnswers
ctx.passThe pass; always 1 in a block.
ctx.pcThe current program counter.
ctx.bankThe 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

CallDoes
ctx.bank = nSelect 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

CallAnswers
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

CallDoes
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

CallDoes
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          ; 100

Conditional 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 BANNER

See The Project File for where __NAME__ comes from.

Documentation