aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcrecher <crecher@crecher.net>2026-10-13 01:13:13 +0200
committercrecher <crecher@crecher.net>2026-10-13 01:13:13 +0200
commit99cae5a5a37f41f878a84857361eda47910303ba (patch)
tree31c8665ca991f95e09e93bb2e6ec91d81d2427ff
parent96ff6d8eda2ba434682909f4ff4ce350d78b2273 (diff)
downloadgones-99cae5a5a37f41f878a84857361eda47910303ba.tar.gz
gones-99cae5a5a37f41f878a84857361eda47910303ba.zip
sprites!!!!!!
-rw-r--r--README.md12
-rw-r--r--cmd/gones/main.go24
-rw-r--r--cpu/cpu.go5
-rw-r--r--cpu/instructions.go80
-rw-r--r--cpu/opcodes.go15
-rw-r--r--nes/bus.go9
-rw-r--r--nes/controller.go53
-rw-r--r--nes/dma.go19
-rw-r--r--nes/nes.go11
-rw-r--r--ppu/memory.go6
-rw-r--r--ppu/ppu.go15
-rw-r--r--ppu/render.go36
-rw-r--r--ppu/sprites.go97
-rw-r--r--tests/controller_test.go119
-rw-r--r--tests/cpu_test.go143
-rw-r--r--tests/dma_test.go78
-rw-r--r--tests/ppu_sprite_test.go291
17 files changed, 978 insertions, 35 deletions
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")
+ }
+}