Files
Sprinter-SDCC/applications/PoP/roomtest/bug_closed.md
T
snark13 6673279cef PoP: скелет уровня 3, цвета стражей, окклюзия соперника; кэш соседних комнат
Скелет (L3-SKEL, ассеты + механика):
- pop_pack_guard.py получил параметр набора (GUARD/SKEL): атлас скелета
  poc/res/skel/g0..g3.atl (28 кадров), палитра — из его res750.pal (на ур. 3
  curr_guard_color = 0, оригинал палитру не подменяет);
- pop_guard_load выбирает набор по tbl_guard_type и перезагружается ПРИ СМЕНЕ
  УРОВНЯ (load_lev_spr, seg000:1092) — без этого скелет рисовался атласом
  стража и был невидим;
- load_frame: charid_4_skeleton идёт по таблице стража (seg006:529), тень —
  только в кадрах 150..189.  Пока ветка была одна (charid_2_guard), скелет
  получал image из таблицы Кида (180 при 28 спрайтах) и не рисовался;
- check_skel (seg002:1042), ветка charid_4 в enter_guard, возрождение в
  комнате 3 при падении (seg002:252), autocontrol_skeleton;
- leveldoor_open (seg007:456) — новый флаг, сбрасывается стартом уровня.

Цвета стражей (BUG-GUARD-COLOR-1, закрыт):
- все 7 палитр res10.bin -> pop_guard_pal.h, заливка 16 слотов по
  guards_color комнаты перед отрисовкой (set_chtab_palette, seg003:257).
  Проверено в MAME: ур. 2 комн. 11 = цвет 1, комн. 7 = цвет 3, полоса HP
  меняется вместе со стражем.  Грабля: gfx_pal_load отдаёт указатель в BIOS,
  а тот читает только #4000-#BFFF — таблицу из банка копируем в стек.

Кэш соседних комнат (BUG-SWORD-GHOST-1, закрыт):
- pop_map кэширует fg соседей слева/справа ЦЕЛИКОМ и резолвит col -10..19.
  Было -2..11, дальше мнимая стена: луч видимости упирался в неё (страж
  после follow_guard в col 12), Кид прятал меч посреди боя и не мог достать
  обратно.  +48 байт W2.

Окклюзия соперника:
- pop_fore_over_char получил проход other_overlay_tile (порядок midtable,
  seg008:1B06) и расширение перебора объединённым прямоугольником
  «персонаж + клинок + брызги» — падающий скелет больше не рисуется поверх
  кладки и верхней грани пола;
- клип полем 192 строк (reset_obj_clip, seg006:0507) для спрайта, клинка
  (общий pop_sword_draw) и брызг — спрайт не залезает на полосу HP;
- ROOMNAV после смерти Кида делает честный pop_start_level: телепорт
  «оживлял» мёртвого мимо старта уровня, оставляя живого скелета рядом с
  вернувшейся кучей костей.

Ассеты чомпера (под L3-CHOMP): весь набор кадров в атласе явным списком
(101-105 низ, 111-113 верх, 106-110 фронт, 114-123 кровь mono-силуэтом) —
render_room анимированные тайлы пропускает, и в атласе не было ни одного.
Число EMM-страниц не изменилось.

Тесты: tests-host все 5 наборов зелёные, t_char вырос до 65 проверок
(резолв колонок за краем комнаты, возрождение скелета); в testkit добавлен
гард «код наехал на данные» (DATA_LOC).

Доски: TASKS.md разнесён на TASKS_OPEN/TASKS_CLOSED, закрытые баги с
разбором корней — в bug_closed.md; заведены DRAW-CHAR (отрисовка одна на всех
Char, как физика после GUARD-PHYS) и L3-COLOR (зелёная кладка уровня 3:
level_var_palettes = ресурс 20, есть в MSDOS/PRINCE.DAT).

В roomtest.c временно оставлен автостоп на падении соперника (отладка
падений скелета) — помечен ВРЕМЕННО.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 22:11:03 +03:00

1810 lines
137 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# roomtest — архив закрытых багов
Сюда переезжает всё, что **закрыто**: подтверждённые фиксы, снятые
диагнозы, осознанные решения «не делать». Открытые баги — в
[`bug_list.md`](bug_list.md), текущие задачи — в
[`TASKS_OPEN.md`](TASKS_OPEN.md), закрытые задачи с протоколами — в
[`TASKS_CLOSED.md`](TASKS_CLOSED.md).
Файл существует не ради истории как таковой: половина записей ниже — это
разбор КОРНЯ (odd-pixel арифметика `char_x`, подстановка тайла нажатой
кнопки, баг кодогенератора SDCC), и он экономит часы, когда похожий симптом
всплывёт снова. Прежде чем заводить новый баг — грепни здесь по симптому.
---
<a id="bug-guard-color-1"></a>
## 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[room1] & 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).
---
<a id="bug-sword-ghost-1"></a>
## 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](bug_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](bug_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, колонки `col1..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 сценариев набора не изменились ни на байт: правка
трогает только ветку разбег-прыжка у кромки.
---
<a id="bug-fall-sword-1"></a>
## 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()` | (159758)/14 = 6 ост. **10** | (165758)/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`).
---
<a id="bug-land-sword-1"></a>
## 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=6157=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 = aligned4 = 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+(184140)+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` для навигации, потиковые
трассы, скриншоты.
**Оговорка о полноте проверки.** Каждый корень закрыт тем, что его СОБСТВЕННЫЙ
сценарий больше не воспроизводится; сквозного прохождения уровня и поиска
регрессий в соседней механике автоматика не делала. Чек-лист для ручной
перепроверки — в [`bug_list.md`](bug_list.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), см.
`bug_list.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 (вечер)
<a id="bug-kbd-5"></a>
## 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`, то есть
расширенную половину), но если всплывёт — искать здесь.
---
<a id="bug-kbd-4"></a>
## 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 программ, роста нет.
<a id="bug-respawn-2"></a>
## 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.
<a id="bug-draworder-1"></a>
## 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: там так же. Подробности — в
разделе «НЕ БАГИ» выше.
<a id="bug-loose-2"></a>
## 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`.
<a id="bug-seam-draw-1"></a>
## 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) — ЗАКРЫЛ ТРИ РЕВИЗИИ РАЗОМ
Результат прогона: **крупных багов нет**. Тем самым закрыты и переехали сюда
из `bug_list.md` три накопившихся хвоста — чек-листы ручной перепроверки
фиксов, таблица обхода 24 комнат уровня 1 и таблица сырых наблюдений прогона
2026-08-03. Всё, что с прогона 2026-08-07 осталось открытым, — три записи
уровня 2 в [`bug_list.md`](bug_list.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).
<a id="ручная-перепроверка-2026-08-03"></a>
## Ручная перепроверка фиксов (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). Не всплыло.
<a id="обход-всех-24-комнат-уровня-1"></a>
## Обход всех 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_y3), подъём по Shift (check_get_item/get_item/do_pickup: присед → seq_91 pickupsword → меч исчезает с пола) и статус `pop_have_sword` |
| 13, 18, 24 | недостижимы в игре | свойство данных уровня, разбор — «НЕ БАГИ» выше |
<a id="сырые-наблюдения-прогон-2026-08-03"></a>
## Сырые наблюдения (прогон 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, который остаётся открытым (ждёт сценария).
---
<a id="bug-guard-splash-1"></a>
## 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](bug_list.md#bug-guard-color-1) —
отдельной работы не требует.