Files
Sprinter-SDCC/applications/PoP/roomtest/TASKS.md
T
Александр Петров 6aefee0cc7 docs: TASKS — цель «уровни 1-3 (подземелье)», разбор что нужно уровню 3
Palace (уровни 4+) отложен решением 2026-08-04.  На доску вынесены три
задачи уровня 3, снятые с данных и SDLPoP, а не с общих соображений:

- L3-CHOMP: 5 чомперов (комнаты 5, 16×3, 22); шаблон как у пик/ворот,
  риск — банк 2 занят на 86.5%.
- L3-SKEL: в данных уровня 3 стражей НЕТ ВООБЩЕ; единственный враг —
  скелет, и он спецсобытие check_skel (seg002:1044), а не страж из
  данных.  Нужен новый атлас (data/SKEL, 29 файлов) — pop_pack_guard.py
  прибит к GUARD/.
- L3-CHKP: чекпойнт (seg002:519 + seg003:141); hitp_beg_lev уже есть.

Плюс инвентарь тайлов по уровням: ур. 3 вводит только chomper(18),
palace-набор (lattice*) начинается с уровня 4 — отсюда и граница скоупа.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:38:43 +03:00

616 lines
51 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-04)
Не список багов (открытые — [`bug_list.md`](bug_list.md), закрытые с разбором
корней — [`bug_closed.md`](bug_closed.md)) и не план фаз
(`../docs/PORT_PLAN.md`, `../docs/layout_plan_v2.md`, `../docs/levels_plan.md`),
а то, **что берём в работу сейчас и в каком порядке**. Каждая запись: что
сделать, почему именно сейчас, чем подтверждать результат.
**Открытых багов нет** ([`bug_list.md`](bug_list.md)): остались незакрытые
оптимизации [T-1](bug_list.md#t-1), [T-2](bug_list.md#t-2) и незаконченная
[таблица обхода 24 комнат](bug_list.md#обход-всех-24-комнат-уровня-1).
Правило проекта в силе: механику сверять с `../SDLPoP/src/` ДО кодинга;
диагноз платформы подтверждать артефактом (брейкпоинт/дамп/.asm), а не
гипотезой (memory `defer_unexplained_quirks`).
---
## ТЕКУЩАЯ ЦЕЛЬ: уровни 1–3 (подземелье) отлажены целиком
Решение 2026-08-04: **palace (уровни 4+) откладываем**, доводим до
играбельности три dungeon-уровня. Основание — они не требуют ни одного
нового ассета фона: инвентарь тайлов, снятый с `res200N.bin`, показывает,
что новое появляется только так —
```
ур. 1 empty, floor, spike, pillar, gate, closer, potion, loose, debris,
opener, level_door L/R, torch, wall, skeleton, sword ← всё есть
ур. 2 bigpillar_bottom(8), bigpillar_top(9), doortop(12) ← есть (2026-08-04)
ур. 3 chomper(18) ← НЕТ механики
ур. 4 lattice_pillar(25)…lattice_right(29) + тайлсет palace ← отложено
```
Порядок работ — ниже: **L3-CHOMP → L3-SKEL → L3-CHKP** (уровень 3 в
принципе не проходится без них), параллельно **L1-SPEED** (общий для всех
уровней) и приёмочные прогоны **L1-PASS / L2-PASS**.
| Задача | Что | Блокирует |
|--------|-----|-----------|
| [L3-CHOMP](#l3-chomp) | чомперы (5 шт) | прохождение ур. 3 |
| [L3-SKEL](#l3-skel) | скелет (единственный противник ур. 3) | прохождение ур. 3 |
| [L3-CHKP](#l3-chkp) | чекпойнт ур. 3 | корректный респавн ур. 3 |
| [L1-SPEED](#l1-speed) | игра на ~39 % быстрее оригинала | ощущение от ВСЕХ уровней |
| [L1-PASS](#l1-pass) / L2-PASS | сквозные прогоны + таблица 24 комнат | приёмка |
| [DBG-CHEATS](#dbg-cheats) | `[`/`]` — подгонка Кида по X | отладка (BUG-GATE-PASS-1) |
---
## P0 — делаем сейчас
### <a id="l3-chomp"></a>L3-CHOMP. Чомперы — механика уровня 3
**Где:** 5 штук, комнаты 5(тайл 4), 16(23,24,25), 22(26). Дальше они почти
на каждом уровне, так что это вложение не только в ур. 3.
**Что портировать:** `animate_chomper` (seg007) — состояние в `room_modif`,
как у пик/ворот, значит шаблон уже отработан (`gates_spikes_plan.md`);
коллизия и смерть Кида в сомкнутых челюстях (`seg004`/`seg006`); отрисовка
кадра по модификатору (`draw_tile_anim`, ветка `tiles_18_chomper`).
**Риск:** банк 2 (`pop_bg`) занят на **86.5 %, свободно 2213 Б** — считать
место ДО кодинга (`levels_plan.md` §5.1), иначе повторится «банк 2 упёрся
в потолок» (коммит 2f3e854). Свободные номера банков есть (5+).
### <a id="l3-skel"></a>L3-SKEL. Скелет — единственный противник уровня 3
**Важно:** в данных уровня 3 **стражей нет вообще** (`guards_tile` пуст во
всех 24 комнатах). Единственный враг — скелет, и он не «страж из данных»,
а **спецсобытие** `check_skel` (seg002:1044):
> в комнате 1, когда `Kid.curr_col` == 2 или 3 и дверь уровня открыта,
> тайл `tiles_21_skeleton` (комната 1, тайлпос 15) стирается в пол, а на
> его месте поднимается персонаж: `charid_4_skeleton`, меч сразу вынут,
> `seq_88_skel_wake_up`, skill 2, HP 3.
Ещё два тайла скелета (комнаты 17 и 19) — декорация, они не оживают.
**Что нужно:**
- `charid_4_skeleton` в `enter_guard`/`pop_guard_enter` (seg002:196): при
`tbl_guard_type[level] == 2` персонаж поднимается **с вынутым мечом** и
последовательностью `seq_63_guard_active_after_fall`, а не
`seq_77_guard_stand_inactive`;
- скелет **бессмертен** — чит `K` его уже не берёт (`pop_guard_kill`
проверяет `CHARID_4_SKELETON`), но и боёвка должна возвращать его к
жизни (seg002:252);
- **новый атлас**: `../SDLPoP/data/SKEL/` (29 файлов) — `pop_pack_guard.py`
сейчас прибит к `GUARD/`, нужен параметр набора (`tbl_guard_dat` =
GUARD/FAT/SKEL/VIZIER/SHADOW).
### <a id="l3-chkp"></a>L3-CHKP. Чекпойнт уровня 3
`level3_set_chkp` (seg002:519): вход в комнату 7 ставит `checkpoint = 1` и
`hitp_beg_lev = hitp_max`. `do_startpos` (seg003:141) при `checkpoint`
подменяет старт: комната 2, тайлпос 6, направление влево, и убирает
loose-плиту (комната 7, колонка 4, ряд 0). Механика `hitp_beg_lev` уже
есть (сделана в L2), остаётся сам флаг и подмена старта.
### 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](bug_list.md#t-2) (idle-skip)**: не
перерисовывать Кида, пока поза/координаты не менялись, — это минус
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. Аудит блитов: где клип не нужен — **СДЕЛАНО 2026-08-01**
> **Итог.** Heal Кида и стража переведены на выбор ядра по тому же тесту,
> что давно стоит у блитов. Замер в MAME (счётчики `totalcycles` на входах
> `kid_heal` и `kid_tick`, то есть вся группа heal за кадр; комната 1, Кид
> стоит, стража нет):
>
> | путь | тактов на кадр | кадров в выборке |
> |------|----------------|------------------|
> | клипающее ядро (как было) | **26 200** | 149 |
> | noclip (стало) | **15 848** | 239 |
>
> **−10 352 такта на кадр, то есть −39.5 % с группы heal** (≈0.49 мс при
> ~21 МГц). A/B честный: оба замера сняты в ОДНОМ прогоне, вторая половина —
> с пропатченным в памяти условием (`jr nz` → `jr` в `pop_heal_fast`), то
> есть на той же геометрии и в той же сцене.
>
> **Размер: −362 Б суммарно** (не плюс!): `_CODE` 25 289 → 25 306 (+17),
> BANK2 13 792 → **13 676** (116, свободно стало 2708 Б — это тот самый
> тесный банк из рисков `levels_plan.md` §5), BANK3 6512 → 6249 (263),
> BANK4 без изменений.
>
> **Грабли, стоившие двух пересборок** (вынесено в память
> `sdcc-static-inline-double-cost`): первым заходом хелпер был `static
> inline` в `pop_bg.h` — и SDCC 4.5 И встроил его тело (181 Б) в каждое
> место вызова, И оставил отдельную копию в КАЖДОМ TU, который видит
> заголовок. `pop_guard_heal` раздулся с ~60 до 663 Б, итого +1091 Б в
> `_CODE` и +636 Б в банке стража. Лечится обычной функцией в одном
> резидентном модуле (`pop_draw.c`, W1 — из банков это прямой `call` без
> трамплина, как у `pop_sword_draw`).
>
> **Проверено визуально:** обычная ходьба, прыжок, спуск и позиция «за
> решёткой шва» (straddle — там как раз работает клипающий фолбэк) —
> артефактов и следов нет.
**Ниже — исходная постановка задачи (что и почему смотрели).**
**Зачем сейчас.** Кадр занят на ~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_guard_heal()` звали `gfx_heal` — **всегда с
клипом**, хотя `gfx_heal_noclip` существует и `heal_off` в `pop_bg.c` им
уже пользовался. Это heal 2–4 прямоугольников КАЖДЫЙ кадр; замер общего
ядра — 11 658 тактов на heal 22×22. Теперь все три идут через общий
`pop_heal_fast` (`pop_draw.c` + `_pop_draw.h`).
-`pop_room_clip_borders()` (`pop_bg.c`) — `gfx_heal(0,0,320,…)`:
**оставлен клипающим осознанно**. Полоса шириной 320 не лезет в 8-битный
параметр noclip-ядра, а бить её на два куска по 160 нет смысла: гейт
`border_dirty` пускает туда только в кадрах падения, и выигрыш подготовки
тонет в цене самих 320×28 пикселей. Причина записана прямо в коде, чтобы
не «оптимизировать» повторно.
**Что обязано остаться с клипом** (зафиксировано комментариями в коде):
- кромочные тайлы фона (`blit_b` в `pop_bg.c`) — тайл у края экрана режется
по построению;
- спрайты при straddle (`kid_render_dx = ∓140`, комната Кида ≠ отрисованной)
и при падении ниже поля — фолбэки в `kid_draw`/`pop_guard_draw`;
- борта поля (`pop_room_clip_borders`) — см. выше.
**Что уже было правильно** (шаблон, который и распространили): блиты Кида,
стража, клинка и брызг спрашивают `pop_onscreen_cols()` и уходят в
`gfx_blit_cols_part_noclip`. Отдельный случай — `pop_kid_img_blit`: noclip
БЕЗ проверки, потому что единственный вызывающий (полоса HP) рисует по
фиксированным координатам; это тоже помечено в коде.
---
## P1 — сразу после P0 (закрываем уровень 1 как ИГРУ, а не стенд)
### L1-START. Старт по данным уровня — **СДЕЛАНО 2026-08-01**
Старт и оба рестарта (смерть, выпадение из уровня) сведены в один
`pop_start_level()` — порт `start_level` + `do_startpos` + `set_start_pos`
(seg003): комната/тайл/направление берутся из `pop_level_start_*`, направление
инвертируется (`~start_dir`), поза входа — из `tbl_entry_pose`. У уровня 1
это «падение внутрь» плюс нажатие кнопки room5(0,2) — то самое, что
захлопывает решётку за спиной. Проверено: старт даёт room 1, col 0, падение
на row 1 — как по данным уровня.
`#define ROOMNAV` оставлен ВКЛЮЧЁННЫМ осознанно: это наш чит, которого в
оригинале не было, — как и `S` (выдать меч), `K`, `I`. Все они со временем
съедутся в общий блок читов, разрешаемый в настройках (решение 2026-08-01).
### L1-EXIT. Выход с уровня (дверь уровня) — **СДЕЛАНО 2026-08-01**
Портированы: ветка двери уровня из `up_pressed` + `go_up_leveldoor`
(seg005:0482/0574) — в `pop_leveldoor_enter()` (`pop_map.c`, тайлы и
геометрия) и `up_pressed()` (`pop_ctrl.c`, только последовательность);
опкод `0xF1 END_LEVEL` в `play_seq` теперь инкрементит `pop_next_level`
(порт `next_level`), а главный цикл по нему перезапускает уровень — ровно
та точка, куда `levels_plan.md` §2.2 подключит загрузку уровня 2.
Открытость двери проверяем по `modifier >= 42` (ветка `fix_exit_door`), а не
по ванильному `leveldoor_open`: с ванильным условием можно войти в ещё
ползущую створку.
**Грабли, которые стоили отдельного разбора:** `go_up_leveldoor` сначала
писал `Char.x`/`Char.direction`, и оба присваивания молча терялись — окно
`Char` вокруг диспетчера возвращает назад ТОЛЬКО `curr_seq` и `sword`
(`pop_savekid_state`). Направление оставалось «вправо», а все `DX`
последовательности seq_70 отрицательные, поэтому Кид уходил ИЗ проёма влево.
Вывод на будущее: геометрию персонажа в этом порте меняет `pop_map` (пишет в
`Kid`), а не диспетчер.
✅ Косметика тоже закрыта: BUG-DOOR-CLIP (обрезка силуэта правым косяком
проёма) — недоставало второй половины `clip_char` (`obj_clip_right`) и
обрезки СПРАВА у колоночного блита. В libbgi добавлен
`gfx_blit_cols_part_w(..., maxw)`; разбор — в [`bug_closed.md`](bug_closed.md).
### L1-TRIAGE. Ревизия багов — **ЧАСТЬ 1 СДЕЛАНА 2026-08-01**
**Три Critical'а прогнаны в MAME и закрыты** (протокол с числами — в
[`bug_closed.md`](bug_closed.md), раздел «Проверено в MAME 2026-08-01»):
BUG-1 (провал на row 1 при переходе через открытые ворота) и BUG-2
(ping-pong при возврате) **не воспроизводятся**, BUG-3 (окклюзия climb-up на
кнопке) закрыт фиксом `tile_code_drawn` от 2026-07-28. Заодно снят неверный
диагноз BUG-1: репроекция Y при БОКОВОМ переходе — не наш пробел, а точное
поведение `goto_other_room` (`SDLPoP/src/seg002.c:390` меняет только `x`).
Список разделён на [`bug_list.md`](bug_list.md) (открытое) и
[`bug_closed.md`](bug_closed.md) (закрытое + разбор корней).
**Косметика окклюзии тоже закрыта** — BUG-CEIL-1/2/3 и BUG-OCCL-1 были
починены кодом ещё в июле, а записи никто не снял: `ceil_over_kid_tile`
(`pop_bg.c:1275`), `pop_ceil_modif` + `pop_ceil_shake_draw`/`_bake_empty`
(`:547,815,827`), `bar` с `POP_YOFF+3` в `pop_room_redraw_seam_left` (`:793`),
разделение слоёв по `add_backtable` vs `ptr_add_table` в `overlay_mid_tile`
(`:1367`). Разбор — в [`bug_closed.md`](bug_closed.md).
**Осталось:** закрыть таблицу обхода 24 комнат
([`bug_list.md`](bug_list.md#обход-всех-24-комнат-уровня-1)) — заполнена на
5 строк из 24, инструмент (`ROOMNAV`) готов. Делать вместе с L1-PASS: это
единственный оставшийся источник новых багов уровня 1.
### <a id="l1-speed"></a>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» **снято** — CLIP-1 закрыт (см. P0). Проверка —
секундомером по одинаковому отрезку рядом с живым SDLPoP, не «на глаз».
### <a id="l1-pass"></a>L1-PASS. Сквозное прохождение уровня 1
От старта до двери уровня одним заходом: подбор меча, страж, кнопки/ворота,
пики, loose-полы, зелье, падения. Это приёмка этапа 1 и одновременно
регресс-база для уровня 2.
Совмещать с [таблицей обхода 24 комнат](bug_list.md#обход-всех-24-комнат-уровня-1):
известные баги закрыты, значит новые придут только отсюда. Точки, где стоит
смотреть внимательно, — уже закрытая косметика окклюзии (потолок при прыжке
вверх, шов при анимации решётки, грани дальней колонны) и подъём на
тайл-кнопку: см. оговорку к BUG-3 в [`bug_closed.md`](bug_closed.md).
### L2. Переход на уровень 2 и его игра — **МАШИНЕРИЯ СДЕЛАНА 2026-08-04**
Порт `levels_plan.md` §2 (шаг 1). Что появилось:
- **Номер уровня стал состоянием.** `pop_current_level` (порт
`current_level`) в `pop_level.c`; `pop_next_level` больше не флаг, а
НОМЕР — `END_LEVEL` его инкрементит (как `++next_level`, seg006:662), а
главный цикл срабатывает по расхождению `pop_next_level !=
pop_current_level` (порт `play_level_2`, seg003:0386).
- **Загрузка по номеру** — `pop_level_load_num(n)`: `LEVELS\res20NN.bin`
(fallback `a:\`), старая EMM-страница отпускается ТОЛЬКО после успешной
загрузки новой (нет файла — играем дальше на текущем). На диск кладутся
все 15 уровней (34 КБ).
- **Потабличные различия** (`data.h:840..848`) — таблицы по 16 в
`pop_level.c`: `tbl_entry_pose` (поза входа), `tbl_guard_hp` (HP стража,
ушло из хардкода `3`), `tbl_guard_type` (**−1 = стражей нет**, иначе на
14/15 они полезли бы из данных комнат), `tbl_level_type` (тайлсет —
пока только читается).
- **`find_start_level_door`** (seg003:02E6) — на уровне 2 это НЕ косметика:
стартовый тайл (комната 5, ряд 1, колонка 3) — правая половина двери
уровня, и без `modif = 43` + `add_trob(...,3)` Кид материализуется внутри
глухой створки. Тип 3 = «быстро закрыть»: дверь захлопывается за спиной
за три кадра, как в оригинале.
- **HP через уровень** — `hitp_beg_lev` (seg003): рестарт уровня
откатывает HP к нему, пройденный уровень подтягивает его к `hitp_max`.
Заодно реализована **большая склянка** (`add_life`, тип зелья 2: +1 к
ПОТОЛКУ HP до 10) — на уровне 2 она есть, комната 20.
- **Меч** — `have_sword = level >= 2` (play_level, seg003:106), а не
жёсткий ноль.
- **Чит Shift+L** (seg000:698) — `pop_next_level = pop_current_level + 1`.
NB: `L` без Shift занят отладочным «осторожным шагом вправо»
(`pop_ctrl` `KBD_DBG_STEPR`); с Shift шаг тоже пройдёт, но уровень тут же
сменится — на практике не мешает.
**Проверено в MAME (2026-08-04):** старт уровня 1 → Shift+L → уровень 2
рисуется правильно (комната 5, большие колонны — новые для нас тайлы 8/9 —
на месте, дверь захлопнута, HP 3) → влево в комнату 4, страж на месте →
Shift+L → уровень 3 (комната 9). То есть цепочка загрузок работает
повторно, а не только один раз.
**Что осталось по уровню 2 (не машинерия, а контент):**
- сквозное прохождение уровня 2 руками — приёмка, как L1-PASS;
- выход через дверь уровня 2 (комната 23) вживую: путь тот же, что уже
закрыт на уровне 1 (L1-EXIT), но не прогнан на этом уровне;
- цвет стража из данных (`guards_color`, на уровне 2 их три разных) — у нас
один атлас; отдельная задача, к переходам отношения не имеет.
### <a id="dbg-cheats"></a>DBG-CHEATS. Отладочные читы SDLPoP — разведка сделана, код не написан
Записано 2026-08-03 (по ходу охоты за BUG-GATE-PASS-1, отложено на потом).
Мотив прямой: мост MAME **теряет нажатия при быстрой отправке**, поэтому
подогнать Кида в нужную позу для отладки автоматикой сейчас нельзя — именно
на это упёрлись BUG-LOOSE-2 и BUG-GATE-PASS-1.
**Берём (дёшево и бьёт в наш класс багов):**
- **`[` / `]` — сдвинуть Кида на пиксель влево/вправо.** Оригинал —
`../SDLPoP/src/seg000.c:1828`:
```c
if (key_states[SDL_SCANCODE_RIGHTBRACKET] & key_state) ++Char.x;
else if (key_states[SDL_SCANCODE_LEFTBRACKET] & key_state) --Char.x;
```
У нас: коды PS/2 set 2 `[` = **0x54**, `]` = **0x5B** в `pop_cheat.h`
рядом с `KBD_CHEAT_KILL/IMMO/SWORD`; обработка — в том же блоке читов
`roomtest.c` (~строка 479), **по фронту** (`*_prev`, как у остальных),
иначе одно нажатие уедет на десяток пикселей. Работает по `Kid.x`
напрямую: геометрию персонажа в этом порте меняет не диспетчер (см.
грабли L1-EXIT).
Первый же потребитель — BUG-GATE-PASS-1: там надо снять `Kid.x` и
`Kid.curr_col` в момент, когда решётка уже закрылась, а Кид ещё стоит
на её тайле.
- **Shift+L — следующий уровень.** Уже входит в L2-машинерию
(`../docs/levels_plan.md` §2.4): `++pop_next_level` и всё.
**Остальные — оценка, а не обязательство:**
| Чит | Вердикт |
|-----|---------|
| **T — таймер** | **не сейчас**: таймера уровня у нас нет вообще (Фаза 6), чит пришлось бы делать вместе с механикой |
| **F — остаток feather-fall** | **вместе с Shift+W**: ветка `JMP_IF_FEATHER` (опкод `0xF7`) в `play_seq` есть, но не проверена ничем; индикатор без самого зелья бесполезен, а пара «включить + видеть остаток» закрывает ветку целиком. Перо — уровень 7, так что не срочно |
| **Shift+F9 — quickload с тем же уровнем** | **самое ценное и самое дорогое**: это сериализация `Char` + `room_modif` всех комнат + trob'ов + стражей (`levels_plan.md` §4). Даёт воспроизводимый регресс «вот кадр, где баг» вместо ручной подгонки. Кандидат сразу после того, как заработают уровни |
---
## Отложено осознанно (не брать, пока не появится причина)
- **Звук** (CBL-эффекты, Фаза 5 `PORT_PLAN.md`) — геймплей не блокирует.
- **Таймер уровня / HUD времени / меню / сохранения** — Фаза 6.
- **[T-1](bug_list.md#t-1)** (пики: перерисовка по причине) — отдаётся почти
бесплатно после [T-2](bug_list.md#t-2), отдельно не окупается.
- **Отключение мыши на время игры** и **замена PRNG** —
`../docs/ideas_backlog.md` (оба дают доли процента кадра).
- **OPT-1** (хирургический редрой шва) — решено НЕ делать, стоимость
транзиентная; разбор в [`bug_closed.md`](bug_closed.md).