aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README13
-rw-r--r--nes/bus.go4
-rw-r--r--nes/nes.go20
-rw-r--r--ppu/memory.go46
-rw-r--r--ppu/ppu.go114
-rw-r--r--ppu/ppu_test.go108
-rw-r--r--ppu/registers.go141
-rw-r--r--tests/ppu_test.go338
8 files changed, 777 insertions, 7 deletions
diff --git a/README b/README
index 5db532a..605481d 100644
--- a/README
+++ b/README
@@ -8,16 +8,17 @@ emulator base
- [x] cart package (iNES parser, mapper 0)
system bus (nes package)
-- [/] Bus type: 2KB RAM mirrored through $1FFF, PPU regs $2000-$3FFF (mirrored every 8), APU/IO $4000-$4017, cart $4020+ (need to do PPU and APU first)
+- [/] Bus type: 2KB RAM mirrored through $1FFF, PPU regs $2000-$3FFF (mirrored every 8), APU/IO $4000-$4017, cart $4020+ (APU still missing)
- [ ] OAM DMA at $4014 (stalls CPU 513/514 cycles)
- [x] reuse it in nestest_test.go instead of the test-only bus
-- [ ] CPU stall hook and proper IRQ/NMI line (polled between instructions)
+- [ ] CPU stall hook and proper IRQ/NMI line (polled between instructions) - PPU currently fires NMI straight from Console.Step, see its doc comment
ppu, background
-- [ ] registers $2000-$2007 and v/t/x/w latch logic
-- [ ] nametables with mirroring, pattern tables, palettes, buffered $2007 reads
-- [ ] 341 dots x 262 lines timing, 3 dots per CPU cycle
-- [ ] vblank flag and NMI
+- [x] registers $2000-$2007 and v/t/x/w latch logic
+- [x] nametables with mirroring, palettes, buffered $2007 reads (pattern tables go through the mapper already)
+- [x] 341 dots x 262 lines timing, 3 dots per CPU cycle, odd-frame skip dot
+- [x] vblank flag and edge-triggered NMI, including the $2002 set-dot race window (see ppu/registers.go's readStatus)
+- [ ] background shift-register pixel pipeline, PPUMASK clipping
- [ ] 256x240 framebuffer and RGB palette
frontend and input
diff --git a/nes/bus.go b/nes/bus.go
index eeb96b0..af4f242 100644
--- a/nes/bus.go
+++ b/nes/bus.go
@@ -22,6 +22,8 @@ func (n *Console) Read(addr uint16) uint8 {
switch {
case addr < 0x2000: // internal RAM and mirrors
return n.RAM[addr&0x7FF]
+ case addr < 0x4000: // PPU registers, mirrored every 8 bytes
+ return n.PPU.ReadRegister(addr)
case addr >= 0x4020: // cartridge
return n.Cart.Mapper.CPURead(addr)
default:
@@ -33,6 +35,8 @@ func (n *Console) Write(addr uint16, v uint8) {
switch {
case addr < 0x2000:
n.RAM[addr&0x7FF] = v
+ case addr < 0x4000:
+ n.PPU.WriteRegister(addr, v)
case addr >= 0x4020:
n.Cart.Mapper.CPUWrite(addr, v)
}
diff --git a/nes/nes.go b/nes/nes.go
index 7f36b1a..e3845f6 100644
--- a/nes/nes.go
+++ b/nes/nes.go
@@ -3,26 +3,44 @@ package nes
import (
"crecher.net/gones/v2/cart"
"crecher.net/gones/v2/cpu"
+ "crecher.net/gones/v2/ppu"
)
type Console struct {
CPU *cpu.CPU
+ PPU *ppu.PPU
Cart *cart.Cartridge
RAM [0x800]uint8
- // PPU, APU, controllers once they exist
+ // APU, controllers once they exist
cycles uint64
}
func New(c *cart.Cartridge) *Console {
n := &Console{Cart: c}
+ n.PPU = ppu.New(c.Mapper, c.Mirroring)
n.CPU = cpu.NewCPU(n)
n.CPU.Reset()
return n
}
+// Step runs one CPU instruction and the PPU dots (3 per CPU cycle) that
+// elapse alongside it, firing NMI on a PPUCTRL/vblank edge. PPU state seen
+// by bus accesses mid-instruction still lags behind by up to one
+// instruction's worth of dots, since the CPU isn't cycle-stepped yet (see
+// README: "CPU stall hook and proper IRQ/NMI line (polled between
+// instructions)") - timing-sensitive register logic (the $2002 race window,
+// NMI edge detection) is implemented to the real dot granularity and is
+// exercised directly in the ppu package's tests, but end-to-end precision
+// against real games/test ROMs needs that CPU work to land too.
func (n *Console) Step() int {
cycles := n.CPU.Step()
n.cycles += uint64(cycles)
+ for i := 0; i < cycles*3; i++ {
+ n.PPU.Tick()
+ if n.PPU.PollNMI() {
+ n.CPU.NMI()
+ }
+ }
return cycles
}
diff --git a/ppu/memory.go b/ppu/memory.go
new file mode 100644
index 0000000..3379242
--- /dev/null
+++ b/ppu/memory.go
@@ -0,0 +1,46 @@
+package ppu
+
+import "crecher.net/gones/v2/cart"
+
+// maps a $2000-$3EFF address onto an offset into the 2KB of nametable RAM,
+// according to the cartridge's mirroring mode. four-screen carts will be
+// expected to use their own extra 2KB of RAM via the mapper, but since no
+// implemented mapper does that yet, four-screen just falls back to the vertical
+// mirroring as a placeholder
+
+func (p *PPU) nametableOffset(addr uint16) int {
+ addr &= 0x0FFF
+ table := addr / 0x400
+ offset := int(addr % 0x400)
+
+ var page uint16
+ switch p.mirroring {
+ case cart.Horizontal:
+ page = table / 2
+ case cart.SingleScreenLower:
+ page = 0
+ case cart.SingleScreenUpper:
+ page = 1
+ default: // TODO: add correct four-screen behavior once four-screen carts are added
+ page = table % 2
+ }
+ return int(page)*0x400 + offset
+}
+
+// paletteIndex applies the $3F10/$3F14/$3F18/$3F1C -> $3F00/$3F04/$3F08/$3F0C
+// mirroring
+func paletteIndex(addr uint16) uint16 {
+ idx := addr & 0x1F
+ if idx >= 0x10 && idx%4 == 0 {
+ idx -= 0x10
+ }
+ return idx
+}
+
+func (p *PPU) readPalette(addr uint16) uint8 {
+ return p.palette[paletteIndex(addr)]
+}
+
+func (p *PPU) writePalette(addr uint16, v uint8) {
+ p.palette[paletteIndex(addr)] = v
+}
diff --git a/ppu/ppu.go b/ppu/ppu.go
new file mode 100644
index 0000000..cfc3620
--- /dev/null
+++ b/ppu/ppu.go
@@ -0,0 +1,114 @@
+package ppu
+
+import "crecher.net/gones/v2/cart"
+
+const (
+ ctrlNametableMask = 0x03
+ ctrlIncrement32 = 0x04
+ ctrlNMIEnable = 0x80
+
+ maskShowBackground = 0x08
+ maskShowSprites = 0x10
+
+ statusOverflow = 0x20
+ statusSprite0 = 0x40
+ statusVBlank = 0x80
+)
+
+type PPU struct {
+ mapper cart.Mapper
+ mirroring cart.Mirroring
+
+ vram [0x800]uint8 // 2KB nametable RAM
+ palette [32]uint8
+
+ Ctrl uint8 // $2000, latched
+ Mask uint8 // $2001, latched
+ OAMAddr uint8 // $2003
+ OAM [256]uint8
+
+ // scroll registers, see https://www.nesdev.org/wiki/PPU_scrolling
+ v, t uint16 // 15 bits used
+ x uint8 // fine X, 3 bits used
+ w bool // write toggle, shared by $2005/$2006
+
+ readBuffer uint8 // internal $2007 read buffer
+ busLatch uint8 // crude open-bus stand in last byte written to any register
+
+ spriteOverflow bool
+ sprite0Hit bool
+ vblank bool // aka nmi_occurred
+
+ Scanline int
+ Dot int
+ Frame uint64
+
+ nmiOutput bool // PPUCTRL bit 7
+ nmiLine bool // vblank && nmiOutput, latched each tick for edge detection
+ nmiPending bool // set on nmiLine's rising edge, consumed by PollNMI
+
+ // see the $2002 read/vblank race window handled in readStatus
+ suppressVBlankSet bool
+ suppressNMIForFrame bool
+}
+
+func New(mapper cart.Mapper, mirroring cart.Mirroring) *PPU {
+ return &PPU{mapper: mapper, mirroring: mirroring}
+}
+
+// reports if an NMI edge fired since the last call, clearing it
+func (p *PPU) PollNMI() bool {
+ pending := p.nmiPending
+ p.nmiPending = false
+ return pending
+}
+
+func (p *PPU) renderingEnabled() bool {
+ return p.Mask&(maskShowBackground|maskShowSprites) != 0
+}
+
+func (p *PPU) updateNMILine() {
+ current := p.vblank && p.nmiOutput
+ if current && !p.nmiLine && !p.suppressNMIForFrame {
+ p.nmiPending = true
+ }
+ p.nmiLine = current
+}
+
+// advances PPU by one dot, right now only tracks the dot/scanline position and the vblank flag/NMI line,
+// which is enough to just validate the timings.
+func (p *PPU) Tick() {
+ p.advanceDot()
+
+ switch {
+ case p.Scanline == 241 && p.Dot == 1:
+ if !p.suppressVBlankSet {
+ p.vblank = true
+ }
+ p.suppressVBlankSet = false
+ case p.Scanline == 261 && p.Dot == 1:
+ p.vblank = false
+ p.sprite0Hit = false
+ p.spriteOverflow = false
+ p.suppressVBlankSet = false
+ p.suppressNMIForFrame = false
+ }
+ p.updateNMILine()
+}
+
+func (p *PPU) advanceDot() {
+ maxDot := 340
+ if p.Scanline == 261 && p.renderingEnabled() && p.Frame%2 == 1 {
+ maxDot = 339 // pre-render line is one dot short every odd frame while rendering
+ }
+
+ p.Dot++
+ if p.Dot > maxDot {
+ p.Dot = 0
+ p.Scanline++
+ if p.Scanline > 261 {
+ p.Scanline = 0
+ p.Frame++
+ }
+ }
+}
diff --git a/ppu/ppu_test.go b/ppu/ppu_test.go
new file mode 100644
index 0000000..9426031
--- /dev/null
+++ b/ppu/ppu_test.go
@@ -0,0 +1,108 @@
+package ppu
+
+// more tests for the scroll registers since they can't be checked outside
+// the package without a $2006 write that would mess up the bits under test
+
+import (
+ "testing"
+
+ "crecher.net/gones/v2/cart"
+)
+
+// nilMapper is enough for these tests
+type nilMapper struct{}
+
+func (nilMapper) CPURead(addr uint16) uint8 { return 0 }
+func (nilMapper) CPUWrite(addr uint16, v uint8) {}
+func (nilMapper) PPURead(addr uint16) uint8 { return 0 }
+func (nilMapper) PPUWrite(addr uint16, v uint8) {}
+func (nilMapper) Mirroring() cart.Mirroring { return cart.Horizontal }
+
+func newPPU() *PPU { return New(nilMapper{}, cart.Horizontal) }
+
+func TestCtrlNametableBitsSetT(t *testing.T) {
+ p := newPPU()
+ p.WriteRegister(0x2000, 0x03) // nametable select = 3 -> t bits 10-11
+ if got := p.t & 0x0C00; got != 0x0C00 {
+ t.Errorf("t&0x0C00 = %#04x, want 0x0C00", got)
+ }
+
+ p.WriteRegister(0x2000, 0x00)
+ if got := p.t & 0x0C00; got != 0 {
+ t.Errorf("t&0x0C00 = %#04x, want 0 after clearing nametable select", got)
+ }
+}
+
+func TestScrollFirstWriteSetsCoarseXAndFineX(t *testing.T) {
+ p := newPPU()
+ p.WriteRegister(0x2005, 0b11101_011) // coarseX=0b11101=29, fineX=0b011=3
+
+ if got := p.t & 0x001F; got != 29 {
+ t.Errorf("coarse X in t = %d, want 29", got)
+ }
+ if p.x != 3 {
+ t.Errorf("fine X = %d, want 3", p.x)
+ }
+ if !p.w {
+ t.Error("w should be set after the first $2005 write")
+ }
+}
+
+func TestScrollSecondWriteSetsCoarseYAndFineY(t *testing.T) {
+ p := newPPU()
+ p.WriteRegister(0x2005, 0x00) // first write, establishes w=true
+ p.WriteRegister(0x2005, 0b10110_101) // coarseY=0b10110=22, fineY=0b101=5
+
+ if got := (p.t >> 5) & 0x1F; got != 22 {
+ t.Errorf("coarse Y in t = %d, want 22", got)
+ }
+ if got := (p.t >> 12) & 0x7; got != 5 {
+ t.Errorf("fine Y in t = %d, want 5", got)
+ }
+ if p.w {
+ t.Error("w should be cleared after the second $2005 write")
+ }
+}
+
+func TestScrollWriteDoesNotTouchNametableBits(t *testing.T) {
+ p := newPPU()
+ p.WriteRegister(0x2000, 0x03) // select nametable 3
+ p.WriteRegister(0x2005, 0xFF)
+ p.WriteRegister(0x2005, 0xFF)
+
+ if got := p.t & 0x0C00; got != 0x0C00 {
+ t.Errorf("PPUSCROLL writes clobbered the nametable-select bits: t&0x0C00 = %#04x, want 0x0C00", got)
+ }
+}
+
+func TestAddrWriteSequenceSetsTThenV(t *testing.T) {
+ p := newPPU()
+ p.v = 0x1234 // poison v so we can tell the first write didn't touch it
+
+ p.WriteRegister(0x2006, 0x3D) // high byte: only the low 6 bits are kept, bit 14 cleared
+ if got := p.t & 0xFF00; got != 0x3D00 {
+ t.Errorf("t high byte = %#04x, want 0x3D00", got)
+ }
+ if p.v != 0x1234 {
+ t.Errorf("v changed after the first $2006 write: %#04x", p.v)
+ }
+
+ p.WriteRegister(0x2006, 0x56)
+ if p.t != 0x3D56 {
+ t.Errorf("t = %#04x, want 0x3D56", p.t)
+ }
+ if p.v != 0x3D56 {
+ t.Errorf("v = %#04x, want 0x3D56 (should be copied from t on the second write)", p.v)
+ }
+}
+
+func TestAddrHighByteClearsBit14(t *testing.T) {
+ p := newPPU()
+ p.WriteRegister(0x2006, 0xFF) // top 2 bits of the written byte are discarded
+ if p.t&0x4000 != 0 {
+ t.Errorf("t bit 14 should always be cleared by a $2006 high-byte write, t = %#04x", p.t)
+ }
+ if got := p.t & 0xFF00; got != 0x3F00 {
+ t.Errorf("t high byte = %#04x, want 0x3F00 (0xFF masked to 6 bits)", got)
+ }
+}
diff --git a/ppu/registers.go b/ppu/registers.go
new file mode 100644
index 0000000..5445619
--- /dev/null
+++ b/ppu/registers.go
@@ -0,0 +1,141 @@
+package ppu
+
+func (p *PPU) ReadRegister(addr uint16) uint8 {
+ switch addr & 0x7 {
+ case 2:
+ return p.readStatus()
+ case 4:
+ return p.OAM[p.OAMAddr]
+ case 7:
+ return p.readData()
+ default:
+ return p.busLatch // $2000,2001,2003,2005,2006 are write only: open bus
+ }
+}
+
+// handles cpu write to $2000-2007
+func (p *PPU) WriteRegister(addr uint16, v uint8) {
+ p.busLatch = v
+ switch addr & 0x7 {
+ case 0:
+ p.Ctrl = v
+ p.t = (p.t &^ 0x0C00) | (uint16(v&ctrlNametableMask) << 10)
+ p.nmiOutput = v&ctrlNMIEnable != 0
+ p.updateNMILine()
+ case 1:
+ p.Mask = v
+ case 3:
+ p.OAMAddr = v
+ case 4:
+ p.OAM[p.OAMAddr] = v
+ p.OAMAddr++
+ case 5:
+ p.writeScroll(v)
+ case 6:
+ p.writeAddr(v)
+ case 7:
+ p.writeData(v)
+ }
+}
+
+// implements the $2002 read, including the hardware race around the dot
+// vblank gets set (see https://www.nesdev.org/wiki/NMI)
+func (p *PPU) readStatus() uint8 {
+ result := p.busLatch & 0x1F
+ if p.vblank {
+ result |= statusVBlank
+ }
+ if p.sprite0Hit {
+ result |= statusSprite0
+ }
+ if p.spriteOverflow {
+ result |= statusOverflow
+ }
+
+ if p.Scanline == 241 {
+ switch p.Dot {
+ case 0:
+ p.suppressVBlankSet = true
+ p.suppressNMIForFrame = true
+ case 1, 2:
+ p.suppressNMIForFrame = true
+ p.nmiPending = false
+ }
+ }
+
+ p.vblank = false
+ p.w = false
+ p.updateNMILine()
+ return result
+}
+
+func (p *PPU) writeScroll(v uint8) {
+ if !p.w {
+ p.t = (p.t &^ 0x001F) | uint16(v>>3)
+ p.x = v & 0x07
+ } else {
+ p.t = (p.t &^ 0x73E0) | (uint16(v&0x07) << 12) | (uint16(v&0xF8) << 2)
+ }
+ p.w = !p.w
+}
+
+func (p *PPU) writeAddr(v uint8) {
+ if !p.w {
+ p.t = (p.t & 0x00FF) | (uint16(v&0x3F) << 8)
+ } else {
+ p.t = (p.t & 0xFF00) | uint16(v)
+ p.v = p.t
+ }
+ p.w = !p.w
+}
+
+// implements buffered $2007 and a quirk which i am too lazy to write down
+// right now :D
+// https://www.nesdev.org/wiki/PPU_registers#The_PPUDATA_data_port
+func (p *PPU) readData() uint8 {
+ addr := p.v & 0x3FFF
+ var result uint8
+ if addr >= 0x3F00 {
+ result = p.readPalette(addr)
+ p.readBuffer = p.readVRAM(addr & 0x2FFF)
+ } else {
+ result = p.readBuffer
+ p.readBuffer = p.readVRAM(addr)
+ }
+ p.incrementV()
+ return result
+}
+
+func (p *PPU) writeData(v uint8) {
+ addr := p.v & 0x3FFF
+ if addr >= 0x3F00 {
+ p.writePalette(addr, v)
+ } else {
+ p.writeVRAM(addr, v)
+ }
+ p.incrementV()
+}
+
+func (p *PPU) incrementV() {
+ if p.Ctrl&ctrlIncrement32 != 0 {
+ p.v += 32
+ } else {
+ p.v++
+ }
+ p.v &= 0x7FFF
+}
+
+func (p *PPU) readVRAM(addr uint16) uint8 {
+ if addr < 0x2000 {
+ return p.mapper.PPURead(addr)
+ }
+ return p.vram[p.nametableOffset(addr)]
+}
+
+func (p *PPU) writeVRAM(addr uint16, v uint8) {
+ if addr < 0x2000 {
+ p.mapper.PPUWrite(addr, v)
+ return
+ }
+ p.vram[p.nametableOffset(addr)] = v
+}
diff --git a/tests/ppu_test.go b/tests/ppu_test.go
new file mode 100644
index 0000000..82248d6
--- /dev/null
+++ b/tests/ppu_test.go
@@ -0,0 +1,338 @@
+package tests
+
+import (
+ "testing"
+
+ "crecher.net/gones/v2/cart"
+ "crecher.net/gones/v2/ppu"
+)
+
+// fakeMapper gives the PPU 8KB of CHR-RAM and a fixed mirroring mode,
+// without needing a real iNES file.
+type fakeMapper struct {
+ chr [0x2000]uint8
+ mirroring cart.Mirroring
+}
+
+func (m *fakeMapper) CPURead(addr uint16) uint8 { return 0 }
+func (m *fakeMapper) CPUWrite(addr uint16, v uint8) {}
+func (m *fakeMapper) PPURead(addr uint16) uint8 { return m.chr[addr&0x1FFF] }
+func (m *fakeMapper) PPUWrite(addr uint16, v uint8) { m.chr[addr&0x1FFF] = v }
+func (m *fakeMapper) Mirroring() cart.Mirroring { return m.mirroring }
+
+func newTestPPU(mirroring cart.Mirroring) (*ppu.PPU, *fakeMapper) {
+ m := &fakeMapper{mirroring: mirroring}
+ return ppu.New(m, mirroring), m
+}
+
+// runToDot ticks the PPU until it reaches the given scanline/dot.
+func runToDot(p *ppu.PPU, scanline, dot int) {
+ for !(p.Scanline == scanline && p.Dot == dot) {
+ p.Tick()
+ }
+}
+
+// PPUCTRL's nametable-select bits feeding into t are covered by the
+// white-box tests in the ppu package itself (ppu/ppu_test.go), since the
+// only way to observe t from outside is via $2006, which fully overwrites
+// those same bit positions - not useful for isolating PPUCTRL's effect.
+
+func TestStatusReadResetsWriteLatch(t *testing.T) {
+ p, _ := newTestPPU(cart.Horizontal)
+
+ // start a $2006 write pair but only complete the first half.
+ p.WriteRegister(0x2006, 0x12)
+ // reading $2002 must reset the shared write latch, so the writes
+ // below start a fresh pair instead of continuing this one.
+ p.ReadRegister(0x2002)
+
+ p.WriteRegister(0x2006, 0x24)
+ p.WriteRegister(0x2006, 0x56)
+ p.WriteRegister(0x2007, 0x9A)
+
+ p.WriteRegister(0x2006, 0x24)
+ p.WriteRegister(0x2006, 0x56)
+ p.ReadRegister(0x2007)
+ if got := p.ReadRegister(0x2007); got != 0x9A {
+ t.Errorf("byte at $2456 = %#02x, want 0x9A (status read should have reset the write latch)", got)
+ }
+}
+
+func TestPPUADDRWriteAndPPUDATAIncrement(t *testing.T) {
+ p, _ := newTestPPU(cart.Vertical)
+
+ p.WriteRegister(0x2006, 0x20)
+ p.WriteRegister(0x2006, 0x00) // v = $2000
+ p.WriteRegister(0x2007, 0x11)
+ p.WriteRegister(0x2007, 0x22) // increments by 1 (CTRL bit2 clear)
+
+ p.WriteRegister(0x2006, 0x20)
+ p.WriteRegister(0x2006, 0x00)
+ p.ReadRegister(0x2007) // buffered: returns old stale value
+ if got := p.ReadRegister(0x2007); got != 0x11 {
+ t.Errorf("byte at $2000 = %#02x, want 0x11", got)
+ }
+ if got := p.ReadRegister(0x2007); got != 0x22 {
+ t.Errorf("byte at $2001 = %#02x, want 0x22", got)
+ }
+}
+
+func TestPPUDATAIncrement32(t *testing.T) {
+ p, _ := newTestPPU(cart.Vertical)
+ p.WriteRegister(0x2000, 0x04) // VRAM increment = 32
+
+ p.WriteRegister(0x2006, 0x20)
+ p.WriteRegister(0x2006, 0x00) // v = $2000
+ p.WriteRegister(0x2007, 0x99) // writes $2000, v -> $2020
+ p.WriteRegister(0x2007, 0x77) // writes $2020, v -> $2040
+
+ // if the increment were wrongly 1 instead of 32, this second byte
+ // would have landed at $2001, not $2020.
+ p.WriteRegister(0x2006, 0x20)
+ p.WriteRegister(0x2006, 0x00)
+ p.ReadRegister(0x2007)
+ if got := p.ReadRegister(0x2007); got != 0x99 {
+ t.Errorf("byte at $2000 = %#02x, want 0x99", got)
+ }
+
+ p.WriteRegister(0x2006, 0x20)
+ p.WriteRegister(0x2006, 0x20)
+ p.ReadRegister(0x2007)
+ if got := p.ReadRegister(0x2007); got != 0x77 {
+ t.Errorf("byte at $2020 = %#02x, want 0x77 (increment should skip $2001-$201F)", got)
+ }
+}
+
+func TestPPUDATABufferedReadUnderPalette(t *testing.T) {
+ p, _ := newTestPPU(cart.Vertical)
+
+ // seed the nametable byte that sits "under" palette address $3F00,
+ // i.e. $2F00 (since $3F00 & 0x2FFF == 0x2F00).
+ p.WriteRegister(0x2006, 0x2F)
+ p.WriteRegister(0x2006, 0x00)
+ p.WriteRegister(0x2007, 0x7E)
+
+ // write a palette entry and read it straight back: should return the
+ // palette byte immediately (unbuffered for palette reads)...
+ p.WriteRegister(0x2006, 0x3F)
+ p.WriteRegister(0x2006, 0x00)
+ p.WriteRegister(0x2007, 0x15)
+ p.WriteRegister(0x2006, 0x3F)
+ p.WriteRegister(0x2006, 0x00)
+ if got := p.ReadRegister(0x2007); got != 0x15 {
+ t.Errorf("palette readback = %#02x, want 0x15", got)
+ }
+
+ // ...but the read buffer underneath should now hold the nametable
+ // byte from $2F00, not the palette byte. A non-palette read returns
+ // the *previous* buffer, so point v somewhere else first.
+ p.WriteRegister(0x2006, 0x21)
+ p.WriteRegister(0x2006, 0x00)
+ if got := p.ReadRegister(0x2007); got != 0x7E {
+ t.Errorf("buffered byte after palette read = %#02x, want 0x7E (buffer should hold the $2F00 nametable byte)", got)
+ }
+}
+
+func TestPalettePointerMirroring(t *testing.T) {
+ p, _ := newTestPPU(cart.Vertical)
+
+ p.WriteRegister(0x2006, 0x3F)
+ p.WriteRegister(0x2006, 0x10) // $3F10 mirrors $3F00
+ p.WriteRegister(0x2007, 0x30)
+
+ p.WriteRegister(0x2006, 0x3F)
+ p.WriteRegister(0x2006, 0x00)
+ if got := p.ReadRegister(0x2007); got != 0x30 {
+ t.Errorf("$3F00 = %#02x, want 0x30 (written via its $3F10 mirror)", got)
+ }
+}
+
+func TestNametableMirroringHorizontal(t *testing.T) {
+ p, _ := newTestPPU(cart.Horizontal)
+
+ // horizontal mirroring: NT0==NT1 (page A), NT2==NT3 (page B).
+ p.WriteRegister(0x2006, 0x20)
+ p.WriteRegister(0x2006, 0x00)
+ p.WriteRegister(0x2007, 0xAA)
+
+ p.WriteRegister(0x2006, 0x24)
+ p.WriteRegister(0x2006, 0x00)
+ p.ReadRegister(0x2007)
+ if got := p.ReadRegister(0x2007); got != 0xAA {
+ t.Errorf("$2400 = %#02x, want 0xAA (should mirror $2000 under horizontal mirroring)", got)
+ }
+
+ p.WriteRegister(0x2006, 0x28)
+ p.WriteRegister(0x2006, 0x00)
+ p.ReadRegister(0x2007)
+ if got := p.ReadRegister(0x2007); got == 0xAA {
+ t.Errorf("$2800 should NOT mirror $2000 under horizontal mirroring, got 0xAA")
+ }
+}
+
+func TestNametableMirroringVertical(t *testing.T) {
+ p, _ := newTestPPU(cart.Vertical)
+
+ // vertical mirroring: NT0==NT2 (page A), NT1==NT3 (page B).
+ p.WriteRegister(0x2006, 0x20)
+ p.WriteRegister(0x2006, 0x00)
+ p.WriteRegister(0x2007, 0x55)
+
+ p.WriteRegister(0x2006, 0x28)
+ p.WriteRegister(0x2006, 0x00)
+ p.ReadRegister(0x2007)
+ if got := p.ReadRegister(0x2007); got != 0x55 {
+ t.Errorf("$2800 = %#02x, want 0x55 (should mirror $2000 under vertical mirroring)", got)
+ }
+}
+
+func TestOAMDataReadWrite(t *testing.T) {
+ p, _ := newTestPPU(cart.Horizontal)
+
+ p.WriteRegister(0x2003, 0x10) // OAMADDR
+ p.WriteRegister(0x2004, 0x42) // OAMDATA, also increments OAMADDR
+
+ p.WriteRegister(0x2003, 0x10)
+ if got := p.ReadRegister(0x2004); got != 0x42 {
+ t.Errorf("OAM[0x10] = %#02x, want 0x42", got)
+ }
+}
+
+func TestVBlankFlagSetAndClearedByRead(t *testing.T) {
+ p, _ := newTestPPU(cart.Horizontal)
+
+ runToDot(p, 241, 1) // the tick that sets vblank already ran
+ status := p.ReadRegister(0x2002)
+ if status&0x80 == 0 {
+ t.Fatal("vblank bit should be set at scanline 241, dot 1")
+ }
+ if status2 := p.ReadRegister(0x2002); status2&0x80 != 0 {
+ t.Fatal("reading $2002 should clear the vblank flag")
+ }
+}
+
+func TestVBlankClearedAtPreRender(t *testing.T) {
+ p, _ := newTestPPU(cart.Horizontal)
+ // don't read $2002 here, that would clear vblank itself
+ runToDot(p, 241, 1)
+ runToDot(p, 261, 1)
+ if got := p.ReadRegister(0x2002); got&0x80 != 0 {
+ t.Fatal("vblank should be cleared at the pre-render line")
+ }
+}
+
+func TestNMIFiresOnVBlankWhenEnabled(t *testing.T) {
+ p, _ := newTestPPU(cart.Horizontal)
+ p.WriteRegister(0x2000, 0x80) // enable NMI on vblank
+
+ runToDot(p, 241, 1)
+ if !p.PollNMI() {
+ t.Fatal("expected an NMI edge at scanline 241, dot 1")
+ }
+ if p.PollNMI() {
+ t.Fatal("PollNMI should clear the pending flag")
+ }
+}
+
+func TestNMIDoesNotFireWhenDisabled(t *testing.T) {
+ p, _ := newTestPPU(cart.Horizontal)
+ runToDot(p, 241, 10)
+ if p.PollNMI() {
+ t.Fatal("NMI should not fire with PPUCTRL bit 7 clear")
+ }
+}
+
+func TestNMIRetriggersOnCTRLToggleDuringVBlank(t *testing.T) {
+ p, _ := newTestPPU(cart.Horizontal)
+ p.WriteRegister(0x2000, 0x80)
+
+ runToDot(p, 241, 1)
+ if !p.PollNMI() {
+ t.Fatal("expected first NMI edge")
+ }
+
+ // toggling NMI enable off then on again while still in vblank produces
+ // a second rising edge on the internal NMI line, since it's edge- not
+ // level-triggered, so it should fire a second NMI.
+ p.WriteRegister(0x2000, 0x00)
+ if p.PollNMI() {
+ t.Fatal("clearing bit 7 must not itself signal an NMI")
+ }
+ p.WriteRegister(0x2000, 0x80)
+ if !p.PollNMI() {
+ t.Fatal("expected a second NMI edge after re-enabling mid-vblank")
+ }
+}
+
+func TestVBlankReadRaceOneDotEarlySuppressesWholeFrame(t *testing.T) {
+ p, _ := newTestPPU(cart.Horizontal)
+ p.WriteRegister(0x2000, 0x80)
+
+ runToDot(p, 241, 0)
+ if status := p.ReadRegister(0x2002); status&0x80 != 0 {
+ t.Fatal("reading one dot early should see the vblank flag as clear")
+ }
+
+ // advance through where vblank would normally be set, and well beyond
+ runToDot(p, 241, 10)
+ if p.PollNMI() {
+ t.Fatal("NMI must not fire this frame after the one-dot-early read")
+ }
+ if status := p.ReadRegister(0x2002); status&0x80 != 0 {
+ t.Fatal("vblank flag should never get set this frame after the one-dot-early read")
+ }
+
+ // but the next frame should behave completely normally again
+ runToDot(p, 241, 1)
+ if status := p.ReadRegister(0x2002); status&0x80 == 0 {
+ t.Fatal("vblank flag should set normally on the following frame")
+ }
+}
+
+func TestVBlankReadRaceSameDotSuppressesNMIOnly(t *testing.T) {
+ p, _ := newTestPPU(cart.Horizontal)
+ p.WriteRegister(0x2000, 0x80)
+
+ runToDot(p, 241, 1)
+ status := p.ReadRegister(0x2002)
+ if status&0x80 == 0 {
+ t.Fatal("reading on the exact set dot should still see the flag as set")
+ }
+ if p.PollNMI() {
+ t.Fatal("NMI should be suppressed for this frame when $2002 is read on the set dot")
+ }
+
+ runToDot(p, 261, 1) // pre-render clears the suppression state
+ // Read well clear of the race window this time (dot 1/2 would
+ // re-trigger the very suppression we're testing past).
+ runToDot(p, 241, 10)
+ if !p.PollNMI() {
+ t.Fatal("NMI should fire normally again next frame")
+ }
+ if status := p.ReadRegister(0x2002); status&0x80 == 0 {
+ t.Fatal("vblank should set normally again next frame")
+ }
+}
+
+func TestOddFrameDotSkipOnlyWhenRenderingEnabled(t *testing.T) {
+ p, _ := newTestPPU(cart.Horizontal)
+
+ // Rendering disabled: scanline 261 must have the full 341 dots (0..340).
+ runToDot(p, 261, 340)
+ p.Tick()
+ if p.Scanline != 0 || p.Dot != 0 {
+ t.Fatalf("with rendering disabled, dot 340 should advance to scanline 0 dot 0, got scanline %d dot %d", p.Scanline, p.Dot)
+ }
+
+ p2, _ := newTestPPU(cart.Horizontal)
+ p2.WriteRegister(0x2001, 0x08) // show background -> rendering enabled
+ // get to an odd frame's pre-render line
+ for p2.Frame%2 == 0 {
+ p2.Tick()
+ }
+ runToDot(p2, 261, 339)
+ p2.Tick()
+ if p2.Scanline != 0 || p2.Dot != 0 {
+ t.Fatalf("on an odd frame with rendering enabled, dot 339 should skip straight to scanline 0 dot 0, got scanline %d dot %d", p2.Scanline, p2.Dot)
+ }
+}