From 0be10a289132f7ed9b42d5949db844227075e7ce Mon Sep 17 00:00:00 2001 From: crecher Date: Mon, 12 Oct 2026 16:30:32 +0200 Subject: ITS ALIVE (not rly no image yet) --- tests/ppu_test.go | 338 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 tests/ppu_test.go (limited to 'tests') 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) + } +} -- cgit v1.2.3