Skip to content

Cat Assembly Reference

The Cat assembler (catasm) takes one or more .cat source files and emits a flat ROM binary alongside an optional debug-symbol file. Source is line-oriented and case-insensitive for mnemonics and register names.

Invocation:

Terminal window
catasm <input.cat> [-o output.bin]

The assembler always writes a sibling <output>.debug file containing JSON debug symbols (see Debug Symbols below). The reference VM picks these up automatically when launched with --debug.

You can also try the assembler and VM directly in the browser using the Assembly Playground. It uses the same CatAssembler and CatVM implementations compiled to WebAssembly.

Labels just define absolute memory addresses that can then be used as constants throughout the code. A label is defined by writing the label name followed by a colon (:) at the beginning of a line. For example:

start:
; code here

Labels can be used in place of immediate values in instructions. For example:

JMP start ; Jump to the address defined by the label 'start'

Labels must be unique within a program. Duplicate labels will error.

Cat assembly supports three label kinds:

  • Scoped global labels (default): main:
    These are globally visible and define the local-label scope.
  • Local labels: .loop:
    These are only visible inside the nearest scoped global label.
  • Unscoped global labels: $print_char:
    These are globally visible but do not change the current local-label scope.

Local labels start with a dot (.). For example:

main:
.loop:
; code here
JMP .loop ; Jump to the local label '.loop'

Local labels cannot be duplicated within the same global label scope, but can be reused in different global label scopes.

Unscoped global labels are useful for shared helpers/constants when you do not want to “break out” of the current scoped-global context:

main:
.loop:
CALL $print_char
JMP .done
$print_char:
; helper routine
RET
.done:
JMP .loop

You can reference unscoped global labels the same way in expressions (JMP $entry, D32 $table_start, etc.). Labels can be defined before or after they are used in the code. The assembler will resolve the addresses during assembly.

Commands are supported, all text after a semicolon (;) on a line is considered a comment and ignored by the assembler. For example:

MOV R1, R2 ; This is a comment
; This entire line is a comment

Data directives are used to directly insert data instead of encoding an instruction. The following data directives will directly define data in memory:

  • D8 (Define Byte): Defines one or more bytes (8 bits each).
  • D16 (Define Short): Defines one or more shorts (16 bits each).
  • D32 (Define Word): Defines one or more words (32 bits each).
  • DSTR (Define String): Defines bytes from a string literal, not null-terminated by default.
  • DFILE (Define File): Inlines the raw contents of an external file into the ROM at the current position. The path is resolved relative to the directory containing the source file.
  • RES8 (Reserve Byte): Places a certain number of 0x00 bytes in the file.
  • RES16 (Reserve Short): Places a certain number of 0x0000 shorts in the file.
  • RES32 (Reserve Word): Places a certain number of 0x00000000 words in the file.

For examples:

mydata:
D8 0x12, 0x34, 0x56 ; Defines three bytes
D16 0x1234, 0x5678 ; Defines two shorts
D32 0x12345678, 0x9ABCDEF0 ; Defines two words
DSTR "Hello, World!\n\0" ; Defines bytes for the string (including null terminator and newline)
DFILE "sprite.bin" ; Inlines the raw bytes of sprite.bin at this position
RES8 4 ; Same as D8 0, 0, 0, 0
RES16 4 ; Same as D16 0, 0, 0, 0
RES32 3 ; Same as D32 0, 0, 0
Register Use
r0 return value
r1 first argument
r2 second argument
r3 third argument
stack the rest of the arguments

r0-3 inc is clobbered (Caller preserved)
rest is not clobbered (Callee preserved)

For stack args they should be pushed right to left so that they can be popped in the natural order.

For example:

1st Arg: a
2nd Arg: b
3rd Arg: c
4th Arg: d
5th Arg: e
6th Arg: f

then:

mov r1, a
mov r2, b
mov r3, c
push f
push e
push d
call someFunc

To specify that you want to access memory, wrap the address source in []. For example:

MOV R1, [R2] ; Load the value from the memory address in R2 into R1
MOV [0x1000], R3 ; Store the value in R3 into memory address 0x1000

Any constant, label, or number literal may be used where numbers go. For example:

JMP 0x00
JMP main
JMP variablename

are all valid, as long as those labels/constants exist.

You may define constants using

#const VAR_NAME, value

The value of the constant can also be any numerical input, including a label.

As well as using a mix of these types, you may also combine them in mathematical expressions, that will be evaluated at compile time. The order in which you define constants is also meaningless, you may use them before they are defined. For example:

#const A, 5
#const B, A*7 + 1
#const C, B-main
main:
JMP A+C/B

Although this would likely cause a runtime error this is completely valid syntax. And all these values will be evaluated just fine. But be careful, circular dependencies will cause errors.

