351. De scanlines temporizadas a máscara opaca

Componer una máscara de opacidad de fondo a partir de 240 estados de scanline completados.

Lección 351 de 356 · tests/chapter_13_scrolling/test_351_timed_scanlines_to_opaque_mask.py

Archivo a actualizar

emulator/rendering/ppu_background_renderer.py

Referencias

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

Por qué existe este paso

Las filas de framebuffer temporizadas pueden mostrar un área de estado fija y un área de juego desplazada de forma distinta en un mismo fotograma. Las decisiones de prioridad de sprites y de sprite-zero-hit también necesitan la opacidad del fondo en esas mismas coordenadas de pantalla exactas. Si los píxeles RGB usan estados temporizados mientras que la opacidad usa una única instantánea final de t, los sprites pueden aparecer delante de tiles sólidos que deberían ocultarlos.

Un BackgroundOpaqueMask es una lista plana de 256x240 valores booleanos

True:  the decoded background pattern pixel is nonzero
False: the decoded background pattern pixel is transparent

La opacidad es información de patrón, no una prueba de color RGB. Un píxel de patrón visible puede usar un color de paleta negro, mientras que un píxel de patrón transparente muestra el color de fondo universal.

El compositor de la máscara debe reflejar exactamente la selección de coordenadas del framebuffer:

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 256

Para este hito horizontal, la Y de origen sigue siendo screen_y.

¿Por qué cachear máscaras de origen? El viewport X puede cambiar en cada scanline sin que cambie el par de origen lógico.

La caché local construye cada par una sola vez por composición

$2000 key -> masks for ($2000, $2400)
$2800 key -> masks for ($2800, $2C00)

Invariantes importantes

  • la entrada contiene exactamente 240 estados completados
  • la salida contiene exactamente 256 * 240 entradas booleanas
  • el índice de estado, la fila de origen y la fila de destino usan el mismo screen_y
  • las coordenadas horizontales dan la vuelta a lo largo del par completo de 512 píxeles
  • cada par lógico se construye como máximo una vez por composición
  • los cambios de viewport-X reutilizan el par de origen existente
  • el código de la máscara de opacidad nunca llama al renderizado del framebuffer
  • las correspondencias de coordenadas del framebuffer y de la máscara de opacidad permanecen idénticas

Concepto erróneo habitual

No derives esta máscara a partir de valores RGB ya renderizados. La prioridad de sprites depende de si el valor original del patrón de fondo era cero, algo que no puede reconstruirse de forma fiable a partir de su color de paleta final.

Estrategia de pruebas

Estas pruebas sustituyen la construcción de máscaras respaldada por la PPU por máscaras sintéticas. Cada valor booleano es una función determinista de la dirección base lógica, la X de origen y la Y de origen. Eso hace observables la selección incorrecta de pares, el indexado incorrecto de filas y los errores de ajuste horizontal sin involucrar la decodificación de CHR, las paletas ni el mirroring del cartucho.

Fuera de alcance

  • activar este helper en el adaptador público de la máscara de opacidad
  • selección de reserva para fotogramas de inicio o incompletos
  • integración con sprite-zero-hit
  • desplazamiento vertical completo de la fila de origen

Implementación de ejemplo completa (lógica muy similar a _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 result

Ejecutar esta lección

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