# roomtest — архив закрытых багов

Сюда переезжает всё, что **закрыто**: подтверждённые фиксы, снятые
диагнозы, осознанные решения «не делать».  Открытые баги — в
[`bug_list.md`](bug_list.md), текущие задачи — в [`TASKS.md`](TASKS.md).

Файл существует не ради истории как таковой: половина записей ниже — это
разбор КОРНЯ (odd-pixel арифметика `char_x`, подстановка тайла нажатой
кнопки, баг кодогенератора SDCC), и он экономит часы, когда похожий симптом
всплывёт снова.  Прежде чем заводить новый баг — грепни здесь по симптому.

---

## BUG-JUMPWALL-1. Недолетевший прыжок проходил СКВОЗЬ стену — **ЗАКРЫТ 2026-08-05**

**Симптом (пользователь, приёмка уровня 3).**  Комната 14: Кид на (0,8)
лицом влево, прыжок с места — пролетает сквозь кладку и падает в комнату 13
на (2,3).  Обобщение пользователя (оно и оказалось верным): «если Кид
прыгает, недолетает и должен врезаться в стену и упасть ВДОЛЬ неё, у нас он
летит по свободной траектории, как будто ему никто не мешает».

**Корень — упрощение в `check_collisions`, про которое там же стояла
оговорка.**  Оригинал (seg004:0004) каждый кадр считает флаги перекрытия для
ТРЁХ рядов (`curr`/`above`/`below`), а `move_coll_to_prev` (seg004:00DF) в
начале следующего кадра выбирает из них тот, что соответствует ряду прошлого
кадра.  Так «прошлые» флаги остаются настоящими и на кадре СМЕНЫ РЯДА.

У нас ряд был один, и на смене ряда `prev` заполнялся тройками («уже
перекрывал всё»), что подавляло бамп на этом кадре.  В падении ряд меняется
почти каждый кадр, а переход флага 0→1 на стене приходится ровно на него —
поэтому удар не регистрировался и `bumped_fall` (который и гасит `fall_x`)
не вызывался.

**Как ловили.**  Не в MAME, а на харнессе: набор
[`tests-host/t_wall.c`](tests-host/t_wall.c) — комната 14 уровня 3 с
подложенным дном шахты, свип по всей ширине стартовой плиты (x = 177..196).
Из 20 стартовых позиций 4 давали проход сквозь кладку (Кид оказывался в
колонке 4 при стене в колонке 5).  Первый вариант сценария (одна стартовая
X, из центра плиты) БАГА НЕ ПОКАЗАЛ — отсюда свип.

**Фикс.**  Три ряда как в оригинале + дословный `move_coll_to_prev`
(включая условия ±3 на заворот ряда при смене комнаты).  Цена — 3×14
`get_tile` на кадр вместо 14, банк 3 +115 Б.

**Чем проверено.**  `t_wall` зелёный; **все 1723 существующие трассы
`t_phys` не изменились ни на байт** — правка поведение-сохраняющая для
всего остального.  В живой игре подтвердил пользователь: «ударились и
падаем вертикально вниз».

---

## BUG-SEAM-WEDGE-1. Клин кладки в пустом углу (2,0) — **ЗАКРЫТ 2026-08-05**

**Симптом (пользователь, приёмка уровня 3).**  Комната 18: в тайле (2,0)
нарисован кусок кладки, хотя по данным там пусто.

**Корень.**  У тайла (2,0) «сосед снизу-слева» — это колонка −1 ряда 3, то
есть тайл ЧУЖОЙ комнаты (`room_BL` — левая от нижней).  `load_rowbelow`
(seg008:368) резолвит его через `get_tile_to_draw(room_left, 9, 0, …)` и
подставляет стену ТОЛЬКО когда такой комнаты нет.  Мы клали стену
безусловно, а `draw_topright` для стены рисует угловой кусок — он и был
клином.  Для комнаты 18 диагональ — комната 13, её тайл (0,9) пуст, значит
рисовать нечего.

**Фикс.**  Ряд «снизу» стал одиннадцатибайтным: `[0..9]` — колонки комнаты
снизу, `[10]` — тайл (0,9) комнаты снизу-слева (дефолт «стена», если её
нет).  Затронуты `pop_level.c` (заполнение), `pop_bg.c` (чтение),
`roomtest.c` (размер массива).

**Проверено** в MAME: комната 18 уровня 3, клина нет.

---

## BUG-LOOSE-3. Чёрный бар под упавшей плитой-потолком — **ЗАКРЫТ 2026-08-05**

**Симптом (пользователь, приёмка уровня 2).**  Комната 6: Кид сбивает
плиту-потолок (−1,2) — плита падает, но на её месте остаётся чёрный бар.
Два уточнения пользователя оказались диагностическими:

1. «бар попал в фоновое изображение» — Кид прыгает поверх, уходит, бар
   остаётся → чернота лежит в ОЗУ-копии, и heal возвращает её каждый кадр;
2. «когда возвращаемся в комнату после выхода — бара нет» → статическая
   отрисовка комнаты рисует всё правильно, виновата ЗАПЕЧКА.

**Корень.**  `pop_ceil_bake_empty` стирал полосу потолка чёрной плитой
(`bar`, банк NORMAL — пишет и в ОЗУ-копию) шириной 64 px, а восстанавливал
только ДВА тайла ряда −1: `(−1,col)` и `(−1,col+1)`.  Но куски тайлов
рисуются ВВЕРХ от своей нижней грани и свисают ВПРАВО, поэтому в стёртую
полосу попадает графика и соседа СЛЕВА, и тайлов ряда 0 — их верхушки.
Незакрашенным оставался прямоугольник ~13×3 px.

**Замер (он же метод).**  Плита роняется без игры — записью
`pop_ceil_modif[col] = 1` в отладчике MAME (это ровно то, что делает
`make_loose_fall` для потолка).  Дальше попиксельная сверка скриншота
«после запечки» со скриншотом «комната перерисована заново» (выйти и
вернуться): различие локализовалось в прямоугольник **экранные x 230..255,
y 47..52** при полосе потолка y 44..52.

**Фикс.**  Запечка восстанавливает всё, чья графика попадает в полосу:
ряды −1 И 0, колонки `col−1..col+1`.

**Проверено:** та же попиксельная сверка после фикса даёт **0 различий** в
полосе; пользователь подтвердил в игре.

---

## BUG-RJUMP-1. Разбег-прыжок не берёт провал в три тайла — **ЗАКРЫТ 2026-08-04**

**Симптом (пользователь, приёмка уровня 2).**  Комната 1: Кид с разбегу
обязан перелететь колодец с (0,5) на (0,1) — у нас он долетает до колодца и
валится вертикально вниз.  Комната 9: то же с (1,5) на (1,1).  Обобщение
пользователя оказалось точным: **провал ровно в три пустых тайла наш Кид не
перепрыгивал никогда**, а провалы поменьше брал.

