diff options
| author | crecher <crecher@crecher.net> | 2026-10-12 16:30:32 +0200 |
|---|---|---|
| committer | crecher <crecher@crecher.net> | 2026-10-12 16:30:32 +0200 |
| commit | 0be10a289132f7ed9b42d5949db844227075e7ce (patch) | |
| tree | 305a468b3c8e1fecb86c22bbe920c8140c46c3a0 | |
| parent | f5969959b81bce1841974cedcac0289d44f7c69e (diff) | |
| download | gones-0be10a289132f7ed9b42d5949db844227075e7ce.tar.gz gones-0be10a289132f7ed9b42d5949db844227075e7ce.zip | |
ITS ALIVE (not rly no image yet)
| -rw-r--r-- | README | 13 | ||||
| -rw-r--r-- | nes/bus.go | 4 | ||||
| -rw-r--r-- | nes/nes.go | 20 | ||||
| -rw-r--r-- | ppu/memory.go | 46 | ||||
| -rw-r--r-- | ppu/ppu.go | 114 | ||||
| -rw-r--r-- | ppu/ppu_test.go | 108 | ||||
| -rw-r--r-- | ppu/registers.go | 141 | ||||
| -rw-r--r-- | tests/ppu_test.go | 338 |
8 files changed, 777 insertions, 7 deletions
@@ -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 @@ -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) } @@ -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) + } +} |