Programming the MEGA65 — Chapter 9: Mixing BASIC with Machine Language

Oct 4, 2026 · Updated Oct 11, 2026 · @Craig Taylor

Introduction

A MEGA65 program does not have to be written entirely in one language. BASIC 65 is excellent at the parts of a program that change often and run rarely: menus, setup, file handling, scoring, game logic that runs once a frame. Machine language is excellent at the parts that run constantly or must be exact: drawing, collision checks, sorting, decompression, interrupt handlers. Writing each part in the language that suits it gives a program that is both quick to develop and quick to run.

The hard part is the boundary between them. This chapter covers everything that happens at that boundary:

Chapter 6 introduced several of the BASIC 65 features used here: BANK, SYS, RREG, BLOAD and POINTER. This chapter puts them to work and ends with a complete program that uses each of them.

Throughout, the machine-language routines are cooperative in the sense of Chapter 8: they are called by SYS, they run with the SYS mapping, and they restore everything they change before returning with RTS.

Where to Put Machine Code

On the C64 the usual answer was $C000, a 4KB block BASIC never touches. The MEGA65 has no single equivalent, but it has several good places, each with a different trade-off.

Where code can live depends on what SYS can reach. SYS builds a fixed memory map for each call, and only part of it follows the bank chosen with BANK or a long address (MEGA65 Book, pp. 14-18, 14-43):

CPU address During SYS
$0000-$1FFF Always bank 0
$2000-$7FFF The selected bank, 0 to 5
$8000-$BFFF Always bank 0
$C000-$FFFF ROM and I/O, in every bank

A long address does the same job as BANK: SYS $47000 calls $7000 in bank 4. SYS cannot reach attic RAM at all. With that in mind, the main places for machine code are:

Location Size Called with Notes
Bank 0, $1600-$1EFF 2.25KB SYS $1600 from any bank The Book's recommended home for routines; also the only range usable with SYS TO ... I1 in ROMs 920414 to 920418
After the BASIC program in bank 0 Varies SYS with its address Moves if the program is edited; ideal for single-file programs
Banks 4 and 5, $x2000-$x7FFF 24KB per bank SYS $42000, or BANK and SYS Reserve banks 4 and 5 with MEM; shared with BASIC's graphics
Anywhere in the first 384KB Any SYS TO with an O offset Code must expect to run at its mapped address
Attic RAM 8MB Not directly Storage only: copy into chip RAM to run

Bank 0 below the program

The area from $1600 to $1EFF lies below the start of BASIC text, and during SYS it is always visible, whatever bank BASIC has selected. The Book recommends it for machine-code subroutines called from BASIC (MEGA65 Book, pp. 14-18, 14-19). In ROMs 920414 to 920418 it was also the only range usable with SYS TO's BASIC-active mode, described later; from 920419 that mode works anywhere SYS can reach (MEGA65 ROM change log). The MEGA65 team's own unit-test module for BASIC programs loads here too (MEGA65 Developer Guide).

It is a shared resource rather than a reserved one. Resident tools such as on-device assemblers may claim part of it, so a program that will be used alongside other software should keep its footprint small or use another bank.

After the BASIC program

Machine code can be appended to the end of a BASIC program in bank 0 and saved with it as one file. This is a common way to build a single-file program, and the next section shows how. The only limitation is that the code moves if the BASIC program above it changes length, so it suits finished programs better than ones still being edited.

Banks 4 and 5

For anything larger, banks 4 and 5 offer 24KB each in the $2000-$7FFF window that SYS maps from the selected bank. Banks 1 to 3 are not available: bank 1 holds BASIC's arrays and strings, and banks 2 and 3 hold the ROM (MEGA65 Book, memory chapter). Banks 4 and 5 are reserved with MEM as described in Chapter 6 so BASIC's graphics commands leave them alone. Code at $42000 is called with SYS $42000, or with BANK 4 : SYS $2000. Code at $40000-$41FFF or $48000-$4FFFF cannot be called with plain SYS, because those offsets fall outside the bank's window; $8000-$BFFF during SYS is always bank 0.

Bank 0 itself can hold code above the BASIC program up to $BFFF, provided it stays clear of the end of the program text and BASIC's work areas (MEGA65 Book, p. B-256).

Attic RAM

