# roomtest — архив закрытых багов
Сюда переезжает всё, что **закрыто**: подтверждённые фиксы, снятые
диагнозы, осознанные решения «не делать». Открытые баги — в
[`BUGS_OPEN.md`](BUGS_OPEN.md), текущие задачи — в
[`TASKS_OPEN.md`](TASKS_OPEN.md), закрытые задачи с протоколами — в
[`TASKS_CLOSED.md`](TASKS_CLOSED.md).
Файл существует не ради истории как таковой: половина записей ниже — это
разбор КОРНЯ (odd-pixel арифметика `char_x`, подстановка тайла нажатой
кнопки, баг кодогенератора SDCC), и он экономит часы, когда похожий симптом
всплывёт снова. Прежде чем заводить новый баг — грепни здесь по симптому.
---
## CUTSCENE-SKIP-HELD-KEY. Катсцены схлопывались в один кадр (и финал — в чёрный экран) — **ЗАКРЫТ 2026-08-25**
**Наблюдение (пользователь, 2026-08-24/25).** Сначала: после 14-го уровня
вместо финала чёрный экран. Затем, после первого фикса: во ВСЕХ сценах
(8, 9, 12 и финал) показывается один кадр — и сразу следующий уровень.
**Корень.** Все сцены проверяют «не просят ли пропустить» через
`kbd_raw_any_down()` на КАЖДОЙ итерации, начиная с первой. А клавиша,
которой закончили уровень, в этот момент ещё зажата: в финальную комнату Кид
ВБЕГАЕТ (стрелка удерживается), дверь уровня проходят на ходу, чит Shift+L
тоже удерживается. Удержание засчитывалось как запрос skip.
Первый фикс (ждать отпускания перед сценой) проблему только сдвинул: ждать
безусловно нельзя — потерянный break-код вешает игру намертво
([KBD-STUCK-WAIT](BUGS_OPEN.md#kbd-stuck-wait)), — а с таймаутом в секунду
сцена всё равно стартовала при зажатой клавише и гасла на первом же кадре.
**Правка.** Детектор ФРОНТА (`intro_skip_begin`/`intro_skip_requested`):
пока со старта сцены хоть что-то зажато, skip не взводится; как только всё
отпущено — следующее нажатие сцену прерывает. Применён во всех четырёх
местах: `intro_run`, `pre_princess_animated`, `intro_pv_animated`, `cut_run`.
Ожидание отпускания убрано за ненадобностью. Залипший бит в худшем случае
делает сцену непропускаемой — она доиграет сама, игра не встанет.
**Проверка.** MAME: переход 7 -> 8 читом (клавиша удерживается) — сцена 8
отыгрывает целиком, видно сидящую принцессу и мышь; финал (уровень 14,
комната 5) — принцесса поворачивается и обнимает Кида, затем мышь, затем
текст HAIL.
---
## GATE-SAFE-STEP-SHORT. Перед ОТКРЫТОЙ решёткой осторожный шаг вдвое короче — **ЗАКРЫТ 2026-08-24**
**Наблюдение (пользователь, 2026-08-24).** Идя слева направо к поднятой
решётке, Кид делает лишний мелкий шаг: чтобы подойти к краю, Shift+вперёд
надо нажать дважды.
**Корень.** Длину осторожного шага выбирает сам `safe_step` (seg005:0604) по
числу из `get_edge_distance`: последовательности 29..42 — это «шаг ровно на
distance пикселей». А `dist_from_wall_forward` у оригинала ПЕРВОЙ строкой
отвечает −1, если тайл — ворота, сквозь которые можно пройти (seg004:0A18:
`if (tiletype == tiles_4_gate && !can_bump_into_gate()) return -1`); минус
единица уводит разбор дальше, где открытые ворота опознаются обычным полом и
дают `distance = 11`. В нашем порту этой строки не было: расстояние честно
считалось до створки (у ворот `wall_type = 1`, «стена справа»), край
классифицировался как `EDGE_WALL`, и шаг выходил коротким.
**Правка.** Ветка возвращена в `dist_from_wall_forward` (pop_map.c),
проходимость берётся у нашего `gate_passable` (это и есть
`!can_bump_into_gate`).
**Проверка.** Хост-набор `tests-host/t_gate.c` (новый): на одном и том же X
открытая решётка обязана давать то же, что чистый пол, а закрытая — остаться
`EDGE_WALL`. Со снятым фиксом набор падает ровно на симптоме: `d = 9`,
`EDGE_WALL` вместо `11`, `EDGE_FLOOR`.
---
## GATE-SUPERPOSITION. Кид остаётся в тайле опустившейся решётки — **ЗАКРЫТ 2026-08-24**
**Наблюдение (пользователь, 2026-08-24).** Кид встал в тайл решётки, пока та
была поднята; решётка опустилась — Кид остался внутри. Дальше он «в
суперпозиции»: лицом влево рисуется ЗА решёткой, лицом вправо — ПЕРЕД ней, и
уйти может в обе стороны. То же самое — при попытке проползти под
опускающейся решёткой в присяде.
**Корень.** В оригинале выталкиванием занимается ОТДЕЛЬНАЯ функция кадровой
цепочки — `check_gate_push` (seg004:0833, зовётся из play_kid_frame,
seg000:1241, между `check_bumped` и `check_action`). В нашем `kid_phys` её
не было вовсе: `check_bumped` ловит только движение В препятствие, а
«препятствие появилось вокруг стоящего» — случай именно `check_gate_push`.
**Правка.** Порт добавлен в pop_map.c и вставлен в `kid_phys` на своё место.
Условия оригинала сохранены: срабатывает только в позах «стоит на месте» —
разворот (action 7), стойка (кадр 15), присед (кадры 108..110); решётка
ищется в своём тайле И в тайле слева (створка нарисована у правой грани);
перекрытие обязано быть обеими гранями и в этом кадре, и в прошлом; толчок —
5 пикселей влево (решётка своя) либо вправо (решётка левого соседа) +
`bumped_sound`.
---
## GUARD-DROPPEDOUT-CARRY. Страж идёт в челюсти: `droppedout` пережил границу уровня — **ЗАКРЫТ 2026-08-24**
**Наблюдение (пользователь, 2026-08-24).** Уровень 4, комната 22: страж без
всякого повода идёт навстречу Киду и гибнет в челюстях; если Кид спустился
рядом ниже и челюсти остановились — страж проходит сквозь них и сваливается к
Киду. Воспроизводилось несколько раз, но ТОЛЬКО когда на 4-й уровень
приходили ногами, через дверь 3-го; при отладочном старте прямо на 4-м
(`make LEVEL=4 ROOM=22`) сцена вела себя правильно — страж стоял, боя не было.
**Корень.** Единственная ветка, которая двигает стража ВПЕРЁД при
`can_guard_see_kid == 0`, — `guard_follows_kid_down()` (seg002:0806), и гейтит
её флаг `droppedout`: «Кида спихнули из боя вниз — идти ли следом». Флаг
взводит `start_fall` (seg006:1123, у нас pop_map.c), когда персонаж срывается
с уступа В БОЕВОЙ ПОЗЕ (кадры 150..179) — то есть обычная концовка боя со
скелетом на 3-м уровне. В оригинале его гасит `set_start_pos()`
(seg003:028A) при старте КАЖДОГО уровня; в нашем порту (pop_start_level,
roomtest_cold.c) этого сброса не было, и флаг переезжал на следующий уровень.
Дальше всё сходится: `can_guard_see_kid` считался ПРАВИЛЬНО (в живой сцене
прочитано `1` — чомпер в луче даёт «вижу, но не пойду»), но активная ветка
при 0 уходила не в «попятиться» (`move_down_back`), а в «идти за упавшим
Кидом», а она смотрит только на пол впереди и внизу — чомпер ей не помеха.
**Правка.** В порт `set_start_pos` возвращены сбросы оригинала:
`pop_droppedout = 0`, `offguard = 0`, `knock = 0` (fall_x/fall_y уже обнуляет
`kid_init`).
**Проверка.** MAME, сборка `make LEVEL=4 ROOM=22 POS=0`: флаг взведён
отладчиком (`pop_droppedout = 1`), затем меню -> RESTART LEVEL -> флаг снова
0, страж стоит. Само старое поведение вживую НЕ переигрывалось: причина
доказана кодом (в оригинале сброс есть, у нас его не было) и тем, что
единственный путь стража вперёд при `can_guard_see_kid == 0` гейтится этим
флагом.
**Урок.** Меж-уровневые флаги проверять поимённо по `set_start_pos`:
симптом вылезает НЕ там, где флаг взводится, и не воспроизводится отладочным
стартом с нужного уровня — именно потому, что тот стартует с чистым
состоянием.
---
## PAL-SKEL-GUARD. Скелет (уровень 3) розовый — палитру набора затирал kid.pal — **ЗАКРЫТ 2026-08-24**
**Наблюдение (пользователь, 2026-08-24).** Уровень 3, комната 1: поднявшийся
скелет нарисован розово-телесным вместо костяного белого. Кид, фон и факелы —
верные.
**Корень.** Все пиксели спрайтов скелета — ОДИН индекс 3 его набора
(`SKEL/res751..res778.png`, проверено гистограммой), то есть слот `0x93`.
Палитру набора `pop_guard_load()` заливал ОДИН раз, сразу после атласов, а
дальше по маршруту загрузки уровня шёл `pop_pal_level_load(1)` →
`pop_pal_game_load()` → `gfx_pal_fload("KID\\kid.pal")`. `kid.pal` содержит
ВСЕ 256 записей, включая диапазон стража `0x90..0x9F` с запечённым цветом 2
(`pop_pack_guard.py`, `COLOR = 2`), — и он затирал палитру скелета. Слот
`0x93` цвета 2 = `(FF,79,79)`, ровно тот розовый, что был на экране. Тот же
механизм ждал Джафара на уровне 13 (`tbl_guard_type == 3`, палитра тоже из
файла набора).
Обычный страж симптома не давал только по случайности: у него цвет
переставляет `pop_guard_set_palette()` при входе в комнату (enter_guard), то
есть ПОСЛЕ file-load. У скелета и визиря `curr_guard_color == 0` (seg002:186)
— пере-установки нет вовсе.
**Правка.** `0x90..0x9F` — такой же ДИНАМИЧЕСКИЙ диапазон, как тайлсет и Тень,
и обязан доплачиваться после каждого file-load логической палитры. Добавлен
`pop_guard_pal_apply()` (pop_cdraw.c, банк 4) — пара к `pop_bg_pal_apply` /
`pop_shadow_pal_apply`: набор со своим цветом (скелет/Джафар) берёт палитру из
своей таблицы, обычный страж — последний `curr_guard_color`
(`guard_pal_cur`, его пишет `pop_guard_set_palette`). Зовётся из
`pop_pal_game_load()` и `pop_pal_level_load(0)` ДО snapshot, чтобы правильный
цвет попал и в снимок для fade. `pop_guard_load()` больше не грузит палитру
сам, а зовёт ту же функцию.
**Проверка.** MAME, сборка `make LEVEL=3 ROOM=1 POS=12`, `pop_leveldoor_open`
взведён отладчиком, чтобы скелет поднялся сразу. Скелет белый; цвет пикселей
на скриншоте `(255,255,242)` — ровно запись 3 `pop_skel_pal`.
---
## GRAB-BELOW-ROOM. Зацеп за кромку НИЖНЕГО ряда при пролёте вниз не работал вовсе
**Наблюдение (пользователь, 2026-08-12).** Уровень 7, комната 14: по сценарию
уровня Кид спускается с ряда 0 на ряд 2 через зацеп — виснет на кромке кнопки
`(0,2)`, отпускает и в падении цепляется за кромку `(2,2)`. «Пытался много
раз — Кид летит и не цепляется ни за `(2,2)` комнаты 14, ни за `(1,2)` комнаты
15». При этом вис на `(2,2)` с последующим перелётом на `(1,2)` **работает
стабильно** — то есть сам зацеп в падении жив.
**Корень.** `do_fall` (порт seg005:0030) не давал ряду персонажа выйти за
нижнюю границу комнаты:
```c
else if (Char.curr_row < 2)
inc_curr_row(); /* «следующий ряд ВНУТРИ комнаты» */
```
В оригинале `inc_curr_row()` безусловный, и `curr_row` доходит до **3** — ряда
«за нижней кромкой»; `get_tile` для него уходит по `links.down` в комнату снизу
(`find_room_of_tile`, seg006:005D). Ряд 3 нужен не сам по себе, а потому, что
`check_grab` целится в тайл **спереди-сверху**, то есть в ряд `curr_row − 1`:
только при `curr_row == 3` целью становится ряд 2 своей комнаты.
С залипшим рядом 2 происходило вот что: пока Кид летел вдоль нижнего ряда,
`do_fall` каждый кадр уходил в ветку «достиг `y_land`», где `check_grab` не
вызывается вовсе. К моменту, когда переход в комнату снизу открывал окно
зацепа заново (там цель — «ряд −1», уже реализованный `g_above`), скорость
падения успевала дойти до `fall_y = 33`, а `check_grab` требует `< 32`. Окно
закрывалось **по скорости** — отсюда «летит и не цепляется никак».
Второй дефект, найденный там же: ветка `action == ACT_MIDAIR` в `check_action`
стояла ПУСТОЙ заглушкой с комментарием «frames 102..105: check_grab — K4». В
оригинале (seg006:0619) это первые четыре кадра падения, где `fall_y` ещё не
разгоняется (`fall_accel` работает только в `ACT_FREEFALL`), — самое широкое
окно зацепа. У нас его не было.
**Фикс** (`pop_map.c`): `inc_curr_row()` теперь безусловный внутри ветки
`curr_row <= 2` (то есть 2 → 3 разрешён), а сама ветка «достиг `y_land`»
гейтится по `curr_row <= 2` — при ряде 3 ни `in_wall`, ни `land` звать нельзя
(тайлов своей комнаты там нет, `get_tile` отдаёт `WALL`-сентинел; до
`y_land[4] = 244` дело не доходит, `check_leave_below` переводит в комнату
снизу на `y >= 211`). Плюс восстановлена ветка `ACT_MIDAIR` с кадрами 102..105.
**Подтверждение.**
- Хост-тест `tests-host/t_grab.c` → `grab_below_room_edge_window_exists`:
сцена комнаты 14 + переход в 15 по `pop_fell_out` (как в главном цикле).
Карта исходов по фазе X и задержке Shift **до** фикса — сплошные точки (ни
одного зацепа), **после** — окно из 8 фаз X при задержках 0..5 кадров.
Тест проверяет и играбельность: зацеп обязан удаваться при самом
естественном вводе (отпустил вис и сразу зажал Shift).
- Живьём в MAME (мост, 2026-08-12): Кид повис на кромке `(2,2)` — в памяти
`frame=91 y=55 row=0 room=15 act=6`, на экране держится за правый край плиты.
**Грабли теста, стоившие часа.** Первая версия сцены засчитывала как успех
ЛЮБОЙ вис — а Кид на первых кадрах падения цепляется обратно за ту же верхнюю
кромку, и тест проходил даже на сломанном коде. Признак цели пришлось делать
позиционным (`went_below` + `curr_row == 0` после перехода). Вторая ловушка:
кадры спуска `seq_68` идут с `action == 3`, то есть «дождаться падения» по
одному лишь `action` нельзя — ждать надо ВИСА, и только потом отпускать Shift.
---
## BUG-CHEAT-FIGHT-1. Чит `+`/`−` в бою: Кид остаётся в режиме боя и теряет управление
**Наблюдение (пользователь, 2026-08-07).** Если нажать `+`/`−` (ROOMNAV,
переход по комнатам) в момент, когда Кид вытащил меч для битвы, — в новой
комнате Кид не управляется: нажатия стрелок игнорируются.
**Корень (прочитан по коду, сверен с seg005).** Чит ROOMNAV
(`roomtest.c:576`) телепортирует и сбрасывает позу и HP —
`enter_room()`, `kid_init(SEQ_STAND, …)`, `pop_kid_hp_reset()`, — но **не
трогает состояние боя**: `Kid.sword` остаётся `SWORD_2_DRAWN`. Диспетчер
`pop_control` (`pop_ctrl.c:578`) при вынутом мече уходит в
`control_with_sword`, а там единственный выход из режима боя —
```c
if (Char.frame == FRAME_171_STAND_WITH_SWORD) { /* seg005:987 */
Char.sword = SWORD_0_SHEATHED;
seqtbl_offset_char(SEQ_92_PUT_SWORD_AWAY);
}
```
Стража в новой комнате нет (`can_guard_see_kid` = 0), кадр после
`kid_init(SEQ_STAND)` — обычная стойка, а не 171, поэтому не срабатывает ни
`swordfight()`, ни ветка «убрать меч»: `control_with_sword` каждый кадр
не делает НИЧЕГО, и ввод не доходит до движения. Заклинивание вечное.
Это баг **нашего чита**, а не порта: в оригинале телепорта между комнатами
нет, и в режим боя без соперника попасть нечем.
**Как чинить.** В ветке ROOMNAV телепорт трактовать как выход из боя (то же
самое, что делает `pop_start_level`): `Kid.sword = SWORD_0_SHEATHED`,
`holding_sword = 0`, сбросить `offguard`/`guard_refrac` и состояние стража
(`pop_guard_reset()` вызывается по входу в комнату — проверить, что он
обнуляет `Opp`). Меч в инвентаре (`pop_have_sword`) при этом НЕ терять.
Второй кандидат на ту же болезнь — чит `K` (убить стража) в момент, когда
Кид в стойке с мечом, но не в кадре 171: там оригинал сам доводит до 171
через `swordfight`, так что проверить сценарием, а не менять вслепую.
**ЗАКРЫТ 2026-08-10.** Сделано ровно по плану выше: ветка ROOMNAV после
`kid_init`/`pop_kid_hp_reset` гасит состояние схватки —
`Kid.sword = SWORD_0_SHEATHED`, `holding_sword = 0`, `offguard = 0`,
`guard_refrac = 0`. Меч в инвентаре (`pop_have_sword`) не теряется, только
режим боя.
## BUG-LOOSE-BUTTON-1. Упавшая плита не нажимает кнопку — **ЗАКРЫТ 2026-08-10**
**Симптом (пользователь, уровень 4).** Плита `16(1,1)` падает вниз, в комнате
17 ничего не меняется: кнопка цела, щебня нет, ворота не открылись.
**Корень — три слоя.**
1. Порт `loose_land` (seg007:11E8) у нас вообще не звал `trigger_button`: и в
своей комнате (`pop_map.c`), и в комнате снизу (`roomtest.c`) он только
клал щебень.
2. `pop_room_col_landing` (pop_level.c) считала площадкой **только чистый
пол** (`t == 1`). У оригинала площадок семь — `tiles_1_floor`,
`tiles_2_spike`, `tiles_6_closer`, `tiles_10_potion`, `tiles_15_opener`,
`tiles_19_torch`, `tiles_30_torch_with_debris`; на всём прочем кусок просто
исчезает. Кнопка в набор не входила, поэтому плита растворялась.
3. Сигнал `pop_loose_fell` взводится в момент ОТРЫВА плиты, а по нему главный
цикл и щебень клал, и кнопку жал — ворота начинали открываться, пока плита
ещё в воздухе.
**Механика оригинала (важно, интуиция обманывает).** Плита не «постоянно
давит» на кнопку. Нажатие ОДНО, но с `button_type = tiles_14_debris`, а это у
`trigger_gate` отдельная ветка: возврат 2 → `animate_door` доводит створку и
ставит `curr_modifier = 0xFF` → ворота заморожены открытыми навсегда, обычный
opener их больше не трогает (`if (modifier == 0xFF) return -1`). Сама кнопка
съедается: тайл становится щебнем. На closer — быстрое закрытие и тоже
щебень. На факеле остаётся отдельный тайл `tiles_30_torch_with_debris`.
**Фикс.** Набор площадок расширен до оригинального; `pop_trigger_button`
зовётся в обоих местах с правильным `button_type`; посадка в комнате снизу
переехала с `pop_loose_fell` на новый сигнал `pop_loose_exit`, который
`mob_tick_one` взводит, когда кусок ушёл ниже поля комнаты.
**Осознанное расхождение.** Оригинал через `mob_down_a_row` продолжает
симуляцию куска уже в нижней комнате; у нас он там не летит, а садится сразу.
Задержка получается «высота комнаты», а не «высота комнаты плюс путь до пола
внизу».
---
## BUG-GATE-FF-1. Ворота, открытые навсегда, нарисованы открытыми, но непроходимы — **ЗАКРЫТ 2026-08-10**
**Симптом.** После BUG-LOOSE-BUTTON-1 ворота `23(0,9)` рисуются поднятыми, но
Кид в них упирается.
**Корень — перегруженное значение.** `gate_modif` (pop_map.c) отдаёт `0xFF`
как сторожевое «тайла нет за краем уровня», а `trigger_gate` ставит тот же
`0xFF` как живой модификатор «открыто НАСОВСЕМ». В `gate_passable` стояло
`if (gm == 0xFF) return 0;`. До сегодняшнего дня значение в игре не
возникало — ветку по щебню никто не запускал.
**Фикс.** Ветка убрана: она мёртвая (нас зовут только когда тайл ТОЧНО
ворота), а `(0xFF >> 2) + 6 = 69` и так больше любого `fph`. Остальные
читатели `gate_modif` трактуют 63 как «открыто» и правок не потребовали.
---
## BUG-TORCH-CHOMP-1. Чомпер стирает пламя соседнего факела — **ЗАКРЫТ 2026-08-10**
**Симптом (пользователь).** Комната 23 уровня 4: факел есть, огня нет.
**Корень.** Пламя рисуется в ячейке ПРАВОГО соседа (`x = COL_XH[col+1]*8 + 8`
= 232..248, `y` 33..50), то есть в тайле чомпера (0,7). `pop_chomp_redraw`
начинается с `pop_heal_off(224, 2, 32, 64)` — восстанавливает запечённый фон в
`x 224..255, y 30..93`. Пламя в фон не запекалось и рисовалось в
`GFX_BANK_SPRITE`, поэтому heal его съедал; вдобавок `pop_redraw_needed` идёт
ПОСЛЕ `pop_process_trobs`, так что стиралось только что нарисованное.
**Тупик, в который я сначала свернул.** Вынес отрисовку пламени в отдельный
проход после `pop_redraw_needed` — стало хуже: пламя легло ПОВЕРХ челюстей
(правильный z-порядок существует только внутри общего обхода тайлов, где
`draw_tile_anim_right` идёт до `draw_tile_anim`), плюс проход индексировал
`trob_code[i]` параллельно `trobs[i]`, а `pop_process_trobs` в конце уплотняет
массив — индексы разъезжались, и чужой trob рисовался как факел (пламя из
головы Кида). Проход откачен.
**Фикс (подсказан пользователем).** Пламя ЗАПЕКАЕТСЯ: `pop_torch_draw` пишет
в `GFX_BANK_NORMAL` (видео-ОЗУ + ОЗУ-копия). Это безопасно именно у факела —
все девять кадров лежат на общем канвасе 16×18 и упакованы `opaque=True`, то
есть кадр полностью накрывает предыдущий, протухнуть в копии нечему; и факел
всегда задний план. Тогда heal чомпера возвращает огонь сам, а челюсти
ложатся поверх — тот же z-порядок, что в оригинале, и без лишнего прохода.
Пузырёк ЗЕЛЬЯ так нельзя: он ползёт вверх, себя не накрывает и требует heal —
`pop_potion_draw` остаётся в `GFX_BANK_SPRITE`. Столкнуться с чомпером в
одном тайле зелье не может.
## BUG-GUARD-IX-1. «Зависание» при бое со стражем — затёртый IX главного цикла — **ЗАКРЫТ 2026-08-10**
**Симптом (пользователь, уровень 4).** Дважды подряд при ручном входе в
комнату 18 со стражем игра «повисала»: картинка стоит, управление не
отвечает. Нажатие «2» ненадолго возвращало движение, потом всё повторялось.
**Диагноз — НЕ зависание.** Главный цикл всё это время крутился (маркер
верха цикла тикал раз в кадр). Стоял отладочный стоп-кадр: локаль `frozen`
в `main` сама собой становилась ненулевой.
**Корень — однобайтовый выход за границу локального массива.** У `main` все
локали лежат по `IX-1..IX-19`, и IX затирался: вместо `0xBFFA` в нём
оказывался `0xBF00`. Признак железный — в теле цикла обязано выполняться
`SP == IX-19`, а по факту было `SP=0xBFE7, IX=0xBF00`. Дальше `main` читал
`frozen`, `dbuf`, `front/back`, `dead_frames` и edge-флаги читов из живого
стекового мусора.
Виновник — `check_chomped_guard` (pop_map.c):
```c
uint8_t flags[COLL_N]; /* 14 байт в кадре функции */
calc_coll_window(); /* окно СТРАЖА -> win_lo/win_hi */
get_row_collision_data(Char.curr_row, flags);
```
`coll_row()` пишет по `flags + scan_off`, а число байт берёт из
`win_lo/win_hi`. `scan_off`/`scan_left0` выставляет `coll_scan_prepare()`,
которую звал ТОЛЬКО `check_collisions` — путь Кида. То есть ряд стража
писался **по смещению Кида, длиной стража**. Диапазон записи
`[kid_win_lo+2 … kid_win_lo+2 + ширина_окна_стража − 1]`; при обычной ширине
4 он уезжает за `flags[13]`, когда `kid_win_lo > 8` — то есть **когда Кид
стоит у правого края комнаты** (взаимное расположение Кида и стража ни при
чём, стража даёт только длину). Сразу за массивом лежит сохранённый IX;
затёртый младший байт и превращал `0xBFFA` в `0xBF00`.
Перелёт в отрицательные индексы был невозможен: `scan_off = kid_win_lo + 2`,
а `kid_win_lo >= COLL_C0 = −2` из-за клампа в `calc_coll_window`.
**Почему не ловилось раньше.** На уровне 1 (комната 3) бой идёт левее
середины — запись оставалась внутри массива. Ничего не портилось, но флаги
стража всё равно читались по чужим смещениям и с иксами Кида: тихо неверные
данные, без последствий (чомперов там нет).
**Фикс.** `coll_scan_prepare()` в начале `get_row_collision_data()` — теперь
запись всегда ложится в `[win_lo+2 … win_hi+2]`, а `calc_coll_window` клампит
окно в `[−2 … 11]`, то есть индексы гарантированно `0…13`. Заодно чинится
сам расчёт: `scan_left0` задаёт x колонок, и чомпер-коллизия стража считалась
по координатам Кида.
**Как ловилось (приём на будущее).** Детекторы `ix != 0xBFFA` на границах
фаз `PROF()` с починкой IX в верху цикла: игра остаётся живой, а первый
сработавший детектор называет фазу. Дальше — инструкционная трасса MAME
(`trace file,0`), включаемая на подозрительном вызове и выключаемая сразу
после, со стопом только на плохом кадре: в файле остаётся ровно тот вызов,
где IX испортился. В хвосте трассы видно `ld sp,ix / pop ix`, достающий
`BF00` вместо `BFFA`. См. memory `z80_profiling_method`.
**Проверка.** Бой со стражем в комнате 18 уровня 4 (пользователь) —
детекторы IX висели без починки и не сработали ни разу; `make -C tests-host`
— все 5 наборов.
---
## BUG-GATE-SEAM-ROW1. Решётка в шве не анимируется, если ворота НЕ в ряду 0 — **ЗАКРЫТ 2026-08-10**
**Симптом (пользователь, уровень 4, стартовая комната).** Плита нажимается,
ворота физически открываются (Кид проходит), но визуально с решёткой ничего
не происходит. Отдельно: чёрный треугольник над воротами (верх решётки под
ковром) статичен, тогда как в оригинале он ездит вместе с барами.
**Корень.** Зонд на `pop_add_trob` показал `room=8 tp=19 type=1`: плита
комнаты 1 открывает ворота **комнаты 8 в (1,9)** — соседней комнаты, видимые
через ЛЕВЫЙ шов (бары ворот в col9 рисует col0 соседа справа). Наш
change-driven редрой шва смотрел только один байт `m[9]` (ряд 0), а
`pop_room_redraw_seam_left()` перерисовывал жёстко `draw_tile(0, 0)`.
Изменение приходило в `m[19]` — сигнатура его не видела.
На уровне 1 та же связка комнат 6/8 работала только потому, что решётка
соседа стояла в **(0,9)**. Тайлсет ни при чём: в подземелье просто не
попадалось ворот в ряду 1 у шва (и треугольника не было — над решётками
всегда потолок).
**Фикс.**
1. `seam_sig` — массив на три ряда, сравниваются все три; накопленная маска
изменившихся рядов живёт в `seam_rows` и переживает оба кадра
дабл-буфера.
2. `pop_room_redraw_seam_left(uint8_t rows)` берёт маску и перерисовывает
только помеченные ряды — не все три, чтобы не платить каждый кадр
анимации.
3. В маску добавляется ряд ВЫШЕ (`changed | (changed >> 1)`): верх решётки
(`draw_tile_anim_topright`, seg008:0568 — маска 68 + кадр
`DOOR_FRAM_TOP`) рисует не сам тайл ворот, а тайл над-справа от него, у
нас `(r−1, 0)`. Без этого бары ездили, а треугольник над ними стоял.
**Проверка.** Уровень 4, стартовая комната: нажатие плиты — решётка шва
поднимается/опускается вместе с верхом (пользователь).
## BUG-GUARD-COLOR-1. Страж и его полоса HP — всегда одного цвета — **ЗАКРЫТ 2026-08-07**
**Симптом (пользователь, прогон уровня 2).** Комната 4: страж не того цвета,
что в SDLPoP, и полоса его HP тоже.
**Корень.** Оригинал держит ОДИН набор спрайтов стража и подменяет 16 цветов
палитры: `redraw_screen` (seg003:255) зовёт `set_chtab_palette(chtab_5_guard,
&guard_palettes[0x30*curr_guard_color − 0x30], 16)` ПЕРЕД отрисовкой комнаты,
где `curr_guard_color = level.guards_color[room−1] & 0x0F` (enter_guard,
seg002:184), а `guard_palettes` — ресурс 10 из `PRINCE.DAT` (7 палитр × 16
цветов, 6-битные каналы). У нас `pop_pack_guard.py` брал `COLOR = 2`
константой (цвет стражей уровня 1) и запекал одну палитру в слоты
`0x90..0x9F`. Полоса HP рисуется тем же атласом — отдельного бага не было.
**Фикс.**
1. `pop_pack_guard.py` выгружает ВСЕ 7 палитр в `pop_guard_pal.h`
(7 × 16 × 4 = 448 Б, записи `B,G,R,0` — формат `gfx_pal_load`);
каналы масштабируются `scale6to8`, как в `pop_pack_kid` для `kid.pal`.
2. `pop_guard_set_palette(color)` (`pop_gdraw.c`) заливает 16 слотов в ОБЕ
палитры дабл-буфера; `color == 0` — не трогать (так и оригинал).
3. Зовётся из `pop_guard_enter` сразу после чтения данных комнаты, то есть
ДО отрисовки — как `redraw_screen`. `pop_level_guard` цвет отдавал уже
давно, его просто никто не использовал.
**Грабли, стоившие итерации (записать на будущее).** Первая версия передавала
`gfx_pal_load` указатель прямо на таблицу — а таблица лежит в rodata
БАНКОВОГО модуля, то есть по 0xC000+. `gfx_pal_load` отдаёт указатель в BIOS
(`$A4` через `rst #0x08`), а BIOS читает только `#4000-#BFFF` (корневой
CLAUDE.md). BIOS забирал мусор с чужой страницы, палитра уезжала в тёмное и
страж становился НЕВИДИМЫМ. Лечится копией записи в локальный буфер — стек
гарантированно в W2.
**Проверено в MAME (уровень 2, ROOMNAV):**
| комната | цвет из уровня | что на экране |
|---------|----------------|---------------|
| 11 | 1 | страж сине-фиолетовый, полоса HP синяя |
| 7 | 3 | страж оранжевый, полоса HP оранжевая |
| ур. 1 | 2 | охра, как было (регрессии нет) |
Цвета сходятся с таблицей `pop_guard_pal.h`: цвет 1 = (72,145,255),
цвет 3 = (255,80,0), цвет 2 = (170,48,0).
**Смежное, НЕ входит сюда:** палитра КЛАДКИ уровня 3 (в оригинале зелёная) —
другой механизм и другой ресурс, заведена отдельной задачей
[L3-COLOR](TASKS_OPEN.md#l3-color).
---
## BUG-SWORD-GHOST-1. Переход комнаты В БОЮ: Кид прячет меч и дерётся пустой рукой — **ЗАКРЫТ 2026-08-07**
**Проверено в игре (пользователь):** «вроде проблема не воспроизводится —
всё корректно». Замер на замороженном кадре: оба персонажа в комнате 2,
ряд 1, колонки 4 и 7, `Kid.sword = 2`, `Guard.sword = 2`, луч видимости
прошёл, счётчик уборок меча за прогон — **0**.
**Симптом.** Кида вытеснили из комнаты 3 в комнату 2 в бою, он спрятал
меч; страж вошёл следом, и дальше Кид «отбивался» без клинка. Зависимость
от расстояния между Кидом и стражем в момент перехода: близко — бой
продолжался нормально, далеко — меч убирался и страж не шёл, а в
промежутке страж приходил, но меч оставался в ножнах.
**Корень — мнимая стена за краем комнаты, а не боёвка.** Ложным было
`can_guard_see_kid`. Луч видимости (`check_can_guard_see_kid`, seg003:688)
идёт по тайлам между колонками Кида и стража, а колонка считается из `x`:
`floor((x − 7 − 58) / 14)`. На всём диапазоне `x` 0..255 это даёт `col`
от **−5 до 13**, то есть колонка регулярно уходит ЗА комнату. Оригинал
такие колонки резолвит через `find_room_of_tile` (seg006:005D), гуляя по
`roomlinks` на любую глубину; наш `get_tile` знал соседей только на две
колонки (`−2..11`), а дальше отдавал `TILE_WALL`. Луч упирался в эту
мнимую стену, `can_guard_see_kid` падал в 0 — и `control_with_sword`
(ветка «противника не видно») штатно убирал меч.
Тот же ноль не давал достать меч обратно: `control_standing` зовёт
`draw_sword` только при `can_guard_see_kid >= 2`. Отсюда и «драка без
меча», и зависимость от расстояния — на самом деле не от расстояния, а от
того, вышла ли колонка за кэш.
**Артефакт (регистратор в памяти, снят с живой сцены).** Брейкпоинт тут
бесполезен — баг ловится руками и редко, поэтому в код были временно
вшиты байты «кто убрал меч и почему оборвался луч»; читались после сцены:
```
src = 2 control_with_sword, ветка «противника не видно»
why = 2 луч упёрся в стену
tile = 0x14 = 20 = TILE_WALL
col = 12 ← колонка стража ЗА кэшем (было −2..11)
kid_col = 8 guard_col = 12
```
**Фикс.** `pop_map` кэширует `fg` соседних комнат слева и справа ЦЕЛИКОМ
(по 30 байт, раскладка комнаты) и резолвит `col` от −10 до 19 в реальный
тайл соседа; за этими пределами — по-прежнему стена (у оригинала там
следующий `roomlink`, у нас край кэша). Данные забирает сама
`pop_map_set_edges(left, right, up, down)`, читая уровень — отдельной
копии в приложении нет. Заодно `gate_modif` перестал читать за границу
массива модификаторов: openness ворот на дальних колонках берётся из
`room_modif` СОСЕДА (`pop_gate_modif`, им же пользуется луч).
Цена: **+48 байт** в W2 (кэш 6+6 → 30+30), код W1 даже уменьшился.
Комнаты сверху/снизу оставлены как были (`above_fg` / `below_fg`): по
вертикали `curr_row` за −1..3 не выходит, мнимых стен там не возникает.
Попутно вернули строку оригинала, потерянную при порте: `control_with_sword`
сбрасывает `holding_sword` для живого Кида (seg005:980) — от неё зависит
индикатор HP стража.
**Регресс:** `tests-host` — все 5 наборов зелёные, характеризационные
трассы Кида не сдвинулись (`[phys] ok: 1723`). Новый набор проверок
`char_tiles_resolve_across_rooms` в `t_char` держит резолв колонок
−11..20 в тайлы соседей и стену на краю кэша — чтобы кэш нельзя было
молча сузить обратно.
**Почему в SDLPoP не воспроизводилось** (пользователь пробовал): там этой
границы просто нет — `find_room_of_tile` уходит по `roomlinks` сколько
нужно, и луч никогда не встречает мнимой стены.
---
## BUG-GRAB-1. Прыжок с места через провал в 3 тайла: зацепа нет — **ЗАКРЫТ 2026-08-05**
**Проверено в игре (пользователь):** «зацеп работает». Физика была верна с
самого начала, чинить пришлось клавиатуру — [BUG-KBD-5](#bug-kbd-5).
> **Итог 2026-08-05.** Физика тут ни при чём — виновата клавиатура.
> Зажатый Shift снимался автоповтором зажатой стрелки, поэтому к кадрам
> 102…106 (окно зацепа) движок видел Shift отпущенным. Полный разбор и
> фикс — [BUG-KBD-5](BUGS_CLOSED.md#bug-kbd-5); поведение Shift в MAME
> проверено замером карты `_kbdraw_down`. Осталось подтвердить сам зацеп
> живой игрой; версии 2 и 3 ниже проверять только если он всё ещё не выйдет.
**Симптом.** Уровень 2, комната 9. Перепрыгнув на (1,1), Кид должен
вернуться обратно: разбегаться негде, поэтому он встаёт на самый край
плиты, прыгает с места и **цепляется руками за (1,5)**, после чего
подтягивается. У нас Кид с зажатым Shift всё равно срывается.
**Что уже точно известно (и не надо перепроверять).**
1. **Физика прыжка у нас совпадает с оригиналом кадр в кадр.** Сверено по
логу SDLPoP против трассы харнесса при одинаковом старте `x=95`:
```
кадр 16 18 22 23 24 25 102 103 104 105
SDLPoP 95 97 105 112 121 126 128 130 131 133
наш 95 97 105 112 121 126 128 130 131 133
```
Совпадает и по `y`, и по колонке/ряду, и по приземлению на 107–108.
2. **В оригинале зацеп срабатывает на кадре 106, а не 102..105.**
`check_grab` зовётся из ДВУХ мест: ветка «в воздухе» в `check_action`
(кадры 102..105) и `do_fall` (seg005) для `actions_4_in_freefall`.
Успешная попытка из лога:
```
GRAB try f=106 x=135 y=166 col=4 row=2 fall_y=18
GRAB probe x=127 col=4 through=0 front_above=3 modif=0
GRAB can_grab=1
GRAB OK dist=9
-> f=91 x=136 y=181 col=5 row=2 act=2 (повис)
```
Наш `do_fall` (`pop_map.c`) `check_grab()` из этой ветки тоже зовёт —
то есть структура на месте, расходится что-то внутри.
3. **По харнессу зацеп у нас РАБОТАЕТ**: окно стартовых `x = 91…95`, и
короткий шаг ставит Кида ровно туда (91 после первого нажатия, 95 после
второго). Зафиксировано тестом `t_grab`.
**Отсюда главный вопрос был: почему харнесс говорит «работает», а живая
машина — «нет».** Расхождение между ними и оказалось уликой; версии
выдвигались по убыванию правдоподобия, и сработала первая:
- **Shift не доезжает до движка — ПОДТВЕРЖДЕНО, это и была причина.**
Харнесс подменяет клавиатуру и потому этот путь не проверяет вовсе, а у
нас есть история проблем ровно с «Shift + стрелки» (KBD-1, BUG-KBD-3/4).
Замер в MAME: при зажатом Shift и зажатой стрелке бит `LSh` в
`_kbdraw_down` стоял в нуле. Разбор — [BUG-KBD-5](BUGS_CLOSED.md#bug-kbd-5).
- **Сцена харнесса не равна комнате 9.** Там изолированная комната
(соседи — стена), а в игре слева комната 8; кромки шва участвуют в
`get_tile`. Проверять чтением `Kid.x` в момент прыжка: попал ли он в
окно 91…95 вообще.
- **Расхождение в `check_grab`.** Наш вариант зовёт `determine_col()`
там, где оригинал зовёт `load_fram_det_col()` (перезагрузка кадра +
колонка). Для Кида это обычно одно и то же (`cur_frame` в фазе физики
принадлежит ему), но проверить стоит.
**Инструменты готовы.** В SDLPoP включена отладка (пометка `DBG-GRAB`):
`JMP` — покадровая трасса прыжка/падения/виса, `GRAB try|probe|fail|OK` —
вход в `check_grab` и причина отказа. Снимается поиском по `DBG-GRAB`.
**Найдено попутно, отдельным наблюдением.** После касания площадки на
кадрах 107–108 (x=140) оба движка снова падают, но X расходится: SDLPoP
уводит Кида на 134 (колонка 4), мы — на 141 (колонка 5). Похоже на разную
отработку `in_wall()` у стены (2,7). На зацеп не влияет.
---
---
## BUG-GATEMOD-1. Ворота стартуют закрытыми, хотя в уровне открыты — **ЗАКРЫТ 2026-08-05**
**Проверено в игре (пользователь).**
**Симптом (пользователь, 2026-08-04).** Уровень 2, комната 13: решётка
между (2,5) и (2,6) обязана быть ОТКРЫТА в начале и захлопнуться, когда Кид
нажмёт кнопку (2,4) — после этого назад дороги нет. У нас она закрыта
сразу, кнопка бессмысленна, проход не работает.
**Корень.** Модификатор тайла в ФАЙЛЕ уровня и модификатор в РАНТАЙМЕ —
разные величины; оригинал переводит их при загрузке в `load_alter_mod`
(seg008:198E), которую зовёт `alter_mods_allrm` из `load_level`:
```c
case tiles_4_gate: *modif = (*modif == 1) ? 188 : 0; break;
case tiles_11_loose:*modif = 0; break;
case tiles_10_potion:*modif <<= 3; break;
```
Наш `pop_trob_modif` портировал из неё **только зелье**. Для ворот
`bg = 1` — это «открыты» (Table 8 спецификации DAT), а в рантайме открытость
измеряется высотой подъёма 0..188; мы клали в рантайм-модификатор сырую
единицу, то есть «закрыты на 1/188».
**Фикс.** Ветки ворот и loose дописаны в ленивую инициализацию
`pop_trob_modif` (`pop_trob.c`). Ветка СТЕН не портируется намеренно: у нас
`pop_bg` считает связи кладки по типам соседей прямо при отрисовке
(`wall_modifier`), сохранённый модификатор стены не читается.
**Что это ещё задевает.** Решётка (0,9) комнаты 5 уровня 1 тоже имеет
`bg = 1`, то есть обязана стартовать открытой — Кид сваливается в комнату 1
именно через неё, и она захлопывается у него за спиной. Закрывает её
стартовый триггер `do_startpos` (seg003:167): для уровней с
`tbl_entry_pose == 1` оригинал ВИРТУАЛЬНО ЖМЁТ кнопку комнаты 5 (0,2) —
```c
// Special event: press button + falling entry
get_tile(5, 2, 0); trigger_button(0, 0, -1); seqtbl_offset_char(seq_7_fall);
```
Замер в SDLPoP (лог по кадрам): `gate(5,0,9)` идёт `188 → 148 → 88 → 8 → 0`,
шаги 40/60/80 — это `gate_close_speeds`, то есть быстрое закрытие
(`trigger_gate` вернул тип 3). У нас этот триггер портирован, и закрытие
работает.
Полный список ворот с `bg = 1`: ур. 1 комн. 5 (0,9); ур. 2 комн. 13 (1,5).
Остальные ворота уровней 1–3 имеют `bg = 2` → 0, и для них ничего не
меняется (при модификаторе 2 и 0 и отрисовка, и `can_bump_into_gate` дают
одно и то же).
**Побочная находка: чит обхода комнат отматывал мир.** После фикса
пользователь увидел «ворота снова открылись», пройдя `+` в комнату 2 и `-`
обратно. Причина не в воротах: `ROOMNAV` звал `pop_trob_reset()` перед
`enter_room`, тот обнулял `room_seen[]`, и `pop_trob_modif()` перечитывал
модификаторы из уровня заново — то есть чит откатывал открытые/закрытые
ворота, выдвинутые пики и нажатые кнопки. Пока ворота с `bg=1` ошибочно
стартовали закрытыми, откат был не виден. `pop_trob_reset()` из навигации
убран: она обязана только телепортировать, исходное состояние даёт
перезапуск уровня. Замер, который это показал: `room_modif` комнаты 5
после `+`/`-` = `00 00 0B 00 09 00 08 01 00 BC` — последний байт 0xBC = 188,
файловое значение.
---
---
## BUG-GUARD-DEAF-1. Страж не оборачивался на вернувшегося Кида — **ЗАКРЫТ 2026-08-05**
**Наблюдение (пользователь, 2026-08-05).** Уровень 2, комната 11, идёт бой
со стражем. Страж **выталкивает Кида в правую комнату 22**. Кид заходит
обратно в комнату 11 — страж стоит на (1,1), **повёрнут налево и Кида не
видит**. Предположение пользователя: это часть большой задачи «полностью
переделать поведение стража по образцу Кида».
**Диагноз: большая переделка тут ни при чём, корень маленький и точный.**
Само возвращение стража в исходную позу — ПРАВИЛЬНОЕ поведение, порт верен;
не хватает ровно одного сигнала.
Разбор по SDLPoP:
1. **Выход Кида из комнаты «усыпляет» стража — так и в оригинале.**
`leave_guard` (seg002:02F5) складывает стража обратно в данные уровня
(тайл, `x`, **направление**, skill, HP), а `enter_guard` (seg002:0112)
при возврате поднимает живого стража **с УБРАННЫМ мечом**
(`sword_0_sheathed` + `seq_77_guard_stand_inactive`) и обнуляет
`is_guard_notice`/`guard_refrac`. То есть страж после возврата ВСЕГДА
неактивен и смотрит в запомненную сторону — у нас так же
(`pop_guard_enter`, `pop_guard.c:127`).
2. **Дальше решает `autocontrol_guard_inactive` (seg002:0876), и он Кида за
спиной ИГНОРИРУЕТ.** Кид вернулся справа, страж смотрит влево →
`char_opp_dist()` отрицательна → ветка `else if (distance < 0) return;`.
Единственный выход из неё — флаг **`is_guard_notice`**: «Кид нашумел».
При нём страж оборачивается (`move_4_down`). Направление в
`check_can_guard_see_kid` НЕ участвует вовсе, так что «не видит» — это
не про луч видимости, а именно про этот флаг.
3. **У нас `is_guard_notice` не взводится НИГДЕ.** `grep` по всем исходникам
roomtest: объявление (`pop_guard.c:20`), сброс (`pop_guard.c:47`) и одно
чтение (`guards.c:171`). Присваивания `= 1` нет ни одного — флаг мёртв,
поэтому неактивный страж не обернётся НИКОГДА, что бы Кид ни делал.
**Где его взводит оригинал** (это и есть объём фикса):
| место | событие |
|-------|---------|
| `seg006:633..641` — `play_seq`, опкод SOUND | звук `SND_SILENT`(0), `SND_FOOTSTEP`(1), `SND_BUMP`(2). `SND_DRINK`(3)/`SND_LEVEL`(4) — НЕ шум. Главный источник: шаги бега/приземления |
| `seg004:05F1` `bumped_sound` | удар в стену |
| `seg005:185,195` | мягкое и среднее приземление (**только `charid_0_kid`**) |
| `seg006:1294`, `seg006:1734` | Кид обрушил loose-плиту (зацепом и наступив) |
| `seg007:766` | нажата кнопка |
У нас опкод SOUND в `play_seq` (`pop_kid.c:426`) просто съедает байт
аргумента — звука нет, и флаг вместе с ним потерялся. Именно эта строка —
90 % фикса: `SND_SILENT` называется «silent» потому, что звука не издаёт,
**но стражи его всё равно замечают**, и в `seqtbl` он стоит, например, в
`ready` (доставание меча).
**Ожидаемое поведение после фикса.** Кид возвращается в комнату 11 бегом →
первый же `SND_FOOTSTEP` взводит флаг → страж оборачивается и достаёт меч.
Стоя на месте, Кид может подкрасться к стражу со спины — это НЕ баг, а
механика оригинала.
**Оговорка (не проверено вживую).** Разбор построен на том, что страж после
возврата неактивен (меч убран, кадр 166). Это следует из кода
`pop_guard_enter`, но в MAME не снималось; если окажется, что меч у него
ОБНАЖЁН, то работает другая ветка (`autocontrol_guard_active`, где
`can_guard_see_kid == 2` направления не спрашивает) — и тогда корень другой.
Снять при фиксе: `Guard.sword`, `Guard.frame`, `can_guard_see_kid` сразу
после входа в комнату.
**Фикс (2026-08-05): все пять мест портированы.** Опкод SOUND в `play_seq`
(`pop_kid.c`) взводит флаг для звуков 0..2; `bumped_fall`/`bumped_floor`,
мягкое и среднее приземление, обрушенная плита (все три пути `check_press`)
— в `pop_map.c`; щелчок кнопки — в `pop_trob.c`. Заглушка `is_guard_notice`
добавлена в `tests-host/stubs.c` (автопилота стража в наборах нет).
**Проверено в игре (пользователь, 2026-08-05):** тот же сценарий — страж
выталкивает Кида в комнату 22, Кид возвращается бегом — **страж
оборачивается**.
**Что осталось «как в оригинале» и багом не является:** подкрасться к
стражу СТОЯ по-прежнему можно (шумит движение, а не присутствие), и после
возврата в комнату страж всегда поднимается с УБРАННЫМ мечом в неактивной
стойке, повёрнутый туда же, куда смотрел при выходе Кида — это `enter_guard`
(seg002:0112), а не потеря состояния.
---
---
## BUG-JUMPWALL-1. Недолетевший прыжок проходил СКВОЗЬ стену — **ЗАКРЫТ 2026-08-05**
**Симптом (пользователь, приёмка уровня 3).** Комната 14: Кид на (0,8)
лицом влево, прыжок с места — пролетает сквозь кладку и падает в комнату 13
на (2,3). Обобщение пользователя (оно и оказалось верным): «если Кид
прыгает, недолетает и должен врезаться в стену и упасть ВДОЛЬ неё, у нас он
летит по свободной траектории, как будто ему никто не мешает».
**Корень — упрощение в `check_collisions`, про которое там же стояла
оговорка.** Оригинал (seg004:0004) каждый кадр считает флаги перекрытия для
ТРЁХ рядов (`curr`/`above`/`below`), а `move_coll_to_prev` (seg004:00DF) в
начале следующего кадра выбирает из них тот, что соответствует ряду прошлого
кадра. Так «прошлые» флаги остаются настоящими и на кадре СМЕНЫ РЯДА.
У нас ряд был один, и на смене ряда `prev` заполнялся тройками («уже
перекрывал всё»), что подавляло бамп на этом кадре. В падении ряд меняется
почти каждый кадр, а переход флага 0→1 на стене приходится ровно на него —
поэтому удар не регистрировался и `bumped_fall` (который и гасит `fall_x`)
не вызывался.
**Как ловили.** Не в MAME, а на харнессе: набор
[`tests-host/t_wall.c`](tests-host/t_wall.c) — комната 14 уровня 3 с
подложенным дном шахты, свип по всей ширине стартовой плиты (x = 177..196).
Из 20 стартовых позиций 4 давали проход сквозь кладку (Кид оказывался в
колонке 4 при стене в колонке 5). Первый вариант сценария (одна стартовая
X, из центра плиты) БАГА НЕ ПОКАЗАЛ — отсюда свип.
**Фикс.** Три ряда как в оригинале + дословный `move_coll_to_prev`
(включая условия ±3 на заворот ряда при смене комнаты). Цена — 3×14
`get_tile` на кадр вместо 14, банк 3 +115 Б.
**Чем проверено.** `t_wall` зелёный; **все 1723 существующие трассы
`t_phys` не изменились ни на байт** — правка поведение-сохраняющая для
всего остального. В живой игре подтвердил пользователь: «ударились и
падаем вертикально вниз».
---
## BUG-SEAM-WEDGE-1. Клин кладки в пустом углу (2,0) — **ЗАКРЫТ 2026-08-05**
**Симптом (пользователь, приёмка уровня 3).** Комната 18: в тайле (2,0)
нарисован кусок кладки, хотя по данным там пусто.
**Корень.** У тайла (2,0) «сосед снизу-слева» — это колонка −1 ряда 3, то
есть тайл ЧУЖОЙ комнаты (`room_BL` — левая от нижней). `load_rowbelow`
(seg008:368) резолвит его через `get_tile_to_draw(room_left, 9, 0, …)` и
подставляет стену ТОЛЬКО когда такой комнаты нет. Мы клали стену
безусловно, а `draw_topright` для стены рисует угловой кусок — он и был
клином. Для комнаты 18 диагональ — комната 13, её тайл (0,9) пуст, значит
рисовать нечего.
**Фикс.** Ряд «снизу» стал одиннадцатибайтным: `[0..9]` — колонки комнаты
снизу, `[10]` — тайл (0,9) комнаты снизу-слева (дефолт «стена», если её
нет). Затронуты `pop_level.c` (заполнение), `pop_bg.c` (чтение),
`roomtest.c` (размер массива).
**Проверено** в MAME: комната 18 уровня 3, клина нет.
---
## BUG-LOOSE-3. Чёрный бар под упавшей плитой-потолком — **ЗАКРЫТ 2026-08-05**
**Симптом (пользователь, приёмка уровня 2).** Комната 6: Кид сбивает
плиту-потолок (−1,2) — плита падает, но на её месте остаётся чёрный бар.
Два уточнения пользователя оказались диагностическими:
1. «бар попал в фоновое изображение» — Кид прыгает поверх, уходит, бар
остаётся → чернота лежит в ОЗУ-копии, и heal возвращает её каждый кадр;
2. «когда возвращаемся в комнату после выхода — бара нет» → статическая
отрисовка комнаты рисует всё правильно, виновата ЗАПЕЧКА.
**Корень.** `pop_ceil_bake_empty` стирал полосу потолка чёрной плитой
(`bar`, банк NORMAL — пишет и в ОЗУ-копию) шириной 64 px, а восстанавливал
только ДВА тайла ряда −1: `(−1,col)` и `(−1,col+1)`. Но куски тайлов
рисуются ВВЕРХ от своей нижней грани и свисают ВПРАВО, поэтому в стёртую
полосу попадает графика и соседа СЛЕВА, и тайлов ряда 0 — их верхушки.
Незакрашенным оставался прямоугольник ~13×3 px.
**Замер (он же метод).** Плита роняется без игры — записью
`pop_ceil_modif[col] = 1` в отладчике MAME (это ровно то, что делает
`make_loose_fall` для потолка). Дальше попиксельная сверка скриншота
«после запечки» со скриншотом «комната перерисована заново» (выйти и
вернуться): различие локализовалось в прямоугольник **экранные x 230..255,
y 47..52** при полосе потолка y 44..52.
**Фикс.** Запечка восстанавливает всё, чья графика попадает в полосу:
ряды −1 И 0, колонки `col−1..col+1`.
**Проверено:** та же попиксельная сверка после фикса даёт **0 различий** в
полосе; пользователь подтвердил в игре.
---
## BUG-RJUMP-1. Разбег-прыжок не берёт провал в три тайла — **ЗАКРЫТ 2026-08-04**
**Симптом (пользователь, приёмка уровня 2).** Комната 1: Кид с разбегу
обязан перелететь колодец с (0,5) на (0,1) — у нас он долетает до колодца и
валится вертикально вниз. Комната 9: то же с (1,5) на (1,1). Обобщение
пользователя оказалось точным: **провал ровно в три пустых тайла наш Кид не
перепрыгивал никогда**, а провалы поменьше брал.
**Почему «никогда», а не «иногда».** Суммарный `dx` последовательности
`seq_4_run_jump` (кадры 34..44) — 62 пикселя при ширине тайла 14. Это
ровно 4 колонки и меньше половины тайла запаса. То есть перелёт трёх
пустых тайлов возможен ТОЛЬКО если оттолкнуться почти точно от кромки; из
случайной фазы бегового цикла он не получается никогда.
**Корень.** Оригинал именно поэтому и не даёт прыгать откуда попало:
`run_jump` (seg005:0AA8) перед стартом **выравнивает Кида по кромке**.
```c
short xpos = char_dx_forward(4);
short col = get_tile_div_mod_m7(xpos);
for (short tiles_forward = 0; tiles_forward < 2; ++tiles_forward) {
col += dir_front[Char.direction + 1];
get_tile(Char.room, col, Char.curr_row);
if (curr_tile2 == tiles_2_spike || !tile_is_floor(curr_tile2)) {
pos_adjustment = distance_to_edge(xpos) + TILE_SIZEX * tiles_forward - TILE_SIZEX;
if ((word)pos_adjustment < (word)-8 || pos_adjustment >= 2) {
if (pos_adjustment < 128) return; // ПРЫЖКА НЕТ
pos_adjustment = -3;
}
Char.x = char_dx_forward(pos_adjustment + 4);
break;
}
}
control_up = release_arrows();
seqtbl_offset_char(seq_4_run_jump);
```
**У нас этой половины не было** — стояла заглушка с честным комментарием
«оригинал выравнивает Kid по краю пола (нужны tile-запросы) — это полировка
K3; K2b просто запускает run-jump». Полировкой это не оказалось: без
выравнивания три тайла непроходимы в принципе.
**Две тонкости, которые легко потерять при порте.**
1. **Беззнаковое сравнение.** `(word)pos_adjustment < (word)-8 || pos_adjustment >= 2`
означает ровно «`pos_adjustment` НЕ попал в `[-8,-1]`». Ветка
`pos_adjustment = -3` недостижима: `distance_to_edge ∈ [0,13]`,
`tiles_forward ∈ {0,1}`, значит `pos_adjustment ∈ [-14,13]`, а туда нужно
`>= 128`. В порте она записана как мёртвая, с объяснением.
2. **Отказ НЕ гасит `control_up`.** `return` выходит из `run_jump` до
`release_arrows()`, поэтому Кид бежит дальше с зажатой «вверх» и пробует
снова на следующем кадре. Именно так игрок и ловит фазу — просто
удерживая клавишу. Если погасить, прыжок у кромки станет одноразовым и
почти всегда неудачным.
**Фикс.** Тайловая половина — `pop_run_jump_align()` в `pop_map` (там живут
`get_tile`/`distance_to_edge`), диспетчерская — в `pop_ctrl.run_jump`.
Разделение то же, что у `pop_jump_up_seq`: pop_map правит `Kid.x` напрямую,
и `pop_savekid_state` эту правку намеренно не затирает.
**Проверка — на харнессе, а не в MAME.** Сценарий
`phys_running_jump_over_3tile_gap` в [`tests-host/t_phys.c`](tests-host/t_phys.c)
(комната с провалом в колонках 2–4). Было: прыжок со старта в кадре 34 при
`x=165`, кадр 44 приходится на колонку 3 — провал, падение. Стало: Кид
пробегает лишние 5 кадров, выравниватель ловит фазу, прыжок стартует при
`x=149`, кадр 44 даёт `x=87, col=1, row=1` — приземление на пол и бег
дальше. Остальные 8 сценариев набора не изменились ни на байт: правка
трогает только ветку разбег-прыжка у кромки.
---
## BUG-FALL-SWORD-1. Отход с мечом в провал: не та последовательность падения — **ЗАКРЫТ 2026-08-04**
**Симптом (приёмка уровня 2, комната 4).** Кид с вынутым мечом отступает
от стража к дыре от упавших loose-плит. Наблюдение пользователя: он
«проваливается раньше времени», летит **с клинком в руке**, и падает **по
другим X**, чем в оригинале — «почти на целый тайл левее».
**Разбор.** Сверка `seg006:1044 start_fall` показала, что наш порт
пропустил ТРИ вещи из оригинала, и все три бьют именно по этому сценарию:
```c
void start_fall() {
Char.sword = sword_0_sheathed; // (1) меч В НОЖНЫ
inc_curr_row(); start_chompers();
...
} else if (frame >= 81 && frame < 86) { // (2) срыв при приземлении
seq_id = seq_19_fall; // после прыжка вверх
Char.x = char_dx_forward(5);
load_fram_det_col();
} else if (frame >= 150 && frame < 180) { // (3) кадры С МЕЧОМ
droppedout = 1;
if (Char.direction < dir_0_right && distance_to_edge_weight() <= 7)
Char.x = char_dx_forward(-5);
seq_id = seq_81_kid_pushed_off_ledge;
}
```
У нас все они падали в общий `else` → `seq_7` (stepfall). Разница между
`seq_7` и `seq_81` в `seqtbl.c` и объясняет ВЕСЬ симптом:
```
seq_7 stepfall : dx(1) dy(3) | 102 | dx(2) dy(6) | dx(-1) dy(9) | dy(12) | dx(-2) set_fall(1,15)
seq_81 fightfall : dy(-1)| 102 | dx(-2) dy(6)| dx(-2) dy(9) | dx(-1) dy(12) | dx(-3) set_fall(0,15)
```
`set_fall(1, 15)` против `set_fall(0, 15)` — **горизонтальный дрейф**: в
`seq_7` во время всего свободного падения `Char.x` уходит на 1 в сторону
КАЖДЫЙ кадр. За два этажа падения это и есть тот самый «почти тайл».
Оригинал в бою падает строго вниз.
**Сверка по числам** (лог SDLPoP `DBG shot`, кадры 102..105):
```
SDLPoP: 102 x=155 103 x=157 104 x=159 105 x=160 ← +2 +2 +1 = seq_81
у нас: 102 x=151 ← seq_7
```
Обратный счёт: у SDLPoP в момент решения `x = 150`, дальше
`char_dx_forward(-5)` при `dir=-1` даёт `+5` → 155. У нас решение при
`x = 152` — то есть **по самому правилу срыва расхождения нет**: обе
позиции лежат в колонке 7 (`dx_weight = x + 14`, кадр 157 имеет
`weight_x = 14`; колонка 7 — это `x ∈ [149,163)`). Двухпиксельная разница
— фаза отхода (шаг отступления `dx(-3)+dx(-2)` = 5 пикселей за цикл), а она
зависит от RNG стража и между движками совпасть не обязана. «Раньше
времени» — это не срыв не там, а seq_7 вместо seq_81.
**Что подтвердилось попутно.** Старт уровня 2 у нас **байт в байт** как в
SDLPoP: `frame=15 x=107 y=118 dir=-1 col=3 row=1 room=5`. Таблицы кадров и
`seqtbl` вынуты из оригинального бинарника, так что расходиться могут
только РЕШЕНИЯ движка — искать надо всегда там.
**Связь с [BUG-LAND-SWORD-1](#bug-land-sword-1).** Тот фикс (`land()`
даёт `seq_63` при вынутом мече) остаётся — он есть в оригинале, — но для
Кида он теперь почти недостижим: `start_fall` убирает меч в ножны, и после
приземления кадр 109 разбирает обычный `control_crouched`. То есть
настоящий корень вечного приседа был здесь, а не в `land()`.
**Метод.** Пустышка `pop_dbg_trap()` в резиденте W1 (идея пользователя):
брейкпоинт на банковый код ставить нельзя — 0xC000+ это окно, куда мапятся
все банки, и точка ловит чужие функции. Оставлена в `pop_state.c` как
многоразовый инструмент.
---
## BUG-CTRL-FRAME-1. Геометрия Кида считалась по кадру СТРАЖА — **ЗАКРЫТ 2026-08-04**
Самый неприятный класс: не косметика, а неверные РЕШЕНИЯ движка, причём
плавающие — одна и та же поза Кида давала разный результат в зависимости от
того, в какой фазе анимации находится страж.
**Симптом (приёмка уровня 2, комната 4).** Кид под сплошной плитой (1,6),
справа от него дыра от упавшей loose-плиты. По ↑ он то прыгает вверх
впустую (упираясь головой в плиту), то пытается зацепиться — но **не с той
координаты X**, с которой запрыгивает оригинал. Наблюдение пользователя,
оказавшееся точным: «похоже, дело в страже — в комнатах без стража то же
самое рисуется и работает правильно».
**Замер (брейкпоинт на входе `pop_jump_up_seq`, состояние снято в момент
решения).**
```
_Kid frame=15 (стойка) x=156 y=181 dir=влево curr_col=6 curr_row=2
кадр 15 Кида (kid_data.bin): dx=0 weight_x=3
pop_gframe (кадр СТРАЖА, image 17): dx=-1 weight_x=8
```
Считаем `dx_weight()` обоими кадрами:
| | кадр Кида (правильно) | кадр стража (что было) |
|---|---|---|
| `dx_weight()` | 156+3 = **159** | 156+9 = **165** |
| `m7()` | (159−7−58)/14 = 6 ост. **10** | (165−7−58)/14 = 7 ост. **2** |
| `curr_col` | 6 | **7** ← намерено |
| `distance_to_edge_weight()` | **10** | **2** |
| ветка `jump_up_or_grab` | 10 ≥ 6 → шаг назад + зацеп | 2 < 6 → `jump_up_plain` |
| результат | x 156 → **160**, `seq_24/8` | x без изменений, **`seq_28`** ← намерено (A=28 на выходе) |
Обе строки «намерено» совпали с предсказанием по кадру стража до единицы —
диагноз подтверждён, а не выведен.
**Корень.** `cur_frame` (`pop_kid.c:200`) — ОДИН глобал на всех персонажей
(так и в оригинале), его владелец — тот, кто последним прошёл `load_frame`.
В нашем кадре последним тикает страж (`pop_guard_tick`), поэтому к моменту
управления Кидом там лежит кадр стража. А `kid_cur_dx/dy/flags` читают этот
глобал напрямую, и через него считается ВСЯ геометрия управления:
`dx_weight` → `determine_col` → `distance_to_edge_weight` →
`get_edge_distance` → выбор ветки в `check_jump_up`.
Оригинал страхуется явно и симметрично: `play_kid_frame` (seg000:1209) и
`play_guard_frame` (seg000:1246) сразу после `loadkid`/`loadshad` зовут
**`load_fram_det_col()`** (= `load_frame` + `determine_col`, seg006:0144) —
ДО `control()`. У нас этого вызова не было; в `pop_ctrl_tick` стоял
комментарий «кадр не перезагружаем — play_seq в kid_tick сделает это
следующим шагом», и он был неверен: `control()` читает `cur_frame` РАНЬШЕ,
чем `play_seq` его обновит.
**Фикс.** `pop_load_fram_det_col()` (`pop_kid.c`) — порт связки; зовётся
после `pop_loadkid()` в `pop_ctrl_tick` и после `pop_loadshad_and_opp()` в
`pop_guard_tick`. Вторая половина связки (`determine_col`) выполняется
только на ветке Кида: `determine_col` у нас существует лишь для него —
`pop_map` работает с `Kid` напрямую, а не с абстрактным `Char`.
**Что это объясняет задним числом.** Плавающее поведение прыжка — кадр
стража меняется каждый тик, вместе с ним `dx`/`weight_x`, и `distance`
Кида скакал через порог 6. А «голова Кида поверх плиты (1,6)», с которой
начался разбор, — не баг отрисовки: Кид просто оказывался в позе, которой
в оригинале в этом месте не бывает.
**Урок.** Любой глобал, который в оригинале «принадлежит активному
`Char`», у нас обязан перезагружаться на КАЖДОМ входе в окно `Char` — иначе
между персонажами течёт состояние, и баг проявляется только когда в комнате
есть второй персонаж. Здесь такой глобал уже ловили однажды: см. запись
про кэш кадра отрисовки в `load_frame` (`pop_kid.c:342`).
---
## BUG-LAND-SWORD-1. Кид навсегда застревает в приседе после падения с мечом — **ЗАКРЫТ 2026-08-04**
**Симптом (приёмка уровня 2).** Страж ударил Кида, Кид потерял HP,
провалился через loose-плиту на этаж ниже — и **сел в присед, из которого
не выходит**: клавиши не действуют вообще.
**Что показало ЖИВОЕ состояние в MAME** (мост `mame-z80`, чтение по
адресам из `roomtest.noi` — окно застало багу в момент):
```
_Kid frame=109 x=144 y=181 dir=-1 col=6 row=2 action=1 room=4
sword=2 (ВЫНУТ) alive=-1 curr_seq=0x2037
_holding_sword=1 _can_guard_see_kid=0 control_x/y/shift = 0
```
`curr_seq = 0x2037` — это внутри метки `softland_crouch`
(`SEQTBL_BASE + 1736 = 0x2036`). Смотрим `seqtbl.c:916`:
```c
LABEL(softland) // seq_17_soft_land
act(actions_5_bumped), SEQ_KNOCK_DOWN, dx(1), frame_107_fall_land_1,
dx(2), frame_108_fall_land_2,
act(actions_1_run_jump), LABEL(softland_crouch) frame_109_crouch,
jmp(softland_crouch), // ВЕЧНЫЙ ЦИКЛ на кадре 109
```
То есть `seq_17_soft_land` **не заканчивается сам** — он крутится на кадре
109, и вывести из него может ТОЛЬКО `control_crouched()`.
**Корень.** `control()` (seg005:252) до `control_crouched` при вынутом
мече не доходит — раньше срабатывает ветка
```c
} else if (Char.sword == sword_2_drawn) {
control_with_sword();
```
а `control_with_sword` (seg005:964) в мирной обстановке
(`can_guard_see_kid < 2`, под ногами не loose) умеет ровно одно: если кадр
== 171 (стойка с мечом) — убрать меч. Кадр 109 он не знает. **Замкнутый
круг: последовательность ждёт control_crouched, а диспетчер туда не
пускает.**
Оригинал такой позы просто не допускает — `land()` (seg005:176):
```c
if (Char.charid >= charid_2_guard || Char.sword == sword_2_drawn) {
Char.sword = sword_2_drawn;
seq_id = seq_63_guard_active_after_fall; // боевая стойка
} else {
seq_id = seq_17_soft_land; // присед
}
```
**У нас этой ветки не было** — `pop_map.c land()` ставил
`SEQ_17_SOFT_LAND` безусловно. На уровне 1 не всплывало, потому что там
меч подбирается поздно и падать с ним особо негде; на уровне 2 меч у Кида
**с первого кадра** (`have_sword = level >= 2`), а в комнате 4 страж стоит
в одном ряду с двумя loose-плитами — сценарий собирается сам.
**Фикс.** Порт недостающей ветки: при `Kid.sword == SWORD_2_DRAWN`
падение на один этаж даёт `seq_63` (боевая стойка), а не присед. Ветка
`charid >= charid_2_guard` нам не нужна — наш `land()` работает только с
Кидом.
**Почему не задело падение на ДВА этажа** (`seq_20_medium_land`): там
`jmp` в конце нет — 29 кадров приседа и автоматический подъём
(`seqtbl.c:934`), поэтому оно развязывается само. Вечный цикл только у
`softland`.
**Урок на будущее.** Симптом «персонаж не реагирует на управление» стоит
диагностировать не по кадру, а по `curr_seq`: адрес прямо показывает, в
какой метке `seqtbl` он завис, и дальше видно, кто обязан был его оттуда
вывести.
---
## Проверено в MAME 2026-08-01 (ревизия L1-TRIAGE)
Три бага стояли как **Critical** с 2026-07-21 и по исходникам выглядели
закрытыми, но переподтверждены не были. Прогон в MAME (roomtest, `ROOMNAV`,
чтение `_Kid` через мост) закрыл все три.
### BUG-1. Боковой переход через ворота: Kid проваливается на row 1 — **ЗАКРЫТ (не воспроизводится)**
**Был симптом:** при проходе через ОТКРЫТЫЕ ворота в соседнюю комнату
(через шов) Kid оказывался на ряду row 1 вместо row 0.
**Проверка 2026-08-01, точный сценарий бага.** Комната 6, Kid на ряду 0;
осторожный шаг на кнопку (0,2) — решётка room8 (0,9) поднимается; удержание
← через открытый шов:
| момент | `room` | `x` | `y` | `curr_col` | `curr_row` |
|--------|--------|-----|-----|-----------|-----------|
| на кнопке в room6 | 6 | 97 | 55 | 2 | 0 |
| после перехода | **8** | 181 | **55** | 8 | **0** |
Ряд и Y сохранены, Kid стоит на полу ряда 0 (скриншот `triage_06.png`).
Провала нет.
**Заодно снят и сам диагноз записи** — он был неверен. В записи стояло:
«Y/`curr_row` при боковом переходе НЕ репроецируются, в отличие от
`check_leave_below`». Это не дефект, а **точное поведение оригинала**:
`goto_other_room` (`SDLPoP/src/seg002.c:390`) для направлений left/right
меняет ТОЛЬКО `Char.x` (±140), а `Char.y`/`curr_row` трогает исключительно
для up/down. Наш `check_leave` (`pop_map.c:1402`) делает ровно то же.
Реальной причиной симптома была, судя по всему, кромочная коллизия — её
закрыл `char_x_forward_edge` (см. BUG-SEAM-PINGPONG ниже).
### BUG-2. Возврат из комнаты назад: Kid отбрасывается обратно (ping-pong) — **ЗАКРЫТ (не воспроизводится)**
**Был симптом:** после перехода в соседнюю комнату попытка сразу вернуться
приводила к тому, что Kid снова закидывался в ту же комнату — выйти нельзя.
**Проверка 2026-08-01.** Шов room2↔room3 (ряд 1, без ворот — чистый
горизонтальный переход), с намеренным разворотом СРАЗУ после пересечения:
| действие | `room` | `x` | `curr_col` |
|----------|--------|-----|-----------|
| старт в room2 | 2 | 86 | 1 |
| держим → | **3** | 122 | 3 |
| сразу держим ← | **2** | 195 | 9 |
| сразу держим → | **3** | 85 | 1 |
| сразу держим ← | **2** | 183 | 8 |
Комната меняется РОВНО один раз на пересечение, туда и обратно, без
осцилляции. Механизм на месте: `pop_leave_timer` (порт `exit_room_timer`,
`pop_map.c:112,1553`) + `char_x_forward_edge` (`pop_map.c:365`).
### BUG-3. Climb-up на тайл-кнопку: неправильная окклюзия — **ЗАКРЫТ фиксом от 2026-07-28**
**Был симптом:** при подтягивании на тайл, верх которого — кнопка
(opener/closer), Kid рисовался ПОВЕРХ кнопки вместо того, чтобы быть
перекрытым её передней гранью.
**Почему закрыт.** Запись требовала: «трактовать нажатую кнопку как
floor-тайл в climb-overlay, учесть подстановку из `get_tile_to_draw`».
Ровно это и сделано `tile_code_drawn()` — `climb_overlay_tile`
(`pop_bg.c:1346`) берёт ПОДСТАВЛЕННЫЙ код тайла, а не сырой. То есть
BUG-3 — дубль пункта «спуск с кнопки (room8, кромка (0,6))» из раздела
«Исправлено», заведённый до фикса.
**Оговорка, чтобы не выдавать желаемое:** покадрово в MAME снимался СПУСК
(кадры 148..138). Подъём идёт через ту же ветку и ту же таблицу
`FLOOR_LEFT_OVERLAY[fidx]`, поэтому отдельного дефекта тут быть не может,
но визуально направление «вверх» не переснималось. Если при сквозном
прохождении (L1-PASS) увидишь Kid поверх кнопки на подъёме — заводи заново.
---
## Косметика окклюзии — закрыта кодом, список отставал (сверено 2026-08-01)
Четыре записи от 2026-07-22 висели как открытые Medium. Каждая описывала
недостающий кусок порта; каждый из них с тех пор написан, но записи никто не
снял. Ниже — что именно закрывает каждую.
### BUG-CEIL-1. Прыжок вверх: руки Kid рисуются ПОВЕРХ потолка — **ЗАКРЫТ**
**Был симптом:** при прыжке вверх (SEQ up, кадры 67..79) руки/голова Kid
заходили в полосу кладки у потолка (row −1) и рисовались ПОВЕРХ неё.
**Чем закрыт:** `ceil_over_kid_tile()` (`pop_bg.c:1275`) — порт «нижняя грань
потолка и кадр плиты-потолка идут в FOREtable», т.е. поверх персонажа.
Зовётся из `pop_fore_over_kid` (`pop_bg.c:1575`) и из fore-прохода стража
(`:1607`). Комментарий на месте прямо называет причину: «иначе руки
прыгающего Kid лезут на кромку потолка».
### BUG-CEIL-2. Тряска/разбитие loose-плиты в потолке (row −1) — **ЗАКРЫТ**
**Был симптом:** loose-плита в ряду 2 верхнего соседа (room5 (2,5) → потолок
room6 над (0,5)) при прыжках Kid на (0,5) не тряслась и не разбивалась —
loose-состояние соседней комнаты не тянулось.
**Чем закрыт:** отдельное состояние плиты-потолка `pop_ceil_modif[10]`
(`pop_bg.c:547`, ведёт `pop_map`) + пара `pop_ceil_shake_draw()` /
`pop_ceil_bake_empty()` (`pop_bg.c:815,827`): дрожание рисуется на текущей
странице поверх фона, а провал «запекается» в ОЗУ-копию, чтобы heal его
сохранял. В полосе у потолка виден только НИЗ плиты (`draw_tile_aboveroom`),
всё выше режется клипом `POP_YOFF` — как в оригинале.
Из этого следует, что и запись «требует персистентного per-room modifier
соседей, это Фаза P0 gates_spikes_plan» больше не верна: понадобился не общий
механизм, а один массив на 10 байт под конкретный случай.
### BUG-CEIL-3. Анимация ворот стирает потолок над ними — **ЗАКРЫТ**
**Был симптом:** при анимации решётки шва полоса кладки у потолка НАД
воротами пропадала — чёрный `bar` по col0 стирал ряд −1, а redraw его не
восстанавливал.
**Чем закрыт:** `pop_room_redraw_seam_left()` (`pop_bg.c:793`) больше не
трогает полосу потолка — `bar` идёт с `POP_YOFF + 3`, а не с `POP_YOFF`:
низ полосы кладки на room-space y=2, бары ворот начинаются с y=3
(`gate_top_y = dby − 62`). Приятный побочный эффект: отпала необходимость
перерисовывать `draw_tile(-1,0)` на КАЖДОМ кадре анимации решётки — то есть
фикс не только косметический, но и вдвое дешевле прежнего.
### BUG-OCCL-1. Тень дальней колонны перекрывает Kid — **ЗАКРЫТ**
**Был симптом:** Kid у (0,4)-(0,5) частично перекрыт тёмной штриховкой —
боковой гранью ДАЛЬНЕЙ колонны, которая окклюдить персонажа не должна
(over-occlusion fore-слоя, рисовавшего fore футпринт-тайлов без учёта
глубины).
**Чем закрыт:** разделение слоёв по признаку из оригинала, а не по нашим
соображениям о глубине (`overlay_mid_tile`, `pop_bg.c:1367`). Ключ,
подтверждённый трассой оригинала: `draw_tile_right`, `draw_tile_anim_right` и
`draw_loose` кладут спрайты через `add_backtable` НАПРЯМУЮ, минуя
`ptr_add_table` — значит правая грань левого соседа («шахматка» столба 93,
blueline, грани пик/loose соседа, кадр loose) **всегда** рисуется ПОД
персонажем. Через `ptr_add_table` (→ midtable при `draw_other_overlay`) идут
только «floor B» 42 при левом соседе-поле, `draw_tile_base`,
`draw_tile_anim` (свои пики) и `draw_tile_bottom`.
---
## BUG-DOOR-CLIP. Подъём по лестнице двери уровня: нет обрезки по правому косяку — **ИСПРАВЛЕН 2026-08-01**
**Был симптом (найден пользователем сразу после L1-EXIT):** при подъёме по
лестнице за дверью уровня (кадры 224..228) силуэт Кида вылезал ПРАВЕЕ правого
косяка проёма. По высоте обрезка была корректна.
**Причина — недопортированная половина `clip_char`** (seg006:1231). Для
кадров двери оригинал ставит ДВА клипа, у нас был только первый:
```c
if (frame >= frame_224_exit_stairs_8 && frame < 229) {
obj_clip_top = leveldoor_ybottom + 1;
obj_clip_right = leveldoor_right;
}
```
Отдельная ловушка: комментарий в SDLPoP говорит «frames 217..228», а КОД
проверяет `>= frame_224_exit_stairs_8`, то есть **224..228**. Портировано по
коду.
**Почему именно обрезка, а не fore-слой:** створка и косяк уходят в оригинале
ЦЕЛИКОМ в backtable (`draw_leveldoor`, все `add_backtable`), то есть рисуются
ПОД персонажем и перекрыть его не могут. Единственный способ спрятать
поднимающегося — срезать сам спрайт.
**Фикс:**
- libbgi: `gfx_blit_cols_part_w(..., uint8_t maxw)` — обрезка СПРАВА у
колоночного блита. Для column-major это ровно уменьшение числа колонок,
то есть внутри ядра механизм уже был (так же клипается край экрана:
`w = _bgi_maxx + 1 - x`), наружу не было выведено. Тело блита переехало
туда, `gfx_blit_cols_part` стал тонкой обёрткой (maxw=0) — тем же приёмом,
каким `gfx_blit_cols` уже обёрнут вокруг `gfx_blit_cols_part`. Работает и
при flip: первые `maxw` нарисованных колонок всегда ложатся в ЛЕВУЮ часть
футпринта.
- PoP: `pop_leveldoor_right` / `pop_leveldoor_ybottom` (порт одноимённых
глобалов) пишет `draw_leveldoor` в `pop_state` — их читает `clip_char` из
другого банка; `pop_clip_char_right()` отдаёт границу, `kid_draw`
превращает её в `maxw` и уводит эти кадры с noclip-пути на общий.
`kid_lw` (прямоугольник heal) тоже сужается — стираем ровно нарисованное.
**Проверено в MAME:** `pop_leveldoor_right = 176` — ровно
`(draw_xh<<3) + 48` для двери комнаты 9; `pop_leveldoor_ybottom` = 112 у
закрытой створки и 69 у поднятой (сходится с формулой оригинала).
Отрисовка подтверждена пользователем на живом подъёме.
---
## BUG-SEAM-PINGPONG (#4). Пинг-понг drawn_room у шва с закрытыми воротами — **РЕШЁН 2026-07-22**
Настоящий корень найден потиковой трассой ЖИВОГО SDLPoP 1.23
(lldb-брейкпоинты на leave_room/bumped/safe_step с логом Char +
char_x_left/right; fixes выключены = vanilla). Прежние гипотезы оставлены
ниже для истории — они НЕ были причиной.
### КОРЕНЬ (подтверждён трассой + исходником)
`set_char_collision` (seg006:0723): `char_x_right = obj_x/2 + 58`, где
`load_frame_to_obj` (seg008:1728) считает `obj_x = 2*char_dx_forward(dx) - 116`
и **добавляет +1** для кадров «чётного пикселя»:
`if ((sbyte)(cur_frame.flags ^ obj_direction) >= 0) ++obj_x;`
(бит 0x80 флагов кадра XOR направление; вправо: +1 если бит НЕ стоит).
Деление `obj_x/2` — C-усечение К НУЛЮ, поэтому при `e = x+dx <= 57`
(obj_x < 0, зона левого шва) поправка +1 даёт `char_x_right = e+1`, а при
e >= 58 формула сокращается к чистому `e`.
Итог: Kid, осевший после отскока от ворот шва на x=57 (frame15, флаги 0x43 —
бит 0x80 не стоит), имеет **char_x_right = 58** и порога leave-left (<=57)
НЕ достигает. Наш движок считал передний край как `Kid.x + dx` без поправки
→ 57 → ложный leave → пинг-понг.
Эталонный цикл SDLPoP (нормализовано по трассе): стойка x=61 → тап вправо →
safe_step(d=0) → step, на первом dx(1) x=62 → bump (edge-триггер) → align 61
→ seq47 dx(-4) → **x=57** (скрыт за кромкой) → кадры 50/51/52 (cxr 61/60/58,
у всех бит 0x80 снят, e>57 — без сдвига) → стойка cxr=58 → leave НЕ
срабатывает; тап → safe_step d=3 → x=60 (1/3 видно); тап → step1 → x=61
(2/3 видно); тап → bump → 57 … по кругу. Char.room и drawn_room НЕ меняются.
### Фикс (pop_map.c)
`char_x_forward_edge()`: `e = char_dx_forward(dx); if (((flags ^ (dir<0 ?
0x80 : 0)) & 0x80) == 0 && e <= 57) e++;` — используется в `char_front_coll`
(коллизия/bump/edge_distance) и в `check_leave` (порог ухода). Плюс порт
doortop-гарда leave-right из leave_room (тайл (9,row) = doortop → правого
выхода нет). `pop_leave_timer` (exit_room_timer) оставлен — он реален в
seg002/seg003.
### Симптом (как выглядел)
Kid стоит за решёткой закрытых ворот шва (левый сосед room8 виден в кромке
room6). При удержании/нажатии ВПРАВО экран пинг-понгует между двумя
состояниями:
- **A**: показывается room6, Kid у левой кромки за решёткой (спрайт на 2/3);
- **B**: показывается room8, Kid у его правой кромки.
Эталон SDLPoP: drawn_room **всегда остаётся room6**, Kid осциллирует у
кромки (1/3→2/3→отступил→по кругу), в room8 экран НЕ переключается.
### Инструментальный диагноз (watchpoint на pop_leave_dir)
В момент лишнего свитча A→B: `pop_leave_dir=1 (LEFT)`, Kid **frame=15 (СТОЯ,
не transient!), Kid.x=57** (до репроекции +140). То есть:
- `char_x_right = char_dx_forward(kid_cur_dx()) = Kid.x + frame15.dx = 57+0 = 57`.
- Порог leave-left (взгляд вправо): `char_x_right <= 57` → срабатывает РОВНО на 57.
- Грань ворот (где их держит коллизия) = **61** (`wall_dist_from_left[1]=10 +
coll_tile_left_xpos=51`). Между 57 и 61 — **зазор 4px**: Kid НЕ удержан
воротами (d=61−57=4≥0 → check_bumped не бампит), но уже на пороге ухода.
- Kid оседает на 57 из-за recoil отскока: seq_47 = `act(bumped), dx(-4),
frame_50, 51, 52`; SEQ_DX(−4) двигает Char.x на −4 суммарно; frame_50.dx=4
компенсирует ТОЛЬКО точку коллизии НА кадре 50, но при возврате в стойку
(frame15, dx=0) `char_x_right = Char.x = aligned−4 = 57`.
### Что было ИСКЛЮЧЕНО (сверено с исходниками SDLPoP, НЕ причина)
- Формула char_x: `char_x_right = obj_x/2+58 = Char.x+frame.dx` (seg006
set_char_collision) — совпадает с нашим char_dx_forward.
- Позиция грани ворот: `get_left_wall_xpos = wall_dist_from_left[1](10) +
xpos_in_drawn_room(x_bump[9+5])+7 = 10+(184−140)+7 = 61` — совпадает с нашим
(`x_bump[−1+5]=44`, +7, +10 = 61).
- Порог leave: SDLPoP leave_room looking-right `char_x_right<=57` — совпадает.
- Данные кадров: frame_50 (image=49,dx=4,flags=0x67) и frame_15
(image=14,dx=0,flags=0x43,sword=9) — БАЙТ-В-БАЙТ как в SDLPoP frame_table_kid.
- seq_47 (act bumped, dx(-4), frame 50/51/52) — совпадает.
### Почему точечные фиксы НЕ работали
- Гард в check_leave (подавить leave на закрытых воротах) — это ОТСЕБЯТИНА,
не SDLPoP (в leave_room такого нет); откачено.
- `exit_room_timer=2` (порт seg002 exit_room — РЕАЛЬНЫЙ механизм, оставлен как
pop_leave_timer): блокирует leave 2 кадра после входа в комнату. НЕ спасал:
положение Char.x=57 **устойчивое** (Kid стоит), а не transient.
### Задел, который НЕ понадобился: порт coll_room + отложенный drawn_room
Трасса показала, что в эталоне `Char.room`/`drawn_room` вообще не меняются,
поэтому план S2/S3 для этого бага не потребовался. Остаётся заготовкой под
стражей/двух персонажей в кадре:
1. **Раздельные комнаты.** `Char.room` ≠ `drawn_room`. Уже есть `kid_room`
(S1) + рендер-смещение `pop_kid_set_render_dx(∓140)`.
2. **Коллизия по Char.room через coll_room (seg004, S2).** Портировать
`check_collisions` → `get_row_collision_data` → `get_left_wall_xpos`/
`get_right_wall_xpos` → `curr_row_coll_room[]`/`_flags[]`,
`bump_col_left_of_wall`/`bump_col_right_of_wall` → `check_bumped_look_*` →
`bumped()`. Тайлы — из РЕАЛЬНОЙ комнаты колонки, НЕ из снапшота.
3. **Отрисовка по drawn_room**, персонаж со сдвигом ±140.
4. **Отложенная смена drawn_room (seg002/seg000, S3):** `leave_room` →
`goto_other_room` → `exit_room` (`next_room`) → `check_the_end`.
Реализовано на 2026-07-22: S1 + `pop_leave_timer`.
Память: [[pop_seam_room_model]], [[sdlpop_odd_pixel_char_x]].
---
## Решено НЕ делать
### OPT-1. Хирургический редрой левого шва (ворота соседа) — 2026-07-22
**Возможность:** `pop_room_redraw_seam_left()` (pop_bg.c) на каждое изменение
openness рисует `bar(BLACK)` по всему col0 + **полный `draw_tile(0,0)`**
(стены, `topright`, `wall_pattern` с `prandom()` — десятки блитов). Реально
анимируются только бары решётки — `draw_gate_back` (~9 `env_b`).
**Стоимость:** seam-блок (синий io_border) занимает ~30-50% кадрового периода,
**но только пока openness меняется** — во время открытия и медленного
авто-закрытия (~5 сек после схода с кнопки). В покое — 0%. Замерено в MAME:
брейк на `_pop_room_redraw_seam_left` (0x5A54) срабатывает ⟺ сегмент дорогой.
**Приём** (heal НЕ годится: печёные бары устаревшие, поэтому и стоит
`bar(BLACK)`+redraw): `bar(BLACK)` только по полосе баров (x=0,
gate_top..gate_bot) + `draw_gate_back(lmod,...)` + дорисовать статику тайла
(0,0), задетую полосой. **Пиксель-чувствительно** — обязательна выверка в
MAME по кромкам.
**Решение:** оставляем как есть — стоимость транзиентная, в бюджет
помещаемся. Делать, только если упрёмся в кадровый бюджет на сценах с
воротами.
---
## Исправлено
- **Спуск с кнопки (room8, кромка (0,6)): Кид просвечивал в щель, ближняя рука
срезана до одного пикселя** — ИСПРАВЛЕНО 2026-07-28. Две причины:
(а) не был портирован `clip_char()` (seg006:1749) — верхняя обрезка спрайта по
`y_clip[curr_row+1]`, когда тайл над головой стена/пол; сделано
(`pop_clip_char_top` в pop_map.c + `gfx_blit_cols_part` в libbgi, heal чистит
уже обрезанный прямоугольник);
(б) `climb_overlay_tile` выбирал ветку `draw_floor_overlay` (seg008:1E3A) по
СЫРОМУ коду тайла — а нажатая кнопка в `get_tile_to_draw` (seg008:240)
подменяется на floor/stuck. Тайл-кнопка не проходил тест floor, уходил в
`draw_other_overlay` и закрашивал Kid ЦЕЛЫМ тайлом вместо узкой кромки
`floor_left_overlay[frame-137]`. Фикс — `tile_code_drawn()` (одна подстановка
на все слои). Проверено покадрово в MAME (кадры 148..138). **Этим же
фиксом закрыт BUG-3** (см. выше).
- **Шов ворот жёг 50% кадра в покое (закрытая решётка)** — ИСПРАВЛЕНО
2026-07-22. Причина — **баг кодогенератора SDCC z80** (memory
`sdcc_z80_cmp_store_a_bug`): `if (m[9] != seam_sig) seam_sig = m[9];`
компилировался в `sub (seam_sig)` (A ← разность) + `ld (seam_sig),a` —
сохранял РАЗНОСТЬ `m[9]-seam_sig`, не `m[9]`. `seam_sig` осциллировала
(напр. 25↔231), `m[9]!=seam_sig` истинно каждый кадр → `draw_tile(0,0)`
каждый кадр даже у неподвижной решётки. Фикс: store-до-сравнения
(`seam_sig = g;` из чистого `g` ДО `sub`), подтверждён в .asm. Прочёс всех
модулей PoP: других случайных compare-then-store нет.
- **Пики: 2×полный `draw_tile` на кадр анимации** → хирургический редрой,
2026-07-22. `pop_spike_redraw`: `heal_off` уже возвращает всю печёную
статику (база 127, пол, грани); поверх анимируются только два острия
(`SPIKES_FRAM_LEFT` в своей ячейке + `SPIKES_FRAM_RIGHT` в соседней).
Заменили 2×`draw_tile` на 2×`env_b` — пиксель-в-пиксель тот же результат,
в разы дешевле (шахта из нескольких пик больше не съедает полный кадр).
- **Отрисовка нажатой кнопки (0,2)/(0,3)** — ИСПРАВЛЕНО. Причина: `fore_tile`
(pop_bg.c, fore-слой поверх Kid) рисовал переднюю грань КНОПКИ (bottom_id
149) поверх уже нарисованной грани пола (43) — «остаток нажатой кнопки».
Фикс: `fore_tile` применяет ту же подстановку нажатой кнопки, что и
`draw_tile` (opener→floor / closer→stuck при таймере связи >1). Плюс
`pop_button_redraw` — wipe своей ячейки + правой грани (дальний угол в
0,3), низ строго yb+64 (не залезать в стену ряда 1).
---
## НЕ БАГИ (кривая картинка, но совпадает с оригиналом — НЕ чинить)
- **Кадр падения с мечом: голова/руки поверх кромки пола — САМ РЕНДЕР верен.**
Сверено с SDLPoP v1.24 (2026-08-04): там кадр падения выглядит один в один.
⚠ Речь ТОЛЬКО о виде кадра. Расхождение по X, которое было отмечено здесь
как открытое, разобрано и закрыто: [BUG-FALL-SWORD-1](#bug-fall-sword-1) —
дело не в моменте срыва (он верен), а в том, что мы играли `seq_7` вместо
`seq_81` и получали горизонтальный дрейф `set_fall(1,15)` за всё падение.
Механика точки веса, чтобы не разбирать заново. У стоек с мечом она
ОГРОМНАЯ:
```
кадр 158/170/171 (stand_with_sword): dx=0 weight_x=13
кадр 157 (walk_with_sword): dx=0 weight_x=14
кадр 15 (обычная стойка): dx=0 weight_x=3
```
`dx_weight()` = `char_dx_forward(dx − weight_x)`, а при взгляде ВЛЕВО знак
меняется — точка веса уезжает на 13 px ВПРАВО от `Char.x`. Замер нашего
кадра падения: `x=151`, взгляд влево → `dx_weight = 164` → `m7(164)` = колонка
**7**, а (1,7) — дыра. `check_on_floor` честно видит «под ногами не пол».
С обычной стойкой (weight_x=3) вышло бы 154 → колонка 6 → пол.
То есть с мечом персонаж «стоит» на 10 px правее, чем выглядит, и кромку
переступает раньше. Данные кадров у нас совпадают с `frame_table_kid`
(seg006:127) байт в байт, `swordfight`/`back_with_sword` портированы дословно
(включая `control_backward = CONTROL_IGNORE` — один нажим = один шаг).
Чинить нечего.
**Важное отличие от остальных записей этого раздела.** Там (труп стража,
падающая плита) картинка кривая, и совпадает с оригиналом лишь потому, что
оригинал сам так рисует — артефакт порядка midtable. **Здесь картинка
ПРАВИЛЬНАЯ**: Кид реально уже за кромкой, и спрайт перекрывает её ровно
настолько, насколько персонаж туда зашёл (наблюдение пользователя,
2026-08-04). Геометрия и рендер согласованы; «неправильно выглядит» —
только если считать позой то, что видит глаз, а не точку веса.
⚠ **Не путать** с тем, что БЫЛО нашим багом рядом: расширение футпринта
перерисовки под клинок (`redraw_at_char`, seg003:0430) — его не было, и меч
оставлял след/лез поверх столба. Исправлено 2026-08-04.
- **Голова стоящего Кида поверх падающей на него loose-плиты.** Комната 12:
зацеп не удался, Кид остался стоять, сбитая плита падает прямо на него —
голова рисуется ПОВЕРХ плиты. Сверено покадрово с SDLPoP v1.24 (2026-07-29):
там ровно то же самое. Артефакт оригинального движка (порядок midtable), а
не наш баг; «починка» увела бы от эталона. Отличать от соседних случаев,
которые БЫЛИ нашими багами и исправлены: вис/подтягивание на кромке плиты и
падение вместе с плитой — там плита обязана быть поверх Кида.
- **Ноги стоящего Кида поверх головы лежащего трупа стража.** Комната 21:
страж убит, комната покинута и открыта заново (у трупа своя запомненная X),
Кид СТОИТ на одну колонку правее тела — его ноги рисуются поверх головы.
Проверено в SDLPoP (2026-08-03): там ровно то же.
Корень — устройство движка, а не наш порт. `set_objtile_at_char`
(seg006:13F3) приписывает персонажа РОВНО ОДНОМУ тайлу, и ширина спрайта на
выбор тайла не влияет никак; дальше `redraw_needed_tiles` (seg008:1B06)
обходит тайлы рядами 2,1,0 и колонками 0..9, и кто позже — тот поверх.
Стоящий Кид укладывается примерно в колонку, поэтому у него это незаметно, а
лежащий труп занимает две-три колонки, но «принадлежит» левой. Всё, что
стоит правее, перекрывает выступающую часть тела. Механизма «широкий объект
участвует в нескольких тайлах» в оригинале нет — сверено с
`draw_objtable_items_at_tile`, `sort_curr_objs` и веткой
`tile_object_redraw == 0xFF` (последняя про оверлеи пола, не про персонажей).
Отличать от того, что БЫЛО нашими багами в этом же месте и исправлено
(BUG-DRAWORDER-1): колонка трупа считалась из тайла вместо X, ветка
`actions_1_run_jump` не была портирована, окно fore-клипа затиралось стражем.
Если когда-нибудь захочется «починить» и это — минимальный вариант — брать
тайл по ЦЕНТРУ габарита, а не по левой границе; но это осознанный отход от
эталона, и в других позах порядок поменяется в обратную сторону.
- **Комнаты 13, 18, 24 недостижимы в обычной игре** — свойство ДАННЫХ уровня 1,
не наш баг. Обход графа от стартовой комнаты (по `res2001.bin`, links @1952)
показывает: у всех трёх ссылки наружу есть, а на них не ссылается никто
(24: `L→9`, но у 9 `R=0`; 13 и 18 связаны только друг с другом). Признак
«комнату выкинули из компоновки, связи не почистили» — несимметричные ссылки
ровно у этих трёх, у остальных 21 симметрия полная:
```
13 L→22, у 22 R=16 | 18 L→15, у 15 R=12 | 24 L→9, у 9 R=0
13 R→16, у 16 L=22 | 18 R→12, у 12 L=15
| 18 D→19, у 19 U=12
```
Следствие: в 13/18/24 возможен «мусор в шве» — наш рендер кромки читает
крайнюю колонку соседа ПО ССЫЛКЕ, а сосед этих комнат соседом себя не
считает. Приоритет багов в этих трёх комнатах — низкий (в игре не видно).
---
## Проверено в MAME 2026-08-03 (прогон уровня 1: 11 наблюдений → 6 корней)
Сырой список наблюдений с обхода всех комнат разобран по корням.
Проверка — MAME + мост `mame-z80`: чтение `_Kid` по адресу из
`.sprinter-cc-roomtest/roomtest.noi`, `ROOMNAV` для навигации, потиковые
трассы, скриншоты.
**Оговорка о полноте проверки.** Каждый корень закрыт тем, что его СОБСТВЕННЫЙ
сценарий больше не воспроизводится; сквозного прохождения уровня и поиска
регрессий в соседней механике автоматика не делала. Чек-лист для ручной
перепроверки — в [`BUGS_OPEN.md`](BUGS_OPEN.md), раздел «Ручная перепроверка
фиксов». Особое внимание — порту `check_collisions`: он переписал ВСЮ
горизонтальную коллизию.
### BUG-LVLSTATE-1. Уровень был немутабельным — **ЗАКРЫТ**
**Симптомы:** выпитое зелье / поднятый меч / разбитая плита возвращались при
возврате в комнату; иногда кувшина нет, а пузырёк над ним крутится; в
комнате 15 раз в 1–2 с мигал контур кладки. «Первое остаётся, остальное
возвращается».
**Корень.** Уровень лежал в EMM-странице только на чтение, `enter_room`
перезаливал `room_fg[30]` из неё при каждом входе, а персистентность держала
таблица переопределений на **8 записей** (`OVR_MAX` в `roomtest.c`) — девятая
и дальше молча терялись. Второй хвост: `pop_trob.c` брал тип тайла из
СТРАНИЦЫ (`pop_level_tile_raw`), поэтому заводил trob зелья/меча там, где
предмет уже поднят — отсюда пузырёк без кувшина и мигание от `animate_sword`.
**Фикс.** `pop_level_set_tile()` пишет тайл ПРЯМО в страницу уровня (порт
`curr_room_tiles[…] = …`: `do_pickup` seg006:1671, `remove_loose` seg007:0EB8,
`loose_land` seg007:11E8). Таблица `ovr_*` удалена. Эталонная копия
foretable — в той же странице по смещению `0x1000` (страница 16 КБ, данных
2.3 КБ).
**Проверено:** комната 22, зелье (0,6) выпито → выход в 23 → возврат: кувшина
нет, пузырька нет; ROOMNAV ставит Кида уже на (0,6), потому что тайл стал
полом.
### BUG-RESPAWN-1. Респавн не перезагружал уровень — **ЗАКРЫТ**
**Как в оригинале:** цикл `play_level` (seg003:57) на КАЖДОЙ итерации, в том
числе после смерти, зовёт `load_level()` — уровень читается заново.
**Фикс.** `pop_level_reset_tiles()` (восстановление foretable из эталонной
копии) в `pop_start_level` рядом с `pop_trob_reset()`.
**Проверено:** после смерти от стража зелье в комнате 22 снова на месте.
### BUG-DEATH-1. Смерть от меча не доводилась до конца — **ЗАКРЫТ**
**Симптом:** страж убивает Кида, тот «воскресает» на месте и его убивают
снова, по кругу.
**Корень.** `hurt_by_sword` ставил seq_85 и обнулял HP, но `Kid.alive`
оставался −1, а `pop_kid_dead` (по нему главный цикл делает респавн) взводил
только путь пик/падения. В оригинале это первая строка `control_kid`
(seg006:0CD1): `if (Char.alive < 0 && hitp_curr == 0) Char.alive = 0;`.
**Фикс.** Порт этой ветки в начало `pop_ctrl_tick` + `pop_kid_dead = 1`.
**Проверено:** страж в комнате 21 убивает Кида → респавн в стартовой позиции
уровня (комната 1), цикла нет.
### BUG-GATE-ANIM-1. Ворота в отрисованной комнате не перерисовывались — **ЗАКРЫТ**
**Корень.** `pop_process_trobs` продвигал модификатор ворот, но пометки
перерисовки не ставил; ворота рисовались только при полной отрисовке комнаты
и в `pop_room_redraw_seam_left`. В оригинале `animate_door` (seg007:0522)
заканчивается `draw_trob()` (seg007:01E6).
**Фикс.** Вид перерисовки `POP_RD_GATE` + `pop_gate_redraw(row,col)` в
`pop_bg.c` (wipe зоны решётки в ячейке ПРАВОГО соседа + `draw_tile`), пометка
из `pop_process_trobs` (текущая страница каждый кадр, обе — на последнем).
**На уровне 1 это ровно ОДНА решётка:** room5 (0,5). Все остальные стоят в
колонке 9, их бары рисуются уже в соседней комнате — там работает
`pop_room_redraw_seam_left`.
**Проверено:** кнопка room5 (0,4) — решётка (0,5) поднимается на экране.
### BUG-COLL-1. Bump искал стену только в колонке переднего края — **ЗАКРЫТ**
**Симптомы:** пробегание сквозь закрытую решётку (комната 12, room5 (0,9));
влёт внутрь стены на длинном прыжке (комната 6).
**Два корня.**
1. `check_bumped` брал ОДНУ колонку — ту, в которой оказался передний край.
Для решётки в col9 окно этой колонки всего 4 px (201..204), беговой кадр
его перескакивает — бампа нет, а `check_leave` тут же уводит в соседнюю
комнату. Оригинал (`check_collisions`, seg004:0004) считает флаги
перекрытия для ВСЕХ колонок ряда и берёт колонку с переходом флага 0→1.
2. Наш guard `action == FREEFALL || MIDAIR → return` в `check_bumped`. В
оригинале таких гардов НЕТ: `bumped_fall` (seg004:04E4) специально
разбирает `actions_4_in_freefall`. Отсюда влёт в стену в прыжке.
**Фикс.** Полный порт `check_collisions` + `get_row_collision_data` +
`is_obstacle` + `bumped(delta, push_dir)`; колонки считаются от −2 до 11,
чтобы решётки СОСЕДНЕЙ комнаты (швы) бампили как свои; гарды в `check_bumped`
приведены к оригинальным (только вис и подтягивание).
**Проверено:** комната 5, бег вправо в закрытую решётку (0,9) — Кид упирается
(x встаёт на 60 в системе комнаты 1 и дальше не растёт).
**Остаток:** экран при этом перелистывается на соседнюю комнату, и Кид в шве
не рисуется — отдельный баг BUG-SEAM-DRAW-1 (модель straddle S3), см.
`BUGS_OPEN.md`.
### BUG-STANDUP-1. Вставание из приседа у стены роняло сквозь пол — **ЗАКРЫТ**
**Симптом:** комната 5, падение с кнопки (0,6) на щебень (2,7) с уроном,
присед — и при вставании провал в комнату 6.
**Корень — лишний guard `if (Kid.action == ACT_BUMPED) return;` в
`bumped_floor`** (в оригинале, seg004:0520, там `if (Char.alive)`). Цепочка:
вставание двигает Кида на 1 px в стену → бамп → `bumped_floor` прижимает `y`
к полу, отменяя `dy(−2)` из начала `medland` → наш guard возвращает
управление вместо `seq_47`, `medland` доигрывает свои `dy(+1)`,`dy(+1)` уже
ОТ пола → Кид НИЖЕ пола → следующий бамп читает беззнаковую разность как
«высоко над полом» → `bumped_fall` → выпадение вниз. У оригинала `seq_47`
обрывает `medland`, лишних `dy` нет.
Найдено потиковой трассой + временной диагностикой (колонка бампа = 8, тайл
после разрешения = 14 щебень, ветка = `bumped_floor`).
**Заодно** убран полу-порт опционального `FIX_STAND_ON_THIN_AIR`: у нас была
взята только его первая часть (кадры вставания 110..119 требуют пол), без
парной правки seqtbl (`dx(1)→dx(0)`, `dx(−4)→dx(−3)`), которую применить
нельзя — seqtbl извлечён из данных оригинала. Вернулись к ванильному
`seg006:909` (только кадр 109).
---
# Вторая волна прогона 2026-08-03 (вечер)
## BUG-KBD-5. Зажатый Shift снимается автоповтором стрелки — **ЗАКРЫТ 2026-08-05**
**Симптом (пользователь).** Прыжок с места с зацепом (уровень 2, комната 9)
не выходит: Кид прыгает, но за кромку не цепляется. Дальше уточнения,
которые и указали на клавиатуру, а не на физику:
- «отпускаю стрелки в полёте и жму Shift один — зацеп есть»;
- «жму Shift не сразу, а когда бо́льшая часть прыжка позади — зацеп есть»;
- «зажимаю Shift, потом стрелки — прыжок есть, зацепа нет»;
- в SDLPoP та же комбинация в том же порядке работает всегда.
Обобщение: **Shift работал в одиночку и не работал вместе со стрелками.**
Ровно то же ломало и осторожный шаг — игрок держит Shift, тапает стрелку, а
Кид на каком-то тапе уходит в бег (это же поведение раньше описывалось как
BUG-KBD-4, см. ниже).
**Замер (MAME, `roomtest`, чтение карты `_kbdraw_down` + breakpoint на выходе
из `in a,($18)` в декодере трамплина).**
1. Зажать LShift → байт 2 карты = `04` (LSh взведён), поток `12 12 12 …`
(Shift автоповторяется сам, пока он последняя нажатая клавиша).
2. Добавить ↑ → байт 46 = `20` (↑ взведена), **байт 2 = `00` — Shift снят,
хотя физически зажат.**
3. Добавить ещё → байт 46 = `30` (обе стрелки видны, 3-key rollover в
порядке), Shift по-прежнему `00`.
4. КОРОТКИЙ тап ↑ при зажатом Shift — Shift выживает. Это и сбивало с
толку: бит сносился, но тут же восстанавливался, потому что после
отпускания стрелки Shift снова становился «последней клавишей» и его
автоповтор `12` взводил бит обратно за ~30 мс.
5. Сам поток при зажатом Shift и зажатой ↑:
```
… E0 E0 E0 75 E0 75 E0 75 ← ни одного F0/12
```
**Причина.** Декодеры (`_irq_tramp.c`, `kbd_raw_poll.c`) делали из «fake
shift» ДВА вывода, и второй был неверен:
- обёртка `E0 F0 12` / `E0 12` есть → Shift зажат → взвести бит ✔ верно;
- расширенный make **без** обёртки → Shift отпущен → снять биты обоих
шифтов ✘ **неверно**.
Обратный вывод опирался на «клавиатура обёртывает КАЖДЫЙ расширенный код».
Замер это опровергает: клавиатура MAME-Sprinter (`pc_kbd ms_naturl`) обёртку
не шлёт вовсе, и уж точно её не бывает на typematic-повторах — а повторы идут
непрерывно, пока стрелка зажата. Значит каждый повтор снимал реально зажатый
Shift, и к кадрам 102…106 (окно `check_grab`) движок видел Shift отпущенным.
Отсюда и «работает, если нажать Shift позже»: бит успевал постоять несколько
кадров до ближайшего повтора стрелки.
**Фикс.** Обратный вывод убран целиком — расширенная клавиша идёт обычным
путём и о состоянии Shift не судит. Прямой вывод оставлен (обёртка, если
клавиатура её всё-таки шлёт, подтверждает «Shift зажат» и стоит дёшево).
Состояние Shift теперь ведут его собственные make/break `12` / `F0 12` —
они приходят всегда. Заодно ушла ставшая ненужной переменная
`_kbdraw_fakesh`, и оба декодера стали короче (трамплину это на пользу: его
клавиатурный блок упирается в диапазон `jr`).
Файлы: `libc/irq/_irq_tramp.c`, `libc/kbd/kbd_raw_poll.c`, `libc/kbd/_kbdraw.h`,
`libc/kbd/_kbdraw_state.c`, `libc/kbd/kbd_raw_sync.c`.
**Чем платим.** Потерянный при Rx-overrun break Shift снять теперь нечем —
модификатор может залипнуть до перенажатия (это старый BUG-KBD-3). Размен
осознанный и решён в ту же сторону, что и раньше в `kbd_raw_sync`: **лучше
залипание, чем отвал** — залипший Shift игрок снимает нажатием Shift, а
сорванный посреди игры Shift в PoP стоит жизни. Вероятность overrun'а сильно
снижена дренажом FIFO опросом из главного цикла (`kbd_raw_poll`, KBD-1).
**Проверено в MAME после фикса** (карта `_kbdraw_down` при `roomtest`):
| действие | LSh (байт 2) | стрелки (байт 46) |
|---|---|---|
| зажать LShift | `04` | `00` |
| + зажать ↑ и → | `04` | `30` |
| держать 5 с (автоповтор идёт) | `04` | `30` |
| отпустить Shift, стрелки держать | `00` | `30` |
| отпустить стрелки | `00` | `00` |
`make size-check`: 70 программ, роста нет.
**Осталось наблюдением, не багом этой задачи.** В карте изредка остаётся
взведённым бит в PLAIN-половине для кода стрелки (напр. байт 14 бит 4 =
`0x74` «Right без E0»). Это потерянный префикс `E0` — след старого рассинхрона
FIFO. Игру не задевает (движок читает `KBD_RIGHT = EXT|0x74`, то есть
расширенную половину), но если всплывёт — искать здесь.
---
## BUG-KBD-4 (+ BUG-KBD-3). Shift: сначала залипал, потом стал отваливаться — **ЗАКРЫТ (с поправкой, см. BUG-KBD-5)**
> **Поправка 2026-08-05.** Вывод «клавиатура обёртывает КАЖДЫЙ расширенный
> код, пока зажат Shift» оказался неверным, и построенный на нём обратный
> вывод («расширенный make без обёртки ⇒ Shift отпущен») убран — он ломал
> Shift вместе со стрелками. Разбор — [BUG-KBD-5](#bug-kbd-5). Проверка
> «пять тапов стрелки подряд» ниже проходила не потому, что вывод был верен,
> а потому что между тапами бит восстанавливал автоповтор самого Shift.
> Актуальное поведение `kbd_raw_sync` — вариант 1 (модификаторы не сбрасываем).
**Симптом (вторая редакция).** Залипание ушло, но появилось обратное: при
зажатом Shift второй-третий-четвёртый тап стрелки отрабатывал уже не
осторожным шагом, а бегом. Для PoP это ХУЖЕ залипания: игрок рассчитывает
на короткий шаг, а Кид убегает в яму или на пики.
**Почему обе прежние редакции были неправильны.** Это был размен между
двумя способами «починить» потерю байта при Rx-overrun SIO (FIFO 3 байта):
1. исключать модификаторы из сброса — потерянный break Shift снять нечем,
Shift залипает навсегда (BUG-KBD-3);
2. сбрасывать всю карту, как DSS (`KBD_Receiver_Overrun` в `KEYINTER.ASM`
чистит и `KEYCTRL`, и `KEY_FLG`) — залипания нет, но Shift сносится
каждым overrun'ом (BUG-KBD-4).
Причина, по которой вариант 2 бил так часто, замерена: при зажатом Shift
клавиатура обёртывает КАЖДЫЙ расширенный код служебной парой, поэтому тап
стрелки — это не 5 байт, а 10, и overrun почти гарантирован.
**Что нашлось (замер в MAME, watchpoint на порт данных SIO + чтение карты
`_kbdraw_down`).** Эта самая обёртка — «fake shift» — и есть решение, а не
помеха. Клавиатура шлёт `E0 F0 12` перед расширенным make и `E0 12` после
его break, и шлёт ТОЛЬКО пока Shift реально зажат. Проверено обоими
шифтами: правый обёртывается своим кодом `0x59` (bit 1 байта 11 карты), левый
— `0x12` (bit 2 байта 2). Это непрерывное и прямое свидетельство реального
состояния Shift — единственное доступное, потому что опросить состояние у
PS/2 нельзя, а typematic повторяет только ПОСЛЕДНЮЮ нажатую клавишу, то есть
стрелку, а не Shift.
**Фикс.** Оба декодера (`libc/irq/_irq_tramp.c`, `libc/kbd/kbd_raw_poll.c`)
читают обёртку в обе стороны:
- увидели fake shift → реальный Shift ЗАЖАТ → взвести plain-бит (бит кладёт
общий писатель: достаточно обнулить префиксы, и он попадёт в plain-половину
карты как make; в расширенную половину не пишем — это не клавиша);
- расширенный make БЕЗ предшествующей обёртки → Shift ОТПУЩЕН → снять
plain-биты обоих шифтов.
`kbd_raw_sync` вернулся к исключению модификаторов — теперь это безопасно:
состояние Shift подтверждается независимо от того, что съел overrun, а
залипание снимается первым же нажатием стрелки.
**Побочно найдена и исправлена своя ошибка** в новом коде `kbd_raw_poll`:
после `bit 1, a` в `A` лежал `pending`, а не скан-код, — обычные
(нерасширенные) клавиши декодировались бы из мусора. Оба декодера приведены
к одной логике.
**Раскладка трамплина.** Клавиатурный блок перевалил за 127 байт, а `jp`
внутри трамплина запрещён (код копируется в W2 побайтно, абсолютные
само-ссылки сломают копию). Поэтому префиксные обработчики переехали вплотную
к своим `cp`, а посередине тела стоят три ретранслятора (`tr_kbd_hub`,
`tr_hub_notkbd`, `tr_hub_dss`) — до них дотягиваются `jr` и сверху, и снизу.
В `kbd_raw_poll` такого ограничения нет, там три перехода стали `jp`.
**Проверено в MAME:**
- Shift зажат, пять тапов стрелки подряд → `LSh` остаётся `04` во всех пяти,
`ovr = 0`, расширенная половина карты чистая;
- штатное отпускание Shift → `LSh = 00`;
- искусственно залипший Shift (бит записан в карту отладчиком) → снимается
ПЕРВЫМ же тапом стрелки;
- `make size-check`: 70 программ, роста нет.
## BUG-RESPAWN-2. После respawn стражи остаются мёртвыми — **ЗАКРЫТ**
**Вопрос из отчёта:** «после respawn — должны ли оживать стражники?»
**Ответ по SDLPoP: да.** Цикл `play_level` (seg003:57) на КАЖДОЙ итерации —
в том числе после смерти Кида — делает `load_level()`, следом `pos_guards()`
(seg003:83), а затем `Guard.charid = charid_2_guard; Guard.direction =
dir_56_none`. То есть стражи — такая же часть данных уровня, как тайлы, и
перезагрузка файла возвращает их всех.
**Корень у нас.** `gstate_init()` (живая копия таблицы стражей: тайл,
направление, мастерство, поза трупа) вызывался ТОЛЬКО при загрузке уровня.
Рестарт возвращал тайлы (`pop_level_reset_tiles`), но не стражей: в `gstate`
оставался сохранённый `leave_guard`'ом `seq_hi != 0`, и `pop_guard_enter`
поднимал стража трупом с `guardhp_curr = 0`.
**Фикс.** `pop_level_reset_guards()` (тот же `gstate_init`) + `pop_guard_reset()`
в `pop_start_level`, рядом с `pop_level_reset_tiles()`. Туда же уехал
`pop_loose_mob_reset()` — настоящий `mobs_count = 0` из `start_level`.
**Проверено в MAME:** комната 21, страж жив (`alive = -1`, hp 3) → убит читом
`K` (`alive = 0`, hp 0) → Кид убит стражем → респавн → возврат в комнату 21:
страж снова `alive = -1`, hp 3.
## BUG-DRAWORDER-1. Кид рисовался поверх тела стража — **ЗАКРЫТ**
**Симптом.** В оригинале Кид проходит ЗА телом убитого стража; у нас — перед ним.
**Три разных корня, найденные по очереди.** Стоит того, чтобы перечислить: два
первых захода были неполны, и каждый следующий кадр от тестера вскрывал новый
слой.
1. **Порядок задавался ролью, а не тайлом.** Мы безусловно рисовали
`pop_guard_draw()` → `kid_draw()`, то есть Кид был сверху всегда. В
оригинале никто не «поверх» по определению: оба персонажа попадают в midtable
при обработке СВОЕГО тайла (`set_objtile_at_char`, seg006:13F3), а тайлы
обходятся строго — `redraw_needed_tiles` (seg008:1B06) идёт рядами 2,1,0 и
внутри ряда колонками 0..9; кто позже, тот поверх. Совпали тайлы — решает
`sort_curr_objs` (seg008:203C): ниже по `obj_y` = позже.
Фикс: `guard_over_kid()` в `roomtest.c`.
2. **Своя же регрессия: Кид полез поверх передних столбов.** Окно fore-клипа
(`pop_fore_set_clip`) одно на всех, и его ставит каждый, кто рисует
персонажа. Пока страж рисовался строго ДО Кида, к моменту
`pop_fore_over_kid` в окне оказывался Кид и всё сходилось само. Как только
порядок стал переменным, при «страж поверх» окно оставалось СТРАЖЬИМ, и
fore-проход Кида отсекался целиком.
Фикс: `kid_fore_clip_restore()` — окно восстанавливается явно, а не
«по счастливому порядку вызовов».
3. **Колонка трупа была нулевой.** `leave_guard` сохраняет тайл как
`get_tilepos(0, row)`, то есть колонку 0 всегда, а `enter_guard` у нас брал
`curr_col` оттуда и следом перетирал X запомненным значением. В памяти это
было видно прямо: `col=0` при `x=95`. Оригинал (seg002:180) выводит колонку
ИЗ X: `Char.curr_col = get_tile_div_mod_m7(Char.x)`. У живого стража
незаметно (X там сама считается из колонки), у запомненного ТРУПА — ломало
порядок. Колонка нужна не только отрисовке: на неё смотрят коллизия и
`check_can_guard_see_kid`.
4. **Ветка `actions_1_run_jump` оказалась не «упрощаемой».** Сначала я решил,
что её можно не портировать. Кадр в движении показал `ACT=1`: в беге
оригинал берёт тайл не из `curr_row`/`curr_col`, а из нижнего ряда и ЛЕВОЙ
колонки ГАБАРИТА (`char_bottom_row`/`char_col_left` из `set_char_collision`,
seg006:0723). При беге вправо левая колонка меньше `curr_col` примерно на
тайл — потому бегущий Кид и уходит за объекты справа. Отсюда «некоторые
кадры бега рисовали Кида поверх тела». Считается для ОБОИХ персонажей:
`enter_guard` ставит `action = 1` и стражу, односторонний учёт дал бы
перекос в другую сторону. Тонкость: `char_col_left` берётся по НЕ
утоньшённой границе — поправку THIN оригинал применяет только к `*_coll`.
**Проверено в MAME:** после возврата в комнату у трупа `col=5` при `x=135`
(было `col=0`); Кид слева от тела — и стоя, и в кадре бега — рисуется за телом;
передние столбы снова перекрывают Кида.
**Остаток, который НЕ баг.** Ноги стоящего Кида поверх головы трупа, когда он
стоит колонкой правее тела, — врождённое свойство движка (один спрайт = один
тайл при спрайте шире колонки). Сверено в SDLPoP: там так же. Подробности — в
разделе «НЕ БАГИ» выше.
## BUG-LOOSE-2. Осколки только от одной из двух падающих плит — **ЗАКРЫТ**
**Симптом.** Комната 12, соседние падающие плиты (0,1) и (0,2): после
пробежки осколки появляются только на (1,2), на (1,1) — нет. В комнате 7
(плиты 0,5/0,6 → 2,5/2,6) обе дают осколки.
**Корень.** Гипотеза автора отчёта подтвердилась чтением кода: Кид уходит в
комнату 15 раньше, чем долетает плита (0,1). Наш падающий кусок был привязан
к ТЕКУЩЕЙ комнате — `pop_loose_reset()` на смене комнаты звал
`pop_loose_mob_reset()` и гасил всё, что ещё летит, а тайл под куском читался
из карты отрисованной комнаты. Кусок уничтожался, `loose_land` не случался,
щебень не клался. В комнате 7 обе плиты успевают сесть до ухода — потому там
и выглядело правильно.
В оригинале `mobs[]` чистится ТОЛЬКО в `start_level` (seg003:88); `do_mobs`
(seg007:1063) прокручивает все куски независимо от `drawn_room`, `move_loose`
работает по `curmob.room`, а `redraw_at_cur_mob` (seg007:132C) сверяется с
`drawn_room` лишь для ОТРИСОВКИ.
**Фикс.** У куска появилось поле `room` (`curmob.room`). На смене комнаты
зовётся `pop_loose_mob_room_changed()` — сбрасывает только heal-историю, а
полёт продолжается. Тайл читается через `mob_tile_at()` (своя комната —
`tile_code`, чужая — `pop_level_tile`). Приземление в покинутой комнате пишет
щебень прямо в данные уровня: сигнальная пара `pop_loose_landed`/
`pop_debris_at` обслуживает только текущую комнату. Отрисовка и
`check_loose_fall_on_kid` (там оригинал начинает с `Char.room == curmob.room`)
— только для своей комнаты. Настоящий `mobs_count = 0` переехал в
`pop_start_level`.
**Проверено вручную (2026-08-03).** Автоматикой гонку воспроизвести не
удалось: мост MAME шлёт нажатия рывками, и «уйти из комнаты раньше, чем
долетит плита» через него не набирается. Подтверждено живым прогоном.
Это ровно тот случай, ради которого заведены host-тесты: на уровне логики
проверка занимает несколько строк — заспавнить кусок, сменить комнату, тикать
до приземления, проверить щебень в данных уровня. Стоит первым в
`../docs/host_tests_plan.md`.
## BUG-SEAM-DRAW-1. Кид, упершийся в решётку на шве, не рисуется — **ЗАКРЫТ (не воспроизводится)**
**Как было заведено.** Комната 5: Кид добегает до закрытой решётки (0,9),
решётка его держит, но экран переключается на комнату 1, и в ней Кида не
видно — он стоит на `x = 57..60`, левее кромки комнаты (col 0 начинается с 58).
**Проверка 2026-08-03.** Не воспроизводится: Кид упирается и встаёт на
`x = 61`, `curr_col = -1`, `room = 1` — и в комнате 1 РИСУЕТСЯ, за решёткой,
как и должен.
Почему сходится: при `x = 61` персонаж уже внутри системы координат комнаты 1
(`obj_x = 2*61 − 116 = 6`), поэтому никакого straddle-смещения не требуется и
обычная отрисовка справляется. Отрицательная `curr_col` тут не противоречие —
это правило `get_tile_div_mod_m7`: `(61 − 7 − 58) / 14 = −1`.
**Что осталось за скобками.** Само переключение экрана штатное: `leave_room`
(seg002:423) уводит вправо при `char_x_right >= 201`, а левая грань решётки как
раз 201 — оригинал в этой позе тоже перелистнёт.
И остаётся теоретический остаток: при `x <= 60` (`obj_x <= 4`) спрайт уходил бы
за левую кромку и обрезался. В текущей сборке Кид туда не встаёт, поэтому баг
и не воспроизводится. Полное лекарство — довести модель straddle (S3 в
`../docs/room_model_plan.md`): держать `Kid.room` отдельно от `drawn_room` и
рисовать со смещением ±140, как `xpos_in_drawn_room` (seg004:0405). Пока
поводов для этого нет — заводить обратно только по живому наблюдению.
---
# Прогон всех комнат уровней 1 и 2 (пользователь, 2026-08-07) — ЗАКРЫЛ ТРИ РЕВИЗИИ РАЗОМ
Результат прогона: **крупных багов нет**. Тем самым закрыты и переехали сюда
из `BUGS_OPEN.md` три накопившихся хвоста — чек-листы ручной перепроверки
фиксов, таблица обхода 24 комнат уровня 1 и таблица сырых наблюдений прогона
2026-08-03. Всё, что с прогона 2026-08-07 осталось открытым, — три записи
уровня 2 в [`BUGS_OPEN.md`](BUGS_OPEN.md) (BUG-GUARD-COLOR-1,
BUG-GUARD-SPLASH-1, BUG-CHEAT-FIGHT-1); ни одна из них не мешает играть.
Сырые формулировки пользователя лежат рядом: [`bugs_level1.md`](bugs_level1.md)
и [`bugs_level2.md`](bugs_level2.md).
## Ручная перепроверка фиксов (2026-08-03) — ПРОЙДЕНА
Шесть корней прогона 2026-08-03 были закрыты автоматической проверкой в MAME
(мост `mame-z80`: чтение `_Kid`, потиковые трассы, скриншоты) — этого хватает,
чтобы показать, что конкретный сценарий больше не воспроизводится, но НЕ
хватает, чтобы поймать регрессии в соседней механике. Отсюда список сценариев
ровно в тех формулировках, в которых баги были заведены. Прогон 2026-08-07
прошёл все комнаты уровней 1 и 2 и ни одного из этих симптомов не показал.
| # | что проверялось | ожидаемо |
|---|-----------------|----------|
| 1 | комната 22: выпить зелье (0,6), выйти в 16/23 и вернуться | кувшина нет, пузырька над пустым местом нет |
| 2 | то же для комнат 14 (0,5) и 17 (2,3) | так же |
| 3 | комната 15: подобрать меч (2,2), выйти и вернуться | меча нет; кладка на дальней стене НЕ мигает |
| 4 | комната 12: разбить плиты (0,1)/(0,2) и потолок в 16, выйти-вернуться | остаются разбитыми, проём не закрывается |
| 5 | комната 17 из 23: разбить (1,5)/(1,6), выпить зелье (2,3), вернуться | всё остаётся |
| 6 | **после смерти** зайти в те же комнаты | ВСЁ восстановлено (в оригинале смерть = `load_level`) |
| 7 | комната 12: разбег в закрытую решётку (0,9) с полушага | не проходит насквозь; перелистывание экрана штатно (см. BUG-SEAM-DRAW-1) |
| 8 | комната 6: бег справа налево от (0,9), длинный прыжок (0,6)→(0,7) | не влетает внутрь стены |
| 9 | комната 5: с кнопки (0,6) падение на (2,7), присед, вставание | остаётся в комнате 5 (проверено трассой: `fr=111 x=177` → `seq_47` → `fr=15 x=173`) |
| 10 | комната 5: нажать кнопку (0,4) | поднимаются ОБЕ решётки — (0,5) видно на экране, (0,9) проверять из комнаты 1 |
| 11 | страж (комнаты 3, 21) убивает Кида | смерть доигрывается, респавн в стартовой позиции уровня; цикла «убил-воскрес» нет |
| 12 | клавиатура: долгая игра с Shift+стрелка | ↑ и Shift не залипают; осторожный шаг не превращается в бег |
| 17 | **[BUG-KBD-4]** долго играть Shift+стрелка, много тапов подряд | Shift не «отваливается»; если однажды залипнет — снимается первым же нажатием стрелки |
| 18 | **[BUG-RESPAWN-2]** убить стража (комнаты 3/21), умереть, вернуться в ту же комнату | страж снова жив, стоит на исходном тайле, HP полные |
Отдельно смотрели **регрессии от порта `check_collisions`** — он трогает всю
горизонтальную коллизию: бамп в стену на бегу и в прыжке, осторожный шаг у
стены, проход через ОТКРЫТЫЕ ворота, разворот в проёме решётки, переходы через
швы (старый BUG-SEAM-PINGPONG). Не всплыло.
## Обход всех 24 комнат уровня 1 — ЗАКРЫТ прогоном 2026-08-07
Инструмент: `#define ROOMNAV` в `roomtest.c` — `+`/`-` (цифровой блок либо
`=`/`-` основного ряда) переключают комнату по номеру (1..24, с обёрткой),
Kid ставится на первый пол. Номер комнаты — полосками в верхнем борте: слева
десятки, справа единицы (`||` `||||` = 24). Инструмент остаётся в сборке:
он же нужен для приёмки уровня 3.
Таблица заполнялась по ходу отладки и осталась незакрытой на 19 строк из 24;
её закрыл ручной прогон всех комнат уровней 1 и 2 (2026-08-07, крупных багов
нет). Ниже — то, что таблица успела зафиксировать: это не «отчёт по
комнатам», а разбор корней, полезный при похожем симптоме.
| комната | статус | что не так |
|---------|--------|------------|
| 1 | пофикшено | падающая плита (2,6): правая грань видна через пол (2,7) и перекрывает его переднюю грань — `mob_render` брал ряд соседа из `m->row` (счётчик, уже ушедший на ряд вперёд), а не из координаты |
| 5 | пофикшено | прыжок в решётку: Kid оставался стоять на 6 px ВЫШЕ пола и без приземления-приседания — от `bumped()` (seg004) был портирован только хвост (`seq_47`), не хватало `bumped_floor` (прижатие Y к полу + `seq_46_hardbump` на кадрах прыжка 24/25/40..42/102..106) и `bumped_fall` |
| 9 | сделано | дверь уровня (1,3)-(1,4) рисовалась чёрным проёмом: не был портирован `draw_leveldoor` (seg008:1D29) — створка (слайсы 33 + верх 34), лестница за ней (99/144) и анимация подъёма по кнопке (`animate_leveldoor`, seg007:05F1, modif 0→43). Спрайты 33/34/99/144 добавлены в атлас явным набором (render_room.py дверь не рисует) |
| 12 | пофикшено | вис/подтягивание на кромке loose-плиты: плита рисовалась ПОД Кидом. Не хватало двух кусков `draw_tile`: (а) `draw_loose` кладёт нижнюю грань плиты И в foretable (поверх персонажа), (б) `draw_tile_base` подставляет верх плиты из `loose_fram_left`, а в нашем midtable-оверлее стоял голый `base_id` (у loose он 0). Голова Кида поверх падающей на него плиты — см. «НЕ БАГИ» выше |
| 15 | сделано | меч (2,2) не рисовался: тайл 22 в draw_tile_anim не был портирован. Добавлены отрисовка предмета (chtab_1 id 10/11 на draw_main_y−3), подъём по Shift (check_get_item/get_item/do_pickup: присед → seq_91 pickupsword → меч исчезает с пола) и статус `pop_have_sword` |
| 13, 18, 24 | недостижимы в игре | свойство данных уровня, разбор — «НЕ БАГИ» выше |
## Сырые наблюдения (прогон 2026-08-03) → корень
Одиннадцать формулировок с прогона свелись к шести корням; все шесть закрыты
и проверены в MAME — разборы выше по файлу.
| # | наблюдение (кратко) | корень |
|---|---------------------|--------|
| 1 | кувшин выпит, а пузырёк рисуется / кувшин возвращается (14, 22, 17) | BUG-LVLSTATE-1 |
| 2 | меч возвращается в 15 / мигает контур кладки | BUG-LVLSTATE-1 |
| 3 | комната 12: пробегает сквозь закрытую решётку (0,9) | BUG-COLL-1 |
| 4 | после respawn плиты остаются разбитыми, зелья выпитыми | BUG-RESPAWN-1 |
| 5 | плита/зелье возвращаются и БЕЗ respawn (12→16, 17, 22) | BUG-LVLSTATE-1 |
| 6 | комната 6: длинный прыжок (0,6)→(0,7) — влёт в стену, респавн | BUG-COLL-1 |
| 7 | залипает ↑ | BUG-KBD-3 |
| 8 | залипает Shift | BUG-KBD-3 / BUG-KBD-4 (поправка — BUG-KBD-5) |
| 9 | комната 5: кнопка (0,4) не открывает решётку (0,5) | BUG-GATE-ANIM-1 |
| 10 | комната 5: с кнопки (0,6) на (2,7), присед — провал в комнату 6 | BUG-STANDUP-1 |
| 11 | страж убил Кида → Кид воскресает на месте и его убивают снова | BUG-DEATH-1 |
Вторая волна того же прогона (вечер) дала ещё четыре наблюдения — BUG-KBD-4,
BUG-RESPAWN-2, BUG-DRAWORDER-1, BUG-LOOSE-2, — все закрыты, разборы выше.
Тогда же закрыт как невоспроизводящийся BUG-SEAM-DRAW-1 и заведён
BUG-GATE-PASS-1 (он закрыт ниже, 2026-08-09).
---
## BUG-GUARD-SPLASH-1. Нет «брызг» при попадании по стражу — **ЗАКРЫТ 2026-08-07**
**Как было заведено (пользователь, 2026-08-07).** При попадании по стражу
должен рисоваться сплеш («звёздочка»), чтобы игрок видел, что удар дошёл до
цели, не переводя взгляд на полосу HP. У Кида такое есть, у стража — нет.
**Корень.** `draw_hurt_splash` (seg006:16CE) в оригинале зовётся ИЗ ДВУХ
мест: `draw_kid` при `hitp_delta < 0` (seg008:1643) и `draw_guard` при
`guardhp_delta < 0` (seg008:1654). У нас была портирована только первая
половина — `kid_draw_splash()`; для стража вызова не было вовсе, хотя
`guardhp_delta` считался и уже использовался.
**Фикс** (`pop_guard.c`, `pop_gdraw.c`, `roomtest.c`):
- флаг `pop_guard_hurt` — симметрия `pop_kid_hurt`: ставится в
`pop_do_delta_hp`, когда `guardhp_delta < 0`, снимается главным циклом
после отрисовки (стража могло не оказаться в отрисованной комнате —
тогда брызги просто пропадают, как в оригинале);
- отрисовка — ВНУТРИ `pop_guard_draw`, между спрайтом стража и клинком:
`draw_guard` кладёт брызги в objtable сразу после стража и ДО клинка, а
порядок записей задаёт порядок рисования. Заодно это решает fore-слой:
`pop_fore_over_char` в конце той же функции накрывает и брызги;
- спрайт — `chtab_5_guard` `obj_id = 1`, то есть **image 1 нашего атласа
стража** (`res752.png`, звезда 28×26): у `add_midtable` аргумент
`obj_id + 1`, а `get_image` вычитает единицу обратно — тот же off-by-one,
что расписан в шапке `pop_pack_guard.py`. У Кида это image 218;
- смещения — три ветки оригинала: кадр 178 (chomped) брызг не даёт вовсе;
185 и 106..110 (смерть/падение) — `obj_y + 4`; 177 (пики) — сдвиг НАЗАД
на 5; иначе `obj_y − 11` (у Кида 15: `((charid == kid) << 2) + 11`);
- свой прямоугольник heal по страницам (`qx_l/qvalid`) — брызги живут один
кадр, но стирать их обязана та страница, в которую рисовали.
**Побочно исправлено:** `pop_kid_hurt` взводился при ЛЮБОЙ ненулевой дельте
HP, поэтому зелье здоровья (+1) давало Киду и брызги, и красную вспышку.
И `draw_kid` (seg008:1643), и `flash_if_hurt` (seg003:785) смотрят именно
`hitp_delta < 0` — условие приведено к оригиналу.
**Проверено в MAME (2026-08-07)**, мост `mame-z80`, уровень 1, комната 21,
живой страж (HP 3/3):
```
bp 51BA ← адрес снятия pop_guard_hurt в main: срабатывает ТОЛЬКО
в кадре удара, то есть уже после отрисовки брызг
key k ← чит «убить стража» = guardhp_delta < 0
→ state=stop PC=0x51BA, guardhp: curr=0 max=3 delta=0 hurt=1
bp 8BDF (_gfx_set_visible_page) + out ← домотать до флипа страницы
snap ← звезда нарисована поверх стража
```
Следующие два кадра (обе страницы дабл-буфера) — чисто, следов брызг нет.
Приём на будущее: снятие флага сделано **по факту** (`if (pop_guard_hurt)
pop_guard_hurt = 0;`), а не безусловно — это и экономит запись каждый кадр,
и даёт готовую точку останова для отладки в MAME. Условные брейкпоинты
мост не принимает (`bp ADDR cond` вешает плагин), поэтому точка останова,
срабатывающая сама по себе только в нужном кадре, — рабочий обходной путь.
**Цена:** `_CODE` 24 179 → 24 209 (+30 Б резидента), BANK4 (`pop_gdraw`)
2407 → 3369 (+962 Б, банк занят на 20.6 %, свободно 13 015 Б).
**Цвет брызг** зависит от палитры стража (слоты `0x90..0x9F`), поэтому он
изменится вместе с [BUG-GUARD-COLOR-1](BUGS_CLOSED.md#bug-guard-color-1) —
отдельной работы не требует.
---
## BUG-GATE-PASS-1. Проход сквозь закрывшуюся решётку — **ЗАКРЫТ 2026-08-09**
**Приёмка:** смоук-прогон уровня 1 (пользователь, 2026-08-09) — с проблемного
`x` Кид больше сквозь решётку не пробегает, пинг-понг у шва не вернулся.
> **ВОСПРОИЗВЕДЁН И РАЗОБРАН.** Сценарий: уровень 1, комната 8, ряд 0,
> `Kid.x = 170`, лицом ВПРАВО, решётка шва (комната 8, колонка 9) ЗАКРЫТА.
> Разбежаться вправо и НЕ ОТПУСКАТЬ клавишу. Кид проходит сквозь решётку и
> убегает вглубь комнаты 6.
>
> Ниже — новый разбор; всё, что было раньше (гипотеза про x > 205), не
> подтвердилось и оставлено в конце для истории.
### Корень: история флагов коллизии индексируется НЕ ТАК, как в оригинале
Сняты две трассы — наша (брейкпоинт на резидентном `kid_tick`) и оригинала
(SDLPoP, собран с `POP_TRACE=1`, печать в `play_kid_frame` + маркеры в
`bumped` и `exit_room`). Стартовая позиция совпала до пикселя, поэтому
сравнение построчное.
**Оригинал:**
```
LEAVE dir=1 x=61 cxl=184 cxr=201 room=6 смена комнаты — ШТАТНАЯ
BUMPED dx=-2 push=-1 x=63 tile=4 col=9 и СРАЗУ бамп о решётку, откат
KID f=15 x=60 act=0 col=-1 room=6 drawn=6 сел за решёткой, стоит
```
**Мы:**
```
x 170 -> 196 бегом, col 7 -> 8 -> 9, НИ ОДНОГО бампа
x=198 -> room 8 -> 6
при удержании ВПРАВО доезжает до col 1 комнаты 6 и бежит дальше
```
Смена комнаты в этой точке правильна и у нас, и у оригинала: `leave_room`
(seg002:423) вправо блокируют ТОЛЬКО `doortop`-тайлы, решётки в списке нет, а
порог `char_x_right >= 201` совпадает с плоскостью ворот
(`x_bump[9+FIRST_ONSCREEN_COLUMN] + TILE_MIDX + wall_dist_from_left[1]`
= 191 + 10). Оригинал полагается не на запрет ухода, а на бамп СРАЗУ ПОСЛЕ
перехода.
**Почему бамп есть у них и нет у нас.** `check_collisions` (seg004:0004)
хранит флаги так:
```c
row_coll_flags_ptr[tile_col] = curr_flags; // индекс — колонка В РАЗРЕШЁННОЙ комнате
row_coll_room_ptr [tile_col] = curr_room; // и рядом — НОМЕР ЭТОЙ КОМНАТЫ
...
if (curr_row_coll_room[column] >= 0 &&
prev_coll_room[column] == curr_row_coll_room[column]) { /* только тогда сравниваем */ }
```
То есть история привязана к паре **(колонка внутри своей комнаты, комната)**.
Решётка комнаты 8 и до перехода, и после лежит в слоте 9 с `room = 8` —
переход 8->6 историю НЕ рвёт, переход флага 0->1 виден, бамп срабатывает.
У нас массив `coll_prev/coll_curr` индексируется колонкой ОТНОСИТЕЛЬНО
отрисованной комнаты (−2…11). При смене комнаты все индексы уезжают на 10,
сопоставить прошлый кадр с текущим нечем, и `enter_room` вынужден звать
`pop_coll_invalidate()`; тот ставит «прошлые флаги = уже перекрывал» (3), то
есть ПОДАВЛЯЕТ бамп на кадре входа. А к следующему кадру Кид уже за
плоскостью ворот — перехода 0->1 не будет никогда.
### Фикс сделан 2026-08-09 (вариант B — сдвиг истории), ЖДЁТ ПРИЁМКИ В MAME
Рассматривались два пути:
- **A, дословный порт.** 10 слотов + параллельный массив номера комнаты,
индекс по колонке разрешённой комнаты, бамп только при совпадении номеров;
`pop_coll_invalidate()` тогда не нужен вовсе. **Не взят:** оригиналу
хватает десяти слотов только потому, что он перебирает узкое окно вокруг
Кида, а мы намеренно считаем все четырнадцать колонок (FIX_COLL_FLAGS,
чтобы не оставалось протухших ячеек) — при таком переборе колонки −2/−1 и
8/9 сядут в одни слоты 8/9. То есть A тянет за собой ещё и сужение окна.
- **B, сдвиг истории (сделано).** Индексацию оставляем свою, но при
БОКОВОМ переходе историю не выбрасываем, а перенумеровываем на 10 слотов:
`check_leave` двигает `x` ровно на ∓140 = 10 тайлов, координата грани
(`pop_x_bump`) едет на те же 140 вместе с габаритом Кида, значит сами
флаги инвариантны — меняется только номер слота.
Реализация: `pop_coll_shift(int8_t dcol)` в `pop_map.c` сдвигает
`coll_curr/above/below` (не `prev` — его на следующем кадре всё равно
перезапишет `move_coll_to_prev`), освободившиеся слоты = 3 («уже
перекрывал», бампа нет — ровно то, что в оригинале даёт несовпадение номера
комнаты). `enter_room_side` зовёт её сразу после `pop_map_set_edges`:
`side == 0` (ушёл влево) → `+10`, `side == 1` (вправо) → `−10`. Переходы
вверх/вниз и все прочие входы в комнату остаются на полной инвалидации —
там колонки не сдвигаются, но тайлы под ними уже из другой комнаты.
Расхождение с дословным вариантом A записано в `../docs/impl_diff.md` (D-1).
**Осторожно:** это сердце коллизии, вокруг которого разбирался
BUG-SEAM-PINGPONG (см. `BUGS_CLOSED.md`). Приёмка:
1. Комнаты 6 ↔ 8 уровня 1, закрытая решётка, **обе** стороны.
2. Мелким шагом (упереться) И с разбега (не пройти насквозь) — сценарий
выше воспроизводится с `Kid.x = 170`, комната 8, ряд 0, лицом вправо.
3. Пинг-понг у шва не вернулся (экран не перескакивает туда-сюда).
4. `make -C tests-host` — прошли все 5 наборов (2026-08-09).
**Инструмент для сверки готов:** SDLPoP собран с трассой, включается
`POP_TRACE=1` (правки в `seg000.c` play_kid_frame, `seg004.c` bumped,
`seg002.c` exit_room, чтение переменной — `main.c`). Сборка на macOS:
`make CFLAGS="-std=gnu99 -O2 -I/opt/homebrew/include -Wno-implicit-function-declaration" LIBS="-L/opt/homebrew/lib -lSDL2 -lSDL2_image"`
(в системе нет pkg-config, штатный Makefile без этого не собирается).
---
### История: прежний разбор (гипотеза НЕ подтвердилась)
**Статус: наблюдался один раз (2026-08-03), воспроизвести не удалось ни
тогда, ни прогоном всех комнат уровня 1 (2026-08-07).** Заведён, чтобы
наблюдение не потерялось; закрывать нельзя — ни как исправленный, ни как
«не баг», пока нет надёжного сценария. Оговорка: BUG-GATEMOD-1, из-за
которого решётка стартовала не в том состоянии, с тех пор закрыт, и
наблюдение могло быть его следствием.
**Что наблюдалось.** Комната 5: Кид стоял НА тайле решётки (0,9)
и ждал, пока она опустится. После закрытия пошёл вправо — прошёл в комнату 1
и упал на (1,1).
**Что уже измерено и в чём загвоздка.** Сразу после наблюдения повторить не
получилось: в том же месте Кид стоит на `x = 196`, `col = 9`, и решётка его
ДЕРЖИТ — то есть штатно.
Арифметика оригинала объясняет разницу. `is_obstacle` (seg004) ставит
плоскость блокировки в `x_bump[col + FIRST_ONSCREEN_COLUMN] + TILE_MIDX`, для
колонки 9 это **x = 205**. При этом «колонка 9» по `get_tile_div_mod_m7` —
это `x ∈ [191, 205)`. Пока `curr_col == 9`, Кид гарантированно левее
плоскости и обязан блокироваться; чтобы пройти, он должен оказаться **правее
205**, то есть уже на дальней стороне решётки, — и тогда уход вправо законен:
решётка закрылась у него за спиной, в оригинале она блокирует плоскость, а не
весь тайл.
Отсюда рабочая гипотеза: в момент наблюдения Кид стоял правее 205 (успел зайти
по тайлу дальше, пока решётка была поднята), и поведение штатное. Но повторить
эту позу и снять `x` пока не удалось, поэтому гипотеза НЕ подтверждена.
**Что снять в следующий раз** (без этих чисел вопрос не закрыть):
1. `Kid.x` и `Kid.curr_col` в момент, когда решётка уже закрылась, а Кид ещё
стоит на её тайле — до шага вправо;
2. модификатор решётки (openness) комнаты 5, тайл 9 — `can_bump_into_gate()`
считает её препятствием только пока `(modif >> 2) + 6 < char_height`, то
есть пока она опустилась достаточно низко относительно РОСТА кадра;
3. `Kid.x` покадрово на самом шаге вправо — где именно перестал блокировать.
Быстрый способ снять первое: отладочный стоп-кадр (**1** заморозить, **2**
продолжить), затем чтение `_Kid` из отладчика MAME; подгонка позы по пикселю —
читы `[`/`]`.
**Возможный корень, если гипотеза не подтвердится.** Проверка идёт по колонке,
которая на шве уже принадлежит СОСЕДНЕЙ комнате (`curr_row_coll_room[]` в
оригинале); у нас межкомнатная коллизия на шве — исторически проблемное место
(ср. закрытый BUG-SEAM-PINGPONG). Второй кандидат — `char_height` в
`can_bump_into_gate()`: если он берётся не от того кадра, решётка может
перестать считаться препятствием раньше времени.
---
## BUG-TORCH-CHOMP-2. Застывший чомпер накрывается пламенем факела — ЗАКРЫТ 2026-08-10
Симптом (уровень 4, скриншот пользователя): пока чомпер щёлкает, всё в
порядке; как только его анимация кончается, пламя соседнего факела ложится
ПОВЕРХ челюстей.
Регрессия от фикса [BUG-TORCH-CHOMP-1](#bug-torch-chomp-1): там пламя
перевели на ЗАПЕКАНИЕ в фон (`GFX_BANK_NORMAL`), чтобы `heal` соседнего
чомпера не съедал огонь. Аргумент «запекать безопасно, кадры пламени
самонакрываются» верен для СОБСТВЕННЫХ пикселей факела, но не для чужой
графики в той же ячейке: пламя рисуется в клетке ПРАВОГО СОСЕДА
(`x = (col+1)*32 + 8`, seg008:560), и если сосед — чомпер, огонь ложится на
его челюсти. Пока trob чомпера жив, `pop_set_redraw(tp, POP_RD_CHOMP, ...)`
возвращает их поверх каждый кадр; как только анимация кончилась и trob
умер, возвращать стало нечем.
**Как это устроено в оригинале** (и почему у него бага нет).
`animate_torch` (seg007:0241) заканчивается вызовом `set_redraw_anim_right()`
— он метит `redraw_frames_anim` у ПРАВОГО СОСЕДА (seg007:0101). А
`redraw_needed` (seg008:0178) рисует помеченный слой строго в порядке
```
draw_tile_anim_topright();
draw_tile_anim_right(); <- пламя ЛЕВОГО соседа (факела)
draw_tile_anim(); <- СВОЯ графика тайла: челюсти чомпера
```
То есть челюсти возвращаются поверх огня **каждый кадр факела**, независимо
от того, жива ли собственная анимация чомпера. У нас пламя рисуется
напрямую из `pop_process_trobs`, минуя механизм пометок, — этой второй
половины не было.
**Фикс** (`pop_trob.c`): после `pop_torch_draw` метим правого соседа
`pop_set_redraw(tp + 1, POP_RD_CHOMP, 1)`, если там чомпер. Одна страница:
факел анимируется каждый кадр, значит обе страницы дабл-буфера получат свою
перерисовку по очереди. Порядок сходится сам — блок факелов стоит ДО
`pop_redraw_needed`. Код соседа читается в том же префетче кодов тайлов
(`trob_rcode[]`), чтобы не свапать W0 второй раз за кадр. Банк 6: +104 Б.
**Осознанное расхождение** — оригинал метит соседа БЕЗУСЛОВНО, мы только под
чомпера. Безусловная пометка = перерисовка тайла каждый кадр на каждый
факел; слой `draw_tile_anim` рисует ещё пики, зелье и меч (seg008:0644),
поэтому теоретически «застыть под пламенем» могут и они — заведено открытым
в `BUGS_OPEN.md` (TORCH-ANIM-RIGHT). На уровнях 1–4 такого соседства не
встретилось.
---
## T-2. Idle-skip — **ЗАКРЫТ 2026-08-08**
Сделан как шаг 1 задачи [DRAW-COST](TASKS_OPEN.md#draw-cost) (коммит
`a25ce58`), и шире, чем формулировался здесь: пропускается не только Кид, а
ЛЮБОЙ персонаж, у которого с прошлой отрисовки этой страницы дабл-буфера не
изменился ни один вход отрисовки, — включая труп стража и ждущего стража.
Условие «обе страницы уже получили это состояние», которого требовала эта
запись, выполнено само собой: снимок входов хранится ПО СТРАНИЦАМ.
Замер: комната 1.3 с трупом стража, Кид стоит — 210 % -> 116 % кадрового
периода, ноль вызовов `pop_heal_fast` за кадр. Контракт — шапка
`pop_cdraw.h`, разбор и что делать дальше — `TASKS_OPEN.md#draw-cost`.
Связь с T-1 сработала как и предсказано: пока Кида не перерисовываем, heal'а
нет, стирать пики нечем. Но T-1 остаётся открытым — трогать пики
безусловно мы всё ещё продолжаем, когда Кид рядом ДВИЖЕТСЯ.
---
## MIRROR-FG-STALE. Зеркало и снимок комнаты — **НЕ БАГ, закрыт 2026-08-11**
Заводился как краевой случай от [L4-MIRROR](TASKS_CLOSED.md#l4-mirror):
`place_mirror` (`pop_trob.c`) пишет тайл зеркала в ДАННЫЕ УРОВНЯ
(`pop_level_set_tile`), а коллизия работает со СНИМКОМ комнаты `room_fg`,
который `pop_room_load` делает при входе в комнату. Опасение: если тайл
поставлен, пока игрок УЖЕ в комнате 4, снимок останется старым и зеркало
будет невидимо для коллизии — Кид пробежит сквозь него, тень не родится.
**Проверка пользователем (2026-08-11), чит ROOMNAV:** нажал кнопку открытия
двери, телепортировался в комнату 4 — **зеркала не было вовсе**; вышел из
комнаты и вошёл обычным путём — зеркало появилось и **непроходимо, кроме
правильного прыжка**, то есть коллизия видит его корректно.
**Вывод: сценария нет.** Тайл ставится в момент, когда дверь ДОРИСОВАЛА
открытие (`animate_leveldoor`, переход `pop_leveldoor_open` 0/2 → 1, 43
тика анимации) — телепорт успевает раньше, поэтому «постановки при игроке в
комнате» в этом прогоне не случилось вообще. А при обычном входе комната
рисуется целиком из данных уровня и `room_fg` снимается заново — графика и
коллизия согласованы по построению.
**Решение пользователя:** телепорт по комнатам — ОТЛАДОЧНЫЙ режим; в реальном
прохождении дверь выхода стоит не в комнате зеркала, игрок физически не может
быть там в момент постановки, и зеркало с тенью появляются всегда штатно.
Чинить нечего.
**Если симптом всё же всплывёт** (например, появится мод/уровень, где кнопка
и зеркало в одной комнате): лечение — обновлять в `place_mirror` не только
данные уровня, но и живую карту (в `pop_map.c` уже есть внутренние точки
записи `g_fg[tilepos]`, нужна публичная «поставить тайл в текущей комнате»).
---
## SEAM-BUTTON-STALE. Кнопка в шве не меняла вид при нажатии/отжатии — ЗАКРЫТ
> **Симптом (пользователь, 2026-08-11, уровень 5).** Кнопка нижних ворот
> комнаты 24 стоит в шве: в комнате 11 это (1,9), в комнате 24 — (1,−1).
> «Нажали её из комнаты 11, она проанимировалась — зайдя в 24, она ВСЕГДА
> нарисована нажатой, хотя по статусу отжимается. Не нажимали, вошли в 24 и
> наступили — ворота открываются, а визуально кнопка остаётся отжатой.»
**Корень.** Change-driven редрой шва (`seam_sig` в `roomtest.c`) сравнивал
`room_modif` колонки 9 соседа. У кнопки `modif` — это ИНДЕКС LINKLOC,
константа уровня; само нажатие живёт в `doorlinks2`
(`get_doorlink_timer`, seg007:0BB6 — младшие 5 бит). Изменение в сигнатуру
не приходило НИКОГДА, редрой не заказывался, и шов оставался нарисованным
таким, каким был на входе в комнату — отсюда оба симптома сразу.
Видимой разницу делает `draw_tile`: нажатый opener рисуется как ПОЛ
(порт `get_tile_to_draw`, seg008:253 — `pop_room.c:256`), а у пола правая
грань есть, у кнопки её нет.
**Фикс.** `seam_row_sig()` (roomtest.c) подмешивает в сигнатуру ряда бит
«кнопка нажата» для тайлов `0x0F`/`0x06`. Оригинал шов в этом случае не
перерисовывает вовсе (`get_trob_pos_in_drawn_room` возвращает 30 для чужой
комнаты) — осознанное расхождение, записано как D-2 в
[`../docs/impl_diff.md`](../docs/impl_diff.md).
Цена: резидент +200 Б (`_CODE` 24 739 → 24 939), куча W2 1795 → 1595 Б.
**Проверено (пользователь, 2026-08-11):** «кнопка теперь отрисовывается
правильно в обоих случаях» — оба направления — нажать из комнаты 11 и войти в 24;
войти в 24 поверху и наступить на кнопку, стоя в шве. Картинка кнопки
обязана совпадать со статусом ворот.
---
---
## BUG-POTION-STRIPE. Узор паласа пропадал на месте выпитого зелья — ЗАКРЫТ
**Наблюдение (пользователь, 2026-08-11, уровень 5).** «На стенке паласа есть
узор — синяя в цветочек полоса, она запекается в фон и должна быть всегда.
Но если на её фоне Кид (или Тень) выпивает зелье — полоса пропадает.»
Воспроизведено в комнате 14 (два зелья в ряду 2, колонки 4 и 5): после
каждого глотка узор исчезал в ячейке СПРАВА от выпитого тайла и **не
возвращался даже после выхода из комнаты и повторного входа**.
**Корень.** У оригинала `curr_room_tiles` — это сама таблица уровня, поэтому
`curr_room_tiles[curr_tilepos] = tiles_1_floor` в `do_pickup` (seg006:1671)
виден всем в тот же миг. У нас `fg` живёт в ДВУХ местах — буфер отрисовки
(`g_fg`) и EMM-страница уровня, — и страницу правил главный цикл ПОЗЖЕ в
кадре, уже после `pop_process_trobs`.
В этот зазор успевал проснуться trob поднятого зелья: код тайла он читает из
страницы уровня (`pop_level_tile_raw`), там ещё зелье — и `animate_potion`
крутил фазу пузырька поверх только что обнулённого модификатора,
`bubble_next_frame(0)` = **1**. Модификатор оставался единицей навсегда
(`room_modif` персистентен, `room_seen` не даёт переинициализации).
Дальше срабатывало правило самого оригинала: для ПОЛА в паласе `modif == 1`
означает «узор не рисовать» — `if (num == !!level_type) return;`
(seg008:499, у нас `pop_room.c:322`). Отсюда и «не лечится перезаходом».
**Как подтверждено (MAME, чтение памяти, а не рассуждение):** буфер тайлов
комнаты (`0xA342`) после подбора — `01` = пол на обеих позициях, всё верно;
а в модификаторах на позициях 24 и 25 стояли `01 01`.
**Фикс.** `do_pickup` (`pop_map.c`) ставит тайл в странице уровня СРАЗУ
(`pop_level_set_tile`). Тогда trob того же кадра уходит в `default` и
снимается (`type = −1`), модификатор остаётся нулём. Дублирующая запись из
главного цикла убрана. Тем же фиксом лечится МЕЧ: там `animate_sword`
делал `--mod[tp]` из нуля и получал 255.
**Проверено (пользователь, 2026-08-11):** «узор теперь на месте».
**Ложный след, для протокола:** первым подозреваемым был `pop_floor_bake` —
после подбора он чистит `bar` шириной 60 px при 64-пиксельной колонке, и
уцелевший хвостик узора в 4 px выглядел как его подпись. Отвергнуто
перезаходом в комнату: полная перерисовка идёт мимо `bake`, а узор всё
равно не появлялся.
---
## SHADOW-FIGHT-L5. Кид и Тень вставали в боевую стойку в комнате с зельем — ЗАКРЫТ
**Наблюдение (пользователь, 2026-08-11).** Тень выходит и пьёт зелье
штатно, но если Кид успевал подняться в ряд 0, пока она пьёт: (а) Кид
доставал меч, (б) Тень тоже вставала в боевую стойку и **рисовалась
спрайтами стража**.
**Корень.** В `pop_check_can_guard_see_kid` (порт `check_can_guard_see_kid`,
seg003:702) первым множителем стояло `Guard.charid != 0`, тогда как оригинал
пишет:
```c
if ((Guard.charid != charid_1_shadow || current_level == 12) && ...
```
То есть ТЕНЬ — боевой персонаж только на 12-м уровне; на 4/5/6 она Кида «не
видит». Это не косметика: `can_guard_see_kid >= 2` разворачивается в оба
симптома через одну ветку `control_standing` (seg005:352) — Кид достаёт меч
сам, а Тень с `charid 1 < CHARID_2_GUARD` и убранным мечом идёт в
диспетчере `pop_control` ТОЙ ЖЕ веткой, что и Кид; с кадра 150 боевые кадры
лежат в атласе стража.
**Фикс.** Порт условия целиком + возвращён пропущенный множитель
`Guard.direction != dir_56_none` (им оригинал гасит выключенного
персонажа). Константа `SHADOW_FIGHT_LEVEL` в `pop_guard.h`.
**Проверено (пользователь, 2026-08-11):** тень выпивает и уходит, боя нет.
---
## BUG-LATTICE-DOORTOP. Чёрная дыра на месте арки у ворот — ЗАКРЫТ
**Наблюдение (пользователь, 2026-08-11).** Уровень 5, комната 12, тайл
(0,9): чёрный квадрат вместо арки.
**Корень.** `draw_tile_base` (seg008:622) начинается со СПЕЦСЛУЧАЯ, которого
у нас не было:
```c
if (tile_left == tiles_26_lattice_down && curr_tile == tiles_12_doortop) {
id = 6; // Lattice + door A
ybottom += 3;
}
```
У `doortop` своей базы нет (`base_id == 0`), поэтому рядом с аркой пара
«арка + верх двери» рисуется ОДНИМ спрайтом 6, опущенным на 3 px. Без
ветки тайл не рисовал ничего, кроме полоски `bottom_id` 85 (32×4).
**Фикс.** Ветка добавлена в `pop_room.c` (draw_tile) И в
`toolchain/render_room.py` — последнее обязательно: `pop_pack_bg.py`
собирает атлас по обходу `render_room`, поэтому без правки скрипта спрайт 6
просто не попал бы в `pop_env*.atl` (см. memory `pop_atlas_dynamic_ids`).
**Как найдено — методика, годная для любого «не так нарисовано»:**
1. `./prince megahit --screenshot-level` в SDLPoP рисует карту ВСЕГО
уровня (комнаты разложены по `roomxs`/`roomys`, клетка 320×192).
2. Вырезать нужную комнату и совместить со снимком MAME перебором смещения
(у нас совпало на `crop(101, 50, +640, +192)`, масштаб 2×1).
3. Разница по порогу яркости даёт карту расхождений: до фикса — белый
прямоугольник ровно в (0,9) и 1892 пикселя, после — 995 (Кид, факел,
статус-полоса, то есть динамика).
4. Что именно рисует оригинал в тайле — трасса `POP_TRACE_BT=1` (печать в
`add_backtable`, `src/seg008.c`, выводит room/row/col/id/размер). Она
сразу показала `id=6 w=32 h=63` рядом со «своим» `id=85 w=32 h=4`.
---
## BUG-BELOWROW-WALL. Жёлтые треугольники в шахте падения — ЗАКРЫТ
**Наблюдение (пользователь, 2026-08-11).** Уровень 6, комната 3 (шахта под
комнатой 1): в нижнем ряду под пустыми тайлами (2,4)-(2,6) торчат жёлтые
треугольники.
**Корень.** `load_rowbelow` (seg008:368) при ОТСУТСТВУЮЩЕЙ комнате снизу
подставляет РАЗНЫЕ кромки: колонкам 1..9 — `tiles_0_empty`, и только левому
краю (тайл из `room_BL`) — `tiles_20_wall`. У нас `pop_room_load` забивал
стеной все одиннадцать позиций `below_fg`, а стена в роли соседа снизу-слева
даёт `topright` стены — её грань и лезла треугольниками.
**Фикс.** `below_fg[0..9] = 0`, `below_fg[10] = 20`.
**Проверено:** комната 3 совпала с картой SDLPoP — 300 пикселей расхождения,
все они силуэт Кида. Опасение «не пропадёт ли законный треугольник в (1,3)»
снято тем же сравнением: грань стены в колонке 3 и скос у её основания на
месте у обоих. Оговорка пользователя: эталон брался из `--screenshot-level`,
живая сверка в SDLPoP — потом.
---
## BUG-BALCONY-RIGHT. Правая половина портала не рисовалась — ЗАКРЫТ
**Наблюдение (пользователь, 2026-08-11).** Уровень 6, комната 18: «закрытый
портал показывается некорректно» — у арки балкона нет правой половины,
решётка обрывается, справа чёрный провал.
**Корень — не в движке, а в УПАКОВЩИКЕ АТЛАСОВ.** `id 12` стоял в
`FORE_ENV_IDS` (`toolchain/pop_pack_bg.py`), потому что в `tile_table` он
числится `fore_id` ЗЕЛЬЯ (0x0A). Но зелье рисуется из `chtab_1`
(`add_foretable(id_chtab_1_...)`; у нас `pop_potion_flask`), а в `chtab_6`
под номером 12 лежит ПРАВАЯ ЧАСТЬ АРКИ БАЛКОНА 32x62, и она идёт в
backtable. Из-за списка спрайт уезжал в `pal_fore.atl`, а `pal_env0.atl`
получал дырку: `idx 12: w=0 h=0`. Движок берёт правую грань соседа из
env-атласа — и не рисовал ничего.
**Как найдено.** Трасса оригинала `POP_TRACE_BT=1` показала в спорном тайле
`BT room=18 r=1 c=7 ch=6 id=12 w=32 h=62`; дальше — чтение каталога
`pal_env0.atl` напрямую (запись 12 пустая) и `pal_fore.atl` (она там).
**Фикс.** `id 12` убран из `FORE_ENV_IDS`; в env-атласе он занял своё место.
Дифф комнаты с оригиналом 1151 -> 375 (остаток — Кид и пламя).
**Проверено пользователем:** «портал отрисовался корректно».
**Урок:** список `FORE_ENV_IDS` собирается по `tile_table.fore_id`, но
`fore_id` НЕ значит «спрайт из chtab_6» — у зелья, меча и пламени он
указывает в `chtab_1`. Прежде чем вносить номер в этот список, проверять,
из какой таблицы спрайт берётся.
---
## BUG-MOB-MULTIROOM. Плита не пролетала несколько комнат — ЗАКРЫТ
**Наблюдение (пользователь, 2026-08-11).** Уровень 6: плита (1,7) комнаты 6
должна пролететь НЕСКОЛЬКО комнат и упасть на кнопку, открывающую решётку.
«Плита просто падает и ничего не открывается.»
**Корень.** У оригинала кусок живёт в списке mobs и спускается ряд за рядом:
`mob_down_a_row` (seg007:1387) из НИЖНЕГО ряда переводит его в комнату снизу
(`y -= 192`, ряд 0), и так сколько угодно комнат. У нас кусок на границе
комнаты ГАСИЛСЯ, а «переход вниз» подменялся сигналом главному циклу — он
искал посадку в комнате снизу через `pop_room_col_landing`. Для колонки 7
комнаты 7 (сквозная шахта) посадки нет — кусок исчезал.
**Фикс.** Честный `mob_down_a_row` в `mob_tick_one` (`pop_room.c`);
приземление в НЕ отрисованной комнате сигналит комнатой и тайлом, а щебень
и кнопку ставит главный цикл (порт `loose_land`, seg007:11E8).
Путь в этом сценарии: комната 6 (1,7) -> сквозная шахта комнаты 7 -> кнопка
(2,7) комнаты 11, а она по LINKLOC открывает ворота (1,9) комнаты 18 — те
самые у портала. **Проверено пользователем:** ворота открываются.
---
## BUG-MOB-STALE-PAGE. Улетевшая плита оставалась на одной странице — ЗАКРЫТ
**Наблюдение (пользователь, 2026-08-11).** Сразу после предыдущего фикса:
«в одном из кадров дабл-буфера осталась падающая плита».
**Корень.** Чистка прошлого кадра куска стояла под гейтом «кусок в
ОТРИСОВАННОЙ комнате»: `if (here && m->prev_y[pg] != MOB_Y_NONE)`. Как
только кусок уходил вниз через `mob_down_a_row`, гейт закрывался — и след,
оставленный им на этой странице, не стирал уже никто.
**Фикс.** Гейт убран: `prev_y[pg]` — след ИМЕННО НА ЭТОЙ странице, стирать
его надо независимо от того, где кусок сейчас. Плюс `clean = 2` во всех
ветках гашения, чтобы дочищались обе страницы. **Проверено пользователем.**
---
## BUG-CHAR-STALE-PAGE. Тень застывала на двух страницах в РАЗНЫХ позах
**Наблюдение (пользователь, 2026-08-11, редкий — пойман дважды).** Уровень
6, комната с тенью: «Тень застряла в двух разных кадрах в разных позициях».
**Замер в отладчике (состояние было живым).** `Guard` = кадр 15 (stand),
x = 0x51 — то есть персонаж В ПОКОЕ. А слот отрисовки `pop_cd[OPP]`:
```
страница 0: x=25 y=85 w=34 h=38 valid=1 <- ЛЕЖАЩАЯ поза
страница 1: x=41 y=78 w=12 h=41 valid=1 <- стойка
```
**Корень.** В `pop_char_draw` (`pop_cdraw.c`) прямоугольник и `valid[dp]`
пишутся ВНУТРИ `if (w && h)`, а снимок состояния для пропуска перерисовки
(`cd_sig_make`) брался БЕЗУСЛОВНО, в самом конце функции. Если спрайт
кадра нулевой (пустая запись атласа), блок рисования пропускался — `x/y/w/h`
и `valid` оставались от ПРОШЛОГО кадра этой страницы, а сигнатура
обновлялась на текущее состояние. Дальше `cd_quiet` видел `valid = 1` и
совпавшую сигнатуру, то есть считал «на странице нарисовано ровно то, что
надо», и слот залипал навсегда: `heal` не звался, старый спрайт оставался.
**Фикс.** `cd_sig_make` вызывается только когда кадр реально рисовали
(`if (w && h)`). Без снимка слот не будет «тихим», и следующий кадр
начнётся с `heal` — старое сотрётся само.
**ОСТАЁТСЯ ОТКРЫТЫМ (в BUGS_OPEN):** почему спрайт кадра оказался нулевым.
Фикс убирает залипание, но кадр, для которого атлас отдал 0x0, всё равно
не нарисуется. Подозрение — конкуренция за окно W0 между `atlas_image` и
чтением данных уровня; проверять трассировкой маппинга.
---
## BUG-CHEAT-IMM-1. Кид залипал в боевой стойке без меча — ЗАКРЫТ 2026-08-17
**Наблюдение (пользователь, уровень 2, комната 4).** Кида с мечом сталкивают
ударами с ряда 1 на ряд 2; страж падает следом и оказывается у него за
спиной. Кид встаёт в боевую стойку, но: **меча в руке нет** (не
отрисовывается), и **к сопернику он не разворачивается** — на клавиши не
реагирует вовсе. «Иногда Кид успевает развернуться, а вот сейчас не успел».
**Два симптома — один отказ.** Диспетчер `control()` (`pop_ctrl.c`,
порт seg005:252) уходит в `control_with_sword` только при
`Char.sword == SWORD_2_DRAWN`. При `sword == 0` управление проваливается в
обычные ветки, а среди них для кадров стойки с мечом (158/170/171) нет ни
одной — каждый кадр не делается НИЧЕГО. Отсюда и «не разворачивается»:
разворот к сопернику за спиной (`SEQ_60_TURN_WITH_SWORD` при
`char_opp_dist() < -4`) живёт внутри `control_with_sword`.
**Корень — наш чит бессмертия, а не механика боя.** `hurt_by_sword`
(`guards.c`, порт seg002) перехватывался читом в самом начале и
**безусловно**, не глядя на меч, ставил `SEQ_74_HIT_BY_SWORD`:
```c
if (Char.charid == CHARID_0_KID && pop_immortal) {
pop_char_set_seq(SEQ_74_HIT_BY_SWORD); /* ← без проверки sword */
```
А `seq_74` — это анимация «получил удар **В БОЕВОЙ СТОЙКЕ**», её кадры
(150..179) все с мечом. В оригинале в неё попадают ТОЛЬКО из ветки
`sword == 2`; безоружного там убивают (seg002, дословный комментарий:
*«Being hurt when not in fighting pose means death»*, `take_hp(100)`).
Чит подменял эту смерть выживанием и оставлял Кида в кадрах с мечом при
`sword == 0` — состояние, которого в оригинале не существует и для которого
у диспетчера нет ветки.
**Как доказано** (важно для метода — гипотез было три, и две неверные):
1. Кодоген `draw_sword` проверен по `bank5_pop_ctrl.asm`: `ld hl,#_Char+12 /
ld (hl),#0x02` — store меча на месте, версия «SDCC потерял запись»
(ср. [[sdcc_z80_cmp_store_a_bug]]) отпала.
2. Watchpoint на `Kid.sword` в MAME: `0 → 2` при доставании и `2 → 0` в
`start_fall` проходят штатно (`start_fall` меч гасит и в оригинале,
seg006:1102).
3. **Решающее:** с ВЫКЛЮЧЕННЫМ читом (`pop_immortal = 0`) тот же сценарий
даёт корректную смерть — то есть порт совпадает с оригиналом, а баг
живёт только под читом.
Грабли метода, стоившие времени: ловушки прошлой сессии печатали
`b@0x992A` **без** префикса `0x10000`, то есть мимо логического вида Z80 —
`charid/frame/dir` в той трассе были мусором, отсюда ложный вывод «записей
в `sword` нет вовсе». И ловушка на `Char.sword` бесполезна: `Char` —
переключаемое окно, у стража меч вынут, поэтому `2 → 0` там происходит на
каждом переключении окна. Ещё одно: `pop_char_set_seq` пишет
`ld ($992D),hl`, запись 16-битная — байтовое условие `wpdata==0xDC` не
совпало бы никогда.
**Фикс** (решение пользователя: «повторяем поведение оригинала, наш иммортал
работает только во время боя, с мечом»). Спецветка чита удалена целиком —
она оказалась **избыточной**: под бессмертием `pop_take_hp(1)` и так
возвращает 0, и обычный путь сам приводит к `seq_74` с сохранённым мечом.
Осталось запретить читу действовать в безоружной ветке:
```c
if (Char.sword != SWORD_2_DRAWN) {
uint8_t imm = pop_immortal;
pop_immortal = 0; /* чит не действует вне боевой стойки */
pop_take_hp(100);
pop_immortal = imm;
pop_char_set_seq(SEQ_85_STABBED_TO_DEATH);
}
```
Глушится именно вызов, а не `pop_take_hp`: тот общий с физикой (падения,
пики, чомперы), и там бессмертие обязано работать при любом положении меча.
**Подтверждение.** Пользователь, 2026-08-17: «всё сработало как надо»,
проверено обоими концами — «во время боя удары, пропускаемые Кидом, урон не
приносят; если Кид без меча — смерть с одного удара». То есть чит в бою
работает как работал, а вне боевой стойки поведение снова совпадает с
оригиналом.
Взведена регресс-ловушка на сам инвариант: Киду пишется кадр стойки
(158/170/171), а `Char.sword != 2`. Условие смотрит на `Char`, а НЕ на
`Kid`: `ldir` копирует байт кадра (0) раньше байта меча (12), поэтому по
`Kid` ловушка слепа ровно в тот тик, когда меч теряется, и ложно срабатывает
на каждом законном доставании.
---
## LEVELDOOR-PALACE-CLIP. Кид скрывался за кромкой дворцового портала раньше времени — ЗАКРЫТ 2026-08-17
**Наблюдение (пользователь, уровень 4, обход после оптимизации фаз).**
Порталы, через которые Кид входит на уровень и уходит на следующий, в
подземелье и во дворце РАЗНОЙ ширины — дворцовый шире. В анимации ухода с
уровня 4 на 5-й Кид убегает по лестнице, и его контур обрезается не правой
гранью портала, а раньше, «как будто он прячется проходом». Диагноз
пользователя сразу верный: взята ширина подземного портала.
**Корень.** `draw_leveldoor` (`pop_room.c`, порт seg008:1D29) считал правую
кромку проёма без дворцовой поправки:
```c
pop_leveldoor_right = xh * 8 + 48;
```
В оригинале строкой ниже стоит (seg008:1429):
```c
leveldoor_right = (draw_xh<<3)+48;
if (custom->tbl_level_type[current_level]) leveldoor_right += 8;
```
Это значение читает `clip_char` (у нас `pop_map.c:2668`) как правую границу
клипа персонажа — отсюда ранняя обрезка.
Расхождение было **осознанным и отложенным**: в коде стоял комментарий
«+8 у palace-уровней (tbl_level_type) — на уровне 1 не применяется». На
момент написания дворцовых уровней в порту не было, и условие не дописали.
`tbl_level_type[4] = 1` (`pop_level_cold.c:52`), то есть уровень 4 —
дворцовый, и на нём это наконец проявилось.
**Фикс.** `if (pop_palace) pop_leveldoor_right += 8;` Флаг `pop_palace`
выставляет `pop_bg_load(set)` из того же `tbl_level_type`, который читает
оригинал, так что эквивалент дословный и рассинхронизироваться не может.
**Найдено попутно, НЕ починено:**
[LEVELDOOR-STARTROOM-WIPE](BUGS_OPEN.md#leveldoor-startroom-wipe) — в той же
функции оригинал в СТАРТОВОЙ комнате кладёт затирающий прямоугольник вместо
лестницы (и там своя дворцовая/подземная разница 48/39 и сдвиг 2 px), а мы
рисуем марш 144 безусловно. Заведено отдельным багом.
---
## SHADOW-STALE-FRAME. Тень мигала между двумя РАЗНЫМИ позами на страницах дабл-буфера — ЗАКРЫТ 2026-08-18
**Наблюдение (пользователь, 2026-08-18).** Уровень 6, комната 1 (Кид и через
большой провал — тень). «У Тени на двух экранах отрисованы разные позиции —
одна стоящая, одна бегущая; увидел сразу, как вошёл в комнату. Повторяется
нечасто, но очень неприятно». Гипотеза пользователя — «в один из экранов
попала случайная позиция Тени» — оказалась дословно верной.
**Улика (живое состояние, снято через мост MAME до перезапуска).** Слот
соперника `pop_cd[POP_CH_OPP]`:
```
стр.0: x=41 y=78 w=12 h=41 valid=1 <- кадр 15 «стойка»
стр.1: x=25 y=85 w=34 h=38 valid=1 <- ЧТО-ТО ДРУГОЕ
cd_sig[OPP][0] == cd_sig[OPP][1] == { frame=15, x=81, y=118, dir=0, charid=1 }
```
Обе страницы «тихие» (снимок совпал с живым `Guard`), поэтому ни одна больше
не перерисовывается — мигание навсегда. Прямоугольник страницы 1
восстанавливается однозначно: `bx = scr_x(obj_x) − w` и `top = obj_y − h + 1`
при `Guard.x = 81`, `Guard.y = 118`, `dir = 0` дают `dx = 3`, `dy = 4`,
`flags ≥ 0x80` и картинку 34×38. В таблице кадров ровно одна такая запись —
**`frame_tbl_guard[36]`, то есть кадр 185 «страж мёртв»** (`image = 33`,
`dx = 3`, `dy = 4`, `flags = 0xC9`; 34×38 — это размер `image 33` в атласе
КИДА, что сходится: набор спрайтов выбирается по `Guard.frame = 15`, а
`image` приходил из кэша).
**Корень.** Оригинал грузит кадр В САМОЙ ОТРИСОВКЕ: `add_kid_to_objtable` и
`add_guard_to_objtable` (seg008:22F0/2324) первым делом после
`loadkid`/`loadshad` зовут `load_fram_det_col()`. У нас `kid_frame` /
`pop_gframe` были КЭШЕМ, который наполняет тик (`play_seq` → `load_frame`), а
отрисовка брала готовое. Кэш отстаёт от `Char.frame` у всех, кто пишет кадр
мимо `play_seq` — например `do_init_shad` (`guards.c`) кладёт тени
`frame = 15` прямой записью. А `pop_gframe` в этот момент держал кадр 185
убитого стража с прошлого уровня: после смерти соперника `pop_guard_tick`
выходит по `charid == 0` РАНЬШЕ `pop_load_fram_det_col`, и кэш не трогается
вообще, сколько бы комнат Кид ни прошёл.
Само по себе это моргнуло бы один кадр. Смертельным его делает **пропуск
неизменившегося кадра** (DRAW-COST): снимок `cd_sig` пишется по
`Guard.frame`, то есть страница запоминает «нарисован кадр 15», хотя на ней
чужой спрайт. Тень дальше стоит, снимок совпадает — страница не
перерисовывается НИКОГДА.
**Фикс.** `pop_char_draw` зовёт `pop_load_frame()` сразу после
`pop_loadkid`/`pop_loadshad` — там же, где это делает оригинал. После этой
строки `image` однозначно определяется парой `(charid, frame)`, то есть
снимок `cd_sig` снова описывает ровно то, что нарисовано, и расхождение
«пиксели против снимка» становится невозможным независимо от того, кто и как
испортил кэш. Цена: +3 байта в банке 4, кадровый бюджет не трогает
(отрисовка и так пропускается у «тихого» слота).
Заодно приведена к оригиналу ветка «кадра нет в таблице»: `load_frame`
(`pop_kid.c`) ставила `cur_frame.image = 255` и выходила, НЕ обновив кэш
владельца, — то есть вместо «не рисовать» рисовалась прошлая картинка.
Оригинал кладёт туда `blank_frame {255,0,0,0,0}` (`get_frame_internal`,
seg006:507). Выход из функции теперь один на обе ветки: +46 байт резидента.
**Проверка (MAME, уровень 6 комната 1).** Брейкпоинт на трамплине
`___sdcc_bcall_ehl` с условием `hl == _pop_char_draw` и действием
«записать в `pop_gframe` кадр 185 и продолжить» — то есть кэш травится
ровно перед КАЖДОЙ отрисовкой персонажа. Слот при принудительной
перерисовке (`valid[0] = valid[1] = 0`) остаётся `12×41` на обеих
страницах, `pop_gframe` после кадра — снова кадр 15. До фикса та же
подмена дала бы `34×38 @ (25,85)` — ровно числа из улики.
**Что осталось невыясненным.** Не удалось воспроизвести САМ МОМЕНТ порчи:
вход в комнату с заведомо испорченным кэшем (подмена `pop_gframe` в соседней
комнате + возврат читом обхода) самолечится — метка «фон трогали» от
перерисовки комнаты стоит на обеих страницах, и слот честно перерисовывается
следующим кадром. Значит порча случилась в кадре, где фон НЕ трогали, и
конкретный путь остался неизвестен. На вывод это не влияет: фикс закрывает
класс целиком, а не найденный путь. Ловушка на будущее, если симптом
всплывёт снова: `wp <адрес pop_cd[OPP].w>,4,w` — сработает на первой же
записи «чужой» ширины.
---
## BLUELINE-NOBLUE. Кусок синего узора на стене там, где оригинал его не рисует — ЗАКРЫТ 2026-08-18
**Наблюдение (пользователь, 2026-08-18).** Уровень 6, комната 1, тайл (2,3):
у правой кромки левого блока висит небольшой кусочек тёмно-синего узора,
которого в оригинале нет. Диагноз пользователя («первоначальная отрисовка
фона, фикс не должен повлиять на скорость») подтвердился полностью.
**Корень.** Полоску рисует `draw_tile_right` (`pop_room.c`, порт
seg008:0666) — её кладёт ПРАВЫЙ сосед стены:
```c
case tiles_20_wall:
if (tbl_level_type[current_level] && (modifier_left & 0x80) == 0)
add_backtable(chtab_6, 84 /*wall stripe*/, draw_xh + 3, 0, draw_main_y - 27, ...);
```
Проверка `& 0x80` у нас была, а вот значения, которое она проверяет, — нет.
Модификатор стены В ФАЙЛЕ уровня и В РАНТАЙМЕ у оригинала разные:
`load_alter_mod` (seg008:1216) переводит их при загрузке комнаты —
`*curr_tile_modif <<= 7`, то есть единственный бит уровня («no blue»,
авторский флаг «не рисовать полоску на моей правой кромке») переезжает в
СТАРШИЙ. Наш порт `load_alter_mod` (`pop_trob.c`) ветку стен пропускал
НАМЕРЕННО, и в комментарии это было записано: связи кладки мы считаем по
типам соседей (`pop_wall_modifier`), сохранённый модификатор стены «не
читается вовсе».
Ошибка в слове «вовсе»: у модификатора стены ДВА независимых смысла, и
из соседей выводится только один. `noblue` проставлен вручную в данных
уровня и не выводится ниоткуда. В комнате 1 уровня 6 стена (2,2) имеет в
файле модификатор 1, то есть ровно «без полоски»; без перевода отрисовка
видела сырую единицу, `(1 & 0x80) == 0` проходило — и полоска вылезала.
**Фикс.** `case TILE_WALL: *m = (uint8_t)(*m << 7);` в порте
`load_alter_mod`. Перевод одноразовый, при первом входе в комнату
(`room_seen`), — кадровый бюджет не трогает вообще; +24 байта в банке 6,
резидент не изменился.
**Проверка (MAME, уровень 6 комната 1).** Попиксельный диф скриншотов до и
после: изменения ровно в боксе `(344,199)-(358,206)`, 38 пикселей — сам
артефакт и ничего кроме. На подземных уровнях изменений нет по построению:
полоска целиком под `if (PALACE)`.
---
## SPIKE-BAKED. Выдвинутые пики консервировались в фон и оставались навсегда
**Наблюдение (пользователь, 2026-08-18).** Уровень 7, комната 19. Пики стоят
в `(0,6)` и из-за изометрии частью выходят в ячейку `(0,7)`. Выдвигаются
корректно все; **прячутся корректно только те, что в `(0,7)`**, а в `(0,6)`
остаются два вида мусора: куски остриёв на фоне дальней стены и белые точки в
дырах пола. Пользователь сразу указал и на класс («первоначальная отрисовка
фона, фикс не должен повлиять на скорость»), и на причину («в `(0,5)` не пол,
а кнопка») — оба попадания верные.
**Артефакты, снятые в MAME на живом кадре с мусором.**
| что | значение |
|-----|----------|
| `pop_t_bg[6]` (живой modif пики) | `0x00` — пики УБРАНЫ |
| `pop_t_fg[0..9]` | `03 04 13 0F 01 06 02 04 01 03` → `(0,5)` = `0x06` closer-кнопка, `(0,6)` = `0x02` spike |
| мусор на экране | x 198..215, y 63..88 |
Модификатор нулевой, а пиксели пик есть — значит рисует их не живое
состояние. И `pop_spike_redraw` каждый кадр делает `pop_heal_off(192, 62,
64, 40)` — ровно поверх этого прямоугольника. Мусор переживает СВОЙ ЖЕ heal,
а heal восстанавливает ОЗУ-копию фона: пики в ней и лежат.
**Корень.** Кнопка при смене состояния метится `POP_RD_FLOOR`, а это
`pop_floor_bake(0,5)` — ЗАПЕЧКА, то есть рисование в банке, который пишет и в
ОЗУ-копию. Запечка перерисовывает свой тайл **и правого соседа**:
```c
pop_t_bake_rest = 1;
draw_tile(row, col);
if (col + 1 < 10) draw_tile(row, col + 1); /* <- это пики (0,6) */
```
`pop_t_bake_rest` для того и заведён — «в фон кладём состояние ПОКОЯ», — но
читал его ТОЛЬКО `pop_loose_frame`. Кадр пик считался прямо по месту,
`(m & 0x80) ? 5 : m`, в ЧЕТЫРЁХ слоях, и ни один флага не спрашивал. Так
выдвинутая пика попадала в ОЗУ-копию, и дальше heal возвращал её вечно.
Всё сходится численно. Окно клипа запечки — `x 160..219` (ширина 60 от
кнопки), спрайт пики — `x 192..222`, `y 62..88`. Пересечение `x 192..219` —
это и есть наблюдаемые 198..215; а `(0,7)` начинается с 224, за окном
запечки, — потому там и чисто. Правая часть пики живёт только в видео-ОЗУ и
честно стирается.
**Фикс (`pop_tile.c`).** Порт `get_spike_frame` (SDLPoP seg008:08A0), которого
у нас не было, — плюс наша платформенная оговорка про запечку:
```c
uint8_t pop_spike_frame(uint8_t m)
{
if (pop_t_bake_rest) return 0;
return (uint8_t)((m & 0x80) ? 5 : m);
}
```
Все четыре слоя (`draw_tile_anim`, `draw_tile_anim_right` соседа, fore-проход,
mid-оверлей) переведены на него. Заодно `pop_chomp_pose` тоже стал отдавать
позу покоя в запечке: у чомпера ровно та же схема, а рядом с кнопкой он
встретится так же легко.
Одна точка вместо четырёх лечит ВСЕ пять путей запечки сразу, включая
`pop_room_draw` — вход в комнату с выдвинутыми пиками тоже консервировал бы их.
**Цена.** Резидент +28 Б, банки без изменений; в кадре — один `call` на слой,
и только на тайлах-пиках. Скорость не затронута, как и предполагал
пользователь.
**Проверка.** Пользователь прошёл сценарий вручную на пересобранном билде —
мусора нет.
**Урок (записан в `pop_tile.h` над `pop_t_bake_rest`).** Флаг «сейчас
запечка» обязана спрашивать САМА функция «кадр по модификатору», а не
вызывающий слой: слоёв отрисовки у тайла четыре, и забыть один слишком легко.
Для новой анимированной ловушки это теперь обязательный пункт.
---
## DIED-ON-BUTTON. Смерть на кнопке не ломала её: решётка закрывалась обратно
**Наблюдение (пользователь, 2026-08-18).** Уровень 7 начинается падением, и
если не зацепиться за кромку, Кид разбивается на `(2,1)` комнаты 3 — а там
кнопка открытия решётки. В оригинале решётка после этого открыта НАСОВСЕМ,
у нас отжималась.
Баг был заведён ещё при закрытии
[BUG-LOOSE-BUTTON-1](#bug-loose-button-1) как «`died_on_button` (seg007:776)
не портирован», но оказался ДВОЙНЫМ — до недостающей функции ещё и не было
пути.
### Половина 1: физика трупа выключена целиком
```c
void pop_phys_tick(void) __banked
{
if (pop_kid_dead) return; /* <- вся цепочка, включая check_press */
```
У оригинала `play_kid_frame` (seg000:1231) гейт ровно один — `Char.room != 0`;
весь список крутится и на трупе (по «жив/мёртв» разведены `check_spiked`/
`check_chomped_kid`, и то через `resurrect_time`). Ранний выход выглядел
безобидной экономией — «труп не шевелится», — но `check_press` на мёртвом
персонаже делает СОВСЕМ ДРУГОЕ, чем на живом, и именно это выключалось.
Отсюда наблюдаемое: кнопка получала РОВНО ОДНО нажатие — в кадре самой
смерти, потому что `pop_kid_dead` ставит `land()` уже ВНУТРИ цепочки, после
раннего возврата. Решётка приоткрывалась и закрывалась.
Снято в MAME до фикса: Кид `frame 185, action 5 (bumped), alive 0`, тайл
`(2,1)` = `0x0F` opener с модификатором `0x12`, openness решётки ползёт
вниз 146 → 112. Кадр смерти при этом проходит все проверки `check_press`
штатно (`action == ACT_BUMPED`, флаги кадра 185 = `0x49`, бит
`FRAME_NEEDS_FLOOR` есть) — то есть путь был закрыт ровно одним `return`.
«Труп не шевелится» держится и без него, тем же, чем в оригинале: кадр
смерти не двигается сам, а уход из комнаты и сотрясение отсечены ВНУТРИ
`kid_phys` (там свой `if (pop_kid_dead) return;` перед `pop_check_knock`).
### Половина 2: сам `died_on_button`
```c
if (Char.alive < 0) pop_trigger_button(...); /* жив — нажатие на 5 кадров */
else died_on_button(...); /* мёртв — кнопка СЛОМАНА */
```
Открывалка превращается в обычный пол с нулевым модификатором, а связь
дёргается типом «щебень» (`TILE_DEBRIS`) — у `trigger_gate` это отдельная
ветка «открыть насовсем». Любая другая кнопка становится заклиненной
(`TILE_STUCK` = 5). Тайл пишем и в страницу уровня (переживает выход из
комнаты), и в живую `g_fg`, с пометкой на перерисовку своей ячейки и правой.
Инфраструктура была готова давно: `TILE_DEBRIS` и ветка «насовсем» в
`trigger_gate` написаны ещё тогда, с комментарием-ссылкой именно на
`died_on_button` — из-за чего механизм и казался реализованным. Опасение
старой записи, что нужен «тайл заклиненной кнопки в атласе, а его там нет»,
не подтвердилось: `pop_tile_table[5]` (stuck floor) заполнен и уже
рисуется — на него подставляется НАЖАТАЯ кнопка-закрывалка.
### Половина 3 (вылезла сразу): таймер связи переживал рестарт уровня
Пользователь поймал на первом же прогоне: **до первого падения кнопка
рисуется правильно, после — выглядит нажатой с самого начала уровня**, хотя
логически не нажата.
`died_on_button` ставит таймер связи в 5 и заводит trob кнопки — но тайл к
этому моменту УЖЕ пол, и trob умирает в первом же обходе
(`default: type = -1`), ни разу не уменьшив таймер. У оригинала ровно то же
самое, только там `load_level()` перечитывает файл ЦЕЛИКОМ, а наш
`pop_level_reset_tiles` восстанавливал одну foretable. Остаток `dl2[18] = 5`
переживал респавн, а `pop_tile_code_drawn` рисует кнопку нажатой, пока
таймер > 1.
Фикс — восстанавливать в рестарте и LINKMAP. Отдельная эталонная копия не
нужна: в странице уровня LINKMAP остаётся нетронутой, игра правит только
ОЗУ-копию `pop_dl2`.
**Проверка в MAME (полный цикл).**
| момент | тайл `(2,1)` | `dl2[18]` | `pop_kid_dead` | решётка `(2,6)` |
|--------|--------------|-----------|----------------|-----------------|
| труп на кнопке | `01` floor | 5 | 1 | `FF` открыта навсегда |
| после респавна | `0F` opener | **`00`** | 0 | `00` закрыта |
| второе падение | `01` floor | 5 | 1 | открывается заново |
Плюс попиксельная сверка кадров: решётка, до фикса стоявшая опущенной,
поднята. Цена: резидент без изменений, банк 3 +193 Б, банк 8 +22 Б.
**Осталось невыясненным.** На СТАРОМ поведении openness решётки замирал на
112 и дальше не убывал, хотя `animate_door` (совпадает с seg007:0522) обязан
досчитать до 0 и снять trob. С фиксом сцена так не воспроизводится
(решётка уходит в `FF`), проверить нечем; если всплывёт где-то ещё — это
отдельный баг, к `died_on_button` отношения не имеющий.
---
## GUARD-RESPAWN-COL0. Страж при возврате в комнату телепортировался в колонку 0 и падал
**Наблюдение (пользователь, 2026-08-18).** Уровень 8, комната 24. Первый
вход из левой комнаты — страж около `(0,7)`, стоит. Кид уходит вправо и
возвращается — страж уже «в `(0,-1)`». Ещё раз туда-обратно — страж на
`(0,0)`, идёт биться, падает на два ряда и разбивается. Второй сценарий:
вход из комнаты 23, страж на `(0,7)` неподвижен; Кид уходит влево и
возвращается — страж падает на `(2,1)` и разбивается.
Формулировка пользователя — «страж, если не может биться с Кидом, не должен
менять позиции; иначе он будет ждать Кида прямо на входе, и тот не успеет
достать меч» — совпадает с тем, что гарантирует оригинал.
**Как это устроено в оригинале.** Позиция стража живёт в ТРЁХ полях данных
уровня, и колонки среди них нет:
- `pos_guards` (seg003:0913), один раз сразу после `load_level`:
`guards_x[room] = x_bump[(tile % 10) + FIRST_ONSCREEN_COLUMN] + TILE_SIZEX`.
То, что лежит в `guards_x` В ФАЙЛЕ, оригинал ВЫБРАСЫВАЕТ (в комнате 24
уровня 8 там 255);
- `leave_guard` (seg002:02F5) при уходе Кида:
`guards_tile = get_tilepos(0, row)` — колонка обнуляется НАМЕРЕННО, тайл
несёт только РЯД, — и `guards_x = Guard.x`, то есть настоящая позиция;
- `enter_guard` (seg002:0112) при возврате: `curr_row = tile / 10`,
`y = y_land[row + 1]`, **`x = guards_x`**, `curr_col = f(x)`.
Носитель колонки — `guards_x`, и только она.
**Что было у нас.** `pop_guard_leave` тайл обнуляла верно, а
`pop_guard_enter` считала X из `tile % 10` — из колонки, которой там уже нет.
Запомненную X брали ТОЛЬКО у трупа. Значит после ПЕРВОГО же выхода из
комнаты живой страж возвращался в колонку 0.
Численно: тайл стража комнаты 24 = 6 (ряд 0, колонка 6), ряд 0 =
`13 00 00 00 0F 01 01 01 01 01`, то есть **колонки 1..3 ПУСТЫЕ**, а в
колонке 0 факел. Телепорт давал `x = x_bump[5] + 14 = 72`, а 72 по таблице
деления — это колонка **1**: страж оказывался над дырой, падал с ряда 0 на
ряд 2 (щебень в `(2,1)`) — два ряда, смерть. Оба сценария пользователя
ложатся сюда ровно, включая «упал на 2,1».
**Фикс.** Два места, оба — приведение к оригиналу:
- `pop_gstate_init` — порт `pos_guards`: при загрузке уровня `GS_X`
считается из колонки тайла, а не читается из файла;
- `pop_guard_enter` — `Guard.x` берётся из запомненной ВСЕГДА, не только у
трупа; колонка, как и раньше, считается из X.
**Проверка в MAME** (уровень 8, комната 24, три входа через `enter_room`):
| | room | x | y | col | row | alive |
|---|---|---|---|---|---|---|
| первый вход | 24 | 156 | 55 | 7 | 0 | жив |
| возврат | 24 | 156 | 55 | 7 | 0 | жив |
| второй возврат | 24 | 156 | 55 | 7 | 0 | жив |
Позиция не меняется, страж стоит на полу. Host-тесты — 5106 проверок без
расхождений. Резидент без изменений, банк 8 −15 Б.
**Что осталось невыясненным.** Наблюдение «страж был в `(0,-1)`» отдельно
не воспроизведено: телепорт даёт колонку 0/1, а −1 требует `x < 65`.
Вероятнее всего это кадр уже НАЧАВШЕГОСЯ падения (при падении X сносит), но
доказательства нет. Если `(0,-1)` появится на исправленном билде — это
отдельный баг.
---
## SEAM-FIGHT-FLICKER. Бой у шва: комната перерисовывалась то одна, то другая
**Наблюдение (пользователь, 2026-08-18).** Уровень 8, шов комнат 24/18: во
время схватки со стражем экран постоянно переключался между комнатами —
«драться очень неудобно». Отдельно пользователь заметил, что уходит Кид,
даже когда просто держит «вверх» (защита), и предположил верную причину:
стойка защиты не одна, Кид периодически проходит через обычную.
**Корень.** `leave_room` (seg002:0490) запрещает смену комнаты не только на
развороте, подъёме-с-зацепа (135..149) и вставании из приседа (110..119), но
и на ВСЕЙ боевой анимации — кадры **150..162** и **166..168**. У нас были
только первые три условия.
Разрешены при этом 163..165, 169 (`begin_block`) и 170/171 (`stand with
sword`) — то есть уйти можно, но только на «легальном» кадре. Цикл защиты
(seqtbl: `readyblock` = 169 -> `blocking` = 150, петля) как раз проходит
через 169, а между атаками Кид возвращается в 158/170 — отсюда и ощущение
«стоять в защите нельзя».
Побочный эффект известен и в оригинале эксплуатируется — **Trick 35,
«retreat without leaving the room»**: определённым ритмом «назад» кадр 170 не
наступает никогда, и Кид пятится из комнаты, не переключая её. В SDLPoP это
чинит `FIX_RETREAT_WITHOUT_LEAVING_ROOM`, по умолчанию ВЫКЛЮЧЕННЫЙ, — мы
портируем оригинал.
**Сторона стража проверена отдельно, расхождений нет.** `play_guard_frame`
(seg000:0F48) ухода из комнаты не содержит вовсе (ни `leave_room`, ни
`check_leave_below`), физика загейчена окном `Char.x ∈ [44, 211)`. Комнату
страж меняет только через `follow_guard` (у нас `pop_guard_follow`) и
`check_guard_fallout` (у нас `pop_guard_fallout`) — оба портированы.
**Проверка: покадровая сверка с SDLPoP.** Пользователь добавил в SDLPoP
вывод в конце `add_kid_to_objtable` (seg008:1679); мы навесили такой же
(`pop_dbg_kidobj`, см. ниже). Окно перехода совпало КАДР В КАДР:
| | SDLPoP | наша |
|---|---|---|
| | `tilepos=10 frame=161 col=-1` | `tilepos=10 frame=161 col=-1` |
| | `tilepos=10 frame=160 col=-1` | `tilepos=10 frame=160 col=-1` |
| | `tilepos=10 frame=157 col=-2` | `tilepos=10 frame=157 col=-2` |
| | `tilepos=10 frame=158 col=-2` | `tilepos=10 frame=158 col=-2` |
| **смена** | `tilepos=18 frame=169 col=9` | `tilepos=18 frame=169 col=9` |
Прогон уровня 1 комнаты 3 (1779 кадров): за весь бой комната сменилась
ОДИН раз; 34 боевых кадра прошли за краем комнаты (`col<0`), самый длинный
непрерывный прогон — 17 кадров, и комната при этом не переключалась — ровно
как в трассе SDLPoP.
Прогон уровня 8, шов 24/18 (1433 кадра, 360 кадров с мечом): внутри боя
**4 смены комнаты на 387 кадров** — то есть примерно раз в 6 секунд, и это
настоящее выдавливание, а не мерцание. Все четыре пришлись на РАЗРЕШЁННЫЕ
кадры (170, 164, 170, 165); ни одной смены на запрещённом кадре во всей
трассе нет. Кид подолгу держался на `col=-1` и `col=10`, комната не
переключалась.
**Инструмент (оставлен в дереве, выключен).** `pop_dbg_kidobj`
(`roomtest_cold.c`) + резидентный буфер `pop_dbg_obj` (`pop_state.c`),
включается `#define DBG_KIDOBJ 1` в `roomtest.c`. Считает то же, что
`set_char_collision` + `set_objtile_at_char`. Вывод забирает брейкпоинт
MAME на резидентном `pop_dbg_trap`; сборщик — `collect_kidobj.py`.
Грабли, стоившие времени:
- `#define DBG_KIDOBJ` сперва положили внутрь `#ifndef PROF_BORDER`, а
`PROF_BORDER` задаётся ключом сборки — блок не выполнялся, трасса молча
не включалась. Нужен свой `#ifndef`.
- Знаковые значения через выражения отладчика MAME получить не вышло: `b@`
связывается слабее `+`, и `b@ADDR + 128` читает ДРУГОЙ адрес; скобки не
помогают. Зонд печатает сырые байты, перевод — в сборщике.
- Кольцо `clog` не потребляющее, одинаковые строки от повторов не отличить —
в строку добавлен `totalcycles`, по нему и склеиваются чанки.
- Замерено: один зонд с коротким `printf` — **98 % полной скорости**
эмуляции, играть можно (в отличие от четырёх зондов профилировщика).
**Осталось невыясненным.** Поле `cols` (`char_col_left..char_col_right`) у
нас систематически на единицу меньше, чем в SDLPoP, и дважды дало `0..-1`,
чего в оригинале не бывает. Все поля, которые считает ДВИЖОК (`tilepos`,
`frame`, `act`, `col`, точка перехода), совпали полностью — расхождение
только в величине, которую выводит сам отладочный помощник из ширины кадра
`pop_cd[].fpw`. Подозрение на паддинг кадров в атласе
(`png_strip_padding_tradeoff`) либо на мой пересчёт переднего края мимо
боевого `char_x_forward_edge`. Проверять отдельно.
---
## MOB-NEIGHBOUR-ROOM. Плиты нижнего ряда пропадали без кадров падения
**Наблюдение (пользователь, 2026-08-18).** Уровень 11, комната 14: Кид бежит
по ряду проваливающихся плит, они трясутся и «падают сразу полностью
пропадая, а должен быть промежуточный кадр». Догадка пользователя — «для
плит на 2-ом ряду мы рисуем только тряску» — оказалась верной по симптому и
почти верной по причине.
**Корень.** Порт `draw_mob` (seg007:13E5) был неполным. У оригинала кусок
рисуется НЕ ТОЛЬКО в своей комнате:
```c
if (curmob.room == drawn_room) { if (curmob.y >= 210) return; }
else if (curmob.room == room_B) { if (ABS((sbyte)ypos) >= 18) return; curmob.y += 192; }
else if (curmob.room == room_A) { if (curmob.y < 174) return; ypos = curmob.y - 189; }
else return;
```
У нас на этом месте стояло `if (!mt_here) return;` — «чужая комната: считаем,
но не рисуем».
Почему страдал ИМЕННО ряд 2: кусок спавнится на `y_loose_land[row+1]`, для
ряда 2 это **191**, а граница ряда `y_something[3]` — **188**. То есть на
ПЕРВОМ же `move_loose` он уходит в комнату снизу и у нас переставал
рисоваться мгновенно. Ряды 0 и 1 летят внутри комнаты, поэтому там всё было
видно. Промежуточных кадров ровно три — пока `|y| < 18`.
**Фикс.** У куска появилась отдельная ЭКРАННАЯ координата `draw_y` (своя
комната / +192 из комнаты снизу / −189 из комнаты сверху, `MOB_Y_NONE` =
не рисуется). По ней идут отрисовка, `prev_y`/heal, порядок относительно
Кида, пометки соседа и оверлей; отбор в проходе отрисовки — по `draw_y`, а не
по комнате. Ссылки вверх/вниз кэшируются (иначе `pop_room_link` мапил бы W0
каждый кадр).
Вторым заходом пришлось поправить `mob_overlay_neighbour`: ряд там уже считался
по `draw_y`, а габарит спрайта и вызов `pop_mob_overlay_tile` остались по `y` —
для куска из соседней комнаты это расходится на 192, и пересечение с габаритом
тайла считалось заведомо неверно (порядок переднего слоя).
**Цена — и урок про то, ЧТО именно дорого.** Замер 13/23:
| секция | до | первый вариант | после гейта |
|---|---:|---:|---:|
| работа | 880 272 | 898 578 | **888 984** |
| зелёная | 419 562 | 445 248 | **427 242** |
| циан | 380 202 | 379 818 | 378 864 |
Первый вариант метил «фон трогали» по ПРОШЛОЙ нарисованной позиции
безусловно. У куска, видимого из соседней комнаты, она отличается от `y` на
192 — объединение прямоугольников растягивалось почти на весь экран и тянуло
за собой лишние перерисовки. Гейт `m->room != pop_t_room` вернул 18 000 из
26 000.
Я сперва списал рост на клипованный блит (кусок у кромки по определению
клипуется, а клипованный путь дороже: 14 088 против 10 422) — замер это
опроверг: дело было в пометке. Остаток +7 680 в зелёной (+1,8 %) —
собственно отрисовка трёх кадров на плиту; зелёная остаётся ниже растрового
кадра 430 000.
Host-тесты 5106 проверок без расхождений (`pop_room.c` они не линкуют —
проверка визуальная, в MAME: уровень 11 комната 14, отделившаяся плита видна
три кадра).
### Продолжение: в мид-оверлее не было переднего торца плиты
Пользователь тут же нашёл следующий слой (2026-08-18, та же комната): правый
конец падающего куска рисовался ПОВЕРХ соседней плиты `(2,6)`, хотя торец той
плиты при этом лежал поверх куска — то есть порядок был противоречивым в
разных частях перекрытия.
Сначала я проверил, не нужен ли клип объекта: `add_mob_to_objtable`
(seg007:1161) ставит куску `clip.right = 40`. Оказалось — **не нужен и в
оригинале не работает**: клип применяется только если
`chtab_flip_clip[chtab_id]`, а таблица (`data.h:196`,
`{1,0,1,1,1,1,0,0,0,0}`) для `chtab_6_environment` даёт **0**. Поле
выставляется и молча игнорируется, так что «клипа нет» у нас — верно. Пункт
MOB-CLIP-RIGHT можно закрывать как несуществующий.
Настоящая причина — состав мид-оверлея. У оригинала это `draw_tile2()`:
```c
draw_tile_right(); draw_tile_anim_right(); draw_tile_base();
draw_tile_anim(); draw_tile_bottom(0); draw_loose(0);
```
а у нашего `overlay_mid_tile` последнего вызова не было. У loose в
`pop_tile_table` `bottom_id = 0`, поэтому передний торец плиты рисует ТОЛЬКО
`draw_loose` — и в оверлее его не рисовал никто. Добавлено; кадр берётся по
живому modif, чтобы дрожащая плита осталась дрожащей.
**Осталось невыясненным:** в `overlay_mid_tile` по-прежнему нет полного
`draw_tile_right` (есть только случай пола, спрайт 42) и нет
`draw_tile_anim_right` — то есть анимированной правой грани СОСЕДА (пики,
ворота, loose). Симптома на это пока не видели; если всплывёт «правая грань
соседа под персонажем/куском» — смотреть сюда.
### Вариант B: порт корзины 30 (2026-08-18)
Реализовано то, что разобрано в коммите `272cf8f`. Кусок, у которого ряд
объекта вне 0..2 (`y_to_row_mod4` даёт −1 и «выше потолка», и «ниже пола»),
считается объектом ТАЙЛА 30 — а такие оригинал рисует ПЕРВЫМИ, до всего
обхода тайлов. Два следствия:
- `defer = 0` — кусок идёт под всем, включая Кида (раньше сравнение рядов
давало обратное: «−1 обходится последним» читалось как «поверх»);
- оверлею отдаётся ориентир **3** («раньше любого ряда обхода 2,1,0»)
вместо сырого −1, и в набор перекрываемых тайлов добавляется **своя
клетка** — у куска в обычном ряду её перерисовывать нельзя, там объект
вливается в midtable уже после частей своего тайла.
**Замер 13/23 (3032 кадра, зонды фаз + детализация зелёной):**
| секция | тег `mob-order-B-start` | после B | дельта |
|---|---:|---:|---:|
| работа | 888 984 | **913 848** | +24 864 |
| синяя | 159 804 | 159 810 | +6 |
| зелёная | 427 242 | **440 418** | +13 176 |
| циан | 378 864 | 393 000 | +14 136 |
Период кадра не изменился: 4 растра в 23 кадрах, 5 в двух — как и до правки.
**Зелёная вышла за растровый кадр** (440 418 против 430 000) при цели
400 000. Детализация показывает, где именно:
| участок зелёной | максимум |
|---|---:|
| `pop_loose_tick` целиком | 185 826 |
| — из них `pop_loose_mob_tick` | **168 180** |
| — циклы по тайлам | 23 736 |
| — `check_loose_fall_on_kid` | 5 868 |
| тробы + `pop_redraw_needed` | 337 800 |
| шов и прочее | 11 964 |
(максимумы взяты по разным кадрам, поэтому не складываются в общий).
Основной кандидат на возврат этих тактов — задача
[HEAL-WIDTH](TASKS_OPEN.md#heal-width): heal'ы кусков и плит берут ширину по
клеткам, а фактический след — 58/57.
### BUG-DBGSTART-LEVEL. Отладочный старт ломал переход между уровнями (закрыт 2026-08-20)
**Симптом** (найден пользователем на сборке `make LEVEL=6 ROOM=1 POS=18`):
Кид проваливается в комнате 1 шестого уровня, уровень меняется на 7-й — и
Кид появляется не там: приземляется в комнате 1 на 2,8 вместо того, чтобы
продолжать падение по своей колонке.
**Корень.** Отладочный старт (`DBG_START_ROOM`/`DBG_START_POS`,
`roomtest_cold.c`) подменял комнату и позицию БЕЗУСЛОВНО, а
`pop_start_level` зовётся не только на первом запуске, но и **на каждой
смене уровня**. В результате на 7-м уровне стартовой становилась
отладочная комната 1 вместо комнаты 17 из данных — и спецсобытие «вход
падением» (`set_start_pos`, seg003:0196: уровень 7, комната 17 ->
`goto_other_room(3)`) не срабатывало вовсе, потому что его условие
сравнивает именно `start_room`.
Само спецсобытие было портировано и работало; сломан был вход в него.
**Починка:** подмена применяется только пока `pop_current_level ==
FIRST_LEVEL`. Рестарт ТОГО ЖЕ уровня (смерть, чекпойнт) отладочную
позицию сохраняет — ради этого она и перебивает чекпойнт.
**Проверено в MAME:** после провала на 6-м `pop_current_level` = 7, Kid.room
= 16, то есть спецсобытие отработало (старт в 17, переход вниз).
---
## CUTSCENE-LTR. Переходы story должны проявляться слева направо — **ЗАКРЫТ 2026-08-26**
**Было.** Полосовой переход (`transition_ltr` оригинала) стоял только на
«In the absence…»; «свадьба» → титры возникали мгновенно, а промежуточный
титульный экран отсутствовал вовсе.
**Стало.** Переход выделен в общий `pop_screen_present_ltr()` (pop_ui.c) —
им пользуются story-переход интро, титры, таблица рекордов и титульная
картинка финала. Восстановлен и пропущенный шаг `show_title()`
(seg000:2045): между «свадьбой» и титрами показывается композиция с
логотипом и Jordan Mechner, тоже полосами, и лишь через 0x78 тиков идут
титры.
**Грабля, которая стоила отладки.** Переход копирует страницу
акселератором, а тот читает ОЗУ-копию. `GFX_BANK_SPRITE` (NOSHADOW +
TRANSPARENT) в неё не пишет, поэтому переезжал один фон без текста (так вышла
пустая таблица рекордов, см. [HOF-ENTRY](TASKS_CLOSED.md#hof-entry)). Всё,
что должно проявляться ВМЕСТЕ с фоном, рисовать банком
`GFX_BANK_TRANSPARENT` — прозрачность та же, но копия обновляется.
---
## PV-MUSIC-STALL. В сцене с принцессой звучала только первая реплика — **ЗАКРЫТ 2026-08-26**
**Симптом (пользователь).** «Музыка после открытия двери для входа Джафара
пропала» — дальше сцена шла в тишине до конца: не звучали ни реплика входа
Джафара (m53), ни его ухода (m52).
**Корень.** Регрессия коммита `4086dde` («убрана промотка кадров»). До него
шаг постраничной подкачки стоял БЕЗУСЛОВНО в теле кадра сцены (плюс там же
вызывался `pop_music_service`). Тот коммит перенёс шаг «в паузу кадра» —
внутрь цикла ожидания тика насоса — и одновременно ввёл правильную защиту от
промотки (`if (snd_done > snd_want) snd_want = snd_done`). Вместе это и убило
подкачку: пауза в этой сцене почти не выпадает, цикл ожидания выходит сразу,
и шаг не вызывается.
**Замер, который это показал** (адреса статиков `pop_music.c` вычислены от
public `_pop_mus_page` по смещениям из `.sym`):
| | до фикса | после фикса |
|---|---|---|
| `ld_next` (прочитано страниц m53) | **0** за 20 с | **11 из 11** |
| `mus_ready` | 0 | 1 |
| что звучит | только m50 | m50 → m53 → m52 |
**Фикс.** Один безусловный `pop_music_load_step()` в кадре сцены (шаг в паузе
оставлен: он бесплатный, когда время есть). Страница стоит 33 мс против
кадра сцены в 100-133 мс, а шкала работает по факту прошедшего времени,
поэтому сцена от этого не разъезжается.
**Ловушка отладки, стоившая ложного диагноза.** Пока машина стоит на
брейкпоинте, значения в памяти застывают — картина «`busy=0` при `next=3` из
20» выглядела как обрыв чтения с ошибкой, хотя это был просто стоп-кадр.
Прежде чем толковать значения, проверять `status`: `state=run` или `stop`.
---
## FINAL-BANKCALL. Финал убивал программу: прямой вызов в чужой банк
**Симптом (пользователь, 2026-08-27).** Пройденная игра доходила до
таблицы рекордов и умирала: программа исчезала, а машина следом вставала
намертво (`di; halt` по адресу 0x0000) либо уходила в reset. Из Flex
Navigator и из голого DSS — одинаково.
**Что оказалось не при чём** (проверено, чтобы не искать заново): Flex
Navigator, звук и CBL (с выключенным звуком падало так же), потоковое
чтение победной темы, второй `open` поверх открытого файла, фейды с
клавиатурным диспетчером, чтение архива PV, межбанковый вызов
банк 10 → банк 11, переполнение банка (самый полный — BANK8, 15376 из
16384), утечка манипуляторов (`_fd_open_count` = 1) и утечка ссылок на
IM2-таблицу (`_irq_refs` = 2, как задумано).
**Как искали.** Бисекция ключом `HOF=0/2/4/5` (пропустить таблицу целиком
/ только `hof_load` / без фона / без фейдов) сузила место до
`hof_draw_rows`, а точную инструкцию дала ТРАССИРОВКА MAME на узком
участке: `trace` включалась брейкпоинтом на входе в `pop_hof_show`
(0x44AB) и выключалась на процедуре завершения процесса DSS (0x1E56).
`history` для этого не годится — её 250 записей забиваются обработчиком
прерываний, пока процессор ползёт по мусору.
**Корень.**
```
CD42: ld hl,$B8AB ← _gfx_bank
CD45: ld (hl),$58 ← gfx_set_bank(GFX_BANK_TRANSPARENT)
CD47: call $E503 ← ПРЯМОЙ вызов; по карте 0009E503 = _pop_text_map, банк 9
E503: rst $38 ← а в W3 стоит банк 10, там пустой хвост (0xFF)
```
`pop_ui.h` объявлял группу `pop_text_*_mapped` БЕЗ `__banked`. Пока
`pop_hof.c` лежал в банке 9 рядом с `pop_ui.c`, прямой `call` был верен.
Когда `pop_hof`/`pop_config`/`pop_pal` перенесли в банк 10 ради разгрузки
банка 9, тот же `call` стал уходить в пустоту. Процессор полз по 0xFF до
0x0000, где ловушка DSS ставит B=0x27 и сворачивает процесс: закрывает его
файлы, возвращает контекст (байт 0x1E48: 2 = шелл, 3 = наша программа) и
восстанавливает страницы родителя. Именно это выглядело как «W2 подменили
у нас под ногами» — на самом деле подмена была уже уборкой трупа.
**Фикс.** Группа помечена `__banked`; появилась
`toolchain/check_bank_calls.py`, встроенная в `app.mk` и валящая сборку
(проверено намеренной поломкой). Правило записано в `CLAUDE.md`.
---
## FINAL-HOF-GARBAGE. Таблица рекордов заливалась знаками вопроса
**Симптом.** После фикса FINAL-BANKCALL экран HOF рисовался правильно, но
с началом ввода имени строку заливало `?` во всю ширину.
**Корень — вторая мина того же класса, созданная первым фиксом.** Курсор
рисовался литералом:
```c
pop_text_draw_mapped(POP_TEXT_BIG_DARK, x, baseline, "_"); /* ___str_3 в _BANK10 */
```
Пока `pop_text_draw_mapped` был в том же банке, литерал был виден. После
пометки `__banked` трамплин на время вызова переключает W3 на банк 9 —
указатель показывает в чужой банк, функция читает оттуда байты до первого
нуля и рисует их знаками вопроса. Сходится и с наблюдением пользователя
«сначала рисуется правильно, потом мусор»: строка времени берётся из
локального массива (стек, W2 — виден всем), а мусор начинается с мигания
курсора.
**Фикс.** Курсор строится на стеке. Вторая проверка
`check_bank_calls.py` предупреждает о таких случаях (проверено: на сборке
с возвращённым литералом даёт ровно одно срабатывание).
---
## KBD-ARROW-PHANTOM. Залипшая стрелка вешала ожидания насмерть
**Симптом.** После игры (особенно 14-й уровень — он почти весь проходится
удержанием Left) текстовые экраны переставали прерываться, а «новая игра»
вставала намертво: `pop_new_game_load` начинается с
`while (kbd_raw_any_down())`. При этом ввод имени в HOF работал —
он опрашивает конкретные коды, а не всю карту.
**Корень (подтверждён дважды: 0x72 24.08, 0x6B 27.08).** Стрелка идёт по
проводу как `E0 6B`. При переполнении 3-байтового FIFO SIO теряется
префикс, make садится в PLAIN-половину карты как код нумпада (0x6B = KP4),
а break приходит уже с префиксом и снимает бит в EXT-половине. PLAIN-бит
остаётся зажатым навсегда, `kbd_raw_any_down()` отвечает «да» вечно.
Доказано вмешательством: запись 0 в этот байт отладчиком мгновенно
перевела состояние 5 (LEVEL_LOAD) → 6 (PLAYING).
**Фикс.** `kbd_raw_keypad_as_ext()` в libc: `kbd_raw_sync()` переносит
биты голых кодов нумпада в EXT-половину и гасит в PLAIN — make и break
начинают работать с одним битом. В PS/2 это и есть одни и те же
физические клавиши, поэтому нумпад заодно стал управлением (7/8/9, 4/6,
2 и 5 — вниз). НЕ лечит обратный случай (префикс потерян у break) — там
бит стоит уже в EXT и выглядит как реально зажатая клавиша.
---
## F10-GAMEPLAY. Выход по F10 не работал в игре
F10 проверяется в idle-хуке, а хук висит на `gfx_wait_vsync`. Когда
игровой цикл перевели на пейсинг по лучу, он стал ждать через
`pop_wait_edge`, который звал `kbd_raw_poll` напрямую — хук не вызывался,
и F10 в геймплее умер (в заставках и меню работал). Фикс: `pop_wait_edge`
зовёт тот же `pop_idle`.