gravatar

2014-10-14 15:30writing an nes game, part 2

Writing an NES game - code layout

So before we get into the code itself, there are a couple more concepts that will be important to understand, in order to understand why the code is laid out the way it is.

First, we need to understand how the PPU handles drawing. The NES was designed to work on CRT screens, which work by drawing a horizontal line at a time from the top of the screen to the bottom, at which point it starts again at the top. This is done by a device called an electron gun, which fires electrons at a screen of pixels. The key point here is that drawing is done sequentially, one pixel at a time, and the mechanism requires some amount of time to move from one line to the next, and to move from the bottom of the screen back to the top. The period of time when the electron gun is moving from the end of one line to the beginning of the next is called the "HBlank" time, and the period of time when the electron gun is moving from the bottom of the screen back to the top is called the "VBlank" time. Except during HBlank and VBlank, the PPU is busy controlling what actually needs to be drawn, and manipulating it at all can cause all kinds of weird graphical glitches, so we need to make sure we only communicate with the PPU during HBlank or VBlank.

The way the NES handles this is to provide an interrupt (called NMI) which fires at the beginning of every VBlank, which allows you to do all of your drawing during a safe period of time. HBlank is harder to detect and not as useful (since it occurs in the middle of drawing a frame), and so it is not typically used except for some visual effects. NTSC television screens refresh at 60 frames per second, and the CPU clock speed in the NES is approximately 1.79MHz, and so we get approximately 30,000 CPU cycles per frame, which translates into roughly 5,000-10,000 opcodes. VBlank, though, only lasts around 2273 cycles (roughly 400-800 opcodes), so drawing code needs to be especially efficient. In particular, we don't want to do any game logic at all during VBlank time, since that time is so limited.

The other aspect that needs to be handled is system initialization. When the CPU starts up, it's in an undefined state, so we need to set things up to ensure that the game executes in a repeatable way. Emulators tend to be consistent in how they initialize the system state at startup, but this isn't true of the real hardware, so it's important to do this explicitly. Also, the PPU requires initialization, but that is handled automatically. It does take a bit over 30,000 CPU cycles though (a little over one frame), so we wait for two frames before starting our main game code. Two frames is plenty of time to do any initialization we might need to do.

To illustrate these concepts, here is an example program which modifies the background color every second. The details about how the background color is set isn't particularly important (it's not really a feature you're likely to use very often), but this should illustrate the basic structure of an NES game. This isn't intended to be a lesson on 6502 assembly (there are plenty of much better tutorials and references out there for that - see below), but just to show how games for the NES specifically are structured.

.ROMBANKMAP
BANKSTOTAL  2
BANKSIZE    $4000
BANKS       1
BANKSIZE    $2000
BANKS       1
.ENDRO

.MEMORYMAP
DEFAULTSLOT  0
SLOTSIZE     $4000
SLOT 0       $C000
SLOTSIZE     $2000
SLOT 1       $0000
.ENDME

.ENUM $00
; declare the label 'sleeping' to refer to the byte at memory location $0000
sleeping     DB
; 'color' will then be at $0001
color        DB
; and 'frame_count' will be at $0002
frame_count  DB
.ENDE

  .bank 0 slot 0
  .org $0000
