aboutsummaryrefslogtreecommitdiff
path: root/tests/ppu_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'tests/ppu_test.go')
-rw-r--r--tests/ppu_test.go338
1 files changed, 338 insertions, 0 deletions
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)
+ }
+}