Skip to content

Write assembly code

Alekmaul edited this page Jul 9, 2026 · 2 revisions

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

Why is it important

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.

Stack basics on the 65C816

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 / PLY push/pop the accumulator and index registers. The size pushed/pulled depends on the current M (accumulator) and X (index) width flags, set with SEP/REP.
  • PHP / PLP push/pop the processor status register (flags).
  • PEA #imm16 pushes a 16-bit immediate value directly — very handy for pushing arguments without going through A.
  • 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,S is a signed 8-bit value internally in generated code, which is why PVSnesLib/816-tcc stack 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; RTS pops it.
  • JSL (24-bit, long/far call, can cross banks) pushes a 3-byte return address; RTL pops it. Most PVSnesLib library calls are far calls (JSR.L / RTL in WLA-DX syntax), since code is spread across ROM banks.

The PVSnesLib / 816-tcc calling convention

Passing parameters

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/rtl returns, the caller adjusts S back 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/u8 arguments are still generally pushed as a full stack slot for alignment/simplicity.

Accessing parameters and locals

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 S pointed at function entry): 2 bytes for JSR/RTS, 3 bytes for JSL/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 local

Returning a value

Return 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
    rtl

Important: always leave the accumulator width (SEP/REP #$20) in the state the C code expects for the return type before executing RTS/RTL.

Declaring a C-callable function in WLA-DX

To let C code (.c/.h) call an .asm function, and vice versa, you need matching declarations on both sides.

In the .asm file

.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

In the .h file (so C code can call it)

// myfuncs.h
extern short addValues(short a, unsigned char b);

Calling an asm function from C

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

Calling a PVSnesLib/C function from your own .asm

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

Practical tips

  • 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 in A/X/Y directly 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 A as 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 .asm output. Compile a small C function with the shape you want to replicate (set PVSNESLIB_DEBUG=1 in your Makefile to get .dbg/intermediate files), and read the exact 816-tcc output 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.

Clone this wiki locally