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
endtestfantasm build main.asm --testTests 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_*andassert_*are directives. They are collected from the block, not executed in place — so where you write an assert makes no difference. Before thehaltor 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 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
!test "copies a byte"
init_mem $9000, $42
ld a,($9000)
ld ($9001),a
halt
assert_mem $9001, $42
endtestassert_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 | |
|---|---|
addr | where the run starts, read through the slot mapping |
phys | where it starts in the machine’s memory, whatever is paged in |
length | how many bytes |
fill | one byte, repeated across it |
file | a file holding what it should contain |
file_offset | where 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 0xFFaddr 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
endtestSometimes 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"
endtestA 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
endtestWhere 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
endtestThe 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 failedThe 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 runPinning it is a statement about the program — this routine must survive — so the image the tests run against stays the image that ships.