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

@= also works inside an .alloc body: the alloc places the bytes, and labels after the @= count from its address. Bytes before it still count in the body's size.

.alloc room_script in rom_free {
    @= 0x7F0000   ; the script runs from WRAM 7F:0000
script_start:
    .dw script_end - script_start
script_end:
}

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 (E0408): the build fails, naming both blocks the way the source names them, with their sizes, addresses, file:line and the bytes they share. 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.

The check runs on the final link (a816 build, object + link), across every module's sections, so two modules pinning bytes at the same spot fail the build too. On error no output file is written.

linker error[E0408]: `second` in pool `q` overlaps `first` in pool `p`
  overlap: `second` in pool `q` (2 bytes from $40:8000) at main.s:11 x `first` in pool `p` (4 bytes from $40:8000) at main.s:8
  shared: 2 bytes from $40:8000
  hint: two pools hand out the same bytes; keep their ranges apart

.map: memory map

Declares one bus region: which banks and addresses it covers, and where those bytes sit in the ROM file.

.map identifier=N bank_range=LO, HI addr_range=LO, HI mask=M [mirror_bank_range=LO, HI] [writable=1]
Attribute Meaning
identifier region number; the same N as [map.N] in a816.toml
bank_range first and last bank of the region
addr_range the window the region serves inside each bank
mask bytes of ROM per bank: an address lands at (bank - first bank) * mask + (address & ~mask & 0xFFFF) in the file
mirror_bank_range banks that show the same bytes ($80-$FF mirroring $00-$7F)
writable 1 for RAM: the region has addresses but no file offset

Values are number literals. LoROM and HiROM ROM regions, and WRAM:

.map identifier=1 bank_range=0x00, 0x7d addr_range=0x8000, 0xffff mask=0x8000 mirror_bank_range=0x80, 0xff
.map identifier=3 bank_range=0x7e, 0x7f addr_range=0x0000, 0xffff mask=0x10000 writable=1

.alloc reset at 0x008000 {
    sei              ; file offset 0x000000
}
.alloc bank1 at 0x018000 {
    rts              ; file offset 0x008000: one 0x8000 bank further
}
.map identifier=1 bank_range=0xc0, 0xff addr_range=0x0000, 0xffff mask=0x10000 mirror_bank_range=0x40, 0x7f

Where a .map applies:

  • It changes the bus of the translation unit that declares it, from that line on. Without any region the bus follows -m (LoROM when -m is absent); declaring one replaces that default, so declare every region the code uses.
  • .import brings the imported module's regions along, so a module can rely on the maps of a shared prelude it imports.
  • In object mode each region is written into the .o; the linker replays them and keeps one copy of each identical region. The same identifier with a different shape, in one unit or across modules, is E0308. An address in a bank no region covers is E0317.
  • The layout belongs to the project, not to each module: declare it once in a816.toml with board (a real cartridge board) and/or [map.N] tables, see Bus map. The toml regions are on every unit's bus before its first line; a source .map with the same identifier and shape is a no-op there.

Expressions

Every place that takes an expression (opcode operands, name =, name :=, .db / .dw / .dl, .if, .for bounds) accepts the same operators with the same results, and so does the linker when it resolves an expression that references an .extern.

Literals

Form Example Value
decimal 42 42
hex 0x2A 42
binary 0b101010 42
octal 0o52 42
string "abc" compared with == / != only

Prefixes are lowercase. $2A and %101010 are not literals, and digits take no _ separator (0x1_000 is an error).

There is no character literal: 'A' is a one-character string, so lda #'A' does not load 0x41. Write the code (#0x41), or emit text through a .table with .text.

Operators

Tightest first. Binary operators of the same level are left-associative.

Level Operators Meaning
1 -x ~x negate, bitwise not
2 * / % multiply, divide, remainder
3 + - add, subtract
4 << >> shift left, shift right
5 < <= > >= comparison (1 or 0)
6 == != equality (1 or 0)
7 & bitwise and
8 ^ bitwise xor
9 \| bitwise or

