Programming the MEGA65 — Chapter 6: Advanced BASIC 65

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

Introduction

Chapter 4 treated BASIC 65 as a language: how to structure programs, debug them and make them run well. This chapter treats it as a program, which is what it really is. BASIC 65 is a large piece of machine language in ROM that reads your program from memory, keeps your variables in memory, and calls the KERNAL to do its input and output.

Knowing how it does this pays off in several ways. You can read a program's tokens directly when a listing is damaged or a file will not load. You can find a variable's value from machine language and pass data between BASIC and ML without PEEKs and POKEs through a single byte. You can place machine code, data tables and graphics where BASIC will not overwrite them. And you can use the parts of BASIC 65 that reach beyond the 64KB window, which are some of the most useful in the language.

This chapter covers:

The details here follow the MEGA65 ROM as distributed with current systems. BASIC 65 has changed considerably across ROM versions, and where a feature arrived in a particular version, that is noted. A program that depends on these internals should check the ROM version before relying on them.

Where BASIC 65 Keeps Things

On the C64, BASIC squeezes everything into one area of about 38KB: the program first, then variables, then arrays, with strings growing down from the top. A long program leaves little room for data, and a large array leaves little room for the program.

BASIC 65 spreads the same structures across two banks, so program text and data no longer compete:

What Where Notes
Program text Bank 0, $2001-$F6FF About 54KB for the program alone
Scalar variables Bank 0 Simple variables such as A, B%, C$ (the descriptor, not the text)
Arrays Bank 1 All array elements
String text Bank 1 The characters of every string, for both scalars and arrays
Text screen Bank 0 80 by 25 screen at $0800
Bitmap graphics Banks 4 and 5 Used by the graphics commands covered in Chapter 12
Attic RAM $8000000 upward Not used by BASIC 65; free for programs

The arrangement has practical consequences. A program can be up to about 54KB long regardless of how much data it uses, and arrays and strings share most of bank 1 between them. The split also explains why a pointer to a variable is not enough on its own: you also need to know which bank it is in. The POINTER function, described below, solves this by returning a full flat address.

Reserving memory

Banks 4 and 5 belong to the graphics system, but a program that does not use all of them can claim parts for its own data or machine code. The MEM command, added in ROM 920273, reserves memory in those banks:

MEM bank4mask, bank5mask

Each argument is a byte whose eight bits stand for the eight 8KB blocks of that bank. A set bit reserves the block so the graphics commands will not use it. For example, MEM 1,0 reserves $40000-$41FFF.

Attic RAM needs no reservation, since BASIC 65 does not touch it. It is the natural home for large tables, level data, or anything loaded once and copied into chip RAM as needed. Bear in mind that some resident utilities do use attic RAM, and that early development boards lack it entirely.

Example: checking memory

FRE reports more than free space on the MEGA65. FRE(0) and FRE(1) give the free bytes in banks 0 and 1, FRE(4) and FRE(5), from ROM 920415, return the reservation bitfields for banks 4 and 5 in the same format as MEM (MEGA65 ROM change log), and FRE(-1) returns the ROM version (MEGA65 Book, p. B-117). This program reserves 16KB of bank 4 and confirms it:

10 MEM 3,0                            : REM RESERVE $40000-$43FFF
20 PRINT FRE(0);"BYTES FREE FOR PROGRAM"
30 PRINT FRE(1);"BYTES FREE FOR VARIABLES"
40 PRINT "BANK 4 RESERVED BLOCKS: ";HEX$(FRE(4))
50 PRINT "ROM VERSION";FRE(-1)

Line 40 shows at least 0003 (HEX$ always gives four digits), the two blocks reserved in line 10; other bits may be set if the system is using parts of bank 4. In 80 by 50 text mode, for example, the first 8KB of bank 4 always holds screen data, whether or not MEM reserved it (MEGA65 Book, p. B-164). FRE(-1) is the simplest way for a program to check that it is running on a ROM new enough for the features it uses.

How a Program Is Stored

A BASIC 65 program is stored in bank 0 from $2001 as a chain of lines, in the same format Commodore has used since the PET. Each line is laid out like this:

Bytes Contents
2 Link: the address of the next line, low byte first
2 Line number, low byte first
varies The line's text, with keywords replaced by tokens
1 $00, marking the end of the line

