NES TDD

318. Выравнивание кадров в main

Ограничиваем цикл ручного интерфейса скоростью NES NTSC.

Урок 318 из 356 · tests/chapter_10_performance/test_318_main_frame_pacing.py

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

main.py

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

После более быстрой загрузки framebuffer в pygame и опциональных запускаторов PyPy ручной эмулятор может работать быстрее реальной NES. В ходе разработки курса неограниченный путь достигал примерно 90-120 FPS.

Более высокая скорость работы — полезное доказательство производительности, но она делает геймплей, анимацию и тайминг ввода слишком быстрыми. Интерфейс должен ждать, когда кадр завершается раньше времени.

Ключевой термин: выравнивание кадров Выравнивание кадров означает задержку показа, когда эмулируемый кадр завершается быстрее, чем целевая длительность в реальном времени.

Целевой тайминг NTSC

NES_NTSC_FPS = 60.0988
TARGET_FRAME_SECONDS = 1.0 / NES_NTSC_FPS

Один целевой кадр составляет примерно

16.64 milliseconds

Пример реализации

# --- 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 ---

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

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

Почему не делать sleep в Console/CPU/PPU? Ядро эмулятора отвечает за детерминированные переходы эмулируемого состояния. Выравнивание по реальному времени — это политика интерфейса, поэтому спать должен только main.py.

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

Выравнивание кадров не исправляет точность по тактам CPU и не делает медленную эмуляцию быстрее. Оно лишь не позволяет достаточно быстрой эмуляции работать быстрее целевого оборудования.

Текущий ручной эталон

Продолжайте использовать локальный, предоставленный пользователем MarioBros.nes для этого шага по производительности. Проверка на Super Mario Bros. откладывается до реализации sprite 0 hit.

Почему тесты формы исходного кода? Автоматические тесты не должны вызывать main(), реально засыпать, открывать pygame или требовать коммерческий ROM. Эти тесты проверяют структуру выравнивания без запуска ручного цикла.

Совместимость в будущем

Шаг 356 заменяет форму исходного кода с относительным sleep из этого урока на выравнивание по абсолютному дедлайну. Когда обнаруживается эта полная замена, пропускаются только шесть устаревших утверждений формы исходного кода ниже. Устойчивая граница интерфейс/ядро и утверждения об одном эмулируемом кадре за проход цикла остаются активными, пока тест 356 проверяет новый контракт. Не добавляйте в main.py мёртвый код совместимости с wait_time или time.sleep.

Вне рамок

  • точность по тактам CPU при переходе через страницу/ветвлении
  • sprite 0 hit
  • проверка на Super Mario Bros.
  • оптимизация рендеринга pygame (завершена на предыдущем шаге)
  • вызов main() из pytest

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

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