Files
Sprinter-SDCC/applications/PoP/roomtest/BUGS_OPEN.md
T
snark13 b49ba15666 DIED-ON-BUTTON: найдена вторая половина — физика трупа выключена целиком
Кид гибнет на кнопке открытия решётки (уровень 7, комната 3, тайл 2,1) —
в оригинале решётка открыта насовсем, у нас отжимается.  Мало того, что
died_on_button (seg007:776) не портирован: pop_phys_tick целиком выходит
по pop_kid_dead, так что check_press до трупа не доходит вовсе.  У
оригинала play_kid_frame гейтится только Char.room != 0.

Фикса пока нет — запись в BUGS_OPEN.

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

67 KiB
Raw Blame History

roomtest — ОТКРЫТЫЕ баги и незакрытые оптимизации

Здесь ТОЛЬКО незакрытое. Всё закрытое (и, что важнее, разбор корней) живёт в BUGS_CLOSED.md — прежде чем заводить новый баг, грепни там по симптому. Сырые формулировки пользователя с прогонов — bugs_level1.md / bugs_level2.md.

Приоритеты работ — в TASKS_OPEN.md (закрытые задачи с протоколами — TASKS_CLOSED.md), а не здесь. Правило проекта: механику сверять с ../SDLPoP/src/ ДО кодинга.

Ревизия 2026-08-11: файл вычищен от закрытых записей (правило «в _OPEN только открытое»). Уровни 1-4 приняты smoke-тестами; крупных багов нет.

ID что тип статус
SPRITE-ZERO-W0 кадр персонажа изредка отдаёт спрайт 0x0 (залипание уже вылечено) редкий открыт: нужна трасса маппинга W0
GATE-FORE-KID Кид в проёме ворот виден поверх решётки окклюзия фикс есть, ждёт проверки в MAME (2026-08-13)
FORE-DUP передний слой тайла рисуется дважды при перекрытии объектов оптимизация открыт: сначала замерить, потом чинить
DIED-ON-BUTTON died_on_button (seg007:776) не портирован порт открыт
TORCH-ANIM-RIGHT под запечённым пламенем застывают не только челюсти чомпера низкий открыт: на уровнях 1-4 такого соседства нет
T-1 пики перерисовываются безусловно оптимизация открыт
BUG-SPIKE-1 пики залипают выдвинутыми рядом с Кидом низкий маловоспроизводим: ни сценарием, ни попиксельной подгонкой X не поднимается
BUG-CHOMP-JUMP-1 прыжок с места вплотную к чомперу: кадр с отступом назад низкий маловоспроизводим: на повторе не поднялся; на пререлиз
GRAB-KBD-TIMING зацеп в падении срабатывает менее стабильно, чем в оригинале после всех уровней открыт: подозрение на клавиатурный модуль, не на физику
GUARD-ENTRY-DEATH вход в комнату вплотную к стражу = мгновенная смерть (не портирован bump_into_opponent) порт фикс есть, ждёт проверки в MAME
LOOSE-ROOM-CHANGE расшатанная плита не доваливается, если Кид ушёл в соседнюю комнату порт закрыт 2026-08-13, кроме LOOSE-SEAM-ANIM
JUMP-FLOOR-LEGS в прыжке с пола ноги Кида рисуются ПОВЕРХ кромки пола, а должны за ней окклюзия корень найден и починен, ждёт проверки в MAME
MOB-CLIP-RIGHT окна и узор кладки рисуются ПОВЕРХ падающих плит окклюзия открыт: корень найден 2026-08-13
MID-OVERLAY-LAYER оверлей кромки всегда поверх плит и персонажей (у оригинала — midtable с сортировкой) слои открыт: нужен разбор архитектуры
GUARD-FALLOUT-VICTORY страж, выпавший из комнаты, не засчитывается убитым порт открыт: две строки
LOOSE-SEAM-ANIM плита в шве (col −1), уроненная в прошлой комнате, исчезает без анимации падения мелкий открыт по решению пользователя: «пока пусть будет так»

GRAB-KBD-TIMING. Зацеп в падении срабатывает НЕ ТАК СТАБИЛЬНО, как в оригинале — РАЗБОР ПОСЛЕ ВСЕХ УРОВНЕЙ

Решение пользователя (2026-08-12): отложено до готовности всех уровней. Механика работает, это вопрос ощущения, а не проходимости.

Наблюдение (пользователь). Вход на уровень 7: Кид влетает в комнату сверху и, пролетая мимо края пола, МОЖЕТ за него зацепиться — но получается заметно реже, чем в оригинале. «Похоже, это проблемы нашего клавиатурного модуля».

Что уже сделано и почему это НЕ закрывает вопрос. В ходе разбора GRAB-BELOW-ROOM восстановлена ветка ACT_MIDAIR в check_action (кадры 102..105 — начало падения, где fall_y ещё не разогнан): раньше на этих четырёх кадрах check_grab не звался вовсе, то есть окно зацепа было короче оригинального. Это должно было добавить стабильности, но саму гипотезу про клавиатуру не проверяет.

Куда смотреть, когда дойдут руки. Порядок именно такой — сначала измерить, потом чинить:

  1. Снять, ЧТО видит движок в момент падения: pop_ctrl_shift_held() покадрово (Shift зажат заранее — читается ли он каждый кадр, или FIFO SIO отдаёт состояние с пропусками; см. memory kbd_raw_fifo_drain, kbd_overrun_wipe_modifiers — оба прошлых бага были именно про модификаторы).
  2. Сверить с оригиналом ширину окна по кадрам: у нас check_grab доступен на 102..105 (midair) + весь ACT_FREEFALL, как в seg006 — расхождений в коде быть не должно, значит расхождение либо во вводе, либо в темпе игры (L1-SPEED: игра идёт на ~39 % быстрее оригинала, а окно зацепа отмеряется В КАДРАХ — то есть по времени оно у нас короче).
  3. Хост-тест t_grab.c уже умеет мерить ширину окна (карта исходов по фазе X и задержке нажатия) — на нём и проверять «стало стабильнее».

Протокол замера (написан 2026-08-12, чтобы следующий заход начался с фактов)

