318. Main frame pacing

Cap the manual frontend loop to NES NTSC speed.

Lesson 318 of 356 · tests/chapter_10_performance/test_318_main_frame_pacing.py

File to update

main.py

Why this step exists

After the faster pygame framebuffer upload and the optional PyPy launchers, the manual emulator can run faster than the real NES. During tutorial development, the uncapped path reached roughly 90-120 FPS.

Running faster is useful performance evidence, but it makes gameplay, animation, and input timing too fast. The frontend should wait when a frame finishes early.

Key term: frame pacing Frame pacing means delaying presentation when an emulated frame finishes faster than the target wall-clock duration.

Target NTSC timing

NES_NTSC_FPS = 60.0988
TARGET_FRAME_SECONDS = 1.0 / NES_NTSC_FPS

One target frame is approximately

16.64 milliseconds

Example implementation

# --- NEW BLOCK: NES FRAME-TIME TARGET ---
NES_NTSC_FPS = 60.0988
TARGET_FRAME_SECONDS = 1.0 / NES_NTSC_FPS
# --- END NEW BLOCK ---

...

while running:
    # --- NEW LINE: START THIS FRAME'S TIMER ---
    frame_start_time = time.perf_counter()
    # --- END NEW LINE ---

    for event in pygame.event.get():
        ...

    executed = console.step_until_next_frame()
    framebuffer = console.render_framebuffer()
    draw_framebuffer(window, framebuffer, SCALE)
    pygame.display.flip()

    # --- NEW BLOCK: WAIT FOR THE UNUSED FRAME BUDGET ---
    # Sleep only for the unused part of this frame's time budget.
    frame_end_time = time.perf_counter()
    frame_elapsed_time = frame_end_time - frame_start_time
    wait_time = TARGET_FRAME_SECONDS - frame_elapsed_time

    if wait_time > 0:
        time.sleep(wait_time)
    # --- END NEW BLOCK ---

    # --- UPDATED BLOCK: MEASURE FPS AFTER PACING ---
    # Measure FPS after sleeping so the report shows paced gameplay speed.
    frames_since_last_report += 1
    now = time.perf_counter()
    elapsed = now - last_fps_report_time

    if elapsed >= FPS_REPORT_INTERVAL_SECONDS:
        fps = frames_since_last_report / elapsed
        print(f"fps={fps:.1f}")
        frames_since_last_report = 0
        last_fps_report_time = now
    # --- END UPDATED BLOCK ---

Important invariants

Fast frame:
    frame_elapsed_time < TARGET_FRAME_SECONDS
    wait_time > 0
    frontend sleeps for the remaining budget

Slow frame:
    frame_elapsed_time >= TARGET_FRAME_SECONDS
    wait_time <= 0
    frontend does not sleep

Why not sleep in Console/CPU/PPU? The emulator core owns deterministic emulated state transitions. Wall-clock pacing is a frontend policy, so only main.py should sleep.

Common misconception

Frame pacing does not fix CPU-cycle accuracy and does not make slow emulation faster. It only prevents sufficiently fast emulation from running faster than the target hardware.

Current manual reference

Continue using the local, user-provided MarioBros.nes for this performance step. Super Mario Bros. validation is deferred until sprite 0 hit is implemented.

Why source-shape tests? Automated tests must not call main(), sleep for real, open pygame, or require a commercial ROM. These tests verify the pacing structure without running the manual loop.

Future compatibility

Step 356 replaces this lesson's relative-sleep source shape with absolute-deadline pacing. When that complete replacement is detected, only the six obsolete source-shape assertions below are skipped. The enduring frontend/core boundary and one-emulated-frame-per-loop assertions remain active, while Test 356 validates the new contract. Do not add dead wait_time or time.sleep compatibility code to main.py.

Out of scope

  • CPU branch/page-cross cycle accuracy
  • sprite 0 hit
  • Super Mario Bros. validation
  • pygame rendering optimization (completed in the previous step)
  • calling main() from pytest

Run this lesson

uv run pytest tests/chapter_10_performance/test_318_main_frame_pacing.py -v