Implement dot-level PPU rendering pipeline
- Dominant language
- JavaScript
- Stars
- 6.4k
- Forks
- 857
- PR merge metrics
- No merged PRs in 30d
Description
# Dot-Level PPU Rendering Pipeline
## Context
The PPU currently renders in bulk per-scanline: background tiles and sprites are evaluated all at once in `endScanline()` (called when `curX` wraps from 341 to 0). While `ppu.step(dots)` now advances the dot counter cycle-accurately (PR #562), the actual rendering logic doesn't execute at specific dots — it all happens at the scanline boundary.
On real NES hardware, the PPU performs specific operations at specific dots within each scanline. Tile fetches, attribute lookups, sprite evaluation, and shift register updates all happen at precise dot positions. 12 AccuracyCoin tests fail because of this.
## What Works Today
- **Dot counter** (`curX`, `scanline`): Accurately tracked via `ppu.step(dots)`, advancing 3 dots per CPU cycle inline with every bus operation.
- **VBlank timing**: Set/clear at exact dots (scanline 0/20, dot 1). NMI edge detection is φ2-accurate.
- **Sprite 0 hit**: Checked at specific `curX` positions during `step()`, but the hit position itself (`spr0HitX/Y`) is pre-computed during bulk evaluation.
- **Status register reads** (`$2002`): VBlank flag, sprite 0 hit, and sprite overflow are readable at correct dots.
## What's Missing
The bulk `endScanline()` approach means:
1. **Background tile fetches don't happen at specific dots**: On real hardware, tiles are fetched in an 8-dot pattern (2 nametable reads, 2 pattern table reads per tile). Mid-scanline register writes ($2000, $2005, $2006) affect which tiles are fetched for the remainder of the scanline. Our bulk approach applies register state from the start of the scanline.
2. **Background shift registers aren't maintained**: Real hardware has two 16-bit shift registers for pattern data and two 8-bit latches for attribute data, shifted every dot. Writes to $2006/$2005 mid-scanline affect the internal `t` and `v` registers, which changes what appears on screen. Tests check for "stale" data in shift registers.
3. **Sprite evaluation doesn't happen at the right time**: On real hardware, sprite evaluation for the NEXT scanline runs during dots 65-256 of the current scanline. This is observable: writes to OAM ($2003/$2004) during evaluation corrupt the evaluation state. The current bulk evaluation sees a consistent OAM snapshot.
4. **$2004 reads return evaluation state**: During rendering, reads from $2004 return internal OAM buffers at specific stages of evaluation, not the addressed OAM byte. Our implementation always returns `spriteMem[sramAddress]`.
5. **$2007 reads during rendering increment v differently**: When rendering is active, $2007 reads increment both coarse X and fine Y of the `v` register (instead of the normal +1/+32). This is testable and currently wrong.
6. **Rendering flag changes mid-frame**: Toggling background/sprite rendering ($2001) mid-frame should take effect at the dot level, affecting what gets rendered for the rest of the scanline.
## Failing AccuracyCoin Tests (12 tests)
### Sprite Evaluation (8 tests)
| Test | Addr | Error | What it checks |
|------|------|-------|----------------|
| Sprite overflow | 0x0459 | 0x06 | Overflow flag timing and the evaluation bug |
| Sprite 0 hit | 0x0457 | 0x06 | Hit detection at specific dots |
| Suddenly resize sprite | 0x0489 | 0x06 | Changing sprite size ($2000 bit 5) mid-frame |
| Arbitrary sprite zero | 0x0458 | 0x06 | Sprite 0 hit with non-zero $2003 |
| Misaligned OAM | 0x045a | 0x06 | OAM address affecting evaluation start |
| $2004 behavior | 0x045b | 0x06 | Reading $2004 during evaluation |
| OAM corruption | 0x047b | 0x0A | OAM glitch during specific dots |
| INC $4014 | 0x0480 | 0x0E | RMW on $4014 (dummy write side effect) |
### PPU Behavior (2 tests)
| Test | Addr | Error | What it checks |
|------|------|-------|----------------|
| Rendering flag behavior | 0x0486 | 0x0A | Enabling/disabling rendering mid-frame |
| $2007 read w/ rendering | 0x048a | 0x06 | VRAM read increment during active rendering |
### PPU Misc (5 tests)
| Test | Addr | Error | What it checks |
|------|------|-------|----------------|
| Attributes as tiles | 0x0481 | 0x06 | Attribute table fetch timing |
| t register quirks | 0x0482 | 0x06 | Internal t/v register behavior |
| Stale BG shift registers | 0x0483 | 0x0E | Shift register contents after toggling rendering |
| BG serial in | 0x0487 | 0x06 | Background pattern data shifting |
| Sprites on scanline 0 | 0x0484 | 0x06 | Sprite evaluation on the pre-render scanline |
## Architecture Approach
### Option A: Dot-by-dot rendering in `step()`
Move all rendering logic into `step()`, executing at the correct dot positions. Each dot would:
- Dots 1-256: Shift BG registers, output pixel, evaluate sprites (dots 65-256)
- Dots 257-320: Fetch sprite pattern data for next scanline
- Dots 321-336: Fetch first two BG tiles for next scanline
- Dots 337-340: Dummy nametable fetches
**Pro**: Most accurate, mirrors real hardware.
**Con**: Significant performance impact — rendering logic runs 341 times per scanline instead of once. Currently `step()` is called ~90,000 times per frame; adding rendering logic to each call could slow things down significantly.
### Option B: Event-driven rendering at key dots
Keep `step()` lightweight but trigger rendering events at specific dot boundaries (similar to how VBlank is handled):
- At dot 1: Begin background fetch sequence for this scanline
- At dot 65: Begin sprite evaluation
- At dot 257: Copy horizontal position from `t` to `v`
- At dot 280-304: Copy vertical position from `t` to `v` (pre-render line only)
- At dot 321: Begin next-scanline tile prefetch
- At dot 341: End scanline, finalize pixel output
Rendering between events happens in bulk when the next event dot is reached.
**Pro**: Much better performance — only ~6-8 events per scanline instead of 341.
**Con**: Mid-event register writes still need special handling. More complex state management.
### Option C: Hybrid with lazy rendering
Track a "rendered up to dot X" marker. When PPU registers are written during rendering, flush rendering up to the current dot, then continue with new register state. When no mid-scanline writes happen (the common case), rendering is bulk just like today.
**Pro**: Best performance for the common case. Only slow when games actually do mid-scanline effects.
**Con**: Most complex implementation. Need to carefully track which register writes are "rendering-affecting."
## Key Implementation Details
### Internal PPU registers (t, v, x, w)
The PPU uses internal registers that the CPU can't directly access:
- **v** (15 bits): Current VRAM address, used during rendering for fetches
- **t** (15 bits): Temporary VRAM address, written by $2000/$2005/$2006
- **x** (3 bits): Fine X scroll
- **w** (1 bit): Write latch (first/second write toggle for $2005/$2006)
These are partially implemented (`regS`, `regFH`, `regFV`, etc.) but the rendering pipeline doesn't use them correctly for mid-scanline behavior.
See: https://www.nesdev.org/wiki/PPU_scrolling
### Sprite evaluation state machine
Real hardware evaluation has distinct phases:
1. Clear secondary OAM (dots 1-64)
2. Evaluate primary OAM sprites (dots 65-256): read Y, compare range, copy 4 bytes or skip
3. The overflow bug: after finding 8 sprites, evaluation continues but increments both sprite index AND byte offset, causing incorrect comparisons
See: https://www.nesdev.org/wiki/PPU_sprite_evaluation
### Background shift registers
Two 16-bit shift registers hold 2 tiles of pattern data. Every 8 dots, new tile data is loaded into the upper 8 bits. Fine X scroll selects which bit of the shift register is output.
See: https://www.nesdev.org/wiki/PPU_rendering
## Suggested Implementation Order
1. **$2007 read during rendering** (0x048a): Simplest — just check if rendering is active and use the coarse X/fine Y increment instead of +1/+32. Doesn't require dot-level rendering.
2. **Rendering flag behavior** (0x0486): Track when rendering is enabled/disabled relative to the dot counter. Partially doable without full dot-level rendering.
3. **t/v register quirks** (0x0482): Fix internal register handling for $2005/$2006 writes. May be testable without full rendering.
4. **Sprite 0 hit** (0x0457): Improve hit detection timing. Currently pre-computed; needs to happen at the correct dot.
5. **Full sprite evaluation** (0x0459, 0x0458, 0x045a, 0x045b, 0x047b): Implement the evaluation state machine. Most complex piece.
6. **Background shift registers** (0x0483, 0x0487, 0x0481): Implement shift register model. Requires Option A or C approach.
7. **Sprites on scanline 0** (0x0484): Pre-render scanline sprite evaluation edge case.
## References
- [PPU rendering](https://www.nesdev.org/wiki/PPU_rendering) — full dot-by-dot breakdown
- [PPU scrolling](https://www.nesdev.org/wiki/PPU_scrolling) — t/v/x/w register details
- [PPU sprite evaluation](https://www.nesdev.org/wiki/PPU_sprite_evaluation) — evaluation state machine
- [PPU frame timing](https://www.nesdev.org/wiki/PPU_frame_timing) — timing diagram
- [PPU OAM](https://www.nesdev.org/wiki/PPU_OAM) — OAM structure and $2003/$2004 behavior
Contributor guide
No contributing guide indexed for this repository
Research direction
Start by reading ppu.step(dots) and the current endScanline() path, then review the linked NESDev references for the timing model. Run the named AccuracyCoin tests, beginning with $2007 read w/ rendering and rendering flag behavior. Done means the dot-level pipeline handles the listed register, sprite, and background cases without the reported errors.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- game-dev
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100