264. Cpu interrupt nmi

Implement CPU-side NMI interrupt mechanics.

Lesson 264 of 356 · tests/chapter_04_ppu_timing_and_vblank/test_264_cpu_interrupt_nmi.py

References

https://www.nesdev.org/wiki/CPU_interrupts
https://www.nesdev.org/wiki/PPU_registers#Vblank_NMI

Files to update

emulator/cpu/cpu.py

Why this step exists

The PPU can already produce an nmi_requested signal. Before connecting that signal to the CPU through a system/console coordinator, the CPU must know how to perform the NMI sequence by itself.

What is NMI? NMI means Non-Maskable Interrupt. On the NES, the PPU can request NMI at VBlank so game code can run its vertical blank handler.

Intuitive model

NMI is like a hardware emergency jump. The CPU pauses its current path, saves enough state to return later, then jumps to the address stored in the NMI vector.

Mechanistic model

When NMI is accepted, the CPU performs this sequence

1. Push PC high byte
2. Push PC low byte
3. Push status with:
       ONE_FLAG set
       B_FLAG clear
4. Set INTERRUPT_FLAG in CPU status
5. Read low byte from $FFFA
6. Read high byte from $FFFB
7. Set PC = high << 8 | low

Important distinction

NMI does not write to $FFFA/$FFFB. The CPU reads those addresses. In a real ROM, the vector bytes already exist in PRG ROM. In these tests, FakeROM lets us prepare those bytes as test setup.

Example implementation

NMI_VECTOR_LOW = 0xFFFA
NMI_VECTOR_HIGH = 0xFFFB

class CPU:
    ...

    def interrupt_nmi(self) -> None:
        # Save the current PC so RTI can restore it later.
        pc_high = (self.pc >> 8) & 0xFF
        pc_low = self.pc & 0xFF
        self.push_stack(pc_high)
        self.push_stack(pc_low)

        # Hardware interrupts push status with B clear and bit 5 set.
        status_to_push = self.p | ONE_FLAG
        status_to_push &= ~B_FLAG
        self.push_stack(status_to_push)

        # After accepting an interrupt, set the interrupt-disable flag.
        self.p |= INTERRUPT_FLAG

        # NMI vector bytes are read from PRG space. NMI does not write them.
        low = self.bus.read(NMI_VECTOR_LOW)
        high = self.bus.read(NMI_VECTOR_HIGH)
        self.pc = low | (high << 8)

Concrete runtime example

FakeROM setup:
    $FFFA = $00
    $FFFB = $C0

CPU before NMI:
    PC = $8123
    S  = $FD

CPU.interrupt_nmi()

CPU after NMI:
    stack contains return PC/status
    PC = $C000

Stack example with S = $FD and PC = $8123:

write $81 to $01FD, S becomes $FC
write $23 to $01FC, S becomes $FB
write status to $01FB, S becomes $FA

Common misconception

The B flag is not set for hardware interrupts. BRK/PHP push status with B set, but NMI pushes status with B clear. Bit 5, ONE_FLAG, is still set in the pushed status byte.

Out of scope

  • PPU calling CPU.interrupt_nmi()
  • clearing ppu.nmi_requested
  • exact interrupt latency/cycle counts
  • IRQ/APU/mapper interrupts
  • RTI behavior, which was tested earlier in the CPU chapter

Run this lesson

uv run pytest tests/chapter_04_ppu_timing_and_vblank/test_264_cpu_interrupt_nmi.py -v