345. Record visible scanline scroll state

Record the effective viewport state at dot 1 of each visible scanline.

Lesson 345 of 356 · tests/chapter_13_scrolling/test_345_record_visible_scanline_scroll_state.py

File to update

emulator/ppu/ppu.py

References

https://www.nesdev.org/wiki/PPU_rendering#Cycles_1-256
https://www.nesdev.org/wiki/PPU_scrolling#Details

Why this step exists

The PPU now updates v with horizontal and vertical rendering timing, but the existing high-level renderer runs after the frame. It needs a small record of the effective address that represented each visible screen row.

One immutable state stores

vram_addr:
    the copied address represented by the prefetched pixels

fine_x:
    the separate 0-7 pixel offset inside the first tile

At dot 1, real v is two tile columns ahead because dots 321-336 prefetched the first two tiles for the current scanline. Recording therefore performs:

copied visible v = real v rewound once, then rewound again

Example

current scanline = 20
real v coarse X  = 7

copied visible coarse X:
    7 -> 6 -> 5

stored destination:
    current_scanline_scroll_states[20]

The rewinds change horizontal tile position, not the scanline index. The state belongs to current scanline 20, not scanline 18.

Recording conditions

  • background or sprite rendering is enabled
  • scanline is visible: 0-239
  • current dot is 1

Important invariants

  • the buffer always has exactly 240 entries
  • each PPU owns a separate buffer
  • the real PPU.vram_addr is not modified by recording
  • fine X is stored separately
  • post-render, VBlank, and pre-render are not recorded

Out of scope

  • archiving the completed frame
  • replacing missing entries
  • resetting the current-frame buffer
  • framebuffer or opacity-mask composition

Complete example implementation

# emulator/ppu/ppu.py

# --- NEW BLOCK: EFFECTIVE STATE FOR ONE VISIBLE SCANLINE ---
@dataclass(frozen=True)
class BackgroundScanlineState:
    vram_addr: int
    fine_x: int


@dataclass
class PPU:
    ...

    # --- NEW LINE: CURRENT FRAME'S VISIBLE SCANLINE STATES ---
    current_scanline_scroll_states: list[
        BackgroundScanlineState | None
    ] = field(default_factory=lambda: [None] * 240)

    ...

    # --- NEW BLOCK: RECORD THE CURRENT VISIBLE SCANLINE ---
    def _record_visible_scanline_scroll_state(self) -> None:
        rendering_enabled = self.mask & (
            MASK_SHOW_BACKGROUND | MASK_SHOW_SPRITES
        )
        if not rendering_enabled:
            return

        if not 0 <= self.scanline < 240:
            return

        if self.cycle != 1:
            return

        visible_vram_addr = decrement_horizontal_vram_addr(
            decrement_horizontal_vram_addr(self.vram_addr)
        )

        self.current_scanline_scroll_states[self.scanline] = (
            BackgroundScanlineState(
                vram_addr=visible_vram_addr,
                fine_x=self.fine_x,
            )
        )

    def step(self, cycles: int = 1) -> None:
        ...

        for _ in range(cycles):
            self.cycle += 1
            self._step_horizontal_rendering_address()
            self._step_vertical_rendering_address()

            # --- NEW LINE: RECORD THE CURRENT SCANLINE AT DOT 1 ---
            self._record_visible_scanline_scroll_state()

            ...

Run this lesson

uv run pytest tests/chapter_13_scrolling/test_345_record_visible_scanline_scroll_state.py -v