Code cannot run from attic RAM through SYS, but attic RAM is a good place to keep many routines or overlays that are copied into chip RAM when needed. A single EDMA command moves an overlay into place faster than BASIC could PEEK a few bytes.

A BASIC Header

A program written entirely or mostly in machine language should still load and run like a BASIC program, so the user can type RUN and nothing else. The standard way is to begin the file with a one-line BASIC program whose only job is to call the machine code that follows it. This is called a BASIC header, or a BASIC stub.

The line we want is:

10 BANK0:SYS$2012

The BANK 0 makes the SYS address refer to bank 0 whatever BANK setting the user left behind. Early MEGA65 ROMs required it before a SYS like this, and the Book recommends it as best practice on all versions (MEGA65 Book, p. 14-18). $2012 is the address immediately after the header, worked out below. The line has no spaces, because spaces typed in a line are stored too, one byte each, and would move the address.

Building it byte by byte

Using the program format from Chapter 6, the line becomes:

Address Bytes Meaning
$2001 $10 $20 Link: the next line starts at $2010
$2003 $0A $00 Line number 10
$2005 $FE $02 BANK token
$2007 $30 $3A "0:"
$2009 $9E SYS token
$200A $24 $32 $30 $31 $32 "$2012"
$200F $00 End of line
$2010 $00 $00 End of program
$2012 First byte of machine code

In assembler form:

        * = $2001
        .word next      ; link to the next line
        .word 10        ; line number
        .byte $fe, $02  ; BANK
        .text "0:"
        .byte $9e       ; SYS
        .text "$2012"   ; entry point, as text
        .byte 0         ; end of line
next    .word 0         ; end of program

start   ; machine code begins here, at $2012

The address in the SYS is text, so the assembler cannot fill it in automatically unless it can convert a number to hex digits; check your assembler's documentation, which may also offer a ready-made header macro. Writing the address in hex rather than decimal makes the arithmetic easy either way: four digits, always five characters with the $.

Using the header

The assembler writes a PRG file whose first two bytes are the load address, $2001. On the MEGA65 that file loads and lists like any BASIC program:

LOAD "MYPROG"
LIST
10 BANK0:SYS$2012
RUN

The header must be built for $2001. LOAD and DLOAD always put a BASIC program at $2001, whatever load address the file has, so a header built for another address, such as the C64's $0801, would point its SYS at the wrong place (MEGA65 ROM source, LOAD). C64 programs are run in GO64 mode instead.

When the machine code returns with RTS, BASIC carries on with the rest of line 10 and then finds the end of the program. Further BASIC lines can be added between the header and the code, as the worked example at the end of this chapter does, provided the SYS address is updated to match.

Getting Machine Code into Memory

A BASIC program that uses machine-language helpers needs a way to put them in memory before calling them. There are three common methods.

Method Best for Drawback
BLOAD from disk Routines of any size, data, graphics A second file to keep with the program
DATA statements and POKE Small routines in a self-contained listing Slow to load, bulky, easy to mistype
Appended to the program Finished single-file programs Code moves if the BASIC part changes

BLOAD

BLOAD loads a file into memory without disturbing the BASIC program in memory. With B and P it loads to a given bank and address; without P it uses the load address stored in the file:

10 BLOAD "SPRITES.BIN", B4, P8192   : REM TO $42000
20 BLOAD "HELPERS"                  : REM TO ITS OWN ADDRESS

Because BLOAD does not restart the program, it can be called from inside a running program, which is the usual way to load overlays or level data. A missing file stops the program with an error unless TRAP is set, as in Chapter 6; check DS for other drive problems.

BOOT and raw files

BOOT combines BLOAD and SYS: it loads a PRG file and starts executing at its load address. With no file name it runs AUTOBOOT.C65, the MEGA65's auto-start file (MEGA65 Book, p. B-31):

BOOT "HELPERS"                         : REM LOAD AND RUN

BLOAD normally treats the first two bytes of a file as its load address. The R option reads every byte as data instead, and a ,S suffix on the name lets BLOAD read a sequential file, which is how data written by other programs or tools can be loaded (MEGA65 Book, p. B-29):

BLOAD "USER DATA,S", P($1600), R       : REM RAW SEQ FILE TO $1600
BLOAD "TABLES", P($8000000), R          : REM INTO ATTIC RAM

An address above $FFFF puts BLOAD into flat-address mode, which reaches attic RAM; the older form with a 16-bit P address and a B bank cannot.

