Files
Sprinter-SDCC/applications/PoP/docs/frame_pacing_plan.md
T
snark13 7f778bba2f Разбор перехода на фиксированный логический кадр (кода не трогали)
Замеры в MAME: кадровые прерывания теряются фазозависимо (0..3%), на
полной перерисовке комнаты — три подряд.  Причина: импульс запроса 32
такта (9,14 мкс) против DI-окон блита ~0,29 мс.  Счёт попаданий
брейкпоинтом на этом драйвере недостоверен (WAIT-линия), достоверен
только детектор разрыва.

Блокер включения gfx_set_fps_div как есть: счётчиковый путь ждёт через
halt и не зовёт idle-хук, то есть возвращает KBD-1.
2026-08-19 21:41:13 +03:00

21 KiB
Raw Blame History

Фиксированный логический кадр — разбор перед реализацией

Дата разбора: 2026-08-19. Отправная точка — тег 0.0.1-prealpha. Статус: АНАЛИЗ, кода не трогали.

Задача пользователя: перевести логический кадр на фиксированный размер, не зависящий от длительности синей/зелёной/циан фаз. Инструмент — gfx_set_fps_div, режимы FASTEST / FAST / NORMAL.

1. Что на самом деле меняется

Сейчас главный цикл (roomtest.c) после отрисовки ждёт три gfx_wait_vsync() подряд. Каждый ждёт ближайший фронт луча, поэтому период логического кадра равен

период = ceil(W) + 2 растровых кадра,   W = работа в растрах

Первое ожидание доедает хвост текущего растра, два следующих — целые растры. Отсюда наблюдаемое: W <= 1 → период 3; W = 1.1 → период уже 4. То есть реальный бюджет логического кадра сегодня — один растр (430 080 тактов), всё сверх него стоит целого лишнего растра.

Нужное поведение:

период = max(n, ceil(W))

При n = 3 бюджет становится 1 290 240 тактов — втрое больше. Замеренный максимум работы на 13/23 (911 862) укладывается туда с запасом, а 11/15 (437 484 лёгкая / ~603 000 тяжёлая позиция) — вдвойне.

Это главный выигрыш, и он не про скорость игры, а про исчезновение скачков: сегодня превышение растра на один такт стоит +33 % к периоду.

2. Три режима и их привязка к оригиналу

Растровый кадр Sprinter: 320 строк × 896 пикселей при 14 МГц = 20,48 мс (48,83 Гц). В тактах CPU (21 МГц) — 430 080; замерено на холостом DSS: 430 131 на прерывание.

Оригинал (SDLPoP/src/seg003.c:363): BASE_FPS = 60, base_speed = 5 тика = 83,3 мс, fight_speed = 6 = 100 мс.

режим обычно в бою мс обычно мс в бою к оригиналу
FASTEST 3 3 61,4 61,4 +36 % скорости
FAST 3 4 61,4 81,9 +36 % / точно
NORMAL 4 5 81,9 102,4 точно (83,3 / 100)

NORMAL воспроизводит оригинал с точностью 1,7 % и 2,4 % — расхождение только из-за 48,83 Гц против 60 Гц, целыми делителями точнее не выйдет.

Условие «бой» берём у оригинала буквально — оно проще, чем кажется:

if (Kid.sword == sword_2_drawn)  set_timer_length(timer_1, fight_speed);
else                             set_timer_length(timer_1, base_speed);

Это не «идёт бой» и не «есть страж рядом», а только «у Кида вынут меч», и проверяется в самом верху главного цикла, до play_frame(). У нас поле есть (Kid.sword, SWORD_2_DRAWNguards.c), так что переключение — одна строка в том же месте цикла.

Это закрывает и давнюю задачу L1-SPEED (TASKS_OPEN.md): сейчас мы идём на 60 мс вместо 83,3 — примерно на 39 % быстрее эталона.

3. Как устроен темп сейчас и что мешает

