Skip to content

Bigger cartridges ​

A cartridge holds 16 KB, and that is usually enough room for the program but not for everything the program needs. 16 KB of 65C02 is a great deal of code. The trouble is that the same 16 KB has to hold the levels, the tile sets, the tunes and the text as well, so a game that keeps growing starts losing the last few screens, then the music, and eventually something it was actually about.

A Flash Cart is a cartridge that holds between 128 KB and 1 MB. A Flash Cart plugs into the same slot as a 16 KB cart, boots the same way, and takes over the machine at the same reset vector. What is different is that the machine can only see 8 KB of the cartridge at a time, and the program on the cartridge chooses which 8 KB that is. This chapter covers how a program selects one of those 8 KB banks, where the code that does the selecting has to be stored, and how that requirement shapes the way you lay a cartridge out.

Read Writing a cartridge first. Nearly all of it still holds — a Flash Cart boots the same way, calls the same Kernal, and owns the same vectors — and everything below assumes you have written a 16 KB cart or at least read how one works.

The window and the fixed bank ​

The cartridge still overlays $C000–$FFFF. That 16 KB is now two halves that behave completely differently.

The Flash Cart window, the fixed bank, and where the vectors live What the CPU sees What is on the chip the same 16K a cartridge always owned up to 128 banks of 8K, in file order Fixed $E000–$FFFF never moves, whatever the register holds $FFFA–$FFFF vectors Window $C000–$DFFF one bank at a time changes under your feet bank $00 bank $01 bank $02 …up to bank $3E bank $3F the last one always whichever the register last named The vectors are in the half that never moves. They could not be anywhere else: reset clears the register, so a vector in a bank would point into bank 0 by luck.
The lower half is a window onto one bank at a time. The upper half is always the same 8 KB, and the vectors are in it.
$C000–$DFFFThe window. 8 KB, showing whichever bank the bank register last named
$E000–$FFF9The fixed region. The chip's last bank, always, whatever the register holds
$FFFA–$FFFFThe vectors, which are therefore in the half that never moves

The vectors could not be anywhere else, and the reason is worth following through rather than taking on trust. Reset clears the bank register to zero, and reading the reset vector is the first thing the processor does afterwards. A reset vector stored in a bank would therefore always be read out of bank 0 — not because the program chose bank 0, but because zero is what the register happens to hold at that one moment. Putting the vectors in a region that does not move means the machine always knows where to look for them.

This is not the RAM card kind of banking

Banked RAM is a different mechanism that goes by the same name: kilobyte windows at $8000 and $8400, latches at $83FF and $87FF, and read-write memory behind them. Banked RAM is still present on a machine with a Flash Cart in the slot, and the two mechanisms have nothing to do with each other.

Neither one reports a mistake. Write a bank number to a RAM card latch instead of to the cartridge's bank register and nothing complains: the machine does exactly what the store asked for, to memory you did not intend to touch.

The bank register ​

Writing to any address in $E000–$FFFF latches the bank register. By convention that address is called BANK and set to $E000:

asm
BANK      = $E000

  lda #$07
  sta BANK                      ; bank $07 is now at $C000-$DFFF

Three properties of that register shape everything else in this chapter:

  • The write reaches no flash. The register is a latch sitting in front of the chip, and the chip's own write line is never asserted in the fixed region. Writing the register cannot also write to the cartridge by accident.
  • It cannot be read back. No address returns the bank that is currently selected, so a program that loses track of which bank it chose has no way to ask the hardware.
  • Reset clears it to zero. The register's reset input is on the machine's reset line, so after a power-on or a press of the reset button the window is showing bank 0.

Since the register cannot be read back, a program has to remember what it last wrote there. Every Flash Cart keeps that in one byte of zero page, called the shadow:

asm
BANKSHDW  = $3A                 ; the first byte free for a program that has
                                ;   taken the machine over

SetBank, and where it has to live ​

asm
SetBank:
  sta BANKSHDW                  ; the shadow first
  sta BANK                      ; then the register
  rts

Four lines, and two things about them need explaining: why the stores are in that order, and why the routine cannot live anywhere but the fixed region.

The shadow is written first. If an interrupt lands between the two stores, a handler that reads the shadow sees it one instruction ahead of the hardware. Putting the hardware back from the shadow on the way out then makes the two agree. Write the register first and the handler sees the shadow one instruction behind, and restoring from it leaves the two permanently disagreed — which will show up an hour later as a graphic drawn out of the wrong bank.

