K816 is a high-level assembler (HLA) for the WDC 65816 microprocessor.
Its language descends from K65, a high-level assembler for the 6502:
https://github.com/Krzysiek-K/k65. K816 is a new compiler, not a port of
that one: it emits ca65 assembly for the cc65 toolchain to assemble and link,
and it adds the 65816's register widths, 24-bit calls and units that import
each other. A .k65 source is read as K65 plus those extensions, except where
doc/k65-compat.md says the superset stops, and
--strict-k65 reports every extension a program uses.
K816 aims to combine a familiar, readable source format — C-like blocks, loops and conditions over the instructions themselves — with the control and predictability of hand-written assembly, while providing a small set of high-level conveniences that remove boilerplate and improve code clarity. A statement is one instruction or a documented expansion of a few, and a condition tests the flags an instruction set, so nothing the machine cannot do has a spelling. What the compiler adds to what you wrote is the implicit RTS and the REP/SEP a call or a width statement needs, and the optimizer then rewrites what it can prove equivalent — a redundant width switch goes, a store of a known zero becomes STZ. k816 emit-ir --after-opt --explain shows each rewrite, and --no-opt turns them off.
- Target the WDC 65816 as a first-class CPU, including 24-bit addressing workflows.
- Provide a K65-inspired high-level assembly syntax designed for readability.
- Support compile-time evaluation for generating constants and data programmatically.
- Support structured code blocks (functions, inlining, and explicit far functions).
- Provide deterministic, reproducible output suitable for golden testing.
- Provide a formatter to keep sources consistently styled.
- Keep the toolchain small, fast, and well-suited to modern development workflows.
Want to get a feel for K816 code? This is the vibe:
var SCREEN = $0400
func main {
x = 0
loop {
a = hello, x
if !z { SCREEN, x = a x++ }
} while !z
}
data text_data {
charset ".ABCDEFGHIJKLMNOPQRSTUVWXYZ..... "
hello: "HELLO WORLD" $00
}
Conditions chain (if c | z), a loop counts in its test (while x--),
when is the compare ladder over one register, continue goes to a loop's
test, and unroll 4 { … } writes a block out four times — each compiling to
exactly the branches and compares it names
(Branch and Flow Control).
Functions declare their expected register widths with @a8/@a16 and
@i8/@i16. The compiler sizes immediate operands correctly, emits
REP/SEP at call sites automatically, and optimizes away redundant mode
switches:
func draw @a16 @i16 {
lda #$1234 // 16-bit immediate -- sized automatically
}
func main {
call draw // REP #$20 before JSR -- the accumulator half only
}
draw states @i16, and the call does not establish it: nothing in draw,
and nothing after the call, reads the index flag, so that half of the bridge
changes no instruction and the optimizer removes it. A width a body never
depends on is a width no caller has to pay for
(register-width-aware-syntax.md,
"Dead mode elimination").
Functions can also declare the registers they take and hand back. A call
that names them is checked against the declaration (K816-E0122), and an
inline may take typed immediate aliases:
var SUM = $10
func add @a16 @i16 (a, x) -> a, y {
stx SUM
clc
adc SUM
tay
}
inline add_imm @a16 (a, #value:word) -> a {
clc
adc #value
}
func total @a16 @i16 {
lda #1000
ldx #24
add a, x -> a, y
add_imm a, #16 -> a
}
A width statement in front of a label is that label's entry width, and the label is entered the way a function is: the code that falls into it runs the statement where it is written, and a branch that arrives at another width is switched in front of the branch, by the caller. Nothing runs at the label, so a loop whose back edge is already at the width pays nothing per iteration:
var RDY = $FFE0
func poll @a16 {
@a8 // SEP #$20 here, once, before the loop
.wait:
bit RDY // 8-bit, so N is bit 7 of RDY
bpl .wait // already 8-bit: no switch on the back edge
@a16
}
See register-width-aware-syntax.md for full details.
Declare named-field layouts on a fixed address and access them with dot or bracket syntax (inspired by Pawn):
var player[
.x :word
.y :word
.hp :byte
] = $0200
func tick @a16 {
a = player.x
a++
player.x = a
lda #player.hp:offsetof // 4 -- folded at compile time
}
Square brackets evaluate arbitrary expressions at compile time, including
lookup-table generation with for ... eval. A range includes both ends, so a
table of 256 entries runs to 255:
[ TABLE_SIZE = 256 ]
data SineTable {
align 256
for x=0..TABLE_SIZE-1 eval [ sin(x / TABLE_SIZE * pi * 2) * 127 + 128 ]
}
A far func returns with RTL, and a call to one is written call far,
which emits JSL — in the unit that defines it and in every unit that imports
it. A plain call to a far func is an error, since a JSR cannot return
through an RTL:
far func lib_init @a8 @i8 {
nop
}
func main {
call far lib_init
}
K816 compiles one source file to ca65 assembly. It does not assemble, link, or
manage projects: ca65 assembles what it emits and ld65 links the result,
which is the rest of the cc65 toolchain doing what it already does.
Usage: k816 <command> [options]
Commands:
compile [--no-opt] [-o OUT.s] FILE compile K816 source to ca65 assembly
emit-ir [--after-opt] [--explain] FILE
dump the lowered operation stream, and
with --explain what the optimizer did
fmt [--check] FILE... normalize whitespace: indentation, trailing
space, and the final newline. --check writes
nothing and exits non-zero if a file would
change
bind [--for FILE] [--all] [--layouts [--flat]] [-o OUT.k816] DEBUGFILE
write K816 declarations for the symbols
ca65 modules provide, read from the debug
file ld65 wrote (`cl65 -g -Wl --dbgfile,F`).
--for FILE: only what the module built from
FILE imports; --all: every export, used or
not; --layouts: every `.struct` as a field
layout instead, --flat as one `var` per
member of a `.struct` at an `.org`
convert [-I DIR]... [--out-dir DIR | -o OUT.kk] [--force] [--check] FILE...
convert ca65 sources to K816: each FILE
and every file it includes becomes a
`.kk` unit beside it, or under --out-dir.
-I DIR: where `.include` looks after the
including file's directory; --force:
replace a `.kk` that exists; --check:
compile what was written
lsp [--strict-k65] [--entry AXES] run the language server over stdio. The
same options a build states, so an editor
and a build agree; a client may also send
them as `strict-k65` and `entry` in its
initializationOptions or configuration
Options:
--create-dep FILE write a make dependency file for what
compile read: the source, every unit it
imports, and every file a `binary`
includes. --create-full-dep is the same
list, as K816 has no include path
--dep-target NAME the name on the left of the `:`, when the
file the build tracks is not the -o one
--message-format FORMAT diagnostic output of compile and emit-ir:
human, compact, json
--strict-k65 report every construct the original K65
language does not have. Extensions are
errors, and a construct that means
something else there is a warning
--entry AXES the state the machine is in when main
gets control: native or emulation, a8 or
a16, i8 or i16, comma-separated in that
order and each optional. Default
native,a8,i8 - what the startup stub in
examples/fullstack establishes
-V, --version print the version number and exit
One unit, through the three tools:
k816 compile main.k816 -o main.s
ca65 main.s -o main.o
ld65 -C fullstack.cfg -o fullstack.bin startup.o main.oA unit that uses another unit's names. Each names the units it may see, and K816 derives the imports and exports from their symbols — so a compilation takes one file and reads the rest itself:
k816 compile main.k816 -o main.s # reads whatever main.k816 imports
k816 compile lib.k816 -o lib.s # and whatever lib.k816 imports# main.k816
import "lib.k816"
func main @a8 @i8 {
call helper
}
A unit sees what it imports directly and nothing further: if main
imports lib and lib imports hw, then main cannot name hw's
symbols without importing it too. That is what makes a unit mean one thing —
lib.k816 resolves the same names whether it is the file being compiled or a
file main.k816 reached — and it is why the closure is still a dependency
of the build even where it is not visible: hw's body decides contracts
lib's object was compiled against.
Inspecting a compilation rather than building it:
k816 emit-ir main.k816 # the lowered operation stream
k816 emit-ir main.k816 --after-opt --explain # ... and what the optimizer did to it
k816 compile main.k816 --no-opt -o main.s # skip the optimizer entirelyexamples/fullstack/ is a worked build — K816 source, a startup stub, a linker
config, and a Makefile.
You do not have to run the three tools yourself. cl65, the cc65 driver,
knows the pipeline: it compiles a .k816, .k65 or .kk file with
k816, assembles what k816 emits with ca65, and links the objects with
ld65. One installation provides all four, and cl65 finds them beside
itself. The extension names the dialect: .kk and .k816 are K816, and
.k65 also accepts K65's own spellings of a loop, a flag test and the stack
(doc/k65-compat.md, "Two dialects, one grammar").
cl65 -t none --no-target-lib -C fullstack.cfg -o fullstack.bin startup.asm main.k816That is the whole of examples/fullstack/Makefile, and it is the command the
test suite runs: tests/driver.rs requires the image cl65 produces to be
byte-identical to the one the three tools produce by hand, for every program
in the test corpus that compiles and every committed compatibility image.
-t none --no-target-lib -C <config> is what a K816 build says. cl65
defaults to the C64, so without -t none ld65 looks for c64.cfg and links
c64.lib into a program with no C runtime; -C replaces the target's
configuration but not its library, and there is no none.lib. The layout is
your program's, and you state it. There is no cc65 target for a K816 machine
and k816 never sees -t.
Units name each other; cl65 compiles what it is given. A K816 file says
which units it may see, so the driver hands the compiler one file and nothing
about the others — the same thing it does with a C or an assembler file.
cl65 -t none --no-target-lib -C game.cfg -o game.bin startup.asm main.k816 sprites.k816Both K816 files are compiled and both objects are linked, in the order they
appear. Importing a unit does not put it in the link: a unit whose code or
data the program uses has to be on the line, or ld65 reports its symbols as
unresolved externals. Only an interface unit — nothing but extern
declarations — is left off, since it produces no object.
-Wk forwards one option to k816, exactly as written. Unlike -Wa,
-Wc and -Wl, it does not split its argument at commas — a comma is data
inside a K816 option value:
cl65 -t none --no-target-lib -C game.cfg -Wk --entry=native,a16,i16 -o game.bin main.k816
cl65 -S -Wk --strict-k65 main.k65 # compile only; leaves k816's own main.scl65 invents no K816 options of its own: there is one vocabulary, spelled the
way k816 spells it. cl65 -S leaves exactly the assembly k816 compile
writes, and cl65 -g reaches ca65 so that a hand-written module linked
beside your K816 one is in the debug database too — K816's own module is there
either way, because it asks for debug info itself.
--create-dep works, and it is k816 that writes it. The dependencies of
a K816 object are the source, every unit it imports — transitively — and every
file a binary includes, and only the compiler knows the last two. The file
is a make rule naming the object, so a Makefile can -include it and a
build generator that reads make-style dependency files can use it — once it
is told to pass --create-dep. A build generator also has to be told
everything else above: that cl65 is the compiler for these extensions,
-t none --no-target-lib, and the -C configuration. Adding the extensions
to CMake's assembler language does none of that, and no CMake integration is
shipped or tested.
doc/cl65.sgml in the cc65 tree is the driver's reference.
K816 is one Cargo package. Four gates have to pass:
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test
make -C .. k816 # cc65 integration; produces ../../bin/k816cargo test needs ca65, ld65 and cl65 on hand for the end-to-end tests:
they are looked up in the cc65 tree's bin/ (build them with
make -C ../.. bin) and can be pointed elsewhere with the CA65, LD65 and
CL65 environment variables.
Two tracked files are generated, and their tests tell you so when they drift:
tree-sitter-k65/generated.lock— the checksum of the committed parser. After editinggrammar.jsand re-runningtree-sitter generate, refresh it withUPDATE_GENERATED_LOCK=1 cargo test --test corpus.doc/corpus-census.md— the counts the prose cites instead of quoting. Refresh withUPDATE_CORPUS_CENSUS=1 cargo test --test live_outs.
k816 fmt FILE... normalizes whitespace and nothing else: the indentation of
each line — four spaces per brace-delimited construct — trailing whitespace,
and the final newline. Whitespace inside a line is left exactly as written,
so a column of aligned = signs or trailing comments survives, and a file's
line endings are not converted. Two lines keep a column of their own: a line
continuing a const list hangs under the list's first name, and a comment
line in the column of the trailing comment above it continues that comment
and stays there.
k816 fmt --check FILE... writes nothing and exits non-zero if a file would
change.
A file whose parse has an error is never formatted: formatting a program the
grammar could not read is guessing, and the diagnostics already say why. The
editor's textDocument/formatting calls the same function.
k816 lsp runs a language server over stdio. It answers live diagnostics,
hover, go-to-definition, references, document and workspace symbols, semantic
rename, completion, code actions from the compiler's own structured fixes,
document formatting, and semantic tokens.
It analyzes with the options the build uses, which is what stops an editor
and a build disagreeing about the same file. State them on the command line —
k816 lsp --strict-k65 --entry native,a8,i8 — or send them as strict-k65
and entry in the client's initializationOptions or configuration, at the
top level or under a k816 section. The values are read by the same functions
the flags are, so a spelling the command line refuses is refused in the editor
with the same words.
Highlighting comes in two formats, and the second is derived from the
first. tree-sitter-k65/queries/highlights.scm uses the standard capture
names, so an editor that reads Tree-sitter highlight queries — Zed, Helix,
Neovim — can highlight from it; the repository wires it up for Zed only.
Any client that asks is served textDocument/semanticTokens, which the
server answers by running that same query and refining what only the symbol
table knows. No TextMate grammar is shipped (doc/tree-sitter.md).
Zed has an extension in this repository, editors/zed-k816/. Install it
with Zed's zed: install dev extension command and pick that directory. Zed
compiles the grammar at the commit the extension pins and starts k816 lsp
from your PATH, so build it first with make -C .. k816. Options go in
Zed's settings.json, under the server's own keys:
{
"lsp": {
"k816": {
"binary": { "path": "/path/to/cc65/bin/k816", "arguments": ["lsp"] },
"initialization_options": { "strict-k65": false, "entry": "native,a8,i8" }
}
},
"languages": {
"K816": { "formatter": "language_server" }
}
}binary is optional when k816 is on the PATH your login shell sets. When
it is given, arguments has to stay beside path: Zed starts a configured
binary with those arguments alone, and without lsp that is the compiler,
which waits for a file while the editor waits for the server.
Not offered, deliberately: inlay hints, code lens, folding ranges, and signature help.
The program the server analyzes is the open documents plus what they
import, transitively, read from disk. So a name another unit defines
resolves whether or not that unit has been opened, and the server answers the
question a build answers rather than a subset of it — because it reads the
same import lines. A problem in a file nobody opened is reported against
that file.
An open buffer always wins its own text: it may not be saved yet, and it is what the author is looking at. Opening a file the server had already read keeps the identity it had, so nothing that pointed into it moves. There is still no project file, and there is nothing for one to say.
k816 convert converts a ca65 program: every .asm, and every .inc it
includes, becomes a .kk unit with the program's shape kept — its
segments, its routines in order, its comments — so that the build can be
compared with the original's symbol by symbol. The bytes differ where K816
decides something the assembler did not: a width switch the optimizer proves
redundant, a call it has to bridge, a branch that arrives at another width
(doc/ca65-to-k816.md, "The converter"). Each place it could not be
faithful is a note. A program can also be carried over by hand, one file at a time:
cl65 compiles .asm and .k816 alike and ld65 links the objects, so the
program links at every step. doc/ca65-to-k816.md is
the idiom table — what K816 writes where ca65 writes each thing, every pair
assembled to the same bytes by a test, and converted to them — what the
converter writes, and the loop a file goes through. Three tools go with it:
k816 bindwrites theexterndeclarations a unit needs for the symbols ca65 modules still provide, from the debug file of the previous build, and--layoutswrites every.structas a field layout.tools/ca65-widths.pyreports the register widths a ca65 source assembled each label with, as the K816 annotation, and every call whose widths disagree with its target's.tools/k816-compare.pylists which symbols' bytes changed between two builds, both sides disassembled with the width flags tracked.
The compiler names each ca65 idiom it meets in a K816 file — a ; comment,
a .byte, RIA::op, #<addr, @loop — with its own code (K816-CA65-)
and the K816 spelling, in the editor as in the build.
K816 is licensed under the 0BSD License. See LICENSE for details.