The program ends with a link of $0000. BASIC follows the links to find lines quickly, without scanning every byte, which is why a corrupted link makes the rest of the program vanish from a listing even though the bytes are still in memory.

Tokens

Keywords are not stored as text. When you type a line, BASIC replaces each keyword with a token, a byte with its high bit set. This saves space and saves BASIC from recognising keywords every time a line runs. LIST reverses the process.

The C64's single-byte tokens still mean what they did: $80 is END and $9E is SYS, for example. BASIC 65 has far more keywords than there are single-byte codes, so most of its extensions use two bytes: a prefix byte followed by a code. Extended statements use the prefix $FE, a scheme inherited from the C128's BASIC 7.0, so BANK is stored as $FE $02 and CURSOR as $FE $41. A small number of additional functions use a different prefix. Chapter 3 gives each keyword's token alongside its description.

Everything that is not a keyword is stored as PETSCII text, including numbers. The constant 3000 occupies four bytes, and is converted to a number each time the line runs. Hexadecimal constants such as $3000 are stored the same way, as five characters.

A worked example

The one-line program 10 BANK0:SYS$3000, typed without spaces, is stored like this. Spaces typed in a line are stored too, one byte each, so a version with spaces would be two bytes longer:

$2001:  10 20             link: next line at $2010
$2003:  0a 00             line number 10
$2005:  fe 02             BANK
$2007:  30                "0"
$2008:  3a                ":"
$2009:  9e                SYS
$200a:  24 33 30 30 30    "$3000"
$200f:  00                end of line
$2010:  00 00             end of program

This format matters most when you write machine language that must load and run like a BASIC program. The usual approach, used by most MEGA65 assemblers' startup code, is to emit exactly these bytes at $2001 followed by the machine code. Typing RUN then executes the SYS. Chapter 9 builds such a header step by step.

Example: walking the program

Because every line starts with a link to the next, a program can walk its own listing. This one prints each line's number, address and the length of its tokenised text: the line's total size less two bytes of link, two of line number and one terminating zero.

10 BANK 0
20 A=$2001
30 DO
40 N=WPEEK(A) : IF N=0 THEN EXIT
50 PRINT "LINE";WPEEK(A+2);" AT $";HEX$(A);":";N-A-5;"BYTES"
60 A=N
70 LOOP
80 BANK 128

WPEEK reads a 16-bit value low byte first, which matches how both links and line numbers are stored (MEGA65 Book, p. B-284). Adding a loop that prints HEX$(PEEK(I)) for each byte of a line shows its tokens directly, which is a quick way to look up a token that Chapter 3 does not list.

Variables, Arrays and Strings

BASIC 65 keeps the classic Commodore approach to variables: each scalar variable has a short entry holding its name and either its value or, for a string, a descriptor pointing to its text. What has changed is where those entries live and how quickly BASIC finds them.

Variable types

Suffix Type Example Value
none Floating point A, XY 5-byte Commodore floating-point number
% Integer B% Signed 16-bit integer, -32768 to 32767
$ String C$ Descriptor: length and address of the text in bank 1
& Byte D&(10) Unsigned 8-bit value, 0 to 255

As on the C64, only the first two characters of a name are significant, so PX1 and PX2 are the same variable. A name must also not contain a keyword: TOTAL and TOP both begin with TO and cannot be used at all. Byte variables are new to BASIC 65 and are most useful in arrays, where they take a fifth of the space of floating-point elements.

Two byte arrays are predefined. T@&() is mapped onto the text screen and C@&() onto colour RAM, so T@&(X,Y) reads or writes the character at column X, row Y without any address arithmetic. They were added in ROM 920354.

Finding variables quickly

The C64 finds a variable by searching the variable table from the start, so a program with many variables slows down noticeably, and variables created late are found last. Since ROM 920279, BASIC 65 stores one-letter scalar variables such as A, B% and C$ at an address computed from the name, so they are found immediately with no search; from ROM 920286 this covers every one-letter name of every type (MEGA65 ROM source history).

The practical lesson is simple: give the variables used inside your busiest loops one-letter names. Longer names still work, but they are found the old way.

POINTER

The POINTER function returns the address of a variable's entry:

