Files
Sprinter-SDCC/applications/PoP/roomtest/TASKS_OPEN.md
T
snark13 844fa6d767 L4-MIRROR шаг 4: прыжок сквозь зеркало и рождение тени
Порт seg003:0798..08A9 + seg004:0239 + seg002:081D/1131 + seg006:1945.

- is_obstacle (pop_map.c): ветка зеркала — Кид, кадры бегового прыжка
  39..43, направление ВЛЕВО -> modif = 0x56, pop_jumped_mirror = -1,
  препятствия нет (пролетает насквозь).
- mirror_image / jump_through_mirror / pop_check_mirror (pop_map.c):
  отражённый Char уходит в слот Guard как CHARID_1_SHADOW, guardhp =
  hitp_max, у Кида hitp_curr = 1.  savekid НЕ делается — как в оригинале,
  отражается только копия.  Полосы HP перерисует pop_hp_draw сам.
- pop_check_mirror() зовётся из главного цикла ПЕРЕД отрисовкой персонажей
  (в оригинале — первая строка draw_people, seg008:228A).
- autocontrol_shadow + autocontrol_shadow_level4 + clear_char (guards.c):
  тень идёт СВОЕЙ веткой целиком, к стражьему ИИ не сводится — на уровне 4
  она не дерётся, а бежит влево и при x < 80 исчезает.

АТЛАС ТЕНИ — вскрылось при чтении seg006:0532.  Тень вне боевых кадров
150..189 ходит по таблице КИДА, и image оттуда индексирует спрайты Кида,
а не стража.  Выбор атласа в pop_cdraw шёл по СЛОТУ, то есть тень
рисовалась бы спрайтами стража.  Условие вынесено в
pop_frame_tbl_is_guard() (pop_kid.c) — его теперь читают и load_frame, и
отрисовка, разъехаться не могут.  Заодно из pop_load_fram_det_col выделен
pop_load_frame() без determine_col: jump_through_mirror берёт ось
отражения из curr_col, и пересчёт колонки по x там был бы вреден.

Константы MIRROR_* и DIR_56_NONE переехали в pop_guard.h — нужны и
постановке тайла (банк 6), и ИИ тени (банк 1).

Звука sound_45_jump_through_mirror в порте нет, пропущен.

Шаг 5 (клип тени слева от зеркала) НЕ сделан и оказался не однострочником:
в pop_cdraw есть клип сверху/снизу/справа, левого нет вовсе — нужен новый
примитив либо срез исходных колонок.  Расписано в TASKS_OPEN.

Цена: _CODE +18 Б, банк 1 2311 -> 2367, банк 3 10328 -> 10551,
банк 4 8243 -> 8267.  tests-host: все 5 наборов прошли.
НЕ ПРОВЕРЕНО В MAME — сценарий проверки записан в TASKS_OPEN.

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

