Files
Sprinter-SDCC/applications/PoP/roomtest/TASKS_CLOSED.md
T
snark13 5348feb5f4 Доски приведены в соответствие с кодом; bug_list/bug_closed → BUGS_OPEN/BUGS_CLOSED
Доска отставала от кода на три задачи — планировать по ней было нельзя.
Сверка проведена грепом по исходникам, а не по записям:

- L4-MIRROR ЗАКРЫТА: шаги 1-5 сделаны и проверены пользователем в MAME
  (зеркало в атласе, постановка тайла, отражение, прыжок сквозь зеркало с
  рождением тени, левый клип тени).  Протокол с разбором решений — в архиве;
- L3-CHOMP и L3-SKEL закрыты ещё 2026-08-08/07 (коммиты dc0bd47, 4d4323f,
  db4106a, 1461ed5), на доске значились как предстоящие;
- тайлсет palace (шаг 2 levels_plan) в коде есть целиком — pop_bg_load(type),
  pal_*.atl, дворцовый wall_pattern, решётки 25-29 и в tile_table, и в
  коллизии (tile_is_floor совпадает с seg006:0628);
- в GUARD-PHYS остаток пересобран по факту: check_chomped_guard сделан,
  скелет в check_guard_fallout сделан, ветки ТЕНИ нет — она уехала в L5-SHADOW.

Приёмки: по решению пользователя уровни 1-4 приняты SMOKE-тестами, полные
обходы всех комнат делаются по готовности ВСЕХ уровней — L3-PASS/L4-PASS как
отдельные задачи отменены, вместо них политика приёмок в архиве.

Новая цель — уровень 5.  Инвентарь res2005.bin: НИ ОДНОГО нового тайла, всё
портировано на уровнях 1-4.  Единственная новая механика — спецсобытие «тень
крадёт зелье» (комната 24): заведена задача L5-SHADOW с портом по SDLPoP
(check_shadow / do_init_shad / do_auto_moves + shad_drink_move /
autocontrol_shadow_level5 + ветка тени в check_guard_fallout), включая
готовые константы и то, что у нас уже есть под это.

Заведён MIRROR-FG-STALE (низкий): place_mirror пишет тайл в данные уровня, но
не в снимок room_fg, по которому работает коллизия — если зеркало поставлено,
пока игрок В комнате 4, оно невидимо для коллизии (тень не родится).  В
обычном прохождении недостижимо: дверь выхода в другой комнате.  Записан
точный сценарий воспроизведения читом ROOMNAV и фикс на несколько строк.

Правило «в _OPEN только незакрытое» теперь выполняется буквально:
- bug_list.md → BUGS_OPEN.md, bug_closed.md → BUGS_CLOSED.md (ссылки
  обновлены во всех документах и в комментарии pop_trob.c);
- из TASKS_OPEN убраны блоки закрытых задач (L3-CHOMP, L3-SKEL, L3-PASS,
  L4-MIRROR, DRAW-CHAR), справка по связности комнат уехала в архив;
- из BUGS_OPEN убраны 8 строк таблицы закрытых багов, закрытый T-2 (уехал в
  BUGS_CLOSED) и раздел «уровень 3 — неначатые задачи» (обе записи закрыты);
  сводная таблица пересобрана по реально открытым записям.

Все внутренние ссылки проверены скриптом: битых якорей 0.  make size-check
OK (65 программ), tests-host 5/5.

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