10 A%=1234
20 P=POINTER(A%)
30 PRINT HEX$(P)

Since ROM 920154, POINTER returns a full flat address rather than a 16-bit one, so the result can be passed directly to PEEK, POKE or a machine-language routine without working out which bank the variable is in. This is the cleanest way to share data between BASIC and machine code: BASIC calls POINTER, passes the result to the routine, and the routine reads or writes the variable in place.

Strings and garbage collection

String text lives in bank 1, growing downward from the top of free memory. Assigning a new value to a string variable does not reuse the old space; it allocates new space and leaves the old text behind as garbage. When free memory runs out, BASIC pauses to collect the garbage, moving live strings together and reclaiming the rest.

On the C64 that pause could last many seconds, because the collector searched every variable for each string it moved. BASIC 65 descends from the C128's BASIC 7.0, which stores a back-pointer with each string so the collector can work in a single pass. Collections still happen, but they are far shorter.

You can check free memory with FRE. FRE(0) reports free space in bank 0, and FRE(1) reports free space in bank 1, which is the figure that matters for strings and arrays. Calling FRE(1) at a quiet moment, such as between levels of a game, forces a collection when it will not be noticed.

Examples: looking inside variables

POINTER gives the address of a variable's value. An integer is two bytes, high byte first, unlike most 16-bit values on the MEGA65 (MEGA65 ROM source; the Book's POINTER entry says the opposite); a floating-point variable is five bytes; a string's POINTER is the address of its three-byte descriptor, which holds the length and then the text's address as an offset in bank 1 (MEGA65 Book, p. B-193).

10 BANK 0                             : REM SCALARS ARE IN BANK 0
20 A%=1234 : P=POINTER(A%)
30 PRINT "A% AT $";HEX$(P);":";PEEK(P);PEEK(P+1)   : REM 4 210
40 N$="MEGA65" : D=POINTER(N$)
50 L=PEEK(D) : S=WPEEK(D+1)           : REM LENGTH, TEXT OFFSET
60 BANK 1                             : REM STRING TEXT IS IN BANK 1
70 FOR I=0 TO L-1 : PRINT CHR$(PEEK(S+I)); : NEXT : PRINT
80 BANK 128

Line 30 prints 4 and 210, because 1234 is $04D2 stored high byte first. Lines 40 to 70 rebuild the string from its descriptor. A string's text can move whenever garbage collection runs, so a program should call POINTER again rather than keep an old text address. Array element addresses, in contrast, stay fixed while the program runs.

Arrays are stored element after element, with the leftmost index changing fastest. DIM A%(3,2) is laid out as (0,0), (1,0), (2,0), (3,0), (0,1), (1,1) and so on, two bytes each, starting at POINTER(A%(0,0)) (MEGA65 Book, p. B-193). This program confirms it by printing each element's offset from the start:

10 DIM A%(3,2)
20 B=POINTER(A%(0,0))
30 FOR Y=0 TO 2 : FOR X=0 TO 3
40 PRINT "(";X;",";Y;")";POINTER(A%(X,Y))-B
50 NEXT X : NEXT Y

The offsets run 0, 2, 4, 6, 8 and so on. This order matters when machine language works through an array: walking memory in order visits A%(0,0), A%(1,0), A%(2,0) before reaching A%(0,1).

Example: the screen as an array

The predefined byte arrays T@& and C@& make the text screen and its colours ordinary BASIC data. This draws a bar of solid blocks across row 12, each block in a different colour, without a single POKE:

10 FOR X=0 TO 79
20 T@&(X,12)=160                      : REM SCREEN CODE 160: SOLID BLOCK
30 C@&(X,12)=X AND 15                 : REM COLOURS 0-15 IN TURN
40 NEXT

Reading works the same way, so T@&(X,Y) tells a game what character is at a position, which is a simple collision test.

Reaching the Whole Machine

C64 BASIC can only PEEK and POKE the 64KB the CPU sees. BASIC 65 can reach anywhere in the 28-bit address space, through a handful of commands that are worth knowing well.

BANK

BANK selects which 64KB bank PEEK, POKE, SYS, BLOAD and BSAVE address when given a 16-bit address. BANK 0 makes $3000 mean $03000; BANK 4 makes it mean $43000.

