Building Several Images

Some programs are more than one file. A dot command is a small bootstrap that incbins a much larger payload; a game might ship a loader, a main binary and a bank of graphics; a debug variant is the same sources with different defines.

Each of those is a target, and fantasm.toml declares them with [[target]] tables. One fantasm builds them all, in an order that respects what depends on what.

Everything here is additive. A project file with [project] main and no [[target]] describes exactly one target and behaves as it always has.

Declaring them

[assembler]
target = "zxnext"
gc_modules = true

[[target]]
name = "payload"
main = "src/console.asm"
output = "dist/payload.bin"
format = "bin"
intermediate = true

[[target]]
name = "console"
main = "src/boot.asm"          # incbins dist/payload.bin
output = "dist/console"
depends = ["payload"]
gc_modules = false

[assembler] stays project-wide and every target inherits it. A target overrides only what it names — here console turns off the module elimination the project turned on, and both still get target = "zxnext".

Driving a second build from [tasks] pre_build instead means restating the flags this file already holds, and a setting added to [assembler] later reaches one image and not the other with nothing to say so.

Setting the keys

KeyMeaning
nameRequired, and unique. What --only and depends refer to.
mainThis target’s entry-point source.
outputWhere it is written. Defaults to the source’s filename with this target’s format extension.
dependsTargets that must be built first.
defaultfalse leaves this target out unless it is asked for.
intermediatetrue marks an image that is built but never shipped.
[target.artifacts]This target’s own labels and sld paths.
[target.tasks]This target’s own pre_build and post_build.
any [assembler] keyWritten directly under [[target]], for this target alone.

name supplies __NAME__ in place of [project] name. That define is text your program can assemble, so renaming a target changes the bytes it produces — not merely a label on the build.

intermediate is stored and never acted on. FantASM builds the target either way. It is there so whatever deploys your build reads one declaration instead of keeping its own list of what not to ship.

Building them

fantasm build                  # every default target, in dependency order
fantasm --only payload         # payload alone
fantasm --only console         # console, and payload first
fantasm --only a --only b      # both, plus whatever they depend on
fantasm --all                  # including targets marked default = false

--only, not --target. -t/--target has named the machine — zx48, zxnext — since long before a project could declare more than one image, and it keeps that meaning.

A target that is depended on is always pulled in, whether or not it is default. --only console therefore builds payload first even if nothing else would have.

Ordering the build

Targets build in an order where each follows what it depends on. Those needing no ordering keep the order the file reads.

Writing the tables in the right order often works without depends. The constraint then lives in the file’s layout, and reordering two tables produces this:

Error [E1004]: File not found: dist/payload.bin
src/boot.asm:3:10
  incbin "dist/payload.bin"

That names the symptom. Declare the dependency and FantASM names the cause instead:

Error [E1078]: dist/payload.bin not found. It is the output of target `payload`,
which `console` does not list in `depends`

A depends loop is refused before anything is assembled, naming every target in it:

Error [E1077]: Targets depend on each other in a cycle: payload, console

When something fails

A failure skips that target’s dependents, and nothing else.

payload: failed
console: skipped (depends on payload, which did not build)
sprites: ok

1 built, 1 failed, 1 skipped

One rule covers an assembly error, a failing --test and a failing recipe alike. A target that does not depend on the one that failed still builds, so one run tells you everything that is wrong rather than only the first thing.

Every skipped target is named with its reason. Output that is silently absent sends you to dist/ to work out what happened; a line saying which dependency failed does not.

The exit status is non-zero if anything failed or was skipped.

Excluding a variant

[[target]]
name = "console"
main = "src/boot.asm"
output = "dist/console"

[[target]]
name = "console-debug"
main = "src/boot.asm"
output = "dist/console-dbg"
default = false

fantasm build builds console. fantasm --only console-debug builds the variant, and fantasm --all builds both.

[defines] is project-wide — a target cannot have its own. A variant is differentiated by its main, by any [assembler] key it overrides, and by its own [target.tasks], but not by constants. A debug build wanting DEBUG = 1 needs a second entry-point source that sets it and includes the real one:

; src/boot-debug.asm
DEBUG   equ 1
        include "boot.asm"

Per-target name is the exception, since it supplies __NAME__.

Two targets may not write the same file. One would silently overwrite the other, and which won would depend on the build order, so the same project file could put different bytes on disk for reasons nothing reports. This is refused before anything is assembled. It covers each target’s output and any labels/sld it names in its own [target.artifacts]; a project-wide [artifacts] shared by every target is the ordinary case and is fine.

Two targets sharing a main and naming no output both infer the same filename, which trips the same check by a different route.

Per-target recipes

[tasks] runs once around the whole build. [target.tasks] runs around one target:

[[target]]
name = "payload"
main = "src/console.asm"

  [target.tasks]
  pre_build = ["png2scr assets/title.png src/title.inc"]

The case is a generated source: the converter should run before the image that includes its output, and not before the others.

A recipe that fails takes its target down like any other failure — dependents are skipped rather than fed a source that was never generated, and that target’s own post_build does not run.

Running tests

--test runs each target’s tests in its own turn, before anything depending on it is built, and reports the counts against the target rather than adding them together:

payload:  12 passed, 2 FAILED
loader:   no tests
sprites:  31 passed

A target with no !test blocks says so rather than being left out. An absence reads as the tests did not run, which is what a !test block that stopped being assembled leaves behind.

Knowing where this stops

The graph is enumerated, not discovered. There are no pattern rules, no variable language, and no interpolation — output = "dist/{name}.bin" is not substituted. Recipes do not declare what they produce, so nothing is ordered on their outputs.

The model is ninja’s explicit graph. If you need make, run make; it calls fantasm perfectly well.

Migrating from a pre_build

Before:

[project]
main = "src/boot.asm"
output = "dist/console"

[assembler]
target = "zxnext"
gc_modules = true
include_dirs = ["src"]

[tasks]
pre_build = ["fantasm build src/console.asm dist/payload.bin -t zxnext --gc-modules -I src"]

After:

[assembler]
target = "zxnext"
gc_modules = true
include_dirs = ["src"]

[[target]]
name = "payload"
main = "src/console.asm"
output = "dist/payload.bin"

[[target]]
name = "console"
main = "src/boot.asm"
output = "dist/console"
depends = ["payload"]

Both images now inherit one [assembler], the ordering is recorded rather than implied, and forgetting it is a diagnostic rather than a missing file.

Documentation