Assembly Interop
R65 is designed to live alongside hand-written 65816 assembly, not replace it. There are three ways to reach the metal, in increasing order of scope:
asm!— drop raw instructions inside a function.#[hw]— bind a variable to a memory-mapped hardware register.include_asm!+extern— assemble an external.sfile and call into it with a type-checked ABI.
asm! — Inline Assembly
asm! embeds raw 65816 instructions directly into the surrounding function.
Each argument is one instruction; pass several to emit a sequence:
asm!("WAI"); // Single instruction
asm!("PHP", "WAI"); // Multiple instructions
asm!("NOP", "NOP", "NOP"); // Three NOPs
Format string substitution
Named parameters let you build instructions at compile time. Placeholders use
{name} syntax and resolve to string or integer literals (not identifiers or
expressions):
asm!("LD{REG} #{VAL}", REG="A", VAL=42); // → LDA #42
asm!("ST{REG} $2100", REG="X"); // → STX $2100
Named arguments apply to every instruction in the same asm! invocation.
Register clobbering
The compiler treats each asm! invocation as a black box that clobbers all
registers. It will not assume any value survives the block. If you need a
register preserved across inline assembly, save and restore it yourself:
asm!("PHA"); // save A
asm!("JSR my_routine");
asm!("PLA"); // restore A
Combining with stringify! in macros
Because format arguments only accept literals, use stringify!($param) to turn
a macro fragment into a string:
macro_rules! push($reg:reg) {
asm!("PH{R}", R=stringify!($reg));
}
push!(A); // PHA
push!(X); // PHX
#[hw] — Memory-Mapped Hardware Registers
#[hw(addr)] binds a static mut to a fixed hardware I/O address. Every read
and write is emitted directly to that address — the compiler never caches,
eliminates, or reorders the access. This is the volatile semantics you need
for talking to SNES PPU/APU/DMA registers:
#[hw(0x2100)]
static mut INIDISP: u8; // Display control
#[hw(0x4210)]
static mut RDNMI: u8; // NMI flag / CPU version
#[hw(0x4212)]
static mut HVBJOY: u8; // H/V blank and joypad status
The address parameter is required — #[hw] without an address is a compile
error.
Because accesses are never optimized away, #[hw] variables work correctly in
polling loops where a normal variable read would be hoisted out:
// Always re-reads the hardware; the loop is never optimized into a single read
loop {
if HVBJOY & 0x01 != 0 { break; }
}
#[hw] registers live in bank 0 and produce near pointers when their address
is taken.
include_asm! + extern — Linking External Assembly
For routines too large or too performance-critical to inline, write them in a
separate .s file, pull them into the build with include_asm!, and describe
their entry points with extern declarations so R65's type checker and code
generator can call them as if they were native functions.
include_asm!
include_asm!("vendor/sound.s");
- The file is assembled into the current bank/section window, so the
.sfile should not carry its own.BANKdirectives. - Paths resolve relative to the including
.r65file, then against the-Iinclude search paths passed tor65c(same rules asinclude_bytes!).
extern fn
extern fn declares a routine implemented in assembly. It is body-less and
ends in a semicolon — a { } block is a parse error. The parameter and return
syntax is identical to a normal function, so you pick the calling convention
(register-bound, variable-bound, or stack) right in the signature:
extern fn sound_tick(a @ A: u8) -> u8; // near: JSR into the current bank
extern far fn sound_play(id @ A: u8); // far: JSL (24-bit), callable cross-bank
extern fnlowers toJSR symbol(near, same bank as the caller).extern far fnlowers toJSL symbol(24-bit, callable from any bank).- Calling a near
extern fnfrom a different bank is a compile-time error, exactly like a near R65fn. Declare itextern far fnto call across banks.
By default the compiler assumes the assembly callee clobbers every register.
Add #[preserves(...)] to assert what the routine actually leaves untouched —
the compiler trusts the annotation and skips the save/restore:
#[preserves(X, Y)]
extern fn sound_tick(a @ A: u8) -> u8;
extern static
extern static names data whose storage is owned by the assembly file (a label
defined in the .s). It cannot carry storage attributes (#[ram],
#[zeropage], etc.) — placement is the assembler's job. Reads and writes
resolve to the bare label:
extern static SONG_TABLE: [u8; 64]; // immutable table in the .s file
extern static mut SOUND_RAM: [u8; 256]; // mutable region owned by the .s file
A type annotation is required; extern static NAME; without : T is a parse
error, and initializers are not allowed (the assembly file provides the data).
Putting it together
include_asm!("game_helpers.s");
extern fn add_asm(a @ A: u8, b @ X: u16) -> u8;
extern far fn audio_tick();
extern static PALETTE: [u8; 32];
fn main() {
A = add_asm(1, 2);
audio_tick();
A = PALETTE[0];
}
The matching game_helpers.s defines add_asm, audio_tick, and the
PALETTE label. R65 type-checks every call site against the extern
signatures and emits the right JSR/JSL and label references.