# Фиксированный логический кадр — разбор перед реализацией Дата разбора: 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 — да).