gfx_wait_vsync() имеет две ветки (libbgi/common/gfx_wait_vsync.c):

  • _gfx_fps_div <= 1 — лучевой поллинг бита 5 порта 0xFE в тесном цикле, и в этом цикле зовётся idle-хук (gfx_set_idle_hook). roomtest вешает туда kbd_raw_poll — это единственное, что делает клавиатуру работоспособной (задача KBD-1: приёмный FIFO SIO 3 байта, импульс запроса прерывания живёт 32 такта и теряется в DI-окнах акселератора; лечится только плотным опросом раз в ~0,5 мс).
  • _gfx_fps_div >= 2 — счётчиковый путь: ждёт, пока фоновый ISR (_gfx_frame_isr, слот кадровой цепочки) насчитает n фронтов, а ожидание реализовано через ei; halt.

И вот здесь блокер. На счётчиковом пути idle-хук не зовётся вообще. Включив gfx_set_fps_div(3) как есть, мы немедленно возвращаем KBD-1: теряются нажатия при зажатом Shift, залипают клавиши. Это не мелочь и не «потом поправим» — это единственная причина, по которой клавиатура сейчас вообще работает.

4. Что измерено (MAME, 2026-08-19)

Метод и его границы

Прерывания считались брейкпоинтами на резидентных адресах трамплина (_irq_tramp = 0x88B4, ветка кадрового пути tr_frame = 0x8995).

Счёт попаданий брейкпоинтом на этом драйвере недостоверен: sprinter дёргает Z80_INPUT_LINE_WAIT (do_mem_wait), инструкция пересчитывается, и один и тот же PC срабатывает по нескольку раз. Наблюдалось «попаданий больше, чем растровых кадров» и «попаданий в tr_frame больше, чем входов в трамплин» — логически невозможные результаты.

Достоверен только детектор разрыва: брейкпоинт на следующей инструкции пишет temp3 = totalcycles, брейкпоинт с условием (totalcycles - temp3) > 0x9D800 (1,5 растра) останавливает машину. Дубли попаданий его не портят. Все числа ниже — этим методом.

Отдельная грабля: литералы в отладчике MAME шестнадцатеричные. Первый прогон с порогом «700000» на деле проверял 0x700000 = 17 растров и не срабатывал никогда.

Факты

  1. Холостой DSS — 799 прерываний на 343 674 880 тактов = 430 131 на прерывание. Подтверждает константу растра и что потерь нет, когда нечего рисовать.

  2. 11/15, покой (чомпер + два факела + страж) — потери ЕСТЬ: разрыв ровно 860 129 тактов = два растра = одно потерянное прерывание. Частота в «плохой» фазе: 12 потерь на 427 растровых кадров = 2,8 %; повтор — 12 на 495 (2,4 %) при бегущем Киде.

  3. Та же сцена после сдвига Кида (]/[, попиксельно) — 0 потерь на 3919 кадров, и после возврата обратно 0 на 4010.

  4. Полная перерисовка комнаты (переход/+) — разрыв 1 720 289 = ровно четыре растра = три потерянных прерывания подряд.

  5. 13/23 — ни в покое, ни при беге потерь не поймано; на смене комнаты — поймано.

  6. Клавиатура ни при чём: с удержанной клавишей 3,1 %, без неё 2,8 % — в пределах разброса.

Как это читать

Пункты 2 и 3 вместе — самое важное. Одна и та же сцена даёт то 2,8 %, то ноль. Значит потеря определяется не нагрузкой, а фазой: попадает ли момент кадрового прерывания внутрь DI-окна блита.

Механизм подтверждён исходником MAME (sprinter.cpp):

  • irq_on поднимает линию и заводит irq_off_timer на 32 такта неразогнанного клока (3,5 МГц) = 9,14 мкс; irq_off гасит. Ядро z80 в MAME уровневое (m_irq_state без защёлки) — импульс, пришедший под di, теряется НАСОВСЕМ. Это же поведение у настоящего Spectrum (INT 32 такта), так что это не эмуляторный артефакт.
  • DI-окно у нас — один вызов _bgi_blit_rows_raw, а он по контракту режется вызывающим на чанки ≤16 строк (≈6 200 тактов ≈ 0,29 мс). То есть DI-окна короткие и с промежутками, отсюда и «то теряем, то нет»: всё решает, куда попал 9-микросекундный импульс.
  • irqack_cb гасит все три входа мержера (экран/клавиатура/CBL) одним подтверждением. Плюс трамплин обслуживает за вход ровно один источник и делает приватный RETI. Значит кадровое прерывание может быть съедено клавиатурной веткой или (в будущем) CBL-веткой.