**Почему «никогда», а не «иногда».**  Суммарный `dx` последовательности
`seq_4_run_jump` (кадры 34..44) — 62 пикселя при ширине тайла 14.  Это
ровно 4 колонки и меньше половины тайла запаса.  То есть перелёт трёх
пустых тайлов возможен ТОЛЬКО если оттолкнуться почти точно от кромки; из
случайной фазы бегового цикла он не получается никогда.

**Корень.**  Оригинал именно поэтому и не даёт прыгать откуда попало:
`run_jump` (seg005:0AA8) перед стартом **выравнивает Кида по кромке**.

```c
short xpos = char_dx_forward(4);
short col = get_tile_div_mod_m7(xpos);
for (short tiles_forward = 0; tiles_forward < 2; ++tiles_forward) {
    col += dir_front[Char.direction + 1];
    get_tile(Char.room, col, Char.curr_row);
    if (curr_tile2 == tiles_2_spike || !tile_is_floor(curr_tile2)) {
        pos_adjustment = distance_to_edge(xpos) + TILE_SIZEX * tiles_forward - TILE_SIZEX;
        if ((word)pos_adjustment < (word)-8 || pos_adjustment >= 2) {
            if (pos_adjustment < 128) return;      // ПРЫЖКА НЕТ
            pos_adjustment = -3;
        }
        Char.x = char_dx_forward(pos_adjustment + 4);
        break;
    }
}
control_up = release_arrows();
seqtbl_offset_char(seq_4_run_jump);
```

**У нас этой половины не было** — стояла заглушка с честным комментарием
«оригинал выравнивает Kid по краю пола (нужны tile-запросы) — это полировка
K3; K2b просто запускает run-jump».  Полировкой это не оказалось: без
выравнивания три тайла непроходимы в принципе.

**Две тонкости, которые легко потерять при порте.**

1. **Беззнаковое сравнение.**  `(word)pos_adjustment < (word)-8 || pos_adjustment >= 2`
   означает ровно «`pos_adjustment` НЕ попал в `[-8,-1]`».  Ветка
   `pos_adjustment = -3` недостижима: `distance_to_edge ∈ [0,13]`,
   `tiles_forward ∈ {0,1}`, значит `pos_adjustment ∈ [-14,13]`, а туда нужно
   `>= 128`.  В порте она записана как мёртвая, с объяснением.
2. **Отказ НЕ гасит `control_up`.**  `return` выходит из `run_jump` до
   `release_arrows()`, поэтому Кид бежит дальше с зажатой «вверх» и пробует
   снова на следующем кадре.  Именно так игрок и ловит фазу — просто
   удерживая клавишу.  Если погасить, прыжок у кромки станет одноразовым и
   почти всегда неудачным.

**Фикс.**  Тайловая половина — `pop_run_jump_align()` в `pop_map` (там живут
`get_tile`/`distance_to_edge`), диспетчерская — в `pop_ctrl.run_jump`.
Разделение то же, что у `pop_jump_up_seq`: pop_map правит `Kid.x` напрямую,
и `pop_savekid_state` эту правку намеренно не затирает.

**Проверка — на харнессе, а не в MAME.**  Сценарий
`phys_running_jump_over_3tile_gap` в [`tests-host/t_phys.c`](tests-host/t_phys.c)
(комната с провалом в колонках 2–4).  Было: прыжок со старта в кадре 34 при
`x=165`, кадр 44 приходится на колонку 3 — провал, падение.  Стало: Кид
пробегает лишние 5 кадров, выравниватель ловит фазу, прыжок стартует при
`x=149`, кадр 44 даёт `x=87, col=1, row=1` — приземление на пол и бег
дальше.  Остальные 8 сценариев набора не изменились ни на байт: правка
трогает только ветку разбег-прыжка у кромки.

---

## BUG-FALL-SWORD-1. Отход с мечом в провал: не та последовательность падения — **ЗАКРЫТ 2026-08-04**

**Симптом (приёмка уровня 2, комната 4).**  Кид с вынутым мечом отступает
от стража к дыре от упавших loose-плит.  Наблюдение пользователя: он
«проваливается раньше времени», летит **с клинком в руке**, и падает **по
другим X**, чем в оригинале — «почти на целый тайл левее».

**Разбор.**  Сверка `seg006:1044 start_fall` показала, что наш порт
пропустил ТРИ вещи из оригинала, и все три бьют именно по этому сценарию:

```c
void start_fall() {
    Char.sword = sword_0_sheathed;            // (1) меч В НОЖНЫ
    inc_curr_row();  start_chompers();
    ...
    } else if (frame >= 81 && frame < 86) {   // (2) срыв при приземлении
        seq_id = seq_19_fall;                 //     после прыжка вверх
        Char.x = char_dx_forward(5);
        load_fram_det_col();
    } else if (frame >= 150 && frame < 180) { // (3) кадры С МЕЧОМ
        droppedout = 1;
        if (Char.direction < dir_0_right && distance_to_edge_weight() <= 7)
            Char.x = char_dx_forward(-5);
        seq_id = seq_81_kid_pushed_off_ledge;
    }
```

У нас все они падали в общий `else` → `seq_7` (stepfall).  Разница между
`seq_7` и `seq_81` в `seqtbl.c` и объясняет ВЕСЬ симптом:

```
seq_7  stepfall  : dx(1) dy(3) | 102 | dx(2) dy(6) | dx(-1) dy(9) | dy(12) | dx(-2) set_fall(1,15)
seq_81 fightfall :       dy(-1)| 102 | dx(-2) dy(6)| dx(-2) dy(9) | dx(-1) dy(12) | dx(-3) set_fall(0,15)
```

`set_fall(1, 15)` против `set_fall(0, 15)` — **горизонтальный дрейф**: в
`seq_7` во время всего свободного падения `Char.x` уходит на 1 в сторону
КАЖДЫЙ кадр.  За два этажа падения это и есть тот самый «почти тайл».
Оригинал в бою падает строго вниз.

**Сверка по числам** (лог SDLPoP `DBG shot`, кадры 102..105):

```
SDLPoP:  102 x=155   103 x=157   104 x=159   105 x=160     ← +2 +2 +1 = seq_81
у нас:   102 x=151                                          ← seq_7
```