DATA loaders

For a short routine in a printed listing, DATA statements remain the most portable method: the whole program is one listing that can be typed in.

10 FOR I=0 TO 8
20 READ B : POKE $1600+I, B : C=C+B
30 NEXT
40 IF C<>1396 THEN PRINT "DATA ERROR" : STOP
50 SYS $1600
60 END
100 DATA 162,0,238,32,208,202,208,250,96

The routine cycles the border colour 256 times in 9 bytes: LDX #0, INC $D020, DEX, BNE back to the INC, RTS. The checksum in line 40 catches typing mistakes before they crash the machine, a practice worth keeping for any DATA loader.

Code appended to the program

When the BASIC header technique is extended with more BASIC lines, the machine code sits after the last line in the same file. A single LOAD brings in both parts, and the program needs no other files. The cost is that the SYS address must be updated whenever the BASIC part changes length, so this method suits programs built by an assembler from source, where the address is computed automatically.

Overlays from attic RAM

A program with many routines can load them all once into attic RAM, then copy each into its working area with EDMA as needed. Because EDMA is so fast, swapping an overlay takes less time than a single screen refresh.

Calling Machine Code and Passing Parameters

SYS is the bridge from BASIC into machine language. Its full form loads the registers before the call:

SYS address, a, x, y, z, s

Every argument after the address is optional and is placed in the matching register: A, X, Y, Z and the status register. Each must be 0 to 255. Arguments can be skipped with empty commas, so SYS $1600,,5 sets only X.

There are four ways to pass information to a routine, suited to different amounts and kinds of data.

Method Capacity Good for
SYS registers 4 bytes Small numbers, flags, a 28-bit address
Parameter block Any size Several values, words, settings
POINTER Any variable or array Large data shared in place
String descriptors Any string Text

What SYS does to BASIC

With plain SYS, a register you don't give a value isn't set to zero: it gets whatever was left in its save location, usually the result of the previous SYS, and the same goes for the status register. SYS TO sets every register you leave out to zero (MEGA65 ROM source, SYS). Pass every register your routine depends on. During the call, SYS switches BASIC's ROM out of $2000-$BFFF and tells the KERNAL's interrupt handler to skip BASIC's interrupt-driven features, such as PLAY music and MOVSPR sprite movement. They resume when the routine returns with RTS (MEGA65 Book, pp. B-256, 14-44). A routine called often while music plays can therefore make the music stutter.

SYS TO

SYS TO is a second form of SYS with named arguments: A, X, Y, Z and S for registers, plus two extra abilities (MEGA65 Book, pp. B-256, 14-18, 14-19):

Argument Effect
A(n) ... S(n) Load a register; hexadecimal values go in parentheses, as in X($FF)
I1 Keep BASIC active during the call, so PLAY and MOVSPR continue. In ROMs 920414 to 920418 the routine had to lie in $1600-$1EFF; from 920419 it can be anywhere
O(offset) Add offset × 256 to addresses $2000-$7FFF, so code anywhere in the first 384KB can run

I and O cannot be used together, because both rely on the MAP register. Some examples:

SYS TO $1600, X($FF), A(5)             : REM REGISTERS BY NAME
SYS TO $1600, I1                       : REM MUSIC KEEPS PLAYING
SYS TO $6100, O($520)                  : REM RUNS CODE AT $58100

In the last line, $520 × 256 is $52000, which maps $2000-$7FFF to $54000-$59FFF; the routine at $58100 runs at CPU address $6100. Code run this way must be written for the address it runs at, since its JMP and JSR targets are CPU addresses, or use only relative branches.

Registers

Register passing is the fastest and simplest. A routine that fills a row of the screen with a character might take the character in A and the row in X:

10 BANK 0
20 SYS $1600, 160, 12   : REM FILL ROW 12 WITH SOLID BLOCKS

The routine sees A = 160 and X = 12 at its first instruction, exactly as if another machine-language routine had loaded them.

Parameter blocks

When four bytes are not enough, BASIC can POKE parameters into a small block of memory the routine knows about, then call it. The block is best placed at the start of the routine's own reserved area, so the address is fixed and documented:

10 PB=$1600              : REM PARAMETER BLOCK
20 POKE PB, X AND 255 : POKE PB+1, X/256
30 POKE PB+2, Y : POKE PB+3, COL
40 SYS $1610             : REM ROUTINE FOLLOWS THE BLOCK

