# 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).
---
## QuickSave
### QSAVE. QuickSave / QuickLoad — **РЕАЛИЗОВАНО И ПРОВЕРЕНО 2026-08-22** (коммит f4b4852, тэг v0.6-pop-quicksave)
F6/F9 в roomtest. Формат снимка `'POPQ'` v3: заголовок
{P,O,P,Q,ver=3,level,reserved}, payload всех игровых переменных (Kid/Guard
через qs_char, HP, fight seed, читы speed/immortal/sound с range-валидацией
на загрузке), хвост = размер payload + XOR-контрольная сумма (старший байт =
инвертированный XOR). Носитель — файл на HDD: транзакционная запись
`unlink POP.NEW → bank_save_file → rename SAV→BAK → rename NEW→SAV`
с откатом; загрузка пробует POP.SAV, потом POP.BAK; при другом уровне —
`pop_next_level+pop_level_switch`. Сериализация через резидентные W0-примитивы
`pop_qs_*` (паттерн request-flag + `pop_qsave_process()` на границе кадра вне
Char-окон); сериализаторы добавлены в pop_map/pop_loose_mob/pop_trob/
pop_guard_ai с восстановлением инвариантов (mobs_live, trob_drawn, redraw).
Дабл-буфер закрыт `pop_qsave_restore_room()`: полная перезагрузка комнаты +
перерисовка ОБЕИХ страниц + инвалидация кэшей спрайтов и HP. Три ГСЧ
(`pop_t_seed`, `trob_seed`, `pop_fight_seed`) — все в payload.
Файловое I/O — новые `bank_load_file`/`bank_save_file` libc (без правила W3).
Критерий приёмки выполнен: сохранить, выйти по ESC, запустить заново,
загрузить — оказались там же; проверено в MAME. Полный разбор и формат —
[`../docs/quicksave_plan.md`](../docs/quicksave_plan.md) (теперь справочник).
---
## Уровни: спецсобытия
### L8-MOUSE. Мышь уровня 8 — ЗАКРЫТА 2026-08-12
Спецсобытие комнаты 16: кнопка (0,7) открывает решётку (0,3), но с левой
площадки верхнего ряда до кнопки не дойти — между ними провал в три колонки.
Когда дверь уровня уже открыта, а Кид всё ещё в комнате 16, оригинал через
150 кадров присылает мышь: она пробегает справа налево, наступает на кнопку и
убегает. Порт по SDLPoP: `do_timers` seg003:0545, `do_mouse` seg003:0ABA,
`autocontrol_mouse` seg002:07EB.
**Ассетов и отрисовки не потребовалось вовсе.** Кадры 186..188 лежат в
таблице КИДА (`load_frame` seg006:0293 — общий case), их images 130..132 уже
упакованы в `kid16.atl` (idx 2/3/4: 17×4, 19×4, 12×10) — `pop_pack_kid.py`
берёт все `res(401+N).png`, что есть в `data/KID`. Отрисовка подхватывает
мышь сама: `pop_frame_tbl_is_guard(24, …)` отдаёт 0, значит и таблица кадров,
и набор атласов берутся кидовские, и палитра (0x70) тоже.
Что написано:
| где | что |
|---|---|
| `guards.c` | `pop_check_mouse` (условие + рождение), `autocontrol_mouse`, ветки мыши в `autocontrol_opponent` / `play_guard` / `pop_check_can_guard_see_kid` |
| `pop_state.c/h` | `pop_leveldoor_open` стал **word**, как в оригинале (data.h:361): в него же считается задержка, байт переполнился бы через 255 кадров и обнулил признак «дверь открыта» |
| `pop_guard.c` | `leave_guard` не записывает в данные уровня тень и мышь (seg002:02F5) — раньше эта проверка отсутствовала вовсе |
| `pop_cdraw.c` | полосы HP у мыши нет (`draw_guard_hp`, seg000:1159) |
| `pop_tune.h` | числа события (уровень/комната/задержка/пороги X) |
**Правило появления — мышь ОДНОРАЗОВАЯ (сверено с оригиналом).** Счётчик
живёт в `leveldoor_open`: 0 на старте уровня (seg003:97), 1 — когда створка
двери уровня доехала доверху (seg007:456, то есть Кид нажал кнопку в комнате
3), дальше `++` каждый кадр, пока Кид в комнате 16, и вызов ровно при
`== 150`. Отсюда три следствия:
- второго раза НЕТ: счётчик растёт дальше, а сравнение строгое. Не успел
пройти решётку, пока она открыта — мышь больше не придёт;
- выход из комнаты 16 счётчик не сбрасывает, а ЗАМОРАЖИВАЕТ: ушёл на сотом
кадре, вернулся — ждать осталось пятьдесят;
- обнуляет только `start_level`, то есть смерть/рестарт уровня — но он же
возвращает тайлы и модификаторы, так что дверь придётся открывать заново.
Значения `0x4D` (музыка уровней 4 и 6) и `2` (победа над Джаффаром, уровень
13) в ту же переменную к уровню 8 отношения не имеют — там счёт идёт
монотонно 1 → 150.
**Чего в условии НЕТ (проверено грепом по всем упоминаниям `mouse_level` /
`mouse_room` / `do_mouse` — единственная точка запуска это seg003:545):**
оригинал не смотрит ни на состояние решётки (0,3), ни на то, с какой стороны
провала стоит Кид, ни на то, прошёл ли он её уже. «Кид заперт за закрытой
решёткой» получается САМО: находиться в комнате 16 двенадцать с половиной
секунд можно только топчась там, а стоит уйти в комнату 4 — счётчик
замирает. Обратная сторона той же простоты: вернувшись в комнату 16 с ЛЮБОЙ
стороны, игрок доигрывает накопленное ожидание и может получить мышь, даже
если решётка в этот момент открыта.
Тупика при этом нет: провал в колонках 4..6 ведёт на нижний ряд комнаты
(`debris` между колоннами, а колонна — проходимый пол, seg006:0628), оттуда
низом через комнату 4 с шипами есть путь к двери уровня в комнате 3. Цена —
падение на два ряда, то есть −1 HP. Мышь экономит эту единицу, а не спасает
от тупика.
**Тонкость, на которой легко сломать уход:** `seq_107` прыгает на метку
`Mscurry1`, стоящую ПОСЛЕ опкода `act(actions_1_run_jump)` в `seq_105` — на
обратном пути кадры бега проигрываются, а `action` остаётся стоечным. Именно
этим оригинал отличает «бежит к кнопке» от «убегает»: условие исчезновения
висит на `action == 0`.
**Проверено.** Хост-набор `t_mouse` (17 проверок, единственный, куда
линкуется `guards.c`) — рождение по таймеру, замороженный счётчик вне комнаты,
требование открытой двери, нажатие кнопки на пробеге, разворот и исчезновение,
невидимость для Кида, необращение в «стража комнаты». Регресс проверен
откатом: без ветки в `autocontrol_opponent` падают две проверки ухода.
Живьём в MAME (комната 16 уровня 8, `leveldoor_open` взведён через мост):
мышь появилась (`charid = 0x18`), добежала до колонки 7 (кадр 188, x = 164),
решётка (0,3) поехала вверх, мышь ушла вправо и погасла (`direction = 0x56`).
### MEM-COLD1. Холодная половина главного цикла — в банк 8 (2026-08-12)
К уровню 8 куча в режиме huge упала до **879 Б**. Убирать из `roomtest.c`
оказалось нечего — отладочное там всё живое (обход комнат, стоп-кадр, читы,
профиль), — поэтому вынесли в банк то, что отрабатывает раз на уровень или
только по клавише: `pop_start_level` + `find_start_level_door`, чит-навигацию
по комнатам и лейбл номера комнаты (`roomtest_cold.c`, `--bank 8`, `n_banks`
поднят до 8).
Результат: `_CODE` 25653 → 24825, **куча 879 → 1707 Б**, банк 8 занят на 5 %.
Кадровый путь не тронут: за обычный кадр в банк 8 уходит ноль вызовов, при
смене комнаты — один трамплин.
Что осталось в резиденте намеренно: `enter_room_side` и рабочие массивы
комнаты (их читает и главный цикл, и холодная половина), `guard_over_kid` с
хелперами (зовётся каждый кадр). Следующий шаг — [MEM-COLD2](TASKS_OPEN.md#mem-cold2).
---
## Приёмка уровней
### ПОЛИТИКА ПРИЁМОК — решение пользователя 2026-08-11
**Уровни 1-4: smoke-тесты пройдены.** Полные прогоны (обход всех комнат
каждого уровня) делаются **по готовности ВСЕХ уровней**, а не по одному за
этапом — отдельных задач `L3-PASS`/`L4-PASS` больше нет.
Основание: сквозные обходы дорогие, а половина находок на неполном наборе
уровней всё равно оказывается «механики ещё нет». Smoke (пройти уровень от
старта до двери) остаётся обязательным на каждом новом уровне — он снимает
блокеры, а не косметику.
Уже сделанные полные обходы уровней 1 и 2 (ниже) остаются регресс-базой.
### 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`).
### 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.
---
### Справка: связность комнат уровней 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
(если выжил) выхода нет вовсе — так в оригинале. При приёмке не считать
«застрял» багом.
---
## Уровни и механика
### 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_column−4)*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_CLOSED.md#mirror-fg-stale): тайл ставится в момент,
когда дверь дорисовала открытие, а в реальном прохождении игрок в этот момент
в другой комнате; при обычном входе комната и снимок `room_fg` берутся из
данных уровня разом.
### 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)).
### 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)).
### 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).
---
## Отрисовка и память
### 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)).
### 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) рисует по
фиксированным координатам; это тоже помечено в коде.
### 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), брать по факту
нехватки места.
### 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` — иначе сравниваются не правки, а уровни оптимизации.
### 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). Даёт воспроизводимый регресс «вот кадр, где баг» вместо ручной подгонки |
---
## 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.
---
### L5-SHADOW. Тень уровня 5: крадёт зелье — **СДЕЛАНА 2026-08-11**
Единственная новая механика уровня 5 (тайлов новых нет вовсе, см. цель выше).
Тень появляется в комнате 24, дожидается, пока откроется дверь, идёт к зелью,
**выпивает его** и уходит за левый край. Боя нет.
**Как это в оригинале** (всё сверено по коду, `custom->*` — это дефолты 1.0):
| что | где | суть |
|---|---|---|
| появление | `check_shadow`, seg002:0064 | при СМЕНЕ КОМНАТЫ: если `current_level == 5` и `drawn_room == 24`, и тайл (кол 3, ряд 0) всё ещё `tiles_10_potion` — породить тень |
| порождение | `do_init_shad`, seg002:0000 | `memcpy(&Char, init_shad_5, 7)` + `seqtbl_offset_char(2 /*stand*/)`, `charid = charid_1_shadow`, `demo_time = 0`, `guard_skill = 3`, `guardhp_* = 4`, `saveshad()` |
| данные | `init_shad_5` | `{0x0F, 0x37, 0x37, 0, 0xFF, 0, 0}` = frame 15, x 55, y 55, direction 0, curr_col −1, curr_row 0, action 0 |
| поведение | `autocontrol_shadow_level5`, seg002:1157 | в комнате 24: пока `demo_time == 0` — ждать, пока дверь (кол 1, ряд 0) не откроется (`modif >= 80`), затем `demo_index = 0`; дальше каждый кадр `do_auto_moves(shad_drink_move)`; при `Char.x < 15` — `clear_char()` |
| движения | `do_auto_moves`, seg002:1089 | крошечный интерпретатор: `demo_time++`, по таблице `{time, move}` выбирается запись, `move` = 0 nothing / 1 forward / 2 backward / 3 up / 4 down / 5 up+forward / 6 shift / 7 move_7; −1 = ничего, −2 = конец |
| таблица | `shad_drink_move` (data.h:866) | `{0x00,0} {0x01,1} {0x0E,0} {0x12,6} {0x1D,7} {0x2D,2} {0x31,1} {0xFF,−2}` — 8 записей по 2 байта |
**Что из этого у нас уже есть:**
- **механизм «спецсобытие порождает персонажа в слоте соперника»** —
`pop_check_skel` (`guards.c`, зовётся из `roomtest.c` в тике); тень
уровня 5 садится на тот же шов, только условие другое;
- **тень как `charid_1_shadow`** — заведена под уровень 4
([L4-MIRROR](TASKS_CLOSED.md#l4-mirror)): своя ветка ИИ
(`autocontrol_shadow` + `autocontrol_shadow_level4`), выбор таблицы кадров
Кида (`pop_frame_tbl_is_guard`), отрисовка спрайтами Кида;
- **питьё зелья** — `SEQ_78_DRINK` и `get_item` в `pop_ctrl.c` (Кид уже
умеет); тень «нажимает» те же кнопки через автодвижения;
- **зелья как trob** — фаза пузырька, тип в старших битах (`pop_trob.c`).
**Что писать:**
1. `do_auto_moves` + таблица `shad_drink_move` + `demo_time`/`demo_index` —
интерпретатор ~30 строк, кладётся рядом с `autocontrol_shadow` в
`guards.c` (банк 1). «Движения» — это те же переменные управления, что
заполняет `read_user_control` (`pop_ctrl.c`), так что `move_*` сводятся к
присваиваниям.
2. `do_init_shad(init_shad_5, seq stand)` — общий порождатель тени; пригодится
и на уровнях 6 и 12 (`init_shad_6`, `init_shad_12` — те же 7 байт).
3. Ветка `check_shadow` для уровня 5 — по образцу `pop_check_skel`, вызов из
того же места тика.
4. `autocontrol_shadow_level5`.
5. **Ветка ТЕНИ в `check_guard_fallout`** (seg002:0241): тень падает, только
если она в свободном полёте (`action == 4`), и тогда
`loadshad(); clear_char(); saveshad()`. Сейчас в `pop_guard_fallout`
(`pop_guard.c`) есть ветки стража и скелета, а тени нет — комментарий там
обещает её «вместе с L3-SKEL», но она относится именно к тени.
**Чем подтверждать:** smoke уровня 5 — дойти до комнаты 24, увидеть, как
тень выходит после открытия двери, выпивает зелье (тайл зелья исчезает) и
уходит влево. Сверять последовательность движений с живым SDLPoP на том же
месте — таблица `shad_drink_move` короткая, расхождение будет видно сразу.
**Оговорка по виду:** тень пока рисуется обычной копией спрайтов Кида, то
есть выглядит вторым Кидом — это [отложенный](../docs/shadow_render.md)
вопрос, к механике уровня 5 отношения не имеет.
---
### GUARD-PHYS. Страж живёт по тем же правилам, что Кид — **ЗАКРЫТА 2026-08-11**
> **Что уже работает** (решение пользователя: переносим физику на `Char`,
> без предварительных замеров — иначе третий-четвёртый экземпляр той же
> логики неизбежен).
>
> - **физика переведена на `Char`**: `pop_map.c` целиком работает с активным
> персонажем, а кто в `Char` — решают окна `loadkid`/`savekid` и
> `loadshad`/`saveshad`, как в оригинале (seg006:809). Два входа:
> `pop_phys_tick` (порт хвоста play_kid_frame) и `pop_guard_phys_tick`
> (порт play_guard_frame) — списки вызовов отличаются ровно тем, чем в
> оригинале;
> - **`take_hp` стал общим** (`pop_take_hp` в резиденте `pop_guard.c`): урон
> идёт тому, кто в `Char`, по `charid`. Раньше у боёвки и у физики были
> свои копии, причём у физики неверная — правила `hitp_curr` мимо дельты и
> про не-Кидов не знала;
> - **порт веток по charid в `land()`** (seg005:173): страж гибнет с двух
> рядов, тень падает как с одного, у не-Кида приземление даёт боевую
> стойку; `check_guard_bumped` (seg004:0522), `droppedout` +
> `guard_follows_kid_down` (seg002:09F8), `check_guard_fallout`
> (seg002:0241);
> - **`pop_savekid_state` снова полное `Kid = Char`**, а
> `pop_load_fram_det_col` пересчитывает колонку ЛЮБОМУ персонажу — обе
> заплатки существовали только потому, что физика знала один `Kid`.
>
> **Проверено:** `tests-host` — трассы Кида не изменились (`[phys] ok: 1723`,
> тот же эталон), новый набор `t_char` (32 проверки) покрывает ветки по
> персонажам; в MAME проверено, что игра жива (респавн, бег, падение,
> приземление). Цена: `_CODE` +80 Б, банк 3 (`pop_map`) 7973 → 8485
> (51.8 %), банк 1 (`guards`) 2042 → 2156.
>
> **Проверено пользователем 2026-08-07:** страж СПРЫГИВАЕТ ЗА КИДОМ на ряд
> ниже — связка «ИИ + физика» работает вживую, не только в тестах.
>
> **`follow_guard` портирован и проверен в MAME (2026-08-07):** уровень 1,
> бой в комнате 3, Кид отступает влево — страж приходит следом (`Guard.room`
> 3 → 2, X перенесён через шов), ровно как в SDLPoP. Условия отбора
> покрыты тестами `t_char` (7 сценариев: пороги 91/165, «не бой», мёртвый,
> вверх/вниз, занятая соседняя комната). **Сцена вскрыла отдельный баг —
> [BUG-SWORD-GHOST-1](BUGS_CLOSED.md#bug-sword-ghost-1): при переходе в бою Кид
> прячет меч и дальше дерётся пустой рукой.**
>
> **Осталось (потому и запись открыта) — ревизия 2026-08-11 по коду:**
> 1. ~~`check_chomped_guard`~~ — **сделан** вместе с
> [L3-CHOMP](TASKS_CLOSED.md#l3-chomp) (`pop_map.c`);
> 2. ветки `check_guard_fallout`: **скелет сделан** (возрождается в комнате 3,
> `pop_guard_fallout` в `pop_guard.c`), **ветки ТЕНИ нет** — падает только
> в свободном полёте, `loadshad/clear_char/saveshad`; идёт в
> [L5-SHADOW](#l5-shadow) п. 5 (комментарий в коде обещает её «вместе с
> L3-SKEL» — устарел, это про тень);
> 3. страж, нажимающий напольную кнопку, вживую не проверялся (код —
> общий `check_press`).
**Почему это была ОДНА физика, а не «сделаем стражу свою».** В оригинале
слот `Guard` — не «стражи», а все НЕ-Киды: тем же `play_guard_frame` ходят
**страж** (charid 2), **скелет** (4, ур. 3), **тень** (1, ур. 4/5/6/12),
**визирь-Джаффар** (ур. 13), **толстяк** (FAT, ур. 12) и **мышь** (0x18,
ур. 8) — `tbl_guard_type` = {0,0,0,2,0,0,1,0,0,0,0,0,4,3,−1,−1}, а
`autocontrol_opponent` (seg002:628) разводит их ТОЛЬКО по ИИ. То есть
второй экземпляр логики пришлось бы делать не один раз, а пять.
**Гард по X: страж не уходит САМ — но его МОГУТ ПЕРЕНЕСТИ.** Поправка к
формулировке, которая была здесь раньше («страж не покидает комнату ни в
каком виде») — она неверна, контрпример дал пользователь: в SDLPoP страж из
комнаты 3 оказывается в комнате 2 вслед за отступающим Кидом.
Разделять надо два разных механизма:
- **своим ходом — не может.** Физика персонажа слота Guard обёрнута
`Char.room == drawn_room` и `Char.x >= 44 && Char.x < 211` (seg000:1252),
и никакого `check_leave` в его списке вызовов нет. Провалившегося ниже
комнаты убирает `check_guard_fallout` (seg002:0241) — вниз он не уходит.
- **следом за Кидом — переносит движок.** `exit_room` (seg002:03C7)
вызывается ПОСЛЕ того, как комнату сменил Кид, и решает судьбу стража:
```c
if (Guard.alive < 0 && Guard.sword == sword_2_drawn) { // жив и В БОЮ
if (guards_tile[kid_room−1] >= 30 || // в новой комнате
guards_seq_hi[kid_room−1] != 0) { // своего живого нет
if (ушёл ВЛЕВО) { if (Guard.x >= 91) leave = 1; } // страж далеко — остаётся
else if (ВПРАВО) { if (Guard.x < 165) leave = 1; }
else if (ВВЕРХ) { if (Guard.curr_row >= 0) leave = 1; } // всегда → не идёт
else { if (Guard.curr_row < 3) leave = 1; } // вниз → не идёт
} else leave = 1;
} else leave = 1;
leave ? leave_guard() : follow_guard();
```
`follow_guard` (seg002:039E) стирает `guards_tile` в ОБЕИХ комнатах
(0xFF — «стража здесь нет», чтобы он не раздвоился) и гонит стража через
`goto_other_room` в окне `loadshad`/`saveshad`.
**Что это значит для нас.** У нас в `enter_room` (`roomtest.c:265`) стоит
безусловный `pop_guard_leave()` — то есть всегда ветка `leave_guard`, и
страж ВСЕГДА остаётся. Портировать надо сам `exit_room`-выбор: условия
«жив + меч вынут + в целевой комнате нет своего стража + он у нужного
края» и `follow_guard`. Пороги 91/165 — это «страж у того края, в который
ушёл Кид»; вверх и вниз оригинал не пускает никогда.
**Чем подтверждать:** уровень 1, комната 3 — начать бой, отступить влево в
комнату 2: страж обязан прийти следом и продолжить бой (как в SDLPoP на
скриншотах пользователя). Обратная проверка: если в соседней комнате СВОЙ
живой страж — переход не происходит.
---
### L6-SHADOW. Тень уровня 6 роняет решётку — СДЕЛАНА 2026-08-11
**Сцена (уровень 6, комната 1).** Кид жмёт `opener` (1,7) — решётка (1,2)
поднимается; он разбегается, прыгает через четырёхтайловую пропасть и
цепляется за порожек решётки, чтобы подтянуться. В этот момент тень делает
ОДИН осторожный шаг с границы (1,0)-(1,1) на `closer` (1,1), и решётка
падает. Обе плиты комнаты ведут на один тайл 12 (проверено по LINKLOC).
**Порт.** Две точки, обе в `seg002.c`:
- `check_shadow` (seg002:0090): условий на содержимое комнаты нет — тень
встаёт при КАЖДОМ входе в комнату 1. `do_init_shad` переписан под
таблицы (`init_shad_5`/`init_shad_6` — первые 7 полей `Char`, как
`memcpy(&Char, source, 7)` у оригинала);
- `autocontrol_shadow_level6` (seg002:1064): `Kid.frame == 43` (кадр
бег-прыжка) и `Kid.x < 128` -> `move_6_shift()` + `move_1_forward()`.
Это Shift+вперёд, то есть ОСТОРОЖНЫЙ ШАГ, а не прыжок; кадр 43 —
условие на КИДА, тень им только триггерится.
**Звук** `sound_25_presentation` не портирован (звука нет вовсе); флаг
оригинала `leveldoor_open = 0x4D` не воспроизводим — чужую переменную
занимать незачем.
**Проверено в MAME (пользователь, 2026-08-11):** «Кид прыгнул, зацепился,
Тень сделал шаг, решётка упала». Тем самым закрыт и остаток
[GUARD-PHYS](TASKS_CLOSED.md#guard-phys): нажатие плиты НЕ-Кидом работает
(`check_press` в `guard_phys`, без гейта по `charid`).
**Фон комнаты сверен с оригиналом** методом из
[`BUGS_CLOSED.md`](BUGS_CLOSED.md#bug-lattice-doortop): 539 пикселей
расхождения, и все — силуэты персонажей (тень пока рисуется палитрой Кида,
см. [`../docs/shadow_render.md`](../docs/shadow_render.md)).
**Цена:** банк 1 2755 -> 2877 Б, резидент без изменений.
---
### Переход 6 -> 7 падением — СДЕЛАН 2026-08-11
Уровень 6 не заканчивается дверью: Кид проваливается вниз из комнаты 1 и
этим попадает на 7-й. Порт двух половин:
- **выход** — `leave_room` (seg002:0504) для направления «вниз» на уровне 6
из комнаты 1 возвращает особый результат −2, а главный цикл (seg000:0893)
разбирает его как `Kid.y = -1; ++next_level`. У нас это ветка в обработке
`pop_fell_out` (`roomtest.c`), и она ОБЯЗАНА идти раньше связи вниз: у
комнаты 1 сосед снизу есть (шахта, комната 3), и без спецсобытия Кид
улетал туда, а оттуда — в рестарт уровня;
- **вход** — `set_start_pos` (seg003:0196): на 7-м уровне Кид ставится в
комнату 17, после чего экран сразу переводится на комнату ПОД ней
(`goto_other_room(3)`: y −= 189, ряд пересчитать) — он влетает сверху.
Константы `FALLING_EXIT_LEVEL/ROOM`, `FALLING_ENTRY_LEVEL/ROOM` — в
`pop_guard.h`.
**Проверено пользователем в MAME:** «переход на уровень 7 сработал».
**Остаток на следующую сессию:** влетая в комнату, Кид должен уметь
зацепиться за край пола, мимо которого пролетает — этой механики у нас,
похоже, нет (см. `NEXT_SESSION.md`, п. 1).
---
## HOF-ENTRY. Таблица рекордов — **СДЕЛАНО И ПРОВЕРЕНО В MAME 2026-08-26**
Порт двух показов оригинала. `pop_hof.c` был написан раньше, но никуда не
вызывался: после финала автомат уходил мимо него в title, а на титрах экран
получался ЧЁРНЫМ.
**Где это в оригинале.** `show_title()` (seg000:2060) показывает
сохранённую таблицу между credits и attract-demo — 240 тиков, переходом
слева направо, без fade. `end_sequence()` (seg001:5F1) после HAIL проявляет
титульную картинку (240 тиков), при подходящем результате гасит её, показывает
таблицу с золотой полосой ввода, принимает имя, ждёт 120 тиков, снова
проявляет титульную картинку и лишь потом дослушивает победную тему
(`while (check_sound_playing() && !key_test_quit())`, seg001:637).
**Два корня, из-за которых экран был пуст.**
1. `story.pal` — 256 записей, и запись 0x3F (цвет глифов шрифта,
`POP_FONT_COLOR`) в ней ЧЁРНАЯ. Текст рисовался, но был не виден.
Теперь генератор кладёт в 0x3F золотой 0xB7 оригинала.
2. Полосовой переход копирует страницу АКСЕЛЕРАТОРОМ, а тот читает ОЗУ-копию
(`gfx_copy_page`: «копируется чистый фон без спрайтов»). `GFX_BANK_SPRITE`
— это NOSHADOW + TRANSPARENT, то есть в копию он не пишет, и на титрах
переезжал один фон без строк. Лечится банком `GFX_BANK_TRANSPARENT`
(0x58): та же прозрачность 0xFF, но с записью в копию — страница
собирается ЦЕЛИКОМ до показа, как offscreen оригинала.
**Что появилось попутно.**
- `s5` в архиве PV — фон таблицы: рамка story + логотип PRINCE OF PERSIA на
y=24 (`HOF_POP` оригинала — тот же спрайт res54, что и в титрах).
- Отдельный индекс палитры под фон текстовой рамки (`POP_PAL_STORY_BG`, 16).
`load_title_images(bgcolor)` красит его в #100060 на титрах и в #800000 в
финале; раньше фон был ремаплен в индекс 9, а тот занят самой титульной
картинкой (8265 пикселей), и подменить его было нельзя.
- Второй набор глифов в `font.atl` цветом `POP_FONT_DARK_COLOR` (0x3E).
У SDLPoP шрифт — маска, и `show_hof_text` рисует текст дважды разным
цветом; у нас цвет запечён в пиксели, поэтому «другой цвет» = другой набор.
Каталог SPA1 держит счётчик картинок в одном байте, поэтому тёмный набор
обрезан по '_' (32..95): 190 + 64 = 254 из 255 возможных.
- Общий `pop_screen_present_ltr()` в `pop_ui.c` — им теперь пользуются и
story-переход интро, и таблица, и титульная картинка финала
([CUTSCENE-LTR](BUGS_CLOSED.md#cutscene-ltr)).
**Формат `POP.HOF`** — 6 записей (как `MAX_HOF_COUNT`), имя до 15 символов,
XOR+инверсия в хвосте; версия 2. Проверено на образе: после ввода имени файл
128 Б, `PHOF`, запись читается обратно и показывается на титрах.
**Что проверено в MAME** (сборка `LEVEL=14`, вход в комнату 5 читом обхода
комнат): цепочка HAIL → титульная картинка → таблица с полосой ввода и
временем справа → ввод имени → титульная картинка; на следующем запуске
таблица показывается между credits и demo.