It must live in the fixed region, and you must call it from there.

You cannot switch banks from inside a bank

jsr SetBank from code at $C000–$DFFF pushes a return address that is about to mean something else. The rts returns to the same address in a different bank, and the processor runs whatever is there.

Nothing about this produces an error. The processor does not fault and does not stop; the processor simply treats whatever bytes are now at that address as instructions and carries on executing them, and the crash that eventually follows happens somewhere else entirely. A bug that shows up nowhere near its cause is a hard one to find, so the safe rule is a blunt one:

All the code goes in the fixed region. The banks hold data.

That rule decides the shape of the whole cartridge, so design around the rule from the start rather than discovering it later. Keeping all the code in the fixed region leaves 8,186 bytes for the entire program — half of what a 16 KB cart gives you — and between 120 KB and 1,016 KB of banks for everything that program reads.

Laying a cartridge out ​

The linker config declares a memory area per bank, and the segment name is the value you write to the register. .segment "BANK07" is reached with lda #$07.

Here is the smallest banked cartridge there is. The code is in FIXED, the string it prints is in BANK01, and nothing else is going on:

asm
; A banked cartridge, as small as the idea gets: the code is in the fixed
; region and the string it prints is in bank $01.
;
; Build it with the Flash Cart config rather than the 16 KB one —
; `make FLASH=512K` in a 6502-CRT or 6502-ASM project — and what comes out is
; a 524,288-byte .crt, a byte-exact image of the chip on the cart.

.setcpu "65C02"

.include "6502-VDP.inc"

; The bank register. Write-only, eight bits, at any address in $E000-$FFFF;
; this one by convention. A write latches it and reaches no flash, so writing
; the register is never also a flash write. RESB clears it to 0.
BANK      = $E000

; It cannot be read back, so this byte is the only record of which bank is
; selected. $3A is the first byte the include marks free for a program that has
; taken the machine over, and a cartridge is by definition such a program.
BANKSHDW  = $3A

.segment "FIXED"

; SetBank has to live in the fixed region, and has to be called from it. A
; `jsr SetBank` from code at $C000-$DFFF returns to an address that no longer
; holds the calling code — the machine does not fault, it runs whatever the
; newly selected bank has there.
;
; The shadow is written before the register. An interrupt landing between the
; two stores then sees the shadow one instruction ahead of the hardware, which
; restoring from the shadow puts right; the other order leaves the two
; permanently disagreed.
SetBank:
  sta BANKSHDW
  sta BANK
  rts

CartReset:
  ldx #$ff
  txs                           ; nothing else has set the stack pointer
  stz BANKSHDW                  ; RESB cleared the register, so the shadow
                                ;   starts honest rather than only becoming so
                                ;   at the first SetBank
  jsr KernalInit
  cli

  lda #<FixedMsg
  ldy #>FixedMsg
  jsr PrintStr

  ; The segment name is the value you write: `.segment "BANK01"` below is
  ; reached with `lda #$01`. BankedMsg is a $C0xx address either way — that is
  ; the window, not a position in the file.
  lda #$01
  jsr SetBank
  lda #<BankedMsg
  ldy #>BankedMsg
  jsr PrintStr                  ; PrintStr is in the Kernal at $A090, not in
                                ;   the window, so the bank stays selected
                                ;   across the call

@Loop:
  bra @Loop                     ; a cartridge has nowhere to return to

FixedMsg:
  .byte "THIS LINE IS IN THE FIXED REGION", CHAR_CR, CHAR_LF, $00

.segment "BANK01"

BankedMsg:
  .byte "AND THIS ONE IS IN BANK $01", CHAR_CR, CHAR_LF, $00

.segment "FIXED"

IrqTrampoline:
  jmp (IRQ_PTR)

NmiTrampoline:
  jmp (NMI_PTR)

; The vectors are the reason the fixed region exists. A vector in a bank would
; point at an address whose contents depend on the bank register, and the
; register is cleared by the very reset that reads the vector.
.segment "VECTORS"

.word   NmiTrampoline
.word   CartReset
.word   IrqTrampoline
THIS LINE IS IN THE FIXED REGION
AND THIS ONE IS IN BANK $01

