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

A block is !test "name"endtest. Between them are two kinds of line:

  • init_* and assert_* are directives. They are collected from the block, not executed in place — so where you write an assert 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 — 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 halves, and the Z80 code between them is the third: 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 it belongs to, not 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 take 8-bit and 16-bit registers alikea 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

    !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

Both raise the ceiling. 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

Raising it 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.

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.

Both take an expression, so a ceiling derived from what the program is doing reads as what it is: init_cycle_limit PAGES * 40000.

Spans

init_mem and assert_mem also take a run of memory rather than a single byte, named by name=value options:

    !test "clears its buffer"
        init_mem   addr=$9000, length=256, fill=$FF
        call clear
        halt
        assert_mem addr=$9000, length=256, fill=$00
    endtest
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

Values are expressions, so length=PAGES * 40 reads as what it is.

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.

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:

    !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

Sometimes the mapping is what is under test, which is why both exist.

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

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

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.

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.

name=value Operands

This is how any directive with optional arguments spells them, not a form peculiar to spans. = 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.

What 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
                      ^
0 passed, 1 failed
Error [E1138]: 1 test failed

The build exits non-zero. The position is the assertion’s own line, not 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 has no body to run is a block that assembled to nothing — empty, or everything in it failed. E1140 there is no room to place 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]: `doubel` is not a function in any script loaded above this line
main.asm:6:11
    ld a, rhai.doubel(3)
          ^
Hint: 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 referenced only by a `!test` block and has been
                 discarded by --gc-modules. Pin it with `KEEP Thing.First` or
                 `GLOBAL Thing.First` if the test should run

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