Обратный счёт: у SDLPoP в момент решения `x = 150`, дальше
`char_dx_forward(-5)` при `dir=-1` даёт `+5` → 155.  У нас решение при
`x = 152` — то есть **по самому правилу срыва расхождения нет**: обе
позиции лежат в колонке 7 (`dx_weight = x + 14`, кадр 157 имеет
`weight_x = 14`; колонка 7 — это `x ∈ [149,163)`).  Двухпиксельная разница
— фаза отхода (шаг отступления `dx(-3)+dx(-2)` = 5 пикселей за цикл), а она
зависит от RNG стража и между движками совпасть не обязана.  «Раньше
времени» — это не срыв не там, а seq_7 вместо seq_81.

**Что подтвердилось попутно.**  Старт уровня 2 у нас **байт в байт** как в
SDLPoP: `frame=15 x=107 y=118 dir=-1 col=3 row=1 room=5`.  Таблицы кадров и
`seqtbl` вынуты из оригинального бинарника, так что расходиться могут
только РЕШЕНИЯ движка — искать надо всегда там.

**Связь с [BUG-LAND-SWORD-1](#bug-land-sword-1).**  Тот фикс (`land()`
даёт `seq_63` при вынутом мече) остаётся — он есть в оригинале, — но для
Кида он теперь почти недостижим: `start_fall` убирает меч в ножны, и после
приземления кадр 109 разбирает обычный `control_crouched`.  То есть
настоящий корень вечного приседа был здесь, а не в `land()`.

**Метод.**  Пустышка `pop_dbg_trap()` в резиденте W1 (идея пользователя):
брейкпоинт на банковый код ставить нельзя — 0xC000+ это окно, куда мапятся
все банки, и точка ловит чужие функции.  Оставлена в `pop_state.c` как
многоразовый инструмент.

---

## BUG-CTRL-FRAME-1. Геометрия Кида считалась по кадру СТРАЖА — **ЗАКРЫТ 2026-08-04**

Самый неприятный класс: не косметика, а неверные РЕШЕНИЯ движка, причём
плавающие — одна и та же поза Кида давала разный результат в зависимости от
того, в какой фазе анимации находится страж.

**Симптом (приёмка уровня 2, комната 4).**  Кид под сплошной плитой (1,6),
справа от него дыра от упавшей loose-плиты.  По ↑ он то прыгает вверх
впустую (упираясь головой в плиту), то пытается зацепиться — но **не с той
координаты X**, с которой запрыгивает оригинал.  Наблюдение пользователя,
оказавшееся точным: «похоже, дело в страже — в комнатах без стража то же
самое рисуется и работает правильно».

**Замер (брейкпоинт на входе `pop_jump_up_seq`, состояние снято в момент
решения).**

```
_Kid        frame=15 (стойка) x=156 y=181 dir=влево curr_col=6 curr_row=2
кадр 15 Кида (kid_data.bin):      dx=0   weight_x=3
pop_gframe (кадр СТРАЖА, image 17): dx=-1 weight_x=8
```

Считаем `dx_weight()` обоими кадрами:

| | кадр Кида (правильно) | кадр стража (что было) |
|---|---|---|
| `dx_weight()` | 156+3 = **159** | 156+9 = **165** |
| `m7()` | (159−7−58)/14 = 6 ост. **10** | (165−7−58)/14 = 7 ост. **2** |
| `curr_col` | 6 | **7** ← намерено |
| `distance_to_edge_weight()` | **10** | **2** |
| ветка `jump_up_or_grab` | 10 ≥ 6 → шаг назад + зацеп | 2 < 6 → `jump_up_plain` |
| результат | x 156 → **160**, `seq_24/8` | x без изменений, **`seq_28`** ← намерено (A=28 на выходе) |

Обе строки «намерено» совпали с предсказанием по кадру стража до единицы —
диагноз подтверждён, а не выведен.

**Корень.**  `cur_frame` (`pop_kid.c:200`) — ОДИН глобал на всех персонажей
(так и в оригинале), его владелец — тот, кто последним прошёл `load_frame`.
В нашем кадре последним тикает страж (`pop_guard_tick`), поэтому к моменту
управления Кидом там лежит кадр стража.  А `kid_cur_dx/dy/flags` читают этот
глобал напрямую, и через него считается ВСЯ геометрия управления:
`dx_weight` → `determine_col` → `distance_to_edge_weight` →
`get_edge_distance` → выбор ветки в `check_jump_up`.

Оригинал страхуется явно и симметрично: `play_kid_frame` (seg000:1209) и
`play_guard_frame` (seg000:1246) сразу после `loadkid`/`loadshad` зовут
**`load_fram_det_col()`** (= `load_frame` + `determine_col`, seg006:0144) —
ДО `control()`.  У нас этого вызова не было; в `pop_ctrl_tick` стоял
комментарий «кадр не перезагружаем — play_seq в kid_tick сделает это
следующим шагом», и он был неверен: `control()` читает `cur_frame` РАНЬШЕ,
чем `play_seq` его обновит.

**Фикс.**  `pop_load_fram_det_col()` (`pop_kid.c`) — порт связки; зовётся
после `pop_loadkid()` в `pop_ctrl_tick` и после `pop_loadshad_and_opp()` в
`pop_guard_tick`.  Вторая половина связки (`determine_col`) выполняется
только на ветке Кида: `determine_col` у нас существует лишь для него —
`pop_map` работает с `Kid` напрямую, а не с абстрактным `Char`.

**Что это объясняет задним числом.**  Плавающее поведение прыжка — кадр
стража меняется каждый тик, вместе с ним `dx`/`weight_x`, и `distance`
Кида скакал через порог 6.  А «голова Кида поверх плиты (1,6)», с которой
начался разбор, — не баг отрисовки: Кид просто оказывался в позе, которой
в оригинале в этом месте не бывает.

**Урок.**  Любой глобал, который в оригинале «принадлежит активному
`Char`», у нас обязан перезагружаться на КАЖДОМ входе в окно `Char` — иначе
между персонажами течёт состояние, и баг проявляется только когда в комнате
есть второй персонаж.  Здесь такой глобал уже ловили однажды: см. запись
про кэш кадра отрисовки в `load_frame` (`pop_kid.c:342`).

---

## BUG-LAND-SWORD-1. Кид навсегда застревает в приседе после падения с мечом — **ЗАКРЫТ 2026-08-04**

**Симптом (приёмка уровня 2).** Страж ударил Кида, Кид потерял HP,
провалился через loose-плиту на этаж ниже — и **сел в присед, из которого
не выходит**: клавиши не действуют вообще.

**Что показало ЖИВОЕ состояние в MAME** (мост `mame-z80`, чтение по
адресам из `roomtest.noi` — окно застало багу в момент):

```
_Kid   frame=109 x=144 y=181 dir=-1 col=6 row=2 action=1 room=4
       sword=2 (ВЫНУТ)  alive=-1  curr_seq=0x2037
_holding_sword=1   _can_guard_see_kid=0   control_x/y/shift = 0
```

