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

This commit is contained in:
2026-08-19 21:55:05 +03:00
parent 7f778bba2f
commit 61b8d80275
+220
View File
@@ -285,3 +285,223 @@ NORMAL секундомером совпадает с живым SDLPoP на о
2. Ветка `frozen` (`roomtest.c:320`) — какой темп ей нужен под делителем.
3. Проверить, что при n=4/5 бит 5 всё ещё единственный источник фронта
(то есть `_cbl_port_ref` держится всё это время).
---
# 9. Предлагаемый вариант подробно: программный счётчик кадров по лучу
Дополнение от 2026-08-19 по запросу: как именно получается **точное**
число пройденных кадровых интервалов. Акселератор рассматриваем только
в нынешнем виде — с DI/EI (режим «акселератор с EI» из новой прошивки
из рассмотрения снят).
## 9.1. Сигнал и его геометрия
Единственный источник — **бит 5 порта `0xFE`**. Точная семантика по
исходнику MAME (`sprinter.cpp`, `kbd_fe_r`):
```c
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. Счётчик
```c
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. Ожидание — тот же примитив
Ожидание фронта и есть плотная выборка, поэтому оно сливается со
счётчиком, а опрос клавиатуры остаётся ровно таким же плотным, как
сегодня:
```c
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. Пейсинг целиком
```c
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. Что осталось проверить зондами до кодирования
1. **Худший зазор между выборками** при размещении «только в обёртках»
— сколько точек реально нужно добавить. Это же число решает, нужна
ли выборка в синей фазе в четырёх местах или в двух.
2. **Владение `cbl_mode`**: сейчас бит 5 доступен потому, что
`gfx_wait_vsync` взвёл `_cbl_port_ref()` при первом вызове. Свой
ожидатель обязан взвести его сам — значит примитив логичнее держать
в libbgi (там доступен `_cbl_port_ref`), а не в приложении.
3. **Ветка `frozen`** (`roomtest.c:320`) — какой темп ей нужен.
4. Совпадает ли момент возврата `wait_edge()` с нынешним возвратом
`gfx_wait_vsync()` с точностью до микросекунд (иначе поедет момент
свопа и появятся разрывы картинки).