969 lines
76 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-11)
Сделанное — с протоколами замеров, граблями и причинами решений. Файл
существует не ради истории: половина записей ниже — это ЧИСЛА (сколько тактов
стоил heal, сколько байт теряет клавиатура, почему `static inline` дорог) и
перечень того, что делать НЕЛЬЗЯ, потому что уже пробовали.
Открытые задачи — [`TASKS_OPEN.md`](TASKS_OPEN.md); открытые баги —
[`BUGS_OPEN.md`](BUGS_OPEN.md), закрытые с разбором корней —
[`BUGS_CLOSED.md`](BUGS_CLOSED.md).
---
## Приёмка уровней
<a id="pass-policy"></a>
### ПОЛИТИКА ПРИЁМОК — решение пользователя 2026-08-11
**Уровни 1-4: smoke-тесты пройдены.** Полные прогоны (обход всех комнат
каждого уровня) делаются **по готовности ВСЕХ уровней**, а не по одному за
этапом — отдельных задач `L3-PASS`/`L4-PASS` больше нет.
Основание: сквозные обходы дорогие, а половина находок на неполном наборе
уровней всё равно оказывается «механики ещё нет». Smoke (пройти уровень от
старта до двери) остаётся обязательным на каждом новом уровне — он снимает
блокеры, а не косметику.
Уже сделанные полные обходы уровней 1 и 2 (ниже) остаются регресс-базой.
<a id="l1-pass"></a>
### L1-PASS. Сквозное прохождение уровня 1 — **ЗАКРЫТ 2026-08-07**
> **Smoke 2026-08-05 (пользователь): успешный.** От старта до выхода с
> уровня одним заходом — меч подобран, **оба стража побеждены в честном бою**
> (без читов), выход отработал корректно. Ценность прогона в том, что он снял
> главные риски этапа 1 разом — боёвка, предметы, переход с уровня — и стал
> регресс-базой для уровня 2.
>
> **Полный обход комнат 2026-08-07 (пользователь): крупных багов нет.** Этим
> же прогоном закрыта [таблица обхода 24 комнат](BUGS_CLOSED.md#обход-всех-24-комнат-уровня-1)
> и [чек-листы ручной перепроверки фиксов](BUGS_CLOSED.md#ручная-перепроверка-2026-08-03)
> — обе уехали в `BUGS_CLOSED.md`.
Приёмка этапа 1 и одновременно регресс-база для уровня 2: от старта до двери
уровня одним заходом — подбор меча, страж, кнопки/ворота, пики, loose-полы,
зелье, падения. Точки, где смотрели внимательно, — закрытая косметика
окклюзии (потолок при прыжке вверх, шов при анимации решётки, грани дальней
колонны) и подъём на тайл-кнопку (оговорка к BUG-3 в `BUGS_CLOSED.md`).
<a id="l2-pass"></a>
### L2-PASS. Приёмка уровня 2 — **ЗАКРЫТ 2026-08-07**
> **Smoke 2026-08-05 (пользователь): уровень 2 пройден.** Из smoke пришли
> BUG-LOOSE-3 и BUG-GUARD-DEAF-1 — оба закрыты (`BUGS_CLOSED.md`). Следом
> прогнан smoke уровня 3.
>
> **Полный обход комнат 2026-08-07 (пользователь): крупных багов нет.**
> Открытыми с этого прогона остались три записи в
> [`BUGS_OPEN.md`](BUGS_OPEN.md): [BUG-GUARD-COLOR-1](BUGS_CLOSED.md#bug-guard-color-1)
> (страж всегда одного цвета), BUG-GUARD-SPLASH-1 (нет брызг при попадании по
> стражу — **закрыт 2026-08-07**, разбор в
> [`BUGS_CLOSED.md`](BUGS_CLOSED.md#bug-guard-splash-1)) и
> [BUG-CHEAT-FIGHT-1](BUGS_CLOSED.md#bug-cheat-fight-1) (наш чит `+`/`` в бою
> отнимает управление). Ни одна играть не мешает.
Ниже — **карта содержимого уровня, снятая прямо с `res2002.bin`**. Она
осталась в архиве не как отчёт, а как справочник для повторных прогонов и
для сравнения с SDLPoP: если механика в таблице есть, а в игре не сработала —
это баг, а не «так задумано».
**Стражи — 5, в комнатах 4, 7, 11, 15, 24** (skill 1/2/1/1/3, цвета
1/3/1/1/6 — цвет мы пока игнорируем, атлас один, см.
[BUG-GUARD-COLOR-1](BUGS_CLOSED.md#bug-guard-color-1)). Заметить: страж
комнаты 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)
```
Отдельно проверялось то, что появилось именно на этом уровне:
- **большая склянка** (к.20) — потолок HP становится 4, индикатор рисует
четыре деления, и это HP **переносится на уровень 3**;
- **меч уже в руках** с самого старта (`have_sword = level >= 2`);
- **выход через дверь к.23** вживую (кнопка в к.9);
- **смерть/респавн** возвращают на уровень 2, а не на 1.
---
### <a id="rooms-graph"></a>Справка: связность комнат уровней 1–3 (снято 2026-08-05)
Обход графа `roomlinks` (@1952, по 4 байта на комнату: L, R, U, D; 0 = нет
соседа) от стартовой комнаты — тем же методом, которым на уровне 1 нашлись
13/18/24 (см. «НЕ БАГИ» в [`BUGS_CLOSED.md`](BUGS_CLOSED.md)). Скрипт разовый,
в репозиторий не клался: чтение трёх массивов, BFS и проверка симметрии.
| уровень | старт | недостижимы | признак |
|---------|-------|-------------|---------|
| 1 | к.1 (0,0) | **13, 18, 24** | ссылки наружу есть, обратных нет |
| 2 | к.5 (1,3) | **нет** | граф полностью симметричен, все 24 достижимы |
| 3 | к.9 (2,4) | **23, 24** | то же, что на 1: односторонние ссылки, обе комнаты **полностью пустые** |
```
ур.3: 23 L→4, у 4 R=22 | 24 L→2, у 2 R=7
23 R→22, у 22 L=4 | 24 R→7, у 7 L=2
| 24 U→16, у 16 D=0
на 23 ссылается только 24, на 24 — только 23; тайлы обеих = все empty
```
То есть на уровне 3 это даже более чистый случай, чем на уровне 1: там в
брошенных комнатах была геометрия, здесь — пустота. Практический вывод тот
же: **в 23/24 возможен «мусор в шве»** (наш рендер кромки читает крайнюю
колонку соседа ПО ССЫЛКЕ, а сосед соседом себя не считает), приоритет багов
там низкий, в игре их не видно.
**Уровень 2 — недостижимых нет, но есть три КОЛОДЦА без выхода** (единственная
связь — вверх, откуда Кид падает):
```
к.10 U→4 пики(2,2) + пол — падение из комнаты 4
к.14 U→21 шахта 2 тайла шириной, дно = обломки
к.17 U→15 то же
```
Это не баги данных: в 14/17 попадают только падением насмерть, а из 10
(если выжил) выхода нет вовсе — так в оригинале. При приёмке не считать
«застрял» багом.
---
## Уровни и механика
<a id="l4-mirror"></a>
### L4-MIRROR. Зеркало уровня 4 и тень — **ЗАКРЫТА 2026-08-11**
Пять шагов из шести сделаны и **проверены пользователем в MAME 2026-08-11**;
шестой (вид тени) осознанно отложен.
| шаг | что | коммит |
|---|---|---|
| 1 | зеркало в атласе: `tile_table[0x0D]` база 75 / фронт 77, `MIRROR_ENV_IDS` + `FORE_ENV_IDS` в `pop_pack_bg.py` | `1b2111f` |
| 2 | постановка тайла: `place_mirror` в `pop_trob.c` по переходу `pop_leveldoor_open` 0/2 → 1 (`animate_leveldoor`, seg007:0457) | `1b2111f` |
| 3 | отражение (`check_mirror`, seg003:0798) — отдельной функцией `pop_mirror_draw`, со своим heal | `8f0362f`, `3913f1e` |
| 4 | прыжок сквозь зеркало и рождение тени (seg004:0239 + seg003:0798..08A9 + seg002:081D/1131) | `844fa6d` |
| 5 | клип тени слева `obj_clip_left = 137 + (mirror_column4)*32` (seg008:1699) + новый примитив `gfx_blit_cols_part_wx` в libbgi | `8f0362f` |
**Почему зеркало пришлось добавлять в атлас явно:** тайла 13 нет ни в одном
уровне статически — проверено перебором всех 15 `res200N.bin`, ноль
попаданий, поэтому `render_room.py` его не видит и на месте зеркала был бы
чёрный провал (memory `pop_atlas_dynamic_ids`).
**Почему отражение — отдельная функция, а не третий слот `Char`:** это
структура самого оригинала — отражение идёт сокращённым путём
`load_frame_to_obj` + `add_objtable(4)`, без клинка, брызг, пропуска кадра;
гейтить всё это в общем теле `pop_cdraw` значило бы добавить ветки в самый
горячий путь. Побочный эффект — [FORE-DUP](BUGS_OPEN.md#fore-dup): передний
слой тайла рисуется дважды, когда футпринты Кида и отражения накрывают один
тайл (всегда, они стоят на одном тайле). Картинку не портит, тратит такты.
**Почему левый клип сделан примитивом libbgi, а не «нарисовать и вернуть фон
поверх лишнего»** (подсказка пользователя): для column-major левая обрезка
стоит ровно столько же, сколько правая — колонка это непрерывный кусок ОЗУ,
меняются стартовая колонка источника и экранная X. Внутри это уже было (так
клипается левый край экрана), наружу не было выведено.
**Отложено — вид тени** (шаг 6): в оригинале она рисуется ДВУМЯ блитами
одного спрайта, `blitters_2_or` на месте и `blitters_3_xor` со сдвигом +1 px
(seg008:1602); у нас пока обычная копия, то есть тень выглядит вторым Кидом.
Разбор, замеры и варианты — [`../docs/shadow_render.md`](../docs/shadow_render.md);
блочные операции акселератора под это в libbgi уже есть (`tests/accop`).
**Открытый краевой случай** — [MIRROR-FG-STALE](BUGS_OPEN.md#mirror-fg-stale):
`place_mirror` пишет тайл в данные уровня, но не в снимок комнаты `room_fg`,
по которому работает коллизия.
<a id="l3-chomp"></a>
### L3-CHOMP. Чомперы — **СДЕЛАНЫ 2026-08-08**
Коммиты `dc0bd47` (анимация, отрисовка, смерть в челюстях), `4d4323f`
(передние зубья через `pop_fore_b` + ветка в `draw_tile`), `db4106a`
(перед чомпером Кид разбегается сразу, без осторожного шага — `safe_step` по
оригиналу), `1461ed5` (фикс регрессии, см. ниже).
Портировано: `animate_chomper` (seg007:0448), `next_chomper_timing`
(seg007:0F9A — 15,12,9,6,13,10,7,14,11,8 по кругу), `start_anim_chomper`
(seg007:08C7), `start_chompers` (seg007:0F13) — все в `pop_trob.c`,
состояние в `room_modif`, как у пик и ворот. Смерть: `SEQ_54_CHOMPED` /
`FRAME_178_CHOMPED` + `check_chomped_guard` для соперника (`pop_map.c`).
Отрисовка — `pop_chomp_pose` (`pop_bg.c`) и холодная перерисовка тайла в
`pop_room.c`. Ассеты упакованы явным набором кадров (`CHOMPER_BOT_IDS`
101-105, `TOP` 111-113, `FORE` 106-110 + кровь 114-123 mono-силуэтом).
**Грабли, стоившие регрессии (`1461ed5`):** `start_chompers` вызывается на
смене ряда персонажа, а у нас `seqtbl` читается ЧЕРЕЗ ОКНО W0 — прямой вызов
из `play_seq` переключал окно посреди чтения байткода. Вызов отложен через
флаг `chomp_pending` (`pop_kid.c`) и делается ПОСЛЕ цикла интерпретатора.
Хвосты в багах: [BUG-CHOMP-JUMP-1](BUGS_OPEN.md#bug-chomp-jump-1) (низкий,
маловоспроизводим) и закрытые BUG-TORCH-CHOMP-1/2 (пламя факела и застывший
чомпер — [`BUGS_CLOSED.md`](BUGS_CLOSED.md)).
<a id="l3-skel"></a>
### L3-SKEL. Скелет уровня 3 — **СДЕЛАН 2026-08-07, принят smoke-прогоном**
В данных уровня 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) — декорация, они не
оживают. Атлас — `pop_pack_guard.py SKEL``poc/res/skel/g0..g3.atl`.
Проверено в MAME: бой, падение в пропасть, окклюзия (2026-08-07); принят
smoke-прогоном уровней 1-4 (2026-08-11). **Этот же механизм**
«спецсобытие порождает персонажа в слоте соперника» — база для тени уровня 5
([L5-SHADOW](TASKS_OPEN.md#l5-shadow)).
<a id="l3-chkp"></a>
### L3-CHKP. Чекпойнт уровня 3 — **СДЕЛАНО 2026-08-06** (коммит 0cd6b2d)
`level3_set_chkp` (seg002:0665): флаг взводится, когда Кид уходит **ВЛЕВО ИЗ
комнаты 7**; `do_startpos` (seg003:141) по нему подменяет старт на комнату 2,
тайлпос 6, направление влево и снимает loose-плиту (комната 7, колонка 4,
ряд 0). Константы — в `pop_tune.h` (`POP_CHKP_*`), механика `hitp_beg_lev`
была сделана раньше в L2.
**Тонкость, на которой сначала ошиблись:** `level3_set_chkp` вызван из
`leave_room` ДО `goto_other_room`, поэтому `Char.room == 7` — это комната, ИЗ
которой уходят, а не в которую входят. Поймал пользователь прогоном в
SDLPoP: смерть В комнате 7 вернула его в стартовую 9, а плита осталась цела.
**Проверено в MAME:** вход в 7 флаг не ставит, уход влево — ставит; респавн в
комнате 2; после обычной смерти (без чекпойнта) плиты восстанавливаются как
раньше. Вживую в игре (не читом) — потрогать на приёмке уровня 3
([L3-PASS](TASKS_CLOSED.md#pass-policy)).
### 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 (сквозное прохождение, выход через дверь) закрыт отдельно —
[L2-PASS](#l2-pass). Цвет стража из данных остался открытым багом —
[BUG-GUARD-COLOR-1](BUGS_CLOSED.md#bug-guard-color-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)`; разбор — в [`BUGS_CLOSED.md`](BUGS_CLOSED.md).
### L1-TRIAGE. Ревизия багов — **ЗАКРЫТА** (часть 1 — 2026-08-01, хвост — 2026-08-07)
**Три Critical'а прогнаны в MAME и закрыты** (протокол с числами — в
[`BUGS_CLOSED.md`](BUGS_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`).
Список разделён на [`BUGS_OPEN.md`](BUGS_OPEN.md) (открытое) и
[`BUGS_CLOSED.md`](BUGS_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`). Разбор — в [`BUGS_CLOSED.md`](BUGS_CLOSED.md).
**Хвост закрыт 2026-08-07:** таблица обхода 24 комнат уровня 1 — прогоном
всех комнат уровней 1 и 2 (крупных багов нет), см.
[`BUGS_CLOSED.md`](BUGS_CLOSED.md#обход-всех-24-комнат-уровня-1).
---
## Отрисовка и память
<a id="draw-char"></a>
### DRAW-CHAR. Отрисовка — ОДНА на всех Char — **СДЕЛАНО И ПРОВЕРЕНО 2026-08-08**
> **Итог.** Отрисовка персонажа сведена к одному набору функций над `Char`
> (`pop_cdraw.c`, банк 4) — как физика после GUARD-PHYS. Было два
> независимых куска кода: `kid_draw`/`kid_heal`/`kid_draw_splash`/
> `kid_fore_clip*` в резиденте W1 и `pop_guard_draw`/`pop_guard_heal` в
> банке 4, каждый со своей математикой кадра, своими heal-прямоугольниками
> и своим проходом окклюзии (`pop_fore_over_kid` / `pop_fore_over_char`).
>
> **Основание в оригинале** (сверено перед кодингом): `add_kid_to_objtable`
> (seg008:22F0) и `add_guard_to_objtable` (seg008:2324) имеют ИДЕНТИЧНОЕ
> тело и различаются окном (`loadkid`/`loadshad`), набором спрайтов и типом
> объекта; `redraw_at_char` / `redraw_at_char2` (seg003:0576/0645) гейтов по
> `charid` не имеют вовсе — единственная ветка по персонажу там это
> объединение с ПРОШЛЫМ футпринтом у Кида (пометки перерисовки, у нас их
> нет). `draw_hurt_splash` (seg006:2003) разводит Кида и остальных ровно
> двумя числами: chtab/image и подъём `((charid == kid) << 2) + 11`.
**Что теперь одно на всех.**
| Было (два места) | Стало |
|---|---|
| `kid_draw` / `pop_guard_draw` | `pop_char_draw(who)` |
| `kid_heal` / `pop_guard_heal` | `pop_char_heal(who)` |
| `kid_draw_splash` / ветка брызг внутри `pop_guard_draw` | `cd_splash` (порт целиком, все три ветки кадров) |
| `kid_fore_clip` + `kid_fore_clip_restore` / хвост `pop_guard_draw` | `pop_char_fore(who)` |
| `pop_fore_over_kid` / `pop_fore_over_char` | `pop_fore_over_char(ch, …)` |
| `kid_fp_*()` / `pop_guard_fp_width()` | `pop_cd[slot]` (структура в `_DATA`, читается из любого банка без трамплина) |
| `pop_kid_set_render_dx` | `pop_char_set_render_dx(who, dx)` |
Слот (`POP_CH_KID` / `POP_CH_OPP`) выбирает ровно то, что различается в
оригинале: окно `Char`, набор атласов и кадр (`kidp`+`kid_frame` /
`gp`+`pop_gframe`), heal-прямоугольники по страницам дабл-буфера.
**Что при этом ПОЧИНИЛОСЬ (расхождения, которые и были ценой дублирования).**
1. **`clip_char` у соперника не было вовсе** — теперь общий (в оригинале он
в обоих `add_*_to_objtable`).
2. **Клип полем 192 у Кида не было** (`reset_obj_clip`: `obj_clip_bottom =
192`): его спрайт рисовался на полосе HP, а следы за него подчищала
полоса `pop_room_clip_borders` по флагу `pop_clip_sprite`. Теперь
персонаж режется полем, как в оригинале, и `border_dirty` остался только
у падающей плиты.
3. **У Кида не было ветки брызг «мёртв / падение 106..110»** (`obj_y += 4`)
— была только у соперника.
4. **`char_width_half` СТРАЖА считался по спрайту КИДА**: `set_char_collision`
и `check_spike_below` (`pop_map.c`) читали `kid_fp_width()` безусловно, а
`pop_guard_fp_width()` использовался только для порядка отрисовки.
Теперь метрики берутся по `charid` активного персонажа (`CD_ACT`).
5. **Расширение перебора тайлов окном спрайта** (клинок и брызги уходят за
габарит кадра) было только у соперника — теперь общее. Заодно оно
перестало быть четырьмя лишними аргументами: окно fore-клипа уже лежит в
`pop_bg.c`, и проход читает его сам.
**Цена / выигрыш (ALLOCS=3000, замер до и после):**
```
было стало дельта
_CODE (W1) 24 881 20 524 4 357 ← куча 2 023 → 6 333 Б (+4 310)
BANK2 pop_bg 15 310 15 045 265 ← свободно 1 074 → 1 339 Б
BANK3 pop_map 8 608 8 713 + 105 (CD_ACT — индекс по charid)
BANK4 pop_cdraw 4 625 5 905 +1 280 (сюда переехала отрисовка Кида)
итого 3 237
```
То есть слияние двух копий дало ~3.2 КБ, но **главный выигрыш не в банке 2,
а в резиденте**: отрисовка Кида уехала из W1 в банк, и куча выросла втрое
(2 023 → 6 333 Б). Банку 2 при этом досталось всего +265 Б свободного
места — **под [L3-CHOMP](TASKS_CLOSED.md#l3-chomp) этого мало**, разгрузку
`pop_bg` придётся делать отдельно (свободные номера банков — 7+).
**Проверено:** `tests-host` — все 5 наборов зелёные, трассы физики не
изменились (`[phys] ok: 1723`, тот же эталон; `[char] ok: 65`). В MAME
(свежий образ, полный рестарт): уровень 1 — комната 1 (Кид, кладка,
факелы, окклюзия колонны), падение на нижний ярус, комната 3 — страж своего
цвета с клинком и своей полосой HP, бой и смерть Кида. Артефактов
отрисовки и мусора на полосе HP нет.
**Приёмка пользователем пройдена 2026-08-08.** Прогон вживую: отрисовка
персонажей, окклюзия и бой — без регрессий.
Единственное наблюдение — **циан-полоса профиля (спрайты + fore) выросла**:
проход над Кидом теперь расширяется окном спрайта, как раньше только у
соперника, то есть перебирает на колонку-другую больше. Решение
пользователя: **в пределах допустимого, оптимизация — ОТДЕЛЬНОЙ задачей**
([DRAW-COST](TASKS_OPEN.md#draw-cost)).
<a id="clip-1"></a>
### <a id="mem-bank2"></a>MEM-BANK2. Разгрузка банка 2 — **ЗАКРЫТА 2026-08-08**
Банк 2 (`pop_bg.c`) упирался в потолок: 90.8 % ещё до чомперов. Разбор по
символам показал, что он держит ДВЕ разные вещи — горячий fore-проход (каждый
кадр) и холодную отрисовку тайлов (вход в комнату, точечные перерисовки), —
а общие «листья» (`blit_b`, `tile_code`, `tile_table`, `wall_pattern`) нужны
обеим.
**Ключевое ограничение платформы** (записано в memory
`sdcc_banked_call_rules`): писучие данные банка лежат в `_DATA`/W2 и видны
всем, а `const`-таблицы — в СТРАНИЦЕ банка, и из другого банка их не
прочитать вовсе. Плюс трамплин выбирается ОБЪЯВЛЕНИЕМ: пометить лист
`__banked` — значит заставить платить и горячих вызывающих (654 такта на
круг).
**Шаг 1 — общие листья в РЕЗИДЕНТ** (`pop_tile.c` + внутренний `pop_tile.h`).
W1 замаплен всегда, поэтому и горячая половина, и будущая холодная зовут их
прямым `call` и читают таблицы напрямую — без дублей и без трамплинов.
Уехали: `blit_b`, `env_b/wall_b/fore_b/pot_b`, `tile_code/tile_mod/
tile_code_drawn/wall_modifier`, `heal_off`, `get_loose_frame`,
`potion_flask`, `pop_fore_set_clip`, `pop_room_set_below/above` + таблицы
`tile_table`, `COL_XH`, `WALL_FRAM_*`, `SPIKES_FRAM_LEFT`,
`LOOSE_FRAM_LEFT/BOTTOM`. Заодно три функции перестали быть `__banked` —
минус трамплин на кадр.
**Шаг 2 — дедуп внутри банка.** `wall_pattern` (808 → 394) и `wall_rnd`
(786 → 654): четыре ветки по виду стены (SWS/SWW/WWS/WWW) отличались ТОЛЬКО
набором кусков и числами в одной и той же серии `prandom` — сведены к
таблицам `WP_PARTS` и `WR_RULE`. Порядок вызовов `prandom` сохранён
дословно (иначе разъедется раскладка кладки).
**Грабля, пойманная замером:** после выноса `tile_table` из `static const` в
глобальную SDCC перестала сворачивать `tile_table[10].fore_x/fore_y` в
константы и стала читать их в рантайме — `potion_flask` раздулся 115 → 265 Б.
Лечится макросами, которыми инициализируется та же запись таблицы (одно место
правки, дублирования данных нет): 265 → 230.
**Замер (ALLOCS=3000):**
```
было после ш.1 после ш.2
BANK2 14 815 12 460 11 942 (90.4 % -> 72.9 %, свободно 4442 Б)
_CODE 20 064 22 591 22 556 (куча 6793 -> 4301 Б)
```
С релизным `ALLOCS=100000` банк 2 будет ещё примерно на 500-600 Б меньше
(замер до разгрузки: 14 815 -> 14 176).
**Проверено:** `tests-host` зелёные; в MAME комната 1 уровня 1 после дедупа
совпала с дорефакторным снимком **попиксельно (0 различий из 227 520)**,
комната 3 (сплошная кладка, все четыре вида стены и марки) — та же раскладка
кирпича.
**Шаг 3 — ХОЛОДНАЯ половина в банк 7** (`pop_room.c` + внутренний
`_pop_bg.h`). Граница проведена по частоте вызова, а не по размеру:
```
pop_bg.c (банк 2) ГОРЯЧЕЕ fore-проход, оверлеи, кладка, клип каждый кадр
pop_room.c (банк 7) ХОЛОДНОЕ draw_tile, точечные перерисовки, mob, раз на комнату
загрузка атласов / на событие
```
Обратных зависимостей нет вовсе: холодная половина зовёт горячую ровно в
трёх местах (`wall_pattern`, `wall_pattern_reset`, `draw_gate_back`) — и
через ТОНКИЕ `__banked`-обёртки, а не пометкой самих тел. Тела остаются
непомеченными, поэтому fore-проход, который зовёт `wall_pattern` до девяти
раз за кадр, платит ноль. Общее состояние (`pop_loose_modif`,
`pop_ceil_modif`, `obj_row/obj_col`) — писучее, лежит в `_DATA`/W2 и видно
обеим половинам; `obj_row/obj_col` для этого перестали быть `static`.
**Цена трамплинов:** полная отрисовка комнаты — ~40 вызовов через границу
(по два `wall_pattern` на стенной тайл), 26 000 тактов = 0.06 кадра РАЗОВО
на вход в комнату. Анимация пик/ворот — 2-4 вызова на кадр, ~1 %. Ровно
тот порядок, который был признан приемлемым при постановке задачи.
Заодно удалена мёртвая `potion_bubble` (169 Б): её работу давно делают
`pop_potion_flask` (резидент) и `pop_potion_draw`, вызывающих не осталось.
**ГРАБЛЯ, стоившая прогона: `n_banks` объявляет САМО ПРИЛОЖЕНИЕ**
(`roomtest.c`), а не `sprinter-cc`. Восьмой банк без правки этой константы
собирается и линкуется без единого предупреждения, `_bank_pages[7]`
остаётся 0xFF, и трамплин уводит в мусор: exe грузится, но программа встаёт
намертво ещё до первого кадра — на экране остаётся приглашение DSS.
Диагноз снят дампом `_bank_pages` из MAME (`FF EC ED EE EF F0 F1 FF` —
семь страниц вместо восьми), а не гаданием. Предупреждение об этом в
шапке `roomtest.c` было — и всё равно не сработало, потому что число банков
живёт в двух местах.
**Замер (ALLOCS=3000):**
```
было ш.1 ш.2 ш.3
BANK2 14 815 12 460 11 942 5 872 (90.4 % -> 35.8 %)
BANK7 — — — 6 035 (36.8 %)
_CODE 20 064 22 591 22 556 22 556 (куча 4301 Б — не тронута)
```
**Проверено (шаг 3):**
- `tests-host` зелёные;
- построчная сверка `pop_bg.c` + `pop_room.c` против дорефакторного
`pop_bg.c`: НИ ОДНОЙ строки логики не пропало и не изменилось —
расхождения только шапки, включения, три обёртки и удалённый мёртвый код;
- в MAME **8 комнат** (стартовая + 7 по `ROOMNAV`) сняты до и после и сверены
попиксельно по активному экрану 320×256: различия ТОЛЬКО в фазе анимации
(пламя факелов, кадр Кида), статическая кладка совпадает пиксель в пиксель;
- живой прогон: переход между комнатами, бой со стражем, решётка ворот,
окклюзия Кида ближней колонной — как на дорефакторном билде (сверено
контрольным запуском HEAD-бинаря с тем же вводом).
**Что осталось в запасе, если места снова не хватит** (не делаем, пока нет
причины): вынести из РЕЗИДЕНТА холодные части — инициализацию в `main` +
`enter_room_side`/`pop_start_level` (~1.7 КБ), загрузчик атласов Кида
(~1.2 КБ), `pop_room_load`/`pop_level_load` (~1.4 КБ). Итого ~4-4.5 КБ
резидента, то есть куча 4301 -> ~8.6 КБ. Целиком `pop_level.c` в банк
нельзя: его `pop_level_tile` зовётся из банка на КАЖДЫЙ тайл — только
расщепление модуля.
### 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`). Это самая дешёвая
оставшаяся оптимизация: не переписывание логики, а выбор ядра.
**Что чинили:**
-`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) рисует по
фиксированным координатам; это тоже помечено в коде.
<a id="mem-bank5"></a>
### MEM-BANK5. Разгрузка W1/W2 новым банком кода — **СДЕЛАНО 2026-08-05**
Итог: `pop_ctrl.c` уехал в банк 5, куча 180 Б → **2298 Б** (после снижения
`--max-allocs`, см. [BUILD-FAST](#build-fast), — 2751 Б).
Вопрос 2026-08-04: «надо делать новый банк?». **Да, и он лечит именно то,
что жмёт.** В нашей раскладке (`MEMORY=huge`, small-вариант) CODE и DATA
живут в ОДНОМ 32-КБ пространстве W1+W2 — карта сборки на тот момент:
```
_CODE 0x4100..0xAA00 26880 Б
_DATA 0xAAD0..0xB9B0 3808 Б
_BSS 0xB9B8..0xBADA 290 Б
куча 0xBADA..0xBB00 38 Б ← упёрлись сюда, добавляя отладку
стек 0xBB00..0xC000 1280 Б
```
Поэтому **каждый килобайт кода, уехавший в банк, становится килобайтом,
доступным данным**. Отдельного «дефицита W2» у нас нет — дефицит один.
(38 байт кучи не опасны сами по себе: malloc'ом мы не пользуемся, страницы
берутся через `mem_alloc_block`. Опасно то, что следующая структура
данных упрётся в стек молча.)
Резидентный код по модулям (из `.sprinter-cc-roomtest/*.rel`):
| модуль | _CODE | как часто зовётся | в банк? |
|--------|------:|-------------------|---------|
| `pop_kid.c` | 6287 | `load_frame`/`play_seq` — 2×/кадр (Кид + страж) | частично: холодная половина (загрузка страниц спрайтов, `pop_kid_load`) — да; движок кадров — нет |
| `roomtest.c` | 3893 | main-loop | нет (точка входа, зовёт всех) |
| `pop_trob.c` | 2526 | `do_trobs` 1×/кадр, но `pop_trob_modif` — горячий аксессор | кандидат, если вынести аксессор в резидент |
| `pop_level.c`| 2292 | `pop_level_tile` — из `pop_bg` (банк 2) на каждый тайл | **нет**: банк→банк на каждый тайл убьёт отрисовку |
| `pop_ctrl.c` | 2189 | `user_control` 1×/кадр | **взят** — дёшево и безопасно |
| `pop_guard.c`| 923 | 1×/кадр | нет смысла |
Критерий кандидата — **не размер, а частота вызова и отсутствие горячих
банк→банк переходов**; `pop_level` показывает, что большой холодный на вид
модуль может быть горячим аксессором. Следующий шаг (расщепление
`pop_kid.c`) — в [`TASKS_OPEN.md`](TASKS_OPEN.md#mem-next), брать по факту
нехватки места.
<a id="build-fast"></a>
### BUILD-FAST. Сборка 10 минут → 1:48 — **СДЕЛАНО 2026-08-06** (коммит 0cd6b2d)
`--max-allocs-per-node` — во сколько вариантов размещения регистров SDCC
упирается на узел. У `sprinter-cc` дефолт 100000 (агрессивно, как fast-сборки
библиотек), и на крупных модулях roomtest это МИНУТЫ на банк. Дефолт SDCC —
3000, для разработки его достаточно: разница в размере — единицы процента.
Итог: сборка с нуля **1:48 вместо >10 минут**, куча 2751 Б вместо 2298
(на 3000 резидент иначе не влезает — замер: конец `_HOME` 0xBC69 при стеке с
0xBB00). В Makefile это `ALLOCS ?= 3000`:
```
make — быстрая сборка (ALLOCS=3000)
make ALLOCS=100000 — как раньше: минимальный код, для замеров размера
и для «релизного» образа
```
**ВАЖНО:** любое сравнение занятости банков имеет смысл только при ОДНОМ и
том же `ALLOCS` — иначе сравниваются не правки, а уровни оптимизации.
<a id="dbg-cheats"></a>
### DBG-CHEATS. Отладочные читы SDLPoP — **`[`/`]` СДЕЛАНЫ 2026-08-05**, остальное — оценка
Мотив прямой: мост 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`, **по фронту** (`*_prev`, как у остальных), иначе одно
нажатие уедет на десяток пикселей. Работает по `Kid.x` напрямую:
геометрию персонажа в этом порте меняет не диспетчер (см. грабли L1-EXIT).
- **Shift+L — следующий уровень** (вошло в L2-машинерию).
**Остальные — оценка, а не обязательство** (актуальный статус —
[`TASKS_OPEN.md`](TASKS_OPEN.md), «Отложено осознанно»):
| Чит | Вердикт |
|-----|---------|
| **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). Даёт воспроизводимый регресс «вот кадр, где баг» вместо ручной подгонки |
---
<a id="kbd-1"></a>
## KBD-1. Shift + стрелки: нажатия теряются — **ПРИЧИНА НАЙДЕНА, ФИКС ПРОВЕРЕН** (остаток отложен)
> **ПОПРАВКА К ПОСЫЛКЕ (2026-08-05).** Ниже «fake shift» подан как
> установленный факт («при зажатом Shift PS/2 удваивает трафик»). Прямой
> замер потока байт это не подтвердил: клавиатура MAME-Sprinter
> (`pc_kbd ms_naturl`) обёртку `E0 F0 12` / `E0 12` не шлёт вовсе — при
> зажатом Shift поток на стрелку ровно `E0 75 E0 75 …`. Значит удвоения
> трафика в связке Shift+стрелка нет, и мотивировка «поэтому FIFO
> переполняется» отпадает; сам ФИКС (плотный опрос `kbd_raw_poll`) остаётся
> верным и нужным — переполнение вызывает не Shift, а короткая жизнь
> импульса IRQ (пункт 3 гипотезы) плюс DI-окна графики. Разбор и следствия
> — [BUG-KBD-5](BUGS_CLOSED.md).
>
> **Итог (2026-08-01).** Причина — не наш код и не DI-окна графики: импульс
> запроса прерывания здесь теряется примерно в 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).
(**Опровергнуто замером 2026-08-05, см. поправку выше.**)
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 стрелки.
### ЧТО ИЗМЕРЕНО (сессия 2026-08-01) — гипотеза про DI НЕ подтвердилась
**Методика.** Симптом «нажатие не отработало» переведён в счётчики, чтобы не
спорить с глазами. Нажимается **Home** — тоже расширенная клавиша (тот же
`E0`-префикс), но игрой игнорируется, поэтому Кид стоит на месте и рельеф
комнаты на результат не влияет. Брейкпоинты с действием
`{ 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 байт. Насчитано **49** — и
ровно один make потерян. То есть до процессора байт не доехал вообще,
декодер тут ни при чём.
**Вывод 4: прерывание на байт теряется примерно в 44 % случаев.** На тех же
49 прочитанных байтах — только **28 входов** в клавиатурную ветку трамплина
(1.75 байта за вход). Байты копятся в трёхбайтовом FIFO вплотную к потолку;
одна неудачная пауза — и байт потерян.
### ПОТОЛОК ПЛОТНОГО ОПРОСА ИЗМЕРЕН — приём лечит полностью
`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. Клавиатура выдаёт байты
со скоростью провода: 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;
- либо хотя бы отключить 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/Ctrl/Alt.
2. ✅ Перемерено в roomtest тем же счётным методом: **35/35**, потерь нет.
3. ⏳ Вызовы в занятую треть кадра НЕ возвращены: одного idle-хука хватило.
Держать в уме, если на реальном железе или на более тяжёлых сценах
(несколько стражей) потери появятся снова — накрыть блиты дешевле, чем
изобретать что-то новое.
4. ⏳ Ручная проверка пользователем — «стало значительно лучше, иногда всё
ещё пропускает»; остаток отложен (см. врезку в начале записи).
**Куда смотреть дальше, если плотного опроса не хватит.**
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 — на
потерю байт это не влияет (линия кадрового остаётся взведённой и вызывает
повторный вход), но кадровый тик может пропасть. Отдельная мелкая правка.
**Что НЕ делать (проверено, стоило времени):**
- **Снимать `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.