Testing

FantASM carries a Z80 and Z80N simulator, so a test runs your actual routines on an emulated CPU at build time and checks what they left behind. No emulator to launch, no harness to write, and nothing about the test reaches the output.

    org $8000
double:
    add a,a
    ret

    !test "double doubles"
        init_reg a, 3
        call double
        halt
        assert_reg a, 6
    endtest
fantasm build main.asm --test

Tests are collected whether or not you pass --test, and run only when you do.

Test Block Syntax

Syntax: !test "<name>" … endtest

!test opens a block that is assembled past the end of the program and run on the simulator. name is a string literal, and is what the report names.

Between the two are two kinds of line. The init_* and assert_* directives are collected from the block rather than executed in place, so where an assert is written makes no difference — before the halt or after it, the result is the same. Everything else is Z80 code, assembled and run.

So the body is: the machine is set up from the init_* lines, the code runs until it halts, and then the assert_* lines are checked against the final state.

The body must halt. The simulator runs until it does, for at most 1,000,000 T-states, and a body still running then is E1104 rather than a build that hangs. That ceiling is low on purpose: most tests exercise a small block of logic, so it is not a constraint but the thing that catches a missing halt at once. A routine that genuinely takes longer is tested by saying how long it may take — see assert_cycles and init_cycle_limit.

A body with no code fails even if its assertions would pass, which is E1139, naming the block. A test that checks the initial state and runs nothing is almost certainly not what you meant.

Test Directives

They fall into two groups, and the Z80 code between them is the third part: arrange with the init_* lines, act by running the body, assert with the assert_* ones. Position on the page makes no difference to any of it — see Test Block Syntax.

Arrange, applied before the body runs:

Directive
init_reg <reg>, <value>Set a register
init_mem <addr>, <value>Set a byte of memory
init_mem <options>Fill a run of memory — see Spans
init_cycle_limit <limit>Allow the body that long to run, checking nothing

Assert, checked against the final state:

Directive
assert_reg <reg>, <value>Check a register
assert_mem <addr>, <value>Check a byte of memory
assert_mem <options>Check a run of memory — see Spans
assert_checksum <options>Check a run’s CRC-32C — see assert_checksum
assert_cycles <limit>Check the run took no more T-states than this, and allow it that long

The prefix is the half a directive belongs to rather than when it is written. assert_cycles raises the ceiling as well as checking it, and init_cycle_limit raises it without checking anything, so the two do overlap — and the init_/assert_ naming is what says which of them makes a claim about the result.

init_reg and assert_reg

Syntax: init_reg <reg>, <value> — assert_reg <reg>, <value>

init_reg sets a register before the body runs, and assert_reg checks one after it halts. reg is a register name and value is an expression.

Both take 8-bit and 16-bit registers alike: a through l, bc, de, hl, ix, iy, sp and af. An indirect such as (hl) is not a register and is refused; use init_mem.

    !test "adds a pair"
        init_reg hl, $1000
        init_reg de, $0234
        call addhl
        halt
        assert_reg hl, $1234
    endtest

init_mem and assert_mem

Syntax: init_mem <addr>, <value> — assert_mem <addr>, <value>

init_mem sets a byte of memory before the body runs, and assert_mem checks one after it halts. addr and value are expressions. Both also take a run of memory rather than a single byte — see Spans.

    !test "copies a byte"
        init_mem $9000, $42
        ld   a,($9000)
        ld   ($9001),a
        halt
        assert_mem $9001, $42
    endtest

assert_cycles and init_cycle_limit

Syntax: assert_cycles <limit> — init_cycle_limit <limit>

Both raise the T-state ceiling the body runs under, and limit is an expression in either. The difference is what they claim about the figure.

assert_cycles keeps an interrupt handler honest: it fails the build when a routine grows past its budget, which is the kind of regression nothing else catches. A block saying assert_cycles 3000000 has stated its budget, so the run goes to that figure rather than stopping at the default. A budget below the default lowers nothing — the body still runs to completion, so a failure reports what it actually cost, expected at most 4, took 35, rather than only that it was too much.

init_cycle_limit only buys the time. Use it where how long the routine takes is not what is under test:

    !test "depacks a screen"
        init_cycle_limit 8000000
        call depack
        halt
        assert_mem $4000, $FF
    endtest