You may define small code snippets called macros which can be used like an instruction would and get expanded at assemble time.

#macro debug, 1
push r1
mov r1, $1
int 0x90
pop r1
#endmacro

Here is an example of a debug macro which preserves r1 and debug prints the number passed through. To use it you would simply write: debug SOMENUMBER or debug r1.

The , 1 specifies how many arguments the macro has. Each argument is referenced as $NUM where NUM is the number of the argument starting from 1 (first arg is $1). These arguments are textually replaced at assemble time, so you can have whatever you like in them.

Example macro that prints 3 numbers:

#define A, 7+8
#macro print_three, 3
mov r1, $1
int 0x90
mov r1, $2
int 0x90
mov r1, $3
int 0x90
#endmacro
main:
mov r4, 0xFF
print_three A, main - 3, r4

You may pull in another source file as if it had been pasted at the point of the directive:

#include "std.cat"
#include "graphics/sprites.cat"

The path is resolved relative to the directory containing the source file performing the include. Included files participate in the same global label and constant namespace, so they can refer to (and be referred to by) anything in the including file.

There is no include guard – including the same file twice will produce duplicate-label errors. The convention is to include each support file exactly once from a top-level entry file.

#line marks the assembly that follows as having been generated from a higher-level language, so that debug symbols can point at the original source instead of the generated assembly. It is emitted automatically by the Catnip compiler; you only need it if you are writing your own code generator that targets Cat Assembly.

#line 42, "src/player.nip"
mov r1, 5
add r1, r2 ; both instructions map to player.nip line 42
#line 43, "src/player.nip"
call update ; maps to player.nip line 43
#line default
d8 0, 0, 0, 0 ; no high-level origin
  • The file argument is optional: #line 43 keeps whichever file is already in effect.
  • The mapping stays in effect for every following line until the next #line, rather than advancing line by line. One high-level statement usually compiles to many instructions, and they all belong to that statement.
  • #line default clears the mapping again, for generated data that has no source-level origin.
  • The directive is scoped to the file it appears in. Included files and macro bodies get their own mapping; a macro expansion inherits the source location of the line that invoked it.

The directive assembles to nothing – it only affects the debug symbols described below.

The conditional jump family (jmp, jz/je, jnz/jne, jul, jule, jug, juge, jil, jile, jig, jige) and call accept a single address-shaped operand in assembly. The assembler automatically encodes the underlying two-argument form (register, immediate):

  • jmp label – encoded as (0xFF, label). The CPU treats register 0xFF as “no base”, so this is an absolute jump.
  • jmp r1 – encoded as (r1, 0). Jumps to whatever absolute address r1 holds.
  • jmp r1 + label– encoded as (r1, label). Useful for jump tables.

The same shorthand applies to call.

Whenever the assembler produces an output file it also writes a sibling <output>.debug JSON file containing a DebugTable:

{
"Symbols": [
{ "FilePos": 0, "File": "main.cat", "Line": 12, "RawLine": "mov r1, 5" },
{ "FilePos": 6, "File": "main.cat", "Line": 13, "RawLine": "add r1, r2" },
{ "FilePos": 12, "File": "main.asm", "Line": 40, "RawLine": "call update",
"SourceFile": "src/player.nip", "SourceLine": 43 }
],
"Labels": {
"main": 0,
"loop": 16
}
}
  • Symbols records, for every assembled instruction, the byte offset in the output (FilePos), the file and line it was assembled from (File, Line), and the un-tokenised text of the line that produced it (RawLine).
  • File is required to make sense of Line: in a project that uses #include, several files contain a line 22, so a line number alone identifies nothing.
  • SourceFile and SourceLine are present only when the instruction came from a higher-level language, and record where in that language it originated (see #line above). Hand-written assembly omits them, because File and Line already point at the original source.
  • Tools should show the user SourceFile/SourceLine when present and fall back to File/Line otherwise. DebugSymbol.EffectiveLocation does exactly this.
  • Labels is a flat map from label name to its assembled address. It contains both global and local labels (with their fully-qualified names).

The file is JSON for ease of consumption by external tooling. The reference VM loads it automatically when launched as catlaunch debug --rom <rom>, enabling source-line lookup, symbolic breakpoints (break symbol main, break line 42, or break line display.cat:42 when a bare line number is ambiguous), and stack traces that show function names instead of bare addresses.

The same file drives the editor debugger (catlaunch dap, see Debugging): source-line breakpoints, stepping and stack traces in VS Code, Neovim or CatIDE all resolve through it.

The same record types live in the CatData project so that any other tool (Catnip compiler, future debuggers, IDE plugins) can produce or consume .debug files without depending on the assembler.