330. Compose horizontal framebuffer viewport

Compose a 256x240 horizontal viewport from two adjacent nametable framebuffers.

Lesson 330 of 356 · tests/chapter_13_scrolling/test_330_compose_horizontal_framebuffer_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

Why this step exists

Each rendered nametable is 256 pixels wide, but horizontal scrolling can make one screen contain pixels from two adjacent nametables. This step treats them as one logical 512x240 background and selects a 256x240 viewport from it.

Coordinate model

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

For each destination screen X

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

The modulo operation wraps the right edge of the logical pair back to its left edge.

Example seam

viewport X = 200

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

Invariants

  • both source framebuffers are exactly 256x240
  • the returned framebuffer is exactly 256x240
  • neither source framebuffer is mutated
  • every output pixel comes from the same Y row in one source framebuffer

Common misconception

This function does not implement cartridge nametable mirroring. PpuBus owns address mirroring; this function only composes already-rendered logical neighbors.

Out of scope

  • vertical scrolling
  • background opacity-mask composition
  • PPU or PpuBus reads
  • nametable tile rendering
  • Console integration
  • pygame
  • exact dot-timed v/t transfers

Complete example implementation

# emulator/rendering/background_viewport.py

from emulator.rendering.framebuffer import Framebuffer


def compose_horizontal_framebuffer_viewport(
    left: Framebuffer,
    right: Framebuffer,
    viewport_x: int,
) -> Framebuffer:
    expected_size = (
        NAMETABLE_PIXEL_WIDTH,
        NAMETABLE_PIXEL_HEIGHT,
    )

    if (left.width, left.height) != expected_size:
        raise ValueError("Left nametable framebuffer must be 256x240")

    if (right.width, right.height) != expected_size:
        raise ValueError("Right nametable framebuffer must be 256x240")

    logical_width = NAMETABLE_PIXEL_WIDTH * 2
    result = Framebuffer(
        width=NAMETABLE_PIXEL_WIDTH,
        height=NAMETABLE_PIXEL_HEIGHT,
    )

    for screen_y in range(NAMETABLE_PIXEL_HEIGHT):
        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

            color = source.get_pixel(source_x, screen_y)
            result.set_pixel(screen_x, screen_y, color)

    return result

Run this lesson

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