# 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    ← отложено
```

**Порядок (решение пользователя 2026-08-04): СНАЧАЛА приёмка уровня 2,
потом уровень 3.**  Причина техническая, а не вкусовая: чомперы лягут в
тот же банк 2, где живёт отрисовка, и чинить баги фона поверх свежей
механики дороже, чем до неё.

| # | Задача | Что | Блокирует |
|---|--------|-----|-----------|
| 1 | [L2-PASS](#l2-pass) | приёмка уровня 2 + баги отрисовки | ← **сейчас** |
| 2 | [L3-CHOMP](#l3-chomp) | чомперы (5 шт) | прохождение ур. 3 |
| 3 | [L3-SKEL](#l3-skel)   | скелет (единственный противник ур. 3) | прохождение ур. 3 |
| 4 | [L3-CHKP](#l3-chkp)   | чекпойнт ур. 3 | корректный респавн ур. 3 |
| — | [L1-SPEED](#l1-speed) | игра на ~39 % быстрее оригинала | ощущение от ВСЕХ уровней; берётся в любой момент |
| — | [L1-PASS](#l1-pass) | сквозной прогон ур. 1 + таблица 24 комнат | приёмка ур. 1 |
| — | [DBG-CHEATS](#dbg-cheats) | `[`/`]` — подгонка Кида по X | отладка (BUG-GATE-PASS-1) |

---

## P0 — делаем сейчас

### <a id="l2-pass"></a>L2-PASS. Приёмка уровня 2

Баги отрисовки уровня 2 пользователь подаёт списком отдельно — они идут в
[`bug_list.md`](bug_list.md), раздел «Уровень 2».  Ниже — **карта
содержимого уровня, снятая прямо с `res2002.bin`**, чтобы приёмка шла по
списку, а не «на глаз»: если механика в таблице есть, а в игре не
сработала — это баг, а не «так задумано».

**Стражи — 5, в комнатах 4, 7, 11, 15, 24** (skill 1/2/1/1/3, цвета
1/3/1/1/6 — цвет мы пока игнорируем, атлас один).  Заметить: страж
комнаты 24 со skill 3 — первый по-настоящему опасный.

**Кнопки и что они открывают** (декодировано из LINKLOC/LINKMAP):

| Кнопка | Тип | Цель |
|--------|-----|------|
| к.9 @ряд1,кол1 | RAISE | **дверь уровня** к.23 @1,3 — то есть выход |
| к.11 @1,1 | RAISE | ворота к.18 @0,9 |
| к.18 @0,7 | RAISE | ворота к.7 @0,9 **и** к.18 @0,9 (две сразу) |
| к.18 @0,2 | DROP | закрывает ворота к.7 @0,9 |
| к.13 @1,4 | DROP | закрывает ворота к.13 @1,5 |

**Ловушки и предметы по комнатам:**

```
к. 3  loose@2,2   зелье@2,5 (здоровье)
к. 4  loose@1,7   loose@1,8            + СТРАЖ
к. 5  дверь уровня @1,2-3 — ВХОД (захлопывается за спиной)
к. 6  пики@1,3    loose@1,5   зелье@2,7 (здоровье)
к. 7  ворота@0,9  пики@2,7               + СТРАЖ
к. 8  зелье@2,2 (здоровье)
к. 9  кнопка RAISE@1,1   loose@2,6
к.10  пики@2,2
к.11  кнопка RAISE@1,1                    + СТРАЖ
к.12  зелье@2,6 (здоровье)   loose@2,8
к.13  зелье@1,3 (ВРЕДНОЕ, −1 HP)  кнопка DROP@1,4  ворота@1,5  зелье@1,8
к.15                                      + СТРАЖ
к.18  кнопка DROP@0,2  loose@0,4  кнопка RAISE@0,7  ворота@0,9  зелье@2,6
к.19  пики@1,4
к.20  ЗЕЛЬЕ@1,2 — БОЛЬШАЯ СКЛЯНКА (+1 к потолку HP)   пики@2,6   пики@2,7
к.23  дверь уровня @1,3-4 — ВЫХОД
к.24                                      + СТРАЖ (skill 3)
```

**Отдельно проверить то, что сделано именно сегодня и на уровне 1 не
проверялось:**
- **большая склянка** (к.20) — потолок HP становится 4, индикатор рисует
  четыре деления, и это HP **переносится на уровень 3**;
- **меч уже в руках** с самого старта (`have_sword = level >= 2`) — на
  уровне 1 его надо было подбирать;
- **выход через дверь к.23** вживую (кнопка в к.9) — путь тот же, что
  закрыт на уровне 1 (L1-EXIT), но на этом уровне не прогонялся;
- **смерть/респавн** возвращают на уровень 2, а не на 1.

### <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).
