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.errorsEvery error the build found. Two undefined names give two entries.
asm.warningsWarnings, 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` writes

save_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:

FileAny WriteWrites
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, and Assembler::write_to.
  • Diagnostics: Diagnostic and ErrorLevel.
  • Linking: fantasm::linker’s link, Input, Asked, Linked and LinkMap, its writers in both forms, and fantasm::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

← Documentation