Files
Sprinter-SDCC/applications/PoP/roomtest/bug_closed.md
T
Александр Петров dc8b2b7115 Приёмка ур. 2–3: сквозь стену, клин в шве, бар под плитой, глухой страж
Четыре разбора с прогонов пользователя; три бага закрыты, четвёртый
(BUG-SPIKE-1, пики) заведён с замером и гипотезой.

BUG-JUMPWALL-1 (Critical) — недолетевший прыжок проходил СКВОЗЬ стену.
check_collisions держал флаги перекрытия ОДНОГО ряда, а оригинал
(seg004:0004) — трёх, и move_coll_to_prev (seg004:00DF) берёт «прошлые»
флаги из нужного.  Наш prev=3 («уже перекрывал») подавлял бамп ровно на
кадре смены ряда, а в падении ряд меняется почти каждый кадр — переход 0→1
на стене приходился как раз на него.  Порт трёх рядов дословно.
Воспроизведено и закрыто на харнессе (новый набор t_wall: свип по 20
стартовым X, 4 давали проход сквозь кладку); 1723 трассы t_phys НЕ
изменились — правка поведение-сохраняющая.  Живьём подтвердил пользователь.

BUG-GUARD-DEAF-1 (Major) — is_guard_notice не взводился НИГДЕ, поэтому
неактивный страж не оборачивался на Кида за спиной никогда.  Портированы
все пять мест оригинала: опкод SOUND в play_seq (звуки 0..2), bumped_sound,
мягкое/среднее приземление, обрушенная плита, щелчок кнопки.  Ждёт
игровой проверки боем в комнате 11 уровня 2.

BUG-SEAM-WEDGE-1 — клин кладки в пустом (2,0).  Сосед угла снизу-слева
лежит в комнате по диагонали (room_BL); мы безусловно считали его стеной,
оригинал (load_rowbelow, seg008:368) — только когда такой комнаты нет.
Ряд «снизу» стал 11-байтным: [10] = тайл (0,9) диагональной комнаты.

BUG-LOOSE-3 — чёрный бар под упавшей плитой-потолком.  pop_ceil_bake_empty
стирал полосу и восстанавливал только два тайла ряда −1, а в полосу лезет
графика соседа слева и верхушки ряда 0.  Теперь перерисовываются ряды −1 и
0, колонки col−1..col+1.  Проверено попиксельной сверкой с эталонной
перерисовкой: 0 различий.

Плюс карта связности комнат уровней 1–3 (TASKS.md): на ур. 2 недостижимых
нет, на ур. 3 это 23 и 24 — те же односторонние ссылки, что дали 13/18/24
на уровне 1, только комнаты полностью пустые.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:29:37 +03:00

96 KiB
Raw Blame History

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

Сюда переезжает всё, что закрыто: подтверждённые фиксы, снятые диагнозы, осознанные решения «не делать». Открытые баги — в bug_list.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 — комната 14 уровня 3 с подложенным дном шахты, свип по всей ширине стартовой плиты (x = 177..196). Из 20 стартовых позиций 4 давали проход сквозь кладку (Кид оказывался в колонке 4 при стене в колонке 5). Первый вариант сценария (одна стартовая X, из центра плиты) БАГА НЕ ПОКАЗАЛ — отсюда свип.

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

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


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

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

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

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

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


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

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

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

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

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

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

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


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

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

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

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

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 (комната с провалом в колонках 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 показала, что наш порт пропустил ТРИ вещи из оригинала, и все три бьют именно по этому сценарию:

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;
    }

У нас все они падали в общий elseseq_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. Тот фикс (land() даёт seq_63 при вынутом мече) остаётся — он есть в оригинале, — но для Кида он теперь почти недостижим: start_fall убирает меч в ножны, и после приземления кадр 109 разбирает обычный control_crouched. То есть настоящий корень вечного приседа был здесь, а не в land().

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


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

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

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

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

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

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

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

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

