351. Timed scanlines to opaque mask
Compose one background opacity mask from 240 completed scanline states.
Lesson 351 of 356 · tests/chapter_13_scrolling/test_351_timed_scanlines_to_opaque_mask.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
Timed framebuffer rows can show a fixed status area and a differently scrolled gameplay area in one frame. Sprite priority and sprite-zero-hit decisions also need background opacity at those exact screen coordinates. If RGB pixels use timed states while opacity uses one final t snapshot, sprites can appear in front of solid tiles that should occlude them.
A BackgroundOpaqueMask is a flat 256x240 list of Boolean values
True: the decoded background pattern pixel is nonzero
False: the decoded background pattern pixel is transparentOpacity is pattern information, not an RGB-color test. A visible pattern pixel can use a black palette color, while a transparent pattern pixel displays the universal background color.
The mask compositor must mirror framebuffer coordinate selection exactly:
state = completed_scanline_scroll_states[screen_y]
logical_x = (viewport_x + screen_x) % 512
logical X 0-255: read the left source mask
logical X 256-511: read the right source mask after subtracting 256For this horizontal milestone, source Y remains screen_y.
Why cache source masks? Viewport X may change on every scanline without changing the logical source pair.
The local cache builds each pair once per composition
$2000 key -> masks for ($2000, $2400)
$2800 key -> masks for ($2800, $2C00)Important invariants
- input contains exactly 240 completed states
- output contains exactly 256 * 240 Boolean entries
- state index, source row, and destination row use the same screen_y
- horizontal coordinates wrap across the complete 512-pixel pair
- each logical pair is built at most once per composition
- viewport-X changes reuse the existing source pair
- opacity-mask code never calls framebuffer rendering
- framebuffer and opacity-mask coordinate mappings remain identical
Common misconception
Do not derive this mask from rendered RGB values. Sprite priority depends on whether the original background pattern value was zero, which cannot be reconstructed reliably from its final palette color.
Testing strategy
These tests replace PPU-backed mask construction with synthetic masks. Each Boolean value is a deterministic function of logical base address, source X, and source Y. That makes wrong pair selection, wrong row indexing, and horizontal-wrap errors observable without involving CHR decoding, palettes, or cartridge mirroring.
Out of scope
- activating this helper in the public opacity-mask adapter
- fallback selection for startup or incomplete frames
- sprite-zero-hit integration
- full vertical source-row scrolling
Complete example implementation (Very similar logic to _timed_scanlines_to_framebuffer):
# emulator/rendering/ppu_background_renderer.py
# --- NEW BLOCK: COMPOSE COMPLETED TIMED OPACITY ROWS ---
def _timed_scanlines_to_opaque_mask(
ppu: PPU,
) -> BackgroundOpaqueMask:
states = ppu.completed_scanline_scroll_states
if len(states) != NAMETABLE_PIXEL_HEIGHT:
raise ValueError(
"Timed opacity mask requires exactly 240 scanline states"
)
result: BackgroundOpaqueMask = [False] * (
NAMETABLE_PIXEL_WIDTH * NAMETABLE_PIXEL_HEIGHT
)
pair_cache: dict[
int,
tuple[BackgroundOpaqueMask, BackgroundOpaqueMask],
] = {}
logical_width = NAMETABLE_PIXEL_WIDTH * 2
for screen_y, state in enumerate(states):
left_base, right_base = _scanline_horizontal_pair(state)
if left_base not in pair_cache:
pair_cache[left_base] = (
ppu_background_to_opaque_mask(
ppu,
base_nametable_addr=left_base,
),
ppu_background_to_opaque_mask(
ppu,
base_nametable_addr=right_base,
),
)
left, right = pair_cache[left_base]
viewport_x = _scanline_viewport_x(state)
destination_row = screen_y * NAMETABLE_PIXEL_WIDTH
for screen_x in range(NAMETABLE_PIXEL_WIDTH):
logical_x = (viewport_x + screen_x) % logical_width
if logical_x < NAMETABLE_PIXEL_WIDTH:
source = left
source_x = logical_x
else:
source = right
source_x = logical_x - NAMETABLE_PIXEL_WIDTH
destination_index = destination_row + screen_x
source_index = destination_row + source_x
result[destination_index] = source[source_index]
return resultRun this lesson
uv run pytest tests/chapter_13_scrolling/test_351_timed_scanlines_to_opaque_mask.py -v