Вывод по фазе. Наш период сейчас 3 растра, но иногда 4 — и каждый такой случай сдвигает фазу рендера относительно луча. Отсюда «полосы»: десятки секунд без потерь, потом полоса с потерями. При жёстком пейсинге период станет РОВНО n, фаза перестанет плавать — и сцена может залипнуть в плохой фазе надолго. Это хуже случайных 3 %: систематическая потеря по прерыванию на кадр превратит логический кадр из 3 растров в 4, то есть даст ровные −25 % скорости, которые никак не проявятся в профиле тактов.

5. Проблемы по убыванию риска

# проблема риск
P1 счётчиковый путь ждёт через halt → idle-хук не зовётся → возврат KBD-1 (потеря нажатий, залипание клавиш) блокер
P2 счётчик кадров теряет тики (фазозависимо, 0…3 %), при жёстком пейсинге может залипнуть в плохой фазе → ровный минус скорости высокий
P3 полноэкранная перерисовка (вход в комнату, старт уровня) теряет 3+ тика подряд → счётчик недосчитает, ожидание растянется сильнее самой работы средний
P4 звук через CBL (_irq_cbl_hook, реальный ISR в трамплине): (а) ещё один источник, крадущий кадровые тики приватным RETI; (б) обратно — любое подтверждение гасит ожидающий запрос CBL → underrun (счётчик cbl_underruns() уже есть); (в) ISR длинный (полный сейв обоих наборов + вызов в приложение) средний, растёт
P5 второй call-site gfx_wait_vsync() — ветка frozen (roomtest.c:320) ждёт один фронт; под делителем её смысл меняется низкий
P6 IRQ_CHAIN_MAX = 4; делитель занимает слот, звук — свой; запас есть, но конечный низкий
P7 бит 5 порта 0xFE доступен только при включённом cbl_mode; сейчас его лениво занимает сам gfx_wait_vsync через _cbl_port_ref, при открытии реального звука владение переходит к CBL — переход уже спроектирован, но его надо проверить в связке низкий

Отдельно, не проблема а рычаг: по сообщению разработчиков (IvanMak.txt:846848) новая прошивка позволяет акселератору работать с EI — по приходу прерывания он отключается, по RETI включается. Если это подтвердится на железе и моделируется в MAME, P2 исчезает полностью. Проверять отдельно; строить на этом нельзя (неизвестно, какая прошивка у пользователя).

6. Варианты реализации

A. Включить gfx_set_fps_div(3) как есть

Отвергается: P1 (убивает клавиатуру) — сразу, без вариантов.

B. Свой wait в приложении: счётчик только на рендер, ожидание — лучом

Счётчик кадровых прерываний отвечает на один вопрос — «сколько фронтов съел рендер», а само ожидание идёт существующим лучевым поллингом с idle-хуком, то есть клавиатура работает ровно как сегодня.

k = tick - tick_at_frame_start;      /* фронтов съел рендер */
if (k >= n) k = n - 1;               /* опоздали — ждём хотя бы один */
повторить (n - k) раз: ждать фронт луча (поллинг + idle-хук)
tick_at_frame_start = tick;          /* якорь на фактическом фронте */

Плюсы: минимальная правка, клавиатура нетронута, фаза переякоривается каждый кадр (ошибка не копится). Минус: остаётся P2 — при залипании в плохой фазе k систематически занижен на 1, и кадр ровно на растр длиннее. Профилем это не видно.

C. Программный счётчик кадров по лучу (без прерываний вообще)

Считать фронты выборкой бита 5 в точках, которые мы и так проходим. Окно бланка (строки 272…319) длится 3,07 мс, максимальное DI-окно — 0,29 мс, значит достаточно опрашивать чаще, чем раз в 3 мс.

