Files
Sprinter-SDCC/applications/PoP/roomtest/TASKS.md
T
Александр Петров 6b4a3b6b41 docs: итог KBD-1 — что лечит плотный опрос и что осталось
Ручная проверка пользователем: стало значительно лучше, но редкие пропуски
стрелок при зажатом Shift всё же ощущаются.  Счётчики на 35 нажатиях подряд
потерь не показали, то есть остаточная частота заметно ниже прежних ~15 %.
Задача отложена до финальной полировки программы (решение пользователя) —
для работы клавиатура пригодна.

Записано, где именно осталась дыра, чтобы не начинать с нуля: idle-хук
покрывает простой (~2/3 кадра), а в занятой трети DI-окно одного
accel-прохода доходит до ~650 мкс при допуске FIFO ~300 мкс — пачка байт,
целиком попавшая в такое окно, ещё может потерять байт.  Порядок действий
на возврат: вызовы между блитами занятой фазы, замер тем же счётным методом
от 50 нажатий, и только потом рычаги вне нашего кода (Scan Code Set 3 через
BIOS $EA — в MAME непроверяемо; общий m_irq_off_timer в драйвере).

Заодно сняты оговорки «плотный опрос ещё не подтверждён замером» в
kbd_raw.h и libc-reference.md — теперь там штатный рецепт через
gfx_set_idle_hook.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 16:15:21 +03:00