An expression reads as what it is, so a ceiling derived from what the program is doing can be written as one: init_cycle_limit PAGES * 40000.

There is deliberately no project-wide setting. It belongs in the block, where whoever reads that test can see it — a fantasm.toml key would be decided once and then invisible everywhere it applied, making every small test permissive too.

⚠ Raising the ceiling is a statement about the test, not a knob to turn when a build gets slow. A figure set far above anything expected gives up what the ceiling is for: a body that would have failed in a moment instead runs to the new limit, and a genuine infinite loop becomes indistinguishable from slow work.

Spans

Syntax: init_mem <option>=<value> [, <option>=<value>…]

init_mem and assert_mem take a run of memory named by name=value options. Each value is an expression, so length=PAGES * 40 reads as what it is.

Option
addrwhere the run starts, read through the slot mapping
physwhere it starts in the machine’s memory, whatever is paged in
lengthhow many bytes
fillone byte, repeated across it
filea file holding what it should contain
file_offsetwhere in that file the run begins
    !test "clears its buffer"
        init_mem   addr=$9000, length=256, fill=$FF
        call clear
        halt
        assert_mem addr=$9000, length=256, fill=$00
    endtest

A failure gives three figures — how many bytes differ, where the first difference is, and how far the first run of differences goes. The last is what separates one damaged region from scattered corruption, which a count alone cannot:

Error [E1091]: assert_mem: 128 of 256 bytes differ
first at offset 128 (0x9080) for 128 bytes
expected 0x00, got 0xFF

addr and phys

A span starts one way or the other, never both.

addr is a 16-bit address and reads what the CPU could reach at the halt. phys indexes the machine’s memory directly — 256 pages of 8 KB, $000000 to $1FFFFF — so it reaches a bank that is not paged in, and describes a run longer than the address space. Sometimes the mapping is what is under test, which is why both exist.

    !test "fills bank 5"
        call fill_bank_5
        halt
        assert_mem phys=$14000, length=2, fill=$AA   ; 16K bank 5 begins at 5 * 16384
    endtest

⚠ Asserting through the CPU’s view can hide a paging fault. A routine that pages the wrong bank in and then writes the right bytes into it reads back correctly through that same wrong mapping. phys against where the bytes were meant to land cannot be fooled that way.

⚠ A phys span past the end of memory is refused, where an addr span wraps. The address space wraps because a Z80 cannot leave it; a mistyped physical address is a different thing, and wrapping would compare a different region silently.

A Reference File

What a routine produced, against the file it should have produced — which for a converter, a packer or a loader is usually already on disk.

    !test "depacks a screen"
        init_cycle_limit 8000000
        init_mem   addr=$C000, file="packed.bin"
        call depack
        halt
        assert_mem addr=$4000, file="screen.scr"
    endtest

A file says how long the span is, so length is only written to compare part of one. file_offset takes a slice, which is what a non-contiguous output needs — one directive per run, each against its own part of a single reference.

The file is read when the test runs, so it never enters the image. Doing the same thing with incbin puts test data in the shipped program, and then needs a KEEP to stop the unused-label sweep reporting it.

The path resolves as INCLUDE’s does — the source’s own directory first, then -I — and is settled where the directive is read. By the time a test runs there is no including file to resolve against, so a missing reference is E1004 at the line that named it, on any build.

assert_checksum

Syntax: assert_checksum <option>=<value> [, <option>=<value>…]

assert_checksum folds a span into a single CRC-32C and checks it, either against a file’s own or against a figure written in the source. It takes the same options as a span, less fill, plus value.

    !test "the table is unchanged"
        call build_table
        halt
        assert_checksum addr=$9000, length=$400, value=$D9C908EB
    endtest

Where the reference file exists, assert_mem is the better tool: same options, same run, and it says which byte differs where this can only say not that figure. What this adds is the case with no file at all.

The algorithm is CRC-32C, the Castagnoli polynomial — the same one the .nex header carries. It is not the CRC-32 that zlib, crc32 and most command-line tools compute, so a figure cannot be reproduced outside FantASM by reaching for the nearest tool. In practice it comes from the failure message.

⚠ A pinned figure has the golden-value failure. Nobody can verify it by reading it, and the natural way to obtain one is to run the test and paste in what the failure reported — so such a test asserts whatever the code did on the day it was written, and cannot fail the first time. It is still worth having, because it catches a later regression, which is most of what a pinned value is for. Written that way deliberately it is a useful guard; arrived at by accident it proves nothing.

