# roomtest — архив закрытых багов Сюда переезжает всё, что **закрыто**: подтверждённые фиксы, снятые диагнозы, осознанные решения «не делать». Открытые баги — в [`bug_list.md`](bug_list.md), текущие задачи — в [`TASKS.md`](TASKS.md). Файл существует не ради истории как таковой: половина записей ниже — это разбор КОРНЯ (odd-pixel арифметика `char_x`, подстановка тайла нажатой кнопки, баг кодогенератора SDCC), и он экономит часы, когда похожий симптом всплывёт снова. Прежде чем заводить новый баг — грепни здесь по симптому. --- ## Проверено в 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). --- ## НЕ БАГИ (кривая картинка, но совпадает с оригиналом — НЕ чинить) - **Голова стоящего Кида поверх падающей на него loose-плиты.** Комната 12: зацеп не удался, Кид остался стоять, сбитая плита падает прямо на него — голова рисуется ПОВЕРХ плиты. Сверено покадрово с SDLPoP v1.24 (2026-07-29): там ровно то же самое. Артефакт оригинального движка (порядок midtable), а не наш баг; «починка» увела бы от эталона. Отличать от соседних случаев, которые БЫЛИ нашими багами и исправлены: вис/подтягивание на кромке плиты и падение вместе с плитой — там плита обязана быть поверх Кида. - **Комнаты 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 возможен «мусор в шве» — наш рендер кромки читает крайнюю колонку соседа ПО ССЫЛКЕ, а сосед этих комнат соседом себя не считает. Приоритет багов в этих трёх комнатах — низкий (в игре не видно).