-
-
Notifications
You must be signed in to change notification settings - Fork 92
Write assembly code
The goal of this guide is to explain how to write hand-coded .asm routines that live alongside PVSnesLib C code — with a focus on the stack, passing parameters, and returning values (char/u8 and short/u16).
PVSnesLib projects are usually a mix of .c files (compiled by 816-tcc) and .asm files (assembled directly by wla-65816). Both get linked together by wlalink. This means:
- Assembly functions can be called from C, and C functions can be called from assembly.
- Both sides have to agree on how arguments arrive and how a result is handed back.
PVSnesLib/816-tcc uses the hardware stack (register S) for parameters and local variables, and a memory entry (tcc__r0) for return values.
In native mode (which PVSnesLib runs in), the stack pointer S is a full 16-bit register, and the stack can live anywhere in bank $00 (PVSnesLib keeps it in low RAM, as usual). Key facts:
-
PHA/PLA,PHX/PLX,PHY/PLYpush/pop the accumulator and index registers. The size pushed/pulled depends on the currentM(accumulator) andX(index) width flags, set withSEP/REP. -
PHP/PLPpush/pop the processor status register (flags). -
PEA #imm16pushes a 16-bit immediate value directly — very handy for pushing arguments without going throughA. -
Stack-relative addressing (
addr,S) lets you read/write a byte or word at a fixed offset from the current stack pointer without popping it off. This is the mechanism used to read parameters and locals in place, e.g.:lda 3,s ; load the 16-bit value sitting 3 bytes above the current top of stack sta 5,s ; store into another stack slot
- The stack pointer offset in
addr,Sis a signed 8-bit value internally in generated code, which is why PVSnesLib/816-tccstack frames are limited to roughly 127 bytes of parameters + locals in any single function scope. Keep hand-written frames well under that. -
JSR(16-bit, same bank) pushes a 2-byte return address;RTSpops it. -
JSL(24-bit, long/far call, can cross banks) pushes a 3-byte return address;RTLpops it. Most PVSnesLib library calls are far calls (JSR.L/RTLin WLA-DX syntax), since code is spread across ROM banks.
The caller pushes arguments onto the stack before the call, typically with PEA (for constants) or PHA (after loading a value into A). The callee then reads them using stack-relative addressing — it does not pop them itself.
; C call for something like someFunc(1, 2):
pea.w 2 ; push 2nd argument
pea.w 1 ; push 1st argument
jsr.l _someFunc
; --- stack cleanup happens AFTER the call returns ---
tsc ; A = current stack pointer
clc
adc #4 ; 4 = number of bytes pushed for arguments
tcs ; S = A (restores stack pointer, discarding args)-
The caller cleans up the stack, not the callee. After the
jsr.l/rtlreturns, the caller adjustsSback up by the number of bytes it pushed. - Multi-byte values (16-bit
short/u16, pointers) are pushed as a single 16-bit unit;char/u8arguments are still generally pushed as a full stack slot for alignment/simplicity.
Once you know the layout, everything is addressed as offset,S:
- The return address sits at the bottom of the current frame (closest to where
Spointed at function entry): 2 bytes forJSR/RTS, 3 bytes forJSL/RTL. - Parameters pushed by the caller sit just above the return address.
- Locals reserved by the callee's prologue sit just below the return address (i.e. at the smallest offsets from the current
S).
A minimal, concrete layout for a far (JSL) function with one 16-bit parameter and one 16-bit local:
S+0 local variable (reserved by prologue)
S+2 return address low word \
S+4 return address bank byte / (3 bytes total, JSL/RTL)
S+5 parameter (pushed by caller before JSL)
So inside the function, after the prologue has reserved 2 bytes:
lda 5,s ; read the parameter
sta 0,s ; write the localReturn values are not put on the stack — they come back in the memory variable tcc__r0 , matching the C type width:
| C return type | Register width | How to set it up |
|---|---|---|
void |
n/a | nothing to do |
char / u8
|
8-bit accumulator |
SEP #$20 (8-bit A), load the byte into A
|
short / u16 / pointer |
16-bit accumulator |
REP #$20 (16-bit A), load the word into A
|
The compiler stages the result in a scratch/direct-page variable (referred to as tcc__r0 in generated code) before the final load into A, but for hand-written asm you can simply load the result straight into A right before RTS/RTL — there's no requirement to use that intermediate variable yourself.
; return a u8 (char) result
sep #$20 ; 8-bit accumulator
lda #42 ; result value
sta.b tcc__r0 ; put in memory
rtl
; return a u16 (short) result
rep #$20 ; 16-bit accumulator
lda #1234 ; result value
sta.w tcc__r0 ; put in memory
rtlImportant: always leave the accumulator width (SEP/REP #$20) in the state the C code expects for the return type before executing RTS/RTL.
To let C code (.c/.h) call an .asm function, and vice versa, you need matching declarations on both sides.
.include "hdr.asm" ; standard PVSnesLib/WLA-DX header, memory map, etc.
.accu 16 ; assume 16-bit accumulator at file scope (adjust with SEP/REP as needed)
.index 16
.section ".text_myfuncs" superfree
; s16 addValues(s16 a, u8 b);
addValues:
; --- prologue: no locals needed here ---
; stack layout at entry (JSL far call):
; S+0..2 return address (3 bytes)
; S+3..4 'a' (s16, pushed 2nd -> sits deeper... see note below)
; S+5 'b' (u8, pushed last -> sits nearest top)
;
; NOTE: exact offsets depend on the order your C call site pushes
; arguments in — always verify with a disassembly/.sym listing
; of the actual call site if in doubt.
rep #$20 ; 16-bit A
lda 3,s ; load 'a'
sep #$20 ; 8-bit A (to combine with u8 cleanly if needed)
clc
rep #$20
adc 5,s ; add 'b' (widened to 16-bit by the caller when pushed)
sta.w tcc__r0 ; put in memory
rtl ; result (16-bit) = return value
.ends// myfuncs.h
extern short addValues(short a, unsigned char b);#include "myfuncs.h"
short result = addValues(10, 5);816-tcc will generate the PEA/PHA + JSR.L + stack-cleanup sequence automatically; your .asm routine just needs to honor the stack layout and leave the result in A.
Going the other direction works the same way, in reverse — push arguments, JSL (or JSR if same-bank) to the label, clean the stack, read the result from A:
rep #$20
pea.w 5 ; push argument (u16-sized slot)
jsl.l padsCurrent ; call the C function (mangled/linked name — check the .sym/.h)
tsc
clc
adc #2 ; pop the 2 bytes we pushed
tcs
; result is now in tcc__r0-
Prefer registers over the stack for simple leaf functions. If a routine takes 0–2 small arguments and doesn't need to be called from
816-tcc-generated C in a generic way, it's often simpler and faster to pass values inA/X/Ydirectly and document the convention in a comment, rather than fighting with stack-relative offsets. Reserve the full stack-frame convention for functions that must interoperate transparently with C call sites. -
Save/restore what you clobber. There's no hardware-enforced "callee-saved" register set on 65816; by convention in PVSnesLib code, treat only
Aas scratch (caller-saved) unless documented otherwise, but always preserve the direct page register (D) and data bank register (B) if you change them, since library code assumes the standard PVSnesLib direct-page/bank setup. -
When in doubt, look at the generated
.asmoutput. Compile a small C function with the shape you want to replicate (setPVSNESLIB_DEBUG=1in your Makefile to get.dbg/intermediate files), and read the exact816-tccoutput for the real stack offsets in your specific WLA-DX/PVSnesLib version — the constants above are illustrative of the pattern, not guaranteed byte-for-byte across every compiler version.