From 96ff6d8eda2ba434682909f4ff4ce350d78b2273 Mon Sep 17 00:00:00 2001 From: crecher Date: Mon, 12 Oct 2026 22:08:09 +0200 Subject: ppu: add background rendering cmd/gones: add sdl2 frontend --- README.md | 6 +- cmd/gones/main.go | 128 +++++++++++++++++++++++++++++++++++ go.mod | 2 + go.sum | 2 + nes/nes.go | 20 ++---- ppu/palette.go | 9 +++ ppu/ppu.go | 24 +++++-- ppu/render.go | 172 +++++++++++++++++++++++++++++++++++++++++++++++ tests/ppu_render_test.go | 160 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 500 insertions(+), 23 deletions(-) create mode 100644 cmd/gones/main.go create mode 100644 go.sum create mode 100644 ppu/palette.go create mode 100644 ppu/render.go create mode 100644 tests/ppu_render_test.go 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 \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)) + } +} -- cgit v1.2.3