350. Select timed framebuffer path
Select timed framebuffer composition only when one complete frame is available.
Lesson 350 of 356 · tests/chapter_13_scrolling/test_350_select_timed_framebuffer_path.py
File to update
emulator/rendering/ppu_background_renderer.pyReferences
https://www.nesdev.org/wiki/PPU_rendering
https://www.nesdev.org/wiki/PPU_scrollingWhy this step exists
The private timed compositor requires one BackgroundScanlineState for every visible row. That evidence is unavailable during startup and may be absent after an incomplete frame, so the public adapter needs an explicit compatibility boundary:
exactly 240 completed states -> use timed row composition
any other tuple length -> use the existing t + fine-X snapshot pathWhy require exact length instead of truthiness? An empty tuple is false, but a partial one-entry or 239-entry tuple is true. Partial timing data still leaves unknown rows and must not enter a compositor that requires all 240 states. The structural invariant, not whether the tuple is non-empty, decides which mechanism is safe.
Why preserve the old path? A new PPU has no completed frame. Historical callers and startup rendering already have deterministic behavior based on temp_vram_addr plus fine_x. Keeping that body unchanged provides a safe fallback until the first complete timed frame is published.
Control flow
ppu_background_viewport_to_framebuffer(ppu)
|
v
completed length == 240?
/ yes no
| |
v v
timed row helper existing snapshot path
| |
+------ return --+Important invariants
- exactly 240 entries select the timed helper
- timed composition returns immediately
- the old source renderer and full-frame compositor do not also run
- empty, partial, and oversized tuples select the established fallback
- the fallback still decodes ppu.temp_vram_addr and ppu.fine_x
- opacity-mask selection remains unchanged in this lesson
Common misconception
Do not catch the timed helper's ValueError and silently retry the fallback. The public gate owns expected availability; an error after the complete-frame gate indicates a broken invariant that should remain visible during debugging.
Out of scope
- timed opacity-mask composition
- sprite/background priority correction
- sprite-zero-hit mask integration
- full vertical source-row scrolling
Complete example implementation
# emulator/rendering/ppu_background_renderer.py
def ppu_background_viewport_to_framebuffer(ppu: PPU) -> Framebuffer:
# --- NEW BLOCK: USE TIMED DATA ONLY WHEN THE FRAME IS COMPLETE ---
if (
len(ppu.completed_scanline_scroll_states)
== NAMETABLE_PIXEL_HEIGHT
):
return _timed_scanlines_to_framebuffer(ppu)
# Existing temp_vram_addr + fine_x fallback remains unchanged below.
viewport_x, _ = decode_background_viewport_position(
temp_vram_addr=ppu.temp_vram_addr,
fine_x=ppu.fine_x,
)
...Manual checkpoint after this lesson
With your own legal Super Mario Bros. ROM, you can now play far enough to try to catch a mushroom while observing the timed horizontal background. At this exact milestone, the mushroom may appear in front of a solid background tile that should visually occlude it. That symptom is expected: RGB framebuffer rows now use timed scanline states, but the background opacity mask used for sprite priority still uses one old frame-level snapshot. The next lesson will compose opacity-mask rows from the same timed states so color and priority decisions use identical screen coordinates.
Run this lesson
uv run pytest tests/chapter_13_scrolling/test_350_select_timed_framebuffer_path.py -v