346. Complete scanline scroll frame

Publish a complete timed scanline frame and reset the current recording buffer.

Lesson 346 of 356 · tests/chapter_13_scrolling/test_346_complete_scanline_scroll_frame.py

File to update

emulator/ppu/ppu.py

Reference

https://www.nesdev.org/wiki/PPU_rendering

Why this step exists

PPU timing records visible scanline states into a mutable current-frame list. The high-level renderer must consume stable data from a frame that has already finished, not a list the PPU is still changing.

The PPU therefore owns two different values

current_scanline_scroll_states:
    mutable list used while the active frame is being stepped

completed_scanline_scroll_states:
    immutable tuple published after the frame finishes

At the frame boundary

all 240 entries exist:
    publish a 240-state tuple

any entry is missing or the list length is not 240:
    publish an empty tuple

after either result:
    replace current state with a fresh [None] * 240 list

Why publish an empty tuple for incomplete data? An unknown row must not inherit a guessed address. The empty tuple becomes a clear signal that later rendering should keep using the existing frame-level compatibility path for this frame.

Intuitive model

current list     = notebook still being written
completed tuple  = sealed notebook safe for the renderer

Important invariants

  • completed data contains exactly 240 states or zero states
  • completed data is immutable
  • current and completed containers are not the same object
  • recording the next frame cannot alter the completed frame
  • publication occurs after pre-render completes and before counters enter frame 0

Common misconception

Frame completion does not happen when VBlank starts at scanline 241. Pre-render scanline 261 still belongs to the timing sequence before the emulator rolls over to the next frame.

Out of scope

  • consuming completed states in framebuffer rendering
  • opacity-mask composition
  • sprite-zero-hit changes

Complete example implementation

# emulator/ppu/ppu.py

@dataclass
class PPU:
    ...
    current_scanline_scroll_states: list[BackgroundScanlineState | None] = field(
            default_factory= lambda: [None] * 240
    )

    # --- NEW LINE: LAST COMPLETE TIMED SCANLINE FRAME ---
    completed_scanline_scroll_states: tuple[
        BackgroundScanlineState, ...
    ] = ()

    ...

    # --- NEW BLOCK: PUBLISH AND RESET SCANLINE STATES ---
    def _complete_scanline_scroll_frame(self) -> None:
        current = self.current_scanline_scroll_states

        if (
            len(current) == 240
            and all(state is not None for state in current)
        ):
            self.completed_scanline_scroll_states = tuple(
                state
                for state in current
                if state is not None
            )
        else:
            self.completed_scanline_scroll_states = ()

        self.current_scanline_scroll_states = [None] * 240

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

        if self.cycle >= PPU_CYCLES_PER_SCANLINE:
            self.cycle = 0
            self.scanline += 1

            if self.scanline >= PPU_SCANLINES_PER_FRAME:
                # --- NEW LINE: PUBLISH BEFORE ENTERING THE NEXT FRAME ---
                self._complete_scanline_scroll_frame()
                self.scanline = 0
                self.frame += 1

        ...

Run this lesson

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