374 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# roomtest — доска текущих задач (обновлено 2026-08-01)
Не список багов (он в `bug_list.md`) и не план фаз (`../docs/PORT_PLAN.md`,
`../docs/layout_plan_v2.md`, `../docs/levels_plan.md`), а то, **что берём в
работу сейчас и в каком порядке**. Каждая запись: что сделать, почему
именно сейчас, чем подтверждать результат.
Правило проекта в силе: механику сверять с `../SDLPoP/src/` ДО кодинга;
диагноз платформы подтверждать артефактом (брейкпоинт/дамп/.asm), а не
гипотезой (memory `defer_unexplained_quirks`).
---
## P0 — делаем сейчас
*(KBD-1 закрыт до финальной полировки — см. ниже; следующая в работе — CLIP-1.)*
### KBD-1. Shift + стрелки: нажатия теряются — **ПРИЧИНА НАЙДЕНА, ФИКС ПРОВЕРЕН**
> **Итог (2026-08-01).** Причина — не наш код и не DI-окна графики: при
> зажатом Shift PS/2 удваивает трафик («fake shift»), а импульс запроса
> прерывания здесь теряется примерно в 44 % случаев, и трёхбайтовый FIFO
> SIO переполняется. Лечится ПЛОТНЫМ опросом: `kbd_raw_poll` повешен
> idle-хуком на ожидание кадра (`gfx_set_idle_hook`, новый API libbgi) —
> процессор всё равно проводит там ~42 мс из 60, крутя опрос луча.
>
> **Проверка в roomtest тем же счётным методом: 35 нажатий Shift+Home →
> 35 make, ноль потерь** (до фикса — 9 из 10). Боевой сценарий тоже:
> четыре Shift+→ подряд дали четыре осторожных шага, `Kid.x` 114 → 147.
> Цена: `_CODE` +170 Б, кадровый бюджет не затронут (опрос стоит в
> простое). Ниже — полный протокол, как к этому пришли.
>
> **ОСТАТОК (ручная проверка пользователем, 2026-08-01): «стало значительно
> лучше, но иногда при зажатом Shift стрелка всё-таки пропускается».**
> Ощущение, не замер — счётчики на 35 нажатиях подряд потерь не показали,
> значит остаточная частота заметно ниже прежних ~15 %. **Задача осознанно
> ОТЛОЖЕНА до финальной полировки всей программы** (решение пользователя);
> сейчас клавиатура пригодна для работы.
>
> **Где именно осталась дыра — чтобы на полировке не начинать с нуля.**
> Idle-хук покрывает простой, то есть ~2/3 кадра. Оставшаяся треть — это
> занятая фаза, и там DI-окно одного accel-прохода доходит до ~650 мкс при
> допуске FIFO ~300 мкс. Пачка байт, целиком попавшая в такое окно, всё
> ещё может потерять байт — ровно «иногда». Порядок действий, если
> вернёмся:
> 1. Вернуть вызовы `kbd_raw_poll()` между блитами занятой фазы (они
> бесплатны; сами по себе не помогали, но вместе с хуком закрывают
> именно этот зазор) и при необходимости внутрь тайловых циклов
> `pop_bg` — тогда слепым остаётся только тело одного блита.
> 2. Мерить тем же счётным методом (см. ниже), а не на ощупь: скриптовые
> нажатия ровнее человеческих, поэтому набирать выборку от 50 нажатий.
> 3. Если и это не добьёт — остаются два рычага вне нашего кода: Scan Code
> Set 3 через BIOS `$EA` (убирает «fake shift» в корне, но в MAME
> непроверяемо — обратный путь к клавиатуре не разведён) и общий
> `m_irq_off_timer` в драйвере MAME.
**Симптом (пользователь, 2026-08-01).** Залипаний почти нет, но при
УДЕРЖИВАЕМОМ Shift первые один-два нажатия ← дают осторожные шаги, дальше
нажатия ← не отрабатываются, пока Shift не отпустишь.
**Рабочая гипотеза (механизм, а не догадка «что-то с клавиатурой»).**
Три известных факта складываются в одну картину:
1. **PS/2 Set 2, «fake shift».** При зажатом Shift нажатие РАСШИРЕННОЙ
клавиши (стрелки — `E0`-коды) обрамляется фиктивным отпусканием/нажатием
шифта: нажатие ← шлёт `E0 F0 12` + `E0 6B` = **5 байт** (без шифта было
бы 2), отпускание — `E0 F0 6B` + `E0 12` = **5 байт** (было 3). То есть
ровно в связке Shift+стрелка трафик удваивается.
2. **Приёмный FIFO SIO — 3 байта.** Пачка в 5 байт переживает только то,
что мы успеваем вычерпывать её по ходу. Потерянный make стрелки =
«нажатие не отработало»; потерянный break = залипание (его лечит
`kbd_raw_sync`, но ценой сброса всех немодификаторных клавиш).
3. **Импульс IRQ клавиатуры в MAME живёт 32 такта CPU.**
`mame/sources/MAME/src/mame/sinclair/sprinter.cpp`: `on_kbd_data()`
выставляет `m_irqs->in_set<1>()` НА КАЖДЫЙ принятый байт (то есть старая
запись в `docs/TODO.md` «MAME не даёт per-byte INT» — неверна), но тут же
заводит `m_irq_off_timer` на 32 такта, а `irq_off()` снимает линию.
**Если в эти 32 такта мы под `DI` — прерывание пропало насовсем**, байт
остаётся в FIFO до следующего IRQ (следующий байт или кадровый 50 Гц).
4. **Наши DI-окна длинные.** Ядра акселератора держат `di` на ВЕСЬ блит
(`libbgi/bgi256/_bgi_blit_cols_raw.c:47` — «один DI на весь блит»);
порядок цены прохода — 13.6 К тактов (`libbgi/include/gfx.h`), это
сотни микросекунд, на порядки больше 32-тактового импульса.
Отсюда: **обе версии пользователя — про одно и то же.** Логика ввода
(`pop_ctrl.c`, порт `read_user_control`/`safe_step`) сверена с SDLPoP и
выглядит корректной: `safe_step()` ставит `control_forward = CONTROL_IGNORE`,
и это снимается в `read_user_control()` при ОТПУСКАНИИ стрелки — то есть
повторные тапы ← при зажатом Shift обязаны работать. Не работают они
потому, что до нас не доезжает либо make, либо break стрелки.
**План проверки — по шагам, каждый даёт артефакт:**
1. Счётчики в MAME: брейк на `_kbdraw_overrun` (запись) и на ветке
`tr_kbd_drain` — сколько overrun'ов за 10 с при «Shift зажат, тапаю ←»
против «тапаю ← без Shift». Ожидание по гипотезе: с Shift кратно больше.
2. Замер максимального DI-окна кадра: брейкпоинты на `di`/`ei` в
`_bgi_blit_cols_raw` + `{printf totalcycles; g}` — получить реальную длину
в тактах и в микросекундах.
3. **Спайк «блит без DI».** `docs/new/06-accel.md §6.6`: новая прошивка
допускает работу акселератора при EI (по приходу прерывания он
отключается, по `RETI` включается обратно); старая — нет. Собрать libbgi
с убранным `di` в блит/heal-ядрах, прогнать roomtest в MAME: (а) не
рушится ли картинка, (б) падает ли счётчик overrun из п.1. Если да —
причина подтверждена, и дальше это вопрос «какая прошивка на живом
железе» (по умолчанию оставить DI, режим без DI — опцией libbgi).
4. **Независимо от п.3 — `kbd_raw_poll()`.** Вычерпывание FIFO ОПРОСОМ
(порт `0x19` бит 0 → читать `0x18`, тот же декодер make/break, что в
трамплине) из главного цикла 2–4 раза за кадр между фазами `PROF()`.
Снимает зависимость от «поймали ли мы импульс IRQ» вообще, стоит сотни
тактов, графику не трогает. Реализация: вынести drain-цикл из
`libc/irq/_irq_tramp.c` в общий кусок либо продублировать в
`libc/kbd/kbd_raw_poll.c`; тело обязано идти под `DI` (гонка с ISR за
деструктивное чтение порта 0x18).
5. Побочно сюда же играет **T-2 (idle-skip)** из `bug_list.md`: не
перерисовывать Кида, пока поза/координаты не менялись, — это минус
heal+blit (то есть минус DI-окна) в самых спокойных кадрах, где как раз
и тапают Shift+стрелку.
6. Только если после 3–4 симптом жив — копать логику
`control_shift2`/`CONTROL_IGNORE` против `seg005.c:374..390`.
**Критерий готовности:** при зажатом Shift десять тапов ← дают десять
осторожных шагов (проверка в MAME через `:kbd:ms_naturl:*` напрямую, НЕ
через `press_key` — тот дёргает обе клавиатуры, см. `docs/libc-reference.md`
`<kbd_raw.h>`).
---
### KBD-1: ЧТО ИЗМЕРЕНО (сессия 2026-08-01) — гипотеза про DI НЕ подтвердилась
**Методика.** Симптом «нажатие не отработало» переведён в счётчики, чтобы не
спорить с глазами. Нажимается **Home** — тоже расширенная клавиша (тот же
`E0`-префикс и тот же «fake shift», что у стрелок), но игрой игнорируется,
поэтому Кид стоит на месте и рельеф комнаты на результат не влияет.
Брейкпоинты с действием `{ b@ADDR = b@ADDR + 1 ; g }` (счёт без остановки
машины) в трёх точках: вход клавиатурной ветки трамплина, чтение порта 0x18
внутри drain-цикла, запись make-бита для кода `0x6C`. Скратч-байты — хвост
`ovr_tile[]` (в этом сценарии не используется).
**Симптом воспроизведён скриптом:** при зажатом Shift 10 нажатий → до
декодера дошло 9 make-байт. Без Shift потерь нет — ровно как сообщил
пользователь.
| Прогон | make дошло / нажато | overrun |
|--------|---------------------|---------|
| игра идёт, `kbd_raw_poll` ВКЛ | 9 / 10 | 3 |
| игра идёт, `kbd_raw_poll` ВЫКЛ (патч `ret` в точке входа) | 9 / 10 | 4 |
| игра ЗАМОРОЖЕНА клавишей «1» (блитов нет вообще, значит и длинных DI нет) | **8 / 10** | 6 |
**Вывод 1: наши DI-окна ни при чём.** В замороженном кадре, где блитов нет
и прерывания разрешены практически всё время, потерь НЕ меньше, а больше.
**Вывод 2: `kbd_raw_poll()` в текущей расстановке бесполезен** — 9/10 и с
ним, и без. Причина понятна задним числом: шесть вызовов стоят В ТЕХ ЖЕ
точках, где прерывания и так разрешены, то есть добавляют ровно то, что
трамплин сделал бы сам. Вызовы из `roomtest.c` убраны; сама функция в libc
оставлена — она корректна и нужна как заготовка под «плотный опрос» (см.
ниже), но в горячем цикле её держать не за что.
**Вывод 3 (главный): байт теряется НИЖЕ нашего кода.** Счётчик чтений порта
0x18: 5 нажатий Shift+Home должны дать ровно 50 байт (нажатие `E0 F0 12` +
`E0 6C`, отпускание `E0 F0 6C` + `E0 12` = по 10 на цикл). Насчитано **49**
— и ровно один make потерян. То есть до процессора байт не доехал вообще,
декодер тут ни при чём.
**Вывод 4: прерывание на байт теряется примерно в 44 % случаев.** На тех же
49 прочитанных байтах — только **28 входов** в клавиатурную ветку трамплина
(1.75 байта за вход). То есть больше сорока процентов импульсов запроса
не были обслужены, и байты копятся в трёхбайтовом FIFO вплотную к его
потолку; одна неудачная пауза — и байт потерян.
### KBD-1: ПОТОЛОК ПЛОТНОГО ОПРОСА ИЗМЕРЕН — приём лечит полностью
`tests/kbdpoll` — программа, которая не делает НИЧЕГО, кроме
`kbd_raw_poll()` в бесконечном цикле (ни графики, ни vsync, ни вывода:
любая работа разредила бы опрос и испортила замер). Это физический
максимум плотности. Тот же счётный метод, те же брейкпоинты-счётчики.
| Прогон | нажатий | make дошло | байт прочитано / ожидалось |
|--------|---------|-----------|-----------------------------|
| контроль: Shift зажат 4 с, нажатий нет | 0 | 0 | 0 (Shift сам ничего не шлёт — автоповтора у модификатора нет) |
| Shift + Home | **25** | **25** | **250 / 250** |
**Ни одного потерянного байта.** Для сравнения: в игре при шести вызовах
за кадр терялся 1 байт из 50. При такой частоте потерь вероятность
случайно получить ноль потерь на 250 байтах ≈ 0.6 %, так что результат не
совпадение.
**Вывод: опрос — рабочее решение, вопрос только в ПЛОТНОСТИ.** Нужно
опрашивать примерно раз в 0.5 мс (≈10 000 тактов), а шесть вызовов за
60-мс кадр давали один раз в 10 мс — в двадцать раз реже необходимого.
**Где взять частоту:** логический тик = 60 мс, из них ~18 мс занято
работой и **~42 мс процессор простаивает внутри `gfx_wait_vsync`**, опрашивая
луч. Опрос там стоит ноль и покрывает две трети периода с запасом по
плотности. Остаётся слепым только тело одного accel-блита под DI (до
~650 мкс) — разорвать его нельзя (см. «что НЕ делать»).
**Почему нужна именно такая частота (вопрос «PS/2 же не даёт больше 30
нажатий в секунду»).** Частота опроса определяется НЕ темпом нажатий, а
темпом байт ВНУТРИ одного нажатия и глубиной FIFO. Одно нажатие при
зажатом Shift — это 5 байт подряд (`E0 F0 12`, `E0 6C`), отпускание — ещё 5,
и клавиатура выдаёт их со скоростью провода: 11 бит на байт при ~10–16 кГц
= ~0.7–1.1 мс на байт. Воронка — 3 байта. Значит между двумя вычерпываниями
имеют право прийти максимум два байта, то есть вычерпывать надо не реже чем
раз в ~1.5–2 мс (0.5 мс взято с запасом). **Даже ОДНО нажатие в секунду
переполнит FIFO**, если в эти несколько миллисекунд его никто не разгребает.
Замер это подтверждает: 1.75 байта за одно вычерпывание — уже 58 % ёмкости.
В норме разгребает прерывание; опрос понадобился только потому, что ~44 %
импульсов здесь теряется.
**Альтернатива, которая убирает опрос совсем — уменьшить трафик, а не
ускорять разгребание.** BIOS `$EA` (`FN_KBD_OUT`, `docs/new/09-input.md`
§9.2) шлёт байт НА клавиатуру, то есть ей можно скомандовать:
- **Scan Code Set 3** — нет ни «fake shift», ни `E0`-префиксов: make = 1 байт,
break = 2. Нажатие с шифтом перестаёт превышать FIFO в принципе.
- либо хотя бы отключить typematic (`0xF5`/`0xF7`).
**Но проверить это в MAME НЕЛЬЗЯ:** в `sprinter.cpp` подключено только
направление клавиатура→SIO (`m_kbd->out_data_cb() → rxa_w`); обратный путь
(SIO→клавиатура) не разведён вовсе, так что команда просто уйдёт в никуда.
Плюс пришлось бы переписать все наши константы кодов под Set 3. Значит это
кандидат на «когда дойдём до реального железа», а не на сейчас.
**Что сделано по этому плану (2026-08-01):**
1. ✅ Idle-хук в libbgi: `gfx_set_idle_hook(fn)`, вызывается в цикле ожидания
луча внутри `gfx_wait_vsync`. Приложение ставит туда `kbd_raw_poll`.
Полезен не только нам — любой программе даёт «качать» что-то в ожидании
кадра. Осторожно с регистрами: цикл ждёт на BC-таймауте, вокруг вызова
нужен push/pop, а сам таймаут в итерациях станет длиннее по времени.
**Важно про цену: это НЕ новая нагрузка.** Опрос ставится ровно туда,
где процессор и так впустую крутит `in a,(#0xFE)` — 42 мс из 60. Полезной
работы не отнимается нисколько.
**Ограничитель области, если «постоянный опрос» всё равно не нравится:**
потери случаются ТОЛЬКО при зажатом модификаторе (замерено; без Shift
потерь нет). Значит хук можно взводить лишь пока нажат Shift/Ctrl/Alt —
тогда опрос работает исключительно в той ситуации, ради которой заведён.
2. ✅ Перемерено в roomtest тем же счётным методом: **35/35**, потерь нет.
3. ⏳ Вызовы в занятую треть кадра НЕ возвращены: одного idle-хука хватило.
Держать в уме, если на реальном железе или на более тяжёлых сценах
(несколько стражей) потери появятся снова — накрыть блиты дешевле, чем
изобретать что-то новое.
4. ⏳ Ручная проверка пользователем — без неё этап не закрыт: скриптовые
нажатия ровнее человеческих, и «залипания до отпускания Shift» они не
воспроизводили с самого начала.
**Куда смотреть дальше, если плотного опроса не хватит.**
1. **Драйвер MAME — НЕ ТРОГАЕМ** (решение пользователя: пересборка MAME на
его машине занимает часы). Для протокола, подозрение осталось:
`sinclair/sprinter.cpp` держит запрос от клавиатуры ровно **32 такта
CPU**, и `m_irq_off_timer`**один на два источника** (`irq_on()` экрана
заводит его же, `irq_off()` гасит разом обе линии). То есть кадровое
прерывание способно обрезать клавиатурный импульс — правдоподобное
объяснение «44 % пропущенных импульсов».
2. **Реальное железо.** Если п.1 — чисто эмуляционный артефакт, на железе
проблемы может не быть вовсе. Проверять при первом прогоне на живом
Sprinter.
**Про совпадение кадрового и клавиатурного прерываний** (вопрос
пользователя, 2026-08-01). Документация Sprinter: оба приходят с вектором
`0FFh`, различать по биту приёма байта в порту клавиатуры — «не пришёл,
значит экран»; совпадение возможно, но «исключительно редкий случай»
(в новой версии обещают развести жёстче через ПЛМ). То есть наш трамплин
делает ровно предписанное. Известный побочный эффект: при совпадении мы
обслуживаем клавиатуру и `reti`, пропуская кадровую цепочку и DSS — на
потерю байт это не влияет (линия кадрового остаётся взведённой и вызывает
повторный вход), но кадровый тик может пропасть. Отдельная мелкая правка,
в KBD-1 не входит.
**Что НЕ делать (проверено, стоило времени):**
- **Снимать `di` в accel-ядрах libbgi нельзя.** Патч `di``nop` в
`_bgi_blit_cols_raw`/`_bgi_heal_rows_raw`/`_bgi_blit_rows_raw` прямо в
памяти **уронил машину в перезагрузку**. То есть режим «акселератор
работает при EI» из `docs/new/06-accel.md §6.6` в этой прошивке/эмуляции
недоступен — вопрос закрыт артефактом, а не рассуждением.
- Дробить DI-окна по колонкам смысла тоже нет: см. вывод 1.
---
### CLIP-1. Аудит блитов: где клип не нужен
**Зачем сейчас.** Кадр занят на ~86 %; подготовка клипающего варианта
стоит ~5.6 К тактов на вызов, а общее ядро против линейного — 13 288 против
4 617 тактов на спрайт 32×3 (`libbgi/include/gfx.h`). Это самая дешёвая
оставшаяся оптимизация: не переписывание логики, а выбор ядра.
**Что уже правильно** (шаблон, который надо распространить): блиты Кида,
стража, клинка и брызг спрашивают `pop_onscreen_cols()` и уходят в
`gfx_blit_cols_part_noclip`, иначе в клипающий вариант
(`pop_kid.c:86,591,676`, `pop_gdraw.c:105`).
**Что чинить:**
- `kid_heal()` (`pop_kid.c:613,615`) и `pop_guard_heal()`
(`pop_gdraw.c:65,66`) зовут `gfx_heal`**всегда с клипом**, хотя
`gfx_heal_noclip` существует и `pop_bg.c:147` им уже пользуется по тому же
тесту. Это heal 2–4 прямоугольников КАЖДЫЙ кадр; замер общего ядра —
11 658 тактов на heal 22×22. Тест onscreen у нас уже посчитан рядом.
- `pop_room_clip_borders()` (`pop_bg.c:1193,1194`) — `gfx_heal(0,0,320,…)`:
noclip требует w,h ≤ 255, значит либо два куска по 160, либо оставить как
есть (зовётся по гейту, только в кадрах падения — проверить, что гейт
действительно редкий, прежде чем трогать).
**Что обязано остаться с клипом** (зафиксировать в комментарии, чтобы потом
не «оптимизировать» повторно):
- кромочные тайлы фона `pop_bg.c:132` — тайл у края экрана режется по
построению;
- спрайты при straddle (`kid_render_dx = ∓140`, комната Кида ≠ отрисованной)
и при падении ниже поля — фолбэки `pop_kid.c:594,681`, `pop_gdraw.c:107`.
**Порядок работы:** (1) выписать полный список вызовов
`gfx_blit*`/`gfx_heal*` в roomtest с ответом «кто гарантирует on-screen»;
(2) перевести то, что можно, на noclip по существующему тесту; (3) замерить
кадр полосами бордюра ДО/ПОСЛЕ (`PROF()` уже в `roomtest.c`) и брейкпоинтом
на конкретной функции — числом, а не «стало плавнее»; (4) `make size-check`.
---
## P1 — сразу после P0 (закрываем уровень 1 как ИГРУ, а не стенд)
### L1-START. Старт по данным уровня
`roomtest.c` жёстко стартует `START_ROOM 1 / COL 3 / ROW 0`, хотя
`pop_level_start_room()` / `pop_level_start_pos()` / `pop_level_start_dir()`
в `pop_level.h` уже реализованы и НИКЕМ не вызываются. Перевести старт и
респавн на них; `#define ROOMNAV` оставить, но выключенным по умолчанию.
### L1-EXIT. Выход с уровня (дверь уровня)
Сейчас: дверь открывается (`animate_leveldoor`), но войти в неё нельзя —
`up_pressed()` (`pop_ctrl.c:188`) не проверяет `tiles_16_level_door_left`, а
опкод `0xF1 END_LEVEL` в `play_seq` (`pop_kid.c:418`) пустой. Портировать
`up_pressed`-ветку + `go_up_leveldoor()` (`seg005.c:410..500`) и завести
`pop_next_level`, который взводит `END_LEVEL` (`seg006.c:662`). **Это же
первый шаг плана следующих уровней** — см. `../docs/levels_plan.md`.
### L1-TRIAGE. Ревизия `bug_list.md`
Список отстал от кода: BUG-1/BUG-2 (боковой переход, ping-pong) закрываются
`pop_leave_timer` + `char_x_forward_edge` (`pop_map.c:1402..1432,1553`),
BUG-3 (окклюзия climb-up на кнопке) — фиксом `tile_code_drawn` от 2026-07-28,
но все три по-прежнему стоят как **Critical**. Пройти их в MAME, закрыть
подтверждённые, оставшиеся (BUG-CEIL-1/2/3, BUG-OCCL-1 — косметика окклюзии)
переклассифицировать. Заодно закрыть таблицу обхода 24 комнат — она
заполнена на 5 строк из 24, а инструмент (`ROOMNAV`) готов.
### L1-SPEED. Игра идёт быстрее оригинала (найдено 2026-08-01)
Сверка таймингов: оригинал — `BASE_FPS = 60` при `base_speed = 5` тиков на
логический кадр (`SDLPoP/src/types.h:1373`, `data.h:869`) = **83.3 мс**, в бою
`fight_speed = 6` = **100 мс**. У нас `roomtest.c` ждёт **три** `gfx_wait_vsync()`
= 60 мс, и отдельной скорости боя нет — то есть примерно **+39 % к скорости
эталона**. Соответствие: 4 ожидания (80 мс) обычно, 5 (100 мс) в бою.
**Делать ПОСЛЕ CLIP-1**: замедление кадра спрячет проблемы бюджета вместо
того, чтобы их показать. Проверка — секундомером по одинаковому отрезку
рядом с живым SDLPoP, не «на глаз».
### L1-PASS. Сквозное прохождение уровня 1
От старта до двери уровня одним заходом: подбор меча, страж, кнопки/ворота,
пики, loose-полы, зелье, падения. Это приёмка этапа 1 и одновременно
регресс-база для уровня 2.
---
## Отложено осознанно (не брать, пока не появится причина)
- **Звук** (CBL-эффекты, Фаза 5 `PORT_PLAN.md`) — геймплей не блокирует.
- **Таймер уровня / HUD времени / меню / сохранения** — Фаза 6.
- **T-1** (пики: перерисовка по причине) — `bug_list.md`; отдаётся почти
бесплатно после T-2, отдельно не окупается.
- **BUG-CEIL-2** (loose-плита в потолке из комнаты сверху) — требует
персистентного per-room modifier соседей, это Фаза P0 из
`../docs/gates_spikes_plan.md`.
- **Отключение мыши на время игры** и **замена PRNG**
`../docs/ideas_backlog.md` (оба дают доли процента кадра).
- **OPT-1** (хирургический редрой шва) — стоимость транзиентная, решение от
2026-07-22 «оставляем».