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.
@=: 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-mis absent); declaring one replaces that default, so declare every region the code uses. .importbrings 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, isE0308. An address in a bank no region covers isE0317. - The layout belongs to the project, not to each module: declare it
once in
a816.tomlwithboard(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.mapwith 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 / 2is-3,-7 % 2is-1, anda == (a / b) * b + a % balways holds./or%by zero is errorE0312, reported at the operator.~complements within the smallest of 8, 16 or 32 bits that holds the value:~0x0Fis0xF0,~0x8000is0x7FFF. Values wider than 32 bits are rejected.>>is an arithmetic shift:-8 >> 1is-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).
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 withE0210.
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.
.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.istructtargets a struct type the file never declared.S003:(p as T).fieldwhenpis already bound asT.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).
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.
.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.
.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.
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.
.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:
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:
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 atADDR. 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: reservessizeof(TYPE)and publishesNAME.<field>at each struct offset;at ADDRpins 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.