aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md6
-rw-r--r--cmd/gones/main.go128
-rw-r--r--go.mod2
-rw-r--r--go.sum2
-rw-r--r--nes/nes.go20
-rw-r--r--ppu/palette.go9
-rw-r--r--ppu/ppu.go24
-rw-r--r--ppu/render.go172
-rw-r--r--tests/ppu_render_test.go160
9 files changed, 500 insertions, 23 deletions
diff --git a/README.md b/README.md
index 49ebf61..d6d1e37 100644
--- a/README.md
+++ b/README.md
@@ -18,11 +18,11 @@ im trying to make it somewhat commented so yeah :D
- [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
+- [x] background shift-register pixel pipeline, PPUMASK clipping
+- [x] 256x240 framebuffer and RGB palette
#### frontend and input
-- [ ] cmd/gones sdl2 gui (veandco/go-sdl2): window, framebuffer texture, one frame per 1/60s, vsync
+- [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
diff --git a/cmd/gones/main.go b/cmd/gones/main.go
new file mode 100644
index 0000000..57b1b6e
--- /dev/null
+++ b/cmd/gones/main.go
@@ -0,0 +1,128 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "runtime"
+ "time"
+
+ "github.com/veandco/go-sdl2/sdl"
+
+ "crecher.net/gones/v2/cart"
+ "crecher.net/gones/v2/nes"
+ "crecher.net/gones/v2/ppu"
+)
+
+const (
+ windowScale = 3
+
+ // nes: 1789773 cpu cycles per sec / 29780.5 cycles per frame
+ framesPerSecond = 60.0988
+)
+
+func init() {
+ // sdl wants to be driven from the main thread
+ runtime.LockOSThread()
+}
+
+func main() {
+ if len(os.Args) != 2 {
+ fmt.Fprintf(os.Stderr, "usage: %s <rom.nes>\n", os.Args[0])
+ os.Exit(2)
+ }
+ if err := run(os.Args[1]); err != nil {
+ log.Fatal(err)
+ }
+}
+
+func run(romPath string) error {
+ c, err := cart.Load(romPath)
+ if err != nil {
+ return err
+ }
+ console := nes.New(c)
+ name := filepath.Base(romPath)
+
+ if err := sdl.Init(sdl.INIT_VIDEO); err != nil {
+ return err
+ }
+ defer sdl.Quit()
+
+ window, err := sdl.CreateWindow("gones - "+name,
+ sdl.WINDOWPOS_CENTERED, sdl.WINDOWPOS_CENTERED,
+ ppu.ScreenWidth*windowScale, ppu.ScreenHeight*windowScale,
+ sdl.WINDOW_SHOWN|sdl.WINDOW_RESIZABLE)
+ if err != nil {
+ return err
+ }
+ defer window.Destroy()
+
+ renderer, err := sdl.CreateRenderer(window, -1, sdl.RENDERER_ACCELERATED|sdl.RENDERER_PRESENTVSYNC)
+ if err != nil {
+ return err
+ }
+ defer renderer.Destroy()
+
+ // the window can be resized freely, sdl scales (and letterboxes) the 256x240 picture
+ if err := renderer.SetLogicalSize(ppu.ScreenWidth, ppu.ScreenHeight); err != nil {
+ return err
+ }
+
+ // framebuffer is ARGB8888, streaming since we rewrite it every frame
+ texture, err := renderer.CreateTexture(sdl.PIXELFORMAT_ARGB8888, sdl.TEXTUREACCESS_STREAMING,
+ ppu.ScreenWidth, ppu.ScreenHeight)
+ if err != nil {
+ return err
+ }
+ defer texture.Destroy()
+
+ fps := framesPerSecond // a variable, a constant would have to be a whole number of nanoseconds
+ frameTime := time.Duration(float64(time.Second) / fps)
+ next := time.Now()
+ fpsStart, frames := next, 0
+ jamShown := false
+
+ for {
+ for e := sdl.PollEvent(); e != nil; e = sdl.PollEvent() {
+ switch ev := e.(type) {
+ case *sdl.QuitEvent:
+ return nil
+ case *sdl.KeyboardEvent:
+ if ev.Type == sdl.KEYDOWN && ev.Keysym.Sym == sdl.K_ESCAPE {
+ return nil
+ }
+ }
+ }
+
+ console.StepFrame()
+
+ // UpdateRGBA multiplies the pitch by 4 itself, so this is in pixels, not bytes
+ if err := texture.UpdateRGBA(nil, console.PPU.Framebuffer(), ppu.ScreenWidth); err != nil {
+ return err
+ }
+ renderer.Clear()
+ renderer.Copy(texture, nil, nil)
+ renderer.Present()
+
+ if console.CPU.Jammed && !jamShown {
+ window.SetTitle("gones - " + name + " - CPU jammed (reset needed)")
+ jamShown = true
+ }
+
+ frames++
+ if elapsed := time.Since(fpsStart); elapsed >= time.Second && !jamShown {
+ window.SetTitle(fmt.Sprintf("gones - %s - %.1f fps", name, float64(frames)/elapsed.Seconds()))
+ fpsStart, frames = time.Now(), 0
+ }
+
+ // vsync alone would run too fast on a 120/144hz monitor, so pace to the nes frame rate
+ next = next.Add(frameTime)
+ if wait := time.Until(next); wait > 0 {
+ time.Sleep(wait)
+ } else if wait < -100*time.Millisecond {
+ next = time.Now() // fell far behind (window drag etc), don't try to catch up
+ }
+ }
+}
diff --git a/go.mod b/go.mod
index 02149ce..12be3a1 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,5 @@
module crecher.net/gones/v2
go 1.26.7
+
+require github.com/veandco/go-sdl2 v0.4.40
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..bc27727
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,2 @@
+github.com/veandco/go-sdl2 v0.4.40 h1:fZv6wC3zz1Xt167P09gazawnpa0KY5LM7JAvKpX9d/U=
+github.com/veandco/go-sdl2 v0.4.40/go.mod h1:OROqMhHD43nT4/i9crJukyVecjPNYYuCofep6SNiAjY=
diff --git a/nes/nes.go b/nes/nes.go
index e3845f6..79580b6 100644
--- a/nes/nes.go
+++ b/nes/nes.go
@@ -23,15 +23,6 @@ func New(c *cart.Cartridge) *Console {
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)
@@ -44,13 +35,10 @@ func (n *Console) Step() int {
return cycles
}
+// runs until the PPU finishes a frame, the framebuffer then holds a full picture
func (n *Console) StepFrame() {
- // 29780 is roughly one frame:
- // ~ 1789773 cycles per sec
- // ~ 60.1 fps
- // 1789773 / 60.1 = 29779.916...
- const cyclesPerFrame = 29780
- for done := 0; done < cyclesPerFrame; {
- done += n.Step()
+ frame := n.PPU.Frame
+ for n.PPU.Frame == frame {
+ n.Step()
}
}
diff --git a/ppu/palette.go b/ppu/palette.go
new file mode 100644
index 0000000..8218c40
--- /dev/null
+++ b/ppu/palette.go
@@ -0,0 +1,9 @@
+package ppu
+
+// one of the palettes people have captured (https://www.nesdev.org/wiki/PPU_palettes)
+var systemPalette = [64]uint32{
+ 0x545454, 0x001E74, 0x081090, 0x300088, 0x440064, 0x5C0030, 0x540400, 0x3C1800, 0x202A00, 0x083A00, 0x004000, 0x003C00, 0x00323C, 0x000000, 0x000000, 0x000000,
+ 0x989698, 0x084CC4, 0x3032EC, 0x5C1EE4, 0x8814B0, 0xA01464, 0x982220, 0x783C00, 0x545A00, 0x287200, 0x087C00, 0x007628, 0x006678, 0x000000, 0x000000, 0x000000,
+ 0xECEEEC, 0x4C9AEC, 0x787CEC, 0xB062EC, 0xE454EC, 0xEC58B4, 0xEC6A64, 0xD48820, 0xA0AA00, 0x74C400, 0x4CD020, 0x38CC6C, 0x38B4CC, 0x3C3C3C, 0x000000, 0x000000,
+ 0xECEEEC, 0xA8CCEC, 0xBCBCEC, 0xD4B2EC, 0xECAEEC, 0xECAED4, 0xECB4B0, 0xE4C490, 0xCCD278, 0xB4DE78, 0xA8E290, 0x98E2B4, 0xA0D6E4, 0xA0A2A0, 0x000000, 0x000000,
+}
diff --git a/ppu/ppu.go b/ppu/ppu.go
index cfc3620..322cf3d 100644
--- a/ppu/ppu.go
+++ b/ppu/ppu.go
@@ -5,10 +5,13 @@ import "crecher.net/gones/v2/cart"
const (
ctrlNametableMask = 0x03
ctrlIncrement32 = 0x04
+ ctrlBGPatternHigh = 0x10
ctrlNMIEnable = 0x80
- maskShowBackground = 0x08
- maskShowSprites = 0x10
+ maskGreyscale = 0x01
+ maskShowBackgroundLeft = 0x02
+ maskShowBackground = 0x08
+ maskShowSprites = 0x10
statusOverflow = 0x20
statusSprite0 = 0x40
@@ -43,6 +46,19 @@ type PPU struct {
Dot int
Frame uint64
+ // finished pixels as ARGB8888
+ framebuffer [ScreenWidth * ScreenHeight]uint32
+
+ // background pipeline, see https://www.nesdev.org/wiki/PPU_rendering
+ // next* hold the bytes fetched for the tile after the one being drawn
+ nextTileID uint8
+ nextAttr uint8
+ nextPatternLo uint8
+ nextPatternHi uint8
+ // shifters, the top bit (minus fine x) is the pixel being drawn
+ patternLo, patternHi uint16
+ attrLo, attrHi uint16
+
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
@@ -75,10 +91,10 @@ func (p *PPU) updateNMILine() {
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.
+// advances PPU by one dot
func (p *PPU) Tick() {
p.advanceDot()
+ p.renderTick()
switch {
case p.Scanline == 241 && p.Dot == 1:
diff --git a/ppu/render.go b/ppu/render.go
new file mode 100644
index 0000000..bfde9e4
--- /dev/null
+++ b/ppu/render.go
@@ -0,0 +1,172 @@
+package ppu
+
+const (
+ ScreenWidth = 256
+ ScreenHeight = 240
+
+ preRenderLine = 261
+)
+
+// pixels of the last (or in progress) frame as ARGB8888
+func (p *PPU) Framebuffer() []uint32 {
+ return p.framebuffer[:]
+}
+
+// runs after Tick has moved to the new dot
+func (p *PPU) renderTick() {
+ line, dot := p.Scanline, p.Dot
+ visibleLine := line < ScreenHeight
+
+ if p.renderingEnabled() && (visibleLine || line == preRenderLine) {
+ p.backgroundFetch(dot)
+ if dot == 256 {
+ p.incrementY()
+ }
+ if dot == 257 {
+ p.loadBackgroundShifters()
+ p.copyHorizontal()
+ }
+ if line == preRenderLine && dot >= 280 && dot <= 304 {
+ p.copyVertical()
+ }
+ }
+
+ 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
+ }
+ if p.Mask&maskShowBackground != 0 {
+ p.shiftBackground()
+ }
+
+ switch (dot - 1) % 8 {
+ case 0:
+ p.loadBackgroundShifters()
+ p.nextTileID = p.readVRAM(0x2000 | (p.v & 0x0FFF))
+ case 2:
+ // attribute byte holds 4 palettes (2 bits each) for a 4x4 tile area,
+ // coarse y/x bit 1 pick which quadrant is ours
+ addr := 0x23C0 | (p.v & 0x0C00) | ((p.v >> 4) & 0x38) | ((p.v >> 2) & 0x07)
+ attr := p.readVRAM(addr)
+ if p.v&0x40 != 0 {
+ attr >>= 4
+ }
+ if p.v&0x02 != 0 {
+ attr >>= 2
+ }
+ p.nextAttr = attr & 0x03
+ case 4:
+ p.nextPatternLo = p.readVRAM(p.backgroundPatternAddr())
+ case 6:
+ p.nextPatternHi = p.readVRAM(p.backgroundPatternAddr() + 8)
+ case 7:
+ p.incrementCoarseX()
+ }
+}
+
+// address of the current row of the next tiles pattern
+func (p *PPU) backgroundPatternAddr() uint16 {
+ var base uint16
+ if p.Ctrl&ctrlBGPatternHigh != 0 {
+ base = 0x1000
+ }
+ fineY := (p.v >> 12) & 0x07
+ return base + uint16(p.nextTileID)*16 + fineY
+}
+
+func (p *PPU) shiftBackground() {
+ p.patternLo <<= 1
+ p.patternHi <<= 1
+ p.attrLo <<= 1
+ p.attrHi <<= 1
+}
+
+// puts fetched tile into the low byte of the shifters. the attribute bits
+// are the same for all 8 pixels of the tile, so they get repeated
+func (p *PPU) loadBackgroundShifters() {
+ p.patternLo = (p.patternLo & 0xFF00) | uint16(p.nextPatternLo)
+ p.patternHi = (p.patternHi & 0xFF00) | uint16(p.nextPatternHi)
+ p.attrLo = (p.attrLo & 0xFF00) | repeatBit(p.nextAttr&0x01)
+ p.attrHi = (p.attrHi & 0xFF00) | repeatBit(p.nextAttr&0x02)
+}
+
+func repeatBit(b uint8) uint16 {
+ if b != 0 {
+ return 0x00FF
+ }
+ return 0
+}
+
+// scroll helpers, v layout is yyy NN YYYYY XXXXX
+// https://www.nesdev.org/wiki/PPU_scrolling
+
+func (p *PPU) incrementCoarseX() {
+ if p.v&0x001F == 31 {
+ p.v &^= 0x001F
+ p.v ^= 0x0400 // wrap into the horizontally adjacent nametable
+ } else {
+ p.v++
+ }
+}
+
+func (p *PPU) incrementY() {
+ if p.v&0x7000 != 0x7000 {
+ p.v += 0x1000 // fine y
+ return
+ }
+ p.v &^= 0x7000
+ y := (p.v & 0x03E0) >> 5
+ switch y {
+ case 29:
+ y = 0
+ p.v ^= 0x0800 // wrap into the vertically adjacent nametable
+ case 31:
+ y = 0
+ default:
+ y++
+ }
+ p.v = (p.v &^ 0x03E0) | (y << 5)
+}
+
+func (p *PPU) copyHorizontal() {
+ p.v = (p.v &^ 0x041F) | (p.t & 0x041F)
+}
+
+func (p *PPU) copyVertical() {
+ p.v = (p.v &^ 0x7BE0) | (p.t & 0x7BE0)
+}
+
+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)
+ }
+
+ // pixel 0 of every palette is the shared backdrop color at $3F00
+ addr := uint16(0x3F00)
+ if pixel != 0 {
+ addr += uint16(palette)<<2 | uint16(pixel)
+ }
+ color := p.readPalette(addr)
+ if p.Mask&maskGreyscale != 0 {
+ color &= 0x30
+ }
+ p.framebuffer[y*ScreenWidth+x] = 0xFF000000 | systemPalette[color&0x3F]
+}
+
+func bitAt(v, bit uint16) uint8 {
+ if v&bit != 0 {
+ return 1
+ }
+ return 0
+}
diff --git a/tests/ppu_render_test.go b/tests/ppu_render_test.go
new file mode 100644
index 0000000..a6602b9
--- /dev/null
+++ b/tests/ppu_render_test.go
@@ -0,0 +1,160 @@
+package tests
+
+import (
+ "testing"
+
+ "crecher.net/gones/v2/cart"
+ "crecher.net/gones/v2/ppu"
+)
+
+const (
+ black = 0xFF000000 // palette entry $0F
+ white = 0xFFECEEEC // $30
+ red = 0xFF982220 // $16
+ blue = 0xFF4C9AEC // $21
+)
+
+// writes bytes through $2006/$2007 starting at addr, then points v back at 0
+func writeVRAM(p *ppu.PPU, addr uint16, data ...uint8) {
+ p.WriteRegister(0x2006, uint8(addr>>8))
+ p.WriteRegister(0x2006, uint8(addr))
+ for _, b := range data {
+ p.WriteRegister(0x2007, b)
+ }
+ p.WriteRegister(0x2006, 0)
+ p.WriteRegister(0x2006, 0)
+}
+
+// ticks two full frames so scroll registers have been copied from t at least once
+func runFrames(p *ppu.PPU) {
+ for i := 0; i < 2*341*262; i++ {
+ p.Tick()
+ }
+}
+
+func pixel(p *ppu.PPU, x, y int) uint32 {
+ return p.Framebuffer()[y*ppu.ScreenWidth+x]
+}
+
+// tile 1: row 0 is color 1 across, row 1 is color 2 across
+// palette 0: backdrop black, 1 = white, 2 = red. palette 1 color 1 = blue
+func newSceneWithTile1() *ppu.PPU {
+ p, _ := newTestPPU(cart.Horizontal)
+ writeVRAM(p, 0x0010, 0xFF, 0x00)
+ writeVRAM(p, 0x0018, 0x00, 0xFF)
+ writeVRAM(p, 0x3F00, 0x0F, 0x30, 0x16)
+ writeVRAM(p, 0x3F05, 0x21)
+ return p
+}
+
+func TestBackgroundDrawsBackdropWhenDisabled(t *testing.T) {
+ p := newSceneWithTile1()
+ writeVRAM(p, 0x2000, 1)
+ runFrames(p)
+ if got := pixel(p, 0, 0); got != black {
+ t.Errorf("pixel(0,0) = %#08x, want %#08x", got, uint32(black))
+ }
+}
+
+func TestBackgroundTilePixels(t *testing.T) {
+ p := newSceneWithTile1()
+ writeVRAM(p, 0x2000, 1)
+ p.WriteRegister(0x2001, 0x0A) // background + left 8 pixels
+ runFrames(p)
+
+ tests := []struct {
+ x, y int
+ want uint32
+ }{
+ {0, 0, white},
+ {7, 0, white},
+ {8, 0, black}, // next tile is tile 0, blank
+ {0, 1, red},
+ {7, 1, red},
+ {0, 2, black}, // rows 2-7 of tile 1 are empty
+ }
+ 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)
+ }
+ }
+}
+
+func TestBackgroundAttributeSelectsPalette(t *testing.T) {
+ p := newSceneWithTile1()
+ writeVRAM(p, 0x2000, 1, 1, 1, 1)
+ writeVRAM(p, 0x23C0, 0x01) // top-left 16x16 quadrant uses palette 1
+ p.WriteRegister(0x2001, 0x0A)
+ runFrames(p)
+
+ if got := pixel(p, 0, 0); got != blue {
+ t.Errorf("pixel(0,0) = %#08x, want %#08x", got, uint32(blue))
+ }
+ if got := pixel(p, 15, 0); got != blue {
+ t.Errorf("pixel(15,0) = %#08x, want %#08x", got, uint32(blue))
+ }
+ if got := pixel(p, 16, 0); got != white {
+ t.Errorf("pixel(16,0) = %#08x, want %#08x (next quadrant, palette 0)", got, uint32(white))
+ }
+}
+
+func TestBackgroundLeftClip(t *testing.T) {
+ p := newSceneWithTile1()
+ writeVRAM(p, 0x2000, 1, 1)
+ p.WriteRegister(0x2001, 0x08) // background on, left 8 pixels hidden
+ runFrames(p)
+
+ if got := pixel(p, 0, 0); got != black {
+ t.Errorf("pixel(0,0) = %#08x, want %#08x (clipped)", got, uint32(black))
+ }
+ if got := pixel(p, 8, 0); got != white {
+ t.Errorf("pixel(8,0) = %#08x, want %#08x", got, uint32(white))
+ }
+}
+
+func TestBackgroundFineXScroll(t *testing.T) {
+ p, _ := newTestPPU(cart.Horizontal)
+ writeVRAM(p, 0x0010, 0x01, 0x00) // only the rightmost pixel of row 0 is set
+ writeVRAM(p, 0x3F00, 0x0F, 0x30)
+ writeVRAM(p, 0x2000, 1)
+ p.WriteRegister(0x2005, 7)
+ p.WriteRegister(0x2005, 0)
+ p.WriteRegister(0x2001, 0x0A)
+ runFrames(p)
+
+ if got := pixel(p, 0, 0); got != white {
+ t.Errorf("pixel(0,0) = %#08x, want %#08x", got, uint32(white))
+ }
+ if got := pixel(p, 1, 0); got != black {
+ t.Errorf("pixel(1,0) = %#08x, want %#08x", got, uint32(black))
+ }
+}
+
+func TestBackgroundCoarseXScroll(t *testing.T) {
+ p := newSceneWithTile1()
+ writeVRAM(p, 0x2003, 1) // tile 1 in the 4th column
+ p.WriteRegister(0x2005, 24) // coarse x = 3
+ p.WriteRegister(0x2005, 0)
+ p.WriteRegister(0x2001, 0x0A)
+ runFrames(p)
+
+ if got := pixel(p, 0, 0); got != white {
+ t.Errorf("pixel(0,0) = %#08x, want %#08x", got, uint32(white))
+ }
+ if got := pixel(p, 8, 0); got != black {
+ t.Errorf("pixel(8,0) = %#08x, want %#08x", got, uint32(black))
+ }
+}
+
+func TestBackgroundPatternTableSelect(t *testing.T) {
+ p := newSceneWithTile1()
+ writeVRAM(p, 0x1018, 0xFF) // tile 1 in the high pattern table, row 0 high plane only = color 2
+ writeVRAM(p, 0x2000, 1)
+ p.WriteRegister(0x2000, 0x10)
+ p.WriteRegister(0x2001, 0x0A)
+ runFrames(p)
+
+ if got := pixel(p, 0, 0); got != red {
+ t.Errorf("pixel(0,0) = %#08x, want %#08x", got, uint32(red))
+ }
+}