604 lines
42 KiB
Markdown
604 lines
42 KiB
Markdown
# Фиксированный логический кадр — разбор перед реализацией
|
||
|
||
Дата разбора: 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 Гц, целыми делителями точнее не выйдет.
|
||
|
||
**Условие «бой» берём у оригинала буквально** — оно проще, чем кажется:
|
||
|
||
```c
|
||
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 растров
|
||
и не срабатывал никогда.
|
||
|
||
### Факты
|
||
|
||
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: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. Что проверить артефактом до начала
|
||
|
||
1. Сколько именно фронтов съедает вход в комнату — от этого зависит,
|
||
нужен ли отдельный «resync» на тяжёлых переходах или хватит того,
|
||
что якорь переставляется каждый кадр.
|
||
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()` с точностью до микросекунд (иначе поедет момент
|
||
свопа и появятся разрывы картинки).
|
||
|
||
|
||
---
|
||
|
||
# 10. РЕЗУЛЬТАТ (2026-08-19, реализовано и проверено в MAME)
|
||
|
||
Реализовано в приложении (`roomtest/pop_pace.c/.h`), в libbgi пока НИЧЕГО не
|
||
переносили — по решению пользователя: сначала обкатать у себя.
|
||
|
||
## 10.1. Что сделано
|
||
|
||
- `pop_beam_sample()` — выборка бита 5 порта `0xFE`, 10 инструкций,
|
||
быстрый путь 46 T + вызов. Модуль НЕ банковый, поэтому из банков
|
||
зовётся прямым `call` (проверено: банки так зовут `_pop_cd_hit_slot`).
|
||
- `pop_wait_edge()` — ожидание одного фронта; внутри тот же
|
||
`kbd_raw_poll()`, что раньше висел idle-хуком.
|
||
- `pop_pace_end(n)` — добрать до n фронтов от якоря; якорь ставится ПО
|
||
ФАКТУ. Главный цикл: `pop_wait_edge()` → строб вспышки →
|
||
`pop_pace_end(n)` → своп страниц.
|
||
- `pop_pace_arm()` — взводит `cbl_mode` через `gfx_wait_vsync()` и
|
||
ПРОВЕРЯЕТ, что фронты идут; если нет — `pace_ok = 0` и всё молча
|
||
откатывается на прежние `gfx_wait_vsync`.
|
||
- Режимы FASTEST/FAST/NORMAL, клавиша **P** по кругу, дефолт FASTEST.
|
||
Условие боя — `Kid.sword == SWORD_2_DRAWN`, буквально как у оригинала.
|
||
|
||
## 10.2. Где стоят выборки и как они выбраны
|
||
|
||
Точки ставились **не на глаз, а по замеру**: детектор зазора между
|
||
выборками (порог 64 512) останавливает машину, адрес возврата со стека
|
||
называет виновника. Пять итераций «замерил → закрыл дыру → перемерил»:
|
||
|
||
| итерация | найденная дыра | тактов |
|
||
|---|---|---:|
|
||
| 1 | `kid_tick` + `pop_phys_tick` без выборок | 68 340 |
|
||
| 1 | весь циан, когда оба персонажа «тихие» | 100 044 |
|
||
| 2 | отрисовка персонажей идёт мимо `pop_blit_b` | 82 242 |
|
||
| 2 | полоса HP: `pop_kid_img_blit` в цикле | 86 586 |
|
||
| 3 | между двумя `pop_blit_b` — работа `pop_bg` | 75 000 |
|
||
| 4 | сам блит и сам heal (выборка была только НА ВХОДЕ) | 71 900 |
|
||
| 5 | `pop_loose_tick` | 73 990 |
|
||
|
||
Итог: выборки в `pop_blit_b` (вход и перед каждым `gfx_w0_unmap`),
|
||
`pop_heal_fast` (вход и выход), после каждого `gfx_set_bank(SPRITE)` в
|
||
`pop_cdraw/pop_kdraw/pop_room`, в 16 потайловых функциях `pop_bg`, после
|
||
зондов `pop_dbg_p1..p8` в физике и в 21 точке главного цикла.
|
||
|
||
## 10.3. Замеры
|
||
|
||
Метод точности счётчика — **атомарный снимок одной командой отладчика**:
|
||
`printf "%d %d", totalcycles, b@<адрес pop_frame_tick>`. Раздельные
|
||
`lmem` и `print totalcycles` НЕ ГОДЯТСЯ: между двумя обращениями к мосту
|
||
проходят десятки кадров, и «недосчёт» получается на ровном месте (на этом
|
||
я сначала и обжёгся).
|
||
|
||
| проверка | результат |
|
||
|---|---|
|
||
| счётчик, 11/15 покой, 5 окон | недосчёт **0** (270 растровых кадров) |
|
||
| счётчик, 11/15 тяжёлая позиция Кида | недосчёт **0** |
|
||
| счётчик, 13/23 | недосчёт **0** |
|
||
| период кадра, 11/15 | **ровно 3 растра**: ни длиннее 3,1, ни короче 2,9 на 302 логических кадрах |
|
||
| период кадра, 13/23 | **ровно 3 растра** на 308 логических (эталон был 3/4/5) |
|
||
| режим NORMAL | ровно 4 растра |
|
||
| NORMAL + `Kid.sword = 2` | ровно 5 растров |
|
||
| клавиша P | 0 → 1 → 2 → 0 |
|
||
| клавиатура | Кид отвечает на удержание и отпускание |
|
||
|
||
**Цена выборок** — A/B прямо в памяти (заглушить `pop_beam_sample`
|
||
байтом `C9` и снять `pace_ok`, чтобы игра не зависла в ожидании фронта):
|
||
работа за кадр 543 860 с выборками против 539 832 без — **≈4 000 тактов,
|
||
0,9 %**. На фоне бюджета, который вырос втрое, это ничто.
|
||
|
||
## 10.4. Грабли, стоившие времени
|
||
|
||
1. **Литералы в отладчике MAME шестнадцатеричные.** Порог «700000» на
|
||
деле проверял 0x700000 = 17 растров и не срабатывал никогда.
|
||
2. **Счёт попаданий брейкпоинтом на этом драйвере недостоверен** (WAIT-
|
||
линия, инструкция пересчитывается): наблюдались «попаданий больше, чем
|
||
растровых кадров». Достоверны только сравнения ВРЕМЁН.
|
||
3. **Раздельные чтения через мост не атомарны** (см. 10.3).
|
||
4. **Мёртвый Кид перезапускает уровень** раз в `RESPAWN_DELAY` тиков, а
|
||
рестарт уровня — это 3-5 растров без единой выборки. Полдня я гонялся
|
||
за «дырой в статике», которой не было: Кид успел убежать в соседнюю
|
||
комнату и погибнуть, пока я мерил. **Проверяй, что на экране, прежде
|
||
чем объяснять числа.**
|
||
5. **`make LEVEL=13` без `make clean` не пересобирает** — флаги в
|
||
зависимостях не участвуют, на диск уезжает старый уровень.
|
||
|
||
## 10.5. Что осталось
|
||
|
||
- Перенос примитива в libbgi — по решению пользователя ПОСЛЕ обкатки.
|
||
Там же уместнее взводить `_cbl_port_ref` напрямую, без обходного
|
||
`gfx_wait_vsync()` в `pop_pace_arm`.
|
||
- Загрузка уровня/комнаты остаётся без выборок (ESTEX-вызовы) — счётчик
|
||
там недосчитывает. Это безобидно: якорь переставляется по факту, и
|
||
ошибка живёт один кадр. Отдельного «resync» не потребовалось.
|
||
- Проверить на реальном железе, что `pace_ok` взводится (в MAME — да).
|