RESET:
  ; First, we disable pretty much everything while we try to get the system
  ; into a consistent state. In particular, we really don't want any
  ; interrupts to fire until the stack pointer is set up (because interrupt
  ; calls use the stack), and we don't want any drawing to be done until the
  ; PPU is initialized. 
  SEI              ; disable all IRQs
  CLD              ; disable decimal mode
  LDX #$FF
  TXS              ; Set up stack (grows down from $FF to $00, at $0100-$01FF)
  INX              ; now X = 0
  STX $2000.w      ; disable NMI (we'll enable it later once the ppu is ready)
  STX $2001.w      ; disable rendering (we're not using it in this example)
  STX $4010.w      ; disable DMC IRQs
  LDX #$40
  STX $4017.w      ; disable APU frame IRQ

  ; First wait for vblank to make sure PPU is ready. The processor sets a
  ; status bit when vblank ends, so we just loop until we notice it.
vblankwait1:
  BIT $2002        ; bit 7 of $2002 is reset once vblank ends
  BPL vblankwait1  ; and bit 7 is what is checked by BPL

  ; set everything in ram ($0000-$07FF) to $00, except for $0200-$02FF which
  ; is conventionally used to hold sprite attribute data. we set that range
  ; to $FE, since that value as a position moves the sprites offscreen, and
  ; when the sprites are offscreen, it doesn't matter which sprites are
  ; selected or what their attributes are
clrmem:
  LDA #$00
  STA $0000, x
  STA $0100, x
  STA $0300, x
  STA $0400, x
  STA $0500, x
  STA $0600, x
  STA $0700, x
  LDA #$FE
  STA $0200, x
  INX
  BNE clrmem

  ; initialize variables in ram
  LDA #%10000001
  STA color
  ; no need to initialize frame_count or sleeping, since we just set them to
  ; $00 in the clrmem loop

  ; Second wait for vblank, PPU is ready after this
vblankwait2:
  BIT $2002
  BPL vblankwait2

  LDA #%10000000   ; enable NMI interrupts now that the PPU is ready
  STA $2000

loop:
  ; sleep while vblank is happening. this serializes the code flow a bit
  ; (the NMI interrupt will almost certainly occur while we are in this loop
  ; unless we do a significant amount of processing in the main codepath, so
  ; it won't interrupt anything important). it also ensures that our game
  ; logic only executes once per frame.
  INC sleeping
wait_for_vblank_end:
  LDA sleeping
  BNE wait_for_vblank_end

  ; change color every 60 frames
  LDX frame_count
  CPX #60
  BCS change_color
  INX
  STX frame_count
  JMP loop_end

change_color:
  LDA #$00
  STA frame_count
  LDX color
  CPX #%10000001
  BEQ turn_green
  CPX #%01000001
  BEQ turn_red

turn_blue:
  LDA #%10000001
  STA color
  JMP loop_end
turn_green:
  LDA #%01000001
  STA color
  JMP loop_end
turn_red:
  LDA #%00100001
  STA color

loop_end:
  JMP loop

NMI:
  ; save the contents of the registers on the stack, since the interrupt can
  ; be called at any point in our main loop
  PHA
  TXA
  PHA
  TYA
  PHA

  LDA color
  STA $2001

  ; indicate that we're done drawing for this frame
  LDA #$00
  STA sleeping
  ; and restore the register contents before returning
  PLA
  TAY
  PLA
  TAX
  PLA

  RTI

  .orga $FFFA
  .dw NMI
  .dw RESET
  .dw 0

  .bank 1 slot 1
  .org $0000
  .incbin "sprites.chr"

The first thing we do when the system turns on is disable IRQ interrupts. Calling and returning from interrupts uses the system stack, but the stack pointer could be pointing anywhere at this point, and so interrupts would be confuse things quite a bit. We never reenable IRQ interrupts here because we don't use them at all (they would be reenabled by the CLI instruction). Next we disable decimal mode (this shouldn't actually do anything, since the NES doesn't have a BCD chip, but no real reason not to do this, to avoid confusion) and set the stack pointer to $FF. The stack pointer is stored in the register named S, and it is a one-byte offset from the RAM address $0100. The stack grows downward, so the stack pointer should start out pointing to $01FF, and then it will be decremented by PHA instructions and incremented by PLA instructions as necessary. Finally, we disable a bunch of other functionality on the PPU and APU, since we don't want them to be active until we have finished initializing.

We need to wait for a total of two frames to ensure that the PPU is entirely initialized, so we next wait for the first frame to end, and then clear out the entire RAM space, and then wait for the second frame to end. At this point, the PPU is initialized, so we can enable NMI interrupts (by setting a bit in the PPU control register at $2000) and begin our main loop.

The main loop is where all of the logic goes. In this example, we just increment the frame count every frame, and change the background color (via some magic) every 60 frames. This allows the NMI interrupt to do nothing more than write a single value to the PPU, without requiring any logic at all. This illustrates the basic principle of using the main game loop to set up values in memory, which the code in the NMI interrupt can just read and act on directly, without requiring any calculations.

Here are some more useful links discussing the topics in this post:

Tags:


blog comments powered by Disqus