Files
Sprinter-SDCC/applications/PoP/roomtest/TASKS_CLOSED.md
T
snark13 4b74478d19 Таблица рекордов на титрах проявляется собранной
Строки дорисовывались уже после полосового перехода: он копирует страницу
акселератором, а тот читает ОЗУ-копию, куда GFX_BANK_SPRITE (NOSHADOW +
TRANSPARENT) не пишет. Банк GFX_BANK_TRANSPARENT даёт ту же прозрачность
0xFF, но обновляет и копию, поэтому страницу можно собрать целиком до
показа — как offscreen оригинала (draw_full_image + show_hof, и только
потом transition_ltr).

Проверено в MAME: на промежуточных кадрах перехода строка рекорда видна в
уже проявившейся части экрана вместе с логотипом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:18:58 +03:00

1378 lines
109 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).
---
## QuickSave
<a id="qsave"></a>
### 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) (теперь справочник).
---
## Уровни: спецсобытия
<a id="l8-mouse"></a>
### 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`).
<a id="mem-cold1"></a>
### 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).
---
## Приёмка уровней
<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_CLOSED.md#mirror-fg-stale): тайл ставится в момент,
когда дверь дорисовала открытие, а в реальном прохождении игрок в этот момент
в другой комнате; при обычном входе комната и снимок `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.
---
### <a id="l5-shadow"></a>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 отношения не имеет.
---
### <a id="guard-phys"></a>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_room1] >= 30 || // в новой комнате
guards_seq_hi[kid_room1] != 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 на
скриншотах пользователя). Обратная проверка: если в соседней комнате СВОЙ
живой страж — переход не происходит.
---
### <a id="l6-shadow"></a>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 Б, резидент без изменений.
---
### <a id="l6-l7-falling"></a>Переход 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).
---
<a id="hof-entry"></a>
## 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.