Skip to content

Directives reference

Quick reference for every assembler directive a816 understands. Each section lists the syntax, what it emits (or doesn't), and a minimal example.

Layout

*= — code position

Sets the logical address the next emitted byte targets. Drives where the bytes land in the ROM image.

*= 0x008000
    sei
    clc
    xce

@= — reloc address

Sets the runtime address symbols resolve against, independent of where the bytes are physically placed. Useful when code is copied to RAM at runtime: emit at the ROM position with *=, but compute jumps and label addresses for the RAM target with @=.

*= 0x00C000   ; bytes go into ROM at C000
@= 0x7E2000   ; but symbols resolve as if running from WRAM 7E:2000
ram_routine:
    lda.w some_var
    rts

Write-overlap detection

When two *= regions (or one *= block plus a .alloc placement, etc.) produce byte spans that share addresses, the assembler emits a diagnostic so a routine that silently grew past its expected end is caught early. Default mode is error: the build fails on the first overlap, naming both source locations. Override with --overlap-mode warn (logged, build continues) or --overlap-mode off (silent) on the CLI, or via Program(overlap_mode=...) from the Python API.

WARNING write at $008004..$00800b overlaps previous write at
        $008000..$008009 ($008004..$008009 would be silently overwritten)

.map — memory map

Selects the cartridge address mapping. Affects how *= translates into a physical ROM offset.

.map low_rom
.map low_rom_2
.map high_rom

Symbols

name = expr — constant

Defines a constant. Evaluated lazily; can reference externs (resolved at link time).

MAX_HP   = 0xFF
font_ptr = target + 0x40   ; target may be `.extern`

name := expr — assign

Same shape as = but the resolver treats the binding as mutable during a build (rebinds allowed). Prefer = unless you need this.

.label NAME = ADDR

Names a constant address as a label without moving the position counter and without emitting any bytes. Use it for original-ROM stubs, WRAM scratch slots, hardware register aliases — anything you want crash traces, the disassembler, and the LSP to symbolicate by name.

"""Bank-2 hardware Mult8 entry. Input $26 * $28 → $2A. RTL."""
.label mult8_far = 0x02855C

"""WRAM byte at $7E:1BAE — field-menu HDMA channel-5 enable shadow."""
.label field_menu_hdma_enable = 0x1BAE

Differences vs name = expr:

Property .label = (constant)
Position counter untouched untouched
Emits bytes no no
.adbg LABEL record yes no
lookup_label(addr) resolves yes no
Cross-module via .extern yes yes
Documentable (fluff) yes (docstring above) no

The RHS must evaluate to an int at the current resolution pass — external references are not allowed (use .extern for that).

.extern name

Declares a symbol defined in another module. Required for cross-module references. Sub-symbols (name.sub) need their own .extern. See Modules.

.extern external_func
.extern messages_vwf
.extern messages_vwf.init_commands_list

.struct Name { ... }

Layout-only declaration; emits no bytes. Field names export as Name.field byte offsets plus Name.__size. Primitive field types: byte, word, long (24-bit), dword (32-bit). A field type can also be the name of another previously declared struct, in which case the nested layout flattens into dotted offsets (Outer.pos.x, Outer.pos.y).

.struct OAM {
    word x
    byte y
    byte tile
    byte attr
}

.struct Inner {
    word x
    word y
}
.struct Outer {
    byte tag
    Inner pos
    byte flags
}

; Bit fields — `uN` (any positive N) declares an N-bit field that
; packs into the surrounding byte run. Mixing with byte/word/long
; flushes the current byte before the primitive lands.
.struct INIDISP {
    u4 brightness
    u3 unused
    u1 force_blank
}
; → INIDISP.force_blank       = 0     (byte offset)
;   INIDISP.force_blank.mask  = 0x80  (pre-shifted)
;   INIDISP.force_blank.shift = 7     (LSB position)
;   INIDISP.__size            = 1
; → Outer.tag = 0, Outer.pos = 1, Outer.pos.x = 1, Outer.pos.y = 3,
;   Outer.flags = 5, Outer.__size = 6

Typed access: as casts and := binds

A (expr as T) cast tags an address with a struct type so a postfix .field resolves through the struct's layout. The two forms share one mechanism:

; Inline cast, single use.
    lda.w (0x2100 as PPU).OAMADDR    ; → lda.w $2102

; Typed bind, reusable across many accesses.
p := (0x7e0000 as OAM)
    lda.l p.x                         ; → lda.l $7e0000
    lda.l p.y                         ; → lda.l $7e0002

; Bare form (no parens) also works for `:=`.
q := 0x010000 as Pt

p := (...) eager-expands one constant per (possibly nested) field of T, so p.field is just a flat symbol after the bind. Nested struct fields chain cleanly: (o as Outer).pos.y and o.pos.y both resolve to base + Outer.pos + Inner.y.

Auto-sized opcodes on typed accesses

When a typed instance is referenced directly as an operand (lda p.field), the assembler picks the addressing mode (lda / lda.w / lda.l) from the binding's base bank — no operand-string guessing involved. The mapping is:

Base value Addressing mode
< 0x100 direct page (lda)
< 0x10000 absolute (lda.w)
otherwise long (lda.l)

An explicit .b / .w / .l on the opcode always wins. Compound operands (p.field + 1, raw addresses, casts) keep using the existing operand-string heuristic.

If the field's declared width disagrees with the current REP/SEP register width (e.g. lda p.word_field while .a8 is in effect), the assembler emits a warning suggesting the rep / sep flip the user probably wants.

Lint hooks:

  • S001 — cast targets a struct type the file never declared.
  • S003(p as T).field when p is already bound as T.
  • S004 — same (expr as T) repeated more than once; promote to :=.

.a8 / .a16 / .i8 / .i16 — register width

Tell the assembler whether the accumulator (A) and index (X/Y) registers are currently 8-bit or 16-bit. Width drives immediate-mode opcode sizing: under .a16, lda #0x42 emits A9 42 00 (3 bytes); under .a8, the same line emits A9 42 (2 bytes).

.a16
.i16
lda #0x1234       ; A9 34 12
ldx #0x5678       ; A2 78 56

Inference from rep / sep

rep #N and sep #N mutate the CPU's M / X flags at runtime; the assembler mirrors that at assembly time so source doesn't have to repeat itself:

rep #0x30         ; clears M+X -> A and X are 16-bit
lda #0x42         ; A9 42 00  (widened because M=16, not because of value)
sep #0x20         ; sets M    -> A back to 8-bit
lda #0x42         ; A9 42

Bit 0x20 controls A, bit 0x10 controls X/Y. rep clears (16-bit), sep sets (8-bit). The inference only fires for constant immediate operands; symbolic constants resolved at assembly time count, but forward references and non-immediate forms are left alone (and explicit .a* / .i* always wins).

Code

.scope name { ... } and { ... }

Named scopes export labels as name.label. Anonymous { ... } blocks keep labels strictly local; nothing inside leaks to the parent.

Inside any scope, names starting with _ are LOCAL (private to the module); other names are GLOBAL.

.scope vwf {
    init:
        rts
    _private_helper:
        rts
}

; usable from outside
    jsr.l vwf.init

.macro name(args) { ... }

Parameterised expansion. Arguments are textual at expansion time; docstring as first body statement attaches to the macro.

.macro store_byte_at(addr, val) {
    """Stash a byte at `addr`."""
    lda.b #val
    sta.l addr
}

store_byte_at(0x2100, 0x80)

.if expr { ... } else { ... }

Static conditional. The expression is evaluated at assembly time; the unselected branch isn't emitted.

.if FEATURE_A == 1 {
    jsr.l feature_a_init
} else {
    jsr.l feature_a_stub
}

.for var := lo, hi { ... }

Compile-time loop. Body is expanded once per integer in [lo, hi]. var is a binding visible inside the body.

.for i := 0, 7 {
    lda.b #i
    sta.l 0x2100 + i
}

Data

.db / .dw / .dl / .dd

Emit raw bytes / words / 24-bit longs / 32-bit dwords.

.db 0x16, 0x20, 0x17, 0x20
.dw 0x2000, 0x2500
.dl 0x010000

.text "..." and .table "path"

Encodes a string using the active character map. Set the map per scope with .table. Strings expand ${VAR} references against defined symbols.

.table "text/menus.tbl"
    .text "Hello"
    .text "Score: ${PLAYER_SCORE}"

.ascii "..."

Emits the literal bytes of a string with no character-map translation.

.incbin "data.bin"

Includes a binary file verbatim. Defines the named label and <label>__size with the byte count.

assets_intro_map:
.incbin "assets/intro.map"
; symbols emitted: assets_intro_map, assets_intro_map__size

.include "file.s"

Lexically inlines the file at this position. Symbols defined inside join the current scope. Use .import for module-style separation.

.include_ips "patch.ips"

Replays the records of an existing IPS patch into the current build.

Modules

See the dedicated Modules page for .import / .extern semantics, the build workflow, and prelude usage.

Freespace pools

See the dedicated Freespace pools page for the full reference. Quick form:

.pool NAME { ... }

Declares a named freespace pool with one or more ranges, optional fill byte, and allocation strategy (pack | order).

.alloc NAME in POOL { body }

Reserves space for body in the named pool; the allocator picks the address and binds NAME there.

.alloc [NAME] at ADDR [size N] { body }

Pinned placement: body lands at the literal ADDR. NAME is optional (3-byte hijacks shouldn't tax with names); the assembler auto-generates a stable identifier for anonymous allocs.

size N upper-bounds the body. Overflow past ADDR + N - 1 is a hard error pointing at the byte that no longer fits. Omit size for an unbounded body (stops at the bank boundary, matching the legacy *= shape).

.alloc vector_table at 0x00FFE0 size 0x20 {
    .dw 0, 0
    .dw brk_handler, brk_handler, brk_handler, nmi_handler
    .dw 0, brk_handler
    .dw 0, 0
    .dw brk_handler, 0, brk_handler, 0
    .dw reset, brk_handler
}

.alloc at 0x07FFFF size 0x01 {
    .db 0  ; pad ROM to 256KB
}

Overlap with any other pinned region (legacy *= included) trips the overlap auditor with both locations named.

.relocate SYMBOL OLD_START OLD_END into POOL { body }

Moves SYMBOL from [OLD_START, OLD_END] into the pool — old range is reclaimed before the new body is placed.

.reclaim POOL START END

Adds [START, END] to the named pool. Escape hatch for slack with no original label.

Comments and docstrings

; line comment
/* block comment
   spans lines */

"""one-line docstring"""

"""
multi-line docstring
attached to the next public target
"""
my_label:

Docstrings attach to modules, scopes, macros, and labels. See Fluff (lint + format) for the placement rules.