Two details in that listing are easy to misread.

The first is that BankedMsg assembles to a $C0xx address, and would do so in any other bank as well. That address is the window rather than a position in the file: every bank appears at $C000 when it is the selected one. A routine that reads a fixed set of addresses therefore works on whichever bank happens to be selected, so if you give every bank the same internal layout you only have to write that routine once.

The second is that PrintStr is called while bank $01 is still selected, which is safe because PrintStr lives in the Kernal at $A090 — nowhere near the window. Banking moves $C000–$DFFF and nothing else: the Kernal, the I/O slots and the whole of RAM stay where they are.

Interrupts ​

A handler in the fixed region is safe, and the trampolines from Writing a cartridge are in the fixed region, so a cartridge that does not write its own handler needs to do nothing here.

A handler that reads banked data has to put the bank back:

asm
MyIrqHandler:                   ; in FIXED, like every handler on a flash cart
  pha
  lda BANKSHDW                  ; what the interrupted code was using
  pha
  lda #$04
  jsr SetBank                   ; the handler's own bank
  ; ... read whatever is in bank $04 ...
  pla
  jsr SetBank                   ; put it back, shadow and all
  pla
  rti

The handler saves the shadow rather than the register because there is no way to read the register. Keeping a shadow at all is what makes an interrupt handler able to leave the window as it found it.

The sizes ​

TargetPartBytesBanks you can select
128KSST39SF010A131,072$00–$0E — 15 banks, 120 KB
256KSST39SF020A262,144$00–$1E — 31 banks, 248 KB
512KSST39SF040524,288$00–$3E — 63 banks, 504 KB
1Mtwo SST39SF0401,048,576$00–$3E and $40–$7F — 127 banks, 1,016 KB

The last bank of the first chip is always the fixed region, which is why every count is one short of a round number. On a 1 MB cart, bit 6 of the register picks the second chip.

On a smaller part the high bank bits go nowhere

A 128 KB or 256 KB chip has fewer address pins than a 512 KB one, so the top bits of the bank register are not connected to anything. Writing $10 to a 128 KB cart selects bank $00: not an error and not an empty bank, just bank $00 a second time, because no hardware is decoding that bit.

So a cart that asks for bank $25 and gets the contents of bank $05 is working correctly. It is a 128 KB part running a program that was built for a 512 KB one.

Saving ​

A Flash Cart can write to itself. That is how a game on one keeps a high score table or a saved position without a memory card.

The chip is an SST39SF0x0, and writing to it means sending command sequences rather than just storing a byte. Three things catch people, and all three are in the listing below:

  1. The unlock addresses are chip addresses, and you reach them through the window. The chip wants $5555 and $2AAA in its own address space; the window is 8 KB, so those are offset $1555 of bank $02 and offset $0AAA of bank $01. There is no way to write both without switching banks in between — so set the bank register before every command cycle, not once at the start.
  2. A byte program can only turn bits off. The chip computes new = old AND data. Writing $5A over $00 leaves $00. So a sector erase — which writes $FF to a 4 KB block — comes first.
  3. The routine has to run from RAM, with interrupts off. While the chip is erasing or programming, every read of it returns status bits instead of data. That includes the fixed region, where the code is, and the vectors.

A programming routine left in ROM will hang the machine

The third point above is not advice about tidiness. A routine that runs from the cartridge while the cartridge is busy is fetching status bits and executing them as instructions, and an interrupt taken during the same period fetches a vector that is also status bits.

Copy the routine into RAM, sei before it starts, and call it there.

asm
; A cartridge that writes to its own flash: erase a sector, program a byte,
; read it back. This is the whole of how a Flash Cart saves, and it is the
; piece nobody gets right from prose alone, so here it is short enough to read
; whole.
;
; Three things catch people, and all three are in the code below:
;
;   1. The unlock addresses are FLASH addresses, and you reach them through
;      the window — so the bank register has to be set before every command
;      cycle, not once at the start.
;   2. A byte program can only turn bits off (new = old AND data), so a sector
;      erase comes first. Erase writes $FF.
;   3. The routine must run from RAM with interrupts off, because while the
;      chip is busy every read of it returns status bits — including the fixed
;      region, where the vectors are.
;
; 6502-EMULATOR models that busy window, so a routine that got (3) wrong hangs
; here exactly as it would on the board. The PicoCalc and the DB Emulator
; complete instantly and would run it happily; see ACCURACY.md.

