Using It as a Library
FantASM is a Rust library as well as a command. A program can assemble in-process and get the bytes and the diagnostics back as values, without shelling out and parsing text.
This page is for Rust developers embedding the assembler. Everything else in this documentation is for people writing Z80.
Dependency
FantASM is not on crates.io. publish = false, it being distributed as signed binaries, so depend on it by path or by git:
[dependencies]
fantasm = { git = "https://codeberg.org/TwistedRaven/fantasm" }There is no semantic-version promise across those. The crate is versioned for its releases, not for its API.
Default features bring in a lot. nex, sna, lsp and update-check are all on by default, and lsp pulls in tower-lsp and an async runtime. If you only want to assemble:
fantasm = { git = "…", default-features = false }Assemble Call
Syntax: asm.assemble_from_str(<source>, <name>) — asm.assemble(<path>)
assemble_from_str assembles source under the file name name, and assemble does the same from a file, resolving include and incbin against the include path. The bytes are on the emitter afterwards.
use fantasm::assembler::{Assembler, AssemblerOptions};
let mut asm = Assembler::new();
asm.enable_console(false);
asm.device("zxnext"); // implies Z80N; enable_z80n(true) for it on another machine
let outcome = asm.assemble_from_str(" org $8000\nmain:\n ld a,1\n ret\n", "demo.asm");
let bytes = asm.emitter.bank.as_slice().to_vec();The machine is device since 2.0, on this surface as well as the command line: Assembler::device() in place of Assembler::target(), and Options::device in place of Options::target. target still means an image the project builds — target_name, Options::targets — so the two are no longer one word.
verbosity(level) sets how much is printed: 0 nothing, 1 the progress log, 2 what the build decided. enable_console(bool) is unchanged and still means one level, which is what it always meant.
⚠ Call enable_console(false) first. Without it the assembler prints progress and messages to stdout, which is fatal for anything speaking a protocol there — a language server, or a tool whose output is piped.
Diagnostics
This is the part that catches people. The Result carries one error; the collected set lives on the assembler.
let _ = asm.assemble_from_str(source, "demo.asm"); // both consumers ignore this
for e in &asm.errors { /* … */ }
for w in &asm.warnings { /* … */ }asm.errors | Every error the build found. Two undefined names give two entries. |
asm.warnings | Warnings, and they accumulate. |
The Result’s single error is the first one; it is there so ? works, not because it is all there was.
W1049 unused label needs asking for. display_unused() is the library equivalent of --find-unused, and it appends to warnings, so call it before you collect or the borrow checker will say so for you.
Each Diagnostic carries level, error_code, message, hint, location (file, line, column) and related_locations. Nothing needs parsing out of a string — see Diagnostics for what the codes mean.
⚠ warnings is not only warnings. !message output (Message, 1056) is in the same list, and so is the shebang notice (Info, 1057). Filter on level, never on the code:
use fantasm::assembler::ErrorLevel;
// `display_unused` takes &mut, so ask for the unused-label sweep before borrowing the list.
asm.display_unused();
let real: Vec<_> = asm.warnings.iter()
.filter(|w| matches!(w.level, ErrorLevel::Warning | ErrorLevel::Error))
.collect();Test Results
Syntax: asm.execute_tests()
execute_tests runs the !test blocks and answers with one error counting the failures, so the same rule applies: the detail is on the assembler.
let _ = asm.execute_tests();
asm.test_counts.passed; // and .failed
for f in &asm.test_failures { /* an ordinary Diagnostic */ }A failing assertion is E1091, positioned at the assertion’s own line. Anything else that went wrong while the body assembled keeps the code and hint it would have had in the program — see Testing.
Debug Information
Syntax: asm.sld_file(<path>) — asm.save_sld()
Source-to-address records are on asm.run.sld_records as values, so you need not read the file back. sld_file nominates where a file would go, and save_sld writes it.
Nominating a file does not write it. The library writes a .sld when save_sld() is called and not before, so a caller that only wants the records in memory need not clean a file up, and one that wants the file has to ask:
let scratch = std::env::temp_dir().join("mine.sld");
asm.sld_file(scratch.to_string_lossy().as_ref());
// … assemble …
let traces: Vec<_> = asm.run.sld_records.iter().filter(|r| r.record_type == "T").collect();
asm.save_sld()?; // only now does the file exist
let _ = std::fs::remove_file(&scratch);⚠ The records are only populated if you nominate a file. sld_file() is what turns recording on; without it sld_records is empty.
record_type is an enum — Trace, Comment, and the rest — rather than the letter the file carries. A !assert reaches the records as a Comment, carrying its text verbatim, and the file column is whatever name the source was assembled under: a compiler assembling generated text gets its own generated file name there, which is not a file a debugger can open.
T records map a source line to an address; L records are symbols. See Debugging a Build for the format.
Assembled Output
Syntax: asm.write_to(<format>, <out>)
write_to writes the assembled program to out in format, in the bytes the matching save_* call puts in its file. format is an OutputFormat, and out is any std::io::Write. OutputFormat::Fxi is E1215, fxi being a link format, and a format the build was compiled without is refused the way save refuses it.
use fantasm::options::OutputFormat;
let mut asm = Assembler::new();
asm.enable_console(false);
asm.output_format(OutputFormat::Object);
asm.assemble_from_str(" SECTION code, region=\"crt0\"\n GLOBAL double\ndouble:\n add a,a\n ret\n", "double.asm")?;
let mut object = Vec::new();
asm.write_to(OutputFormat::Object, &mut object)?; // the bytes `save_object` writessave_raw, save_object, save_intel_hex, save_sna, save_nex and nex_bytes remain, each a file or a buffer around the same call.
Linking
Syntax: fantasm::linker::link(<inputs>, <asked>)
link joins objects and archives in memory, as fantasm link does, and returns a Linked. inputs is a slice of Input { name, bytes }, one per .o or .fzlib, name being what a diagnostic calls it. asked is an Asked: the entry name, whether to collect, the machine, and an optional link map. Asked::default() asks for none of them.
use fantasm::linker::{Asked, LinkMap, link, write_to};
use fantasm::linker::merge::Input;
let map = LinkMap::parse("REGION crt0, run=$6400", "dot.map")?;
let asked = Asked { entry: Some("double".to_string()), map: Some(map), ..Asked::default() };
let linked = link(&[Input { name: "double.o".to_string(), bytes: object }], &asked)?;
let mut image = Vec::new();
write_to(&linked, OutputFormat::Fxi, &mut image, linked.address_of("double"), None)?;The image is 31 bytes, its header naming $6400 as both entry and region address, followed by 87 C9 and the end marker.
LinkMap::parse(text, origin) reads a link map from a string, naming it origin in a diagnostic. LinkMap::load(path) reads one from a file.
Each writer has a file form and a Write form, and the two produce the same bytes:
| File | Any Write | Writes |
|---|---|---|
write(linked, format, path, pc, sp) | write_to(linked, format, out, pc, sp) | the image. pc is the entry point, as fantasm link -e passes it: it overrides !nex pc, and an !fxi entry overrides it. sp overrides !nex sp |
write_sld(linked, path, image, format) | write_sld_to(linked, out, image, format) | the SLD, describing image |
write_labels(linked, path, image) | write_labels_to(linked, out, path, image) | the labels file. The header names image relative to path, and its digest is the file at image where one exists |
fantasm::linker::tests writes the test description file fantasm link --tests does — see Testing. tests_json(linked, image, ready) returns it as a serde_json::Value, and write_tests(linked, path, image, ready) writes it. ready_of(linked, label, limit) answers the ready point a --tests-ready and --tests-ready-limit would give, None naming none, and is E1227 where the label is not placed.
Archives
fantasm::fzobj is the object and archive crate the linker reads with, re-exported so a program depending on fantasm alone builds a .fzlib. Archive::new(), add(name, bytes, modified) and to_bytes() build one, in the bytes fantasm ar writes. fzobj::dump(bytes) returns what fantasm dump prints.
let mut archive = fantasm::fzobj::Archive::new();
archive.add("double.o", &object, 0);
std::fs::write("libdouble.fzlib", archive.to_bytes())?;Tokeniser
fantasm::assembler::tokens::{Token, TokenReader} is the lexer the assembler uses, and can be run without assembling anything — which is how an editor gets highlighting that agrees with the assembler rather than approximating it with regular expressions.
Public Surface
What embedding takes:
- Assembling:
Assembler,AssemblerOptions,OutputFormat, andAssembler::write_to. - Diagnostics:
DiagnosticandErrorLevel. - Linking:
fantasm::linker’slink,Input,Asked,LinkedandLinkMap, its writers in both forms, andfantasm::linker::tests. - Archives:
fantasm::fzobj. - Highlighting:
TokenReader.
Reaching further in is possible, since much of the crate is pub, but nothing outside those promises to stay put.
Further Reading
- Diagnostics — the codes and levels a
Diagnosticcarries. - Debugging a Build — what the SLD records mean.
- Building in Pieces — objects, sections and what
fantasm linkdoes with them. - Output Formats — what each format holds,
fxiincluded. - The Language Server — the other way to drive FantASM from a tool, over a protocol rather than in-process.