C64jasm

Table of contents:

Overview

C64jasm is a symbolic assembler for the Commodore 64 that supports:

C64jasm is free and open source -- its source code can be found here: c64jasm on GitHub.

Installation

In order to use the c64jasm assembler, you need to install the following:

Furthermore, if you wish to use c64jasm with VSCode, you should also install:

Assembler installation: npm install -g c64jasm

Upon successful installation, running c64jasm --help in your shell should work.

VSCode extension: Search for c64jasm in the VSCode marketplace and install.

VICE: See the VICE website for download and installation instructions. Once you have it installed, make sure the VICE emulator binary x64 is in your PATH.

Getting started

Assuming you successfully installed the C64jasm command line compiler, you should be able to compile and run some code. Let's build the sprites sample from under examples/sprites/:

git clone https://github.com/nurpax/c64jasm
cd c64jasm/examples/sprites
c64jasm --out sprites.prg sprites.asm
x64 sprites.prg

You should see something like this in your VICE window:

If you installed the necessary VSCode parts of VSCode, you should be able to load this example project in VSCode and build it with Ctrl+Shift+P + Tasks: Run Build Task. Build errors will be reported under the Problems tab and you should be able to hit F5 to start your program in VICE.

Command line usage

Run c64jasm --help for all c64jasm command line options.

Basic usage:

c64jasm --out output.prg source.asm

where output.prg is the desired output .prg filename and source.asm is the assembly source you want to compile.

Automatic recompilation (watch mode)

Like many modern compiler tools, c64jasm supports "watch mode". Watch mode automatically recompiles your source code when any of the input source files change. To use watch mode, invoke c64jasm with the --watch <DIR> argument as follows:

c64jasm --out output.prg --watch src src/source.asm