.setcpu "65C02"

.include "6502-VDP.inc"

BANK      = $E000               ; the bank register: write-only, reaches no flash
BANKSHDW  = $3A                 ; the only record of what is selected

; Where the save goes. Bank $03 holds flash $6000-$7FFF, so the byte at $C000
; with bank $03 selected is flash $6000 — the first byte of a 4 KB sector.
TARGET_BANK = $03
TARGET      = $C000
SAVE_VALUE  = $5A

; The two unlock cycles of 6502-VCS PLAN.md §3, as the CPU sees them. The chip
; wants flash $5555 and $2AAA; the window is 8 KB, so flash $5555 is offset
; $1555 of bank $02 and flash $2AAA is offset $0AAA of bank $01. There is no
; way to write both without a bank switch in between, and that is the point of
; this listing.
UNLOCK1_BANK = $02
UNLOCK1      = $D555
UNLOCK2_BANK = $01
UNLOCK2      = $CAAA

; Free RAM. $0800 is where a .prg would load; a cartridge has it to itself.
RAM_ROUTINE  = $0800

.segment "FIXED"

SetBank:
  sta BANKSHDW                  ; shadow first, so an interrupt between the two
  sta BANK                      ;   stores sees it ahead rather than behind
  rts

CartReset:
  ldx #$ff
  txs
  stz BANKSHDW
  jsr KernalInit
  cli

  lda #<Banner
  ldy #>Banner
  jsr PrintStr

  ; What is there now. An erased flash chip holds $FF, and the linker fills
  ; every unused byte of the image with $FF for exactly that reason.
  lda #<BeforeMsg
  ldy #>BeforeMsg
  jsr PrintStr
  lda #TARGET_BANK
  jsr SetBank
  lda TARGET
  jsr PrintHexByte
  jsr PrintCRLF

  ; Copy the programming routine into RAM and call it there.
  ldx #$00
@Copy:
  lda RamRoutine,x
  sta RAM_ROUTINE,x
  inx
  cpx #(RamRoutineEnd - RamRoutine)
  bne @Copy
  jsr RAM_ROUTINE

  ; And what is there now.
  lda #<AfterMsg
  ldy #>AfterMsg
  jsr PrintStr
  lda #TARGET_BANK
  jsr SetBank
  lda TARGET
  jsr PrintHexByte
  jsr PrintCRLF

@Loop:
  bra @Loop

; =============================================================================
;   The routine that runs from RAM
; =============================================================================
;   Copied byte for byte to $0800 and called there. It needs no relocating:
;   every address in it is an absolute constant and every branch is relative,
;   so the same bytes work wherever they land. That is worth checking whenever
;   you change it — one `jsr` to a label inside would break it silently, since
;   it would jump back into the copy in the fixed region and run there.
; =============================================================================

RamRoutine:
  sei                           ; a vector fetch during the busy window would
                                ;   read status bits instead of an address

  ; --- Sector erase: $5555<-AA, $2AAA<-55, $5555<-80, $5555<-AA, $2AAA<-55,
  ;     sector<-30. Six cycles, five bank switches.
  lda #UNLOCK1_BANK
  sta BANK
  lda #$AA
  sta UNLOCK1
  lda #UNLOCK2_BANK
  sta BANK
  lda #$55
  sta UNLOCK2
  lda #UNLOCK1_BANK
  sta BANK
  lda #$80
  sta UNLOCK1
  lda #UNLOCK1_BANK
  sta BANK
  lda #$AA
  sta UNLOCK1
  lda #UNLOCK2_BANK
  sta BANK
  lda #$55
  sta UNLOCK2
  lda #TARGET_BANK
  sta BANK
  lda #$30
  sta TARGET

  ; DQ7 reads 0 for the whole 25 ms erase and 1 when the sector is $FF again.
