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.

The Three Forms

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

!rhai "file.rhai" and rhai("fn", …) are the older spellings of the last two, and still work.

Blocks

    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.

Script Files

    include "build/tables.rhai"

The file is found using the include path, exactly as any other include is.

The .rhai extension is what marks a script, and both forms require it. It is how include tells a script from source, and how !rhai tells a filename from the start of a block — so !rhai "tables.inc" falls through to block collection and fails with E1005, `!rhai` opens a block with `{`, which says nothing about the extension.

incbin is unaffected: it asks for a file’s bytes, and gives you a script’s bytes if that is what you point it at.

Script Visibility

Every function defined by every script above it, whether that was a block or a file — so helpers live in one place and are used from anywhere below:

    include "helpers.rhai"       ; fn pages(n) { (n + 8191) / 8192 }

    !rhai {
        ctx.define_constant("PAGES", pages(20000));
    }
    db rhai.pages(8193)

Above, and only above. A script further down the file is invisible, and so is a function defined later in the same one. The build stops with E1088, and its hint says nothing has been loaded yet — which is the usual reason for seeing it.

Only functions are inherited, never statements. A block’s own work happens once, where it is written, however many scripts follow it.

File-Qualified Calls

Two scripts may define the same function name. The later one is called, and W1090 says so, naming both. Loading the same file twice is not a collision — the definitions are identical — and neither is a script defining names nothing else uses.

To reach past the winner, name the file the function came from:

    include "gfx.rhai"             ; fn pack(x)
    include "audio.rhai"           ; fn pack(x), unrelated

    db rhai.gfx.pack(1)
    db rhai.audio.pack(1)

The prefix is the file’s own name without .rhai, not the path it was found down, so moving a script between include directories does not rewrite its call sites. rhai.pack(1) still works and still takes the later definition, so nothing needs qualifying until two files collide.

A !rhai block has no file to be named by, so its functions are reached only unqualified.

Expression Calls

    !rhai {
        fn checksum(a, b) { (a + b) & 0xFF }
    }
    db rhai.checksum(0x12, 0x34)   ; emits 0x46

It reads as a namespace on purpose: MODULE rhai is refused as a reserved word, so the prefix cannot collide with anything you have written, and it says where the function came from. rhai("checksum", 0x12, 0x34) is the older spelling and still works.

The result can be a number, or text, which an EQU can hold:

    !rhai {
        fn version() { "1.0" }
        fn banner()  { "console v" + version() }
    }
        org $8000
BANNER  equ rhai.banner()
        dz  BANNER          ; console v1.0, zero-terminated

Assign text first — db rhai.banner() is E1075, since text has no numeric value.

Execution Order

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.

Read-Only Expression Calls

rhai.fn(…) 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 [E1200]: `seven` failed: Runtime error: define_constant() cannot change assembler
state from inside a rhai() expression function — do it in a !rhai block (line 2, position 22)

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.

State (Read)

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.

State (Write)

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.

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.

Language Features

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.

Errors

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