From 99cae5a5a37f41f878a84857361eda47910303ba Mon Sep 17 00:00:00 2001 From: crecher Date: Tue, 13 Oct 2026 01:13:13 +0200 Subject: sprites!!!!!! --- README.md | 12 +- cmd/gones/main.go | 24 ++++ cpu/cpu.go | 5 + cpu/instructions.go | 80 +++++++++++++ cpu/opcodes.go | 15 +++ nes/bus.go | 9 ++ nes/controller.go | 53 +++++++++ nes/dma.go | 19 ++++ nes/nes.go | 11 +- ppu/memory.go | 6 - ppu/ppu.go | 15 ++- ppu/render.go | 36 ++++-- ppu/sprites.go | 97 ++++++++++++++++ tests/controller_test.go | 119 +++++++++++++++++++ tests/cpu_test.go | 143 +++++++++++++++++++++-- tests/dma_test.go | 78 +++++++++++++ tests/ppu_sprite_test.go | 291 +++++++++++++++++++++++++++++++++++++++++++++++ 17 files changed, 978 insertions(+), 35 deletions(-) create mode 100644 nes/controller.go create mode 100644 nes/dma.go create mode 100644 ppu/sprites.go create mode 100644 tests/controller_test.go create mode 100644 tests/dma_test.go create mode 100644 tests/ppu_sprite_test.go diff --git a/README.md b/README.md index d6d1e37..5fa0110 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ im trying to make it somewhat commented so yeah :D #### system bus (nes package) - [/] 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] 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) - PPU currently fires NMI straight from Console.Step, see its doc comment +- [/] CPU stall hook (done) and proper IRQ/NMI line (polled between instructions) - PPU currently fires NMI straight from Console.Step, see its doc comment #### ppu, background - [x] registers $2000-$2007 and v/t/x/w latch logic @@ -24,11 +24,11 @@ im trying to make it somewhat commented so yeah :D #### frontend and input - [x] cmd/gones sdl2 gui (veandco/go-sdl2): window, framebuffer texture, one frame per 1/60s, vsync - [ ] sdl2 audio output (queue or callback) -- [ ] controller $4016/$4017 strobe and shift register, keyboard mapping +- [x] controller $4016/$4017 strobe and shift register, keyboard mapping #### ppu, sprites -- [ ] OAM, per-scanline evaluation, 8-sprite limit, 8x8 and 8x16, flip, priority -- [ ] sprite-0 hit +- [x] OAM, per-scanline evaluation, 8-sprite limit, 8x8 and 8x16, flip, priority (overflow flag is the simple version, not the buggy hardware one) +- [x] sprite-0 hit #### more mappers - [ ] MMC1 (1), UxROM (2), CNROM (3), AxROM (7) @@ -40,7 +40,7 @@ im trying to make it somewhat commented so yeah :D - [ ] resampling and audio output #### accuracy -- [ ] implement all unofficial instructions +- [x] implement all unofficial instructions - [ ] run accuracycoin and blargg's CPU, PPU, APU and MMC3 test ROMs #### polish diff --git a/cmd/gones/main.go b/cmd/gones/main.go index 57b1b6e..0a1fbe7 100644 --- a/cmd/gones/main.go +++ b/cmd/gones/main.go @@ -96,6 +96,7 @@ func run(romPath string) error { } } + console.SetButtons(0, heldButtons()) console.StepFrame() // UpdateRGBA multiplies the pitch by 4 itself, so this is in pixels, not bytes @@ -126,3 +127,26 @@ func run(romPath string) error { } } } + +// keyboard layout for controller 1 +var keyMap = map[sdl.Scancode]uint8{ + sdl.SCANCODE_X: nes.ButtonA, + sdl.SCANCODE_Y: nes.ButtonB, + sdl.SCANCODE_RSHIFT: nes.ButtonSelect, + sdl.SCANCODE_RETURN: nes.ButtonStart, + sdl.SCANCODE_UP: nes.ButtonUp, + sdl.SCANCODE_DOWN: nes.ButtonDown, + sdl.SCANCODE_LEFT: nes.ButtonLeft, + sdl.SCANCODE_RIGHT: nes.ButtonRight, +} + +func heldButtons() uint8 { + keys := sdl.GetKeyboardState() + var b uint8 + for scancode, button := range keyMap { + if keys[scancode] != 0 { + b |= button + } + } + return b +} diff --git a/cpu/cpu.go b/cpu/cpu.go index 113adf0..b8b0a1e 100644 --- a/cpu/cpu.go +++ b/cpu/cpu.go @@ -111,6 +111,11 @@ func (c *CPU) interrupt(vector uint16, brk bool) { c.PC = c.read16(vector) } +// burns cycles without running anything, used while dma holds the cpu +func (c *CPU) Stall(cycles int) { + c.Cycles += uint64(cycles) +} + func (c *CPU) Step() int { if c.Jammed { c.Cycles++ diff --git a/cpu/instructions.go b/cpu/instructions.go index b74a2db..b4d4bbe 100644 --- a/cpu/instructions.go +++ b/cpu/instructions.go @@ -226,3 +226,83 @@ func jamExec(c *CPU, op operand) { c.Jammed = true c.PC-- } + +// and with immediate, then carry takes the sign bit +func ancExec(c *CPU, op operand) { + c.A &= c.operandValue(op) + c.setZN(c.A) + c.setFlag(FlagC, c.A&0x80 != 0) +} + +// and with immediate, then lsr a +func alrExec(c *CPU, op operand) { + v := c.A & c.operandValue(op) + c.setFlag(FlagC, v&0x01 != 0) + c.A = v >> 1 + c.setZN(c.A) +} + +// and with immediate, then ror a, but C and V come from bits 6 and 5 of the +// result (binary mode only, the 2A03 has no decimal mode) +func arrExec(c *CPU, op operand) { + v := c.A & c.operandValue(op) + var carryIn uint8 + if c.flag(FlagC) { + carryIn = 0x80 + } + c.A = v>>1 | carryIn + c.setZN(c.A) + c.setFlag(FlagC, c.A&0x40 != 0) + c.setFlag(FlagV, (c.A>>6^c.A>>5)&0x01 != 0) +} + +// x = (a & x) - immediate, like cmp: no borrow in, V untouched +func axsExec(c *CPU, op operand) { + v := c.A & c.X + imm := c.operandValue(op) + c.setFlag(FlagC, v >= imm) + c.X = v - imm + c.setZN(c.X) +} + +// memory & sp goes into a, x and sp +func lasExec(c *CPU, op operand) { + v := c.operandValue(op) & c.SP + c.A, c.X, c.SP = v, v, v + c.setZN(v) +} + +const unstableMagic uint8 = 0xEE + +func aneExec(c *CPU, op operand) { + c.A = (c.A | unstableMagic) & c.X & c.operandValue(op) + c.setZN(c.A) +} + +func lxaExec(c *CPU, op operand) { + c.A = (c.A | unstableMagic) & c.operandValue(op) + c.X = c.A + c.setZN(c.A) +} + +func (c *CPU) storeHighAnd(op operand, v uint8) { + baseHi := uint8(op.addr >> 8) + if op.pageCrossed { + baseHi-- + } + v &= baseHi + 1 + addr := op.addr + if op.pageCrossed { + addr = uint16(v)<<8 | addr&0x00FF + } + c.write(addr, v) +} + +func ahxExec(c *CPU, op operand) { c.storeHighAnd(op, c.A&c.X) } +func shxExec(c *CPU, op operand) { c.storeHighAnd(op, c.X) } +func shyExec(c *CPU, op operand) { c.storeHighAnd(op, c.Y) } + +func tasExec(c *CPU, op operand) { + c.SP = c.A & c.X + c.storeHighAnd(op, c.SP) +} diff --git a/cpu/opcodes.go b/cpu/opcodes.go index ea6e1b9..aa8f8a5 100644 --- a/cpu/opcodes.go +++ b/cpu/opcodes.go @@ -334,6 +334,21 @@ func init() { def(0xD2, "JAM", ModeImplied, 2, false, jamExec) def(0xF2, "JAM", ModeImplied, 2, false, jamExec) + def(0x0B, "ANC", ModeImmediate, 2, false, ancExec) + def(0x2B, "ANC", ModeImmediate, 2, false, ancExec) + def(0x4B, "ALR", ModeImmediate, 2, false, alrExec) + def(0x6B, "ARR", ModeImmediate, 2, false, arrExec) + def(0x8B, "ANE", ModeImmediate, 2, false, aneExec) + def(0xAB, "LXA", ModeImmediate, 2, false, lxaExec) + def(0xCB, "AXS", ModeImmediate, 2, false, axsExec) + def(0xBB, "LAS", ModeAbsoluteY, 4, true, lasExec) + + def(0x93, "AHX", ModeIndirectY, 6, false, ahxExec) + def(0x9F, "AHX", ModeAbsoluteY, 5, false, ahxExec) + def(0x9B, "TAS", ModeAbsoluteY, 5, false, tasExec) + def(0x9C, "SHY", ModeAbsoluteX, 5, false, shyExec) + def(0x9E, "SHX", ModeAbsoluteY, 5, false, shxExec) + // illegal / undocumented instructions def(0xA7, "LAX", ModeZeroPage, 3, false, laxExec) def(0xB7, "LAX", ModeZeroPageY, 4, false, laxExec) diff --git a/nes/bus.go b/nes/bus.go index af4f242..4f6c720 100644 --- a/nes/bus.go +++ b/nes/bus.go @@ -24,6 +24,10 @@ func (n *Console) Read(addr uint16) uint8 { return n.RAM[addr&0x7FF] case addr < 0x4000: // PPU registers, mirrored every 8 bytes return n.PPU.ReadRegister(addr) + case addr == 0x4016: // controller 1 + return n.controllers[0].read() + case addr == 0x4017: // controller 2 + return n.controllers[1].read() case addr >= 0x4020: // cartridge return n.Cart.Mapper.CPURead(addr) default: @@ -37,6 +41,11 @@ func (n *Console) Write(addr uint16, v uint8) { n.RAM[addr&0x7FF] = v case addr < 0x4000: n.PPU.WriteRegister(addr, v) + case addr == 0x4014: // oam dma + n.oamDMA(v) + case addr == 0x4016: // strobe is shared by both controllers + n.controllers[0].setStrobe(v&1 != 0) + n.controllers[1].setStrobe(v&1 != 0) case addr >= 0x4020: n.Cart.Mapper.CPUWrite(addr, v) } diff --git a/nes/controller.go b/nes/controller.go new file mode 100644 index 0000000..51afe20 --- /dev/null +++ b/nes/controller.go @@ -0,0 +1,53 @@ +package nes + +// bit order matches the order the console reads them out in +const ( + ButtonA uint8 = 1 << iota + ButtonB + ButtonSelect + ButtonStart + ButtonUp + ButtonDown + ButtonLeft + ButtonRight +) + +// standard controller, it is just an 8 bit shift register +// https://www.nesdev.org/wiki/Standard_controller +type controller struct { + buttons uint8 // what is held right now + shift uint8 // what the cpu is reading out + strobe bool +} + +// while strobe is high the shift register keeps copying the buttons +func (c *controller) setStrobe(on bool) { + c.strobe = on + if on { + c.shift = c.buttons + } +} + +func (c *controller) setButtons(b uint8) { + c.buttons = b + if c.strobe { + c.shift = b + } +} + +// returns one button per read, A first. with strobe high it stays on A +func (c *controller) read() uint8 { + bit := c.shift & 1 + if !c.strobe { + // ones get shifted in, so reads after the 8th return 1 + c.shift = c.shift>>1 | 0x80 + } + // upper bits are open bus, which is $40 here since that is the high byte + // of the address that was read + return 0x40 | bit +} + +// port is 0 for controller 1 and 1 for controller 2 +func (n *Console) SetButtons(port int, buttons uint8) { + n.controllers[port].setButtons(buttons) +} diff --git a/nes/dma.go b/nes/dma.go new file mode 100644 index 0000000..ebdbcea --- /dev/null +++ b/nes/dma.go @@ -0,0 +1,19 @@ +package nes + +// im too lazy to write an explanation so heres the nesdev page +// https://www.nesdev.org/wiki/DMA#OAM_DMA +func (n *Console) oamDMA(page uint8) { + base := uint16(page) << 8 + for i := uint16(0); i < 256; i++ { + n.PPU.WriteRegister(0x2004, n.Read(base+i)) + } + n.dmaPending = true +} + +func (n *Console) dmaStallCycles() int { + stall := 513 + if n.CPU.Cycles&1 == 1 { + stall++ + } + return stall +} diff --git a/nes/nes.go b/nes/nes.go index 79580b6..5d96e8f 100644 --- a/nes/nes.go +++ b/nes/nes.go @@ -11,7 +11,10 @@ type Console struct { PPU *ppu.PPU Cart *cart.Cartridge RAM [0x800]uint8 - // APU, controllers once they exist + + controllers [2]controller + dmaPending bool + // APU once it exists cycles uint64 } @@ -25,6 +28,12 @@ func New(c *cart.Cartridge) *Console { func (n *Console) Step() int { cycles := n.CPU.Step() + if n.dmaPending { + n.dmaPending = false + stall := n.dmaStallCycles() + n.CPU.Stall(stall) + cycles += stall + } n.cycles += uint64(cycles) for i := 0; i < cycles*3; i++ { n.PPU.Tick() diff --git a/ppu/memory.go b/ppu/memory.go index 3379242..d2b0a54 100644 --- a/ppu/memory.go +++ b/ppu/memory.go @@ -2,12 +2,6 @@ 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 diff --git a/ppu/ppu.go b/ppu/ppu.go index 322cf3d..42b3b4c 100644 --- a/ppu/ppu.go +++ b/ppu/ppu.go @@ -3,13 +3,16 @@ package ppu import "crecher.net/gones/v2/cart" const ( - ctrlNametableMask = 0x03 - ctrlIncrement32 = 0x04 - ctrlBGPatternHigh = 0x10 - ctrlNMIEnable = 0x80 + ctrlNametableMask = 0x03 + ctrlIncrement32 = 0x04 + ctrlSpritePatternHigh = 0x08 + ctrlBGPatternHigh = 0x10 + ctrlSpriteSize16 = 0x20 + ctrlNMIEnable = 0x80 maskGreyscale = 0x01 maskShowBackgroundLeft = 0x02 + maskShowSpritesLeft = 0x04 maskShowBackground = 0x08 maskShowSprites = 0x10 @@ -59,6 +62,10 @@ type PPU struct { patternLo, patternHi uint16 attrLo, attrHi uint16 + // sprites for the line being drawn, picked at the end of the previous line + spriteLine [8]sprite + spriteCount int + 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 diff --git a/ppu/render.go b/ppu/render.go index bfde9e4..ef3ad9a 100644 --- a/ppu/render.go +++ b/ppu/render.go @@ -31,14 +31,15 @@ func (p *PPU) renderTick() { } } + if dot == 257 && (visibleLine || line == preRenderLine) { + p.evaluateSprites() + } + if visibleLine && dot >= 1 && dot <= ScreenWidth { p.drawPixel(dot-1, line) } } -// the fetch cycle, every tile takes 8 dots to fetch (nametable byte, attribute -// byte, pattern low, pattern high), 2 dots each. dots 321-336 fetch the first two -// tiles of the next line ahead of time func (p *PPU) backgroundFetch(dot int) { if !((dot >= 2 && dot <= 257) || (dot >= 321 && dot <= 337)) { return @@ -144,18 +145,33 @@ func (p *PPU) copyVertical() { p.v = (p.v &^ 0x7BE0) | (p.t & 0x7BE0) } +func (p *PPU) backgroundPixel(x int) (pixel, palette uint8) { + if p.Mask&maskShowBackground == 0 || (x < 8 && p.Mask&maskShowBackgroundLeft == 0) { + return 0, 0 + } + bit := uint16(0x8000) >> p.x + pixel = bitAt(p.patternHi, bit)<<1 | bitAt(p.patternLo, bit) + palette = bitAt(p.attrHi, bit)<<1 | bitAt(p.attrLo, bit) + return pixel, palette +} + +// mixes the background and sprite pixel at (x, y) into the framebuffer func (p *PPU) drawPixel(x, y int) { - var pixel, palette uint8 - if p.Mask&maskShowBackground != 0 && (x >= 8 || p.Mask&maskShowBackgroundLeft != 0) { - bit := uint16(0x8000) >> p.x - pixel = bitAt(p.patternHi, bit)<<1 | bitAt(p.patternLo, bit) - palette = bitAt(p.attrHi, bit)<<1 | bitAt(p.attrLo, bit) + bgPixel, bgPalette := p.backgroundPixel(x) + spritePixel, spritePalette, behind, isSprite0 := p.spritePixel(x) + + // opaque sprite 0 pixel over an opaque background pixel, never at x = 255 + if isSprite0 && bgPixel != 0 && x != 255 { + p.sprite0Hit = true } // pixel 0 of every palette is the shared backdrop color at $3F00 addr := uint16(0x3F00) - if pixel != 0 { - addr += uint16(palette)<<2 | uint16(pixel) + switch { + case spritePixel != 0 && (bgPixel == 0 || !behind): + addr = 0x3F10 | uint16(spritePalette)<<2 | uint16(spritePixel) + case bgPixel != 0: + addr |= uint16(bgPalette)<<2 | uint16(bgPixel) } color := p.readPalette(addr) if p.Mask&maskGreyscale != 0 { diff --git a/ppu/sprites.go b/ppu/sprites.go new file mode 100644 index 0000000..83d1703 --- /dev/null +++ b/ppu/sprites.go @@ -0,0 +1,97 @@ +package ppu + +import "math/bits" + +const ( + spriteAttrPalette = 0x03 + spriteAttrBehind = 0x20 // draw behind opaque background pixels + spriteAttrFlipH = 0x40 + spriteAttrFlipV = 0x80 +) + +type sprite struct { + patternLo, patternHi uint8 + attr uint8 + x uint8 + isSprite0 bool +} + +// ill add comments later im tired +// https://www.nesdev.org/wiki/PPU_sprite_evaluation +func (p *PPU) evaluateSprites() { + p.spriteCount = 0 + // nothing is drawn above line 1, so the pre-render line and the last line leave it empty + if !p.renderingEnabled() || p.Scanline >= ScreenHeight-1 { + return + } + + height := 8 + if p.Ctrl&ctrlSpriteSize16 != 0 { + height = 16 + } + + for i := 0; i < 64; i++ { + row := p.Scanline - int(p.OAM[i*4]) + if row < 0 || row >= height { + continue + } + if p.spriteCount == 8 { + p.spriteOverflow = true + break + } + p.spriteLine[p.spriteCount] = p.fetchSprite(i, row, height) + p.spriteCount++ + } +} + +func (p *PPU) fetchSprite(i, row, height int) sprite { + tile := p.OAM[i*4+1] + attr := p.OAM[i*4+2] + if attr&spriteAttrFlipV != 0 { + row = height - 1 - row + } + + var addr uint16 + if height == 16 { + table := uint16(tile&1) << 12 + tile &^= 1 + if row >= 8 { + tile++ + row -= 8 + } + addr = table | uint16(tile)<<4 | uint16(row) + } else { + var base uint16 + if p.Ctrl&ctrlSpritePatternHigh != 0 { + base = 0x1000 + } + addr = base | uint16(tile)<<4 | uint16(row) + } + + lo := p.readVRAM(addr) + hi := p.readVRAM(addr + 8) + if attr&spriteAttrFlipH != 0 { + lo, hi = bits.Reverse8(lo), bits.Reverse8(hi) + } + return sprite{patternLo: lo, patternHi: hi, attr: attr, x: p.OAM[i*4+3], isSprite0: i == 0} +} + +func (p *PPU) spritePixel(x int) (pixel, palette uint8, behind, isSprite0 bool) { + if p.Mask&maskShowSprites == 0 || (x < 8 && p.Mask&maskShowSpritesLeft == 0) { + return 0, 0, false, false + } + for i := 0; i < p.spriteCount; i++ { + s := &p.spriteLine[i] + offset := x - int(s.x) + if offset < 0 || offset > 7 { + continue + } + shift := 7 - offset + pixel = (s.patternHi>>shift&1)<<1 | (s.patternLo >> shift & 1) + if pixel == 0 { + continue + } + return pixel, s.attr & spriteAttrPalette, s.attr&spriteAttrBehind != 0, s.isSprite0 + } + return 0, 0, false, false +} diff --git a/tests/controller_test.go b/tests/controller_test.go new file mode 100644 index 0000000..026e68a --- /dev/null +++ b/tests/controller_test.go @@ -0,0 +1,119 @@ +package tests + +import ( + "testing" + + "crecher.net/gones/v2/cart" + "crecher.net/gones/v2/nes" +) + +func newTestConsole(t *testing.T) *nes.Console { + t.Helper() + c, err := cart.Load("testdata/nestest.nes") + if err != nil { + t.Fatal(err) + } + return nes.New(c) +} + +func strobe(n *nes.Console) { + n.Write(0x4016, 1) + n.Write(0x4016, 0) +} + +func readBits(n *nes.Console, addr uint16, count int) []uint8 { + bits := make([]uint8, count) + for i := range bits { + bits[i] = n.Read(addr) & 1 + } + return bits +} + +func equalBits(a, b []uint8) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestControllerReadsButtonsInOrder(t *testing.T) { + n := newTestConsole(t) + n.SetButtons(0, nes.ButtonA|nes.ButtonStart|nes.ButtonRight) + strobe(n) + + // +---------------------- A + // | +------------------- B + // | | +---------------- Select + // | | | +------------- Start + // | | | | +---------- Up + // | | | | | +------- Down + // | | | | | | +---- Left + // | | | | | | | +- Right + // | | | | | | | | + want := []uint8{1, 0, 0, 1, 0, 0, 0, 1} + if got := readBits(n, 0x4016, 8); !equalBits(got, want) { + t.Errorf("bits = %v, want %v", got, want) + } +} + +func TestControllerReturnsOneAfterEightReads(t *testing.T) { + n := newTestConsole(t) + strobe(n) + readBits(n, 0x4016, 8) + if got := readBits(n, 0x4016, 3); !equalBits(got, []uint8{1, 1, 1}) { + t.Errorf("bits after 8 reads = %v, want [1 1 1]", got) + } +} + +func TestControllerStrobeHighKeepsReturningA(t *testing.T) { + n := newTestConsole(t) + n.SetButtons(0, nes.ButtonA) + n.Write(0x4016, 1) + if got := readBits(n, 0x4016, 3); !equalBits(got, []uint8{1, 1, 1}) { + t.Errorf("bits = %v, want [1 1 1]", got) + } + + n.SetButtons(0, nes.ButtonB) + if got := readBits(n, 0x4016, 2); !equalBits(got, []uint8{0, 0}) { + t.Errorf("bits after releasing A = %v, want [0 0]", got) + } +} + +func TestControllerLatchesOnStrobe(t *testing.T) { + n := newTestConsole(t) + n.SetButtons(0, nes.ButtonA) + strobe(n) + n.SetButtons(0, 0) // released after the latch, the readout should not change + + want := []uint8{1, 0, 0, 0, 0, 0, 0, 0} + if got := readBits(n, 0x4016, 8); !equalBits(got, want) { + t.Errorf("bits = %v, want %v", got, want) + } +} + +func TestControllerPortsAreIndependentWithSharedStrobe(t *testing.T) { + n := newTestConsole(t) + n.SetButtons(0, nes.ButtonA) + n.SetButtons(1, nes.ButtonSelect) + strobe(n) + + if got := readBits(n, 0x4016, 3); !equalBits(got, []uint8{1, 0, 0}) { + t.Errorf("port 1 bits = %v, want [1 0 0]", got) + } + if got := readBits(n, 0x4017, 3); !equalBits(got, []uint8{0, 0, 1}) { + t.Errorf("port 2 bits = %v, want [0 0 1]", got) + } +} + +func TestControllerReadHasOpenBusBit6(t *testing.T) { + n := newTestConsole(t) + strobe(n) + if got := n.Read(0x4016); got != 0x40 { + t.Errorf("$4016 with nothing pressed = %#02x, want 0x40", got) + } +} diff --git a/tests/cpu_test.go b/tests/cpu_test.go index 269f9ee..e8489f1 100644 --- a/tests/cpu_test.go +++ b/tests/cpu_test.go @@ -226,14 +226,18 @@ func TestCompareFlags(t *testing.T) { } } -func TestUnimplementedOpcodePanics(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Fatalf("expected panic for illegal opcode") - } - }() - c, _ := newTestCPU([]uint8{0x8B}) - c.Step() +func TestEveryOpcodeIsDefined(t *testing.T) { + for op := 0; op < 256; op++ { + func() { + defer func() { + if r := recover(); r != nil { + t.Errorf("opcode 0x%02X panics: %v", op, r) + } + }() + c, _ := newTestCPU([]uint8{uint8(op), 0x00, 0x00}) + c.Step() + }() + } } func TestJamStopsCPU(t *testing.T) { @@ -269,3 +273,126 @@ func TestJammedIgnoresInterruptsUntilReset(t *testing.T) { t.Fatal("Jammed = true after Reset, want false") } } + +func runSteps(prog []uint8, steps int) (*cpu.CPU, *ramBus) { + c, bus := newTestCPU(prog) + for i := 0; i < steps; i++ { + c.Step() + } + return c, bus +} + +func TestANC(t *testing.T) { + for _, op := range []uint8{0x0B, 0x2B} { + c, _ := runSteps([]uint8{0xA9, 0xFF, op, 0x80}, 2) // lda #$ff ; anc #$80 + if c.A != 0x80 || !has(c, cpu.FlagC) || !has(c, cpu.FlagN) || has(c, cpu.FlagZ) { + t.Errorf("op 0x%02X: A = 0x%02X, P = 0x%02X; want A=0x80, C and N set", op, c.A, c.Status) + } + } +} + +func TestALR(t *testing.T) { + c, _ := runSteps([]uint8{0xA9, 0xFF, 0x4B, 0x03}, 2) // lda #$ff ; alr #$03 + if c.A != 0x01 || !has(c, cpu.FlagC) || has(c, cpu.FlagN) || has(c, cpu.FlagZ) { + t.Errorf("A = 0x%02X, P = 0x%02X; want A=0x01, C set", c.A, c.Status) + } +} + +func TestARR(t *testing.T) { + tests := []struct { + name string + prog []uint8 + wantA uint8 + c, v bool + }{ + {"carry in, bits 6 and 5 set", []uint8{0x38, 0xA9, 0xFF, 0x6B, 0xFF}, 0xFF, true, false}, + {"no carry in, bit 6 only", []uint8{0x18, 0xA9, 0xFF, 0x6B, 0x80}, 0x40, true, true}, + } + for _, tt := range tests { + c, _ := runSteps(tt.prog, 3) + if c.A != tt.wantA || has(c, cpu.FlagC) != tt.c || has(c, cpu.FlagV) != tt.v { + t.Errorf("%s: A = 0x%02X, P = 0x%02X; want A=0x%02X, C=%v, V=%v", tt.name, c.A, c.Status, tt.wantA, tt.c, tt.v) + } + } +} + +func TestAXS(t *testing.T) { + // lda #$f0 ; ldx #$3c ; axs #imm (a & x = 0x30) + c, _ := runSteps([]uint8{0xA9, 0xF0, 0xA2, 0x3C, 0xCB, 0x10}, 3) + if c.X != 0x20 || !has(c, cpu.FlagC) { + t.Errorf("X = 0x%02X, P = 0x%02X; want X=0x20, C set", c.X, c.Status) + } + c, _ = runSteps([]uint8{0xA9, 0xF0, 0xA2, 0x3C, 0xCB, 0x40}, 3) + if c.X != 0xF0 || has(c, cpu.FlagC) || !has(c, cpu.FlagN) { + t.Errorf("X = 0x%02X, P = 0x%02X; want X=0xF0, C clear, N set", c.X, c.Status) + } +} + +func TestLAS(t *testing.T) { + c, bus := newTestCPU([]uint8{0xA0, 0x01, 0xBB, 0x00, 0x02}) // ldy #1 ; las $0200,y + bus.mem[0x0201] = 0xF0 + c.Step() + if cycles := c.Step(); cycles != 4 { + t.Errorf("cycles = %d, want 4", cycles) + } + // 0xF0 & SP(0xFD) = 0xF0 + if c.A != 0xF0 || c.X != 0xF0 || c.SP != 0xF0 || !has(c, cpu.FlagN) { + t.Errorf("A = 0x%02X, X = 0x%02X, SP = 0x%02X; want all 0xF0, N set", c.A, c.X, c.SP) + } +} + +func TestANE(t *testing.T) { + // lda #$01 ; ldx #$0f ; ane #$ff -> (0x01 | 0xEE) & 0x0F & 0xFF + c, _ := runSteps([]uint8{0xA9, 0x01, 0xA2, 0x0F, 0x8B, 0xFF}, 3) + if c.A != 0x0F { + t.Errorf("A = 0x%02X, want 0x0F", c.A) + } +} + +func TestLXA(t *testing.T) { + // lda #$01 ; lxa #$f0 -> (0x01 | 0xEE) & 0xF0 + c, _ := runSteps([]uint8{0xA9, 0x01, 0xAB, 0xF0}, 2) + if c.A != 0xE0 || c.X != 0xE0 || !has(c, cpu.FlagN) { + t.Errorf("A = 0x%02X, X = 0x%02X; want both 0xE0, N set", c.A, c.X) + } +} + +func TestStoreHighAnd(t *testing.T) { + tests := []struct { + name string + prog []uint8 + zp bool // pointer $0200 at $10 for the (zp),y form + steps int + addr uint16 + want uint8 + }{ + // the stored value is reg & (high byte of the base + 1) = reg & 0x03 + {"shx", []uint8{0xA2, 0xFF, 0xA0, 0x01, 0x9E, 0x00, 0x02}, false, 3, 0x0201, 0x03}, + {"shy", []uint8{0xA0, 0xFF, 0xA2, 0x01, 0x9C, 0x00, 0x02}, false, 3, 0x0201, 0x03}, + {"ahx abs,y", []uint8{0xA9, 0xFF, 0xA2, 0x0F, 0xA0, 0x01, 0x9F, 0x00, 0x02}, false, 4, 0x0201, 0x03}, + {"ahx (zp),y", []uint8{0xA9, 0xFF, 0xA2, 0x0F, 0xA0, 0x01, 0x93, 0x10}, true, 4, 0x0201, 0x03}, + {"tas", []uint8{0xA9, 0xFF, 0xA2, 0x07, 0xA0, 0x01, 0x9B, 0x00, 0x02}, false, 4, 0x0201, 0x03}, + // $02ff + 1 crosses a page: the value (X & 0x03 = 1) becomes the new high byte + {"shx page cross", []uint8{0xA2, 0x01, 0xA0, 0x01, 0x9E, 0xFF, 0x02}, false, 3, 0x0100, 0x01}, + } + for _, tt := range tests { + c, bus := newTestCPU(tt.prog) + if tt.zp { + bus.mem[0x10], bus.mem[0x11] = 0x00, 0x02 + } + for i := 0; i < tt.steps; i++ { + c.Step() + } + if got := bus.mem[tt.addr]; got != tt.want { + t.Errorf("%s: mem[0x%04X] = 0x%02X, want 0x%02X", tt.name, tt.addr, got, tt.want) + } + } +} + +func TestTASSetsStackPointer(t *testing.T) { + // lda #$ff ; ldx #$07 ; ldy #$01 ; tas $0200,y + c, _ := runSteps([]uint8{0xA9, 0xFF, 0xA2, 0x07, 0xA0, 0x01, 0x9B, 0x00, 0x02}, 4) + if c.SP != 0x07 { + t.Errorf("SP = 0x%02X, want 0x07", c.SP) + } +} diff --git a/tests/dma_test.go b/tests/dma_test.go new file mode 100644 index 0000000..db38d9e --- /dev/null +++ b/tests/dma_test.go @@ -0,0 +1,78 @@ +package tests + +import ( + "testing" + + "crecher.net/gones/v2/nes" +) + +func fillPage(n *nes.Console, page uint8) { + for i := 0; i < 256; i++ { + n.Write(uint16(page)<<8|uint16(i), uint8(i)) + } +} + +func TestOAMDMACopiesPage(t *testing.T) { + n := newTestConsole(t) + fillPage(n, 0x02) + n.Write(0x4014, 0x02) + + for i := 0; i < 256; i++ { + if n.PPU.OAM[i] != uint8(i) { + t.Fatalf("OAM[%d] = %d, want %d", i, n.PPU.OAM[i], i) + } + } +} + +func TestOAMDMAStartsAtOAMAddr(t *testing.T) { + n := newTestConsole(t) + fillPage(n, 0x03) + n.Write(0x2003, 0x10) + n.Write(0x4014, 0x03) + + // source byte 0 lands at OAM[0x10], and the copy wraps around + for _, i := range []int{0, 1, 0xF0, 0xFF} { + if got := n.PPU.OAM[(0x10+i)&0xFF]; got != uint8(i) { + t.Errorf("OAM[0x%02X] = %d, want %d", (0x10+i)&0xFF, got, i) + } + } +} + +// runs `lda ; sta $4014` from RAM and returns the cycles the sta step took +func dmaStepCycles(t *testing.T, lda []uint8) int { + t.Helper() + n := newTestConsole(t) + prog := append(lda, 0x8D, 0x14, 0x40) // sta $4014 + for i, b := range prog { + n.Write(0x0200+uint16(i), b) + } + n.Write(0x0000, 0x02) // for the zero page lda + n.CPU.PC = 0x0200 + n.Step() + return n.Step() +} + +func TestOAMDMAStallCycles(t *testing.T) { + // reset leaves the cpu at 7 cycles + // lda #imm (2): sta ends on cycle 13, odd -> 4 + 514 + if got := dmaStepCycles(t, []uint8{0xA9, 0x02}); got != 518 { + t.Errorf("odd start: sta step = %d cycles, want 518", got) + } + // lda zp (3): sta ends on cycle 14, even -> 4 + 513 + if got := dmaStepCycles(t, []uint8{0xA5, 0x00}); got != 517 { + t.Errorf("even start: sta step = %d cycles, want 517", got) + } +} + +func TestOAMDMAAdvancesCPUCycles(t *testing.T) { + n := newTestConsole(t) + for i, b := range []uint8{0xA9, 0x02, 0x8D, 0x14, 0x40} { + n.Write(0x0200+uint16(i), b) + } + n.CPU.PC = 0x0200 + n.Step() + n.Step() + if n.CPU.Cycles != 7+2+4+514 { + t.Errorf("CPU.Cycles = %d, want %d", n.CPU.Cycles, 7+2+4+514) + } +} diff --git a/tests/ppu_sprite_test.go b/tests/ppu_sprite_test.go new file mode 100644 index 0000000..13ba1d0 --- /dev/null +++ b/tests/ppu_sprite_test.go @@ -0,0 +1,291 @@ +package tests + +import ( + "testing" + + "crecher.net/gones/v2/ppu" +) + +// oam stores y - 1, so a sprite with y shows from scanline y + 1 +func setSprite(p *ppu.PPU, index int, y, tile, attr, x uint8) { + p.WriteRegister(0x2003, uint8(index*4)) + for _, b := range []uint8{y, tile, attr, x} { + p.WriteRegister(0x2004, b) + } + p.WriteRegister(0x2003, 0) +} + +// tile 1 from newSceneWithTile1: row 0 is color 1, row 1 is color 2 +// sprite palette 0: 1 = blue, 2 = red, 3 = white. palette 1: 1 = white +func newSpriteScene() *ppu.PPU { + p := newSceneWithTile1() + writeVRAM(p, 0x3F10, 0x0F, 0x21, 0x16, 0x30) + writeVRAM(p, 0x3F15, 0x30) + for i := 0; i < 64; i++ { + setSprite(p, i, 0xFF, 0, 0, 0) + } + return p +} + +func checkPixels(t *testing.T, p *ppu.PPU, tests []struct { + x, y int + want uint32 +}) { + t.Helper() + for _, tt := range tests { + if got := pixel(p, tt.x, tt.y); got != tt.want { + t.Errorf("pixel(%d,%d) = %#08x, want %#08x", tt.x, tt.y, got, tt.want) + } + } +} + +type px = struct { + x, y int + want uint32 +} + +func TestSpriteDrawsOneLineBelowOAMY(t *testing.T) { + p := newSpriteScene() + setSprite(p, 0, 9, 1, 0, 16) + p.WriteRegister(0x2001, 0x10) + runFrames(p) + + checkPixels(t, p, []px{ + {16, 9, black}, + {16, 10, blue}, + {23, 10, blue}, + {24, 10, black}, + {15, 10, black}, + {16, 11, red}, + {16, 12, black}, + }) +} + +func TestSpriteWithYZeroFirstShowsOnLineOne(t *testing.T) { + p := newSpriteScene() + setSprite(p, 0, 0, 1, 0, 16) + p.WriteRegister(0x2001, 0x10) + runFrames(p) + + checkPixels(t, p, []px{{16, 0, black}, {16, 1, blue}}) +} + +func TestSpriteFlipHorizontal(t *testing.T) { + p := newSpriteScene() + writeVRAM(p, 0x0020, 0x80) + setSprite(p, 0, 9, 2, 0, 16) + setSprite(p, 1, 9, 2, 0x40, 64) + p.WriteRegister(0x2001, 0x10) + runFrames(p) + + checkPixels(t, p, []px{ + {16, 10, blue}, {23, 10, black}, + {64, 10, black}, {71, 10, blue}, + }) +} + +func TestSpriteFlipVertical(t *testing.T) { + p := newSpriteScene() + setSprite(p, 0, 9, 1, 0x80, 16) + p.WriteRegister(0x2001, 0x10) + runFrames(p) + + checkPixels(t, p, []px{{16, 10, black}, {16, 16, red}, {16, 17, blue}}) +} + +func TestSpritePaletteSelect(t *testing.T) { + p := newSpriteScene() + setSprite(p, 0, 9, 1, 0x01, 16) + p.WriteRegister(0x2001, 0x10) + runFrames(p) + + checkPixels(t, p, []px{{16, 10, white}}) +} + +func TestSpritePatternTableSelect(t *testing.T) { + p := newSpriteScene() + writeVRAM(p, 0x1018, 0xFF) + setSprite(p, 0, 9, 1, 0, 16) + p.WriteRegister(0x2000, 0x08) + p.WriteRegister(0x2001, 0x10) + runFrames(p) + + checkPixels(t, p, []px{{16, 10, red}}) +} + +func TestSprite8x16(t *testing.T) { + p := newSpriteScene() + writeVRAM(p, 0x0020, 0xFF) + writeVRAM(p, 0x0030, 0xFF) + writeVRAM(p, 0x0038, 0xFF) + setSprite(p, 0, 9, 2, 0, 16) + p.WriteRegister(0x2000, 0x20) + p.WriteRegister(0x2001, 0x10) + runFrames(p) + + // rows 0-7 come from tile 2, rows 8-15 from tile 3 + checkPixels(t, p, []px{{16, 10, blue}, {16, 11, black}, {16, 18, white}, {16, 26, black}}) +} + +func TestSpritePriority(t *testing.T) { + // background tile 1 at x 16-23: line 1 is color 2 (red), line 2 is transparent + scene := func(attr uint8) *ppu.PPU { + p := newSpriteScene() + writeVRAM(p, 0x2002, 1) + setSprite(p, 0, 0, 1, attr, 16) + p.WriteRegister(0x2001, 0x1E) + runFrames(p) + return p + } + + checkPixels(t, scene(0), []px{{16, 1, blue}, {16, 2, red}}) + // behind the background it only shows through transparent pixels + checkPixels(t, scene(0x20), []px{{16, 1, red}, {16, 2, red}}) +} + +func TestSpriteLimitOf8PerLine(t *testing.T) { + p := newSpriteScene() + for i := 0; i < 9; i++ { + setSprite(p, i, 9, 1, 0, uint8(i*12)) + } + p.WriteRegister(0x2001, 0x1E) + runFrames(p) + runToDot(p, 50, 0) + + checkPixels(t, p, []px{{84, 10, blue}, {96, 10, black}}) + if p.ReadRegister(0x2002)&0x20 == 0 { + t.Error("overflow flag not set with 9 sprites on a line") + } +} + +func TestSpriteOverflowNotSetWith8(t *testing.T) { + p := newSpriteScene() + for i := 0; i < 8; i++ { + setSprite(p, i, 9, 1, 0, uint8(i*12)) + } + p.WriteRegister(0x2001, 0x1E) + runFrames(p) + runToDot(p, 50, 0) + + if p.ReadRegister(0x2002)&0x20 != 0 { + t.Error("overflow flag set with only 8 sprites") + } +} + +func TestSpriteLeftClip(t *testing.T) { + scene := func(mask uint8) *ppu.PPU { + p := newSpriteScene() + setSprite(p, 0, 9, 1, 0, 0) + p.WriteRegister(0x2001, mask) + runFrames(p) + return p + } + + checkPixels(t, scene(0x10), []px{{0, 10, black}, {8, 10, black}}) + checkPixels(t, scene(0x14), []px{{0, 10, blue}}) +} + +func TestSpriteHiddenWhenSpritesDisabled(t *testing.T) { + p := newSpriteScene() + setSprite(p, 0, 9, 1, 0, 16) + p.WriteRegister(0x2001, 0x08) + runFrames(p) + + checkPixels(t, p, []px{{16, 10, black}}) +} + +func sprite0Hit(p *ppu.PPU) bool { + return p.ReadRegister(0x2002)&0x40 != 0 +} + +func TestSprite0HitTiming(t *testing.T) { + p := newSpriteScene() + writeVRAM(p, 0x2002, 1) + setSprite(p, 0, 0, 1, 0, 16) + p.WriteRegister(0x2001, 0x1E) + runFrames(p) + + // first overlap is pixel x = 16 on line 1, which is dot 17 + runToDot(p, 1, 10) + if sprite0Hit(p) { + t.Error("hit set before the overlap") + } + runToDot(p, 1, 20) + if !sprite0Hit(p) { + t.Error("hit not set after the overlap") + } + runToDot(p, 261, 2) + if sprite0Hit(p) { + t.Error("hit not cleared on the pre-render line") + } +} + +func TestSprite0HitNeedsOverlap(t *testing.T) { + p := newSpriteScene() + writeVRAM(p, 0x2002, 1) + setSprite(p, 0, 0, 1, 0, 100) + p.WriteRegister(0x2001, 0x1E) + runFrames(p) + runToDot(p, 50, 0) + + if sprite0Hit(p) { + t.Error("hit set without a background pixel under sprite 0") + } +} + +func TestSprite0HitOnlyForSprite0(t *testing.T) { + p := newSpriteScene() + writeVRAM(p, 0x2002, 1) + setSprite(p, 1, 0, 1, 0, 16) + p.WriteRegister(0x2001, 0x1E) + runFrames(p) + runToDot(p, 50, 0) + + if sprite0Hit(p) { + t.Error("hit set by sprite 1") + } +} + +func TestSprite0HitWhenBehindBackground(t *testing.T) { + p := newSpriteScene() + writeVRAM(p, 0x2002, 1) + setSprite(p, 0, 0, 1, 0x20, 16) + p.WriteRegister(0x2001, 0x1E) + runFrames(p) + runToDot(p, 50, 0) + + if !sprite0Hit(p) { + t.Error("hit not set for a sprite 0 behind the background") + } +} + +func TestSprite0HitRespectsLeftClip(t *testing.T) { + scene := func(mask uint8) *ppu.PPU { + p := newSpriteScene() + writeVRAM(p, 0x2000, 1) + setSprite(p, 0, 0, 1, 0, 0) + p.WriteRegister(0x2001, mask) + runFrames(p) + runToDot(p, 50, 0) + return p + } + + if sprite0Hit(scene(0x18)) { + t.Error("hit set inside the clipped left 8 pixels") + } + if !sprite0Hit(scene(0x1E)) { + t.Error("hit not set with the left 8 pixels shown") + } +} + +func TestSprite0HitNeverAtX255(t *testing.T) { + p := newSpriteScene() + setSprite(p, 0, 0, 1, 0, 255) + p.WriteRegister(0x2001, 0x1E) + runFrames(p) + runToDot(p, 50, 0) + + if sprite0Hit(p) { + t.Error("hit set at x = 255") + } +} -- cgit v1.2.3