Корень. cur_frame (pop_kid.c:200) — ОДИН глобал на всех персонажей (так и в оригинале), его владелец — тот, кто последним прошёл load_frame. В нашем кадре последним тикает страж (pop_guard_tick), поэтому к моменту управления Кидом там лежит кадр стража. А kid_cur_dx/dy/flags читают этот глобал напрямую, и через него считается ВСЯ геометрия управления: dx_weightdetermine_coldistance_to_edge_weightget_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:

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 при вынутом мече не доходит — раньше срабатывает ветка

} 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):

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). Для кадров двери оригинал ставит ДВА клипа, у нас был только первый:

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

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

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

Фикс:

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

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


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

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

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

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

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

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

Фикс (pop_map.c)

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

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

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

  • A: показывается room6, Kid у левой кромки за решёткой (спрайт на 2/3);
  • B: показывается room8, Kid у его правой кромки.

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

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

В момент лишнего свитча A→B: pop_leave_dir=1 (LEFT), Kid frame=15 (СТОЯ, не transient!), Kid.x=57 (до репроекции +140). То есть:

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

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

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

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

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

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

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

  1. Раздельные комнаты. Char.roomdrawn_room. Уже есть kid_room (S1) + рендер-смещение pop_kid_set_render_dx(∓140).
  2. Коллизия по Char.room через coll_room (seg004, S2). Портировать check_collisionsget_row_collision_dataget_left_wall_xpos/ get_right_wall_xposcurr_row_coll_room[]/_flags[], bump_col_left_of_wall/bump_col_right_of_wallcheck_bumped_look_*bumped(). Тайлы — из РЕАЛЬНОЙ комнаты колонки, НЕ из снапшота.
  3. Отрисовка по drawn_room, персонаж со сдвигом ±140.
  4. Отложенная смена drawn_room (seg002/seg000, S3): leave_roomgoto_other_roomexit_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 — дело не в моменте срыва (он верен), а в том, что мы играли 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 = 164m7(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, раздел «Ручная перепроверка фиксов». Особое внимание — порту 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 (вечер)

BUG-KBD-5. Зажатый Shift снимается автоповтором стрелки — ЗАКРЫТ 2026-08-05

Симптом (пользователь). Прыжок с места с зацепом (уровень 2, комната 9) не выходит: Кид прыгает, но за кромку не цепляется. Дальше уточнения, которые и указали на клавиатуру, а не на физику:

  • «отпускаю стрелки в полёте и жму Shift один — зацеп есть»;
  • «жму Shift не сразу, а когда бо́льшая часть прыжка позади — зацеп есть»;
  • «зажимаю Shift, потом стрелки — прыжок есть, зацепа нет»;
  • в SDLPoP та же комбинация в том же порядке работает всегда.

Обобщение: Shift работал в одиночку и не работал вместе со стрелками. Ровно то же ломало и осторожный шаг — игрок держит Shift, тапает стрелку, а Кид на каком-то тапе уходит в бег (это же поведение раньше описывалось как BUG-KBD-4, см. ниже).

Замер (MAME, roomtest, чтение карты _kbdraw_down + breakpoint на выходе из in a,($18) в декодере трамплина).

  1. Зажать LShift → байт 2 карты = 04 (LSh взведён), поток 12 12 12 … (Shift автоповторяется сам, пока он последняя нажатая клавиша).

  2. Добавить ↑ → байт 46 = 20 (↑ взведена), байт 2 = 00 — Shift снят, хотя физически зажат.

  3. Добавить ещё → байт 46 = 30 (обе стрелки видны, 3-key rollover в порядке), Shift по-прежнему 00.

  4. КОРОТКИЙ тап ↑ при зажатом Shift — Shift выживает. Это и сбивало с толку: бит сносился, но тут же восстанавливался, потому что после отпускания стрелки Shift снова становился «последней клавишей» и его автоповтор 12 взводил бит обратно за ~30 мс.

  5. Сам поток при зажатом Shift и зажатой ↑:

    … E0 E0 E0 75  E0 75  E0 75      ← ни одного F0/12
    

Причина. Декодеры (_irq_tramp.c, kbd_raw_poll.c) делали из «fake shift» ДВА вывода, и второй был неверен:

  • обёртка E0 F0 12 / E0 12 есть → Shift зажат → взвести бит ✔ верно;
  • расширенный make без обёртки → Shift отпущен → снять биты обоих шифтов ✘ неверно.

Обратный вывод опирался на «клавиатура обёртывает КАЖДЫЙ расширенный код». Замер это опровергает: клавиатура MAME-Sprinter (pc_kbd ms_naturl) обёртку не шлёт вовсе, и уж точно её не бывает на typematic-повторах — а повторы идут непрерывно, пока стрелка зажата. Значит каждый повтор снимал реально зажатый Shift, и к кадрам 102…106 (окно check_grab) движок видел Shift отпущенным. Отсюда и «работает, если нажать Shift позже»: бит успевал постоять несколько кадров до ближайшего повтора стрелки.

Фикс. Обратный вывод убран целиком — расширенная клавиша идёт обычным путём и о состоянии Shift не судит. Прямой вывод оставлен (обёртка, если клавиатура её всё-таки шлёт, подтверждает «Shift зажат» и стоит дёшево). Состояние Shift теперь ведут его собственные make/break 12 / F0 12 — они приходят всегда. Заодно ушла ставшая ненужной переменная _kbdraw_fakesh, и оба декодера стали короче (трамплину это на пользу: его клавиатурный блок упирается в диапазон jr).

Файлы: libc/irq/_irq_tramp.c, libc/kbd/kbd_raw_poll.c, libc/kbd/_kbdraw.h, libc/kbd/_kbdraw_state.c, libc/kbd/kbd_raw_sync.c.

Чем платим. Потерянный при Rx-overrun break Shift снять теперь нечем — модификатор может залипнуть до перенажатия (это старый BUG-KBD-3). Размен осознанный и решён в ту же сторону, что и раньше в kbd_raw_sync: лучше залипание, чем отвал — залипший Shift игрок снимает нажатием Shift, а сорванный посреди игры Shift в PoP стоит жизни. Вероятность overrun'а сильно снижена дренажом FIFO опросом из главного цикла (kbd_raw_poll, KBD-1).

Проверено в MAME после фикса (карта _kbdraw_down при roomtest):

действие LSh (байт 2) стрелки (байт 46)
зажать LShift 04 00
+ зажать ↑ и → 04 30
держать 5 с (автоповтор идёт) 04 30
отпустить Shift, стрелки держать 00 30
отпустить стрелки 00 00

make size-check: 70 программ, роста нет.

Осталось наблюдением, не багом этой задачи. В карте изредка остаётся взведённым бит в PLAIN-половине для кода стрелки (напр. байт 14 бит 4 = 0x74 «Right без E0»). Это потерянный префикс E0 — след старого рассинхрона FIFO. Игру не задевает (движок читает KBD_RIGHT = EXT|0x74, то есть расширенную половину), но если всплывёт — искать здесь.


BUG-KBD-4 (+ BUG-KBD-3). Shift: сначала залипал, потом стал отваливаться — ЗАКРЫТ (с поправкой, см. BUG-KBD-5)

Поправка 2026-08-05. Вывод «клавиатура обёртывает КАЖДЫЙ расширенный код, пока зажат Shift» оказался неверным, и построенный на нём обратный вывод («расширенный make без обёртки ⇒ Shift отпущен») убран — он ломал Shift вместе со стрелками. Разбор — BUG-KBD-5. Проверка «пять тапов стрелки подряд» ниже проходила не потому, что вывод был верен, а потому что между тапами бит восстанавливал автоповтор самого Shift. Актуальное поведение kbd_raw_sync — вариант 1 (модификаторы не сбрасываем).

Симптом (вторая редакция). Залипание ушло, но появилось обратное: при зажатом Shift второй-третий-четвёртый тап стрелки отрабатывал уже не осторожным шагом, а бегом. Для PoP это ХУЖЕ залипания: игрок рассчитывает на короткий шаг, а Кид убегает в яму или на пики.

Почему обе прежние редакции были неправильны. Это был размен между двумя способами «починить» потерю байта при Rx-overrun SIO (FIFO 3 байта):

  1. исключать модификаторы из сброса — потерянный break Shift снять нечем, Shift залипает навсегда (BUG-KBD-3);
  2. сбрасывать всю карту, как DSS (KBD_Receiver_Overrun в KEYINTER.ASM чистит и KEYCTRL, и KEY_FLG) — залипания нет, но Shift сносится каждым overrun'ом (BUG-KBD-4).

Причина, по которой вариант 2 бил так часто, замерена: при зажатом Shift клавиатура обёртывает КАЖДЫЙ расширенный код служебной парой, поэтому тап стрелки — это не 5 байт, а 10, и overrun почти гарантирован.

Что нашлось (замер в MAME, watchpoint на порт данных SIO + чтение карты _kbdraw_down). Эта самая обёртка — «fake shift» — и есть решение, а не помеха. Клавиатура шлёт E0 F0 12 перед расширенным make и E0 12 после его break, и шлёт ТОЛЬКО пока Shift реально зажат. Проверено обоими шифтами: правый обёртывается своим кодом 0x59 (bit 1 байта 11 карты), левый — 0x12 (bit 2 байта 2). Это непрерывное и прямое свидетельство реального состояния Shift — единственное доступное, потому что опросить состояние у PS/2 нельзя, а typematic повторяет только ПОСЛЕДНЮЮ нажатую клавишу, то есть стрелку, а не Shift.

Фикс. Оба декодера (libc/irq/_irq_tramp.c, libc/kbd/kbd_raw_poll.c) читают обёртку в обе стороны:

  • увидели fake shift → реальный Shift ЗАЖАТ → взвести plain-бит (бит кладёт общий писатель: достаточно обнулить префиксы, и он попадёт в plain-половину карты как make; в расширенную половину не пишем — это не клавиша);
  • расширенный make БЕЗ предшествующей обёртки → Shift ОТПУЩЕН → снять plain-биты обоих шифтов.

kbd_raw_sync вернулся к исключению модификаторов — теперь это безопасно: состояние Shift подтверждается независимо от того, что съел overrun, а залипание снимается первым же нажатием стрелки.

Побочно найдена и исправлена своя ошибка в новом коде kbd_raw_poll: после bit 1, a в A лежал pending, а не скан-код, — обычные (нерасширенные) клавиши декодировались бы из мусора. Оба декодера приведены к одной логике.

Раскладка трамплина. Клавиатурный блок перевалил за 127 байт, а jp внутри трамплина запрещён (код копируется в W2 побайтно, абсолютные само-ссылки сломают копию). Поэтому префиксные обработчики переехали вплотную к своим cp, а посередине тела стоят три ретранслятора (tr_kbd_hub, tr_hub_notkbd, tr_hub_dss) — до них дотягиваются jr и сверху, и снизу. В kbd_raw_poll такого ограничения нет, там три перехода стали jp.

Проверено в MAME:

  • Shift зажат, пять тапов стрелки подряд → LSh остаётся 04 во всех пяти, ovr = 0, расширенная половина карты чистая;
  • штатное отпускание Shift → LSh = 00;
  • искусственно залипший Shift (бит записан в карту отладчиком) → снимается ПЕРВЫМ же тапом стрелки;
  • make size-check: 70 программ, роста нет.

BUG-RESPAWN-2. После respawn стражи остаются мёртвыми — ЗАКРЫТ

Вопрос из отчёта: «после respawn — должны ли оживать стражники?» Ответ по SDLPoP: да. Цикл play_level (seg003:57) на КАЖДОЙ итерации — в том числе после смерти Кида — делает load_level(), следом pos_guards() (seg003:83), а затем Guard.charid = charid_2_guard; Guard.direction = dir_56_none. То есть стражи — такая же часть данных уровня, как тайлы, и перезагрузка файла возвращает их всех.

Корень у нас. gstate_init() (живая копия таблицы стражей: тайл, направление, мастерство, поза трупа) вызывался ТОЛЬКО при загрузке уровня. Рестарт возвращал тайлы (pop_level_reset_tiles), но не стражей: в gstate оставался сохранённый leave_guard'ом seq_hi != 0, и pop_guard_enter поднимал стража трупом с guardhp_curr = 0.

Фикс. pop_level_reset_guards() (тот же gstate_init) + pop_guard_reset() в pop_start_level, рядом с pop_level_reset_tiles(). Туда же уехал pop_loose_mob_reset() — настоящий mobs_count = 0 из start_level.

Проверено в MAME: комната 21, страж жив (alive = -1, hp 3) → убит читом K (alive = 0, hp 0) → Кид убит стражем → респавн → возврат в комнату 21: страж снова alive = -1, hp 3.

BUG-DRAWORDER-1. Кид рисовался поверх тела стража — ЗАКРЫТ

Симптом. В оригинале Кид проходит ЗА телом убитого стража; у нас — перед ним.

Три разных корня, найденные по очереди. Стоит того, чтобы перечислить: два первых захода были неполны, и каждый следующий кадр от тестера вскрывал новый слой.

  1. Порядок задавался ролью, а не тайлом. Мы безусловно рисовали pop_guard_draw()kid_draw(), то есть Кид был сверху всегда. В оригинале никто не «поверх» по определению: оба персонажа попадают в midtable при обработке СВОЕГО тайла (set_objtile_at_char, seg006:13F3), а тайлы обходятся строго — redraw_needed_tiles (seg008:1B06) идёт рядами 2,1,0 и внутри ряда колонками 0..9; кто позже, тот поверх. Совпали тайлы — решает sort_curr_objs (seg008:203C): ниже по obj_y = позже. Фикс: guard_over_kid() в roomtest.c.

  2. Своя же регрессия: Кид полез поверх передних столбов. Окно fore-клипа (pop_fore_set_clip) одно на всех, и его ставит каждый, кто рисует персонажа. Пока страж рисовался строго ДО Кида, к моменту pop_fore_over_kid в окне оказывался Кид и всё сходилось само. Как только порядок стал переменным, при «страж поверх» окно оставалось СТРАЖЬИМ, и fore-проход Кида отсекался целиком. Фикс: kid_fore_clip_restore() — окно восстанавливается явно, а не «по счастливому порядку вызовов».

  3. Колонка трупа была нулевой. leave_guard сохраняет тайл как get_tilepos(0, row), то есть колонку 0 всегда, а enter_guard у нас брал curr_col оттуда и следом перетирал X запомненным значением. В памяти это было видно прямо: col=0 при x=95. Оригинал (seg002:180) выводит колонку ИЗ X: Char.curr_col = get_tile_div_mod_m7(Char.x). У живого стража незаметно (X там сама считается из колонки), у запомненного ТРУПА — ломало порядок. Колонка нужна не только отрисовке: на неё смотрят коллизия и check_can_guard_see_kid.

  4. Ветка actions_1_run_jump оказалась не «упрощаемой». Сначала я решил, что её можно не портировать. Кадр в движении показал ACT=1: в беге оригинал берёт тайл не из curr_row/curr_col, а из нижнего ряда и ЛЕВОЙ колонки ГАБАРИТА (char_bottom_row/char_col_left из set_char_collision, seg006:0723). При беге вправо левая колонка меньше curr_col примерно на тайл — потому бегущий Кид и уходит за объекты справа. Отсюда «некоторые кадры бега рисовали Кида поверх тела». Считается для ОБОИХ персонажей: enter_guard ставит action = 1 и стражу, односторонний учёт дал бы перекос в другую сторону. Тонкость: char_col_left берётся по НЕ утоньшённой границе — поправку THIN оригинал применяет только к *_coll.

Проверено в MAME: после возврата в комнату у трупа col=5 при x=135 (было col=0); Кид слева от тела — и стоя, и в кадре бега — рисуется за телом; передние столбы снова перекрывают Кида.

Остаток, который НЕ баг. Ноги стоящего Кида поверх головы трупа, когда он стоит колонкой правее тела, — врождённое свойство движка (один спрайт = один тайл при спрайте шире колонки). Сверено в SDLPoP: там так же. Подробности — в разделе «НЕ БАГИ» выше.

BUG-LOOSE-2. Осколки только от одной из двух падающих плит — ЗАКРЫТ

Симптом. Комната 12, соседние падающие плиты (0,1) и (0,2): после пробежки осколки появляются только на (1,2), на (1,1) — нет. В комнате 7 (плиты 0,5/0,6 → 2,5/2,6) обе дают осколки.

Корень. Гипотеза автора отчёта подтвердилась чтением кода: Кид уходит в комнату 15 раньше, чем долетает плита (0,1). Наш падающий кусок был привязан к ТЕКУЩЕЙ комнате — pop_loose_reset() на смене комнаты звал pop_loose_mob_reset() и гасил всё, что ещё летит, а тайл под куском читался из карты отрисованной комнаты. Кусок уничтожался, loose_land не случался, щебень не клался. В комнате 7 обе плиты успевают сесть до ухода — потому там и выглядело правильно.

В оригинале mobs[] чистится ТОЛЬКО в start_level (seg003:88); do_mobs (seg007:1063) прокручивает все куски независимо от drawn_room, move_loose работает по curmob.room, а redraw_at_cur_mob (seg007:132C) сверяется с drawn_room лишь для ОТРИСОВКИ.

Фикс. У куска появилось поле room (curmob.room). На смене комнаты зовётся pop_loose_mob_room_changed() — сбрасывает только heal-историю, а полёт продолжается. Тайл читается через mob_tile_at() (своя комната — tile_code, чужая — pop_level_tile). Приземление в покинутой комнате пишет щебень прямо в данные уровня: сигнальная пара pop_loose_landed/ pop_debris_at обслуживает только текущую комнату. Отрисовка и check_loose_fall_on_kid (там оригинал начинает с Char.room == curmob.room) — только для своей комнаты. Настоящий mobs_count = 0 переехал в pop_start_level.

Проверено вручную (2026-08-03). Автоматикой гонку воспроизвести не удалось: мост MAME шлёт нажатия рывками, и «уйти из комнаты раньше, чем долетит плита» через него не набирается. Подтверждено живым прогоном.

Это ровно тот случай, ради которого заведены host-тесты: на уровне логики проверка занимает несколько строк — заспавнить кусок, сменить комнату, тикать до приземления, проверить щебень в данных уровня. Стоит первым в ../docs/host_tests_plan.md.

BUG-SEAM-DRAW-1. Кид, упершийся в решётку на шве, не рисуется — ЗАКРЫТ (не воспроизводится)

Как было заведено. Комната 5: Кид добегает до закрытой решётки (0,9), решётка его держит, но экран переключается на комнату 1, и в ней Кида не видно — он стоит на x = 57..60, левее кромки комнаты (col 0 начинается с 58).

Проверка 2026-08-03. Не воспроизводится: Кид упирается и встаёт на x = 61, curr_col = -1, room = 1 — и в комнате 1 РИСУЕТСЯ, за решёткой, как и должен.

Почему сходится: при x = 61 персонаж уже внутри системы координат комнаты 1 (obj_x = 2*61 116 = 6), поэтому никакого straddle-смещения не требуется и обычная отрисовка справляется. Отрицательная curr_col тут не противоречие — это правило get_tile_div_mod_m7: (61 7 58) / 14 = 1.

Что осталось за скобками. Само переключение экрана штатное: leave_room (seg002:423) уводит вправо при char_x_right >= 201, а левая грань решётки как раз 201 — оригинал в этой позе тоже перелистнёт.

И остаётся теоретический остаток: при x <= 60 (obj_x <= 4) спрайт уходил бы за левую кромку и обрезался. В текущей сборке Кид туда не встаёт, поэтому баг и не воспроизводится. Полное лекарство — довести модель straddle (S3 в ../docs/room_model_plan.md): держать Kid.room отдельно от drawn_room и рисовать со смещением ±140, как xpos_in_drawn_room (seg004:0405). Пока поводов для этого нет — заводить обратно только по живому наблюдению.