331. Compose horizontal opaque mask viewport

Compose a scrolled opacity mask from two adjacent nametable masks.

Lesson 331 of 356 · tests/chapter_13_scrolling/test_331_compose_horizontal_opaque_mask_viewport.py

File to update

emulator/rendering/background_viewport.py

Reference documentation

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

Existing flow

build_background_opaque_mask() converts one nametable into a 256x240 list[bool].
Sprite priority and sprite-zero-hit detection index that mask using screen
coordinates.

New behavior

This step does not replace the existing mask builder. It composes two already
constructed masks into the same horizontal viewport introduced for framebuffers
in Step 330.

Coordinate model

left mask:  logical X   0-255
right mask: logical X 256-511

logical X = (viewport X + screen X) modulo 512

For viewport X 200

screen X   0-55  <- left X 200-255
screen X  56-255 <- right X   0-199

Invariants

  • each source contains exactly 256 * 240 Boolean entries
  • the result is a new 256 * 240 entry list
  • source masks remain unchanged
  • Y rows do not move
  • mask mapping exactly matches framebuffer mapping

Common misconception

The mask does not derive opacity from final RGB colors. It preserves whether the original background pattern color index was nonzero.

Out of scope

  • building a mask from CHR or nametable bytes
  • reading PPU memory
  • Console integration
  • sprite-zero-hit integration
  • vertical scrolling
  • pygame

Complete example implementation

# emulator/rendering/background_viewport.py

from emulator.rendering.nametable_renderer import BackgroundOpaqueMask


def compose_horizontal_opaque_mask_viewport(
    left: BackgroundOpaqueMask,
    right: BackgroundOpaqueMask,
    viewport_x: int,
) -> BackgroundOpaqueMask:
    expected_size = NAMETABLE_PIXEL_WIDTH * NAMETABLE_PIXEL_HEIGHT

    if len(left) != expected_size:
        raise ValueError(
            f"Left background opacity mask must contain {expected_size} entries"
        )

    if len(right) != expected_size:
        raise ValueError(
            f"Right background opacity mask must contain {expected_size} entries"
        )

    logical_width = NAMETABLE_PIXEL_WIDTH * 2
    result = [False] * expected_size

    for screen_y in range(NAMETABLE_PIXEL_HEIGHT):
        row_start = 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 = row_start + screen_x
            source_index = row_start + source_x
            result[destination_index] = source[source_index]

    return result

Run this lesson

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