The default is not bank 0. BASIC starts in a special configuration, BANK 128, which shows the system memory layout with ROM and I/O mapped in, much as a C64 program would expect. This is why POKE 53280,0 changes the border colour without any BANK command. A common mistake is to write BANK 0 early in a program to reach RAM and then wonder why a later POKE to $D020 does nothing: in bank 0, $D020 is just RAM. Use BANK 128 to return to the system view.

Flat addresses

An address above $FFFF given to PEEK or POKE is treated as a full 28-bit address and ignores BANK entirely:

10 POKE $FF80000,1     : REM FIRST BYTE OF COLOUR RAM
20 PRINT PEEK($8000000) : REM FIRST BYTE OF ATTIC RAM

This is usually clearer than switching banks, and it combines naturally with POINTER, which returns flat addresses.

SYS

SYS calls a machine-language routine, which must end with RTS. A 16-bit address is interpreted through BANK; an address above $FFFF is taken as a flat address and BANK is ignored. Either way, SYS sets up a fixed mapping for the call, and only part of it comes from the chosen bank (MEGA65 Book, p. B-256):

CPU address During SYS
$0000-$1FFF Always bank 0
$2000-$7FFF The selected bank, 0 to 5
$8000-$BFFF Bank 0: BASIC's ROM is switched out for the call
$C000-$FFFF ROM and I/O, as in the system view, so the KERNAL and hardware registers remain available

The Book explains why: the KERNAL must keep its ROM at $E000-$FFFF and its variables in $0000-$1FFF to handle interrupts, and SYS uses the remaining MAP offset to bring the selected bank into $2000-$7FFF (MEGA65 Book, pp. 14-43, 14-44).

So a routine in bank 4 must lie at $42000-$47FFF to be called with BANK 4 : SYS $2000, and SYS cannot reach attic RAM at all. For small routines, the Book recommends bank 0 from $1600 to $1EFF, which it describes as guaranteed unused by the system; its SYS entry suggests $1800-$1EFF.

SYS can also load the registers before the call, and RREG reads them back afterwards:

10 BANK 0
20 SYS $3000, 1, 2, 3, 4   : REM A=1, X=2, Y=3, Z=4
30 RREG A, X, Y, Z, S

Chapter 9 uses these to pass parameters between BASIC and machine language.

BLOAD and BSAVE

BLOAD and BSAVE load and save raw memory rather than programs. The B parameter selects the bank and P the start address within it:

BLOAD "LEVEL1", B4, P0
BSAVE "LEVEL1", B4, P0 TO P8192

These are the usual way to load machine code, character sets and graphics data alongside a BASIC program.

EDMA

EDMA gives BASIC direct access to the DMAgic controller described in Chapter 5. It takes a command, a length, and source and destination addresses, all as flat 28-bit addresses:

EDMA 0, 8192, $8000000, $40000  : REM COPY 8KB FROM ATTIC RAM TO BANK 4
EDMA 3, 2000, 32, $800          : REM FILL 2000 BYTES OF SCREEN WITH SPACES

Command 0 copies and command 3 fills, using the low byte of the source argument as the fill value. Because the hardware does the work, an EDMA copy of thousands of bytes finishes before a FOR loop could move a few dozen. It is the fastest way for BASIC to move data between attic RAM and chip RAM.

SYS TO

SYS has a second form, SYS TO, with named arguments and two extra abilities (MEGA65 Book, p. B-256). Registers are set by name, with hexadecimal values in parentheses:

SYS TO $1800, A(5), X($FF)

The O argument overrides the MAP offset for $2000-$7FFF, which lets SYS TO run code anywhere in the first 384KB rather than only in the window above. The value is multiplied by 256 and added to the CPU address. To run a routine stored at $54000, in bank 5 beyond the reach of ordinary SYS:

SYS TO $2000, O($520)                 : REM $2000 + $52000 = $54000

The I argument, I(1), keeps BASIC's interrupt-driven features running during the call, so PLAY music and MOVSPR movement continue. I and O cannot be combined. SYS TO arrived in ROM 920414. In 920414 to 920418, a routine called with I had to lie between $1600 and $1EFF; from 920419 it can be anywhere SYS can reach (MEGA65 ROM change log).

