# roomtest — ОТКРЫТЫЕ баги и незакрытые оптимизации **Здесь ТОЛЬКО незакрытое.** Всё закрытое (и, что важнее, разбор корней) живёт в [`BUGS_CLOSED.md`](BUGS_CLOSED.md) — прежде чем заводить новый баг, грепни там по симптому. Сырые формулировки пользователя с прогонов — [`bugs_level1.md`](bugs_level1.md) / [`bugs_level2.md`](bugs_level2.md). Приоритеты работ — в [`TASKS_OPEN.md`](TASKS_OPEN.md) (закрытые задачи с протоколами — [`TASKS_CLOSED.md`](TASKS_CLOSED.md)), а не здесь. Правило проекта: механику сверять с `../SDLPoP/src/` ДО кодинга. **Ревизия 2026-08-11:** файл вычищен от закрытых записей (правило «в `_OPEN` только открытое»). Уровни 1-4 приняты smoke-тестами; крупных багов нет. | ID | что | тип | статус | |----|-----|-----|--------| | [FORE-DUP](#fore-dup) | передний слой тайла рисуется дважды при перекрытии объектов | оптимизация | открыт: **сначала замерить**, потом чинить | | [MIRROR-FG-STALE](#mirror-fg-stale) | зеркало поставлено, пока игрок В комнате 4 → коллизия его не видит | **низкий** (в обычном прохождении недостижимо) | открыт, фикс на несколько строк | | [DIED-ON-BUTTON](#died-on-button) | `died_on_button` (seg007:776) не портирован | порт | открыт | | [TORCH-ANIM-RIGHT](#torch-anim-right) | под запечённым пламенем застывают не только челюсти чомпера | **низкий** | открыт: на уровнях 1-4 такого соседства нет | | [T-1](#t-1) | пики перерисовываются безусловно | оптимизация | открыт | | [BUG-SPIKE-1](#bug-spike-1) | пики залипают выдвинутыми рядом с Кидом | **низкий** | маловоспроизводим: ни сценарием, ни попиксельной подгонкой X не поднимается | | [BUG-CHOMP-JUMP-1](#bug-chomp-jump-1) | прыжок с места вплотную к чомперу: кадр с отступом назад | **низкий** | маловоспроизводим: на повторе не поднялся; на пререлиз | --- # С приёмки уровня 2 (2026-08-07) Всё найденное тем прогоном закрыто, кроме двух записей ниже (`BUG-SPIKE-1` — уровень 2, комната 6). Карта содержимого уровня 2 (что где стоит по `res2002.bin`, какие кнопки какие ворота открывают) — в [`TASKS_CLOSED.md`](TASKS_CLOSED.md#l2-pass), по ней видно, «механика не сработала» это или «так и задумано». ## BUG-SPIKE-1. Пики залипают выдвинутыми — НИЗКИЙ ПРИОРИТЕТ, МАЛОВОСПРОИЗВОДИМ > **Статус 2026-08-05, вечер.** Пользователь **повторить не смог**, а замер > (ниже) показал, что чистый пробег по убранным пикам убивает штатно. То > есть в смертельности бага, похоже, нет вовсе: наблюдался частный случай — > пики, УЖЕ выдвинутые полностью (h = 1), для бегущего безвредны и в > оригинале. Запись оставлена открытой ровно из-за визуального расхождения > со скриншотом SDLPoP (у нас острия торчат, у него убраны) — см. конец. > > **Как получить состояние нарочно:** подойти к пикам вплотную (выдвинутся), > отступить на полшага, НЕ выходя из зоны срабатывания, и пробежать по ним. > Пользователь пробовал и это, и попиксельную подгонку X читом `[`/`]` — > не поднялось. Вывод для будущего разбора: состояние **не чисто > позиционное**, одной шириной габарита его не объяснить; следующий > подозреваемый — момент, в который `process_trobs` застаёт модификатор > относительно кадра Кида. **Наблюдение (пользователь, 2026-08-05).** Уровень 2, комната 6, пики (1,3): | действие | что происходит | |----------|----------------| | длинный прыжок с ряда 0 на пики | **смерть — правильно** (путь `fell_on_spikes`) | | пробег по ряду 1 прямо по пикам | **урона нет** | | после уборки пик | **на экране остаются белые остатки остриёв** (в оригинале чисто) | | прыжок на месте, стоя на пиках | урона нет | | просто стоять на выдвинутых пиках | можно сколько угодно | **Замер (MAME, чтение `room_modif` комнаты 6).** Пока Кид стоит на тайле, модификатор пики (индекс 13) = **0x8E**, то есть «пики ПОЛНОСТЬЮ вышли и идёт обратный отсчёт». Дальше вся арифметика сходится с оригиналом: ``` is_spike_harmful (seg007:1178): 0/-1 → 0; <0 → 1; 1..4 → 2; >=5 → 0 check_spiked (seg006:0658): убивает при h>=2 на кадрах бега 7..14 и при h!=0 на кадрах приземления 43/26 ``` То есть **при h = 1 (пики уже вышли) бегущий не гибнет и в оригинале** — смертельно только окно ВЫДВИЖЕНИЯ (модификатор 1..4, h = 2). Наши `animate_spike`, `start_anim_spike`, `is_spike_harmful`, `check_spiked` сверены с seg006/seg007 построчно и совпадают дословно. **ВТОРОЕ НАБЛЮДЕНИЕ (то же место, сравнение с оригиналом).** Кид уронил плиту-потолок и спрыгнул вниз; пики выдвинулись и «спрятались не все — часть артефактов осталась». Скриншоты рядом: [наш](bugscreens/l2-r6-spikes-ours.png) и [SDLPoP](bugscreens/l2-r6-spikes-sdlpop.png) в той же позе. У нас из-под щебня торчат белые острия, у оригинала пик не видно ВООБЩЕ. **ГИПОТЕЗА «ТРИГГЕР СРАБАТЫВАЕТ РАНО» ПРОВЕРЕНА И ОПРОВЕРГНУТА (замер 2026-08-05, MAME, watchpoint на `room_modif[13]` комнаты 6).** Чистый пробег по УБРАННЫМ пикам убивает штатно: ``` запись modif=1 : кадр 11 (беговой), x=112, curr_col=2 ← пики пошли вверх запись modif=2 : кадр 12 (беговой), x=117, curr_col=3 ← Кид уже НА тайле, h=2 запись modif=3 : кадр 177 (frame_177_spiked) ← напоролся ``` То есть `check_spike_below`, `check_spiked`, `is_spike_harmful` и тайминг выдвижения работают правильно, и «раннего» триггера нет. **Настоящий корень — пики ЗАЛИПАЮТ выдвинутыми.** Пока габарит Кида накрывает колонку пики, `check_spike_below` каждый кадр зовёт `start_anim_spike`, а тот при отрицательном модификаторе переставляет его обратно в 0x8F — отсчёт до уборки не доходит. А выдвинутые пики (h = 1) для бегущего БЕЗВРЕДНЫ по правилам оригинала. Отсюда обе жалобы: пробег по уже вышедшим пикам не убивает, и они же остаются торчать на экране. **Что осталось выяснить (и это единственный открытый вопрос).** Код `start_anim_spike` у нас с оригиналом совпадает дословно, значит оригинал тоже удерживал бы пики, стой Кид там же. На скриншоте SDLPoP в похожей позе пики УБРАНЫ — то есть его Кид стоит чуть левее и его габарит колонку пики уже не задевает. Разница в 2–3 пикселя посадки, а у нас такие расхождения по X уже ловились (см. заметку в BUG-GRAB-1: после касания площадки SDLPoP уводит Кида на 134, мы — на 141). **Как закрывать:** инструментировать SDLPoP (печать `char_x_left/right`, `left/right_checked_col` и модификатора пики каждый кадр), проиграть ту же сцену — падение плиты-потолка в комнате 6 и остановку на щебне — и сверить с нашей трассой ПОЗИЦИЮ КИДА после приземления. Если позиции совпадут, а диапазоны колонок разойдутся — виноват габарит (`kid_fp` против `set_char_collision` текущего кадра); если разойдутся позиции — это отдельный баг посадки, а пики — его следствие. --- --- # Оптимизация отрисовки (записано 2026-07-29) Не баги — план работ. Оба пункта про одно: у оригинала пометка тайла к перерисовке стоит копейки (бит в таблице, которая всё равно чистится каждый кадр), а у нас каждая такая пометка превращается в реальный heal (копию из ОЗУ-копии акселератора) плюс блиты. Поэтому буквальный порт «перерисовываем безусловно» корректен, но дорог. ## T-1. Пики: перерисовывать по причине, а не безусловно **Сейчас:** `pop_process_trobs` зовёт `pop_spike_redraw` каждый кадр для каждой живой пики в комнате (порт `redraw_21h`, который `animate_spike` вызывает вне всяких `if`). Это корректно, но лишнее для пик, до которых Киду дела нет. **Надо:** перерисовывать тайл пики, только если 1. **сменился её видимый кадр** (шаг выдвижения/уборки), ЛИБО 2. **её кто-то стёр** — а стереть у нас может только heal, то есть тайл попал в прямоугольник `kid_heal` этого кадра. Это и есть модель оригинала, просто выраженная флагами: `redraw_at_char` (seg003:0576) каждый кадр помечает `set_redraw_fore` тайлы персонажа, причём **объединение текущего и предыдущего** прямоугольника (`MIN(char_top_row, prev_char_top_row)` и т.д.), а `animate_spike` помечает свой тайл. Итог = {тайл сменил кадр} ∪ {тайлы Кида}. **Как:** слой Кида и так считает `cL..cR`/`rT..rB` в `pop_fore_over_kid` — пусть публикует их (плюс предыдущие, как в оригинале), а цикл trob'ов сравнивает `tilepos` с диапазоном целочисленно. Никаких пересечений прямоугольников (см. память `manual_hints_over_auto_detect`). **Приоритет:** отдаётся почти бесплатно ПОСЛЕ T-2, отдельно не окупается. --- ## BUG-CHOMP-JUMP-1. Прыжок с места вплотную к чомперу: кадр с отступом назад — НИЗКИЙ ПРИОРИТЕТ, МАЛОВОСПРОИЗВОДИМ > **Статус 2026-08-08.** Наблюдение пользователя на приёмке L3-CHOMP: Кид > стоит вплотную СЛЕВА от чомпера лицом вправо, прыжок с места — в анимации > проскакивает кадр, где он «чуть отступил назад», и только потом идёт > прыжок. В оригинале прыжок идёт прямо с места. **На повторе в тот же > заход не воспроизвёлся** — отложено до пререлиза. **Что уже установлено (чтобы не разбирать заново).** 1. **Это НЕ анимация.** `seq_3_standing_jump` (SDLPoP `seqtbl.c:382`) состоит только из положительных смещений: `act(run_jump), f16, f17, dx(2) f18…f22, dx(7) f23, dx(9) f24, dx(5) dy(-6) f25`. Ни одного отрицательного `dx` — отката в последовательности нет вовсе. Значит отступ даёт `bumped()`, то есть физика посчитала въезд в препятствие и выровняла `Char.x` назад. 2. **`is_obstacle` для чомпера у нас совпадает с оригиналом** (seg004:037E): препятствие только при `modif == 2`, причём именно `== 2`, БЕЗ маски `0x7F` — то есть окровавленный чомпер (`0x82`) в ванили не бампит вовсе. Проверено, расхождения нет. 3. **Прямая трасса прыжка отката НЕ показала.** Ввод «UP и RIGHT одновременно» (mame bridge, `key UP 24` + `key RIGHT 24`) даёт чистое движение вперёд: `x` 148 -> 155, кадр становится 178 (перемололо). Ни одного кадра с уменьшением `x`. **Главная гипотеза — ПОРЯДОК НАЖАТИЙ.** Если ↑ приходит на кадр раньше →, то `control_standing` уходит не в `standing_jump()`, а в `up_pressed()` — вертикальный прыжок с зацепом, а он **выравнивает `Char.x`**. Отсюда и «отступил, потом прыгнул». Проверять надо `check_jump_up` / `jump_up_or_grab` / `grab_up_no_floor_behind` (seg005:0836 и далее), а не прыжок с места. Внимание на `can_climb_up` (seg005:0828): там у чомпера ЕСТЬ спецкейс (`seq_73_climb_up_to_closed_gate` при взгляде ВПРАВО) — он у нас портирован (`pop_map.c`, ветка `TILE_MIRROR || TILE_CHOMPER`), но именно вокруг него и стоит копать. **Как ловить.** Брейкпоинт на резидентном `_kid_tick` с `{printf "f=%d x=%d act=%d col=%d",b@Kid+0,b@Kid+1,b@Kid+6,b@Kid+4; g}` — одна строка на кадр, адрес `_Kid` из `.sprinter-cc-roomtest/roomtest.map` (после КАЖДОЙ пересборки другой). Плюс стоп-кадр `1` в момент отступа и чтение `Kid` из памяти. Метод — memory `z80_profiling_method`. --- ## Заметки (отладка) - Тестовые клавиши осторожного шага: **J** = шаг влево, **L** = шаг вправо (эмуляция Shift+стрелка), см. `pop_ctrl.c` `KBD_DBG_STEP*`. Первый шаг в сторону = разворот (как в оригинале safe_step), движение со второго. - Читы (`pop_cheat.h`): **K** — убить стража, **I** — бессмертие (toggle), **S** — выдать меч, **Shift+L** — следующий уровень, **`[`/`]`** — сдвиг Кида на пиксель по X. - Респавн после смерти — по **↑** (или авто через `RESPAWN_DELAY`); ставит Kid в стартовую позицию УРОВНЯ (`pop_start_level`, порт do_startpos). - **ROOMNAV (`=`/`-`) — тоже наш чит**, которого в оригинале не было, как и `S`. Все они со временем съедутся в общий блок читов, разрешаемый в настройках; пока просто включены (`pop_cheats = 1` в `roomtest.c`). Известный баг этого чита закрыт — [BUG-CHEAT-FIGHT-1](BUGS_CLOSED.md#bug-cheat-fight-1). - Комнаты **13, 18, 24 уровня 1 недостижимы** в обычной игре — это свойство данных уровня (разбор — «НЕ БАГИ» в [`BUGS_CLOSED.md`](BUGS_CLOSED.md)); приоритет багов в них низкий. Аналогично 23/24 на уровне 3. --- ## DIED-ON-BUTTON. `died_on_button` (seg007:776) не портирован Обнаружено при разборе [BUG-LOOSE-BUTTON-1](BUGS_CLOSED.md#bug-loose-button-1). `check_press` (seg006:1707) разбирает ЛЮБОГО мёртвого `Char`, не только Кида: ```c if (curr_tile2 == tiles_15_opener || curr_tile2 == tiles_6_closer) { if (Char.alive < 0) trigger_button(1, 0, -1); /* жив */ else died_on_button(); /* мёртв */ } ``` `died_on_button` на **opener** делает тайл обычным полом и форсирует `button_type = tiles_14_debris` — ворота открываются насовсем; на любой другой кнопке ставит `tiles_5_stuck` (заклинена, `link_timer == 0x1F`, связь мертва). У Кида эффект живёт до перезапуска уровня (смерть → `is_restart_level` → `play_level` заново зовёт `load_level()`, тайлы перечитываются), а вот когда на кнопке умирает СТРАЖ — перезапуска нет, и кнопка заклинена до конца уровня. Что нужно: сам порт `died_on_button`, константа `TILE_STUCK = 5` и тайл заклиненной кнопки в атласе фона (сейчас его там нет). --- ## TORCH-ANIM-RIGHT. Под пламенем могут застыть не только челюсти чомпера Открыто 2026-08-10 при закрытии [BUG-TORCH-CHOMP-2](BUGS_CLOSED.md). Пламя факела запекается в фон и рисуется в ячейке ПРАВОГО СОСЕДА. Оригинал после каждого кадра факела метит этого соседа (`set_redraw_anim_right`, seg007:0101) и перерисовывает весь его слой `anim` поверх огня; мы метим только когда сосед — чомпер. Слой `draw_tile_anim` (seg008:0644) рисует также **пики, зелье и меч** — если такой тайл окажется справа от факела и будет в статике, пламя накроет и его. На уровнях 1–4 такого соседства не встретилось, поэтому расширять пометку (она стоит перерисовки тайла каждый кадр на каждый факел) заранее не стали. **Как проверять:** поставить факел слева от пики/меча/зелья в тестовой комнате и дождаться статики; либо пройти уровни 5+ и смотреть на клетку справа от каждого факела. **Как чинить, если встретится:** там же, в ветке факела `pop_process_trobs`, добавить в `trob_rcode`-проверку нужные коды и соответствующий вид пометки (`POP_RD_SPIKE` / `POP_RD_FLOOR`), а зелье — переставить в порядке обхода так, чтобы оно рисовалось ПОСЛЕ факела. --- ## FORE-DUP. Передний слой тайла рисуется ДВАЖДЫ при перекрытии объектов Найдено пользователем 2026-08-10 (вопросом, а не по симптому — картинка верная, страдают только такты). **У нас.** Fore-проход зовёт КАЖДЫЙ рисующий по своему прямоугольнику: `pop_char_fore` для слотов Кида и соперника, `pop_mirror_draw` для отражения. Окно клипа (`pop_fore_set_clip`) у каждого своё. Если футпринты двух объектов накрывают один тайл, его передний кусок рисуется два раза. Когда случается: - **Кид и отражение** — ВСЕГДА (стоят на одном тайле зеркала); этот случай создан фиксом fore-прохода над отражением 2026-08-10; - **Кид и соперник в одном тайле** — то есть весь ближний бой; - Кид и падающий кусок плиты. **Картинку не портит:** куски переднего слоя идут прозрачным блитом-копией, операция идемпотентная. Портило бы при XOR/mono с накоплением — таких в fore-слое нет. **Такты тратит,** и в самом дорогом месте: fore-проход исторически самая тяжёлая часть кадра (memory `pop_fore_layer_cost` — было 78 % кадра, окно клипа и кэш кладки дали 3.2×). **Как устроено в оригинале — дубль НЕВОЗМОЖЕН по построению.** `redraw_at_char` (seg003:0427) ничего не рисует, а только помечает тайлы своего прямоугольника: ```c for (tile_row = x_top_row; tile_row <= char_bottom_row; ++tile_row) for (tile_col = x_col_left; tile_col <= x_col_right; ++tile_col) set_redraw_fore(get_tilepos(tile_col, tile_row), 1); ``` а `set_redraw_fore` (seg007:0550) — это `redraw_frames_fore[tilepos] = frames`, ПРИСВАИВАНИЕ. Два персонажа на одном тайле оставят там ту же единицу, и единственный обход тайлов нарисует передний кусок один раз. **Что делать.** Механизм пометок у нас уже есть и прямо назван портом этой архитектуры — `pop_redraw.h`. Fore-слой остался единственным местом на прямом вызове. Приведение к оригиналу: `pop_char_fore`/`pop_mirror_draw` вместо прохода ставят пометки, а один проход в конце кадра их разбирает. **СНАЧАЛА ЗАМЕРИТЬ, потом чинить.** Это самый горячий путь, и окно клипа (вместо перебора тайлов) в своё время дало 3.2× — переход на пометки может часть этого вернуть назад. Замер: брейкпоинт на листьях fore-слоя со счётчиком, сцена «бой в комнате 3 уровня 1» и «Кид на тайле зеркала, ур. 4»; сравнить число нарисованных кусков с числом уникальных тайлов. Если дубль мал — оставить как есть и закрыть запись. --- ## MIRROR-FG-STALE. Зеркало, поставленное при игроке В комнате 4, не попадает в коллизию Заведено 2026-08-11 как краевой случай, оставшийся открытым от [L4-MIRROR](TASKS_CLOSED.md#l4-mirror) (шаг 2). **Низкий приоритет: в обычном прохождении недостижимо** — дверь выхода уровня 4 стоит не в той комнате, где зеркало, поэтому игрок физически не может быть в комнате 4 в момент её открытия. **Причина (по коду, не по симптому).** `place_mirror` (`pop_trob.c`) пишет тайл зеркала в ДАННЫЕ УРОВНЯ (`pop_level_set_tile` — страница уровня в W0) и, если комната зеркала на экране, помечает тайл на перерисовку. А коллизия работает не с данными уровня, а со СНИМКОМ комнаты `room_fg`, который `pop_room_load` делает один раз при входе в комнату (`pop_map_set(room_fg)` в `roomtest.c`). Снимок в этот момент не обновляется. **Что будет:** зеркало нарисуется (перерисовка тайла отработает), но для коллизии его как бы нет — `wall_type` не вернёт «стена слева», а ветка `is_obstacle` для прыжка сквозь зеркало не сработает. То есть Кид пробежит сквозь зеркало насквозь и **тень не родится**, пока комнату не перезайти. **Сценарий проверки** (нужен чит `+`/`−` ROOMNAV, иначе не собрать): 1. уровень 4, нажать кнопку, открывающую дверь выхода; 2. **пока дверь анимируется** (43 тика ≈ 3.5 с) уйти читом в комнату 4; 3. дождаться, когда дверь дорисует открытие — зеркало появится на экране; 4. разбежаться справа налево и прыгнуть в зеркало. - Ожидание при баге: Кид пролетает насквозь как через пустоту, тени нет. - После выхода из комнаты и возврата в неё всё работает нормально. **Фикс — несколько строк:** в `place_mirror`, в ветке `cur_room == MIRROR_ROOM`, обновить и живую карту, а не только данные уровня (в `pop_map.c` уже есть внутренние точки записи `g_fg[tilepos] = ...`, нужна публичная «поставить тайл в текущей комнате»). Делать вместе с любой следующей правкой `pop_trob.c` — отдельного захода не стоит.