NES Advantage

Making NES Development Less Hard

CNROM CHR Bank Switching

Setting up the CHR Banks

To enable CHR bank switching, we first need to configure our NES ROM to include multiple banks. To do so, we edit the nes.cfg file. In the MEMORY object, replace any CHR ROM entries with the following:

CHRROM0: start=$0000, size=$2000, fill=yes, fillval=$FF; CHRROM1: start=$0000, size=$2000, fill=yes, fillval=$FF; CHRROM2: start=$0000, size=$2000, fill=yes, fillval=$FF; CHRROM3: start=$0000, size=$2000, fill=yes, fillval=$FF;

This creates four memory sections of 8k each, all mapped to $0000 through $2000.

Next, replace any CHR ROM entries in the SEGMENTS object with:

CHR0: load=CHRROM0, type=ro, align=16, optional=yes; CHR1: load=CHRROM1, type=ro, align=16, optional=yes; CHR2: load=CHRROM2, type=ro, align=16, optional=yes; CHR3: load=CHRROM3, type=ro, align=16, optional=yes;

This creates four named ROM segments, CHR0 through CHR3, and maps them to the memory spaces we defined above. Note that each of these segments is mapped to the same space in memory, so they can't be available simultaneously. That's exactly what bank switching is: making more than one ROM chunk available at the same memory space by swapping them back and forth.

Configuring the ROM Header

In order for emulators to run your game properly, you also need to configure your header. Byte 6 of the iNES header tells the emulator how many 8k sections of CHR ROM there are in a cart, so it should equal the number of banks you defined in the previous section. In our case, Byte 6 should be $04.

Loading the CHR Banks

In your NES program code, you likely already have a .segment reference that loads graphics data (like a .chr file) into a single memory space. All we need to do is add more (and make sure they're all named for our newly defined segments).

.segment "CHR0" .incbin "src/graphics/jt_dungeon.chr" .segment "CHR1" .incbin "src/graphics/jt_overworld.chr"

This sample code loads .chr files into the segments CHR0 and CHR1. If we need even more graphics, we could load files into CHR2 and CHR3. (But note that we don't have to, because we set our CHR ROM segments to optional=yes in the config file.)

Telling the Mapper to Switch CHR Banks

Normally, we only write data to buses and RAM. Writing to ROM, of course, can't change anything (because it's read-only). Nevertheless, if given a command to write to a location in ROM, the CPU actually tries anyway. The way simple mappers work is by picking up on this attempted write to the ROM range, and reinterpreting it as a bank switch to the value the CPU is attempting to write. For example, the CNROM mapper will interpret any attempted write to the $8000-$FFFF range as a bank switch.

Bus Conflicts

Unfortunately, the ROM that we're fake-writing to can't tell the difference between a read and a write (which isn't really "supposed to" happen in ROM space). When we attempt a write to ROM, the ROM thinks we want to read from that location—and dutifully puts the value at that location onto the data bus.

Unfortunately, this happens at the same time the CPU is putting the value we're writing onto the data bus. Thus two values are being sent to the data bus at once: the bank number we're writing from the CPU, and whatever happens to be in the ROM location we're fake-writing to.

This is called a bus conflict. We can't guarantee the value from the CPU will win (at least not without a more advanced mapper). Instead, we make sure that the value being read from the ROM matches what we want to write. In other words, we have to write a bank number to a memory location in ROM that stores the same value. That way, whichever data wins, we get the right value.

Bank Switching Code

This common solution avoids bus conflicts by writing the bank number to a space in ROM that reliably contains the same value (and simplifies things by also using the bank number as an offset). This is also a good approach if you want a reusable bank-switching subroutine: just load the bank number you need into A before calling the subroutine, rather than inside it. I've tested this code with the CNROM mapper.

bankswitch: LDA #$01 TAX STA bankvalues, X RTS bankvalues: .byte $00, $01, $02, $03

Here's an alternate method. In the case of a bank switch to a known bank number (rather than from a variable), we could do:

bankswitch: LDA #$01 STA bankswitch+1

This loads the value $01, then immediately writes it to the location where it was hardcoded in memory. That is, the label bankswitch points to the LDA opcode, so bankswitch+1 is the location of #$01. Thus we're writing $01 to $01. I haven't tested this technique yet, but apparently it's used in the Super Mario Bros./Duck Hunt multi-cart.

Copyright ©2024 Nathaniel Webb/Nat20 Games