882 lines
71 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-08)
Что берём в работу сейчас и в каком порядке. Каждая запись: что сделать,
почему именно сейчас, чем подтверждать результат.
- закрытые задачи с протоколами и замерами — [`TASKS_CLOSED.md`](TASKS_CLOSED.md);
- открытые баги — [`bug_list.md`](bug_list.md), закрытые с разбором корней —
[`bug_closed.md`](bug_closed.md);
- планы фаз — `../docs/PORT_PLAN.md`, `../docs/layout_plan_v2.md`,
`../docs/levels_plan.md`.
**Состояние на 2026-08-07:** пользователь прогнал ВСЕ комнаты уровней 1 и 2 —
крупных багов нет. Приёмки [L1-PASS](TASKS_CLOSED.md#l1-pass) и
[L2-PASS](TASKS_CLOSED.md#l2-pass) закрыты; с прогона открыты три записи по
уровню 2, из них цвет стража и брызги уже закрыты (см.
[`bug_closed.md`](bug_closed.md)); открытым остался чит `+`/`` в бою.
Уровень 3: **скелет сделан и проверен в MAME 2026-08-07** (бой, падение в
пропасть, окклюзия), чомперов ещё нет.
**Разгрузка банка 2 сделана 2026-08-08** ([MEM-BANK2](TASKS_CLOSED.md#mem-bank2)): 90.4 % →
**35.8 %, свободно 10 512 Б** — холодная половина уехала в банк 7
(`pop_room.c`), чомперам места с запасом.
**DRAW-CHAR сделана и ПРОВЕРЕНА 2026-08-08** (протокол и замеры —
[`TASKS_CLOSED.md`](TASKS_CLOSED.md#draw-char)): отрисовка теперь одна на
всех `Char`. Банку 2 она дала всего +265 Б — место под чомперов дала уже
[MEM-BANK2](TASKS_CLOSED.md#mem-bank2).
Правило проекта в силе: механику сверять с `../SDLPoP/src/` ДО кодинга;
диагноз платформы подтверждать артефактом (брейкпоинт/дамп/.asm), а не
гипотезой (memory `defer_unexplained_quirks`).
---
## ТЕКУЩАЯ ЦЕЛЬ: уровни 1–3 (подземелье) отлажены целиком
Решение 2026-08-04: **palace (уровни 4+) откладываем**, доводим до
играбельности три dungeon-уровня. Основание — они не требуют ни одного
нового ассета фона: инвентарь тайлов, снятый с `res200N.bin`, показывает,
что новое появляется только так —
```
ур. 1 empty, floor, spike, pillar, gate, closer, potion, loose, debris,
opener, level_door L/R, torch, wall, skeleton, sword ← всё есть
ур. 2 bigpillar_bottom(8), bigpillar_top(9), doortop(12) ← есть (2026-08-04)
ур. 3 chomper(18) ← НЕТ механики
ур. 4 lattice_pillar(25)…lattice_right(29) + тайлсет palace ← отложено
```
| # | Задача | Что | Блокирует |
|---|--------|-----|-----------|
| 1 | [GUARD-PHYS](#guard-phys) | физика стража = физика Кида (одна над `Char`) | **ядро сделано 2026-08-07**; открыт живой сценарий в MAME |
| — | [DRAW-CHAR](TASKS_CLOSED.md#draw-char) | отрисовка одна на всех `Char`**закрыта и проверена 2026-08-08** | — |
| — | [MEM-BANK2](TASKS_CLOSED.md#mem-bank2) | разгрузка банка 2 — **закрыта 2026-08-08**: 90.4 % → 35.8 %, свободно 10 512 Б | — |
| 2 | [L3-CHOMP](#l3-chomp) | **СЛЕДУЮЩАЯ**: чомперы (5 шт) | прохождение ур. 3 |
| — | [L3-SKEL](#l3-skel) | скелет ур. 3 — **сделан 2026-08-07**, ждёт финальной приёмки | — |
| 3 | [L3-PASS](#l3-pass) | приёмка уровня 3 (обход комнат) | закрытие цели |
| 4 | [L4-MIRROR](#l4-mirror) | **АКТИВНА с 2026-08-10 по решению пользователя**: зеркало уровня 4 + тень | прохождение ур. 4 |
| — | [DRAW-COST](#draw-cost) | **кадр уложился в бюджет 2026-08-09**: 470 964 -> 425 600 тактов, период цикла 4 растровых кадра -> **3** (игра быстрее на треть). Дальнейшее — запас, не срочность | плавность на ВСЕХ уровнях |
| — | [L1-SPEED](#l1-speed) | игра на ~39 % быстрее оригинала | ощущение от ВСЕХ уровней; берётся в любой момент |
| — | [TUNE-1](#tune-1) | параметры движка → cfg-файл (сейчас `pop_tune.h`) | отладка таймингов и моды; берётся по мере надобности |
Сделанное по этой цели — в [`TASKS_CLOSED.md`](TASKS_CLOSED.md): L2 (машинерия
уровней), L2-PASS, L1-PASS, [L3-CHKP](TASKS_CLOSED.md#l3-chkp) (чекпойнт
уровня 3), MEM-BANK5, CLIP-1, KBD-1, DBG-CHEATS.
---
## Ждёт ФИНАЛЬНОЙ приёмки уровней 1–3
Сюда попадает то, что уже работает в проверочном прогоне, но должно быть
подтверждено на сквозных прогонах уровней — потому что задевает механику
шире, чем собственный сценарий.
- **Зацеп ПРЯМО В ПРЫЖКЕ** (`POP_ENABLE_JUMP_GRAB`, `pop_tune.h`, сделан
2026-08-06, предварительно проверен пользователем). Почему нужен именно
финальный прогон: точки вызова стоят не только в `check_action`, но и в
ОБЕИХ ветках `check_bumped` — то есть код вклинивается перед обычным
ударом о стену. Регрессия проявится не в самом зацепе, а рядом: удар о
стену с зажатым Shift, осторожный шаг у стены, отскок в прыжке. На
уровнях 1–3 это надо специально потрогать в паре мест каждого уровня.
Напоминание: в ВАНИЛИ этого зацепа нет (у SDLPoP — `enable_jump_grab`),
так что сверять его с оригиналом «как есть» нельзя — только с SDLPoP при
включённых enhancements.
Прогон 2026-08-07 (уровни 1 и 2) регрессий рядом не показал, но специально
на удар о стену с Shift не проверялся.
## P0 — делаем сейчас
### <a id="guard-phys"></a>GUARD-PHYS. Страж живёт по тем же правилам, что Кид — **ЯДРО СДЕЛАНО 2026-08-07**
> **Что уже работает** (решение пользователя: переносим физику на `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](bug_list.md#bug-sword-ghost-1): при переходе в бою Кид
> прячет меч и дальше дерётся пустой рукой.**
>
> **Осталось (потому и запись открыта):**
> 1. `check_chomped_guard` — вместе с [L3-CHOMP](#l3-chomp);
> 2. ветки `check_guard_fallout` для тени и скелета (скелет возрождается в
> комнате 3) — вместе с [L3-SKEL](#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="l3-chomp"></a>L3-CHOMP. Чомперы — механика уровня 3
**Где:** 5 штук, комнаты 5(тайл 4), 16(23,24,25), 22(26). Дальше они почти
на каждом уровне, так что это вложение не только в ур. 3.
**Что портировать:** `animate_chomper` (seg007) — состояние в `room_modif`,
как у пик/ворот, значит шаблон уже отработан (`gates_spikes_plan.md`);
коллизия и смерть Кида в сомкнутых челюстях (`seg004`/`seg006`); отрисовка
кадра по модификатору (`draw_tile_anim`, ветка `tiles_18_chomper`).
**Место в банке 2 расчищено** ([MEM-BANK2](TASKS_CLOSED.md#mem-bank2), 2026-08-08):
35.8 %, **свободно 10 512 Б** (было 1074 Б) — холодная половина слоя фона
уехала в банк 7 (`pop_room.c`). Считать место ДО кодинга всё равно
обязательно (`levels_plan.md` §5.1); чомпер — это анимация тайла (холодная
перерисовка), поэтому его отрисовка ложится в `pop_room.c`, а состояние —
в `room_modif`, как у пик и ворот.
**Ассеты УЖЕ упакованы (2026-08-07):** `pop_pack_bg.py` кладёт в атлас весь
набор кадров чомпера явно (`CHOMPER_BOT_IDS` 101-105, `CHOMPER_TOP_IDS`
111-113, `CHOMPER_FORE_IDS` 106-110 + кровь 114-123 mono-силуэтом цветом 12).
Раньше в атласе не было НИ ОДНОГО его кадра: `render_room.py` пропускает
анимированные тайлы, а `tile_table[0x12].base_id = 0` — отсюда и «чомпера не
видно вовсе». Рост: `pop_env3.atl` 5200 -> 12937 Б, `pop_fore.atl` 7763 ->
12359 Б, число EMM-страниц НЕ изменилось (7).
**Чем подтверждать:** комната 22 уровня 3 — чомпер (2,6) анимируется и
рисуется (сейчас его не видно вовсе, см. раздел «Уровень 3» в
[`bug_list.md`](bug_list.md)); смерть Кида в сомкнутых челюстях.
### <a id="l3-skel"></a>L3-SKEL. Скелет — единственный противник уровня 3
**Важно:** в данных уровня 3 **стражей нет вообще** (`guards_tile` пуст во
всех 24 комнатах). Единственный враг — скелет, и он не «страж из данных»,
а **спецсобытие** `check_skel` (seg002:1044):
> в комнате 1, когда `Kid.curr_col` == 2 или 3 и дверь уровня открыта,
> тайл `tiles_21_skeleton` (комната 1, тайлпос 15) стирается в пол, а на
> его месте поднимается персонаж: `charid_4_skeleton`, меч сразу вынут,
> `seq_88_skel_wake_up`, skill 2, HP 3.
Ещё два тайла скелета (комнаты 17 и 19) — декорация, они не оживают.
> **Сделано 2026-08-07 (ждёт живой проверки на уровне 3):**
> - **атлас скелета** — `pop_pack_guard.py SKEL` собирает `poc/res/skel/g0..g3.atl`
> (28 кадров, 30 КБ, 4 EMM-страницы); упаковщик получил параметр набора
> (`GUARD`/`SKEL`). Палитра у скелета СВОЯ (`SKEL/res750.pal`), а не из
> `res10.bin`: на уровне 3 `tbl_guard_type != 0`, значит
> `curr_guard_color = 0` и оригинал палитру не подменяет вовсе;
> - `pop_guard_load` выбирает набор по типу уровня и заливает палитру
> скелета; Makefile кладёт атласы в `SKEL\` на диск;
> - **`check_skel`** (seg002:1042) — порт в `guards.c`: уровень 3, комната 1,
> дверь уровня открыта, `Kid.curr_col` 2 или 3, тайл 21 на (5,1) → тайл
> стирается в пол (обе страницы), персонаж встаёт с `seq_88_skel_wake_up`,
> мечом наголо, skill 2, HP 3. Зовётся из главного цикла ДО логики
> персонажей, как в `play_frame`;
> - **`leveldoor_open`** — флаг появился (`pop_state.c`), взводит анимация
> двери при `modif >= 43` (seg007:456);
> - **`enter_guard`** — ветка `charid_4_skeleton`: встаёт сразу активным
> (меч вынут), а не в стойке покоя;
> - **возрождение** — `check_guard_fallout`: упавший скелет, под комнатой
> которого лежит комната 3, появляется там снова (x 133, ряд 1);
> - **ИИ** — `autocontrol_skeleton` (seg002:685): меч у скелета вынут всегда.
>
> Регресс: `tests-host` зелёные, `t_char` вырос до 65 проверок (добавлены два
> сценария возрождения). Цена: `_CODE` +80 Б, банк 1 (`guards`) 2342 → 2519,
> банк 4 (`gdraw`) 3875 → 4269.
>
> **Не проверено вживую:** сцена требует пройти уровень 3 до кнопки, которая
> открывает выход — без открытой двери скелет по условию не встаёт.
**Что нужно:**
- `charid_4_skeleton` в `enter_guard`/`pop_guard_enter` (seg002:196): при
`tbl_guard_type[level] == 2` персонаж поднимается **с вынутым мечом** и
последовательностью `seq_63_guard_active_after_fall`, а не
`seq_77_guard_stand_inactive`;
- скелет **бессмертен** — чит `K` его уже не берёт (`pop_guard_kill`
проверяет `CHARID_4_SKELETON`), но и боёвка должна возвращать его к
жизни (seg002:252);
- **новый атлас**: `../SDLPoP/data/SKEL/` (29 файлов) — `pop_pack_guard.py`
сейчас прибит к `GUARD/`, нужен параметр набора (`tbl_guard_dat` =
GUARD/FAT/SKEL/VIZIER/SHADOW). Здесь же удобно закрыть
[BUG-GUARD-COLOR-1](bug_list.md#bug-guard-color-1): палитра стража
подменяется по `guards_color`, и оба изменения живут в одном упаковщике.
### <a id="l3-color"></a>L3-COLOR. Палитра КЛАДКИ уровня 3 (в оригинале он зелёный)
**Наблюдение (пользователь, 2026-08-07, со сравнением карт VGA).** В
оригинальной VGA-версии кладка уровня 3 ЗЕЛЁНАЯ, а уровней 1-2 — серо-синяя.
В SDLPoP все подземелья одинаковые, поэтому по нему разницу не увидеть.
**Почему в SDLPoP её нет — проверено, не гипотеза.** Механизм там ЕСТЬ
(seg000:1140, «Level colors (1.3)»):
```c
int level_color = custom->tbl_level_color[current_level];
if (level_color != 0) {
byte* env_pal = level_var_palettes + 0x30*(level_color-1);
byte* wall_pal = env_pal + 0x30 * custom->tbl_level_type[current_level];
set_pal_arr(0x50, 0x10, (rgb_type*)env_pal); /* chtab_6 environment */
set_pal_arr(0x60, 0x10, (rgb_type*)wall_pal); /* chtab_7 wall */
}
```
`tbl_level_color` (data.h:842) = `{0,0,0,1,0,0,0,1,2,2,0,0,3,3,4,0}`у
уровня 3 цвет **1**, у 7 тоже 1, у 8/9 — 2, у 12/13 — 3, у 14 — 4. Но
`level_var_palettes` — это ресурс **20** из `PRINCE.DAT` (только версии
1.3/1.4), а в `SDLPoP/data/PRINCE/` его НЕТ: там лежит лишь `res10.bin`
(палитры стражей). Значит `level_var_palettes == NULL` и вся ветка молча
пропускается — отсюда одинаковые подземелья.
**Данные у нас есть.** В `../MSDOS/PRINCE.DAT` ресурс 20 присутствует:
offset 22790, **240 байт** = 5 палитр × 16 цветов × 3 байта (6-битные
каналы, как res10).
**Что делать (когда дойдём до вида уровня 3).**
1. Достать ресурс 20 из `MSDOS/PRINCE.DAT` (упаковщику придётся читать сам
`.DAT` — сейчас все скрипты берут распакованные PNG из SDLPoP);
2. сгенерировать таблицу палитр рядом с `pop_guard_pal.h`;
3. при загрузке уровня заливать слоты **0x50..0x5F** (env) и **0x60..0x6F**
(wall) — у нас ровно эти базы (`pop_pack_bg.load_indexed`: `pal_base =
0x60` для WALL, `0x50` для env), то есть совпадение со `set_pal_arr`
один в один;
4. `wall_pal = env_pal + 0x30 * tbl_level_type[level]` — для подземелья
(`level_type == 0`) обе палитры одинаковые.
**Грабли, уже пойманные на цвете стражей:** `gfx_pal_load` отдаёт указатель
в BIOS (`$A4` через `rst #0x08`), а BIOS читает только `#4000-#BFFF`
таблицу нельзя передавать прямо из банка (0xC000+), надо копировать в
W1/W2 (см. [BUG-GUARD-COLOR-1](bug_closed.md#bug-guard-color-1)).
### <a id="draw-cost"></a>DRAW-COST. Кадр НЕ УКЛАДЫВАЕТСЯ в бюджет — нужна оптимизация
> **ШАГ 1 СДЕЛАН 2026-08-08: пропуск неизменившегося персонажа.** Комната
> 1.3, труп стража, Кид стоит: было **210 %** кадрового периода, стало
> **116 %** (500 772 такта при бюджете 430 000). Отрисовка перестала быть
> узким местом: персонажи в покое не рисуются ВООБЩЕ (ноль вызовов
> `pop_heal_fast` за кадр), весь фон — ДВА блита факелов (44 136 тактов).
> Механизм и почему метка позиционная — в шапке `pop_cdraw.h`.
**Исходные замеры пользователя (полосы бордюра, до шага 1).** Уровень 1,
Кид СТОИТ — то есть НЕ худший случай, ни боя, ни движения:
| комната | синяя (ввод+heal+логика) | зелёная (фон) | циан (спрайты) | итого |
|---|---|---|---|---|
| 3, страж УБИТ | ~80 % | ~20 % | ~110 % | **~210 %** |
| 2, стража НЕТ | ~60 % | ~20 % | ~60 % | ~140 % |
Разница ровно в теле убитого стража: +20 % синей и +50 % циана. Труп
сохраняет `charid != 0`, поэтому каждый кадр честно проходил весь путь
живого персонажа (heal → спрайт+clip_char+брызги+клинок → fore-проход), хотя
его кадр постоянен до выхода из комнаты.
**Что сделано (шаг 1).** Не спецкейс «мёртвый», а общее правило: у каждой
страницы дабл-буфера свой снимок ВХОДОВ отрисовки слота; совпал снимок,
спрайт этой страницы цел и фон в его прямоугольнике не трогали — heal, блит
и fore-проход пропускаются целиком. Покрывает и труп, и стоящего Кида, и
ждущего стража. Детали контракта — `pop_cdraw.h`, реализация —
`pop_char_skip_mask` / `cd_quiet` в `pop_cdraw.c`, метка фона —
`pop_cd_touch` в резидентном `pop_tile.c`.
Грабли, на которые наступили по дороге: сначала метка была ФЛАГОМ «фон
трогали хоть где-то» — и выигрыш оказался ровно нулевым, потому что факелы
анимируются каждый кадр и гасили пропуск для всех персонажей сразу (замер:
597 684 такта, как без оптимизации). Метка обязана быть ПОЗИЦИОННОЙ.
**Остаточный эффект от объединения прямоугольников.** Метка одна на
страницу — объединение всех правок фона. В комнате 1 два факела дают
прямоугольник x 40…88, а стоящий там Кид занимает x 32…44: пересечение 4 px,
и он перерисовывается каждый кадр (140 % против 94 %, если отойти). Если
понадобится — держать не один прямоугольник, а 3-4 отдельных и мержить
только при переполнении.
### Шаг 2 сделан частично: ЛОГИКА (синяя полоса) 2026-08-09
Пользователь: «на стоящем Киде с двумя факелами на логику уходит 60 %
кадрового периода — недопустимо». Разобрано брейкпоинтами в MAME
(`z80_profiling_method`), сцена: уровень 1 комната 1, Кид СТОИТ вплотную к
левому факелу (то есть пропуск персонажа НЕ срабатывает — худший случай).
**Калибровка, которую надо знать заранее.** Один такт `totalcycles` в MAME
— НЕ один номинальный T-такт Z80: у Sprinter на обращениях к ОЗУ есть
wait-state'ы, и замеренная стоимость выходит **≈ 2,4× номинала**
(`get_tile`: 574 номинальных против 1 422 замеренных). Считать бюджет по
таблице T-тактов из справочника нельзя — только мерить. Кадр растра =
430 000; главный цикл спейсится тремя `gfx_wait_vsync`, поэтому работа
СВЫШЕ 430 000 стоит сразу целый лишний кадр.
**Что нашли и починили:**
| правка | что было | стало |
|---|---|---|
| окно перебора коллизии как в оригинале (было: все 14 колонок каждый кадр) | `check_collisions` 60 888 | 50 940 |
| `get_tile_div_mod` — таблицей (`tile_div_tbl`/`tile_mod_tbl`), было `/14` и `%14` | 5 400 тактов на вызов, 13 вызовов за кадр ≈ 70 000 = **16 % кадра** | ~250 на вызов |
| разрешение ряда вынесено из цикла колонок + грань шагом 14 + `wall_type` таблицей | `check_collisions` 58 026 | 42 750 |
| `move_coll_to_prev``memcpy` (LDIR) вместо цикла на C | 14 байт за 5 514 тактов (390 на байт!) | ~1 200 |
| окно режется на непрерывные пробеги (левый сосед / своя / правый), пролог ряда вынесен на кадр | `check_collisions` 44 022 | **38 334** |
Замер одной итерации перебора: **пустая колонка 750 тактов, колонка-стена
~1 700** (две 16-битные знаковые сверки граней — SDCC пишет их через
`jp PO / xor 0x80 / jp P`). Ловушка, на которую наступили: «быстрый путь для
окна внутри комнаты» не срабатывал ПОЧТИ НИКОГДА — Кид, стоящий в колонке 0,
даёт окно с −1, и шёл медленный сбор во временный буфер с тернарником на
колонку (1 340 тактов на колонку). Отсюда разбиение на пробеги: вопрос «чья
это колонка» решается раз на пробег, а `coll_scan` сам двигает `scan_left`.
Самое дорогое было НЕ там, где ожидалось: `/14` и `%14` SDCC разворачивает
в `__divsint` + `__modsint`, а `__modsint` внутри зовёт `__divsint` ещё раз —
два полноценных 16-битных деления на каждый вопрос «в какой колонке точка».
Оригинал делит таблицей (seg006:702) — мы просто не портировали это место.
**ГЛАВНОЕ: логический кадр уложился в бюджет.** Главный цикл спейсится
тремя `gfx_wait_vsync`, поэтому работа сверх 430 000 тактов стоит СРАЗУ
целый лишний растровый кадр. Было 470 964 (период цикла 4 кадра), стало
**409 956 + ~15 600 на ввод = 425 600** — период цикла **3 растровых кадра**.
Игра стала быстрее на треть (16,7 логических кадров/с против 12,5).
Что дало последние тысячи (по убыванию):
| правка | экономия |
|---|---|
| `pop_y_to_row` — цепочка сравнений вместо `/63 % 4` | ~12 000 |
| расширение окна fore-прохода арифметикой вместо перебора 10 колонок и 3 рядов | ~8 500 |
| `col_from_x` — таблицей (те же `POP_TILE_DIV`, вынесены в резидент) | ~11 000 |
| `pop_cd_touch` — развёрнутый цикл по страницам, `x+w`/`y+h` один раз | ~8 600 (зовётся с каждого блита фона) |
| `tp / 10`, `tp % 10` у факелов — таблицей | ~4 000 |
| пустой слот соперника считается «тихим» | ~8 200 |
**Ловушка SDCC, на которой я потерял один прогон:** в `pop_y_to_row` одно и
то же выражение `t / 63 % 4 - 1` стояло в двух ветках, и компилятор поднял
деление В ВЕРШИНУ функции — быстрые возвраты не спасали, `__divsint` звался
всё равно. Лечится выносом медленного хвоста в ОТДЕЛЬНУЮ функцию. Тот же
эффект уже был описан в `pop_loose_tick`; теперь ясно, что это правило, а не
частный случай: **любое деление, встречающееся дважды, SDCC поднимает выше
всех проверок.**
Приём, которым это ловится: брейкпоинт на `__divsint`/`__divuint`/
`__divuchar` с печатью адреса возврата (`printf "%04X", w@(sp)`) — сразу
видно, кто и сколько раз делит за кадр.
**Хвост подобран 2026-08-10.** После переписи `pop_y_to_row` в pop_room.c
осталось ТРИ места, считавших `(y+60)/63 % 4 - 1` вручную (927 в
`mob_tick_one`, 976/977 в `mob_render`) — сгенерированный asm подтвердил
пару `__divsint`+`__modsint` в каждом. Это ~16 200 тактов (3,8 % кадра), но
только пока кусок плиты в полёте — то есть ровно в самом тяжёлом кадре.
Заменены вызовом `pop_y_to_row`; в банке 7 теперь НОЛЬ `__divsint`.
Эквивалентность закреплена тестом `geom_y_to_row_matches_formula`
(перебор −400..400 против исходной формулы) — вызовы разбросаны по трём
банкам, и соблазн написать деление «по месту» возвращается.
### Полная инвентаризация делений 2026-08-10
Способ (повторяемый одной командой из `.sprinter-cc-roomtest/`):
```
awk '/^;[a-z_0-9]+\.[ch]:[0-9]+:/{s=$0} /^\tcall\t__(div|mod|mul)/{printf "%-22s %-12s %s\n",FILENAME,$2,s}' *.asm
```
Найден **31 вызов в 9 модулях**. Прибрано три места, остальное разобрано и
осознанно оставлено.
Убрано:
| место | что было | почему стоило |
|---|---|---|
| `pop_cdraw.c` `calc_screen_x_coord` (2 вызова) | `x * 8 / 7` = `__divsint`, **2 400 тактов НА ПЕРСОНАЖА КАЖДЫЙ КАДР** | последнее деление в горячем пути; ~4 800/кадр при живом сопернике |
| `pop_guard.c` `guard_col_from_x` | `/14` + `%14` **безусловно**, мимо `POP_TILE_DIV` | единственное 16-битное деление, у которого таблица вообще не была подключена |
| `pop_trob.c` `animate_chomper` | `tp / 10` = `__divuchar` на чомпера каждый кадр | таблицы `TP_ROW`/`TP_COL` уже лежали в ЭТОМ ЖЕ файле, но ниже по тексту — чомперы их не видели |
Приём для `×8/7`: **таблица** `SCRX7[1152]` (int8_t, хранит `x/7`) в банке 4,
индекс `x + 448`, результат `x + SCRX7[i]`. 1 152 байта, резидент не тронут.
Два решения по дороге, оба проверены, а не угаданы:
1. **Диапазон — весь, включая отрицательные.** Первая версия крыла 0..255 по
тождеству `8x/7 == x + x/7` с байтовой таблицей `x/7`. Ошибка: `obj_x =
2*fwd 116` уходит в минус, как только `fwd < 58` (левее `x_bump[5]`) —
то есть у ЛЕВОЙ КРОМКИ комнаты, а это не экзотика, и там мы продолжали
делить. Границы взяты из фактических данных: `kid_data.bin` даёт `dx`
кадров Кида −5..+10, стража −2..+10; при `Char.x` типа uint8_t и
`render_dx ∈ {140, 0, +140}` полный диапазон `obj_x` = **416..695**.
Таблица кроет −448..703, деление стало недостижимым (оставлено
страховкой на третьего персонажа / другой `render_dx`).
2. **Хранить `x/7` байтом, а не готовое `x*8/7` словом** — по тождеству
`8x/7 == x + x/7`. Первая версия хранила готовое, «раз всё равно
индексация двухбайтная». Собраны ОБА варианта, такты посчитаны по
сгенерированному asm:
| вариант | хвост после проверки границ | тактов | байт |
|---|---|---|---|
| `int16_t` готовое | `add hl,hl / add hl,de / ld e,(hl) / inc hl / ld d,(hl)` | **132** | 2 304 |
| `int8_t` `x/7` | `add hl,bc / ld a,(hl) / ld l,a / rlca / sbc a,a / ld h,a / add hl,de` | **131** | 1 152 |
Расширение знака и 16-битное сложение стоят ровно столько же, сколько
лишний `add hl,hl` при двухбайтном индексе, а обращений к памяти на одно
меньше — под wait-state'ами Sprinter (такт ≈ 2,4× номинала именно на
обращениях к ОЗУ) байтовый вариант ещё чуть выгоднее номинала. Итог:
байтовая таблица не хуже по скорости и на килобайт меньше. **Урок:
«двухбайтный индекс съест выигрыш» — гипотеза; она не подтвердилась.**
Кодоген проверен глазами (`bank4_pop_cdraw.asm`): `ld hl,#0x01C0; add hl,de`,
16-битное беззнаковое сравнение — индекс полный, старший байт не теряется
(грабли `sdcc_z80_const_ptr_index_bug` обойдены отдельной
`uint16_t`-переменной). 131 такт номинала против ~1 000 у `__divsint`.
Проверка таблицы: все 1 152 записи сверены питоном обратно из `.c`, а ПРАВИЛА
генерации (тождество + усечение к нулю) — тестом `geom_mul8div7_table_rules`
на целевом компиляторе: округляй SDCC к минус бесконечности, вся
отрицательная половина уехала бы на пиксель, и поймалось бы это только
глазами на левой кромке.
Оставлено сознательно (НЕ трогать, это не забытые места):
- **Хвосты за таблицей** — `guards.c:84`, `pop_bg.c:497`, `roomtest.c:163`,
`pop_map.c:412`: срабатывают только при x вне 0..255, то есть когда
персонаж в соседней комнате. Убирать их — это расширять `POP_TILE_DIV` до
140..395 (+280 Б резидента) ради редкого пути.
- **`pop_geom.c:21`** — намеренный медленный хвост `pop_y_to_row` (см. выше
про подъём деления SDCC).
- **`pop_geom.c:52`** — `v % n` в `pop_rnd_fit` для не-степени двойки:
оригинал зовёт `prandom(1)/(255)/(0xFF)`, все три идут веткой с маской,
сюда управление не приходит вовсе.
- **Холодные, раз на комнату/уровень/событие**: `pop_guard.c:266/268/269`
(вход стража), `pop_map.c:310` (пробуждение скелета), `pop_trob.c` дверь
уровня, `roomtest.c:473/663/887` (читы и старт), `pop_level.c:131/132`
(имя файла уровня), `roomtest.c:976/977` (отладочный HUD номера комнаты).
Каждое — единицы вызовов за секунды игры; таблицы под них только раздули
бы код.
Итог: **в горячем пути делений не осталось**. В банках 4, 6, 7 — ноль
`__div*`; всё, что видно в списке выше, либо за быстрым путём, либо холодное.
### Замер в MAME: A/B со сборкой `ec3cca5` (до правок)
Обе сборки прогнаны через полный цикл (`make hdd` → рестарт MAME → загрузка
уровня 1), сцена — **комната 1, Кид стоит, соперника нет**; скриншоты обеих
сборок идентичны. Фазы сняты брейкпоинтами на инструкциях `out
(_io_border), a` профилировочного бордюра, медиана по 60 логическим кадрам:
| фаза | до | после | Δ |
|---|---|---|---|
| ввод + heal | 62 361 | 62 364 | +3 |
| логика | 79 878 | 79 878 | 0 |
| фон | 119 502 | 119 502 | 0 |
| **спрайты** | **129 568** | **127 510** | **2 058** |
| РАБОТА за кадр | 391 258 | 389 221 | 2 037 |
Счётчик делений (брейкпоинты на `__divsint`/`__modsint`/`__divuchar`/
`__moduchar` с печатью адреса возврата):
| сцена | до | после |
|---|---|---|
| комната 1, только Кид | **1,00 `__divsint`/кадр** (возврат 0xD3AA = `pop_cdraw`) | **0** |
| комната 3, бой со стражем | **2,01 `__divsint`/кадр** | **0** (82 кадра боя) |
Всё сходится в одну картину: единственное деление горячего пути — `×8/7` на
персонажа, по одному вызову на каждого Char. Экономия ровно в фазе спрайтов
и ровно на стоимость одного вызова: **2 058 тактов** (документированная
оценка была ~2 400). С соперником на сцене — вдвое.
**Чего этот замер НЕ показывает, и это важно:**
- Период цикла как был 3 растровых кадра, так и остался — 2 000 тактов его не
двигают. Выигрыш ушёл в запас: до нижней границы 430 000 стало 40 800
тактов вместо 38 700.
- Остальные правки (три `y_to_row` в `pop_room`, `guard_col_from_x`,
`tp/10` у чомпера) в этой сцене **не срабатывают вовсе** — им нужны
падающая плита, переход стража между комнатами и уровень с чомперами.
Их стоимость известна поштучно, но в бою я их не ловил.
- Работа в комнате 3 между прогонами **несравнима** (391 204 против 318 145):
страж живой, фаза боя и срабатывание `pop_char_skip_mask` от прогона к
прогону разные. Оттуда взят только СЧЁТЧИК делений — он от таймингов не
зависит.
**Профиль работы за логический кадр СЕЙЧАС (409 956 тактов + ~15 600 ввод):**
| блок | тактов | % растрового кадра |
|---|---|---|
| see_kid + ctrl_tick + skip + heal + kid_tick | 52 536 | 12 |
| физика Кида + страж + боёвка | 73 452 | 17 |
| `pop_loose_tick` | 27 438 | 6,4 |
| **`pop_process_trobs`** (два факела) | **75 720** | **17,6** |
| `pop_redraw_needed` + шов + skip_mask | 10 932 | 2,5 |
| **`pop_char_draw` Кида** | **56 250** | **13** |
| **`pop_char_fore` Кида + борта** | **113 628** | **26** |
Синяя полоса (ввод + логика) была ~60 % → стала ~29 %.
**Оптимизация закрыта по решению пользователя 2026-08-10.** Всё, что
осталось неcделанным, вынесено с замерами в
[`../docs/perf_backlog.md`](../docs/perf_backlog.md) — там же протокол «как
мерить», чтобы не наступать заново на wait-state'ы и на устаревшие адреса
символов. Что доделано после таблицы выше: `pop_cd_touch` развёрнут,
tp/10 у факелов таблицей, пустой слот соперника считается тихим, ранний
выход в fore-проходе (нулевой эффект, оставлен как порт), быстрый путь без
клипа в `pop_blit_b` (факел 41 778 -> 31 218 тактов).
**Что осталось (запас на будущее, срочности больше нет):**
1. **`pop_char_fore` 113 628.** Внутри: `char_footprint` + расширение окна
~21 000, дальше шесть `fore_tile`, из которых два реально рисуют (по
~32 000). Дальше резать — кэш «в этом тайле переднего слоя нет вовсе».
2. **`pop_process_trobs` 75 720 на два факела** (~31 000 на факел). Внутри
одного факела: `gfx_blit_noclip` 8 700, чтение w/h и клип 6 400,
`pop_cd_touch` (теперь дешевле), маппинг окна 0 и возвраты. Пламя
перерисовывается каждый кадр обязательно (`TORCH_ANIM_DIV = 1`, кадр
меняется), так что пропуск тут не поможет — только удешевление блита.
3. `pop_loose_tick` 27 438 при полном отсутствии падающих плит.
4. Одно `__divsint` осталось в `pop_char_draw` (`obj_x * 8 / 7`) — ~2 400.
### Шаг 2 (СЛЕДУЮЩИЙ, назначен пользователем): ДВИЖУЩИЙСЯ Кид
Пропуск закрывает только покой. Наблюдение пользователя 2026-08-08: как
только Кид побежал, циан-полоса (спрайт + fore-проход) вырастает почти до
**100 % кадрового периода** — и это на ОДНОГО персонажа. То есть цена
одной перерисовки персонажа сама по себе непозволительно велика, и её надо
резать по существу, а не пропусками.
Куда смотреть (мерить каждое, метод — `z80_profiling_method`):
1. разделить замером спрайт-блит и fore-проход: у fore уже была история
78 % кадра до кэша кладки (`pop_fore_layer_cost`), он и сейчас главный
подозреваемый;
2. fore-проход перебирает 2×2…3 тайла и в каждом разбирает кладку заново —
кэш «в этом тайле переднего слоя нет вовсе» снял бы половину;
3. расширять перебор ТОЛЬКО по накладным спрайтам (клинок/брызги), а не по
всему окну клипа: габарит кадра уже покрыт футпринтом + правилом меча;
4. heal + блит спрайта: сейчас это два прохода по одной площади; посмотреть,
нельзя ли стирать только РАЗНОСТЬ прямоугольников при мелком сдвиге.
**Куда идти дальше в ПОКОЕ — там это ЛОГИКА, а не отрисовка.** Разбивка
работы кадра брейкпоинтами (комната 3, труп, Кид стоит; всего 500 772
такта):
| участок | тактов | % кадра |
|---|---:|---:|
| ввод + `check_skel` + луч видимости + `ctrl_tick` + heal | 65 862 | 15 % |
| `kid_tick` + физика + страж + боёвка | **260 022** | **60 %** |
| `loose_tick` + `process_trobs` | 119 562 | 28 % |
| `redraw_needed` + шов + спрайты + метка | 55 152 | 13 % |
| — из них два блита факелов | 44 136 | 10 % |
1. **60 % на тик персонажей** при том, что оба СТОЯТ — первый кандидат.
Смотреть `pop_phys_tick`/`pop_guard_phys_tick` (банк 3) и `pop_guard_tick`
(банк 1): сколько там работы, которую неподвижный персонаж делать не
обязан, и сколько стоит трамплин на каждом шаге.
2. **28 % на `loose_tick` + `process_trobs`** в комнате БЕЗ единой ловушки —
явно перебор; разобрать, что там сканируется каждый кадр (список trob,
`pop_trob_modif` соседа для шва, `pop_room_link`).
3. Запечь труп в фон (как щебень: тело в фоне + fore-часть поверх Кида) —
тогда бесплатным станет и проход Кида ПО телу, который сейчас снимает
пропуск по правилу «слоты рядом». Предложено пользователем 2026-08-08.
Мерить в ЭТОЙ точке (комната 3, страж убит, Кид стоит) — она воспроизводима
и даёт нижнюю границу; худший случай (бой + бег + падающая плита) считать
отдельно. Метод — memory `z80_profiling_method` (брейкпоинты с
`totalcycles`); полосы бордюра показывают только одну картинку из периода и
годятся лишь для раскладки по фазам.
**Наблюдение пользователя на приёмке DRAW-CHAR 2026-08-08:** циан-полоса
профиля (спрайты + fore) выросла — тогда «в пределах».
**Отчего именно.** Раньше перебор тайлов у Кида шёл строго по футпринту
кадра (`char_x_left/right`, seg006:1021), а он УЖЕ спрайта; расширение
перебора окном спрайта (клинок и брызги уходят за габарит кадра) было
только у соперника. DRAW-CHAR сделала его общим — то есть у Кида теперь на
колонку-другую больше `fore_tile` за кадр. Это не регрессия «лишней
работы», а недостающая ранее корректность: тайл, который спрайт задевает,
обязан вернуть свой передний слой поверх него.
**Если fore-проход снова станет узким местом** (сейчас он в покое не
выполняется вовсе): расширять перебор ТОЛЬКО по накладным спрайтам
(клинок/брызги), а не по всему окну; кэш «в этом тайле fore-слоя нет
вовсе»; считать окно клипа в тайловых координатах один раз.
### <a id="draw-char"></a>DRAW-CHAR — **ЗАКРЫТА И ПРОВЕРЕНА 2026-08-08**
Отрисовка сведена к одному набору функций над `Char` (`pop_cdraw.c`), проход
окклюзии — один на всех (`pop_fore_over_char`). Разбор, таблица «было →
стало», пять починенных расхождений и замеры — в
[`TASKS_CLOSED.md`](TASKS_CLOSED.md#draw-char). Оттуда же список того, что
надо потрогать вживую при приёмке уровней.
### <a id="l3-pass"></a>L3-PASS. Приёмка уровня 3 — обход всех комнат
Как L1-PASS/L2-PASS: сквозной проход руками плюс обход комнат читом ROOMNAV.
Осмысленна ТОЛЬКО после L3-CHOMP и L3-SKEL — без них уровень заведомо
неполон, и половина наблюдений будет «механики нет».
Что уже снято и пригодится (справка ниже): комнаты **23 и 24 недостижимы** и
полностью пусты — баги в них не в приоритете; **чекпойнт** уровня 3 сделан
([L3-CHKP](TASKS_CLOSED.md#l3-chkp)), его тоже надо потрогать вживую: уйти
влево из комнаты 7, умереть, проверить респавн в комнате 2 и снятую
loose-плиту (7, кол 4, ряд 0).
### <a id="rooms-graph"></a>Справка: связность комнат уровней 1–3 (снято 2026-08-05)
Обход графа `roomlinks` (@1952, по 4 байта на комнату: L, R, U, D; 0 = нет
соседа) от стартовой комнаты — тем же методом, которым на уровне 1 нашлись
13/18/24 (см. «НЕ БАГИ» в [`bug_closed.md`](bug_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-10**
Приоритет переставлен пользователем: palace-уровни были отложены решением
2026-08-04, но уровень 4 уже гоняется в MAME, и зеркало — единственное, что
мешает пройти его сюжетно.
**Как это работает в оригинале** (SDLPoP, всё сверено по коду):
| что | где | суть |
|---|---|---|
| постановка тайла | `animate_leveldoor`, seg007:0457 | дверь выхода ДОРИСОВАЛА открытие (`modif >= 43`, `leveldoor_open` 0/2 -> 1) -> в комнату 4, колонку 4, ряд 0 пишется `tiles_13_mirror` |
| отражение | `check_mirror`, seg003:0798 | пока Кид на тайле зеркала — каждый кадр рисуется зеркальная копия: `Char.x = (xpos<<1) - Char.x`, `direction` инвертируется, копия идёт в objtable типом 4 с клипом `left = (curr_col<<5)+9`, `top = y_clip[row+1]` |
| прыжок сквозь | seg004:0240 | засчитывается ТОЛЬКО `run-jump` СПРАВА НАЛЕВО (`frame 39..43`, `direction < 0`) -> `modif = 0x56` (разбитое), `jumped_through_mirror = -1` |
| рождение тени | `jump_through_mirror`, seg003:080A | Кид копируется в слот Guard как `charid_1_shadow`; `guardhp = hitp_max`, у Кида `hitp_curr = 1` |
| поведение тени | `autocontrol_shadow_level4`, seg002:1131 | в зеркальной комнате при `x < 80` исчезает (`clear_char`), иначе бежит вперёд. Боя нет |
| клип тени | seg008:1699 | `obj_clip_left = 137 + (mirror_column-4)*32` — тень видна только СПРАВА от зеркала |
| коллизия | `wall_type`, seg006:1632 | зеркало = 2 «стена слева» — **уже портировано** (`pop_map.c`), как и спецкейс `can_climb_up` |
**Шаги.**
1. **АТЛАС — СДЕЛАНО 2026-08-10.** `tile_table[0x0D]` = база 75, фронт 77
(наша таблица совпадает с SDLPoP такт в такт). Тайла 13 **нет ни в одном
уровне статически** — проверено перебором всех 15 `res200N.bin`, ноль
попаданий; санити-проверка разбора: ур.1 без чомпера, ур.3 с 18, ур.4 с
паласными 25..29. Значит `render_room` эти id не увидит и на месте
зеркала был бы чёрный провал (memory `pop_atlas_dynamic_ids`). Добавлены
`MIRROR_ENV_IDS = {75, 77}` в `pop_pack_bg.py`, 77 — ещё и в
`FORE_ENV_IDS`. Оба набора переупакованы: fore 17 -> 18 спрайтов, все
страницы EMM в пределах 16 КБ.
2. **ПОСТАНОВКА ТАЙЛА — СДЕЛАНО И ПРОВЕРЕНО ПОЛЬЗОВАТЕЛЕМ 2026-08-10.**
`place_mirror()` в `pop_trob.c`, вызывается из `animate_leveldoor` по
переходу `pop_leveldoor_open` 0/2 -> 1 (условие оригинала — иначе тайл
ставился бы заново каждый кадр открытой двери). Если комната зеркала уже
на экране, ставится `POP_RD_FLOOR` на обе страницы.
Зеркало появляется, спрайты из атласа читаются. Остался незакрытый
краевой вопрос: не устареет ли `g_fg` коллизии, если игрок окажется в
комнате 4 ровно в момент постановки (в обычном прохождении дверь в другой
комнате).
3. **ОТРАЖЕНИЕ.** Самый дорогой шаг: это ЧЕТВЁРТЫЙ рисуемый Char помимо
Кида и соперника, со своим клипом. Отрисовка у нас уже общая
(`pop_cdraw.c`, слоты — memory `pop_char_draw_unified`), вопрос в числе
слотов и в том, что отражение меняется вместе с Кидом каждый кадр, то
есть `pop_char_skip_mask` на нём не сработает. ЧИСТО КОСМЕТИКА —
делается последним.
4. **ПРЫЖОК СКВОЗЬ + РОЖДЕНИЕ ТЕНИ — СДЕЛАНО 2026-08-10, НЕ ПРОВЕРЕНО В MAME.**
- `is_obstacle` (`pop_map.c`): ветка зеркала (seg004:0239) — Кид, кадры
бегового прыжка 39..43, направление влево -> `modif = 0x56`,
`pop_jumped_mirror = -1`, препятствия нет.
- `mirror_image` / `jump_through_mirror` / `pop_check_mirror`
(`pop_map.c`, порт seg003:0798..08A9). Отражённый Char уходит в слот
Guard как `CHARID_1_SHADOW`; `guardhp = hitp_max`, у Кида `hitp_curr = 1`.
`savekid` НЕ делается — как в оригинале.
- `pop_check_mirror()` зовётся из главного цикла ПЕРЕД отрисовкой
персонажей (в оригинале — первая строка `draw_people`).
- `autocontrol_shadow` + `autocontrol_shadow_level4` + `clear_char`
(`guards.c`, seg002:081D/1131/seg006:1945). Тень идёт СВОЕЙ веткой
целиком: к стражьему ИИ она не сводится, на ур. 4 не дерётся вовсе.
- **АТЛАС ТЕНИ.** Вскрылось при чтении seg006:0532: тень вне боевых
кадров 150..189 ходит по таблице КИДА, и `image` оттуда индексирует
спрайты Кида, а не стража. Выбор атласа в `pop_cdraw` шёл по СЛОТУ —
тень рисовалась бы спрайтами стража. Условие вынесено в
`pop_frame_tbl_is_guard()` (`pop_kid.c`), его теперь читают и
`load_frame`, и отрисовка — разъехаться не могут.
- Звука (`sound_45_jump_through_mirror`) в порте нет, пропущен.
Цена: `_CODE` +18 Б, банк 1 2311 -> 2367, банк 3 10328 -> 10551,
банк 4 8243 -> 8267.
**Что проверить в MAME:** на ур. 4 открыть дверь выхода, дойти до
комнаты 4, разбежаться СПРАВА НАЛЕВО и прыгнуть в зеркало — должна
появиться тень (спрайтами Кида, зеркально) и убежать влево, растворившись
при `x < 80`. У Кида после этого 1 HP, у тени полная полоса.
5. **КЛИП ТЕНИ** (seg008:1699): `obj_clip_left = 137 + (mirror_column-4)*32`
— тень видна только СПРАВА от зеркала. **Не сделано и не однострочник:**
в `pop_cdraw` есть клип сверху, снизу и СПРАВА (`vis_w`), а левого нет
вовсе — `gfx_blit_cols_part_w` умеет ограничить ширину, но не пропустить
колонки слева. Нужен либо новый примитив, либо сдвиг `bx` со срезом
исходных колонок. Пока без него тень будет вылезать левее зеркала.
Порядок: 1 -> 2 -> 4 -> 5 -> 3. После 4 и 5 уровень уже проходится, потому
что сюжетно достаточно прыгнуть сквозь зеркало.
---
## P1 — берётся в любой момент
### <a id="l1-speed"></a>L1-SPEED. Игра идёт быстрее оригинала (найдено 2026-08-01)
Сверка таймингов: оригинал — `BASE_FPS = 60` при `base_speed = 5` тиков на
логический кадр (`SDLPoP/src/types.h:1373`, `data.h:869`) = **83.3 мс**, в бою
`fight_speed = 6` = **100 мс**. У нас `roomtest.c` ждёт **три** `gfx_wait_vsync()`
= 60 мс, и отдельной скорости боя нет — то есть примерно **+39 % к скорости
эталона**. Соответствие: 4 ожидания (80 мс) обычно, 5 (100 мс) в бою.
Условие «делать ПОСЛЕ CLIP-1» снято — CLIP-1 закрыт. Проверка —
секундомером по одинаковому отрезку рядом с живым SDLPoP, не «на глаз».
### <a id="tune-1"></a>TUNE-1. Параметры движка — в конфиг, а не в код
**Что уже есть.** `pop_tune.h` — все настраиваемые числа собраны в одном
заголовке: чекпойнт уровня 3 (`POP_CHKP_*`), отладочное окно решётки
(`POP_DBG_GATE_HOLD`), включатель зацепа в прыжке (`POP_ENABLE_JUMP_GRAB`).
Правило уже действует: новое «особое событие» или тайминг заводится ТАМ, а
не константой по месту.
**Что нужно сделать.** Читать их из ФАЙЛА рядом с exe, чтобы менять без
пересборки — под отладку («растянуть решётку, чтобы успеть пройти») и под
моды. Формат: простой ini/`ключ=значение`, парсер на ~50 строк (числа,
комментарии `;`, неизвестные ключи игнорировать), файл необязателен —
нет файла, значит зашитые дефолты. Секции по смыслу: `[level]`,
`[debug]`, `[enhancements]`.
**Ориентир — SDLPoP.** У него это `custom_options_type` (types.h) +
`SDLPoP.ini` + меню Settings/Mods; наши имена намеренно совпадают с его
(`custom->имя`), чтобы сверка оставалась механической. Осмотр его меню и
опций — часть задачи: у него уже разложены по группам стартовые
HP/минуты, номера «особых» комнат и уровней (шадоу, скелет, зеркало,
чекпойнт), тайминги ворот и пик, скорости, а отдельной группой —
`fixes`/`enhancements` (включая `enable_jump_grab`, который мы уже
портировали). Брать всё подряд не надо: переносим по мере того, как
константа реально понадобилась в игре.
**Оговорка по памяти.** Парсер и таблица параметров — холодный код,
исполняется один раз при старте: кандидат в банк, а не в резидент W1.
### <a id="mem-next"></a>MEM. Следующий шаг разгрузки W1/W2
`pop_ctrl.c` уехал в банк 5 ([MEM-BANK5](TASKS_CLOSED.md#mem-bank5), куча
180 Б → 2298 Б; после снижения `--max-allocs` — 2751 Б). Следующий кандидат
по тому же критерию (**не размер, а частота вызова и отсутствие горячих
банк→банк переходов**) — **расщепление `pop_kid.c`**: холодная половина
(загрузка страниц спрайтов, `pop_kid_load`) в банк, движок кадров
(`load_frame`/`play_seq`, 2×/кадр) оставить в резиденте.
Брать по факту нехватки места, не заранее. Таблица резидентного кода по
модулям и разбор, почему `pop_level.c` в банк НЕЛЬЗЯ, — в
[`TASKS_CLOSED.md`](TASKS_CLOSED.md#mem-bank5).
---
## Отложено осознанно (не брать, пока не появится причина)
- **KBD-1, остаток** — «иногда при зажатом Shift стрелка всё-таки
пропускается» (ощущение пользователя, не замер: счётчики на 35 нажатиях
подряд потерь не показывают). Решение пользователя 2026-08-01: отложено до
финальной полировки. Где именно осталась дыра и что делать, если вернёмся,
— в [`TASKS_CLOSED.md`](TASKS_CLOSED.md#kbd-1) (там же весь протокол
замеров и список того, что делать НЕЛЬЗЯ).
- **Quickload (Shift+F9) и остальные читы SDLPoP** — оценка сделана
([DBG-CHEATS](TASKS_CLOSED.md#dbg-cheats)), код не написан. Самое ценное и
самое дорогое: сериализация `Char` + `room_modif` всех комнат + trob'ов +
стражей (`levels_plan.md` §4), зато даёт воспроизводимый регресс «вот кадр,
где баг» вместо ручной подгонки позы.
- **Звук** (CBL-эффекты, Фаза 5 `PORT_PLAN.md`) — геймплей не блокирует.
- **Таймер уровня / HUD времени / меню / сохранения** — Фаза 6.
- **[T-1](bug_list.md#t-1)** (пики: перерисовка по причине) — отдаётся почти
бесплатно после [T-2](bug_list.md#t-2), отдельно не окупается.
- **Отключение мыши на время игры** и **замена PRNG**
`../docs/ideas_backlog.md` (оба дают доли процента кадра).
- **OPT-1** (хирургический редрой шва) — решено НЕ делать, стоимость
транзиентная; разбор в [`bug_closed.md`](bug_closed.md).