name=value Operands

This is how any directive with optional arguments spells them, rather than a form peculiar to spans. The separator is = rather than a space because a value is an expression: !opt takes one option per line so its value runs to the end, but with several pairs length PAGES * 8192 fill 0 has two readings and nothing to choose between them.

A line uses one shape or the other. The positional forms are unchanged — assert_mem $9000, $22 means what it always did — but mixing them on one line is E1107. An unknown option is E1105, which names the ones it would have taken; one given twice is E1106.

Your Own Routines

Calling the routine under test is the point, and the whole assembled image is loaded before the test runs, so any label is reachable — including one inside a module, by its qualified name:

MODULE Gfx
Double:
    add a,a
    ret
ENDMODULE

    !test "calls into a module"
        init_reg a, 4
        call Gfx.Double
        halt
        assert_reg a, 8
    endtest

The test body is assembled past the end of your program, so it never overlaps the code it is testing.

Machine Model

The simulator models enough for a unit test and no more: a flat 2 MiB store behind an 8-slot MMU, nextreg writes to $50–$57, and the $7FFD paging port. Modelling what the CPU sees across the rest of the machine is Bizmuth’s job.

The MMU registers read back. Selecting $50–$57 at $243B and reading $253B gives the page that slot currently holds, including one $7FFD mapped rather than a nextreg write. That is what lets a routine borrow a slot and put back whatever the caller had there:

    device  zxnext
    org     $8000
    !test "a borrowed slot goes back"
        ld      bc,$243B
        ld      a,$52
        out     (c),a
        inc     b
        in      a,(c)           ; the page slot 2 holds
        ld      e,a             ; keep it
        nextreg $52,60          ; borrow the slot for something else
        ld      a,e
        nextreg $52,a           ; and put the caller's page back
        halt
        assert_reg e, 10
    endtest

⚠ Nothing else in the Next register space is modelled. Reading any other register gives whatever was last written to the port.

⚠ The reset layout is not the machine’s. Slots 0 and 1 hold ordinary writable pages where hardware maps ROM. Low memory being plain RAM is what lets a non-paged !test ignore paging entirely, and there is no ROM here to show instead.

Tests and the Output

A !test block is build-time only. A program holding double and the addhl the tests above call assembles to four bytes — 87 C9 19 C9, being those two routines — however many tests sit beside them.

A Failure Report

A failing test reports the way the build does, so a fault reads the same wherever you meet it:

test double doubles ... FAILED
Error [E1091]: assert_reg A: expected 99, got 6
main.asm:13:20
        assert_reg a, 99
                      ^
test result: 0 passed, 1 failed
Error [E1138]: 1 test failed

The build exits non-zero. The position is the assertion’s own line rather than the !test that opened the block. E1138 carries the position of the first failure, so an editor lands on the block to look at first.

Two faults are the block’s own rather than an assertion’s. E1139 test '…' has an empty body is a block that assembled to nothing — empty, or everything in it failed. E1140 no room for test '…' above the program is a program reaching the top of memory: a test body is assembled past the end of the program so that a routine it calls is the real one, and a full address space leaves nowhere for it.

A fault in the body rather than in what it checks reports the same way, code and hint included, so a misspelling inside a test is as readable as one in the program:

test doubling ... FAILED
Error [E1088]: undefined Rhai function `doubel`
main.asm:6:11
    ld a, rhai.doubel(3)
          ^
help: did you mean `rhai.double`?

None of this needs -v. A passing test is progress and stays behind it; a failing one is the answer --test was asked for.

With several targets, counts are reported against the target the tests came from — see Building Several Images.

Test-Only Routines and the Sweep

A routine called only from a !test is reported unused by -u, and discarded by --gc-modules if it is inside a module: a reference from inside a test block does not count as a use. Mark it GLOBAL, or KEEP it — see Modules for what KEEP does.

Under --gc-modules the build says so rather than leaving you to work it out from a test that will not finish:

Warning [W1084]: `Thing.First` is only referenced from a `!test` block and was
                 removed by `--gc-modules`
help: pin it with `KEEP Thing.First` or `GLOBAL Thing.First`

Pinning it is a statement about the program — this routine must survive — so the image the tests run against stays the image that ships.

← Documentation