`curr_seq = 0x2037` — это внутри метки `softland_crouch`
(`SEQTBL_BASE + 1736 = 0x2036`).  Смотрим `seqtbl.c:916`:

```c
LABEL(softland)                       // seq_17_soft_land
  act(actions_5_bumped), SEQ_KNOCK_DOWN, dx(1), frame_107_fall_land_1,
  dx(2), frame_108_fall_land_2,
  act(actions_1_run_jump), LABEL(softland_crouch) frame_109_crouch,
  jmp(softland_crouch),               // ВЕЧНЫЙ ЦИКЛ на кадре 109
```

То есть `seq_17_soft_land` **не заканчивается сам** — он крутится на кадре
109, и вывести из него может ТОЛЬКО `control_crouched()`.

**Корень.**  `control()` (seg005:252) до `control_crouched` при вынутом
мече не доходит — раньше срабатывает ветка

```c
} else if (Char.sword == sword_2_drawn) {
    control_with_sword();
```

а `control_with_sword` (seg005:964) в мирной обстановке
(`can_guard_see_kid < 2`, под ногами не loose) умеет ровно одно: если кадр
== 171 (стойка с мечом) — убрать меч.  Кадр 109 он не знает.  **Замкнутый
круг: последовательность ждёт control_crouched, а диспетчер туда не
пускает.**

Оригинал такой позы просто не допускает — `land()` (seg005:176):

```c
if (Char.charid >= charid_2_guard || Char.sword == sword_2_drawn) {
    Char.sword = sword_2_drawn;
    seq_id = seq_63_guard_active_after_fall;   // боевая стойка
} else {
    seq_id = seq_17_soft_land;                 // присед
}
```

**У нас этой ветки не было** — `pop_map.c land()` ставил
`SEQ_17_SOFT_LAND` безусловно.  На уровне 1 не всплывало, потому что там
меч подбирается поздно и падать с ним особо негде; на уровне 2 меч у Кида
**с первого кадра** (`have_sword = level >= 2`), а в комнате 4 страж стоит
в одном ряду с двумя loose-плитами — сценарий собирается сам.

**Фикс.**  Порт недостающей ветки: при `Kid.sword == SWORD_2_DRAWN`
падение на один этаж даёт `seq_63` (боевая стойка), а не присед.  Ветка
`charid >= charid_2_guard` нам не нужна — наш `land()` работает только с
Кидом.

**Почему не задело падение на ДВА этажа** (`seq_20_medium_land`): там
`jmp` в конце нет — 29 кадров приседа и автоматический подъём
(`seqtbl.c:934`), поэтому оно развязывается само.  Вечный цикл только у
`softland`.

**Урок на будущее.**  Симптом «персонаж не реагирует на управление» стоит
диагностировать не по кадру, а по `curr_seq`: адрес прямо показывает, в
какой метке `seqtbl` он завис, и дальше видно, кто обязан был его оттуда
вывести.

---

## Проверено в MAME 2026-08-01 (ревизия L1-TRIAGE)

Три бага стояли как **Critical** с 2026-07-21 и по исходникам выглядели
закрытыми, но переподтверждены не были.  Прогон в MAME (roomtest, `ROOMNAV`,
чтение `_Kid` через мост) закрыл все три.

### BUG-1. Боковой переход через ворота: Kid проваливается на row 1 — **ЗАКРЫТ (не воспроизводится)**

**Был симптом:** при проходе через ОТКРЫТЫЕ ворота в соседнюю комнату
(через шов) Kid оказывался на ряду row 1 вместо row 0.

**Проверка 2026-08-01, точный сценарий бага.**  Комната 6, Kid на ряду 0;
осторожный шаг на кнопку (0,2) — решётка room8 (0,9) поднимается; удержание
← через открытый шов:

| момент | `room` | `x` | `y` | `curr_col` | `curr_row` |
|--------|--------|-----|-----|-----------|-----------|
| на кнопке в room6 | 6 | 97 | 55 | 2 | 0 |
| после перехода    | **8** | 181 | **55** | 8 | **0** |

Ряд и Y сохранены, Kid стоит на полу ряда 0 (скриншот `triage_06.png`).
Провала нет.

**Заодно снят и сам диагноз записи** — он был неверен.  В записи стояло:
«Y/`curr_row` при боковом переходе НЕ репроецируются, в отличие от
`check_leave_below`».  Это не дефект, а **точное поведение оригинала**:
`goto_other_room` (`SDLPoP/src/seg002.c:390`) для направлений left/right
меняет ТОЛЬКО `Char.x` (±140), а `Char.y`/`curr_row` трогает исключительно
для up/down.  Наш `check_leave` (`pop_map.c:1402`) делает ровно то же.
Реальной причиной симптома была, судя по всему, кромочная коллизия — её
закрыл `char_x_forward_edge` (см. BUG-SEAM-PINGPONG ниже).

### BUG-2. Возврат из комнаты назад: Kid отбрасывается обратно (ping-pong) — **ЗАКРЫТ (не воспроизводится)**

**Был симптом:** после перехода в соседнюю комнату попытка сразу вернуться
приводила к тому, что Kid снова закидывался в ту же комнату — выйти нельзя.

**Проверка 2026-08-01.**  Шов room2↔room3 (ряд 1, без ворот — чистый
горизонтальный переход), с намеренным разворотом СРАЗУ после пересечения:

| действие | `room` | `x` | `curr_col` |
|----------|--------|-----|-----------|
| старт в room2 | 2 | 86 | 1 |
| держим → | **3** | 122 | 3 |
| сразу держим ← | **2** | 195 | 9 |
| сразу держим → | **3** | 85 | 1 |
| сразу держим ← | **2** | 183 | 8 |

Комната меняется РОВНО один раз на пересечение, туда и обратно, без
осцилляции.  Механизм на месте: `pop_leave_timer` (порт `exit_room_timer`,
`pop_map.c:112,1553`) + `char_x_forward_edge` (`pop_map.c:365`).

### BUG-3. Climb-up на тайл-кнопку: неправильная окклюзия — **ЗАКРЫТ фиксом от 2026-07-28**

**Был симптом:** при подтягивании на тайл, верх которого — кнопка
(opener/closer), Kid рисовался ПОВЕРХ кнопки вместо того, чтобы быть
перекрытым её передней гранью.

**Почему закрыт.**  Запись требовала: «трактовать нажатую кнопку как
floor-тайл в climb-overlay, учесть подстановку из `get_tile_to_draw`».
Ровно это и сделано `tile_code_drawn()` — `climb_overlay_tile`
(`pop_bg.c:1346`) берёт ПОДСТАВЛЕННЫЙ код тайла, а не сырой.  То есть
BUG-3 — дубль пункта «спуск с кнопки (room8, кромка (0,6))» из раздела
«Исправлено», заведённый до фикса.