This also works in reverse: the routine writes results into the block and BASIC PEEKs them back.

POINTER

For large amounts of data, copying is wasteful. POINTER gives the routine the address of a variable or array element, and the routine works on the data where it lies. Because POINTER returns a flat address, split it across the registers and let the routine use [zp],Z addressing:

10 DIM D&(999)
20 P=POINTER(D&(0))
30 SYS $1600, MOD(P,256), INT(P/256) AND 255, INT(P/65536) AND 255, INT(P/16777216)

The low byte uses MOD because an array's address is at least $10000, and AND accepts only values up to 65,535. The routine stores A, X, Y and Z into four bytes of the base page, then reads or writes [zp],Z. Byte arrays are the easiest type to share this way, because each element is exactly one byte and the elements are stored one after another.

Strings

POINTER applied to a string variable returns the address of its descriptor, not the text itself. Following the Commodore convention, the descriptor holds the length in one byte followed by the address of the text, and the text of a BASIC 65 string is in bank 1. A routine therefore reads the length and address from the descriptor, then reads the characters from bank 1 with a flat pointer.

The Book documents this layout as part of POINTER, so it can be relied on: three bytes, the length followed by the low and high bytes of the text's address as an offset in bank 1. The text itself can move whenever garbage collection runs, so a routine should take the address from the descriptor at the moment it is called rather than keep it (MEGA65 Book, p. B-193). Chapter 6 shows the same descriptor read from BASIC.

Returning Results

The same methods work in the other direction. Which to use depends again on how much comes back.

RREG

When a routine returns with RTS, BASIC saves the registers as they were at that moment. RREG copies them into BASIC variables:

RREG a, x, y, z, s

The names after RREG are ordinary BASIC variables, not the registers themselves, so any names will do. Using names such as RA, RX and RY avoids overwriting variables called A, X or Y that the program uses for other things. Like SYS, RREG lets you skip positions with empty commas.

A routine that returns a 16-bit result in X and Y, low byte first, can be read like this:

10 SYS $1600
20 RREG , RX, RY
30 R = RX + 256*RY

Reporting success or failure

The status register comes back as the fifth RREG value, which gives a routine a convenient way to report errors. The carry flag, bit 0, is the usual choice, as the Hypervisor does: set it for success and clear it for failure, or the other way round, as long as routine and caller agree.

10 SYS $1600
20 RREG ,,,, RS
30 IF (RS AND 1)=0 THEN PRINT "FAILED" : STOP

Avoid the tempting name ST: it is BASIC's reserved I/O status variable, and assigning to it is an error.

Parameter blocks and arrays

For more than a few bytes, the routine writes into memory and BASIC reads it. A parameter block works in both directions, and BASIC reads it with PEEK. For bulk results, BASIC can pass the POINTER address of a byte array, and the routine fills the array in place. The results then appear in the array with no PEEKs at all:

10 DIM R&(255)
20 P=POINTER(R&(0))
30 SYS $1600, MOD(P,256), INT(P/256) AND 255, INT(P/65536) AND 255, INT(P/16777216)
40 FOR I=0 TO 255 : PRINT R&(I); : NEXT

Writing directly into floating-point or integer variables is possible too, but it means encoding values in BASIC's internal formats. Byte arrays avoid that: the value the routine stores is exactly the value BASIC reads.

The USR Function

SYS is a statement: it does something but cannot appear inside an expression. USR is a function, so a machine-language routine called through it can take part in a calculation:

Y = 10 + USR(X) * 2

When BASIC evaluates USR, it evaluates the argument, then jumps through the USR vector to a machine-language routine, and uses whatever the routine leaves behind as the function's value. The vector is the two bytes at $02F8, low byte first, read in the system bank; the Book's POKE entry sets it to a routine at $1800 (MEGA65 Book, p. B-194):

10 BANK 128
20 POKE $02F8, 0, 24                   : REM USR VECTOR = $1800

POKE writes successive bytes from a list, so one statement sets both halves of the vector; WPOKE $02F8,$1800 does the same.

BASIC 65's USR accepts string arguments as well as numbers. The MEGA65 team's own unit-test module uses this: after it is loaded at $1600 and initialised with SYS, calls such as A=USR("P") send commands to a testing host, with the first character choosing the operation (MEGA65 Developer Guide). That pattern, one vector serving many commands distinguished by the argument, is a neat way to give BASIC a small family of new functions without patching the interpreter.