@EraseWait:
  lda TARGET
  bpl @EraseWait

  ; --- Byte program: $5555<-AA, $2AAA<-55, $5555<-A0, target<-data.
  lda #UNLOCK1_BANK
  sta BANK
  lda #$AA
  sta UNLOCK1
  lda #UNLOCK2_BANK
  sta BANK
  lda #$55
  sta UNLOCK2
  lda #UNLOCK1_BANK
  sta BANK
  lda #$A0
  sta UNLOCK1
  lda #TARGET_BANK
  sta BANK
  lda #SAVE_VALUE
  sta TARGET

  ; DQ7 is the complement of the bit just written until the 20 us window
  ; closes, so the read matches the value only when the chip is done.
@ProgramWait:
  lda TARGET
  eor #SAVE_VALUE
  and #$80
  bne @ProgramWait

  stz BANK                      ; leave the register and its shadow agreed and
  stz BANKSHDW                  ;   at 0, the way reset left them
  cli
  rts

RamRoutineEnd:

; The copy loop above counts in X, so the routine has to fit in a page.
.assert (RamRoutineEnd - RamRoutine) < 256, error, "the RAM routine no longer fits in one page"

; =============================================================================
;   Printing
; =============================================================================

; A = the byte. Chrout preserves X and Y.
PrintHexByte:
  pha
  lsr a
  lsr a
  lsr a
  lsr a
  jsr @Nybble
  pla
@Nybble:
  and #$0F
  ora #$30
  cmp #$3A                      ; $30-$39 are already "0"-"9"
  bcc @Out
  adc #$06                      ; carry is set here, so this adds seven
@Out:
  jmp Chrout

Banner:
  .byte "FLASH CART SAVE", CHAR_CR, CHAR_LF, $00
BeforeMsg:
  .byte "BEFORE $", $00
AfterMsg:
  .byte "AFTER  $", $00

IrqTrampoline:
  jmp (IRQ_PTR)

NmiTrampoline:
  jmp (NMI_PTR)

.segment "VECTORS"

.word   NmiTrampoline
.word   CartReset
.word   IrqTrampoline
FLASH CART SAVE
BEFORE $FF
AFTER  $5A

The two waiting loops are the standard polls. During a program, DQ7 reads back as the complement of the bit just written, so the read matches the value only when the chip is done. During an erase, DQ7 reads 0 until the sector is $FF again.

The routine runs correctly wherever it is copied to

Every address in it is an absolute constant and every branch is relative, so the same bytes work at any address. That is what lets it be copied into RAM with no relocation step. Adding a single jsr to a label inside it would break that quietly: the assembler would fix that call to the address the routine has in ROM, so the copy in RAM would jump back into the cartridge and run there, with the chip busy. If you extend the routine, keep it to branches.

The sector is 4 KB and it erases as a unit, so a save area wants a sector of its own. Erasing the sector your level data is in will do exactly what it says.

What your file is called ​

A .crt is a byte-exact image of the chip on the cart. No header, no container, no size field: what is in the file is what is on the chip. So the size of the file is the size of the part, and the name carries it:

FileWhat it is
Game.crt32,768 bytes — a ROM cart
Game-512K.crt524,288 bytes — a Flash Cart
Game-VDP-512K.crtthe same, built for the newer video card

The -VDP comes before the size, always.

The size in the name is a label; the size in the bytes is what the machine reads

Everything that loads a .crt — the emulator, the programmer, the machine itself — picks the mapper from the byte count. The name is a convenience for you.

A file named Game-512K.crt that is 131,072 bytes long loads as a 128 KB cart and warns you that the name disagrees. A file named Game.crt that is 524,288 bytes long loads as a 512 KB cart and warns about that instead. When the two disagree, the byte count is the one to trust.

Building one ​

The 6502-CRT template builds both from one source:

sh
make                # Cart.crt, 32,768 bytes, a ROM cart
make FLASH=512K     # Cart-512K.crt, 524,288 bytes

The linker config covers the banked configs and Makefiles covers the FLASH= axis. To try it:

sh
6502 run --cart Cart-512K.crt

Getting it onto a cart ​

A ROM cart comes out of its socket and goes into a programmer. A Flash Cart cannot, because its chip is a surface-mount part soldered to the board. It is programmed in circuit instead, through the card edge, by a small board called the Flash Helper:

sh
6502-flash program Cart-512K.crt

Onto real hardware is the whole procedure, and says where the Helper and the 6502-flash command come from. It also covers what happens to a save already on the cart, which is nothing: program reads the .crt and nothing else.

Next: BASIC and machine code together.

Written for BIOS v2.0. Released under the MIT License.