**Оговорка, чтобы не выдавать желаемое:** покадрово в MAME снимался СПУСК
(кадры 148..138).  Подъём идёт через ту же ветку и ту же таблицу
`FLOOR_LEFT_OVERLAY[fidx]`, поэтому отдельного дефекта тут быть не может,
но визуально направление «вверх» не переснималось.  Если при сквозном
прохождении (L1-PASS) увидишь Kid поверх кнопки на подъёме — заводи заново.

---

## Косметика окклюзии — закрыта кодом, список отставал (сверено 2026-08-01)

Четыре записи от 2026-07-22 висели как открытые Medium.  Каждая описывала
недостающий кусок порта; каждый из них с тех пор написан, но записи никто не
снял.  Ниже — что именно закрывает каждую.

### BUG-CEIL-1. Прыжок вверх: руки Kid рисуются ПОВЕРХ потолка — **ЗАКРЫТ**

**Был симптом:** при прыжке вверх (SEQ up, кадры 67..79) руки/голова Kid
заходили в полосу кладки у потолка (row −1) и рисовались ПОВЕРХ неё.

**Чем закрыт:** `ceil_over_kid_tile()` (`pop_bg.c:1275`) — порт «нижняя грань
потолка и кадр плиты-потолка идут в FOREtable», т.е. поверх персонажа.
Зовётся из `pop_fore_over_kid` (`pop_bg.c:1575`) и из fore-прохода стража
(`:1607`).  Комментарий на месте прямо называет причину: «иначе руки
прыгающего Kid лезут на кромку потолка».

### BUG-CEIL-2. Тряска/разбитие loose-плиты в потолке (row −1) — **ЗАКРЫТ**

**Был симптом:** loose-плита в ряду 2 верхнего соседа (room5 (2,5) → потолок
room6 над (0,5)) при прыжках Kid на (0,5) не тряслась и не разбивалась —
loose-состояние соседней комнаты не тянулось.

**Чем закрыт:** отдельное состояние плиты-потолка `pop_ceil_modif[10]`
(`pop_bg.c:547`, ведёт `pop_map`) + пара `pop_ceil_shake_draw()` /
`pop_ceil_bake_empty()` (`pop_bg.c:815,827`): дрожание рисуется на текущей
странице поверх фона, а провал «запекается» в ОЗУ-копию, чтобы heal его
сохранял.  В полосе у потолка виден только НИЗ плиты (`draw_tile_aboveroom`),
всё выше режется клипом `POP_YOFF` — как в оригинале.

Из этого следует, что и запись «требует персистентного per-room modifier
соседей, это Фаза P0 gates_spikes_plan» больше не верна: понадобился не общий
механизм, а один массив на 10 байт под конкретный случай.

### BUG-CEIL-3. Анимация ворот стирает потолок над ними — **ЗАКРЫТ**

**Был симптом:** при анимации решётки шва полоса кладки у потолка НАД
воротами пропадала — чёрный `bar` по col0 стирал ряд −1, а redraw его не
восстанавливал.

**Чем закрыт:** `pop_room_redraw_seam_left()` (`pop_bg.c:793`) больше не
трогает полосу потолка — `bar` идёт с `POP_YOFF + 3`, а не с `POP_YOFF`:
низ полосы кладки на room-space y=2, бары ворот начинаются с y=3
(`gate_top_y = dby − 62`).  Приятный побочный эффект: отпала необходимость
перерисовывать `draw_tile(-1,0)` на КАЖДОМ кадре анимации решётки — то есть
фикс не только косметический, но и вдвое дешевле прежнего.

### BUG-OCCL-1. Тень дальней колонны перекрывает Kid — **ЗАКРЫТ**

**Был симптом:** Kid у (0,4)-(0,5) частично перекрыт тёмной штриховкой —
боковой гранью ДАЛЬНЕЙ колонны, которая окклюдить персонажа не должна
(over-occlusion fore-слоя, рисовавшего fore футпринт-тайлов без учёта
глубины).

**Чем закрыт:** разделение слоёв по признаку из оригинала, а не по нашим
соображениям о глубине (`overlay_mid_tile`, `pop_bg.c:1367`).  Ключ,
подтверждённый трассой оригинала: `draw_tile_right`, `draw_tile_anim_right` и
`draw_loose` кладут спрайты через `add_backtable` НАПРЯМУЮ, минуя
`ptr_add_table` — значит правая грань левого соседа («шахматка» столба 93,
blueline, грани пик/loose соседа, кадр loose) **всегда** рисуется ПОД
персонажем.  Через `ptr_add_table` (→ midtable при `draw_other_overlay`) идут
только «floor B» 42 при левом соседе-поле, `draw_tile_base`,
`draw_tile_anim` (свои пики) и `draw_tile_bottom`.

---

## BUG-DOOR-CLIP. Подъём по лестнице двери уровня: нет обрезки по правому косяку — **ИСПРАВЛЕН 2026-08-01**

**Был симптом (найден пользователем сразу после L1-EXIT):** при подъёме по
лестнице за дверью уровня (кадры 224..228) силуэт Кида вылезал ПРАВЕЕ правого
косяка проёма.  По высоте обрезка была корректна.

**Причина — недопортированная половина `clip_char`** (seg006:1231).  Для
кадров двери оригинал ставит ДВА клипа, у нас был только первый:

```c
if (frame >= frame_224_exit_stairs_8 && frame < 229) {
    obj_clip_top   = leveldoor_ybottom + 1;
    obj_clip_right = leveldoor_right;
}
```

Отдельная ловушка: комментарий в SDLPoP говорит «frames 217..228», а КОД
проверяет `>= frame_224_exit_stairs_8`, то есть **224..228**.  Портировано по
коду.

**Почему именно обрезка, а не fore-слой:** створка и косяк уходят в оригинале
ЦЕЛИКОМ в backtable (`draw_leveldoor`, все `add_backtable`), то есть рисуются
ПОД персонажем и перекрыть его не могут.  Единственный способ спрятать
поднимающегося — срезать сам спрайт.

**Фикс:**
- libbgi: `gfx_blit_cols_part_w(..., uint8_t maxw)` — обрезка СПРАВА у
  колоночного блита.  Для column-major это ровно уменьшение числа колонок,
  то есть внутри ядра механизм уже был (так же клипается край экрана:
  `w = _bgi_maxx + 1 - x`), наружу не было выведено.  Тело блита переехало
  туда, `gfx_blit_cols_part` стал тонкой обёрткой (maxw=0) — тем же приёмом,
  каким `gfx_blit_cols` уже обёрнут вокруг `gfx_blit_cols_part`.  Работает и
  при flip: первые `maxw` нарисованных колонок всегда ложатся в ЛЕВУЮ часть
  футпринта.