C64jasm will watch the directory specified with --watch <DIR> (and its subdirectories) for any changes and recompile when anything changed. Changes to all types of input files (.asm files, plugin .js files, files loaded by .js extensions, !include/binary'd files, etc.) are considered as rebuild triggers.

A good project structure that makes it easy to work with watch mode is to place all your source files and assets under a single root directory, say src. This makes it easy to specify the watched directory with a single --watch src argument.

Watch mode works well with VSCode. The .vscode configs for examples/ are setup to use watched compiles.

Macro assembler

C64jasm has fairly extensive symbolic macro assembly support. This includes macros, compile-time variables, for-loops, if/else, and source and binary file inclusion.

Assembler pseudo directives start with a bang !. Examples: !let, !if, !include.

Labels and nested label scopes

; Clear the screen RAM (all 1024 bytes)
clear_screen: {
    lda #$20
    ldx #0
loop:
    sta $0400, x
    sta $0400 + $100, x
    sta $0400 + $200, x
    sta $0400 + $300, x
    inx
    bne loop
    rts
}

A label followed by braces {} starts a new scope. Labels declared inside the braces will be local to that scope. Labels declared within such a scope can still be referenced by using the namespacing operator ::, e.g.,

memset256: {
    ldx #0
loop:
    sta $1234, x
ptr:
    inx
    bne loop
    rts
}

; Use self-modifying code to set target ptr
; for a memset

    lda #<buf           ; take lo byte of 'buf' address
    sta memset256::ptr-2
    lda #>buf           ; take hi byte of 'buf' address
    sta memset256::ptr-1
    jsr memset256

buf: !fill 256, 0

You can guard a whole file inside a scope if you start the source file with !filescope:

; Contents of util.asm
!filescope util

!macro inc_border() {
    inc $d020
}

Using util.asm from another file:

; Contents of main.asm
!include "util.asm"

    +util::inc_border()

Data directives

Emitting bytes/words:

foo:  !byte 0     ; declare 8-bit
bar:  !word 0     ; declare 16-bit int (2 bytes)
baz:  !byte 0,1,2 ; declare bytes 0,1,2

baz_256: ; 256 bytes of zero
    !fill 256, 0

Including other source files:

!include "macros.asm"

Including binary data:

!binary "file1.bin"       ; all of file1.bin
!binary "file2.bin",256   ; first 256 bytes of file
!binary "file2.bin",256,8 ; 256 bytes from offset 8

Variables

You can declare a variable with !let. You can use standard C operators like +, -, *, /, <<, >>, &, |, ~ with them.

!let num_sprites = 4

    lda #(1 << num_sprites)-1
    sta $d015

Variables take on JavaScript values such as numbers, strings, arrays and objects. We will explore later in this document why array and object values are useful.

If...else

Conditional assembly is supported by !if/elif/else.

!let debug = 1

!if (debug) { ; set border color to measure frame time
    inc $d020
}
    ; Play music or do some other expensive thing
    jsr play_music
!if (debug) {
    dec $d020
}

For-loops

Use !for to repeat a particular set of instructions or data statements.

Repeating code generation. For-loops are typically written using the built-in range() function that returns an array of integers. This works similar to Python's range built-in.

!let xyptr = $40   ; alias zero-page $40 to xyptr

; shift left xyptr by 5 (e.g., xyptr<<5)
!for i in range(5) {
    asl xyptr
    rol xyptr+1
}

Lookup table generation:

    lda #3            ; index == 3
    tax
    lda shift_lut, x  ; A = 1<<3

; Create a left shift lookup table
shift_lut:
    !for i in range(8) {
        !byte 1<<i
    }

Macros

Macros are declared using the !macro keyword and expanded by +macroname().

; move an immediate value to a memory location
!macro mov8imm(dst, imm) {
    lda #imm
    sta dst
}

+mov8imm($40, 13)  ; writes 13 to zero page $40

Any labels or variables defined inside a macro definition will be local to that macro. For example, the below code is fine -- the loop label will not conflict:

; clear 16 bytes starting at 'addr'
!macro memset16(addr) {
    ldx #15
loop:
    sta addr, x
    dex
    bpl loop
}

+memset16(buffer0)
+memset16(buffer0) ; this is ok, loop will not conflict

However, sometimes you do want the macro expanded labels to be visible to the outside scope. You can make them visible by giving the (normally anonymous) macro expansion scope a name by declaring a label on the same line as your macro expand:

; A = lo byte of memory address
; B = hi byte of memory address
clear_memory: {
    sta memset::loop+1
    stx memset::loop+2
memset: +memset16($1234)  ; macro labels are under memset
    rts
}

C64jasm JavaScript

Extending the assembler with JavaScript was the primary reason why C64jasm was built. This is a powerful mechanism for implementing lookup table generators, graphics format converters, etc.

This section will be expanded to cover various uses such as computing sine tables, importing sprite graphics .SPD files, loading in SID music, etc.

Learning resources on c64jasm extensions:

Making extensions

A c64jasm extension is simply a JavaScript file that exports a function ("default" export) or a JavaScript object containing functions (named exports). The functions can be called from assembly and their return values can be operated on using standard c64jasm pseudo ops.

Minimal example:

math.js:

module.exports = {
    square: ({}, v) => {
        return v*v;
    }
}

test.asm:

!use "math" as math
!byte math.sqr(3)  ; produces 9

Here's another example. Here we'll compute a sine table (see examples/sprites). This extension uses the JavaScript module "default export", ie. it exports just a single function, not an object of function properties.

sintab.js:

module.exports = ({}, len, scale) => {
    const res = Array(len).fill(0).map((v,i) => Math.sin(i/len * Math.PI * 2.0) * scale);
    return res; // return an array of length `len`
}

foo.asm:

!use "sintab" as sintab
!let SIN_LEN = 128
!let sinvals = sintab(SIN_LEN, 30)
sintab:
!for v in sinvals {
    !byte v
}

JavaScript / assembly API

An extension function is declared as follows:

(context, ...args) => { return ... };

For example, if you're defining an extension function that takes one input argument, it must be declared as:

(context, arg0) => { return ... };

C64jasm calls an extension function with a context value that contains some extra functions for the extension to use. The rest of the arguments (...args) come from the assembly source invocation. For example:

!let v = math.sqr(3)

will be called as:

// const sqr = (context, arg0) => return arg0*arg0;
sqr(context, 3);

If you don't need anything from the context parameter in your extension, you can declare your extension function like so: ({}, arg0) => return arg0*arg0;

What is the context parameter?

The context parameter contains functionality that an extension can use to load input files. It may also be extended to contain functions for error reporting.

Currently (c64jasm 0.3), the context object contains the following properties:

A well-behaving extension would use these to load input files as follows:

const loadJson = ({readFileSync, resolveRelative}, fname) => {
    const json = JSON.parse(readFileSync(resolveRelative(filename)));
    return json;
}
module.exports = loadJson;

A relative filename is relative to the location of the assembly source file that called the extension. E.g., assuming the following folder structure:

src/
  main.asm
  assets/
    petscii.json

Consider calling an extension with a filename assets/petscii.json from main.asm:

!use "json" as json
!let j = json("assets/petscii.json")

Suppose you invoke c64jasm outside of the src directory like: c64jasm ./src/main.asm. As main.asm is being compiled, c64jasm knows it resides in ./src/main.asm and with resolveRelative, an extension knows how to resolve assets/petscii.json to ./src/assets/petscii.json.

Why do I need context.readFileSync?

You might be asking: why do I need context.readFileSync when I could just as well import Node's readFileSync and use that.

Using the c64jasm provided I/O functions is necessary as it allows for c64jasm to know about your input files. For example, if you're running c64jasm in watch mode, it can cache all your input files if they didn't change since the previous compile.

Rules of authoring extensions

Breaking the above rules may lead to inconsistent results. This is because c64jasm aggressively caches the results of plugin invocations in watched compile mode.

Release history

c64jasm 0.4.0 (released on 2019-06-29):

c64jasm 0.3.0:

c64jasm 0.2.0: