Debugging a Build
FantASM writes two files a debugger can use: --sld, which maps source lines to addresses, and -e, a plain list of exported symbols. Neither changes the image; both are written alongside it.
With the SLD file you get breakpoints on source lines and a call stack naming your labels. Without it, a debugger has addresses and nothing else.
Flags
fantasm build main.asm game.nex -d zxnext --sld game.sld -e game.labelsor, so nobody has to remember the flags:
[artifacts]
sld = "dist/game.sld"
labels = "dist/game.labels"Both paths in fantasm.toml resolve against the directory holding it. With several images, each [[target]] can name its own — see Building Several Images.
--sld
Syntax: fantasm build <source> <output> --sld <file>
--sld writes sjasmplus-style SLD v1, a text format with one record per line. file is where to write it; sld under [artifacts] is the same setting.
|SLD.data.version|1
t.asm|1||0|-1|-1|Z|pages.size:8192,pages.count:224,slots.count:8,slots.adr:0,8192,…
t.asm|15||0|4|32768|T|
t.asm|14||0|4|32768|L|,main,
t.asm|3||0|20|32777|L|Lib,First,,+used
t.asm|6||0|-1|16384|L|,SCREEN,,+equZdescribes the machine’s paging, so a debugger knows the slot layout.Tmaps a source line to an address. Breakpoints and the stack are built from these.Lis a symbol: module, name, and traits —+equfor a constant,+moduleand+endmodfor boundaries,+usedfor something referenced.Kis an annotation you wrote — see Debugger Annotations.
Records are page-aware: each carries a physical page as well as the Z80 address, so a debugger can tell two banked routines at the same address apart.
Page -1 means no page. A constant has one because it has no address at all — that is the SCREEN record above. A label, trace or annotation record has one where the source marked the region with ?, saying the OS decides where it runs: !virtual <addr>, ? for a block copied at run time, page ?, <slot> for ordinary code at an org. See Memory and Banking.
A debugger arms a -1 record by address alone, ignoring which page is mapped — which is the point of the mark, a breakpoint on a physical page never firing for code the OS put somewhere else.
Debugger Annotations
Syntax: !assert <condition> [, "<text>"] — !debug "<text>" — !watch <register> <comparison> <value>
!assert, !debug and !watch put a claim about the running program into the SLD for the debugger to act on. None of them emits anything: the image is byte-for-byte what it would be without them.
ld hl, 4
!assert hl==4,"HL survived the copy"
!debug "entered draw_row"
!watch sp < $7F00!assert is for something that should be true, and a debugger acts when it is not. !debug has nothing to test and acts every time the address is reached. Both sit at an address and act there.
The ! is optional, as on every directive — assert and !assert are the same thing. The record always names the canonical spelling, so nothing reading the file has to strip one.
FantASM does not read the text. Everything after the directive crosses to the file as written, and what a condition means is the debugger’s to decide, so its vocabulary can grow without a FantASM release. The price is that a mistake in one is not caught at assembly time; whatever reads the file is what finds it.
The operators are the same as an expression’s, tabled in Writing Source. They were not: ==, !=, <=, >=, &&, || and ! could be written here and nowhere else, so the same comparison was spelt two ways depending on where it sat. fantasm --surface now publishes one operator class in place of two, which is what an editor builds its highlighting from.
A trailing ; comment is not part of the annotation and does not cross. A ; inside quotes is just a character.
They do nothing without --sld, or an object build. Anywhere else there is nowhere to record them, and the build says so once (W1120). An object carries them for the link to write — see Building in Pieces — whether or not --sld was named when it was assembled.
Inside a MACRO you get one record per expansion, at each address it reached, every one naming the invocation rather than the body line — that is the position FantASM reports for anything inside a macro. The body line is legible from the text the record carries.
⚠ A condition can contain |, as hl==4 || de==0 does, and the SLD’s own fields are |-separated. The annotation is the last field, so split on the first seven separators and take the rest whole.
!watch
Syntax: !watch <register> <comparison> <value>
!watch is the one annotation that is not about an address. A debugger arms it for the whole run and breaks wherever in execution the comparison first holds. register is a register name, comparison an operator and value an expression.
What it is for is a stack that runs away. LD SP,HL and ADD SP,n move SP without writing memory, and a PUSH writes wherever SP happens to be, so neither a breakpoint on an address nor a watch on a memory location catches the moment it goes wrong.
The operand is a register against a value: that is what the directive means and what a debugger implements. The assembler does not check it, for the reason above, and a condition the debugger cannot evaluate is the debugger’s to report.
⚠ The recorded address is a marker. Every record carries a page and an address because the format has fields for them; a watchpoint is bound to neither. Nothing arms when execution reaches the line and nothing disarms, so there is no !unwatch and no region form — put one anywhere and it covers the run.
-e
Syntax: fantasm build <source> <output> -e <file>
-e writes a plain list of exported symbols. file is where to write it; labels under [artifacts] is the same setting.
; fantasm-labels 1
; source src/main.asm
; image game.nex
; blake3 5b08e4b074a6af0bfea6f2af599d9735858cd146eeb92a05d640bacffb65157d
; built 2026-09-17T16:58:34Z
main = 0x8000
Lib.First = 0x8009A constant holding text is written as NAME = "text", and module labels are written with their qualified name. This is for a loader or a script that needs one address rather than for source-level debugging — the SLD file is what a debugger reads. See Modules for what GLOBAL does and does not do.
⚠ Only GLOBAL symbols appear. The file is empty until something is exported, which is working correctly rather than failing.
A Labels File as Source
NAME = 0xADDR is FantASM’s own constant syntax, so a labels file is includable source. That is the cheapest way to call into something already in memory at fixed addresses — a resident, or a kernel a dot command calls:
org $9000
INCLUDE "kernel.labels"
call entryThe header is comment lines for exactly that reason: a constant there would define a name in the including build’s namespace, and a name is what the file is for. The first line is the marker, and a file without one is an ordinary .inc of addresses.
Including one checks it. The header records which image the addresses came from and what that image hashed to; a build whose copy hashes to something else gets W1161. Every address in a labels file is baked, so without the check a stale one resolves every name to where the code used to be — nothing undefined, nothing warned, and the calls go wherever the old addresses point.
It is a warning because a rebuild that moved nothing still changes a digest. [lint] is where a project that wants it fatal says so:
[lint]
w1161 = "deny"The image is looked for beside the labels file, that being what the header records. One published without its image is the ordinary way to ship an interface, and answers I1162 once rather than passing in silence.
This is not a step towards linking. The addresses are baked, so nothing may move, nothing is collected across the boundary, and a name absent from the file is undefined rather than unresolved. Building in Pieces is the shape that does move.
⚠ A file with no marker is not checked. The first line is what says this is a labels file, so a hand-written .inc of addresses raises nothing.
Bizmuth
The 0x1DE extension (The Language Server) drives the whole cycle from the editor. Its Assemble task runs
fantasm build <source> <source>.nex -t <target> --sld <source>.sldso the debug info is always beside the image and always current. That -t is the old spelling, so every build the extension drives now says W1103 once until 0x1DE moves to -d. Run on Bizmuth then launches the emulator and attaches.
A launch configuration takes program, host, port and stopOnEntry; attaching to an already-running Bizmuth takes host and port. The defaults are 127.0.0.1 and port 11001.
The SLD file is found from the program name — game.nex looks for game.sld — or you can name one explicitly. Without it the session still runs, but at address level only: stack frames show $PC rather than a source line, and breakpoints do not verify.
Limitations
A breakpoint inside a MODULE used to be broken and is not. Trace records for a module body carried the address the body was read at rather than where it was placed, so a breakpoint on one resolved into ROM and never hit. The decide phase now settles every record against where the body landed, page included, so a breakpoint in a module works like any other. -vv reports the same placement if you want to see it from the build side.
A !test block is not debuggable. Tests run inside the assembler on its own simulator (Testing) and never reach the image, so there is nothing for an emulator to attach to. -v and assert_cycles are the tools there. !assert is the other way round: it never runs during a build, and only something reading the SLD can act on it.
Further Reading
- The Language Server — the editor side, and the rest of what 0x1DE provides.
- Testing — catching it at build time instead, which needs no debugger at all.
- Modules —
GLOBAL, and what ends up in the labels file.