351. Тайминговые строки развёртки в маску непрозрачности

Составить одну маску непрозрачности фона из 240 завершённых состояний строк развёртки.

Урок 351 из 356 · tests/chapter_13_scrolling/test_351_timed_scanlines_to_opaque_mask.py

Файл для обновления

emulator/rendering/ppu_background_renderer.py

Ссылки

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

Зачем нужен этот шаг

Тайминговые строки кадрового буфера позволяют отображать фиксированную область статуса и область геймплея с другой прокруткой в одном кадре. Решения о приоритете спрайтов и попадании sprite-zero также требуют непрозрачности фона в тех же самых экранных координатах. Если RGB-пиксели используют тайминговые состояния, а непрозрачность — один финальный снимок t, спрайты могут оказаться перед сплошными тайлами, которые должны их перекрывать.

`BackgroundOpaqueMask` — это плоский список из 256×240 булевых значений

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

Непрозрачность определяется данными паттерна, а не проверкой RGB-цвета. Видимый пиксель паттерна может использовать чёрный цвет палитры, тогда как прозрачный пиксель паттерна отображает универсальный цвет фона.

Композитор маски должен в точности повторять выбор координат кадрового буфера:

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

Для этого горизонтального этапа исходный Y остаётся равным screen_y.

Зачем кэшировать исходные маски? Viewport X может меняться на каждой строке развёртки, не изменяя логическую исходную пару.

Локальный кэш строит каждую пару один раз за композицию

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

Важные инварианты

  • вход содержит ровно 240 завершённых состояний
  • выход содержит ровно 256 * 240 булевых записей
  • индекс состояния, исходная строка и строка назначения используют один и тот же screen_y
  • горизонтальные координаты оборачиваются по всей 512-пиксельной паре
  • каждая логическая пара строится не более одного раза за композицию
  • изменения viewport-X переиспользуют существующую исходную пару
  • код маски непрозрачности никогда не вызывает отрисовку кадрового буфера
  • отображения координат кадрового буфера и маски непрозрачности остаются идентичными

Распространённое заблуждение

Не выводите эту маску из отрендеренных RGB-значений. Приоритет спрайтов зависит от того, было ли исходное значение паттерна фона нулевым, что невозможно надёжно восстановить из итогового цвета палитры.

Стратегия тестирования

Эти тесты заменяют построение масок через PPU синтетическими масками. Каждое булево значение — детерминированная функция логического базового адреса, исходного X и исходного Y. Это делает неправильный выбор пары, неверную индексацию строки и ошибки горизонтального оборачивания наблюдаемыми без привлечения декодирования CHR, палитр или зеркалирования картриджа.

За рамками этого шага

  • активация этого хелпера в публичном адаптере маски непрозрачности
  • выбор запасного варианта для запуска или неполных кадров
  • интеграция sprite-zero-hit
  • полная вертикальная прокрутка исходной строки

Полный пример реализации (логика очень похожа на _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

Запустить этот урок

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