Разбор перехода на фиксированный логический кадр (кода не трогали)
Замеры в MAME: кадровые прерывания теряются фазозависимо (0..3%), на полной перерисовке комнаты — три подряд. Причина: импульс запроса 32 такта (9,14 мкс) против DI-окон блита ~0,29 мс. Счёт попаданий брейкпоинтом на этом драйвере недостоверен (WAIT-линия), достоверен только детектор разрыва. Блокер включения gfx_set_fps_div как есть: счётчиковый путь ждёт через halt и не зовёт idle-хук, то есть возвращает KBD-1.
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
# Фиксированный логический кадр — разбор перед реализацией
|
||||
|
||||
Дата разбора: 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` держится всё это время).
|
||||
Reference in New Issue
Block a user