24 KiB
Programming in c65gm
c65gm is a high-level language for 6502 assembly programming that combines modern programming constructs with direct hardware access. Originally designed for Commodore 64 development, it compiles to efficient 6502 assembly code.
Table of Contents
- Getting Started
- Variables and Types
- Expressions and Operators
- Control Flow
- Functions
- Memory Operations
- Code Blocks
- Preprocessor
- Writing Libraries
- Common Patterns
Getting Started
Your First C64 Program
Here's a simple complete program that changes the screen border color:
#INCLUDE <c64start.c65>
#INCLUDE <c64defs.c65>
GOTO start
FUNC main
BYTE borderColor @ $D020
borderColor = color_green
FEND
LABEL start
main()
Creating a C64 Executable
To create a runnable C64 program, always start with:
#INCLUDE <c64start.c65>
This creates a valid C64 executable with a BASIC loader (0 SYS 2064) and sets up the proper memory layout.
Program Structure
A typical c65gm program for C64 follows this pattern:
#INCLUDE <c64start.c65>
#INCLUDE <c64defs.c65> // Optional: standard C64 definitions
GOTO start // Jump over your function definitions
// Variables
BYTE counter = 0
WORD screen = $0400
// Functions - must be declared before we use them.
FUNC processData
// Do something
FEND
FUNC initialize
counter = 10
processData()
FEND
// Entry point - execution starts here
LABEL start
initialize()
Important: The GOTO start jumps over your function definitions. Without it, the CPU would try to execute directly into your function code, causing a crash. Always put GOTO start before your functions, and LABEL start before your actual program entry point.
Variables and Types
BYTE Variables
BYTE variables store 8-bit values (0-255).
BYTE count // Uninitialized (0)
BYTE speed = 5 // Initialized to 5
BYTE REGISTER temp // Register-hinted (default 0)
BYTE REGISTER scratch = 0 // Register-hinted with init value
BYTE screen @ $D020 // Memory-mapped to specific address
BYTE CONST MAX_SPEED = 10 // Constant
REGISTER Variables
The REGISTER hint tells the optimizer that a BYTE variable's value only matters as
it flows through the computation — never its stored location. The compiler can keep
the value in a CPU register (A, X, or Y) and eliminate dead stores and memory
allocations entirely.
FUNC fast_copy
BYTE REGISTER b
b = PEEK $d011
b = b | 32
POKE $d011, b // b never touches RAM — flows through A
FEND
Rules:
BYTE REGISTERis only valid insideFUNC/FENDblocks (function-local only)- Cannot be combined with
@(memory-mapped) orCONST - Cannot be referenced from
ASM,SCRIPT, orMACROblocks via|varname| WORD REGISTERis not supported (the 6502 has no 16-bit ALU register)- Without
--opt,REGISTERbehaves identically to a normalBYTE - With
--opt, the optimizer may dissolve the variable, eliminating its!8allocation and all store/load operations
WORD Variables
WORD variables store 16-bit values (0-65535).
WORD counter // Uninitialized
WORD address = $C000 // Initialized to hex value
WORD message = "Hello" // String literal (pointer to text)
WORD dataPtr = @myData // Initialized to label address
WORD screenPtr @ $FB // Zero-page pointer
WORD CONST SCREEN_RAM = $0400 // Constant
Memory-Mapped Variables
Variables can be placed at specific addresses using @. Not compatible with REGISTER.
BYTE borderColor @ $D020 // VIC-II border color
WORD irqVector @ $0314 // IRQ vector
WORD zpPointer @ $FB // Zero-page variable (fast access)
Constants
Use CONST keyword for named constants (preferred over preprocessor defines):
BYTE CONST MAX_ITEMS = 100
WORD CONST SCREEN_START = $0400
WORD CONST CHAR_ROM = $D000
// Usage
BYTE itemCount = MAX_ITEMS
screen = SCREEN_START
Expressions and Operators
Number Formats
value = 123 // Decimal
value = $FF // Hexadecimal ($ prefix)
value = %11111111 // Binary (% prefix)
Arithmetic Operators
result = 10 + 5 // Addition
result = 100 - 5 // Subtraction
Bitwise Operators
mask = value & $FF // Bitwise AND
flags = flags | $01 // Bitwise OR
toggle = value ^ $FF // Bitwise XOR
result = value << 3 // Shift left
mask = flags >> 2 // Shift right
Increment and Decrement
counter++ // Increment by 1
counter-- // Decrement by 1
index++
lives--
Critical: No Operator Precedence
Expressions evaluate strictly left to right! There is no operator precedence.
result = 2+3*4 // Evaluates as (2+3)*4 = 20, NOT 2+(3*4) = 14
value = 100-20+5 // Evaluates as (100-20)+5 = 85
These multi-term forms are folded at compile time and only work when every
term is a literal or CONST (write them without spaces). * and / are
only available in these constant expressions, not on runtime variables.
Runtime expressions involving a variable perform exactly one operation and
must be space-separated (dest = a + b). For anything more complex, use
temporary variables:
// Instead of: result = (b - c) + a
temp = b - c
result = a + temp
Control Flow
IF Statements
Basic conditional execution:
IF count == 10
result = 1
ENDIF
IF value > threshold
process()
ELSE
skip()
ENDIF
Supported comparison operators: = == <> != > < >= <=
Single-parameter IF treats 0 as false, non-zero as true:
IF running
// Executes if running != 0
update()
ENDIF
WHILE Loops
Loop while condition is true:
WHILE counter < 100
counter++
process(counter)
WEND
WHILE running
gameLoop()
WEND
WHILE x != y
x++
WEND
FOR Loops
Loop with automatic counter. The loop variable must be declared beforehand:
BYTE i
FOR i = 0 TO 10
screen = i
NEXT
FOR counter = start TO finish
process(counter)
NEXT
BREAK
Exit a loop early:
BYTE i
FOR i = 0 TO 100
IF i == 50
BREAK
ENDIF
process(i)
NEXT
WHILE running
IF error
BREAK
ENDIF
update()
WEND
Functions
Declaring Functions
FUNC initialize
BYTE temp = 0
screen = temp
FEND
FUNC setColor({BYTE color} {BYTE brightness})
borderColor = color
backgroundColor = brightness
FEND
Calling Functions
initialize()
setColor(1, 14)
process(xpos, ypos, spriteData)
printMessage("Hello world") // String literal (passed as pointer)
drawSprite(x, y, @spriteData) // Label address (passed as pointer)
Parameter Passing Modes
Parameters can have different access modes:
in:- Read-only (default if not specified)out:- Write-only (for returning values)io:- Read-write
FUNC add(in:{BYTE a} in:{BYTE b} out:{BYTE result})
result = a + b
FEND
FUNC swap(io:{BYTE x} io:{BYTE y})
BYTE temp
temp = x
x = y
y = temp
FEND
Local Variables
Variables declared inside functions are local:
FUNC calculate({BYTE value})
BYTE local = 10 // Only exists inside this function
WORD temp // Local temporary
temp = value + local
FEND
Returning from Functions
FUNC checkValue({BYTE value})
IF value == 0
EXIT // Return early
ENDIF
process(value)
FEND
Memory Operations
PEEK - Reading Memory
Read a byte from memory:
value = PEEK $D020 // Read from absolute address
byte = PEEK pointer // Read through a WORD pointer
Indexed access: Add [offset] to read at pointer+offset. The offset can
be a constant or a BYTE variable, and the pointer must be a WORD variable
in zero page:
WORD buffer @ $FB // Zero-page pointer
value = PEEK buffer[10] // Read buffer+10
char = PEEK buffer[index] // Read buffer+index
POKE - Writing Memory
Write a byte to memory:
POKE $D020, 0 // Write to absolute address
POKE pointer, value // Write through a WORD pointer
Indexed access: As with PEEK, [offset] requires a zero-page WORD
pointer. This is handy for reaching into structured data through a base
pointer:
WORD vic @ $FB
POINTER vic TO $D000 // VIC-II base register
POKE vic[$20], 2 // Write border color ($D020) = red
POKE vic[$21], 0 // Write background color ($D021) = black
PEEKW - Reading 16-bit Words
Read a 16-bit value from memory:
WORD address
address = PEEKW $FFFC // Read reset vector
WORD buffer @ $FB
value = PEEKW buffer[10] // Read word at buffer+10
POKEW - Writing 16-bit Words
Write a 16-bit value to memory:
POKEW $0314, irqHandler // Set IRQ vector
POKEW dataPtr, address // Write word through a pointer
POKEW dataPtr[2], address // Write word at dataPtr+2 (zero-page pointer)
POINTER - Setting Pointers
Set a pointer to an address:
POINTER screenPtr TO $0400
POINTER bufferPtr TO dataBuffer
POINTER funcPtr TO myFunction
Code Blocks
ASM Blocks
Inline assembly for direct hardware control:
ASM
lda #$00
sta $d020
jsr $ffd2
ENDASM
Referencing Variables in ASM
Global variables can be referenced directly:
BYTE temp = 5
ASM
lda temp
clc
adc #10
sta temp
ENDASM
Local variables (inside FUNC) need pipe delimiters |varname|:
FUNC calculate({BYTE value})
BYTE local = 10
ASM
lda |local| // Reference local variable
clc
adc value // Function parameter
sta |local|
ENDASM
FEND
#### Placing ASM Blocks After Variables
Use the `_P_ASM_AFTER_VARS` pragma to place ASM blocks after all variable storage and constant strings:
```c65
#PRAGMA _P_ASM_AFTER_VARS 1
ASM
sprite_data: !binary "sprite.bin"
!8 1,2,3,4
ENDASM
#PRAGMA _P_ASM_AFTER_VARS 0
This is useful for embedding binary data (graphics, music) with labels that should appear after the variable area.
SCRIPT Blocks
Generate assembly code at compile time using Starlark (Python-like):
// Generate a table of squares
SCRIPT
print("squares:")
for i in range(256):
print(" !8 %d" % (i * i % 256))
ENDSCRIPT
Sine Table Example
SCRIPT
import math
print("sintable:")
for i in range(256):
angle = (i * 2.0 * math.pi) / 256.0
sine = math.sin(angle)
value = int((sine + 1.0) * 127.5)
print(" !8 %d" % value)
ENDSCRIPT
Referencing Variables
Use |varname| syntax in generated assembly:
BYTE tableSize = 64
SCRIPT
print("lookup:")
for i in range(64):
print(" !8 %d" % (i * 2))
print(" // Size stored in |tableSize|")
ENDSCRIPT
File I/O
Scripts can read binary and text files at compile time using load_binary() and load_text(). These functions only allow access to files within the project folder (where the main .c65 file resides) and its subdirectories. Absolute paths and path traversal (..) are rejected.
load_binary(path, offset=0, length=0)
Reads a binary file and returns a list of integers (0-255). The optional offset parameter skips bytes at the start, and length limits how many bytes to read (0 = read to end of file).
SCRIPT
sprite = load_binary("assets/hero.spr")
print("hero_sprite:")
for i in range(0, len(sprite), 8):
row = ", ".join(["$%02x" % b for b in sprite[i:i+8]])
print(" !byte " + row)
ENDSCRIPT
load_text(path)
Reads a text file and returns a list of strings, one per line. Handles both \n (Unix) and \r\n (Windows) line endings.
SCRIPT
level = load_text("levels/lvl1.txt")
print("level_map:")
for y in range(len(level)):
print(" !text " + repr(level[y]))
ENDSCRIPT
Example — resource loading with SCRIPT LIBRARY:
SCRIPT LIBRARY
# Load resources into library globals
font_data = load_binary("assets/font.chr")
level = load_text("levels/lvl1.txt")
def emit_font():
print("font:")
for i in range(0, len(font_data), 8):
row = ", ".join(["$%02x" % b for b in font_data[i:i+8]])
print(" !byte " + row)
def emit_level():
print("level:")
for line in level:
print(" !text " + repr(line))
ENDSCRIPT
SCRIPT LIBRARY Blocks
Define reusable Starlark functions that persist across all subsequent SCRIPT blocks:
SCRIPT LIBRARY
def emit_nops(count):
for i in range(count):
print(" nop")
def emit_delay(cycles):
for i in range(cycles // 4):
print(" nop")
print(" nop")
remainder = cycles % 4
if remainder >= 3:
print(" bit $ea")
remainder -= 3
for i in range(remainder // 2):
print(" nop")
ENDSCRIPT
// Later, use the library functions:
SCRIPT
emit_nops(5)
emit_delay(10)
ENDSCRIPT
Library functions are typically placed in include files for reuse across projects. Multiple SCRIPT LIBRARY blocks accumulate.
SCRIPT MACRO Blocks
Define named, parameterized macros that expand inline when invoked:
SCRIPT MACRO delay(cycles)
if cycles < 2:
fail("Cannot delay less than 2 cycles")
emit_delay(cycles)
ENDSCRIPT
SCRIPT MACRO set_irq(handler)
print(" lda #<%s" % handler)
print(" sta $fffe")
print(" lda #>%s" % handler)
print(" sta $ffff")
ENDSCRIPT
Invoking Macros
Outside ASM blocks, use @name(args):
BYTE CONST CYCLES_PER_LINE = 63
@delay(10)
@delay(CYCLES_PER_LINE-20)
@set_irq(my_handler)
Inside ASM blocks, use |@name(args)|:
ASM
lda #$00
|@delay(8)|
sta $d020
ENDASM
Parameter Types
- Integer expressions: Numbers, constants, arithmetic - passed as int
- Labels: Bare identifiers that aren't constants - passed as string
@delay(10) // 10 passed as int
@delay(CYCLES_PER_LINE-20) // Evaluated, passed as int
@set_irq(my_handler) // "my_handler" passed as string
Error Handling
Use fail() to stop compilation with an error:
SCRIPT MACRO delay(cycles)
if cycles < 2:
fail("Cannot delay less than 2 cycles")
emit_delay(cycles)
ENDSCRIPT
Preprocessor
Comments
// Single-line comment
BYTE counter = 0 // End-of-line comment
In ASM blocks, use assembly syntax:
ASM
lda #$00 ; Assembly comment
ENDASM
In SCRIPT blocks, use StarLark (Python-like) syntax:
SCRIPT
# Python-style comment
print("hello")
ENDSCRIPT
Include Files
#INCLUDE mylib.c65 // Relative to current file
#INCLUDE lib/string.c65 // Subdirectory
#INCLUDE <stdlib.c65> // Search in C65LIBPATH
Include guards prevent multiple inclusion:
#IFNDEF __MY_LIBRARY
#DEFINE __MY_LIBRARY = 1
// Library code here
#IFEND
Define Macros
Text substitution macros:
#DEFINE MAX_SPEED = 10
#DEFINE SCREEN = $$0400 // $$ escapes to literal $
speed = MAX_SPEED // Replaced with 10
Special characters in defines:
#DEFINE SPACE = $20 // Space character (hex 20)
#DEFINE NEWLINE = $0D // Carriage return
#DEFINE HEXADDR = $$D020 // Literal "$D020" text
Note: Prefer BYTE CONST and WORD CONST over #DEFINE for constants.
Conditional Compilation
#IFDEF DEBUG
BYTE debugFlag = 1
#IFEND
#IFNDEF __LIB_INCLUDED
#DEFINE __LIB_INCLUDED = 1
// Include library code
#IFEND
Pragma Directives
Control compiler behavior:
_P_USE_LONG_JUMP: Use JMP instead of branches for large switch statements_P_USE_IMMUTABLE_CODE: Disable self-modifying code (for ROM)_P_USE_CBM_STRINGS: Use PETSCII encoding for strings_P_IGNORE_UNUSED: Suppress warnings for unused variables_P_REMOVE_UNUSED: Remove unused functions from assembly output (requires explicit pragma on each function)_P_ASM_AFTER_VARS: Place ASM blocks after all variable storage and constant strings
#PRAGMA _P_USE_LONG_JUMP 1 // Use JMP instead of branches
#PRAGMA _P_USE_IMMUTABLE_CODE 1 // No self-modifying code (for ROM)
#PRAGMA _P_USE_CBM_STRINGS 1 // Use PETSCII encoding
#PRAGMA _P_IGNORE_UNUSED 1 // Suppress unused variable warnings
#PRAGMA _P_IGNORE_UNUSED 0 // Enable unused variable warnings
#PRAGMA _P_REMOVE_UNUSED 1 // Remove function if unused
#PRAGMA _P_REMOVE_UNUSED 0 // Keep function even if unused (default)
#PRAGMA _P_ASM_AFTER_VARS 1 // Place ASM blocks after variables
#PRAGMA _P_ASM_AFTER_VARS 0 // Normal ASM block placement (default)
Debug Directives
#PRINT Compiling main module // Print during compilation
#HALT // Stop compilation
Writing Libraries
When creating a library file to be included by other programs, use this structure:
#IFNDEF __MY_LIBRARY
#DEFINE __MY_LIBRARY = 1
GOTO lib_mylib_skip // Jump over library code
// Library variables
WORD lib_mylib_buffer
// Library functions
FUNC lib_mylib_initialize
lib_mylib_buffer = $C000
FEND
FUNC lib_mylib_process({BYTE value})
// Do something
FEND
// Skip label - execution continues here after GOTO
LABEL lib_mylib_skip
#IFEND
Key points for libraries:
- Include guard - Use
#IFNDEFto prevent multiple inclusion - GOTO skip - Jump over all library code immediately
- LABEL skip - Place at the end so GOTO jumps past everything
- Naming convention - Prefix all names with
lib_yourlib_to avoid conflicts
This ensures when someone does #INCLUDE <mylib.c65>, the library functions are defined but not executed.
Common Patterns
Complete Working Example
Here's a complete C64 program showing all the pieces together:
#INCLUDE <c64start.c65>
#INCLUDE <c64defs.c65>
GOTO start
// Variables
BYTE frameCount = 0
WORD screenPtr @ $FB
// Functions
FUNC initialize
BYTE borderColor @ $D020
borderColor = color_black
POINTER screenPtr TO $0400
FEND
FUNC updateScreen
BYTE color
color = frameCount & $0F
POKE screenPtr, color
frameCount++
FEND
// Entry point
LABEL start
initialize()
WHILE 1
updateScreen()
WEND
Screen Manipulation
WORD CONST SCREEN = $0400
WORD CONST COLOR_RAM = $D800
BYTE CONST SCREEN_WIDTH = 40
BYTE CONST SCREEN_HEIGHT = 25
FUNC clearScreen
WORD screenPtr @ $FB
POINTER screenPtr TO SCREEN
WORD remaining = 1000
WHILE remaining > 0
POKE screenPtr, 32 // Space character
screenPtr++
remaining--
WEND
FEND
String Handling
// Print null-terminated string
FUNC printString({WORD textPtr})
BYTE char
char = PEEK textPtr
WHILE char != 0
ASM
lda |char|
jsr $FFD2 // CHROUT
ENDASM
textPtr++
char = PEEK textPtr
WEND
FEND
Sprite Manipulation
WORD CONST VIC2 = $D000
BYTE spriteX @ VIC2+0
BYTE spriteY @ VIC2+1
BYTE spriteEnable @ VIC2+21
FUNC enableSprite({BYTE spriteNum})
BYTE mask
BYTE i
mask = 1
FOR i = 0 TO spriteNum
mask = mask << 1
NEXT
spriteEnable = spriteEnable | mask
FEND
Zero Page Optimization
For frequently accessed pointers, use zero page:
WORD fastPtr @ $FB // Zero page = fast pointer access
FUNC processBuffer({WORD buffer} {BYTE size})
POINTER fastPtr TO buffer
WHILE size > 0
BYTE value
value = PEEK fastPtr
// Process value
fastPtr++
size--
WEND
FEND
Interrupt Handlers
The C64 kernal calls the IRQ vector at $0314 on every interrupt. Because
the kernal saves the CPU registers before calling the vector, your handler
does not need to push/pull A/X/Y itself. When finished, chain into the
kernal so it restores the registers and returns from the interrupt:
jmp $ea31— let the kernal do its full IRQ work (scan keyboard, blink cursor, update the jiffy clock, ...) and thenRTIjmp $ea81— skip that work; just restore the saved registers andRTI
Once the vector is installed it keeps firing in the background, so the main
program can simply return to BASIC with SUBEND — the handler stays live.
WORD CONST IRQ_VECTOR = $0314
WORD handler = @myIRQ // Address of our IRQ handler
FUNC installIRQ
ASM
sei // Disable interrupts while we patch
ENDASM
POKEW IRQ_VECTOR, handler // Point the vector at our handler
ASM
cli // Re-enable interrupts
ENDASM
FEND
LABEL start
installIRQ()
SUBEND // Return to BASIC; the IRQ stays installed
// No register saving needed — the kernal already did it.
LABEL myIRQ
ASM
inc $0400 // Do the IRQ work
jmp $ea31 // Let the kernal finish the IRQ
ENDASM
See examples/irq_demo/ for a complete, buildable version.
Lookup Tables
Generate tables at compile time:
ASM
colorTable:
ENDASM
SCRIPT
colors = [0, 1, 15, 12, 11, 9, 2, 8]
for c in colors:
print(" !8 %d" % c)
ENDSCRIPT
Delay Loops
FUNC delay({BYTE frames})
BYTE raster @ $D012
BYTE oldRaster
WHILE frames > 0
oldRaster = raster
WHILE raster == oldRaster
// Wait for raster to change
WEND
frames--
WEND
FEND
Bit Manipulation
// Set bit
flags = flags | %00000001 // Set bit 0
// Clear bit
flags = flags & %11111110 // Clear bit 0
// Toggle bit
flags = flags ^ %00000001 // Toggle bit 0
Best Practices
1. Use Constants for Magic Numbers
// Bad
POKE $D020, 5
// Good
BYTE CONST COLOR_GREEN = 5
BYTE borderColor @ $D020
borderColor = COLOR_GREEN
2. Zero Page for Performance
Place frequently accessed pointers in zero page ($00-$FF):
WORD screenPtr @ $FB // Fast indexed access
WORD tempPtr @ $FD
3. Watch Expression Evaluation Order
Remember: left-to-right evaluation, no precedence!
// Constant expressions fold left-to-right at compile time.
// Write them without spaces:
result = 2+3*4 // = (2+3)*4 = 20, not 14
// Runtime expressions (with variables) do ONE operation per
// statement and must be space-separated. Use temps to order them:
temp = b + c
result = a + temp
4. Include Guards
Always use include guards in library files:
#IFNDEF __MY_LIB
#DEFINE __MY_LIB = 1
// Library code
#IFEND
5. Comment Your Code
// Explain what and why, not how
FUNC calculateTrajectory({WORD velocity} {BYTE angle})
// Use fixed-point math (8.8 format)
// because we don't have floating point
WORD xVel
WORD yVel
// ...
FEND
6. Use Meaningful Names
// Bad
BYTE x
BYTE y
// Good
BYTE playerXPos
BYTE playerYPos
Further Resources
- See
commands.mdfor complete command reference - See
syntax.mdfor detailed syntax rules - Check the
lib/directory for example library code - Set
C65LIBPATHenvironment variable to specify library search paths
Quick Reference Card
// Variables
BYTE varName = 10
WORD address = $C000
BYTE CONST MAX = 100
// Control Flow
IF x == 10
// code
ENDIF
WHILE x < 100
x++
WEND
FOR i = 0 TO 10
// code
NEXT
// Functions
FUNC myFunc(in:{BYTE param1} out:{BYTE result})
result = param1 + 1
FEND
// Memory
value = PEEK $D020
POKE $D020, 5
address = PEEKW $FFFC
POKEW $0314, handler
// Operators
+ - // Arithmetic (runtime + constants)
* / // Multiply/Divide (constant expressions only)
& | ^ << >> // Bitwise / shift
++ -- // Increment/Decrement
== != < > <= >= // Comparison
// Blocks
ASM
// assembly code
ENDASM
SCRIPT
# Starlark code
ENDSCRIPT
SCRIPT LIBRARY
def my_func():
print(" nop")
ENDSCRIPT
SCRIPT MACRO name(param)
my_func()
ENDSCRIPT
// Macro invocation
@name(10) // Outside ASM
|@name(10)| // Inside ASM