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.py

References

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

Why 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 path

Why 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