Changelog¶
1.1.7 (2026-10-20)¶
pr-artifactwrites the comment the game repos' own workflows wrote ("Build Ready!", the artifact, its link, retention, the commit), now with the files and their sizes and a link straight to the artifact rather than the run. Two runs starting together no longer leave two comments: the extras are deleted, the oldest kept. A failure deletes them all.markeris optional: it defaults toA816-BUILD-COMMENT; a repo's old marker keeps its existing comments updated.nightlydefaults to the repos' own title (Nightly Build <run>) and notes;titletakes{run},{sha}and{version}.--dry-runprints the comment or notes it would post.
1.1.6 (2026-10-20)¶
- The wheel ships
script/py.typed, so a project importingscript(Table,script.pointers) type-checks under strict mypy instead of failing withimport-untyped. Only an editable install hid it.
1.1.5 (2026-10-20)¶
- S-DD1 boards (
SHVC-LN3B-01): the mcu windowsboards.bmlleaves unmasked take the power-on MMC bank masks (0x808000for00-3f,80-bf:8000-ffff,0xC00000forc0-ff), so their addresses resolve to the right ROM offsets. An explicit mask still wins; banks a game remaps at runtime are not modelled. - Sealing writes the SNES header checksum, which only the base ROM
can give, when
a816.tomldeclares the cartridge: the header is found through the bus map ($00:FFC0), the sum covers the ROM padded torom_size, mirrored when that is not a power of two.a816 bps sealencodes the patch again with it (--no-checksumto skip); the newa816 ips sealappends the record that writes it, byte for byte what ff6'stools/checksum.pywrote.E0517toE0519cover a bad IPS, a copier-headered base ROM and a project with no cartridge. a816 ips sealalso pads: when the patched ROM falls short ofrom_size, a one-byte zero record atrom_size - 1comes before the checksum. ff4's 2 MB IPS over its 1 MB base gave 0x18A3E7 bytes, which the Analogue Pocket SNES core read as carrying a copier header (size & 0x200).a816 buildleaves IPS output as it was: an IPS used as a byte container (ff6's intro) stays contiguous.a816 bps diff --source ROM --target PATCHED -o x.bpswrites a sealed linear patch between two ROMs (--metadata FILEfor its metadata), for a patch a816's linker does not build alone.[lint]ina816.tomlpicks the lint rules:selectandignoretake rule codes or prefixes (ignore = ["N802"]).check,fixand the LSP honour it;a816 check --select/--ignorereplace its lists for one run. A code no rule starts with isE0516.- Shared GitHub actions under
manz/a816/actions/:setup,decrypt-rom,lint,build,seal,pr-artifact,nightlyandrelease. Each callsa816 ci <step>, which also runs locally, with--dry-runfor the publishing steps. A Renovate preset,github>manz/a816//renovate/a816, bumps the actions and the package together. See GitHub actions.
1.1.4 (2026-10-20)¶
a816 build -f bpswrites a BPS patch without the base ROM, from a[bps]table ina816.toml(the base ROM's size and CRC32, and<patch/>metadata filled from the defines). The patch is unsealed: its patched-ROM CRC32 waits for the base ROM.a816 bps sealfills that CRC32 in from the base ROM;a816 bps applyanda816 bps infoapply and inspect any BPS patch (Flips' delta ones included), so a project needs no Flips build.-orepeats to write several outputs from one link, each in the format its extension names:-o game.ips -o game.bps.unsealed.build_with_imports()takesextra_outputs=[(format, path)]. A single-ostill follows-f.a816 formatkeeps a string in single quotes when its text holds a":.text 'l"ordre'became.text "l"ordre", which no longer parses.a816 formatkeeps the offset of.include_ips "x.ips", -0x200; it dropped, -0x200, which the parser requires.a816 formatkeeps nested bodies nested:.if/.for/.scopebodies inside an.alloccame out at the alloc's indent, and a macro block argumentm(x, { .dw 1 })as.dw 1}then). Files with such bodies need onea816 format; leading whitespace only.a816 fix --select UP001leaves a*=run holding.include_ipsto a manual migration, like.include/.incbin; it wrapped every.include_ipsafter the*=in its pinned alloc.- OP001's safe fix keeps the bytes:
.won a value above0xFFFF(jsr.w 0xC006AC, an in-bank call) and.lonjmp/jsrare no longer called redundant. script.Table.to_byteshonours a subclass'sjoker_regexagain (1.0.4 did): a table writing jokers as<NN>had every one dropped, silently.- Two files pinning an anonymous
.alloc atto one address on the same line fail with E0408 "two blocks pinned at ..." naming both, not E0400 "symbol already defined" on the generated alloc name. a816 formatformats a file whose.includeis not generated yet (build/gen): it failed E0500, so lint needed a build first.a816 checkstill needs the file.- A failed
.asserton a name defined nowhere says "NAMEis not defined" and where a-Dname goes, not "cannot resolveNAMEat link time".
1.1.3 (2026-10-15)¶
- Labels after
@= ADDRcount from ADDR, forward ones included: with a ROM-mapped ADDR (@= 0x0000), a label after any byte took the ROM window's address (0x8004, not0x0004), silently. A branch under@=keeps its offset instead of being re-aimed at link from the placement, which also makes it work over a WRAM@=. @=works inside an.allocbody (pinned or pooled): the body was sized from the@=address (E0337, an 8 MB slot), so a linted source had no way to place bytes here and count labels from there.- A
.forvariable beside a label in an expression the linker evaluates (.dw (grid_font + k * 320) & 0xFFFF) folds to its value; the link failed with "cannot resolvek". build_with_imports()returns a failed module's own errors indiagnostics(e.g. the locatedE0310), not just "Failed to compile module 'main'".a816 formatwrites*= ADDR/@= ADDRwith their space (it wrote*=0x008000), and a full-line comment written flush-left after them stays flush-left instead of being indented as section body.
1.1.2 (2026-10-14)¶
a816 check/format/fixwith no paths leave out what git ignores, and anexcludelist ina816.tomlleaves out files and directories by glob. Bahamut Lagoon's barecheckreported 531 hits, 530 from gitignored or scratch sources.lda #NAME, NAME an alias whose value is known only at link (BANK = (song >> 16) & 0xFF,songimported or placed later in the same module), takes the register's width like the expression written inline; it failed withE0313. That error now names operand widths ("a 16-bit operand ... the value needs 8-bit") instead of instruction lengths. An object records each alias once, so the.adbgloses repeated copies of the same symbol.- A failed
.assertin an.allocbody shows its names as written (_run_bits - _pool_runs), not as the body exports them (__sc16___run_bits). - A
.structtakes a docstring as its first statement, as a.scopeor.macrodoes; the formatter keeps it and the LSP shows it on hover. It was a parse error (E0101). a816 --version(or-V) prints the installed version, the Windows binary's included.- LSP hover (nvim
K) shows the docstring of a label inside an.allocbody, where it answered "No information available"; a top-level label shows its own docstring instead of the module's, and a macro shows its parameters, with or without a docstring. - LSP goto-definition on a struct member (
Entry.ptr,thing.ptrof a.reserve thing as Entry, a typed bind'sview.ptr, also through its scope asrender.view.ptr,(addr as Entry).ptr,Outer.head.ptr, an.istructfield) lands on the field's line, across files; it found nothing.
1.1.1 (2026-10-12)¶
[defines]ina816.tomldeclares the names a build passes with-D, each with its default:a816 checkcounts them as defined (noW0002), and a build binds the default unless-Doverrides it. A bad entry isE0510.a816 check,a816 formatanda816 fixwith no paths take the project the nearesta816.tomldescribes: the.s/.ifiles under the entrypoint's directory andmodule-paths, withoutbuild/,obj/and dot directories.a816 explaintakes an error code too (a816 explain E0317) and prints its entry from the error catalog.W0001reaches every reference to a path-derived.incbinname: the linker warns on one a module makes without importing the blob's module (ff4 had three), and a module reused from the build cache prints its warnings again, so a warm build shows what a cold one does. Objects change: caches rebuild once..patch Type at ADDR { field = value }writes only the fields it names, each pinned at its offset, so a ROM hack can change a header's title or one field of a table record and keep the rest of the original bytes. A partly given bit-field byte isE0348.- A
.assertinside an.allocbody sees the body's_labels (it failed at link with "cannot resolve"); one outside the body says the label is private to it..adbgnames a private label after its module,battle._loop, where two modules both showed__sc1___loop. An.allocbody's_constant in an expression over an imported symbol (text + _CELLS * 2) folds at compile time instead of failing the link. Objects change: caches rebuild once. - The Windows binary ships the package's data files (
boards.bml, the standard library): the 1.1.0 one failed any build naming aboard.
1.1.0 "Swift-Tuttle" (2026-10-10)¶
Separate compilation and linking, declared memory layout (freespace and
bss pools), structs, a build cache, and fluff (lint, format, fix) plus
an LSP. Tested over the alphas against ff4, dq6, cacheguard, Bahamut
Lagoon and kintsuki.
Highlights¶
Freespace pools: pinned blocks are carved first, the rest is placed around them, and the layout is checked at link.
.pool upper_gap { range 0x500000 0x5cffff strategy pack }
.alloc dialogue_stream at 0x500000 in upper_gap cross_bank {
.incbin "assets/stream.dat"
}
.alloc keep_font in upper_gap {
.incbin "assets/keep_font_packed.dat"
}
.assert dialogue_stream + sizeof(dialogue_stream) <= 0x5d0000, "the stream overruns the gap"
bss pools lay out WRAM, SRAM and VRAM without emitting bytes. Memory
used in turns goes in one pool's contexts; other overlaps are errors.
.pool menu_ram { bss range 0x7e9800 0x7e990f contexts field_menu, treasure }
.reserve vwf_state as VwfState in menu_ram.field_menu
.reserve treasure_hdma 0x40 in menu_ram.treasure
Structs with bit fields, arrays and nesting; typed views; initialized
instances; @std/snes/* hardware register definitions.
.import "@std/snes/ppu"
ppu := (PPU_BASE as PPU)
lda ppu.INIDISP
player:
.istruct Sprite {
name = "HERO"
pos = { x = 0x80, y = 0x60 }
palette = 3
}
Upgrading from 1.0¶
a816 fixrewrites*= ADDRplacements as.alloc at ADDR(UP001).*=still assembles; the lint flags it.- Every emitted byte needs a placement: an
.allocor a preceding*=. Bytes before the first*=used to land at0x008000, over whatever was there; they are nowE0310. brk,copandwdmtake their signature byte (brk #0x00). 1.0 emitted the lone opcode, so returning from the handler skipped the next byte; a barebrkis now an error instead.- Errors that 1.0 printed and then ignored (a parse error, an unknown
directive such as
.dd) now fail the build. 1.0 exited 0 with missing output, so a build that "passed" may now report what it always got wrong. - Overlapping writes fail the build (
E0408). In 1.0, two*=blocks writing the same bytes silently overwrote each other;--overlap-mode warn | offbrings back a warning or silence. - A
.bimmediate that doesn't fit a byte isE0309; 1.0 masked it (lda.b #0x1234emitteda9 34)..wand.lstill mask. - A bare
jsr/jmptakes its form from the target's bank: a label in the caller's own bank assembles to the absoluteJSR/JMP, and one in another bank isE0346, which namesjsl/jml. Alphas 44 to 51 sized it by value, so every 24-bit label (all of them in HiROM) became a long call, whose callee must return withrtl. a816 checkfails a file that doesn't parse, printing each error with its code and line. It used to exit 0 whileformat --checkfailed the same file.a816 file.sbuilds through modules: it writes objects and their dependency sidecars underbuild/obj(--obj-dir,--no-cache) and.sym/.adbgnext to the output.- Two fixes change output:
ora.w #immnow encodes$09(1.0 emittedlda's$A9), and a-Dvalue that reads as a number is a number. - A
-Dname must be a symbol name (scope.nameallowed). A name no source can spell is a usage error:-D " DEBUG=1"passed as one shell word used to defineDEBUG, leading space included, so every.if DEBUGdropped out without a word. - Python API:
assemble_string_with_emitterraisesA816Error(AssemblyError,NodeError) instead of returning an error string, andassemble_with_emitterno longer swallows errors.MZParseris nowA816Parser; the old name is a deprecated alias, removed in 1.2.assemble/assemble_as_patchand direct (single-pass) assembly are deprecated; build throughbuild_with_importsor the CLI.
Placement¶
.pool NAME { range LO HI ... }declares freespace, withstrategy pack | orderand ranges over several banks, split along the windows the bus serves.<pool>.capacity,.fragmentsand.largest_chunkread its state, e.g. in an.ifguard, also from a module that imports the pool..alloc NAME in POOL { ... }lets the allocator choose the address;.alloc [NAME] at ADDR [size N] { ... }pins it, withsizeas a hard bound.- A pool places its blocks around every pin inside its ranges:
.alloc at ADDRwith or withoutin POOL, from any module,*=blocks and other pools' pins. align Nplaces a block on anN-byte boundary.cross_banklets a data blob run over bank edges where the ROM is contiguous on the bus; code and labels are refused (E0336)..relocate SYMBOL OLD_START OLD_END into POOLmoves a routine and gives its old space back;.reclaim POOL START ENDadds slack..assert EXPR, "message"checks layout invariants once every address is final; every failed assert is reported (E0407), with the size of each alloc it mentions. An.assertwithout its message says so.- An alloc whose body emits more or fewer bytes than the slot it was
given is
E0337, instead of shifting what follows. - Alloc names are global: the same pool and name from two different
places is a duplicate symbol (
E0400), not one block silently replacing the other. - Pools merge across modules by name, whether the build goes through
a816 buildor links objects, and the linker places allocs from every object in one pass. Fill, strategy,bssand contexts must agree; a second declaration in the same file is still an error. - Overlapping writes fail the build by default (
E0408), checked before any byte is written. The error names each block (alloc and pool, or the address of an anonymous one), itsfile:lineand the bytes they share (--overlap-mode warn | off). - An empty alloc body binds its label and takes no space.
Memory pools¶
bsspools reserve address space and emit nothing;.res NreservesNbytes inside an alloc body..reserve NAME SIZE [at ADDR] in POOLand.reserve NAME as TYPE, which publishesNAME.<field>for each struct field.contexts A, Bon a pool lets memory used in turns overlap; overlap between two different pools isE0406, with both reservations and their source lines.
Language¶
.structwithbyte,word,long,dword,uNbit fields (with.maskand.shift), nested structs andTYPE[N]arrays, whereNmay be a constant expression (byte[ROWS * 16]); every field publishes its offset. Redeclaring an identical struct is a no-op.sizeof(T),sizeof(T.field),sizeof(NAME)for a reservation or a named alloc, andcountof(T.arr)for an array's element count, in any expression (.assert,.forbounds and array lengths included). An alloc or reservation from another module resolves at link. Misuse isE0321.(expr as T).fieldcasts and typed binds, over constants, labels and.externsymbols alike; the operand size follows the bind's base.view := (expr as T)binds eagerly;view = (expr as T)is a lazy view, which also works over a module's own pooled labels (placed at link): its fields relocate with the label. A module's:=over a name placed at link (font_ptr := target + 0x40) reaches its importers as that module's symbol, instead of failing there..istruct Type { ... }emits an initialized instance: strings, lists, nested structs and bit fields, zero-filling the rest..label NAME = ADDRnames an address for debuggers and the LSP without emitting anything..a8/.a16/.i8/.i16, andrep/septracking behind--experimental track_register_size, size immediates; an immediate whose width disagrees with the known size warns. A size a trackedrep/sepset ends atrts,jmp,bra,plpand the like, where the next routine starts; a declared.a16holds until redeclared.- The rest of the 65c816 instruction set:
brl,bvc,bvs,cld,cli,clv,cop,mvn,mvp,perandwdm, plusjsl/jmlas aliases ofjsr.l/jmp.l.brk,copandwdmtake their signature byte explicitly. - One expression grammar everywhere, with C precedence and semantics:
truncating
/,%, comparisons,~within 8, 16 or 32 bits; division by zero isE0312. .scope NAME { ... }publishes its labels, constants, aliases and the labels of macros called inside it asNAME.x;_names stay private.- A bare operand naming a label further down its block (
jsr fwd,lda fwd) is sized as a backward reference would be. If the label lands where that size is wrong,E0313names the suffix to write. - An addressing mode with a single size widens a narrower operand to
it, as ca65 and asar do:
pea 0x0000isF4 00 00,lda 0x12, yisB9 12 00. It never narrows. - A
.forvariable is known while its body expands, so:=and.ifin the body can use it (_c := i << 8).
Modules and linking¶
- Separate compilation:
a816 build -cwrites objects, and the linker combines them.a816 build main.sdiscovers and builds every import. New flags:-f obj,-I/--module-path,--include-path,--obj-dir,--no-auto-imports. .import "@std/snes/ppu"(andcpu,dma,apu,joypad,wram,header): typed SNES registers..externsymbols work in any expression, macro or alias, declared at the top of a module or inside an.allocbody or block; the linker evaluates them once placed.- Transitive imports are deduplicated, and modules are found on the module paths only, so a same-named file next door can't shadow one.
- A module's
.mapregions travel with.importand its object; the linker merges identical ones, and a conflicting one isE0308. .incbinsymbols andscope.labelnames reach importers.- The names
.incbinderives from its path (assets_vwf_bin,assets_vwf_bin__size) are deprecated and go in 1.2. A reference warns (W0001, a816's first warning code), in the build and ina816 check, which still exits 0 on warnings alone.a816 fixrewrites the reference tovwf_font/sizeof(vwf_font)when the blob is alone in.alloc vwf_font; otherwise label the blob. A reference to such a name that two blobs bind isE0347: it read the first one. - A module sees the constants of what it imports, directly or through
another import, and nothing else. Using a constant of a module it
doesn't import is
E0200, with the.importto add as the hint (alphas 42 to 51 resolved it with a warning). - A module's
_names are private. Its importers can't name them (the error says which module owns the name), and a module's own_nameshadows an imported one. A_labelis also private to its alloc. - The linker writes
.symand.adbgdebug info with source mapping. Each source file has one name in the.adbg, relative to the working directory when it lies under it, so the file doesn't change with the checkout's location. - An
.importinside an.ifisE0311: imports are read before any condition runs, so the module was linked whatever the condition. Import it unconditionally and put the.ifinside the module. .includesearches theinclude-pathseverywhere: in imported modules, ina816 checkanda816 format, and in the LSP.
Builds¶
- The build cache rebuilds a module when anything that shaped its object
changed: defines, include and module paths, the bus map, file contents
(a touch alone doesn't count), a lookup that would now find a new file,
an import's output, or the toolchain's codegen revision.
--no-cachecompiles everything. - Identical sources give an identical ROM, independent of
PYTHONHASHSEED. WithSOURCE_DATE_EPOCHset,BUILD_DATEis that time (UTC) rather than the clock, so two builds match byte for byte. a816.tomlholds the project:entrypoint,include-paths,module-paths, a cartridgeboardfrom ares'boards.bml(LoROM, HiROM, ExHiROM, SA-1 and the rest),[map.N]regions in bsnes form,rom_sizeand[experimental]flags.- An
.sfcimage is padded torom_size. - Cold multi-module builds scale with the project instead of its square: each imported module's object, source and import plan are read once per build, not once per importer. A 100-module project went from 19 s to 2.3 s, ff4 from 3.7 s to 1.5 s, cacheguard from 1.8 s to 0.8 s. Scanning is about 1.8x faster, and parsed nodes are immutable, so the build shares them between modules safely.
script.Table.to_bytes, the table encoder text tools build on, is 4 to 9x faster with the same output (Bahamut Lagoon's dialog reflow spent 75% of its time there).
Diagnostics¶
- Errors carry a stable code (
E0001toE0509), a caret on the offending token, a hint and, for names, a did-you-mean; the error codes page documents each one. Every diagnostic carries a code, and a failed module is reported once, without a trailingBuild failedline. - An unsized operand naming a symbol resolved at link (
jmp targetwithtargetfrom another module) asks for its size (E0313), since the right form depends on where the linker puts it. - An undefined symbol passed to a macro is reported where the caller wrote it, with the macro parameter it was bound to.
- Several errors from one pass are reported together.
- An unterminated
/* ...block comment isE0004, pointing at where it opens, instead of hanging the scanner. - Pool overflows say whether the block is too big, the pool fragmented or full, and what to try.
- A duplicate global names both definitions and their values (
E0400). - A missing
.includeor.incbinfile isE0500at the path, with the directories searched, instead of a bare[Errno 2]. - An alloc's size asked for before layout (
.for i := 0, 8 - sizeof(dte)) isE0321, and any name missed while expanding is located and coded, never a bareBuild failed: NAME. - Logs stay quiet by default;
--verboseshows tracebacks.
Fluff: lint, format, fix¶
a816 format [--check]: a canonical formatter that keeps comments, docstrings and strings intact and settles in one pass; it takes several paths, or-for stdin. A unary operator stays against its operand (~3,-x).a816 check: docstring rules (DOC001toDOC007), naming (N801,N802), struct casts (S001,S003,S004), line length (E501), redundant size suffixes (OP001), program structure (ST001for a placement nested in an.alloc,ST002for an.importoutside the prelude) and legacy placement (UP001);; noqa: RULEto suppress.W0002flags an.ifon a name the project defines nowhere: it reads as false, so a deleted or misspelt flag drops its block silently.a816 fixapplies the autofixes, also offered as LSP code actions.
Language server¶
- Hover with docstrings (modules included), struct fields, bit fields
and pools; go-to-definition into includes, imports and
.incbinassets; completion, rename, workspace symbols and semantic tokens. - Diagnostics with error codes and hints, recovery from broken input, and an index that builds in the background.
Tools¶
xobjinspects object files: sections, symbols, relocations, pools and the format identity.xdds(new) disassembles with the assembler's instruction table:--funcwalks a routine's control flow tracking M/X,--follow-callsfollows its calls,--debug FILE.adbg/--symname addresses, and every--asmline reassembles to its bytes: a size suffix is printed only where a bare operand would assemble differently, and long calls and jumps print asjsl/jml.A816_EMIT_TRACE=1logs where every region landed.
Fixes from 1.0¶
ora.w #imm16encodedlda's opcode ($A9), silently loading instead of or-ing.- About 25 addressing modes were missing on
ora,and,eor,adc,sbc,cmp,staandjsr;cmp 0x03, scrashed. - A
.mapline swallowed the next line when it began with a name. |,^,~,/and%failed or crashed in constants,.db,.ifand immediates (X = 1|2,lda.w #10/2).- One-line blocks (
.scope x { rts },{ nop nop }) didn't parse. - A constant inside
.scope sc { K = 6 }was unreachable assc.K. -D NAME=VALUEalways defined a string, solda #NAMEfailed.- Repeated
-Dflags kept only the last one's values:-D A=1 -D B=2definedBalone, and every.if Asilently dropped out. -f sfcignored-m, so SFC output was always LoROM.- An unknown
-mvalue crashed; it now listslow,low2,high. - An
.ifon a.forvariable inside the loop was false on every iteration, without a word.
Documentation¶
- Every example in the docs is checked by the test suite: snippets
parse, complete programs build, and the multi-file tutorials are real
projects under
docs/examples/built against a golden patch. - New pages for placement and this changelog;
.map, expressions, modules and the lint rules are documented against the code.