Example: saving and restoring the screen

EDMA moves memory far faster than any FOR loop. This program copies the 80 by 25 screen at $0800 (2048) to attic RAM, clears it, and puts it back, following the Book's own examples (MEGA65 Book, p. B-91):

10 EDMA 0, 80*25, 2048, $8000000      : REM COPY SCREEN TO ATTIC RAM
20 EDMA 3, 80*25, 32, 2048            : REM FILL SCREEN WITH SPACES
30 PRINT "PRESS A KEY"
40 GETKEY A$
50 EDMA 0, 80*25, $8000000, 2048      : REM COPY IT BACK

A length of 0 means 65,536 bytes. EDMA uses the F018B job format, in which bit 4 of the command runs the source backwards and bit 5 the destination. An overlapping copy to a higher address needs both, so add 48, and give the last byte of each range instead of the first. Adding only 32 reverses the destination alone, which mirrors the data (MEGA65 ROM source, EDMA; mega65-core, gs4510.vhdl). This shifts the top row of the screen one character to the right:

10 EDMA 48, 79, 2126, 2127            : REM BACKWARDS COPY, SAFE FOR OVERLAP

Example: data files in attic RAM

BLOAD and BSAVE switch to flat addressing when given an address above $FFFF, which lets them reach attic RAM and save more than 64KB at once. The R option reads or writes raw data with no two-byte load address (MEGA65 Book, pp. B-29, B-35):

10 BLOAD "LEVELS", P($8000000), R                    : REM INTO ATTIC RAM
20 EDMA 0, 8192, $8000000, $40000                     : REM LEVEL 1 TO BANK 4
30 BSAVE "BANK4", P($40000) TO P($42000), R           : REM SAVE IT BACK RAW

In the older form, a 16-bit P address with a B bank, BLOAD and BSAVE cannot cross a bank boundary or reach attic RAM; the flat form has neither limit.

Error Handling

When C64 BASIC hits an error, the program stops. BASIC 65 lets a program catch the error, find out what went wrong, and carry on. This turns many fatal mistakes, such as bad input, a missing file or a division by zero, into situations the program can handle itself.

TRAP and RESUME

TRAP followed by a line number tells BASIC where to go when an error occurs. TRAP on its own turns trapping off again. Inside the handler, three variables describe the error:

Name Contents
ER The error number
EL The line number where the error occurred
ERR$(n) The message text for error number n

The handler ends with one of three forms of RESUME:

Form Effect
RESUME Runs the statement that failed again
RESUME NEXT Continues with the statement after the one that failed
RESUME line Continues at the given line

This program keeps running however the user answers:

10 TRAP 1000
20 INPUT "DIVIDE 100 BY";N
30 PRINT 100/N
40 GOTO 20
1000 PRINT ERR$(ER);" ERROR IN LINE";EL
1010 RESUME 20

Entering 0 produces a division by zero error in line 30; the handler reports it and asks again.

An error inside the handler itself is not trapped, so a handler should be short and simple. Plain RESUME needs particular care: if the cause of the error has not been fixed, the same statement fails again and the program loops forever.

Disk errors

Disk errors are reported separately, because they happen in the drive rather than in BASIC. A failed OPEN or LOAD may not raise a BASIC error at all. After any disk operation, the reserved variables DS and DS$ hold the drive's status number and message:

10 DOPEN#1,"SCORES"
20 IF DS<>0 THEN PRINT DS$:STOP

A status below 20 means success. Checking DS after every important disk operation is the single most effective way to make a BASIC program robust. Chapter 15 lists the drive's status codes.

Example: a robust file loader

TRAP and the disk status variables work together. This loader asks for a file name, reports BASIC errors and disk errors in plain words, and asks again rather than stopping:

10 TRAP 900
20 INPUT "FILE TO LOAD";F$
30 BLOAD (F$), P($40000), R
40 IF DS<>0 THEN PRINT "DISK: ";DS$ : GOTO 20
50 PRINT "LOADED" : END
900 PRINT "ERROR";ER;"IN LINE";EL;": ";ERR$(ER)
910 RESUME 20