The cost of USR

USR's convenience comes at a price. The argument arrives in BASIC's floating-point accumulator, not in a register, and the result must be left there too. A routine must therefore call BASIC's own conversion routines to turn the argument into an integer and to turn its result back into floating point, and it must remove a string argument from BASIC's temporary string area when it is done. Those routines are internal to BASIC 65, and their addresses can change between ROM versions.

For that reason this book recommends SYS with RREG for most work. Reserve USR for cases where calling machine code inside an expression genuinely simplifies the BASIC program, and keep the conversion calls in one place so they are easy to update if the ROM changes.

A Worked Example

This example brings the chapter's techniques together. A machine-language routine adds up every element of a BASIC byte array and returns the 24-bit total. BASIC passes the array's address with POINTER and the registers, passes the element count in a parameter block, and reads the result from the same block. The routine reports success through the carry flag.

Memory layout

Address Contents
$1600-$1601 count: number of elements, set by BASIC
$1602-$1604 result: 24-bit sum, set by the routine
$1610 Entry point of the routine
$1700-$17FF The routine's private base page

The private base page is a 45GS02 technique worth noting. Rather than guess which zero-page locations BASIC 65 leaves free, the routine points B at a page of its own, uses it freely, and sets B back to zero before returning.

The machine-language routine

        * = $1600
count   .word 0         ; set by BASIC
result  .byte 0, 0, 0   ; returned to BASIC

        * = $1610
ptr     = $00           ; offset in the private base page
workpg  = $17           ; private base page at $1700

sum     pha             ; save A: low byte of the array address
        lda #workpg
        tab             ; base page = $1700
        pla
        sta ptr         ; store the flat address of the array
        stx ptr+1
        sty ptr+2
        stz ptr+3       ; STZ stores the Z register
        ldz #$00
        lda #$00
        sta result
        sta result+1
        sta result+2

loop    lda count       ; finished when count reaches zero
        ora count+1
        beq done
        clc
        lda [ptr],z     ; next element of the array
        adc result
        sta result
        bcc next
        inc result+1
        bne next
        inc result+2
next    inw ptr         ; advance the pointer
        lda count       ; count = count - 1
        bne nodec
        dec count+1
nodec   dec count
        bra loop

done    lda #$00
        tab             ; restore base page 0 for BASIC
        sec             ; report success
        rts

INW advances only the low 16 bits of the pointer, so the routine assumes the array does not cross a 64KB boundary. That holds for arrays of normal size; a routine meant for very large arrays would advance all four bytes, for instance with LDQ, INQ and STQ, saving Z around them since it is part of Q.

The BASIC program

10 BANK 0
20 BLOAD "SUMBYTES", B0, P5632       : REM LOAD TO $1600
30 DIM D&(999)
40 FOR I=0 TO 999 : D&(I)=INT(RND(1)*256) : NEXT
50 P=POINTER(D&(0))
60 POKE $1600, 1000 AND 255 : POKE $1601, INT(1000/256)
70 SYS $1610, MOD(P,256), INT(P/256) AND 255, INT(P/65536) AND 255, INT(P/16777216)
80 RREG ,,,, RS
90 IF (RS AND 1)=0 THEN PRINT "FAILED" : STOP
100 S=PEEK($1602)+256*PEEK($1603)+65536*PEEK($1604)
110 PRINT "SUM:"; S
120 T=0 : FOR I=0 TO 999 : T=T+D&(I) : NEXT
130 PRINT "CHECK:"; T
140 BANK 128

Lines 120 and 130 compute the same total in BASIC so the two can be compared, and the difference in speed is plain to see. The machine-language routine finishes before the screen has time to redraw, while the BASIC loop takes a noticeable moment even at 40.5 MHz.

What each part demonstrates

Technique Where
Code placed in bank 0 below BASIC $1600, called with SYS from any bank
BLOAD to a fixed address Line 20
Parameters in registers The flat address in A, X, Y and Z, line 70
Parameter block count and result, lines 60 and 100
POINTER and flat addressing Line 50 and [ptr],z in the routine
Status through RREG Lines 80 and 90
Private base page TAB in the routine

Summary

Chapter 10 is the complete reference to the 45GS02 instruction set.