- PoP: `pop_leveldoor_right` / `pop_leveldoor_ybottom` (порт одноимённых
  глобалов) пишет `draw_leveldoor` в `pop_state` — их читает `clip_char` из
  другого банка; `pop_clip_char_right()` отдаёт границу, `kid_draw`
  превращает её в `maxw` и уводит эти кадры с noclip-пути на общий.
  `kid_lw` (прямоугольник heal) тоже сужается — стираем ровно нарисованное.

**Проверено в MAME:** `pop_leveldoor_right = 176` — ровно
`(draw_xh<<3) + 48` для двери комнаты 9; `pop_leveldoor_ybottom` = 112 у
закрытой створки и 69 у поднятой (сходится с формулой оригинала).
Отрисовка подтверждена пользователем на живом подъёме.

---

## BUG-SEAM-PINGPONG (#4). Пинг-понг drawn_room у шва с закрытыми воротами — **РЕШЁН 2026-07-22**

Настоящий корень найден потиковой трассой ЖИВОГО SDLPoP 1.23
(lldb-брейкпоинты на leave_room/bumped/safe_step с логом Char +
char_x_left/right; fixes выключены = vanilla).  Прежние гипотезы оставлены
ниже для истории — они НЕ были причиной.

### КОРЕНЬ (подтверждён трассой + исходником)

`set_char_collision` (seg006:0723): `char_x_right = obj_x/2 + 58`, где
`load_frame_to_obj` (seg008:1728) считает `obj_x = 2*char_dx_forward(dx) - 116`
и **добавляет +1** для кадров «чётного пикселя»:
`if ((sbyte)(cur_frame.flags ^ obj_direction) >= 0) ++obj_x;`
(бит 0x80 флагов кадра XOR направление; вправо: +1 если бит НЕ стоит).
Деление `obj_x/2` — C-усечение К НУЛЮ, поэтому при `e = x+dx <= 57`
(obj_x < 0, зона левого шва) поправка +1 даёт `char_x_right = e+1`, а при
e >= 58 формула сокращается к чистому `e`.

Итог: Kid, осевший после отскока от ворот шва на x=57 (frame15, флаги 0x43 —
бит 0x80 не стоит), имеет **char_x_right = 58** и порога leave-left (<=57)
НЕ достигает.  Наш движок считал передний край как `Kid.x + dx` без поправки
→ 57 → ложный leave → пинг-понг.

Эталонный цикл SDLPoP (нормализовано по трассе): стойка x=61 → тап вправо →
safe_step(d=0) → step, на первом dx(1) x=62 → bump (edge-триггер) → align 61
→ seq47 dx(-4) → **x=57** (скрыт за кромкой) → кадры 50/51/52 (cxr 61/60/58,
у всех бит 0x80 снят, e>57 — без сдвига) → стойка cxr=58 → leave НЕ
срабатывает; тап → safe_step d=3 → x=60 (1/3 видно); тап → step1 → x=61
(2/3 видно); тап → bump → 57 … по кругу.  Char.room и drawn_room НЕ меняются.

### Фикс (pop_map.c)

`char_x_forward_edge()`: `e = char_dx_forward(dx); if (((flags ^ (dir<0 ?
0x80 : 0)) & 0x80) == 0 && e <= 57) e++;` — используется в `char_front_coll`
(коллизия/bump/edge_distance) и в `check_leave` (порог ухода).  Плюс порт
doortop-гарда leave-right из leave_room (тайл (9,row) = doortop → правого
выхода нет).  `pop_leave_timer` (exit_room_timer) оставлен — он реален в
seg002/seg003.

### Симптом (как выглядел)

Kid стоит за решёткой закрытых ворот шва (левый сосед room8 виден в кромке
room6).  При удержании/нажатии ВПРАВО экран пинг-понгует между двумя
состояниями:
- **A**: показывается room6, Kid у левой кромки за решёткой (спрайт на 2/3);
- **B**: показывается room8, Kid у его правой кромки.

Эталон SDLPoP: drawn_room **всегда остаётся room6**, Kid осциллирует у
кромки (1/3→2/3→отступил→по кругу), в room8 экран НЕ переключается.

### Инструментальный диагноз (watchpoint на pop_leave_dir)

В момент лишнего свитча A→B: `pop_leave_dir=1 (LEFT)`, Kid **frame=15 (СТОЯ,
не transient!), Kid.x=57** (до репроекции +140).  То есть:
- `char_x_right = char_dx_forward(kid_cur_dx()) = Kid.x + frame15.dx = 57+0 = 57`.
- Порог leave-left (взгляд вправо): `char_x_right <= 57` → срабатывает РОВНО на 57.
- Грань ворот (где их держит коллизия) = **61** (`wall_dist_from_left[1]=10 +
  coll_tile_left_xpos=51`).  Между 57 и 61 — **зазор 4px**: Kid НЕ удержан
  воротами (d=61−57=4≥0 → check_bumped не бампит), но уже на пороге ухода.
- Kid оседает на 57 из-за recoil отскока: seq_47 = `act(bumped), dx(-4),
  frame_50, 51, 52`; SEQ_DX(−4) двигает Char.x на −4 суммарно; frame_50.dx=4
  компенсирует ТОЛЬКО точку коллизии НА кадре 50, но при возврате в стойку
  (frame15, dx=0) `char_x_right = Char.x = aligned−4 = 57`.

### Что было ИСКЛЮЧЕНО (сверено с исходниками SDLPoP, НЕ причина)

- Формула char_x: `char_x_right = obj_x/2+58 = Char.x+frame.dx` (seg006
  set_char_collision) — совпадает с нашим char_dx_forward.
- Позиция грани ворот: `get_left_wall_xpos = wall_dist_from_left[1](10) +
  xpos_in_drawn_room(x_bump[9+5])+7 = 10+(184−140)+7 = 61` — совпадает с нашим
  (`x_bump[−1+5]=44`, +7, +10 = 61).
- Порог leave: SDLPoP leave_room looking-right `char_x_right<=57` — совпадает.
- Данные кадров: frame_50 (image=49,dx=4,flags=0x67) и frame_15
  (image=14,dx=0,flags=0x43,sword=9) — БАЙТ-В-БАЙТ как в SDLPoP frame_table_kid.
- seq_47 (act bumped, dx(-4), frame 50/51/52) — совпадает.

### Почему точечные фиксы НЕ работали

- Гард в check_leave (подавить leave на закрытых воротах) — это ОТСЕБЯТИНА,
  не SDLPoP (в leave_room такого нет); откачено.
