← back to zenas

Zenas programming

This guide is for someone who already knows Z80 assembly and another assembler — pasmo, sjasmplus, z80asm — and wants to understand how zenas fits their hands. It is in three parts: where zenas is just like home, where it differs, and what it lets you do that other assemblers cannot.

For the exhaustive command and directive reference, see the manual. This guide is the orientation; the manual is the dictionary. For a hands-on, build-and-test walkthrough, see the packaged-program tutorial.

Contents

Where zenas is just like home

If you have written Z80 in any mainstream assembler, the core of zenas needs no relearning. It is a two-pass assembler with forward-reference resolution, and the following all behave the way you expect:

zenas assembles real-world operating-system source byte-for-byte identically to pasmo, so for ordinary code the output is not merely equivalent — it is the same bytes, with the same symbol addresses.

Where zenas differs

The differences are deliberate and small in number. None of them change how a plain instruction assembles; they are about the layers around it.

If you do none of these things, zenas is a drop-in for your existing flow.

What zenas lets you do that others cannot

The headline capability is that zenas can execute the code it just assembled, and test it, without leaving the assembler. A conventional assembler's job ends when it has written bytes; verifying those bytes means loading them into a separate emulator, by hand or through a script. zenas folds the emulator in.

Run the code you just assembled

zenas run <file> assembles the source and executes it in-process on the bundled zen80 Z80/Z80N core, then reports the final CPU and memory state. There is no file round-trip and no second program.

zenas run game.asm

Execution starts at the first ORG (or at a named routine with --call), registers start zeroed for reproducibility, and it stops at HALT, at a returning --call routine, or when the instruction cap is hit — the cap (--max-steps, default one million) is the infinite-loop guard.

--trace prints each instruction as it runs; --dump=START:LEN (or --hex) dumps a memory region afterwards; --preload=ADDR,FILE loads a binary into memory before the run, so a routine can operate on real input data.

zenas run sort.asm --call=sort --preload=0xC000,unsorted.bin --dump=0xC000:16 --trace

Assert on machine state

zenas assert runs the code and then checks the final state against expectations, printing PASS/FAIL per check and exiting non-zero if any fail — so it drops straight into CI. The expectation vocabulary covers:

Checks are comma-separated, or given as repeated --expect flags:

zenas assert math.asm --call=multiply --expect="A=0x0C,CF=0"
zenas assert math.asm --call=multiply --expect="A=12" --expect="(0x9000)=12"

This turns "did my routine compute the right answer" into a one-line, scriptable check, with no emulator wiring of your own.

Go-style test files

For a suite rather than a single check, a file named *_test.asm can hold the program under test plus a set of test_* routines, each followed by an .EXPECT directive (and optionally .MATCH for memory spans). zenas assert file_test.asm then discovers and runs every test_* routine, go-test style.

; math_test.asm
    INCLUDE "math.asm"

test_add_basic:
    LD A, 5
    LD B, 10
    CALL add
    RET
    .EXPECT A=15, CF=0

test_add_carry:
    LD A, 0xFF
    LD B, 1
    CALL add
    RET
    .EXPECT A=0, CF=1
zenas assert math_test.asm

.EXPECT and .MATCH are legal only in *_test.asm files — using one elsewhere is an assembly error. Test metadata therefore cannot leak into a production build: the same source that defines your tests cannot accidentally carry them into the shipped binary.

C-style macros

zenas accepts a C-like function syntax that transpiles to traditional macros, selected with .MACRO_STYLE C. Instead of MACRO/ENDMACRO, you write function-shaped blocks with typed signatures and an asm { ... } body:

.MACRO_STYLE C
void main() {
    asm {
        HALT;
    }
}

Functions can be typed — uint8_t, uint16_t, or void — and zenas checks the return width: a uint8_t function must return an 8-bit value, a void function must not return one, and a non-void function that never returns is an error. This is a portable half of a calling convention (the width is checked; where the value lives is the primitive library's concern), and it is checking no plain macro assembler does. It is still sugar over the traditional macro system — the transpiler lowers it before assembly — so the two coexist. See the C-style examples bundled in examples/ (the example1*-cstyle-*.asm files) and the Macros section of the manual.

By default a macro's body is expanded at every call site. .MACRO_MODE SINGLETON instead emits the body once as a routine and turns each call into a CALL (with arguments passed through fixed memory slots) — smaller when a sizeable body is reused. See examples/example15-macro-mode.asm and the manual for the details and its constraints.

Packages and disambiguation

A .PACKAGE directive affiliates the macros that follow with a named package. The point is collision-free libraries: two packages can each define add, and a qualified call math.add(5, 2) selects one unambiguously. A qualified name is also never confused with the ADD mnemonic, so a primitive library can use short names like rotate or add without shadowing instructions for its callers. Unqualified calls still work and resolve bare-unless-ambiguous. This is closer to a namespacing system than the single flat symbol table most assemblers expose.

Build tags as a bitmask

--tag NAME is more structured than a plain define. Each tag:

Because the tags compose, IF conditions can use boolean logic over them:

    IF ZENAS_TAG_debug AND ZENAS_TAG_plus3
        ; debug build for the +3
    ENDIF
zenas assemble game.asm --tag debug --tag plus3

This gives you a single, ordered, testable notion of "which variant is this" rather than a handful of unrelated --defined symbols.

Scoped dialect modes

.pasmo switches the lexer into a pasmo-compatible dialect mid-stream, and .zenas switches back. The switch is scoped, not global: you can .pasmo then INCLUDE a file of pasmo source and have it read correctly, while the rest of your program continues to be read as native zenas. The mechanism is the same one .MACRO_STYLE C uses — a directive flips the lexer until it is turned off again. It is intentionally not an attempt to make the native dialect a superset of pasmo, which would create ambiguities (notably around $).

A worked example

Write a routine, then prove it works without ever opening an emulator:

; add.asm
    ORG 0x8000
add:                 ; A = A + B, sets CF on overflow
    ADD A, B
    RET
# run it once, watching every instruction and the result
zenas run add.asm --call=add --trace

# assert the contract in CI
zenas assert add.asm --call=add --expect="A=0"      # with A=0,B=0 to start

# build a runnable snapshot to try the larger program by hand
zenas build add.asm --z80 --start add --model 48k

The same source assembles to the same bytes pasmo would produce; what zenas adds is everything after the bytes — running them, asserting on them, testing them, and packaging them — in one tool.