352. Select timed opaque mask path

Select timed opacity-mask composition only when one complete frame is available.

Lesson 352 of 356 · tests/chapter_13_scrolling/test_352_select_timed_opaque_mask_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

Step 351 built the mechanism for composing Boolean background-opacity rows from the same timed states as RGB framebuffer rows. The public mask adapter must now activate that mechanism without breaking startup or incomplete-frame behavior.

Selection boundary

exactly 240 completed states -> timed opacity-mask composition
any other tuple length       -> existing t + fine-X snapshot fallback

This exact gate matches the framebuffer adapter. A truthiness check is insufficient because a partial one-entry or 239-entry tuple is nonempty but cannot describe all visible rows.

Why keep the fallback? A new PPU starts with an empty completed tuple. The established snapshot path remains a deterministic compatibility mechanism until the first complete timed frame is published. It also contains the previously tested logical-pair and mirroring boundary.

Control flow

ppu_background_viewport_to_opaque_mask(ppu)
                     |
                     v
         completed length == 240?
                /                               yes              no
               |                |
               v                v
      timed mask helper   existing snapshot mask
               |                |
               +------ return --+

Important invariants

  • exactly 240 entries select the timed helper
  • the timed branch returns immediately
  • old source-mask construction and full-frame composition do not also execute
  • empty, partial, and oversized tuples select the old fallback
  • fallback still reads ppu.temp_vram_addr and ppu.fine_x
  • framebuffer path and sprite-zero-hit code remain unchanged

Common misconception

Do not catch errors from the timed helper and silently retry the fallback. The gate handles expected data availability. Once a complete frame selects the timed helper, an exception represents a violated invariant that should remain observable.

What changes in practice? Console already uses this viewport-aware mask for sprite/background composition. RGB and visual sprite priority can now use identical timed coordinates, correcting cases such as a mushroom incorrectly appearing in front of an opaque tile. The separate sprite-zero-hit helper still uses its older mask dependency and will be aligned in a later lesson.

Out of scope

  • changing timed row composition
  • changing the framebuffer gate
  • changing sprite-zero-hit mask consumption
  • full vertical source-row scrolling

Complete example implementation

# emulator/rendering/ppu_background_renderer.py

def ppu_background_viewport_to_opaque_mask(
    ppu: PPU,
) -> BackgroundOpaqueMask:
    # --- NEW BLOCK: USE TIMED DATA ONLY FOR A COMPLETE FRAME ---
    if (
        len(ppu.completed_scanline_scroll_states)
        == NAMETABLE_PIXEL_HEIGHT
    ):
        return _timed_scanlines_to_opaque_mask(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,
    )
    ...

Run this lesson

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