35 KiB
Фиксированный логический кадр — разбор перед реализацией
Дата разбора: 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_DRAWN — guards.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 растров и не срабатывал никогда.
Факты
-
Холостой DSS — 799 прерываний на 343 674 880 тактов = 430 131 на прерывание. Подтверждает константу растра и что потерь нет, когда нечего рисовать.
-
11/15, покой (чомпер + два факела + страж) — потери ЕСТЬ: разрыв ровно 860 129 тактов = два растра = одно потерянное прерывание. Частота в «плохой» фазе: 12 потерь на 427 растровых кадров = 2,8 %; повтор — 12 на 495 (2,4 %) при бегущем Киде.
-
Та же сцена после сдвига Кида (
]/[, попиксельно) — 0 потерь на 3919 кадров, и после возврата обратно 0 на 4010. -
Полная перерисовка комнаты (переход/
+) — разрыв 1 720 289 = ровно четыре растра = три потерянных прерывания подряд. -
13/23 — ни в покое, ни при беге потерь не поймано; на смене комнаты — поймано.
-
Клавиатура ни при чём: с удержанной клавишей 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:846–848) новая прошивка позволяет акселератору работать с
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. Что проверить артефактом до начала
- Сколько именно фронтов съедает вход в комнату — от этого зависит, нужен ли отдельный «resync» на тяжёлых переходах или хватит того, что якорь переставляется каждый кадр.
- Ветка
frozen(roomtest.c:320) — какой темп ей нужен под делителем. - Проверить, что при n=4/5 бит 5 всё ещё единственный источник фронта
(то есть
_cbl_port_refдержится всё это время).
9. Предлагаемый вариант подробно: программный счётчик кадров по лучу
Дополнение от 2026-08-19 по запросу: как именно получается точное число пройденных кадровых интервалов. Акселератор рассматриваем только в нынешнем виде — с DI/EI (режим «акселератор с EI» из новой прошивки из рассмотрения снят).
9.1. Сигнал и его геометрия
Единственный источник — бит 5 порта 0xFE. Точная семантика по
исходнику MAME (sprinter.cpp, kbd_fe_r):
data |= 0xe0;
data ^= 0x40;
if (cbl_mode()) {
data &= ~0xa0; /* гасит биты 5 и 7 */
data |= (vpos >= BORDER_TOP + SCREEN_YSIZE) << 5;
data |= ... & 0x80; /* бит 7 — CBL */
}
То есть бит 5 = 1 ровно тогда, когда луч ниже картинки
(vpos >= 16 + 256 = 272), и это ЧТЕНИЕ ПОЛОЖЕНИЯ ЛУЧА, а не событие:
ни прерывания, ни защёлки, ни очереди — его невозможно «потерять»,
можно только не посмотреть.
Важное следствие из той же строки: вне cbl_mode бит читается как 1
всегда (его выставляет data |= 0xe0 и уже ничто не гасит). Поэтому
счётчик обязан работать только при взведённом _cbl_port_ref() — том
самом, который сейчас лениво взводит gfx_wait_vsync.
Геометрия кадра (320 строк × 896 пикселей при 14 МГц):
| строк | мс | тактов CPU (21 МГц) | |
|---|---|---|---|
| бит 5 = 1 (нижний бланк) | 48 | 3,07 | 64 512 |
| бит 5 = 0 (картинка + верхний бордер) | 272 | 17,41 | 365 568 |
| кадр целиком | 320 | 20,48 | 430 080 |
Границей кадра берём фронт 1→0 — это vpos = 0, ровно то же
событие, которого ждёт сегодняшний gfx_wait_vsync. Значит момент
свопа страниц не меняется: до начала картинки остаётся верхний бордер,
16 строк ≈ 1 мс запаса, как и сейчас.
9.2. Счётчик
static uint8_t beam_prev; /* бит 5 на прошлой выборке */
static uint8_t frame_tick; /* счётчик кадров, разностная арифметика */
/* ~20 T-состояний тела + вызов; в тактах MAME ≈ 120 на выборку */
void pop_beam_sample(void)
{
uint8_t b = in_fe() & 0x20;
if (beam_prev && !b) frame_tick++; /* фронт 1→0 = начало кадра */
beam_prev = b;
}
Вся арифметика ожидания — разностная по модулю 256, wrap безопасен
(тот же приём, что в существующем _gfx_fps_state).
9.3. Почему счёт ТОЧНЫЙ (условие и запас)
Утверждение: если между соседними выборками проходит меньше 64 512 тактов, то каждый фронт 1→0 будет засчитан ровно один раз.
Доказательство прямое. Пусть максимальный зазор между выборками
Δ < 64 512. Окно «бит 5 = 1» длится 64 512 тактов, то есть длиннее Δ,
значит в него попадает хотя бы одна выборка → beam_prev обязательно
станет 1 внутри каждого бланка. Окно «бит 5 = 0» длится 365 568 — тем
более содержит выборку → сразу после бланка beam_prev перейдёт в 0 и
даст ровно один инкремент. Двойной счёт невозможен: инкремент
происходит только на переходе 1→0, а beam_prev тут же обновляется.
Условие ОДНО и оно про зазор, а не про нагрузку, не про DI, не про прерывания. Отсюда все свойства варианта.
Какой запас по факту. Самый длинный неделимый кусок кода без
возможности выборки — одно DI-окно акселератора, то есть один вызов
_bgi_blit_rows_raw. Он по контракту режется вызывающим на чанки
≤16 строк; при ширине 32 это ≈ 6 200 тактов, при полной высоте
спрайта 63 строки самый дорогой замеренный блит целиком — 32 073.
Даже если мерить самым грубым образом (одна выборка на целый блит,
а не на чанк), зазор вдвое меньше окна бланка.
9.4. Где ставить выборки
Правило простое: выборка обязана стоять так, чтобы ни один путь исполнения не давал зазора длиннее 64 512 тактов. По фазам:
- Зелёная и циан (436 494 и 382 770 тактов на 13/23) состоят из
блитов и хилов. Достаточно одной выборки на вызов наших обёрток
pop_blit_b,pop_heal_off,pop_heal_fast— но НЕ только их: прямые вызовыgfx_blit_*/gfx_heal*разбросаны по шести файлам (pop_cdraw.c,pop_draw.c,pop_kdraw.c,pop_room.c,pop_state.c,pop_tile.c). Точный набор точек определяем НЕ рассуждением, а замером (см. 9.7): ставим в обёртки, меряем худший зазор, добавляем точки только там, где замер их требует. - Синяя (149 106 тактов) — блитов нет вообще, это 2,3 окна бланка.
Нужны явные точки: после ввода, после физики, после
pop_process_trobs, после mob-тика. Ставятся на границах, которые и так размечены зондамиpop_dbg_m*.
Чего заведомо НЕ хватит: выборок только в главном цикле. Один
pop_floor_bake — 179 914 тактов, почти три окна бланка.
9.5. Ожидание — тот же примитив
Ожидание фронта и есть плотная выборка, поэтому оно сливается со счётчиком, а опрос клавиатуры остаётся ровно таким же плотным, как сегодня:
static void wait_edge(void)
{
uint8_t t = frame_tick;
do {
pop_beam_sample();
kbd_raw_poll(); /* то, что сейчас висит idle-хуком */
} while (frame_tick == t);
}
gfx_set_idle_hook при этом больше не нужен — опрос зовётся прямо.
Обязателен аварийный выход по счётчику попыток (как в нынешнем
gfx_wait_vsync): на железе, где бит ведёт себя иначе, цикл не должен
виснуть насмерть.
9.6. Пейсинг целиком
uint8_t k = (uint8_t)(frame_tick - anchor); /* фронтов съел рендер */
if (k >= n) {
wait_edge(); /* опоздали — выравниваемся на ближайший */
} else {
do { wait_edge(); } while ((uint8_t)(frame_tick - anchor) < n);
}
anchor = frame_tick; /* якорь по ФАКТУ, фаза не копится */
flip_page();
anchor = frame_tick, а не anchor += n — сознательно: догонять
пропущенное время нельзя, иначе после тяжёлого кадра игра рванёт
вперёд. Это же правило заложено в исходном дизайне делителя
(«выравнивание на ближайший фронт, без накопления фазовой ошибки»).
Поведение по случаям:
| работа W (растров) | период | комментарий |
|---|---|---|
| W ≤ n | ровно n | цель задачи |
| n < W ≤ n+1 | ceil(W) | подтормаживает ровно настолько, насколько не успели |
| вход в комнату, W ≫ n | ceil(W) + 1 | ветка «опоздали»: один фронт, без растягивания |
| загрузка уровня, файловые операции | ceil(W) + 1…n | выборок нет вовсе → k занижен; худшее — n лишних растров ОДИН раз |
Последняя строка — единственный случай, где счёт неточен, и он
безобиден: во время ESTEX-вызова выбирать нечего, а ошибка живёт один
кадр, потому что якорь переставляется по факту.
9.7. Как это доказывается, а не декларируется
Инструмент уже построен и проверен на нынешнем коде (§4): брейкпоинт на
следующей инструкции пишет temp3 = totalcycles, второй с условием
(totalcycles - temp3) > 0x9D800 останавливает машину.
Для приёмки он ставится на инструкцию инкремента frame_tick.
Если хоть один фронт пропущен, зазор между инкрементами станет два
растра и детектор остановит машину. Критерий: 10 000 кадров без
единого срабатывания, включая смену комнаты и смерть Кида.
Дополнительно, в отладочной сборке — перекрёстная проверка со счётчиком
кадровых прерываний (слот цепочки, один INC): прерывания теряются, луч
не должен, значит frame_tick обязан идти НЕ МЕДЛЕННЕЕ irq_tick.
Расхождение в другую сторону = пропущенная выборка.
Напоминание о методике: счёт попаданий брейкпоинтом на этом драйвере недостоверен (WAIT-линия, инструкция пересчитывается) — только детектор разрыва. И литералы в отладчике MAME шестнадцатеричные.
9.8. Цена
| статья | тактов |
|---|---|
| одна выборка (с вызовом) | ≈ 120 |
| ~60 выборок за логический кадр | ≈ 7 200 |
| доля от бюджета при n=3 (1 290 240) | 0,6 % |
В горячих местах (pop_blit_b) выборку можно заинлайнить и снять цену
вызова.
9.9. Чем это лучше счётчика прерываний
| счётчик кадровых IRQ | счётчик по лучу | |
|---|---|---|
теряет тик под di акселератора |
да, фазозависимо 0…3 % | нет — читается положение луча |
| теряет тик, если IRQ съела клавиатурная/CBL-ветка трамплина | да (приватный RETI) | нет |
| ломается от добавления звука | да (ещё один источник, irqack гасит все входы мержера) |
нет |
| поведение на полной перерисовке | 3 тика подряд мимо | считает все |
| условие корректности | никакого — не в нашей власти | зазор выборок < 64 512 тактов, проверяется артефактом |
| риск залипнуть в плохой фазе и ровно потерять 25 % скорости | есть | нет |
9.10. Что осталось проверить зондами до кодирования
- Худший зазор между выборками при размещении «только в обёртках» — сколько точек реально нужно добавить. Это же число решает, нужна ли выборка в синей фазе в четырёх местах или в двух.
- Владение
cbl_mode: сейчас бит 5 доступен потому, чтоgfx_wait_vsyncвзвёл_cbl_port_ref()при первом вызове. Свой ожидатель обязан взвести его сам — значит примитив логичнее держать в libbgi (там доступен_cbl_port_ref), а не в приложении. - Ветка
frozen(roomtest.c:320) — какой темп ей нужен. - Совпадает ли момент возврата
wait_edge()с нынешним возвратомgfx_wait_vsync()с точностью до микросекунд (иначе поедет момент свопа и появятся разрывы картинки).