- `exit_room_timer=2` (порт seg002 exit_room — РЕАЛЬНЫЙ механизм, оставлен как
  pop_leave_timer): блокирует leave 2 кадра после входа в комнату.  НЕ спасал:
  положение Char.x=57 **устойчивое** (Kid стоит), а не transient.

### Задел, который НЕ понадобился: порт coll_room + отложенный drawn_room

Трасса показала, что в эталоне `Char.room`/`drawn_room` вообще не меняются,
поэтому план S2/S3 для этого бага не потребовался.  Остаётся заготовкой под
стражей/двух персонажей в кадре:

1. **Раздельные комнаты.**  `Char.room` ≠ `drawn_room`.  Уже есть `kid_room`
   (S1) + рендер-смещение `pop_kid_set_render_dx(∓140)`.
2. **Коллизия по Char.room через coll_room (seg004, S2).**  Портировать
   `check_collisions` → `get_row_collision_data` → `get_left_wall_xpos`/
   `get_right_wall_xpos` → `curr_row_coll_room[]`/`_flags[]`,
   `bump_col_left_of_wall`/`bump_col_right_of_wall` → `check_bumped_look_*` →
   `bumped()`.  Тайлы — из РЕАЛЬНОЙ комнаты колонки, НЕ из снапшота.
3. **Отрисовка по drawn_room**, персонаж со сдвигом ±140.
4. **Отложенная смена drawn_room (seg002/seg000, S3):** `leave_room` →
   `goto_other_room` → `exit_room` (`next_room`) → `check_the_end`.

Реализовано на 2026-07-22: S1 + `pop_leave_timer`.
Память: [[pop_seam_room_model]], [[sdlpop_odd_pixel_char_x]].

---

## Решено НЕ делать

### OPT-1. Хирургический редрой левого шва (ворота соседа) — 2026-07-22

**Возможность:** `pop_room_redraw_seam_left()` (pop_bg.c) на каждое изменение
openness рисует `bar(BLACK)` по всему col0 + **полный `draw_tile(0,0)`**
(стены, `topright`, `wall_pattern` с `prandom()` — десятки блитов).  Реально
анимируются только бары решётки — `draw_gate_back` (~9 `env_b`).

**Стоимость:** seam-блок (синий io_border) занимает ~30-50% кадрового периода,
**но только пока openness меняется** — во время открытия и медленного
авто-закрытия (~5 сек после схода с кнопки).  В покое — 0%.  Замерено в MAME:
брейк на `_pop_room_redraw_seam_left` (0x5A54) срабатывает ⟺ сегмент дорогой.

**Приём** (heal НЕ годится: печёные бары устаревшие, поэтому и стоит
`bar(BLACK)`+redraw): `bar(BLACK)` только по полосе баров (x=0,
gate_top..gate_bot) + `draw_gate_back(lmod,...)` + дорисовать статику тайла
(0,0), задетую полосой.  **Пиксель-чувствительно** — обязательна выверка в
MAME по кромкам.

**Решение:** оставляем как есть — стоимость транзиентная, в бюджет
помещаемся.  Делать, только если упрёмся в кадровый бюджет на сценах с
воротами.

---

## Исправлено

- **Спуск с кнопки (room8, кромка (0,6)): Кид просвечивал в щель, ближняя рука
  срезана до одного пикселя** — ИСПРАВЛЕНО 2026-07-28.  Две причины:
  (а) не был портирован `clip_char()` (seg006:1749) — верхняя обрезка спрайта по
  `y_clip[curr_row+1]`, когда тайл над головой стена/пол; сделано
  (`pop_clip_char_top` в pop_map.c + `gfx_blit_cols_part` в libbgi, heal чистит
  уже обрезанный прямоугольник);
  (б) `climb_overlay_tile` выбирал ветку `draw_floor_overlay` (seg008:1E3A) по
  СЫРОМУ коду тайла — а нажатая кнопка в `get_tile_to_draw` (seg008:240)
  подменяется на floor/stuck.  Тайл-кнопка не проходил тест floor, уходил в
  `draw_other_overlay` и закрашивал Kid ЦЕЛЫМ тайлом вместо узкой кромки
  `floor_left_overlay[frame-137]`.  Фикс — `tile_code_drawn()` (одна подстановка
  на все слои).  Проверено покадрово в MAME (кадры 148..138).  **Этим же
  фиксом закрыт BUG-3** (см. выше).

- **Шов ворот жёг 50% кадра в покое (закрытая решётка)** — ИСПРАВЛЕНО
  2026-07-22.  Причина — **баг кодогенератора SDCC z80** (memory
  `sdcc_z80_cmp_store_a_bug`): `if (m[9] != seam_sig) seam_sig = m[9];`
  компилировался в `sub (seam_sig)` (A ← разность) + `ld (seam_sig),a` —
  сохранял РАЗНОСТЬ `m[9]-seam_sig`, не `m[9]`.  `seam_sig` осциллировала
  (напр. 25↔231), `m[9]!=seam_sig` истинно каждый кадр → `draw_tile(0,0)`
  каждый кадр даже у неподвижной решётки.  Фикс: store-до-сравнения
  (`seam_sig = g;` из чистого `g` ДО `sub`), подтверждён в .asm.  Прочёс всех
  модулей PoP: других случайных compare-then-store нет.

- **Пики: 2×полный `draw_tile` на кадр анимации** → хирургический редрой,
  2026-07-22.  `pop_spike_redraw`: `heal_off` уже возвращает всю печёную
  статику (база 127, пол, грани); поверх анимируются только два острия
  (`SPIKES_FRAM_LEFT` в своей ячейке + `SPIKES_FRAM_RIGHT` в соседней).
  Заменили 2×`draw_tile` на 2×`env_b` — пиксель-в-пиксель тот же результат,
  в разы дешевле (шахта из нескольких пик больше не съедает полный кадр).

- **Отрисовка нажатой кнопки (0,2)/(0,3)** — ИСПРАВЛЕНО.  Причина: `fore_tile`
  (pop_bg.c, fore-слой поверх Kid) рисовал переднюю грань КНОПКИ (bottom_id
  149) поверх уже нарисованной грани пола (43) — «остаток нажатой кнопки».
  Фикс: `fore_tile` применяет ту же подстановку нажатой кнопки, что и
  `draw_tile` (opener→floor / closer→stuck при таймере связи >1).  Плюс
  `pop_button_redraw` — wipe своей ячейки + правой грани (дальний угол в
  0,3), низ строго yb+64 (не залезать в стену ряда 1).

---

## НЕ БАГИ (кривая картинка, но совпадает с оригиналом — НЕ чинить)

