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.pyReference
https://www.nesdev.org/wiki/PPU_renderingWhy 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 finishesAt 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 listWhy 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 rendererImportant 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