As in C, comparisons bind tighter than & / ^ / |: write (flags & MASK) == MASK, not flags & MASK == MASK. And + binds tighter than <<: 1 << 2 + 3 is 1 << 5, and var_1 << 8 + var_2 shifts by 8 + var_2; parenthesise, (var_1 << 8) + var_2.

Integer semantics

Values are unbounded integers; the operand or data size masks the result when it is emitted.

  • / truncates toward zero and % takes the sign of the dividend (C / ca65 rules): -7 / 2 is -3, -7 % 2 is -1, and a == (a / b) * b + a % b always holds.
  • / or % by zero is error E0312, reported at the operator.
  • ~ complements within the smallest of 8, 16 or 32 bits that holds the value: ~0x0F is 0xF0, ~0x8000 is 0x7FFF. Values wider than 32 bits are rejected.
  • >> is an arithmetic shift: -8 >> 1 is -4.
TEXT_BYTES_PER_ITEM = 12
HALF  = TEXT_BYTES_PER_ITEM / 2      ; 6
SLOT  = (index + 1) % 8
FLIP  = attributes ^ 0xC0
    lda.w #(table_end - table) / 3

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 now

Both forms bind a name and both may be rebound; they differ in when the right-hand side is evaluated:

  • = is lazy: the value is computed when the name is used, so the right-hand side may name a label defined further down, and in object mode an .extern (the name becomes a link-time alias).
  • := evaluates at once, where it is written. That is what a typed bind needs (p := (base as T) expands one symbol per field), and it rejects a forward reference with E0210.
early = later + 1      ; fine: evaluated on use
later:
    rts
now := 0x2100          ; evaluated here
ppu := (0x2100 as PPU) ; typed bind: needs `:=`

Prefer = unless you need the value fixed at that point.

.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). A docstring as the first statement documents the struct, as in a .scope or .macro body; the LSP shows it on hover.

