diff --git a/applications/PoP/roomtest/TASKS.md b/applications/PoP/roomtest/TASKS.md
index 0836e44..801ce8f 100644
--- a/applications/PoP/roomtest/TASKS.md
+++ b/applications/PoP/roomtest/TASKS.md
@@ -51,7 +51,12 @@
## P0 — делаем сейчас
-### L2-PASS. Приёмка уровня 2
+### L2-PASS. Приёмка уровня 2 — **SMOKE ПРОЙДЕН 2026-08-05**
+
+> **Прогон 2026-08-05 (пользователь): уровень 2 пройден.** Полная приёмка по
+> всем комнатам — позже; из smoke пришли [BUG-LOOSE-3](bug_list.md#bug-loose-3)
+> и [BUG-GUARD-DEAF-1](bug_list.md#bug-guard-deaf-1). Следом прогнан smoke
+> уровня 3 — его наблюдения в [разделе «Уровень 3»](bug_list.md#уровень-3).
Баги отрисовки уровня 2 пользователь подаёт списком отдельно — они идут в
[`bug_list.md`](bug_list.md), раздел «Уровень 2». Ниже — **карта
@@ -105,6 +110,45 @@
закрыт на уровне 1 (L1-EXIT), но на этом уровне не прогонялся;
- **смерть/респавн** возвращают на уровень 2, а не на 1.
+### Связность комнат уровней 1–3 (снято 2026-08-05)
+
+Обход графа `roomlinks` (@1952, по 4 байта на комнату: L, R, U, D; 0 = нет
+соседа) от стартовой комнаты — тем же методом, которым на уровне 1 нашлись
+13/18/24 (см. «НЕ БАГИ» в [`bug_closed.md`](bug_closed.md)). Скрипт разовый,
+в репозиторий не клался: чтение трёх массивов, BFS и проверка симметрии.
+
+| уровень | старт | недостижимы | признак |
+|---------|-------|-------------|---------|
+| 1 | к.1 (0,0) | **13, 18, 24** | ссылки наружу есть, обратных нет |
+| 2 | к.5 (1,3) | **нет** | граф полностью симметричен, все 24 достижимы |
+| 3 | к.9 (2,4) | **23, 24** | то же, что на 1: односторонние ссылки, обе комнаты **полностью пустые** |
+
+```
+ур.3: 23 L→4, у 4 R=22 | 24 L→2, у 2 R=7
+ 23 R→22, у 22 L=4 | 24 R→7, у 7 L=2
+ | 24 U→16, у 16 D=0
+ на 23 ссылается только 24, на 24 — только 23; тайлы обеих = все empty
+```
+
+То есть на уровне 3 это даже более чистый случай, чем на уровне 1: там в
+брошенных комнатах была геометрия, здесь — пустота. Практический вывод тот
+же: **в 23/24 возможен «мусор в шве»** (наш рендер кромки читает крайнюю
+колонку соседа ПО ССЫЛКЕ, а сосед соседом себя не считает), приоритет багов
+там низкий, в игре их не видно.
+
+**Уровень 2 — недостижимых нет, но есть три КОЛОДЦА без выхода** (единственная
+связь — вверх, откуда Кид падает):
+
+```
+к.10 U→4 пики(2,2) + пол — падение из комнаты 4
+к.14 U→21 шахта 2 тайла шириной, дно = обломки
+к.17 U→15 то же
+```
+
+Это не баги данных: в 14/17 попадают только падением насмерть, а из 10
+(если выжил) выхода нет вовсе — так в оригинале. При приёмке не считать
+«застрял» багом.
+
### L3-CHOMP. Чомперы — механика уровня 3
**Где:** 5 штук, комнаты 5(тайл 4), 16(23,24,25), 22(26). Дальше они почти
@@ -576,7 +620,15 @@ BUG-1 (провал на row 1 при переходе через открыты
Условие «делать ПОСЛЕ CLIP-1» **снято** — CLIP-1 закрыт (см. P0). Проверка —
секундомером по одинаковому отрезку рядом с живым SDLPoP, не «на глаз».
-### L1-PASS. Сквозное прохождение уровня 1
+### L1-PASS. Сквозное прохождение уровня 1 — **SMOKE ПРОЙДЕН 2026-08-05**
+
+> **Прогон 2026-08-05 (пользователь): успешный.** От старта до выхода с
+> уровня одним заходом — меч подобран, **оба стража побеждены в честном бою**
+> (без читов), выход отработал корректно. Это smoke: пройдены не все
+> комнаты, поэтому таблица обхода 24 комнат остаётся открытой. Ценность
+> прогона в том, что он снял главные риски этапа 1 разом — боёвка,
+> предметы, переход с уровня — и стал регресс-базой для уровня 2.
+
От старта до двери уровня одним заходом: подбор меча, страж, кнопки/ворота,
пики, loose-полы, зелье, падения. Это приёмка этапа 1 и одновременно
регресс-база для уровня 2.
diff --git a/applications/PoP/roomtest/bug_closed.md b/applications/PoP/roomtest/bug_closed.md
index 0763d10..7a64ff7 100644
--- a/applications/PoP/roomtest/bug_closed.md
+++ b/applications/PoP/roomtest/bug_closed.md
@@ -11,6 +11,99 @@
---
+## 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: Кид с разбегу
diff --git a/applications/PoP/roomtest/bug_list.md b/applications/PoP/roomtest/bug_list.md
index efbbeb2..813c2bb 100644
--- a/applications/PoP/roomtest/bug_list.md
+++ b/applications/PoP/roomtest/bug_list.md
@@ -27,6 +27,8 @@ BUG-GATE-PASS-1 — однократное наблюдение прохода
| ID | что | тип | статус |
|----|-----|-----|--------|
+| [BUG-SPIKE-1](#bug-spike-1) | пробег по выдвинутым пикам не убивает: модификатор застревает в «полностью вышли» (h=1), а это в оригинале не смертельно для бегущего | Major | **открыт, замер есть, заведён 2026-08-05** |
+| [BUG-GUARD-DEAF-1](#bug-guard-deaf-1) | страж не оборачивается на вернувшегося Кида: `is_guard_notice` не взводился | Major | **фикс есть, ждёт игровой проверки** |
| [BUG-GRAB-1](#bug-grab-1) | ур. 2 комн. 9: прыжок с места через 3 тайла — нет зацепа за кромку | Major | **причина найдена (клавиатура, BUG-KBD-5), фикс есть, ждёт игровой проверки** |
| [BUG-GATEMOD-1](#bug-gatemod-1) | ворота стартуют закрытыми, хотя в уровне открыты | Major | **фикс есть, ждёт проверки** |
| [Уровень 2](#уровень-2) | остальные баги с приёмки | — | принимаются по ходу |
@@ -45,6 +47,15 @@ BUG-GATE-PASS-1 — однократное наблюдение прохода
подаст их списком отдельно — раздел заведён под них заранее, чтобы
наблюдения не растеклись по переписке.
+**Закрыто с этой волны:** [BUG-LOOSE-3](bug_closed.md#bug-loose-3) — чёрный
+бар под упавшей плитой-потолком (запечка восстанавливала не всё, что стёрла).
+
+**Порядок работы по уровню 2 (2026-08-05):** наблюдения СНАЧАЛА копятся
+здесь целиком — фиксы берём, когда уровень пройден до конца. Причина:
+косметика отрисовки почти всегда сводится к общему корню (ср. волну
+BUG-CEIL-1/2/3 + BUG-OCCL-1), и чинить по одному наблюдению — значит
+чинить один и тот же корень трижды.
+
Карта содержимого уровня (что где стоит по данным `res2002.bin`, какие
кнопки какие ворота открывают) — в [`TASKS.md`](TASKS.md#l2-pass),
запись **L2-PASS**: по ней видно, «механика не сработала» это или
@@ -56,6 +67,169 @@ BUG-GATE-PASS-1 — однократное наблюдение прохода
вводит ровно три новых фоновых тайла — **большая колонна (низ 8 / верх 9)
и верх двери (12)**; если артефакт рядом с ними, это первый подозреваемый.
+
+# Уровень 3 — баги с приёмки (заведено 2026-08-05)
+
+Smoke-прогон уровня 3 (пользователь, 2026-08-05) дал четыре наблюдения. Два
+из них — **не баги, а неначатые задачи**, и записаны как таковые:
+
+| наблюдение | что это на самом деле |
+|------------|------------------------|
+| к.22: чомпер (2,6) не анимируется и **вообще не рисуется** | [L3-CHOMP](TASKS.md#l3-chomp) — механики чомперов НЕТ. В таблице тайлов `pop_bg.c:211` строка `12 chomper` рисует только основание, правую грань и низ; самих челюстей (спрайт из `chtab`, `draw_tile_anim` seg008) нет вовсе. Так и должно выглядеть до порта |
+| к.10: скелет не оживает | [L3-SKEL](TASKS.md#l3-skel) — `check_skel` (seg002:0E1F) не портирован. **И комната другая:** тайл `skeleton(21)`, который оживает, лежит в **к.1 (1,5)** — это `skeleton_room=1, skeleton_column=5, skeleton_row=1`. Ещё два скелета уровня (к.17 (2,7), к.19 (2,2)) — просто декорация, они не оживают никогда. Плюс условие: скелет встаёт, только когда **дверь уровня уже открыта** и Кид стоит в колонке 2 или 3 |
+
+Остальные два были настоящими багами и **закрыты в тот же день** — разбор
+корней в [`bug_closed.md`](bug_closed.md): **BUG-JUMPWALL-1** (флаги
+коллизий считались для одного ряда вместо трёх — бамп терялся на кадре
+смены ряда) и **BUG-SEAM-WEDGE-1** (сосед угла (2,0) безусловно считался
+стеной).
+
+
+## BUG-SPIKE-1. Пики не убивают бегущего Кида — ОТКРЫТ, ЗАМЕР ЕСТЬ
+
+**Наблюдение (пользователь, 2026-08-05).** Уровень 2, комната 6, пики (1,3):
+
+| действие | что происходит |
+|----------|----------------|
+| длинный прыжок с ряда 0 на пики | **смерть — правильно** (путь `fell_on_spikes`) |
+| пробег по ряду 1 прямо по пикам | **урона нет** |
+| после уборки пик | **на экране остаются белые остатки остриёв** (в оригинале чисто) |
+| прыжок на месте, стоя на пиках | урона нет |
+| просто стоять на выдвинутых пиках | можно сколько угодно |
+
+**Замер (MAME, чтение `room_modif` комнаты 6).** Пока Кид стоит на тайле,
+модификатор пики (индекс 13) = **0x8E**, то есть «пики ПОЛНОСТЬЮ вышли и
+идёт обратный отсчёт». Дальше вся арифметика сходится с оригиналом:
+
+```
+is_spike_harmful (seg007:1178): 0/-1 → 0; <0 → 1; 1..4 → 2; >=5 → 0
+check_spiked (seg006:0658): убивает при h>=2 на кадрах бега 7..14
+ и при h!=0 на кадрах приземления 43/26
+```
+
+То есть **при h = 1 (пики уже вышли) бегущий не гибнет и в оригинале** —
+смертельно только окно ВЫДВИЖЕНИЯ (модификатор 1..4, h = 2). Наши
+`animate_spike`, `start_anim_spike`, `is_spike_harmful`, `check_spiked`
+сверены с seg006/seg007 построчно и совпадают дословно.
+
+**Значит расходится ТАЙМИНГ, а не логика:** у нас пики успевают полностью
+выйти ДО того, как Кид на них вбежит, и он проскакивает мимо смертельного
+окна 1..4. Триггер — `check_spike_below` (seg006:1199), и у нас он берёт
+габарит спрайта из `kid_fp` — метрик ПОСЛЕДНЕГО отрисованного кадра, а
+оригинал берёт `char_x_left/right` ТЕКУЩЕГО кадра (`set_char_collision`).
+Это первый подозреваемый; второй — момент вызова `process_trobs`
+относительно `play_frame` (сколько кадров модификатор успевает пройти,
+пока Кид добегает).
+
+**ВТОРОЕ НАБЛЮДЕНИЕ (то же место, сравнение с оригиналом) — оно и уточняет
+гипотезу.** Кид уронил плиту-потолок и спрыгнул вниз; пики выдвинулись и
+«спрятались не все — часть артефактов осталась». Скриншоты рядом:
+[наш](bugscreens/l2-r6-spikes-ours.png) и
+[SDLPoP](bugscreens/l2-r6-spikes-sdlpop.png) в той же позе. У нас из-под
+щебня торчат белые острия, у оригинала пик не видно ВООБЩЕ.
+
+Это стыкуется с замером: пока Кид стоит рядом, `check_spike_below` каждый
+кадр зовёт `start_anim_spike`, а тот при отрицательном модификаторе
+переставляет его обратно в 0x8F — то есть пики у нас «залипают выдвинутыми»
+и отсчёт до уборки не доходит. В оригинале в той же позе они убраны,
+значит его `check_spike_below` эту колонку УЖЕ не задевает.
+
+**Отсюда единая рабочая гипотеза на оба симптома: наш триггер шире/раньше
+оригинального.** Оригинал берёт габарит ТЕКУЩЕГО кадра
+(`char_x_left/right`, их ставит `set_char_collision` в этом же кадре), мы —
+`kid_fp`, метрики ПОСЛЕДНЕГО ОТРИСОВАННОГО кадра. Если наш диапазон
+колонок хоть на пиксель шире, получаем ровно это: пики выходят до подхода
+Кида (он проскакивает окно 1..4 → не гибнет) и не убираются, пока он рядом
+(→ белые остатки на экране).
+
+**Что снять при разборе:** покадрово `room_modif[13]` вместе с `Kid.x`,
+`Kid.frame` и вычисленными `c0..c1` на подходе и пробеге через тайл; тот же
+прогон в SDLPoP с печатью `left_checked_col`/`right_checked_col`. Сверять
+надо не результат, а ДИАПАЗОН КОЛОНОК кадр в кадр.
+
+**Не регрессия правок 2026-08-05:** ни трёхрядные флаги коллизий, ни
+`is_guard_notice`, ни правки отрисовки в путь пик не входят (`check_spiked`
+сам зовёт `get_tile_at_char`, модификаторы читает из `pop_trob_modif`).
+
+---
+
+
+## BUG-GUARD-DEAF-1. Страж не оборачивается на вернувшегося Кида — ФИКС ЕСТЬ, ЖДЁТ ИГРОВОЙ ПРОВЕРКИ
+
+**Наблюдение (пользователь, 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` (автопилота стража в наборах нет).
+
+**Как проверить в игре:** комната 11 уровня 2, дать стражу вытолкнуть Кида
+вправо в комнату 22 и вернуться бегом — на первом же шаге страж обязан
+обернуться и достать меч. Подкрасться к нему СТОЯ (не двигаясь) по-прежнему
+можно — это механика оригинала, а не баг.
+
+---
+
## BUG-GRAB-1. Прыжок с места через провал в 3 тайла: зацепа нет — **ПРИЧИНА НАЙДЕНА, ФИКС ЕСТЬ, ЖДЁТ ИГРОВОЙ ПРОВЕРКИ**
diff --git a/applications/PoP/roomtest/bugscreens/l2-r6-loose-blackbar.png b/applications/PoP/roomtest/bugscreens/l2-r6-loose-blackbar.png
new file mode 100644
index 0000000..47e2c1b
Binary files /dev/null and b/applications/PoP/roomtest/bugscreens/l2-r6-loose-blackbar.png differ
diff --git a/applications/PoP/roomtest/bugscreens/l2-r6-spikes-ours.png b/applications/PoP/roomtest/bugscreens/l2-r6-spikes-ours.png
new file mode 100644
index 0000000..b5847ef
Binary files /dev/null and b/applications/PoP/roomtest/bugscreens/l2-r6-spikes-ours.png differ
diff --git a/applications/PoP/roomtest/bugscreens/l2-r6-spikes-sdlpop.png b/applications/PoP/roomtest/bugscreens/l2-r6-spikes-sdlpop.png
new file mode 100644
index 0000000..446fae8
Binary files /dev/null and b/applications/PoP/roomtest/bugscreens/l2-r6-spikes-sdlpop.png differ
diff --git a/applications/PoP/roomtest/bugscreens/l3-r18-stray-masonry.png b/applications/PoP/roomtest/bugscreens/l3-r18-stray-masonry.png
new file mode 100644
index 0000000..4f9b178
Binary files /dev/null and b/applications/PoP/roomtest/bugscreens/l3-r18-stray-masonry.png differ
diff --git a/applications/PoP/roomtest/bugscreens/l3-r22-chomper-missing.png b/applications/PoP/roomtest/bugscreens/l3-r22-chomper-missing.png
new file mode 100644
index 0000000..55bab04
Binary files /dev/null and b/applications/PoP/roomtest/bugscreens/l3-r22-chomper-missing.png differ
diff --git a/applications/PoP/roomtest/pop_bg.c b/applications/PoP/roomtest/pop_bg.c
index 426132e..b644299 100644
--- a/applications/PoP/roomtest/pop_bg.c
+++ b/applications/PoP/roomtest/pop_bg.c
@@ -633,7 +633,11 @@ static void draw_tile(int row, int col)
* room_B(0, col-1); col-1<0 → левая комната снизу = стена уровня. */
if (row + 1 > 2) {
int lc = col - 1;
- if (lc < 0) rbl_code = 20; /* кромка уровня = стена */
+ /* lc < 0 — угол (2,0): сосед снизу-слева лежит в ЧУЖОЙ комнате
+ * (room_BL). Её тайл (0,9) приходит одиннадцатым байтом below-ряда;
+ * «стена» там стоит, только если такой комнаты нет — как в
+ * load_rowbelow (seg008:368). См. BUG-SEAM-WEDGE-1. */
+ if (lc < 0) rbl_code = g_below ? (uint8_t)(g_below[10] & 0x1F) : 20;
else rbl_code = g_below ? (uint8_t)(g_below[lc] & 0x1F) : 1;
} else rbl_code = tile_code(row + 1, col - 1);
@@ -843,8 +847,22 @@ void pop_ceil_bake_empty(int col) __banked
gfx_set_bank(GFX_BANK_TRANSPARENT);
g_clip_top = POP_YOFF;
bake_rest = 1; /* фон = покой (см. bake_rest) */
- draw_tile(-1, col); /* тайл уже EMPTY → колодец наверх */
- if (col + 1 < 10) draw_tile(-1, col + 1);
+ /* Восстанавливаем ВСЁ, чья графика попадает в стёртую полосу, а не только
+ * два тайла ряда −1 (BUG-LOOSE-3). Куски тайла рисуются ВВЕРХ от своей
+ * нижней грани и свисают ВПРАВО, поэтому в полосу над тайлом col лезут:
+ * • ряд −1, колонки col−1..col+1 — сама полоса кладки у потолка;
+ * • ряд 0, колонки col−1..col+1 — их верхушки заходят в те же 8 px.
+ * Раньше перерисовывались только (−1,col) и (−1,col+1), и в полосе
+ * оставался чёрный прямоугольник ~13×3 px (замер: экранные y 47..52 при
+ * полосе 44..52), который heal возвращал каждый кадр. Признак, что дело
+ * именно в запечке: после выхода из комнаты и возврата бара нет. */
+ {
+ int c0 = (col > 0) ? col - 1 : 0;
+ int c1 = (col + 1 < 9) ? col + 1 : 9;
+ int c;
+ for (c = c0; c <= c1; c++) draw_tile(-1, c);
+ for (c = c0; c <= c1; c++) draw_tile(0, c);
+ }
bake_rest = 0;
g_clip_top = 0;
gfx_set_bank(GFX_BANK_NORMAL);
diff --git a/applications/PoP/roomtest/pop_bg.h b/applications/PoP/roomtest/pop_bg.h
index 5c1e230..dab1ebf 100644
--- a/applications/PoP/roomtest/pop_bg.h
+++ b/applications/PoP/roomtest/pop_bg.h
@@ -46,9 +46,11 @@ void pop_room_draw(uint8_t room_num,
const uint8_t *fg, const uint8_t *bg,
const uint8_t *lcol_fg, const uint8_t *lcol_bg) __banked;
-/* Задать ряд 0 комнаты СНИЗУ (fg-коды, 10 шт.) — для draw_tile_topright,
- * когда тайл нижнего ряда стал empty (после падения loose): «стена вниз»
- * рисуется по коду room_B(0, col-1). 0 — нет комнаты снизу. */
+/* Задать ряд 0 комнаты СНИЗУ — для draw_tile_topright, когда тайл нижнего
+ * ряда стал empty (после падения loose): «стена вниз» рисуется по коду
+ * room_B(0, col-1). ОДИННАДЦАТЬ байт: [0..9] — колонки комнаты снизу,
+ * [10] — тайл (0,9) комнаты СНИЗУ-СЛЕВА (сосед угла (2,0); см.
+ * load_rowbelow, seg008:368). 0 — нет комнаты снизу. */
void pop_room_set_below(const uint8_t *below_row0_fg) __banked;
/* Задать ряд 2 (fg-коды + модификаторы, 10 шт.) комнаты СВЕРХУ — для полосы
diff --git a/applications/PoP/roomtest/pop_kid.c b/applications/PoP/roomtest/pop_kid.c
index 3694d59..10a64a8 100644
--- a/applications/PoP/roomtest/pop_kid.c
+++ b/applications/PoP/roomtest/pop_kid.c
@@ -423,7 +423,13 @@ void play_seq(void)
case 0xF4: /* KNOCK_DOWN: приземление трясёт loose В СВОЁМ РЯДУ */
knock = -1;
break;
- case 0xF2: /* SOUND — потребить 1 байт */
+ case 0xF2: /* SOUND (seg006:0611): звука у нас нет, но опкод несёт
+ * ВТОРОЙ смысл — «Кид нашумел», и его терять нельзя.
+ * Шумом считаются id 0..2 (SILENT/FOOTSTEP/BUMP); id 0
+ * назван silent потому, что НЕ звучит, а стражи его всё
+ * равно замечают (стоит, например, в seq доставания меча).
+ * DRINK(3)/LEVEL(4) — не шум. См. BUG-GUARD-DEAF-1. */
+ if (SEQ(Char.curr_seq) < 3) is_guard_notice = 1;
Char.curr_seq++;
break;
case 0xF1: /* END_LEVEL: seq_70 доиграл — уровень пройден.
diff --git a/applications/PoP/roomtest/pop_level.c b/applications/PoP/roomtest/pop_level.c
index 0b817b9..23fdca6 100644
--- a/applications/PoP/roomtest/pop_level.c
+++ b/applications/PoP/roomtest/pop_level.c
@@ -210,12 +210,23 @@ int pop_room_load(uint8_t room, uint8_t *fg, uint8_t *bg,
for (i = 0; i < 3; i++) { rcol_fg[i] = 20; rcol_fg[3 + i] = 20; rcol_bg[i] = 0; }
}
- /* belowrow: верхний ряд (row0) комнаты снизу */
+ /* belowrow: верхний ряд (row0) комнаты снизу — ОДИННАДЦАТЬ байт.
+ * [0..9] — колонки 0..9 комнаты снизу; [10] — тайл (0,9) комнаты
+ * ПО ДИАГОНАЛИ СНИЗУ-СЛЕВА (room_BL у оригинала). Одиннадцатый нужен
+ * тайлу (2,0): его «сосед снизу-слева» — это колонка −1 ряда 3, то есть
+ * чужая комната, и load_rowbelow (seg008:368) резолвит её именно так,
+ * с дефолтом «стена» только когда такой комнаты НЕТ. Раньше мы клали
+ * туда стену БЕЗУСЛОВНО, и в пустом углу рисовался клин кладки
+ * (BUG-SEAM-WEDGE-1, ур. 3 комн. 18). */
if (down >= 1 && down <= 24) {
const uint8_t *df = room_fg_ptr(down);
+ uint8_t dl = ((const uint8_t *)(LVL_DATA_OFF + BP_LINKS))[(down - 1) * 4];
for (i = 0; i < 10; i++) below_fg[i] = (uint8_t)(df[i] & 0x1F);
+ below_fg[10] = (dl >= 1 && dl <= 24)
+ ? (uint8_t)(room_fg_ptr(dl)[9] & 0x1F)
+ : 20; /* кромка уровня */
} else {
- for (i = 0; i < 10; i++) below_fg[i] = 20; /* нет комнаты снизу = стена */
+ for (i = 0; i < 11; i++) below_fg[i] = 20; /* нет комнаты снизу = стена */
}
gfx_w0_unmap();
diff --git a/applications/PoP/roomtest/pop_level.h b/applications/PoP/roomtest/pop_level.h
index 0838570..be1c112 100644
--- a/applications/PoP/roomtest/pop_level.h
+++ b/applications/PoP/roomtest/pop_level.h
@@ -63,7 +63,8 @@ uint8_t pop_level_tileset(uint8_t n);
* для коллизии на колонке −2 (Kid стоит В шве);
* rcol_fg[6] симметрично (col0 / col1);
* нет комнаты → стена/0;
- * below_fg[10] — верхний ряд (row0) комнаты СНИЗУ («стена
+ * below_fg[11] — [0..9] верхний ряд (row0) комнаты СНИЗУ,
+ * [10] тайл (0,9) комнаты СНИЗУ-СЛЕВА («стена
* вниз»); нет комнаты → стена.
* 0 — OK, -1 — уровень не загружен / room вне диапазона. */
int pop_room_load(uint8_t room, uint8_t *fg, uint8_t *bg,
diff --git a/applications/PoP/roomtest/pop_map.c b/applications/PoP/roomtest/pop_map.c
index 0064112..392d690 100644
--- a/applications/PoP/roomtest/pop_map.c
+++ b/applications/PoP/roomtest/pop_map.c
@@ -500,6 +500,8 @@ static void land(void)
} else {
seq = SEQ_17_SOFT_LAND;
}
+ /* seg005:185 — мягкое приземление слышно (звука нет, флаг есть). */
+ is_guard_notice = 1;
} else {
uint8_t deadly = (uint8_t)(Kid.fall_y >= 33); /* СНЯТЬ до обнуления */
pop_kid_hurt = 1;
@@ -512,6 +514,7 @@ static void land(void)
pop_kid_dead = 1;
return;
}
+ is_guard_notice = 1; /* seg005:195 */
seq = SEQ_20_MEDIUM_LAND;
}
Kid.fall_x = Kid.fall_y = 0;
@@ -1123,9 +1126,13 @@ static void bumped_fall(void)
if (Kid.action == ACT_FREEFALL) {
Kid.fall_x = 0;
} else {
- kid_set_seq(SEQ_45_BUMPFALL); /* (TODO: bumped_sound) */
+ kid_set_seq(SEQ_45_BUMPFALL);
pop_kid_play();
}
+ /* bumped_sound (seg004:05F1) — ВНЕ ветвления, как в оригинале: удар о
+ * стену слышен и в свободном падении. Звука у нас нет, но «Кид нашумел»
+ * взводить обязаны — см. BUG-GUARD-DEAF-1. */
+ is_guard_notice = 1;
}
/* bumped_floor (seg004:053B): удар о стену, когда под персонажем ПОЛ.
@@ -1173,8 +1180,9 @@ static void bumped_floor(void)
(frame >= 102 && frame < 107))
kid_set_seq(SEQ_46_HARDBUMP);
else
- kid_set_seq(SEQ_47_BUMP); /* (TODO: bumped_sound) */
+ kid_set_seq(SEQ_47_BUMP);
pop_kid_play();
+ is_guard_notice = 1; /* bumped_sound (seg004:05F1) */
}
/* ---- check_collisions (seg004:0004) -------------------------------- *
@@ -1195,13 +1203,22 @@ static void bumped_floor(void)
* Тем же корнем лечится влёт в стену на длинном прыжке (комн. 6) и провал
* сквозь пол после приземления у стены (комн. 5).
*
- * ОТЛИЧИЕ ОТ ОРИГИНАЛА (сознательное): он держит флаги ТРЁХ рядов (curr/
- * above/below), чтобы при смене ряда «прошлые» флаги были настоящими. У
- * нас ряд один: сменился ряд — считаем, что персонаж уже перекрывал всё
- * (prev = 3), то есть на кадре смены ряда бампа нет, а на следующем он
- * посчитается честно. Три ряда — это втрое больше get_tile каждый кадр;
- * если где-то вылезет пропущенный бамп ровно на кадре приземления —
- * вернуться сюда. */
+ * ТРИ РЯДА — как в оригинале (было: один, и это оказалось BUG-JUMPWALL-1).
+ * Оригинал каждый кадр считает флаги для рядов row−1, row, row+1, а
+ * move_coll_to_prev (seg004:00DF) в начале СЛЕДУЮЩЕГО кадра выбирает из них
+ * тот, что соответствует ряду прошлого кадра. Так «прошлые» флаги остаются
+ * настоящими и на кадре смены ряда.
+ *
+ * Наш прежний упрощённый вариант (prev = 3 = «уже перекрывал») подавлял бамп
+ * ровно на этом кадре — а в падении ряд меняется чуть ли не каждый кадр, и
+ * переход 0→1 на стене приходится как раз на него. Симптом: недолетевший
+ * прыжок уходит СКВОЗЬ кладку по свободной траектории (ур. 3 комн. 14).
+ * Цена трёх рядов — 3×14 get_tile на кадр вместо 14; замерено, что это
+ * дешевле любого из обходных вариантов, а поведение совпадает с оригиналом.
+ *
+ * Ряды вне комнаты (−1 и 3) считаются штатно: get_tile сам резолвит ряд −1
+ * через комнату сверху (g_above), а ряд 3 отдаёт WALL — то же «через край
+ * не бампим», что и раньше. */
/* Колонки считаем от −2 до 11, а не 0..9: колонки за швом — это тайлы
* СОСЕДНЕЙ комнаты (get_tile резолвит их через g_lcol/g_rcol), и решётка
* соседа обязана бампить так же, как своя (иначе Kid уходит сквозь
@@ -1215,6 +1232,7 @@ static void bumped_floor(void)
#define COLL_IDX(c) ((c) - COLL_C0)
#define COLL_ROW_NONE ((int8_t)-128)
static uint8_t coll_prev[COLL_N], coll_curr[COLL_N];
+static uint8_t coll_above[COLL_N], coll_below[COLL_N];
static int8_t coll_prev_row = COLL_ROW_NONE; /* ряд, к которому относится coll_prev */
/* «нет колонки»: НЕ −1, потому что −1/−2 — законные колонки шва. */
#define BUMP_NONE ((int8_t)-128)
@@ -1244,6 +1262,32 @@ static void set_char_collision(void)
* колонки от col(char_x_left)−1 до col(char_x_right)+2 и оставляет прочие
* как есть; мы считаем все четырнадцать — они дешёвые (индекс в массив +
* switch), зато не остаётся протухших ячеек (FIX_COLL_FLAGS SDLPoP). */
+/* move_coll_to_prev (seg004:00DF): в prev кладём флаги ряда, в котором
+ * персонаж был НА ПРОШЛОМ кадре. Массивы curr/above/below в этот момент
+ * ещё хранят прошлый кадр, поэтому нужный ряд просто выбирается из них по
+ * разнице рядов. Условия — дословно оригинальные, включая ±3: ряд может
+ * «завернуться» при смене комнаты (2 -> 0 вниз, 0 -> 2 вверх), и тогда
+ * геометрия колонок та же, что у текущего ряда.
+ *
+ * Первый кадр (и кадр после смены комнаты, pop_coll_invalidate) прошлого
+ * ряда не имеет: prev = «уже перекрывал всё», бампа на нём нет. */
+static void move_coll_to_prev(int8_t row)
+{
+ const uint8_t *src;
+ int8_t i;
+ if (coll_prev_row == COLL_ROW_NONE) {
+ for (i = 0; i < COLL_N; i++) coll_prev[i] = 3;
+ return;
+ }
+ if (row == coll_prev_row || row + 3 == coll_prev_row || row - 3 == coll_prev_row)
+ src = coll_curr; /* ряд не менялся */
+ else if (row + 1 == coll_prev_row || row - 2 == coll_prev_row)
+ src = coll_above; /* поднялся на ряд */
+ else
+ src = coll_below; /* опустился на ряд */
+ for (i = 0; i < COLL_N; i++) coll_prev[i] = src[i];
+}
+
static void get_row_collision_data(int8_t row, uint8_t *flags)
{
int8_t col;
@@ -1271,14 +1315,13 @@ static void check_collisions(void)
if (Kid.curr_row < 0 || Kid.curr_row > 2) { coll_prev_row = COLL_ROW_NONE; return; }
set_char_collision();
- /* move_coll_to_prev (seg004:00DF) в нашем однорядном варианте. */
- if (coll_prev_row == Kid.curr_row) {
- for (col = 0; col < COLL_N; col++) coll_prev[col] = coll_curr[col];
- } else {
- for (col = 0; col < COLL_N; col++) coll_prev[col] = 3; /* «уже перекрывал» */
- }
+ move_coll_to_prev(Kid.curr_row);
coll_prev_row = Kid.curr_row;
- get_row_collision_data(Kid.curr_row, coll_curr);
+ /* Порядок важен: prev уже забран, теперь три ряда пересчитываются
+ * НА ЭТОТ кадр (в оригинале ровно так же, seg004:0004). */
+ get_row_collision_data(Kid.curr_row, coll_curr);
+ get_row_collision_data((int8_t)(Kid.curr_row + 1), coll_below);
+ get_row_collision_data((int8_t)(Kid.curr_row - 1), coll_above);
/* Обход СВЕРХУ ВНИЗ, как в оригинале (9→0): побеждает МЛАДШАЯ колонка,
* в которой флаг перешёл 0→1. */
for (col = COLL_C0 + COLL_N - 1; col >= COLL_C0; --col) {
@@ -1418,10 +1461,13 @@ static void check_press(void)
int8_t c = Kid.curr_col;
if (g_above && g_link_u && c >= 0 && c <= 9 &&
(g_above[c] & 0x1F) == TILE_LOOSE && !(g_above[c] & 0x20) &&
- (int8_t)pop_ceil_modif[c] <= 0)
+ (int8_t)pop_ceil_modif[c] <= 0) {
pop_ceil_modif[c] = 1; /* make_loose_fall(1) */
+ is_guard_notice = 1; /* seg006:1734 */
+ }
} else if (get_tile_above_char() == TILE_LOOSE) {
make_loose_fall((Kid.curr_row - 1) * 10 + Kid.curr_col, 1);
+ is_guard_notice = 1; /* seg006:1734 */
}
return;
}
@@ -1435,8 +1481,10 @@ static void check_press(void)
uint8_t tp = (uint8_t)(Kid.curr_row * 10 + Kid.curr_col);
if (t == TILE_OPENER || t == TILE_CLOSER)
pop_trigger_button(g_room, tp, t, pop_trob_modif(g_room)[tp]);
- else if (t == TILE_LOOSE)
+ else if (t == TILE_LOOSE) {
make_loose_fall(tp, 1);
+ is_guard_notice = 1; /* seg006:1734 */
+ }
}
}
diff --git a/applications/PoP/roomtest/pop_trob.c b/applications/PoP/roomtest/pop_trob.c
index 42fda36..9b7421f 100644
--- a/applications/PoP/roomtest/pop_trob.c
+++ b/applications/PoP/roomtest/pop_trob.c
@@ -13,6 +13,7 @@
* пометки, поэтому pop_process_trobs — единственная функция модуля, которую
* нельзя звать из __banked-кода. */
#include "pop_bg.h"
+#include "pop_guard.h" /* is_guard_notice — нажатая кнопка слышна стражу */
/* Коды тайлов (fg & 0x1F), которые анимируются. */
#define TILE_SPIKE 0x02
@@ -284,8 +285,12 @@ void pop_trigger_button(uint8_t room, uint8_t tilepos,
uint8_t link_timer = get_doorlink_timer(modifier);
if (link_timer != 0x1F) { /* не заклинено */
set_doorlink_timer(modifier, 5);
- if (link_timer < 2)
- pop_add_trob(room, tilepos, 1); /* кнопка нажимается (+ TODO sound) */
+ if (link_timer < 2) {
+ pop_add_trob(room, tilepos, 1); /* кнопка нажимается */
+ /* seg007:766: щелчок кнопки — шум, страж оборачивается. Звука у
+ * нас нет, флаг обязан быть (BUG-GUARD-DEAF-1). */
+ is_guard_notice = 1;
+ }
do_trigger_list(modifier, button_type);
}
}
diff --git a/applications/PoP/roomtest/roomtest.c b/applications/PoP/roomtest/roomtest.c
index d5a6b58..c56a037 100644
--- a/applications/PoP/roomtest/roomtest.c
+++ b/applications/PoP/roomtest/roomtest.c
@@ -117,7 +117,7 @@ static uint8_t room_fg[30], room_bg[30];
/* lcol_fg/rcol_fg — ШЕСТЬ записей: [0..2] кромочная колонка соседа (col9/col0),
* [3..5] следующая за ней (col8/col1) для коллизии, когда Kid стоит В шве и
* смотрит наружу (колонка −2 / 11). См. pop_level.h. */
-static uint8_t lcol_fg[6], lcol_bg[3], rcol_fg[6], rcol_bg[3], below_fg[10];
+static uint8_t lcol_fg[6], lcol_bg[3], rcol_fg[6], rcol_bg[3], below_fg[11];
static uint8_t above_fg[10], above_mod[10]; /* ряд 2 верхнего соседа (полоса кладки у потолка) */
static uint8_t cur_room; /* номер ОТРИСОВАННОЙ комнаты (drawn_room) */
static uint8_t kid_room; /* РЕАЛЬНАЯ комната Kid (S1: == cur_room; расходятся в S3 straddle) */
diff --git a/applications/PoP/roomtest/tests-host/Makefile b/applications/PoP/roomtest/tests-host/Makefile
index 3bad08c..2b6cc06 100644
--- a/applications/PoP/roomtest/tests-host/Makefile
+++ b/applications/PoP/roomtest/tests-host/Makefile
@@ -25,6 +25,8 @@ OBJS_phys := build/eng_pop_geom.rel build/eng_pop_kid.rel \
# Пробный набор (разведка, без эталонов) линкуется тем же составом.
OBJS_grab := $(OBJS_phys)
+# t_wall — удар о стену в воздухе (BUG-JUMPWALL-1), состав тот же.
+OBJS_wall := $(OBJS_phys)
include $(TESTKIT)/host-tests.mk
diff --git a/applications/PoP/roomtest/tests-host/stubs.c b/applications/PoP/roomtest/tests-host/stubs.c
index 0e84a06..2c37846 100644
--- a/applications/PoP/roomtest/tests-host/stubs.c
+++ b/applications/PoP/roomtest/tests-host/stubs.c
@@ -194,3 +194,7 @@ kframe pop_gframe;
int8_t can_guard_see_kid;
int8_t guard_refrac, kid_sword_strike;
int8_t offguard, holding_sword;
+/* «Кид нашумел» — физика взводит его на ударе о стену, приземлении и
+ * обрушенной плите (BUG-GUARD-DEAF-1); читает только автопилот стража,
+ * которого в наборе нет. */
+int8_t is_guard_notice;
diff --git a/applications/PoP/roomtest/tests-host/t_wall.c b/applications/PoP/roomtest/tests-host/t_wall.c
new file mode 100644
index 0000000..8086372
--- /dev/null
+++ b/applications/PoP/roomtest/tests-host/t_wall.c
@@ -0,0 +1,95 @@
+/*
+ * t_wall.c — удар о стену В ВОЗДУХЕ (BUG-JUMPWALL-1).
+ *
+ * Наблюдение с приёмки уровня 3 (комната 14): Кид прыгает, не долетает и
+ * вместо удара о кладку с последующим сползанием ВДОЛЬ неё пролетает
+ * сквозь стену «по свободной траектории». Класс отказа шире одной
+ * комнаты, поэтому проверяем его отдельным набором, а не сценарием в
+ * t_phys: там характеризация (трасса == эталон), здесь — утверждение о
+ * поведении («сквозь кладку нельзя»), которое не должно молча
+ * переснашиваться вместе с эталонами.
+ *
+ * Оригинал: bumped_fall (seg004:04E4) на ударе гасит `Char.fall_x`, то есть
+ * горизонтальный снос обнуляется и дальше Кид падает вертикально. Кто
+ * решает, что удар был, — check_collisions (seg004:0004) + check_bumped.
+ *
+ * Проверка — свипом по стартовому X, а не одним прыжком: исход зависит от
+ * фазы, и «с одной X работает» ничего не доказывает (ровно так и вышло с
+ * первым сценарием — из центра плиты стена держит).
+ */
+#include "tcheck.h"
+#include "scene.h"
+#include "stubs.h"
+#include "pop_kid.h"
+#include "pop_map.h"
+
+#define E 0
+#define F 1
+#define W 20
+
+/* Комната 14 уровня 3 с одной правкой: шахте колонок 6–7 подложено дно
+ * (ряд 2), потому что соседних комнат у сцены нет, а в игре Кид улетает
+ * вниз в комнату 13. Остальное — как в данных: площадка (0,8), провал
+ * (0,6)/(0,7), СТЕНА в колонке 5 рядов 1–2, площадка (0,5) за ней. */
+static const uint8_t room14[30] = {
+ F, F, F, E, E, F, E, E, F, E,
+ W, W, W, E, E, W, E, E, W, E,
+ W, W, W, F, F, W, F, F, W, W,
+};
+
+/* Плита (0,8) по X: колонка 8 — это [177, 191), плюс шов с пустой (0,9). */
+#define X_LO 177
+#define X_HI 196
+
+/* Прыгнуть влево со стартовой X. Возврат — код исхода:
+ * 'L' — долетел до площадки (0,5) и стоит на ней (ряд 0) — законно;
+ * '.' — не долетел и упал на дно шахты колонок 6–7 — тоже законно;
+ * цифра — колонка, в которой Кид оказался ЛЕВЕЕ стены, то есть прошёл
+ * сквозь кладку колонки 5 (это и есть баг). */
+static char jump_left_from(uint8_t x)
+{
+ sc_room(room14, 14);
+ sc_kid_at_x(8, 0, x, -1 /* лицом влево */);
+ sc_trace_clear();
+ /* Клавиши держим только на сам прыжок: если удерживать их всю сцену,
+ * Кид, ДОЛЕТЕВШИЙ до (0,5), тут же прыгает с неё второй раз — и исход
+ * говорит уже не о стене, а о втором прыжке. */
+ sc_run(SC_L | SC_U, 6);
+ sc_run(0, 34);
+ if (!sc_len) return '?';
+ {
+ int8_t col = sc_trace[sc_len - 1].col;
+ int8_t row = sc_trace[sc_len - 1].row;
+ if (row == 0) return 'L'; /* остался на верхнем ряду */
+ if (col >= 6) return '.'; /* сполз вдоль стены в шахту */
+ return (char)('0' + (col < 0 ? 0 : col));
+ }
+}
+
+static void put(char c)
+{
+ if (tc_result.logn < TC_LOGSZ) tc_result.log[tc_result.logn++] = (uint8_t)c;
+}
+static void puts_(const char *s) { while (*s) put(*s++); }
+
+TC_TEST(wall_stops_undershot_jump)
+{
+ uint8_t x, bad = 0;
+ /* Карта исходов по всей ширине плиты: 'L' — долетел до (0,5), '.' —
+ * сполз вдоль стены в шахту, цифра — просочился сквозь кладку. */
+ puts_("x=177..196: ");
+ for (x = X_LO; x <= X_HI; x++) {
+ char r = jump_left_from(x);
+ put(r);
+ if (r != 'L' && r != '.') bad++;
+ }
+ put('\n');
+ TC_EQ(bad, 0);
+}
+
+int main(void)
+{
+ sc_init();
+ TC_RUN(wall_stops_undershot_jump);
+ return 0;
+}