- **Кадр падения с мечом: голова/руки поверх кромки пола — САМ РЕНДЕР верен.**
  Сверено с SDLPoP v1.24 (2026-08-04): там кадр падения выглядит один в один.
  ⚠ Речь ТОЛЬКО о виде кадра.  Расхождение по X, которое было отмечено здесь
  как открытое, разобрано и закрыто: [BUG-FALL-SWORD-1](#bug-fall-sword-1) —
  дело не в моменте срыва (он верен), а в том, что мы играли `seq_7` вместо
  `seq_81` и получали горизонтальный дрейф `set_fall(1,15)` за всё падение.

  Механика точки веса, чтобы не разбирать заново.  У стоек с мечом она
  ОГРОМНАЯ:

  ```
  кадр 158/170/171 (stand_with_sword): dx=0  weight_x=13
  кадр 157 (walk_with_sword):          dx=0  weight_x=14
  кадр 15  (обычная стойка):           dx=0  weight_x=3
  ```

  `dx_weight()` = `char_dx_forward(dx − weight_x)`, а при взгляде ВЛЕВО знак
  меняется — точка веса уезжает на 13 px ВПРАВО от `Char.x`.  Замер нашего
  кадра падения: `x=151`, взгляд влево → `dx_weight = 164` → `m7(164)` = колонка
  **7**, а (1,7) — дыра.  `check_on_floor` честно видит «под ногами не пол».
  С обычной стойкой (weight_x=3) вышло бы 154 → колонка 6 → пол.

  То есть с мечом персонаж «стоит» на 10 px правее, чем выглядит, и кромку
  переступает раньше.  Данные кадров у нас совпадают с `frame_table_kid`
  (seg006:127) байт в байт, `swordfight`/`back_with_sword` портированы дословно
  (включая `control_backward = CONTROL_IGNORE` — один нажим = один шаг).
  Чинить нечего.

  **Важное отличие от остальных записей этого раздела.**  Там (труп стража,
  падающая плита) картинка кривая, и совпадает с оригиналом лишь потому, что
  оригинал сам так рисует — артефакт порядка midtable.  **Здесь картинка
  ПРАВИЛЬНАЯ**: Кид реально уже за кромкой, и спрайт перекрывает её ровно
  настолько, насколько персонаж туда зашёл (наблюдение пользователя,
  2026-08-04).  Геометрия и рендер согласованы; «неправильно выглядит» —
  только если считать позой то, что видит глаз, а не точку веса.

  ⚠ **Не путать** с тем, что БЫЛО нашим багом рядом: расширение футпринта
  перерисовки под клинок (`redraw_at_char`, seg003:0430) — его не было, и меч
  оставлял след/лез поверх столба.  Исправлено 2026-08-04.

- **Голова стоящего Кида поверх падающей на него loose-плиты.**  Комната 12:
  зацеп не удался, Кид остался стоять, сбитая плита падает прямо на него —
  голова рисуется ПОВЕРХ плиты.  Сверено покадрово с SDLPoP v1.24 (2026-07-29):
  там ровно то же самое.  Артефакт оригинального движка (порядок midtable), а
  не наш баг; «починка» увела бы от эталона.  Отличать от соседних случаев,
  которые БЫЛИ нашими багами и исправлены: вис/подтягивание на кромке плиты и
  падение вместе с плитой — там плита обязана быть поверх Кида.

- **Ноги стоящего Кида поверх головы лежащего трупа стража.**  Комната 21:
  страж убит, комната покинута и открыта заново (у трупа своя запомненная X),
  Кид СТОИТ на одну колонку правее тела — его ноги рисуются поверх головы.
  Проверено в SDLPoP (2026-08-03): там ровно то же.

  Корень — устройство движка, а не наш порт.  `set_objtile_at_char`
  (seg006:13F3) приписывает персонажа РОВНО ОДНОМУ тайлу, и ширина спрайта на
  выбор тайла не влияет никак; дальше `redraw_needed_tiles` (seg008:1B06)
  обходит тайлы рядами 2,1,0 и колонками 0..9, и кто позже — тот поверх.
  Стоящий Кид укладывается примерно в колонку, поэтому у него это незаметно, а
  лежащий труп занимает две-три колонки, но «принадлежит» левой.  Всё, что
  стоит правее, перекрывает выступающую часть тела.  Механизма «широкий объект
  участвует в нескольких тайлах» в оригинале нет — сверено с
  `draw_objtable_items_at_tile`, `sort_curr_objs` и веткой
  `tile_object_redraw == 0xFF` (последняя про оверлеи пола, не про персонажей).

  Отличать от того, что БЫЛО нашими багами в этом же месте и исправлено
  (BUG-DRAWORDER-1): колонка трупа считалась из тайла вместо X, ветка
  `actions_1_run_jump` не была портирована, окно fore-клипа затиралось стражем.
  Если когда-нибудь захочется «починить» и это — минимальный вариант — брать
  тайл по ЦЕНТРУ габарита, а не по левой границе; но это осознанный отход от
  эталона, и в других позах порядок поменяется в обратную сторону.

- **Комнаты 13, 18, 24 недостижимы в обычной игре** — свойство ДАННЫХ уровня 1,
  не наш баг.  Обход графа от стартовой комнаты (по `res2001.bin`, links @1952)
  показывает: у всех трёх ссылки наружу есть, а на них не ссылается никто
  (24: `L→9`, но у 9 `R=0`; 13 и 18 связаны только друг с другом).  Признак
  «комнату выкинули из компоновки, связи не почистили» — несимметричные ссылки
  ровно у этих трёх, у остальных 21 симметрия полная:

  ```
  13 L→22, у 22 R=16   |  18 L→15, у 15 R=12   |  24 L→9, у 9 R=0
  13 R→16, у 16 L=22   |  18 R→12, у 12 L=15
                       |  18 D→19, у 19 U=12
  ```

  Следствие: в 13/18/24 возможен «мусор в шве» — наш рендер кромки читает
  крайнюю колонку соседа ПО ССЫЛКЕ, а сосед этих комнат соседом себя не
  считает.  Приоритет багов в этих трёх комнатах — низкий (в игре не видно).

---

## Проверено в MAME 2026-08-03 (прогон уровня 1: 11 наблюдений → 6 корней)

Сырой список наблюдений с обхода всех комнат разобран по корням.
Проверка — MAME + мост `mame-z80`: чтение `_Kid` по адресу из
`.sprinter-cc-roomtest/roomtest.noi`, `ROOMNAV` для навигации, потиковые
трассы, скриншоты.

**Оговорка о полноте проверки.**  Каждый корень закрыт тем, что его СОБСТВЕННЫЙ
сценарий больше не воспроизводится; сквозного прохождения уровня и поиска
регрессий в соседней механике автоматика не делала.  Чек-лист для ручной
перепроверки — в [`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).  Пока
поводов для этого нет — заводить обратно только по живому наблюдению.