Точки выборки: pop_blit_b (наша обёртка, зовётся на каждый блит — в фазах рисования это плотнее 0,3 мс) плюс несколько точек в синей фазе (она 149 106 тактов ≈ 6,9 мс без единого блита, нужно 3-4 точки).

Цена: in a,(0xFE) + проверка бита + дедуп фронта ≈ 40-50 тактов; при ~50 выборках это 2 500 тактов = 0,6 % растра.

Плюсы: точно, и точность не зависит ни от DI, ни от звука, ни от клавиатуры — снимает P2, P3, P4(а) разом. Минус: заводит инвариант «между выборками не больше 3 мс», который легко нарушить будущей правкой. Инвариант проверяем в MAME тем же детектором разрыва.

D. CTC как источник кадра

irq_ctc_install уже есть (каналы 2+3, вектор 0x06, отдельный от 0xFF). Запрос CTC защёлкивается (daisy chain — в _irq.h прямо записано, что без RETI следующего прерывания не будет), поэтому под di он не теряется, а откладывается. Пресет 112 × 160 даёт ровно период кадра без дрейфа (обе частоты — от одного X_SP).

Блокер: irq_ctc_install вектрится напрямую и требует кода в W2 → сейчас только tiny/big, а roomtest — huge. Нужна W2-копия CTC-трамплина (в дизайн-доке помечена как follow-up). Плюс защёлка хранит только ОДИН отложенный запрос — на полноэкранной перерисовке (P3) всё равно недосчитает.

Рекомендация

B как первый шаг, C — как способ закрыть P2, и оба под одним интерфейсом: приложение зовёт свой pop_wait_logical_frame(n), а чем внутри считаются кадры — деталь реализации. Тогда B→C не трогает ни главный цикл, ни режимы.

D не нужен, пока C справляется, и требует работы в libc (W2-копия CTC-трамплина) ради того же результата.

7. Порядок работ с критериями приёмки

Ш0. Инструмент. Скрипт замера потерь кадровых прерываний (детектор разрыва) — зафиксировать как повторяемую процедуру, он понадобится на каждом шаге. Критерий: воспроизводит числа §4 на 11/15 и на смене комнаты.

Ш1. Свой wait (вариант B), делитель ещё не включён. Вынести хвост главного цикла в pop_wait_logical_frame(n), поведение при n=3 должно быть бит-в-бит прежним (три фронта после работы). Критерий: такты по фазам и распределение периода не изменились, клавиатура работает.

Ш2. Счётчик кадров. Слот кадровой цепочки + k = tick - anchor. Критерий: в 11/15 период стал ровно 3 растра ВСЕГДА (сейчас 3/4/5), а на 13/23 — 3 вместо нынешних 3/4/5; клавиатура не деградировала (проверка Shift+стрелки по методике KBD-1, не «на глаз»).

Ш3. Режимы. POP_SPEED_FASTEST/FAST/NORMAL + условие боя Kid.sword == SWORD_2_DRAWN в верху цикла, как у оригинала. Критерий: NORMAL секундомером совпадает с живым SDLPoP на одинаковом отрезке (методика из L1-SPEED — секундомер, не глазомер).

Ш4. Закрыть P2 (вариант C). Выборка луча в pop_blit_b и в синей фазе. Критерий: детектор разрыва не ловит ни одного расхождения между программным счётчиком и лучом за 10 000 кадров, включая смену комнаты.

Ш5. Звук. Только после Ш4: открыть CBL и перемерить P4 — cbl_underruns() и потери кадровых тиков.

8. Что проверить артефактом до начала

  1. Сколько именно фронтов съедает вход в комнату — от этого зависит, нужен ли отдельный «resync» на тяжёлых переходах или хватит того, что якорь переставляется каждый кадр.
  2. Ветка frozen (roomtest.c:320) — какой темп ей нужен под делителем.
  3. Проверить, что при n=4/5 бит 5 всё ещё единственный источник фронта (то есть _cbl_port_ref держится всё это время).