The file name is a string expression, so it goes in parentheses, as the BLOAD reference requires (MEGA65 Book, p. B-29). A missing file raises a BASIC FILE NOT FOUND error, which the TRAP handler at line 900 reports, as it does for anything else BASIC rejects. Line 40 catches drive errors that still let the load finish.

Example: finding where a calculation fails

RESUME with a line number lets a program record how far it got before an error. This uses the pattern from the Book's TRAP entry to find where EXP overflows (MEGA65 Book, p. B-267):

10 TRAP 100
20 FOR I=1 TO 100
30 PRINT EXP(I)
40 NEXT
50 PRINT "STOPPED FOR I =";I
60 END
100 PRINT ERR$(ER) : RESUME 50

The program prints values until EXP exceeds the floating-point range, then reports the overflow and the value of I that caused it, and ends normally.

Extending BASIC 65

C64 programmers extended BASIC by redirecting the vectors in page 3, so that new keywords could be recognised and executed. That technique does not carry over cleanly. BASIC 65 has no officially documented extension interface, and its internal routines and work areas have moved between ROM versions. An extension that patches BASIC's internals may stop working after a ROM update.

The robust alternative is to leave BASIC alone and give it fast helpers. Three features from earlier in this chapter make that easy: BANK and SYS to call code anywhere, SYS parameters and RREG to pass small values, and POINTER to pass the address of larger data.

A pattern for helper routines

Suppose a machine-language routine at $42000 sorts an integer array. BASIC loads it once, then passes the array's flat address in A, X, Y and Z:

10 BLOAD "SORT", B4, P8192
20 DIM A%(99)
30 REM ... FILL THE ARRAY ...
40 P=POINTER(A%(0))
50 BANK 4
60 SYS $2000, MOD(P,256), INT(P/256) AND 255, INT(P/65536) AND 255, INT(P/16777216)
70 BANK 128

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 the four registers into a zero-page pointer and works through the array with [zp],Z addressing, as shown in Chapter 5, remembering that each integer element is stored high byte first. It needs no knowledge of where BASIC keeps anything, because POINTER has already worked that out, and it will keep working across ROM versions.

A few rules keep such helpers safe:

Chapter 9 develops this pattern fully, including returning results through RREG and through variables, and Chapter 11 documents the ROM entry points that are stable enough to call.

Example: installing a USR routine

The USR function jumps through a vector at $02F8, which BASIC sees in the system bank (MEGA65 Book, p. B-285). The smallest possible USR routine is a single RTS: it leaves BASIC's floating-point accumulator untouched, so USR returns its argument unchanged. That is enough to show the mechanism:

10 BANK 0 : POKE $1800, $60           : REM RTS AT $1800
20 BANK 128 : WPOKE $02F8, $1800      : REM POINT THE USR VECTOR AT IT
30 PRINT USR(42)                      : REM SHOULD PRINT 42

A real USR routine reads its argument from the floating-point accumulator and leaves its result there, using BASIC's internal conversion routines, as Chapter 9 explains.

Example: machine code alongside PLAY

An ordinary SYS switches BASIC out, so interrupt-driven features pause for the duration of the call. SYS TO with I(1) keeps them running, which suits short routines called often while music plays (MEGA65 Book, p. B-256):

10 PLAY "O4T0IC E G O5C O4G E L"
20 POKE $1800, $EE, $20, $D0, $60     : REM INC $D020 : RTS
30 FOR I=1 TO 50
40 SYS TO $1800, I(1)                 : REM MUSIC KEEPS PLAYING
50 SLEEP 0.1
60 NEXT
70 PLAY                               : REM STOP THE MUSIC

The routine sits at $1800, inside the $1600-$1EFF range that interrupt mode required in ROMs 920414 to 920418, so it works on every ROM that has SYS TO. POKE accepts several values in a row, so line 20 places a four-byte routine in one statement.

Example: one-command start-up

BOOT loads a program file and starts executing it at its load address, combining BLOAD and SYS. With no arguments it runs a file named AUTOBOOT.C65, the MEGA65's equivalent of an auto-start file (MEGA65 Book, p. B-31):

BOOT "GAME"                           : REM LOAD AND RUN MACHINE CODE
BOOT                                  : REM RUN AUTOBOOT.C65

Summary

Chapter 7 begins the machine-language part of the book with the 45GS02 itself.