Пользователь связывает с этим же и «двойные срабатывания не вовремя». Прежде чем трогать драйвер — доказать, что он вообще виноват: сегодняшний случай «Кид ходит как с зажатым Shift» оказался артефактом MCP-моста, а не драйвера (pause освободил 6 незакрытых входов, которые плагин переустанавливал каждый кадр; как только они отпустились, драйвер снял бит сам). То есть break-коды драйвер обрабатывает верно, и ложная тревога здесь стоила часа.

Что мерить:

  1. Поток скан-кодов: кольцевой лог в трамплине прерывания (libc/irq/_irq_tramp.c / kbd_raw_poll.c) — байт кода, флаг make/break, номер кадра. Читать из MAME по адресу буфера (mem), как читаем _Kid.
  2. Что увидел движок: pop_ctrl_shift_held() и битмап _kbdraw_down (адрес — из .map, для roomtest 0xAEF9) на тех же кадрах. Расхождение «код пришёл, а движок не увидел» = потеря в драйвере; «кода не было, а бит стоит» = потеря break (то, что подозревает пользователь).
  3. Сцены: бег с отпусканием, разворот, вис + отпускание Shift, падение с зажатым заранее Shift (вход на уровень 7), быстрое повторное нажатие (проверка «двойного срабатывания» — typematic, см. memory kbd_overrun_wipe_modifiers).

Что считать нормой: каждому make ровно один break; между make и видимым kbd_raw_down() — не больше кадра; при удержании typematic-повторы НЕ должны доходить до pop_ctrl как новые нажатия (диспетчер ждёт фронт).

Отдельная гипотеза, которую замер тоже закроет: окно зацепа отмеряется В КАДРАХ, а игра идёт на ~39 % быстрее оригинала (L1-SPEED) — тогда «менее стабильно» это не клавиатура, а темп, и чинить надо темп.


С приёмки уровня 2 (2026-08-07)

Всё найденное тем прогоном закрыто, кроме двух записей ниже (BUG-SPIKE-1 — уровень 2, комната 6). Карта содержимого уровня 2 (что где стоит по res2002.bin, какие кнопки какие ворота открывают) — в TASKS_CLOSED.md, по ней видно, «механика не сработала» это или «так и задумано».

BUG-SPIKE-1. Пики залипают выдвинутыми — НИЗКИЙ ПРИОРИТЕТ, МАЛОВОСПРОИЗВОДИМ

Статус 2026-08-05, вечер. Пользователь повторить не смог, а замер (ниже) показал, что чистый пробег по убранным пикам убивает штатно. То есть в смертельности бага, похоже, нет вовсе: наблюдался частный случай — пики, УЖЕ выдвинутые полностью (h = 1), для бегущего безвредны и в оригинале. Запись оставлена открытой ровно из-за визуального расхождения со скриншотом SDLPoP (у нас острия торчат, у него убраны) — см. конец.

Как получить состояние нарочно: подойти к пикам вплотную (выдвинутся), отступить на полшага, НЕ выходя из зоны срабатывания, и пробежать по ним. Пользователь пробовал и это, и попиксельную подгонку X читом [/] — не поднялось. Вывод для будущего разбора: состояние не чисто позиционное, одной шириной габарита его не объяснить; следующий подозреваемый — момент, в который process_trobs застаёт модификатор относительно кадра Кида.