.struct OAM {
    """One sprite's low-table entry."""
    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

Array fields: TYPE[N] name

Any primitive or struct field type takes an [N] suffix to declare N consecutive elements. N is an integer literal (21, 0x15) or a constant expression ([(LINE_CELLS + COPY_CELLS) * TILE_BYTES]) whose constants are defined before the .struct, and is at least 1; bit fields (uN) cannot be arrays.

.struct Path {
    byte count
    Inner[3] points
    byte[21] title
}
; → Path.points          = 1   (offset of element 0)
;   Path.points.x        = 1   (element 0's sub-fields)
;   Path.points.y        = 3
;   Path.points.__size   = 12  (whole array, in bytes)
;   Path.title           = 13
;   Path.title.__size    = 21
;   Path.__size          = 34

Name.field is the offset of element 0, and an array field publishes its total byte size as Name.field.__size, mirroring Name.__size. There is no indexing syntax: brackets already mean indirect-long addressing (lda [dp]), so element i is plain arithmetic, Path.points + i * sizeof(Inner), or an indexed operand (lda path.title, x). Typed binds and .reserve NAME as TYPE honour array fields the same way: the field symbol points at element 0 and the reservation spans the whole array.

Sizes: sizeof(...) and countof(...)

sizeof gives a size in bytes, countof an array field's element count. Prefer them to the .__size symbols, which are the assembler's internal spelling. Both work in any expression, .for bounds and struct array lengths included.

.struct Path {
    byte count
    Pt[3] points
    word[4] flags
}
.reserve path_buf as Path in wram
.reserve text_ring 0x100 in wram

    lda.w #sizeof(Path)          ; the struct, like Path.__size
    lda.w #sizeof(Path.points)   ; an array field: all 3 elements
    lda.w #sizeof(Path.count)    ; any other field: its width
    ldx.w #countof(Path.flags)   ; 4 elements, not 8 bytes
    ldx.w #sizeof(text_ring) - 1 ; a reservation's size
    lda.w #sizeof(intro_tiles)   ; an alloc's body size

sizeof takes a struct, a struct field (nested paths too), a reservation (.reserve NAME SIZE or as TYPE) or a named alloc; a bit field has no byte size. countof takes an array field. Anything else, or a name not visible in this module, is E0321. An alloc or reservation from another module resolves at link, like its address.

Typed access: as casts, := binds and = views

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.

:= needs its base now. Over a label whose address is only known later (a pooled alloc, placed at link, or a forward reference) write the lazy view with =: p.field then resolves whenever the base does.

.alloc tbl in data {
    .incbin "tbl.bin"
}
entries = (tbl as Entry)            ; tbl is placed at link
    lda.l entries.bank, x            ; relocated with tbl

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.

Lint hooks:

  • S001: a cast or .istruct 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 (experimental)

Off by default: turn it on with --experimental track_register_size or track_register_size = true under [experimental] in a816.toml.

rep #N and sep #N mutate the CPU's M / X flags at runtime; with tracking on, 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).

When the width is known (a .a* / .i* directive, or tracking) and an immediate is written with a suffix that disagrees (lda.b #0x12 under .a16), the assembler warns once per line with the rep / sep or suffix to fix. Width only sizes immediates: a memory operand keeps its own size (lda 0x12 is direct page under .a16 too).

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): hi itself is excluded, so .for i := 0, 8 runs for i = 0 to 7, eight times, and lo >= hi runs nothing. var is a binding visible inside the body.

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

Data

.db / .dw / .dl / .pointer

Emit raw bytes / 16-bit words / 24-bit longs. .pointer is .dl under a name that says what the value is. There is no 32-bit data directive; a dword struct field (.istruct) emits four bytes.

.db 0x16, 0x20, 0x17, 0x20
.dw 0x2000, 0x2500
.dl 0x010000
.pointer handler      ; 24-bit address of `handler`

.istruct Type { field = value, ... }

Emits one instance of a .struct as data: every field in declaration order, little-endian, unset fields zero-filled. Entries are separated by commas and/or newlines.

.struct Pt {
    word x
    word y
}
.struct Sprite {
    byte[8] name
    Pt pos
    Pt[2] path
    word[3] frames
    u4 palette
    u4 priority
}

player:
    .istruct Sprite {
        name = "HERO"                         ; padded with 0 to 8 bytes
        pos = { x = 0x80, y = 0x60 }          ; nested struct
        path = [{ x = 1 }, { x = 2, y = 3 }]  ; array of structs
        frames = [frame_a, frame_b]           ; third word stays 0
        palette = 3                           ; bit fields pack per run
    }

Value shapes by field type:

Field Value
byte / word / long / dword, uN expression
byte[N] "string" (ASCII, zero-padded) or [expr, ...]
other T[N] [...] of element values
struct T { field = value, ... }

Values mask to the field width exactly like .db / .dw / .dl (word w = 0x12345 emits 45 23); a uN value masks to N bits. Lists and strings longer than the array are errors, shorter ones zero-pad. In object mode a field expression naming an .extern symbol becomes an expression relocation, resolved at link time.

.istruct emits bytes, so it goes where .db goes: after a label, in a *= section or an .alloc body. A preceding label binds the instance's start address; there is no per-field label (use label + Type.field).

Errors: E0122 field initialized twice, E0123 string inside a list, E0330 unknown struct type, E0331 unknown field, E0332 value shape does not match the field, E0333 initializer longer than the array, E0334 non-ASCII string, E0335 initialized bit-field run wider than 32 bits.

.patch Type at ADDR { field = value, ... }

Writes only the fields it names, over bytes a816 does not own: a ROM hack's header, vector or table record whose other fields must keep the original ROM's bytes. The initializer is .istruct's, with two differences: nothing is zero-filled (a short string or list writes just its own bytes), and the instance sits at a fixed ADDR, any constant expression, typed binds included.

.import "@std/snes/header"

; New title; maker, version and checksum stay as the ROM has them.
.patch SnesHeader at SNES_HEADER_BASE {
    title = "MY HACK"
}

Each run of touching fields becomes a pinned block at ADDR plus its offset, so a patch over another pinned block is the usual overlap error (E0408). A bit-field byte is written whole: give every field sharing it, since a816 never sees the ROM's byte to merge into (E0348 otherwise). .patch places itself, so it goes outside any .alloc (E0341). The other errors are .istruct's.

.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. Name the blob by the alloc that holds it, or by a label placed before it:

.alloc intro_map at 0x0e8000 {
    .incbin "assets/intro.map"
}
    lda.l intro_map
    ldx #sizeof(intro_map)

The directive also binds names derived from the path, / and . replaced by _: assets_intro_map and assets_intro_map__size. They are deprecated: a reference warns (W0001) and a816 fix rewrites it to the alloc's name when the blob is alone in a named alloc. They go in 1.2.

.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.

.debug "message {expr}"

Prints the message when the line is emitted, with each {expr} replaced by its value (hex for numbers). Emits no bytes. Handy to see where a label or a pooled alloc landed:

.debug "font table at {font_table}, {font_table__size} bytes"

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). bss makes it a byte-less memory pool; contexts A, B lets screens that never run together share its memory (.reserve x 4 in POOL.A). See Memory pools.

.alloc NAME in POOL { body }

Reserves space for body in the named pool; the allocator picks the address and binds NAME there. sizeof(NAME) is the body's byte count, for every named alloc, pinned ones and .reserve included (the internal symbol behind it is NAME.__size).

.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.

.res N

Inside a bss alloc body, reserves N bytes of address space without emitting any; .reserve is the one-line form.

.pool wram { bss  range 0x7e2000 0x7e2fff }

.alloc buffers in wram {
    text_buffer:
        .res 0x100
    scroll_x:
        .res 2
}

.reserve NAME SIZE [at ADDR] in POOL

Byte-less reservation into a (typically bss) pool; lays out a RAM/VRAM variable flat, no wrapper .alloc block. NAME binds at the reserved address; nothing is emitted into the image.

  • .reserve NAME SIZE in POOL: the allocator picks the address.
  • .reserve NAME SIZE at ADDR in POOL: pins the slot at ADDR. The allocator validates the span lies within a pool range and overlaps no other allocation (pinned or floating), then carves it out. Use for fixed memory maps (VRAM, MMIO mirrors) where the address is the contract but you still want overlap checking across the whole layout.
  • .reserve NAME as TYPE [at ADDR] in POOL: reserves sizeof(TYPE) and publishes NAME.<field> at each struct offset; at ADDR pins it like the flat form.

sizeof(NAME) is the reserved size, so code clearing or copying the buffer needs no second constant: ldx.w #sizeof(bg_char) - 1.

.pool vram { bss  range 0x0000 0x7fff  strategy order }
.reserve bg_char 0x2000 at 0x1000 in vram   ; pinned VRAM word
.reserve bg1_map 0x0800 at 0x6800 in vram
.reserve scratch 0x0040           in vram   ; allocator picks a free hole

Pinned spans that fall outside the pool or collide with another allocation fail the build, naming the offending reservation.

.assert EXPR, "message"

A layout invariant checked at link time, once every address is final, so it may use pooled labels and the module's private ones:

.alloc dialogue_stream in upper_gap cross_bank {
    .incbin "assets/stream.dat"
}

.assert (items_vwf & 0xFFFF) == 0, "items_vwf must open a bank"
.assert dialogue_stream + sizeof(dialogue_stream) <= 0x5d0000, "the stream overruns the gap"

An assert inside an .alloc body sees that body's _ labels; outside the body they are private, and the failure says so.

A false assert fails the link with E0407; every failed assert is reported, each with its message, expression and source line.

.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.