Skip to content
X65Public

About

K816 is a high-level assembler (HLA) for the WDC 65816 CPU

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

K816

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.

Goals

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

Quick Taste

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

Register-Width-Aware Syntax

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.

Symbolic Subscripts

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
}

See symbolic-subscripts.md.

Compile-time Evaluator

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 ]
}

Far Calls

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
}

CLI Usage

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

A 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 entirely

examples/fullstack/ is a worked build — K816 source, a startup stub, a linker config, and a Makefile.

Building with cl65

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

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

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

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

Development

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/k816

cargo 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 editing grammar.js and re-running tree-sitter generate, refresh it with UPDATE_GENERATED_LOCK=1 cargo test --test corpus.
  • doc/corpus-census.md — the counts the prose cites instead of quoting. Refresh with UPDATE_CORPUS_CENSUS=1 cargo test --test live_outs.

Formatting

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.

Editor support

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.

Converting from ca65

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 bind writes the extern declarations a unit needs for the symbols ca65 modules still provide, from the debug file of the previous build, and --layouts writes every .struct as a field layout.
  • tools/ca65-widths.py reports 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.py lists 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.

License

K816 is licensed under the 0BSD License. See LICENSE for details.

About

K816 is a high-level assembler (HLA) for the WDC 65816 CPU

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Contributors

Languages