Наблюдение (пользователь, 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 построчно и совпадают дословно.

ВТОРОЕ НАБЛЮДЕНИЕ (то же место, сравнение с оригиналом). Кид уронил плиту-потолок и спрыгнул вниз; пики выдвинулись и «спрятались не все — часть артефактов осталась». Скриншоты рядом: наш и SDLPoP в той же позе. У нас из-под щебня торчат белые острия, у оригинала пик не видно ВООБЩЕ.

ГИПОТЕЗА «ТРИГГЕР СРАБАТЫВАЕТ РАНО» ПРОВЕРЕНА И ОПРОВЕРГНУТА (замер 2026-08-05, MAME, watchpoint на room_modif[13] комнаты 6). Чистый пробег по УБРАННЫМ пикам убивает штатно:

запись modif=1 : кадр 11 (беговой), x=112, curr_col=2   ← пики пошли вверх
запись modif=2 : кадр 12 (беговой), x=117, curr_col=3   ← Кид уже НА тайле, h=2
запись modif=3 : кадр 177 (frame_177_spiked)            ← напоролся

То есть check_spike_below, check_spiked, is_spike_harmful и тайминг выдвижения работают правильно, и «раннего» триггера нет.

Настоящий корень — пики ЗАЛИПАЮТ выдвинутыми. Пока габарит Кида накрывает колонку пики, check_spike_below каждый кадр зовёт start_anim_spike, а тот при отрицательном модификаторе переставляет его обратно в 0x8F — отсчёт до уборки не доходит. А выдвинутые пики (h = 1) для бегущего БЕЗВРЕДНЫ по правилам оригинала. Отсюда обе жалобы: пробег по уже вышедшим пикам не убивает, и они же остаются торчать на экране.

Что осталось выяснить (и это единственный открытый вопрос). Код start_anim_spike у нас с оригиналом совпадает дословно, значит оригинал тоже удерживал бы пики, стой Кид там же. На скриншоте SDLPoP в похожей позе пики УБРАНЫ — то есть его Кид стоит чуть левее и его габарит колонку пики уже не задевает. Разница в 2–3 пикселя посадки, а у нас такие расхождения по X уже ловились (см. заметку в BUG-GRAB-1: после касания площадки SDLPoP уводит Кида на 134, мы — на 141).

Как закрывать: инструментировать SDLPoP (печать char_x_left/right, left/right_checked_col и модификатора пики каждый кадр), проиграть ту же сцену — падение плиты-потолка в комнате 6 и остановку на щебне — и сверить с нашей трассой ПОЗИЦИЮ КИДА после приземления. Если позиции совпадут, а диапазоны колонок разойдутся — виноват габарит (kid_fp против set_char_collision текущего кадра); если разойдутся позиции — это отдельный баг посадки, а пики — его следствие.



Оптимизация отрисовки (записано 2026-07-29)

Не баги — план работ. Оба пункта про одно: у оригинала пометка тайла к перерисовке стоит копейки (бит в таблице, которая всё равно чистится каждый кадр), а у нас каждая такая пометка превращается в реальный heal (копию из ОЗУ-копии акселератора) плюс блиты. Поэтому буквальный порт «перерисовываем безусловно» корректен, но дорог.

T-1. Пики: перерисовывать по причине, а не безусловно

Сейчас: pop_process_trobs зовёт pop_spike_redraw каждый кадр для каждой живой пики в комнате (порт redraw_21h, который animate_spike вызывает вне всяких if). Это корректно, но лишнее для пик, до которых Киду дела нет.

Надо: перерисовывать тайл пики, только если

  1. сменился её видимый кадр (шаг выдвижения/уборки), ЛИБО
  2. её кто-то стёр — а стереть у нас может только heal, то есть тайл попал в прямоугольник kid_heal этого кадра.

Это и есть модель оригинала, просто выраженная флагами: redraw_at_char (seg003:0576) каждый кадр помечает set_redraw_fore тайлы персонажа, причём объединение текущего и предыдущего прямоугольника (MIN(char_top_row, prev_char_top_row) и т.д.), а animate_spike помечает свой тайл. Итог = {тайл сменил кадр} ∪ {тайлы Кида}.

Как: слой Кида и так считает cL..cR/rT..rB в pop_fore_over_kid — пусть публикует их (плюс предыдущие, как в оригинале), а цикл trob'ов сравнивает tilepos с диапазоном целочисленно. Никаких пересечений прямоугольников (см. память manual_hints_over_auto_detect).

Приоритет: отдаётся почти бесплатно ПОСЛЕ T-2, отдельно не окупается.


BUG-CHOMP-JUMP-1. Прыжок с места вплотную к чомперу: кадр с отступом назад — НИЗКИЙ ПРИОРИТЕТ, МАЛОВОСПРОИЗВОДИМ

Статус 2026-08-08. Наблюдение пользователя на приёмке L3-CHOMP: Кид стоит вплотную СЛЕВА от чомпера лицом вправо, прыжок с места — в анимации проскакивает кадр, где он «чуть отступил назад», и только потом идёт прыжок. В оригинале прыжок идёт прямо с места. На повторе в тот же заход не воспроизвёлся — отложено до пререлиза.

Что уже установлено (чтобы не разбирать заново).

  1. Это НЕ анимация. seq_3_standing_jump (SDLPoP seqtbl.c:382) состоит только из положительных смещений: act(run_jump), f16, f17, dx(2) f18…f22, dx(7) f23, dx(9) f24, dx(5) dy(-6) f25. Ни одного отрицательного dx — отката в последовательности нет вовсе. Значит отступ даёт bumped(), то есть физика посчитала въезд в препятствие и выровняла Char.x назад.

  2. is_obstacle для чомпера у нас совпадает с оригиналом (seg004:037E): препятствие только при modif == 2, причём именно == 2, БЕЗ маски 0x7F — то есть окровавленный чомпер (0x82) в ванили не бампит вовсе. Проверено, расхождения нет.

  3. Прямая трасса прыжка отката НЕ показала. Ввод «UP и RIGHT одновременно» (mame bridge, key UP 24 + key RIGHT 24) даёт чистое движение вперёд: x 148 -> 155, кадр становится 178 (перемололо). Ни одного кадра с уменьшением x.

Главная гипотеза — ПОРЯДОК НАЖАТИЙ. Если ↑ приходит на кадр раньше →, то control_standing уходит не в standing_jump(), а в up_pressed() — вертикальный прыжок с зацепом, а он выравнивает Char.x. Отсюда и «отступил, потом прыгнул». Проверять надо check_jump_up / jump_up_or_grab / grab_up_no_floor_behind (seg005:0836 и далее), а не прыжок с места. Внимание на can_climb_up (seg005:0828): там у чомпера ЕСТЬ спецкейс (seq_73_climb_up_to_closed_gate при взгляде ВПРАВО) — он у нас портирован (pop_map.c, ветка TILE_MIRROR || TILE_CHOMPER), но именно вокруг него и стоит копать.

Как ловить. Брейкпоинт на резидентном _kid_tick с {printf "f=%d x=%d act=%d col=%d",b@Kid+0,b@Kid+1,b@Kid+6,b@Kid+4; g} — одна строка на кадр, адрес _Kid из .sprinter-cc-roomtest/roomtest.map (после КАЖДОЙ пересборки другой). Плюс стоп-кадр 1 в момент отступа и чтение Kid из памяти. Метод — memory z80_profiling_method.


Заметки (отладка)

  • Тестовые клавиши осторожного шага: J = шаг влево, L = шаг вправо (эмуляция Shift+стрелка), см. pop_ctrl.c KBD_DBG_STEP*. Первый шаг в сторону = разворот (как в оригинале safe_step), движение со второго.
  • Читы (pop_cheat.h): K — убить стража, I — бессмертие (toggle), S — выдать меч, Shift+L — следующий уровень, [/] — сдвиг Кида на пиксель по X.
  • Респавн после смерти — по (или авто через RESPAWN_DELAY); ставит Kid в стартовую позицию УРОВНЯ (pop_start_level, порт do_startpos).
  • ROOMNAV (=/-) — тоже наш чит, которого в оригинале не было, как и S. Все они со временем съедутся в общий блок читов, разрешаемый в настройках; пока просто включены (pop_cheats = 1 в roomtest.c). Известный баг этого чита закрыт — BUG-CHEAT-FIGHT-1.
  • Комнаты 13, 18, 24 уровня 1 недостижимы в обычной игре — это свойство данных уровня (разбор — «НЕ БАГИ» в BUGS_CLOSED.md); приоритет багов в них низкий. Аналогично 23/24 на уровне 3.

DIED-ON-BUTTON. died_on_button (seg007:776) не портирован

Обнаружено при разборе BUG-LOOSE-BUTTON-1. check_press (seg006:1707) разбирает ЛЮБОГО мёртвого Char, не только Кида:

if (curr_tile2 == tiles_15_opener || curr_tile2 == tiles_6_closer) {
    if (Char.alive < 0) trigger_button(1, 0, -1);   /* жив */
    else                died_on_button();           /* мёртв */
}

died_on_button на opener делает тайл обычным полом и форсирует button_type = tiles_14_debris — ворота открываются насовсем; на любой другой кнопке ставит tiles_5_stuck (заклинена, link_timer == 0x1F, связь мертва).

У Кида эффект живёт до перезапуска уровня (смерть → is_restart_levelplay_level заново зовёт load_level(), тайлы перечитываются), а вот когда на кнопке умирает СТРАЖ — перезапуска нет, и кнопка заклинена до конца уровня.

Что нужно: сам порт died_on_button, константа TILE_STUCK = 5 и тайл заклиненной кнопки в атласе фона (сейчас его там нет).

Вторая половина: до check_press труп вообще не доходит (2026-08-18)

Найдено на живом сценарии (пользователь): уровень 7 начинается падением, и если не зацепиться за кромку, Кид разбивается на (2,1) комнаты 3 — а это кнопка открытия решётки. В оригинале решётка после этого открыта НАСОВСЕМ, у нас отжимается.

Оказалось, порта died_on_button мало — до него нет пути:

void pop_phys_tick(void) __banked
{
    if (pop_kid_dead) return;         /* <- вся физика трупа выключена */

У оригинала play_kid_frame (seg000:1231) гейт ровно один — Char.room != 0; весь список, включая check_press, крутится и на трупе (по alive разведены только check_spiked/check_chomped_kid, и то через resurrect_time).

Отсюда наблюдаемое поведение: кнопка получает РОВНО ОДНО нажатие — в кадре самой смерти, потому что pop_kid_dead ставит land() уже ВНУТРИ цепочки, после раннего возврата. Решётка успевает приоткрыться и потом закрывается. Снято в MAME: Кид frame 185, action 5 (bumped), alive 0, (2,1) room 3, pop_kid_dead = 1, тайл (2,1) = 0x0F opener, modif 0x12 (индекс связи), openness ворот (2,6) ползёт вниз (146 → 112).

Кадр смерти проходит проверки check_press штатно (action == ACT_BUMPED, флаги кадра 185 = 0x49, бит FRAME_NEEDS_FLOOR есть) — то есть после снятия раннего возврата труп начнёт давить кнопку КАЖДЫЙ кадр, как живой. Это ещё не оригинал: там на alive >= 0 уходит died_on_button, который делает эффект ПОСТОЯННЫМ и снимает кнопку с тайла. Значит чинить надо обе половины разом, иначе получится «кнопка держится, пока труп лежит» — поведение, которого нет ни в оригинале, ни сейчас.

Побочное наблюдение, НЕ проверено: openness ворот остановился на 112 и дальше не убывал, хотя animate_door (у нас совпадает с seg007:0522) обязан досчитать до 0 и снять trob. Довести проверку не удалось — в сцене залип frozen (клавиша фриза осталась в raw-битмапе, 2 перестала сниматься). Если подтвердится — это отдельный баг, к died_on_button отношения не имеющий.


TORCH-ANIM-RIGHT. Под пламенем могут застыть не только челюсти чомпера

Открыто 2026-08-10 при закрытии BUG-TORCH-CHOMP-2.

Пламя факела запекается в фон и рисуется в ячейке ПРАВОГО СОСЕДА. Оригинал после каждого кадра факела метит этого соседа (set_redraw_anim_right, seg007:0101) и перерисовывает весь его слой anim поверх огня; мы метим только когда сосед — чомпер. Слой draw_tile_anim (seg008:0644) рисует также пики, зелье и меч — если такой тайл окажется справа от факела и будет в статике, пламя накроет и его.

На уровнях 1–4 такого соседства не встретилось, поэтому расширять пометку (она стоит перерисовки тайла каждый кадр на каждый факел) заранее не стали.

Как проверять: поставить факел слева от пики/меча/зелья в тестовой комнате и дождаться статики; либо пройти уровни 5+ и смотреть на клетку справа от каждого факела.

Как чинить, если встретится: там же, в ветке факела pop_process_trobs, добавить в trob_rcode-проверку нужные коды и соответствующий вид пометки (POP_RD_SPIKE / POP_RD_FLOOR), а зелье — переставить в порядке обхода так, чтобы оно рисовалось ПОСЛЕ факела.


FORE-DUP. Передний слой тайла рисуется ДВАЖДЫ при перекрытии объектов

Найдено пользователем 2026-08-10 (вопросом, а не по симптому — картинка верная, страдают только такты).

У нас. Fore-проход зовёт КАЖДЫЙ рисующий по своему прямоугольнику: pop_char_fore для слотов Кида и соперника, pop_mirror_draw для отражения. Окно клипа (pop_fore_set_clip) у каждого своё. Если футпринты двух объектов накрывают один тайл, его передний кусок рисуется два раза.

Когда случается:

  • Кид и отражение — ВСЕГДА (стоят на одном тайле зеркала); этот случай создан фиксом fore-прохода над отражением 2026-08-10;
  • Кид и соперник в одном тайле — то есть весь ближний бой;
  • Кид и падающий кусок плиты.

Картинку не портит: куски переднего слоя идут прозрачным блитом-копией, операция идемпотентная. Портило бы при XOR/mono с накоплением — таких в fore-слое нет.

Такты тратит, и в самом дорогом месте: fore-проход исторически самая тяжёлая часть кадра (memory pop_fore_layer_cost — было 78 % кадра, окно клипа и кэш кладки дали 3.2×).

Как устроено в оригинале — дубль НЕВОЗМОЖЕН по построению. redraw_at_char (seg003:0427) ничего не рисует, а только помечает тайлы своего прямоугольника:

for (tile_row = x_top_row; tile_row <= char_bottom_row; ++tile_row)
    for (tile_col = x_col_left; tile_col <= x_col_right; ++tile_col)
        set_redraw_fore(get_tilepos(tile_col, tile_row), 1);

а set_redraw_fore (seg007:0550) — это redraw_frames_fore[tilepos] = frames, ПРИСВАИВАНИЕ. Два персонажа на одном тайле оставят там ту же единицу, и единственный обход тайлов нарисует передний кусок один раз.

Что делать. Механизм пометок у нас уже есть и прямо назван портом этой архитектуры — pop_redraw.h. Fore-слой остался единственным местом на прямом вызове. Приведение к оригиналу: pop_char_fore/pop_mirror_draw вместо прохода ставят пометки, а один проход в конце кадра их разбирает.

СНАЧАЛА ЗАМЕРИТЬ, потом чинить. Это самый горячий путь, и окно клипа (вместо перебора тайлов) в своё время дало 3.2× — переход на пометки может часть этого вернуть назад. Замер: брейкпоинт на листьях fore-слоя со счётчиком, сцена «бой в комнате 3 уровня 1» и «Кид на тайле зеркала, ур. 4»; сравнить число нарисованных кусков с числом уникальных тайлов. Если дубль мал — оставить как есть и закрыть запись.



SPRITE-ZERO-W0. Кадр персонажа изредка отдаёт спрайт 0x0 — РЕДКИЙ

Всплыло при разборе BUG-CHAR-STALE-PAGE (тень застыла на двух страницах в разных позах). Залипание слота вылечено — cd_sig_make больше не берёт снимок, если кадр не рисовали, — но САМА причина нулевого спрайта не найдена: atlas_image для валидного кадра изредка отдаёт запись 0x0.

Подозрение: конкуренция за окно W0. pop_char_draw маппит страницу атласа (gfx_w0_map(pages[page].page)) и держит её до gfx_w0_unmap() в конце, а внутри этого окна успевают отработать cd_splash и pop_sword_draw; рядом в кадре W0 маппят чтение данных уровня (pop_level_tile, pop_level_set_tile) и mob-тик. Если какой-то путь размапливает окно раньше времени, чтение заголовка спрайта даст нули.

Как ловить: watchpoint на порт окна 0 либо счётчик «w==0 при непустом кадре» в pop_char_draw с записью кадра/страницы/idx в отладочную ячейку, дальше читать её из MAME. Симптом редкий (пользователь поймал дважды).

GATE-FORE-KID. Кид, стоящий В ПРОЁМЕ ворот, виден ПОВЕРХ решётки — ФИКС ЕСТЬ, ЖДЁТ ПРОВЕРКИ

Портировано 2026-08-13 (повтор нашёлся на уровне 10, комната 7, тайл (2,6) — подтверждено чтением памяти MAME: Kid.room=7, curr_row=2, curr_col=6, код тайла 0x04). Сделано так:

  • решениеpop_gate_over_char(ch) в pop_map.c: персонаж стоит на тайле ворот текущей комнаты (колонки 0..8; девятая отсекается, там бары легли бы в соседнюю комнату — у оригинала это Kid.room != room_R);
  • отрисовкаdraw_gate_fore в pop_bg.c, точный порт seg008:1153: только прозрачные куски 51 + ломти 52, без непрозрачного 50 и без хвостового DOOR_FRAM_SLICE (фон под барами уже нарисован). Зовётся ОДИН раз на персонажа за кадр, не в тайловом цикле;
  • тестыtests-host/t_char.c: char_gate_covers_char_standing_in_it, char_gate_ignores_neighbour_tiles, char_gate_ignores_other_room, char_gate_never_in_last_column. Отрисовка в харнесс не линкуется, поэтому проверяется именно решение — ради этого оно и вынесено в карту.

Расхождение с оригиналом (осознанное, в docs/impl_diff.md): оригинал спрашивает жёстко про Kid, потому что foretable у него одна на проход тайлов; у нас fore-проход идёт по персонажу, поэтому спрашиваем про того, кого рисуем — страж в проёме тоже уходит за решётку.

Шовный случай (ворота у СОСЕДА слева) работал и раньше — он ниже. Банк 2 +601 Б, банк 3 +92 Б, резидент не изменился.

Нашёл пользователь 2026-08-11 (уровень 5, комната 24, верхние ворота): Кид стоит на тайле ворот, а решётка рисуется ПОД ним — он виден целиком, хотя должен быть за прутьями.

Как в оригинале. draw_tile_fore (seg008:0D15) первой же строкой:

if (tile_left == tiles_4_gate && Kid.curr_row == drawn_row &&
    Kid.curr_col == drawn_col - 1 && Kid.room != room_R) {
    draw_gate_fore();
}

то есть когда Кид стоит ИМЕННО на тайле ворот, их решётка дорисовывается в foretable — поверх персонажа: draw_gate_fore (seg008) кладёт спрайт 51 (низ решётки) и дальше вверх кусками по 8 px спрайтом 52, до gate_top_y, прозрачным блитом.

Что есть у нас. Портирован только ШОВНЫЙ случай (pop_bg.c, pop_fore_over_char): если футпринт зашёл за левый шов и у соседа слева в этом ряду ворота — их бары перерисовываются поверх персонажа через draw_gate_back. Случая «ворота ВНУТРИ комнаты, персонаж на их тайле» нет вовсе, отсюда симптом.

Почему не чинится одной строкой. Правка идёт в pop_fore_over_char — самый горячий путь кадра (memory pop_fore_layer_cost: когда-то 78 % кадра, окно клипа дало 3.2×). Добавлять проверку надо так, чтобы она не стоила ничего на каждом тайле футпринта: условие дешёвое (тайл под персонажем == ворота), но рисование — это ещё один блит на кадр, пока Кид стоит в проёме. Плюс нужен свой расчёт полосы (gate_top_y по живому openness), а не готовый draw_gate_back, который рисует ЗА персонажем.

Проверять: уровень 5, комната 24 — встать в проём верхних ворот (колонка 1 ряда 0) при частично поднятой решётке; прутья должны перекрывать Кида. Тот же случай — любые ворота на уровнях 1-3.


GUARD-ENTRY-DEATH. Вход в комнату со стражем вплотную = мгновенная смерть — ФИКС ЕСТЬ, ЖДЁТ ПРОВЕРКИ

Наблюдение (пользователь, 2026-08-13). В некоторых ситуациях (особенно после телепортов) Кид, входя в комнату, сразу оказывается чуть ли не в одном тайле со стражем и немедленно погибает: меч ещё в ножнах, а безоружному любой укол смертелен.

Что в оригинале. Стража при входе НЕ отбрасывает — проверено по SDLPoP: enter_guard (seg002:0112) ставит его ровно в тайл из данных уровня (Char.x = level.guards_x[room-1]), никаких проверок дистанции до Кида нет. Ситуация «вошёл вплотную» возможна и там. Защит четыре:

  1. bump_into_opponent (seg003:08AA) — главная. Безоружный Кид (Char.sword == sword_0_sheathed) при вооружённом сопернике (Opp.sword != sheathed, Opp.action < 2), лицом к лицу (Char.direction != Opp.direction) и can_guard_see_kid >= 2, на ABS(char_opp_dist()) <= 15 не получает укол, а ОТСКАКИВАЕТ: Char.y прижимается к полу, fall_y = 0, seq_47_bump.
  2. Кид сам достаёт меч — control_standing (seg005:0351).
  3. Страж поднимается в стойке покоя с мечом в ножнах — seq_77, ему нужно сперва заметить Кида (autocontrol_guard_inactivemove_down_forw).
  4. Вплотную страж не колет: autocontrol_guard_active при distance < 12 (или < 8 с убранным мечом) разворачивается/шагает, а check_hurting требует min_hurt_range = 8 для безоружного противника.

Корень у нас. Пункты 2-4 были портированы верно (pop_ctrl.c:288, pop_guard_cold.c:107, guards.c:553), а пункт 1 не портирован вовсе: в kid_phys (pop_map.c) между determine_col() и check_collisions() было пусто, тогда как play_kid_frame (seg000:1238) зовёт там bump_into_opponent. Без отскока дистанция успевает вырасти в рабочий диапазон удара 8..29 — и первый же move_6_shift стража даёт hurt_by_sword с take_hp(100).

Сделано. bump_into_opponent портирован в pop_map.c (рядом с bumped_floor) и вызывается из kid_phys на штатном месте. Резидент не вырос, банк 3: 11066 → 11156 Б. Опциональные фиксы SDLPoP (fix_painless_fall_on_guard, fix_jumping_over_guard) намеренно НЕ портированы — оставлено базовое поведение движка 1989 года.

Проверять: войти в комнату со стражем вплотную (телепортом +/ или Shift+L) без меча в руке — Кид должен отскочить с seq_47, а не умереть; падение на стража сверху по-прежнему безболезненно (это оригинал, не баг).


LOOSE-ROOM-CHANGE. Расшатанная плита не доваливается, если Кид ушёл в соседнюю комнату — ФИКС ЕСТЬ, ЖДЁТ ПРОВЕРКИ

Наблюдение (пользователь, 2026-08-13). Уровень 10, комната 2: в (2,9) стоит плита, которая под бегом Кида должна разрушаться. Если Кид пробегает по ней и уходит в правую комнату, плита не успевает разрушиться и упасть — переключение комнаты обрывает алгоритм.

Что в оригинале. loose-пол — обычный trob (make_loose_fall, seg007:0EF6 → add_trob(curr_room, curr_tilepos, 0)), а process_trobs (seg007:0000) крутит ВЕСЬ список каждый кадр, независимо от drawn_room: animate_tile делает get_room_address(trob.room) и работает с таблицей той комнаты, где trob живёт. Фаза лежит в curr_room_modif, то есть прямо в таблице уровня. Поэтому брошенная плита досчитывает до loose_floor_delay, remove_loose убирает тайл, add_mob рождает кусок — и всё это без Кида в комнате. Ровно так же ведут себя mobs[] (do_mobs, seg007:1063) — их список чистится только в start_level.

Корень у нас. Падающие КУСКИ (mob) чужую комнату умели давно (pop_loose_mob_room_changed, BUG-LOOSE-2), а вот ФАЗА тряски/отсчёта жила в pop_loose_modif[30] — массиве ТЕКУЩЕЙ комнаты, — и pop_loose_reset() при смене комнаты обнулял его целиком. То есть плита, брошенная на полпути, возвращалась в покой. То же для плит-потолков (pop_ceil_modif[10]).

Сделано (двухуровневое хранение вместо переноса всей механики в trob — pop_loose_modif остаётся быстрым массивом отрисовки текущей комнаты):

  1. pop_loose_leave_room() (pop_map.c, зовётся первым делом в enter_room_side, пока карта ещё описывает СТАРУЮ комнату) — сдаёт фазу всех loose-тайлов в персистентный room_modif (это и есть curr_room_modif оригинала) и заводит trob на незаконченные.
  2. animate_loose() (pop_trob.c, case TILE_LOOSE в pop_process_trobs) — досчитывает фазу заочно; на провале ставит тайл в EMPTY (pop_level_set_tile), кладёт в модификатор тип уровня (remove_loose) и рождает кусок через новый pop_loose_mob_spawn_at(room, row, col). Пока комната ОТРИСОВАНА, trob снимается сразу — хозяин фазы pop_loose_tick.
  3. pop_loose_reset() теперь не обнуляет, а ЗАБИРАЕТ фазу новой комнаты из room_modif: вернувшийся Кид застаёт плиту на той же фазе.
  4. pop_loose_forget() — старт/рестарт уровня: забыть фазы БЕЗ сдачи (у оригинала всё сносит load_level). Без него первый же enter_room после pop_trob_reset() занёс бы в свежий уровень дрожь из прошлой жизни.

Резидент не изменился (20569 Б); банк 3 +451 Б, банк 6 +181 Б, банк 7 +62 Б. Хост-тест phys_loose_survives_room_change (tests-host/t_phys.c) закрывает передачу фазы.

Проверять: уровень 10, комната 2 — пробежать по плите (2,9) в правую комнату и вернуться: дыра должна быть на месте. Регресс: комната 7 (две соседние плиты подряд), комната 12 плита (0,1) → уход в 15 (BUG-LOOSE-2), плита-потолок, рестарт уровня после смерти на дрожащей плите.


LOOSE-SEAM-ANIM. Плита в шве исчезает без анимации падения — МЕЛКИЙ, ОТЛОЖЕН

Решение пользователя (2026-08-13): «пока пусть будет так».

Наблюдение. Уровень 10: Кид расшатывает плиту (2,9) комнаты 2 и убегает вправо в комнату 7, где та же плита видна в шве как (2,−1). Плита доваливается правильно (см. LOOSE-ROOM-CHANGE) и с экрана исчезает, но мгновенно — без дрожания и без падающего куска.

Почему так. Заочный досчёт (animate_loose, pop_trob.c) сознательно не рисует: комната не отрисована, а фаза живёт в room_modif, мимо pop_loose_modif, на который смотрит отрисовка дрожащего кадра. Исчезновение видно только потому, что провал меняет ТАЙЛ, а по этому событию (pop_neigh_dirty) главный цикл перечитывает кромку и перерисовывает шов. Падающий кусок (mob) рождается в комнате 2 и честно летит, но mob_tick_one рисует только here (своя комната), а комната 2 — не отрисованная.

Что надо, если браться. Шов — это НЕ обычный тайл: чужая колонка 9 рисуется внутри нашей колонки 0 (draw_tile, ветка lcode == 11), а дрожащий кадр берётся из pop_loose_modif[row*10 + (col-1)], то есть при col == 0 индекс уезжает за границу массива (для ряда 0 — в −1). Значит анимация шва потребует не «включить рисование», а дать чужой колонке собственный источник фазы. Заодно там же чинить и этот выход за границу.

Проверять: тот же сценарий уровня 10; в оригинале плита в шве дрожит и роняет кусок так же, как в своей комнате.


JUMP-FLOOR-LEGS. Прыжок с пола: ноги Кида поверх кромки пола — ПОЧИНЕН, ЖДЁТ ПРОВЕРКИ

Корень (найден трассой 2026-08-13). Проход оверлеев в pop_fore_over_char обходил только два крайних ряда футпринта:

if (action != 2) other_overlay_tile(rB, c);   /* опорный  */
if (rT != rB)    other_overlay_tile(rT, c);   /* верхний  */

Это буквальный порт redraw_at_char2 (seg003:0645), и в оригинале он верен: там char_top_row и char_bottom_row ВСЕГДА соседние. У нас между ними появляется дыра — char_footprint форсит rT = rB 1 (голова торчит в ряд выше), а затем окно fore-клипа расширяет rB ещё на ряд вниз.

На спорном кадре трасса из MAME дала rT=0 rB=2 cL=0 cR=1 do_ov=1 и ноль вызовов overlay_mid_tile: ряды 0 и 2 обошли, ряд 1 — тот самый, где стоит плита пола, — пропустили. Отсюда и подсказка пользователя «глубже в падении плита рисуется правильно»: там y_to_row даёт уже 2, ряд 1 попадает в пару крайних, и всё сходится.

Фикс: обходить ВЕСЬ диапазон rB..rT, сохранив правило оригинала «при action == 2 опорный ряд не трогаем». После фикса трасса на том же кадре: один вызов overlay_mid_tile, исход для (1,1) = «нарисовали», коды тайлов curr=3 left=0 — байт в байт как в отладочном выводе SDLPoP.

Пилларный оверлей, который «работал и раньше», к этому отношения не имел: это fore_id = 95 из отдельного fore-прохода, а не midtable-оверлей.

Банк 2 +28 Б. Хост-тесты (6 наборов) проходят.

Разбор, по которому искали (оставлен: метод пригодится)

Наблюдение (пользователь, 2026-08-13). Уровень 10, комната 1, прыжок с пола. В оригинале нижняя часть Кида скрыта ЗА передней гранью пола, у нас рисуется весь Кид поверх неё (за передней колонной он при этом уходит правильно). Скриншоты — в переписке сессии.

Состояние кадра (снято из памяти MAME на замороженном кадре, сборка 2026-08-13):

поле значение
Kid.frame 103 (frame_103_start_fall_2)
Kid.action 3 (actions_3_in_midair)
Kid.x / Kid.y 69 / 127
Kid.direction 1 (влево)
Kid.curr_col / curr_row 0 / 2
Kid.room / cur_room 1 / 1

pop_y_land[2] = 118 — то есть ноги на 9 px НИЖЕ уровня пола ряда 1, ровно в полосе его передней грани (draw_tile_bottom рисует её на 63*row + 65 = 128).

Тайлы комнаты 1 (room_fg, прочитаны там же):

ряд 0: 01 01 00 00 00 00 00 00 04 0B
ряд 1: 04 00 03 13 10 11 13 01 07 0F
ряд 2: 0C 01 03 01 01 0F 01 01 04 03

Кромочная колонка левого соседа (lcol_fg[0..2]): 04 0C 04.

Что УЖЕ проверено и исключено.

  1. Гейт redraw_at_char2 (seg003:0645) портирован верно. Сверено строка в строку: наш do_ov в pop_fore_over_char даёт то же множество поз (захват 78-79, action 2/3/4/6, bumped 102-106, начало подъёма 135-136), и climb-ветка 137..144 тоже совпадает. При action == 3 оверлей ВКЛЮЧАЕТСЯ — дело не в гейте.
  2. draw_other_overlay (seg008:1492) на колонке 0 не сработал бы и в оригинале. Обе его ветки требуют пустого соседа: tile_left == empty (у нас lcol_fg[1] = 0x0C, doortop — не пусто) либо drawn_col > 0 и пустой тайл ЧЕРЕЗ ОДИН слева. Значит окклюдер — НЕ он, и наш порт этой функции здесь ни при чём.
  3. pop_tile_code(row, 1) кромку читает правильно — из pop_t_lfg, а не «стена всегда».

Две живые гипотезы, проверять в этом порядке.

  • Это вообще не отрисовка, а физика. У нас Kid.y = 127 при уровне пола 118 — Кид «утоплен» на 9 px, и ноги торчат ниже кромки просто потому, что они там и есть. Проверяется дёшево: снять Char.y у SDLPoP на том же кадре 103 той же последовательности. Если там 118..120 — искать надо в seqtbl/fall_speed, а не в слое фона.
  • Окклюдер — другой тайл/другой механизм. Тогда нужна очная ставка: прогнать SDLPoP с трассой (dbg_shots-fprintf в draw_other_overlay и add_kid_to_objtable уже стоят в нашей копии, см. memory pop_pixel_diff_vs_sdlpop) и сравнить СПИСОК тайлов, попавших в midtable на этом кадре, с тем, что рисуем мы. Гадать дальше по коду смысла нет — два прохода по seg008 уже не дали ответа.

Проверять после починки: тот же прыжок в комнате 1 уровня 10; за передней колонной Кид уходит и сейчас — это не должно сломаться.


MOB-CLIP-RIGHT. Окна и узор кладки рисуются поверх падающих плит

Найдено прогонами 2026-08-13 (уровень 13, комнаты 16 и 23).

Симптом. На падающей плите проступают элементы ЗАДНЕЙ стены — узор кладки, а на ряде 0 и целое окно. Плита при этом цела: элементы ложатся поверх неё, не стирая.

Корень. mob_render (pop_room.c) каждый кадр вызывает draw_tile(r, m->col + 1) — перерисовывает ЦЕЛЫЙ соседний тайл поверх уже нарисованного куска, чтобы спрятать его правую часть (спрайт 42 лежит в столбце col+1). draw_tile кладёт всё содержимое тайла, включая декор пустого тайла — окно и узор. Ряд берётся из координаты куска, поэтому на ряде 0 перерисовывается тайл с окном.

Правка #6 (клип перерисовки окном коридора heal) сделала артефакт ЗАМЕТНЕЕ: раньше перерисовка размазывалась вокруг, теперь попадает точно в прямоугольник плиты.

Как в оригинале. Такой перерисовки нет вовсе. redraw_at_cur_mob (seg007:1094) зовётся ТОЛЬКО из loose_fall, один раз в момент сбития, и лишь ставит пометки на тайл и соседа. Правая часть куска прячется собственным клипом объекта: add_mob_to_objtable (seg007:1161) задаёт clip.right = 40.

Что делать. Портировать клип вместо подпорки:

  1. выяснить, во что превращается clip.right = 40 в наших координатах (у оригинала obj_x в удвоенных единицах: char_x_left = obj_x / 2 + 58);
  2. блитить правую часть (env 42) с ограничением ширины — примитив с шириной есть для персонажей (gfx_blit_cols_part_w), для env надо посмотреть;
  3. убрать draw_tile(r, col+1) и окно fore-клипа вокруг него;
  4. вернуть коридор heal к 64x32.

Побочная выгода: уходит по одному полному draw_tile на кусок за кадр — при шести падающих плитах это шесть отрисовок тайла.


MID-OVERLAY-LAYER. Оверлей кромки всегда поверх, а должен сортироваться

Найдено разбором 2026-08-13.

overlay_mid_tile (pop_bg.c) выполняется в проходе ПОСЛЕ персонажей, то есть безусловно поверх всего нарисованного. В оригинале это draw_other_overlay (seg008:1499), и он переключает таблицу на midtable — тот же слой, где живут персонажи и падающие куски (add_mob_to_objtable, тип 0x80), а внутри слоя всё сортируется по y (compare_curr_objs, seg008:1572).

Следствие: узор из оверлея выигрывает у плиты, даже когда плита ближе к зрителю. Пока у нас нет сортируемого midtable, точечными правками это не лечится.

Связано с BG-ONCE — там та же техническая сердцевина: привести наши слои в соответствие с таблицами оригинала.


GUARD-FALLOUT-VICTORY. Выпавший из комнаты страж не засчитывается убитым

Найдено разбором 2026-08-13 (при проверке чита K на Джафаре).

У on_guard_killed в оригинале ДВА места вызова:

место что
play_guard, seg006:1494 HP кончились — портировано
check_guard_fallout, seg002:264 страж выпал из комнаты — НЕ портировано

Наш pop_guard_fallout (pop_guard.c) просто гасит слот. Значит столкнуть Джафара в пропасть на 13-м уровне можно, а выход на 14-й от этого не откроется — расхождение с оригиналом.

Правка: позвать on_guard_killed() в ветке «не скелет». Мешает только то, что функция сейчас static в guards.c (банк 1), а pop_guard_fallout живёт в резиденте.


LEVELDOOR-STARTROOM-WIPE. В стартовой комнате за входной дверью рисуется лестница вместо черноты

Найдено разбором 2026-08-17 (попутно к фиксу LEVELDOOR-PALACE-CLIP — то же draw_leveldoor).

draw_leveldoor (seg008:1D29) в приподнятой створке различает две комнаты:

if (modifier_left) {
    if (level.start_room != drawn_room) {
        add_backtable(..., 144 /*level door stairs*/, ...);
    } else {
        short leveldoor_width = (tbl_level_type[current_level] == 0) ? 39 : 48;
        sbyte x_low           = (tbl_level_type[current_level] == 0) ?  2 :  0;
        add_wipetable(0, 8*(draw_xh + 1) + x_low, ybottom - 4, 45, leveldoor_width, 0);
    }
}

Смысл: за дверью, через которую вошли на уровень, лестницы нет — там чернота, и оригинал её ЗАТИРАЕТ прямоугольником, а не рисует марш 144. Ширина затирки дворцовая/подземная (48 против 39) и подземная сдвинута на 2 px вправо — то есть здесь ЕЩЁ одно расхождение подземелья и дворца, помимо уже починенного leveldoor_right += 8.

У нас (pop_room.c, draw_leveldoor) стоит безусловно:

if (modif)
    pop_env_b(144, x, ybottom - 4);   /* лестница (комната ≠ стартовой) */

— условие названо в комментарии, но не реализовано. Различие стартовой комнаты у нас есть только в логике входа (pop_leveldoor_enter, pop_map.c:1166), не в отрисовке.

Когда видно: только пока створка входной двери приподнята (modif != 0) в стартовой комнате — то есть в анимации входа на уровень и если дверь снова открыть. При закрытой двери modif == 0, и обе ветки ничего не рисуют, поэтому баг и не попался на обходах уровней 1-4.

Что мешает сделать прямо: у нас нет понятия wipetable — нужен чёрный прямоугольник в проходе backtable, то есть либо прямой gfx-fill в draw_leveldoor, либо запечка. Проверять на уровне 1 (подземелье, ширина 39, сдвиг 2) И на уровне 4 (дворец, 48, сдвиг 0) — иначе поймаем только половину.