Тень на уровне 6 стояла на двух страницах дабл-буфера в разных позах. Отрисовка брала image из кэша kid_frame/pop_gframe, который наполняет тик, а снимок пропуска кадра писала по Char.frame — расхождение застревало навсегда, потому что снимок совпадал и страница больше не перерисовывалась. Кадр теперь грузит сама отрисовка, как в оригинале (add_*_to_objtable → load_fram_det_col, seg008:22F0/2324). Разбор — BUGS_CLOSED.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
216 KiB
roomtest — архив закрытых багов
Сюда переезжает всё, что закрыто: подтверждённые фиксы, снятые
диагнозы, осознанные решения «не делать». Открытые баги — в
BUGS_OPEN.md, текущие задачи — в
TASKS_OPEN.md, закрытые задачи с протоколами — в
TASKS_CLOSED.md.
Файл существует не ради истории как таковой: половина записей ниже — это
разбор КОРНЯ (odd-pixel арифметика char_x, подстановка тайла нажатой
кнопки, баг кодогенератора SDCC), и он экономит часы, когда похожий симптом
всплывёт снова. Прежде чем заводить новый баг — грепни здесь по симптому.
GRAB-BELOW-ROOM. Зацеп за кромку НИЖНЕГО ряда при пролёте вниз не работал вовсе
Наблюдение (пользователь, 2026-08-12). Уровень 7, комната 14: по сценарию
уровня Кид спускается с ряда 0 на ряд 2 через зацеп — виснет на кромке кнопки
(0,2), отпускает и в падении цепляется за кромку (2,2). «Пытался много
раз — Кид летит и не цепляется ни за (2,2) комнаты 14, ни за (1,2) комнаты
15». При этом вис на (2,2) с последующим перелётом на (1,2) работает
стабильно — то есть сам зацеп в падении жив.
Корень. do_fall (порт seg005:0030) не давал ряду персонажа выйти за
нижнюю границу комнаты:
else if (Char.curr_row < 2)
inc_curr_row(); /* «следующий ряд ВНУТРИ комнаты» */
В оригинале inc_curr_row() безусловный, и curr_row доходит до 3 — ряда
«за нижней кромкой»; get_tile для него уходит по links.down в комнату снизу
(find_room_of_tile, seg006:005D). Ряд 3 нужен не сам по себе, а потому, что
check_grab целится в тайл спереди-сверху, то есть в ряд curr_row − 1:
только при curr_row == 3 целью становится ряд 2 своей комнаты.
С залипшим рядом 2 происходило вот что: пока Кид летел вдоль нижнего ряда,
do_fall каждый кадр уходил в ветку «достиг y_land», где check_grab не
вызывается вовсе. К моменту, когда переход в комнату снизу открывал окно
зацепа заново (там цель — «ряд −1», уже реализованный g_above), скорость
падения успевала дойти до fall_y = 33, а check_grab требует < 32. Окно
закрывалось по скорости — отсюда «летит и не цепляется никак».
Второй дефект, найденный там же: ветка action == ACT_MIDAIR в check_action
стояла ПУСТОЙ заглушкой с комментарием «frames 102..105: check_grab — K4». В
оригинале (seg006:0619) это первые четыре кадра падения, где fall_y ещё не
разгоняется (fall_accel работает только в ACT_FREEFALL), — самое широкое
окно зацепа. У нас его не было.
Фикс (pop_map.c): inc_curr_row() теперь безусловный внутри ветки
curr_row <= 2 (то есть 2 → 3 разрешён), а сама ветка «достиг y_land»
гейтится по curr_row <= 2 — при ряде 3 ни in_wall, ни land звать нельзя
(тайлов своей комнаты там нет, get_tile отдаёт WALL-сентинел; до
y_land[4] = 244 дело не доходит, check_leave_below переводит в комнату
снизу на y >= 211). Плюс восстановлена ветка ACT_MIDAIR с кадрами 102..105.
Подтверждение.
- Хост-тест
tests-host/t_grab.c→grab_below_room_edge_window_exists: сцена комнаты 14 + переход в 15 поpop_fell_out(как в главном цикле). Карта исходов по фазе X и задержке Shift до фикса — сплошные точки (ни одного зацепа), после — окно из 8 фаз X при задержках 0..5 кадров. Тест проверяет и играбельность: зацеп обязан удаваться при самом естественном вводе (отпустил вис и сразу зажал Shift). - Живьём в MAME (мост, 2026-08-12): Кид повис на кромке
(2,2)— в памятиframe=91 y=55 row=0 room=15 act=6, на экране держится за правый край плиты.
Грабли теста, стоившие часа. Первая версия сцены засчитывала как успех
ЛЮБОЙ вис — а Кид на первых кадрах падения цепляется обратно за ту же верхнюю
кромку, и тест проходил даже на сломанном коде. Признак цели пришлось делать
позиционным (went_below + curr_row == 0 после перехода). Вторая ловушка:
кадры спуска seq_68 идут с action == 3, то есть «дождаться падения» по
одному лишь action нельзя — ждать надо ВИСА, и только потом отпускать Shift.
BUG-CHEAT-FIGHT-1. Чит +/− в бою: Кид остаётся в режиме боя и теряет управление
Наблюдение (пользователь, 2026-08-07). Если нажать +/− (ROOMNAV,
переход по комнатам) в момент, когда Кид вытащил меч для битвы, — в новой
комнате Кид не управляется: нажатия стрелок игнорируются.
Корень (прочитан по коду, сверен с seg005). Чит ROOMNAV
(roomtest.c:576) телепортирует и сбрасывает позу и HP —
enter_room(), kid_init(SEQ_STAND, …), pop_kid_hp_reset(), — но не
трогает состояние боя: Kid.sword остаётся SWORD_2_DRAWN. Диспетчер
pop_control (pop_ctrl.c:578) при вынутом мече уходит в
control_with_sword, а там единственный выход из режима боя —
if (Char.frame == FRAME_171_STAND_WITH_SWORD) { /* seg005:987 */
Char.sword = SWORD_0_SHEATHED;
seqtbl_offset_char(SEQ_92_PUT_SWORD_AWAY);
}
Стража в новой комнате нет (can_guard_see_kid = 0), кадр после
kid_init(SEQ_STAND) — обычная стойка, а не 171, поэтому не срабатывает ни
swordfight(), ни ветка «убрать меч»: control_with_sword каждый кадр
не делает НИЧЕГО, и ввод не доходит до движения. Заклинивание вечное.
Это баг нашего чита, а не порта: в оригинале телепорта между комнатами нет, и в режим боя без соперника попасть нечем.
Как чинить. В ветке ROOMNAV телепорт трактовать как выход из боя (то же
самое, что делает pop_start_level): Kid.sword = SWORD_0_SHEATHED,
holding_sword = 0, сбросить offguard/guard_refrac и состояние стража
(pop_guard_reset() вызывается по входу в комнату — проверить, что он
обнуляет Opp). Меч в инвентаре (pop_have_sword) при этом НЕ терять.
Второй кандидат на ту же болезнь — чит K (убить стража) в момент, когда
Кид в стойке с мечом, но не в кадре 171: там оригинал сам доводит до 171
через swordfight, так что проверить сценарием, а не менять вслепую.
ЗАКРЫТ 2026-08-10. Сделано ровно по плану выше: ветка ROOMNAV после
kid_init/pop_kid_hp_reset гасит состояние схватки —
Kid.sword = SWORD_0_SHEATHED, holding_sword = 0, offguard = 0,
guard_refrac = 0. Меч в инвентаре (pop_have_sword) не теряется, только
режим боя.
BUG-LOOSE-BUTTON-1. Упавшая плита не нажимает кнопку — ЗАКРЫТ 2026-08-10
Симптом (пользователь, уровень 4). Плита 16(1,1) падает вниз, в комнате
17 ничего не меняется: кнопка цела, щебня нет, ворота не открылись.
Корень — три слоя.
- Порт
loose_land(seg007:11E8) у нас вообще не звалtrigger_button: и в своей комнате (pop_map.c), и в комнате снизу (roomtest.c) он только клал щебень. pop_room_col_landing(pop_level.c) считала площадкой только чистый пол (t == 1). У оригинала площадок семь —tiles_1_floor,tiles_2_spike,tiles_6_closer,tiles_10_potion,tiles_15_opener,tiles_19_torch,tiles_30_torch_with_debris; на всём прочем кусок просто исчезает. Кнопка в набор не входила, поэтому плита растворялась.- Сигнал
pop_loose_fellвзводится в момент ОТРЫВА плиты, а по нему главный цикл и щебень клал, и кнопку жал — ворота начинали открываться, пока плита ещё в воздухе.
Механика оригинала (важно, интуиция обманывает). Плита не «постоянно
давит» на кнопку. Нажатие ОДНО, но с button_type = tiles_14_debris, а это у
trigger_gate отдельная ветка: возврат 2 → animate_door доводит створку и
ставит curr_modifier = 0xFF → ворота заморожены открытыми навсегда, обычный
opener их больше не трогает (if (modifier == 0xFF) return -1). Сама кнопка
съедается: тайл становится щебнем. На closer — быстрое закрытие и тоже
щебень. На факеле остаётся отдельный тайл tiles_30_torch_with_debris.
Фикс. Набор площадок расширен до оригинального; pop_trigger_button
зовётся в обоих местах с правильным button_type; посадка в комнате снизу
переехала с pop_loose_fell на новый сигнал pop_loose_exit, который
mob_tick_one взводит, когда кусок ушёл ниже поля комнаты.
Осознанное расхождение. Оригинал через mob_down_a_row продолжает
симуляцию куска уже в нижней комнате; у нас он там не летит, а садится сразу.
Задержка получается «высота комнаты», а не «высота комнаты плюс путь до пола
внизу».
BUG-GATE-FF-1. Ворота, открытые навсегда, нарисованы открытыми, но непроходимы — ЗАКРЫТ 2026-08-10
Симптом. После BUG-LOOSE-BUTTON-1 ворота 23(0,9) рисуются поднятыми, но
Кид в них упирается.
Корень — перегруженное значение. gate_modif (pop_map.c) отдаёт 0xFF
как сторожевое «тайла нет за краем уровня», а trigger_gate ставит тот же
0xFF как живой модификатор «открыто НАСОВСЕМ». В gate_passable стояло
if (gm == 0xFF) return 0;. До сегодняшнего дня значение в игре не
возникало — ветку по щебню никто не запускал.
Фикс. Ветка убрана: она мёртвая (нас зовут только когда тайл ТОЧНО
ворота), а (0xFF >> 2) + 6 = 69 и так больше любого fph. Остальные
читатели gate_modif трактуют 63 как «открыто» и правок не потребовали.
BUG-TORCH-CHOMP-1. Чомпер стирает пламя соседнего факела — ЗАКРЫТ 2026-08-10
Симптом (пользователь). Комната 23 уровня 4: факел есть, огня нет.
Корень. Пламя рисуется в ячейке ПРАВОГО соседа (x = COL_XH[col+1]*8 + 8
= 232..248, y 33..50), то есть в тайле чомпера (0,7). pop_chomp_redraw
начинается с pop_heal_off(224, 2, 32, 64) — восстанавливает запечённый фон в
x 224..255, y 30..93. Пламя в фон не запекалось и рисовалось в
GFX_BANK_SPRITE, поэтому heal его съедал; вдобавок pop_redraw_needed идёт
ПОСЛЕ pop_process_trobs, так что стиралось только что нарисованное.
Тупик, в который я сначала свернул. Вынес отрисовку пламени в отдельный
проход после pop_redraw_needed — стало хуже: пламя легло ПОВЕРХ челюстей
(правильный z-порядок существует только внутри общего обхода тайлов, где
draw_tile_anim_right идёт до draw_tile_anim), плюс проход индексировал
trob_code[i] параллельно trobs[i], а pop_process_trobs в конце уплотняет
массив — индексы разъезжались, и чужой trob рисовался как факел (пламя из
головы Кида). Проход откачен.
Фикс (подсказан пользователем). Пламя ЗАПЕКАЕТСЯ: pop_torch_draw пишет
в GFX_BANK_NORMAL (видео-ОЗУ + ОЗУ-копия). Это безопасно именно у факела —
все девять кадров лежат на общем канвасе 16×18 и упакованы opaque=True, то
есть кадр полностью накрывает предыдущий, протухнуть в копии нечему; и факел
всегда задний план. Тогда heal чомпера возвращает огонь сам, а челюсти
ложатся поверх — тот же z-порядок, что в оригинале, и без лишнего прохода.
Пузырёк ЗЕЛЬЯ так нельзя: он ползёт вверх, себя не накрывает и требует heal —
pop_potion_draw остаётся в GFX_BANK_SPRITE. Столкнуться с чомпером в
одном тайле зелье не может.
BUG-GUARD-IX-1. «Зависание» при бое со стражем — затёртый IX главного цикла — ЗАКРЫТ 2026-08-10
Симптом (пользователь, уровень 4). Дважды подряд при ручном входе в комнату 18 со стражем игра «повисала»: картинка стоит, управление не отвечает. Нажатие «2» ненадолго возвращало движение, потом всё повторялось.
Диагноз — НЕ зависание. Главный цикл всё это время крутился (маркер
верха цикла тикал раз в кадр). Стоял отладочный стоп-кадр: локаль frozen
в main сама собой становилась ненулевой.
Корень — однобайтовый выход за границу локального массива. У main все
локали лежат по IX-1..IX-19, и IX затирался: вместо 0xBFFA в нём
оказывался 0xBF00. Признак железный — в теле цикла обязано выполняться
SP == IX-19, а по факту было SP=0xBFE7, IX=0xBF00. Дальше main читал
frozen, dbuf, front/back, dead_frames и edge-флаги читов из живого
стекового мусора.
Виновник — check_chomped_guard (pop_map.c):
uint8_t flags[COLL_N]; /* 14 байт в кадре функции */
calc_coll_window(); /* окно СТРАЖА -> win_lo/win_hi */
get_row_collision_data(Char.curr_row, flags);
coll_row() пишет по flags + scan_off, а число байт берёт из
win_lo/win_hi. scan_off/scan_left0 выставляет coll_scan_prepare(),
которую звал ТОЛЬКО check_collisions — путь Кида. То есть ряд стража
писался по смещению Кида, длиной стража. Диапазон записи
[kid_win_lo+2 … kid_win_lo+2 + ширина_окна_стража − 1]; при обычной ширине
4 он уезжает за flags[13], когда kid_win_lo > 8 — то есть когда Кид
стоит у правого края комнаты (взаимное расположение Кида и стража ни при
чём, стража даёт только длину). Сразу за массивом лежит сохранённый IX;
затёртый младший байт и превращал 0xBFFA в 0xBF00.
Перелёт в отрицательные индексы был невозможен: scan_off = kid_win_lo + 2,
а kid_win_lo >= COLL_C0 = −2 из-за клампа в calc_coll_window.
Почему не ловилось раньше. На уровне 1 (комната 3) бой идёт левее середины — запись оставалась внутри массива. Ничего не портилось, но флаги стража всё равно читались по чужим смещениям и с иксами Кида: тихо неверные данные, без последствий (чомперов там нет).
Фикс. coll_scan_prepare() в начале get_row_collision_data() — теперь
запись всегда ложится в [win_lo+2 … win_hi+2], а calc_coll_window клампит
окно в [−2 … 11], то есть индексы гарантированно 0…13. Заодно чинится
сам расчёт: scan_left0 задаёт x колонок, и чомпер-коллизия стража считалась
по координатам Кида.
Как ловилось (приём на будущее). Детекторы ix != 0xBFFA на границах
фаз PROF() с починкой IX в верху цикла: игра остаётся живой, а первый
сработавший детектор называет фазу. Дальше — инструкционная трасса MAME
(trace file,0), включаемая на подозрительном вызове и выключаемая сразу
после, со стопом только на плохом кадре: в файле остаётся ровно тот вызов,
где IX испортился. В хвосте трассы видно ld sp,ix / pop ix, достающий
BF00 вместо BFFA. См. memory z80_profiling_method.
Проверка. Бой со стражем в комнате 18 уровня 4 (пользователь) —
детекторы IX висели без починки и не сработали ни разу; make -C tests-host
— все 5 наборов.
BUG-GATE-SEAM-ROW1. Решётка в шве не анимируется, если ворота НЕ в ряду 0 — ЗАКРЫТ 2026-08-10
Симптом (пользователь, уровень 4, стартовая комната). Плита нажимается, ворота физически открываются (Кид проходит), но визуально с решёткой ничего не происходит. Отдельно: чёрный треугольник над воротами (верх решётки под ковром) статичен, тогда как в оригинале он ездит вместе с барами.
Корень. Зонд на pop_add_trob показал room=8 tp=19 type=1: плита
комнаты 1 открывает ворота комнаты 8 в (1,9) — соседней комнаты, видимые
через ЛЕВЫЙ шов (бары ворот в col9 рисует col0 соседа справа). Наш
change-driven редрой шва смотрел только один байт m[9] (ряд 0), а
pop_room_redraw_seam_left() перерисовывал жёстко draw_tile(0, 0).
Изменение приходило в m[19] — сигнатура его не видела.
На уровне 1 та же связка комнат 6/8 работала только потому, что решётка соседа стояла в (0,9). Тайлсет ни при чём: в подземелье просто не попадалось ворот в ряду 1 у шва (и треугольника не было — над решётками всегда потолок).
Фикс.
seam_sig— массив на три ряда, сравниваются все три; накопленная маска изменившихся рядов живёт вseam_rowsи переживает оба кадра дабл-буфера.pop_room_redraw_seam_left(uint8_t rows)берёт маску и перерисовывает только помеченные ряды — не все три, чтобы не платить каждый кадр анимации.- В маску добавляется ряд ВЫШЕ (
changed | (changed >> 1)): верх решётки (draw_tile_anim_topright, seg008:0568 — маска 68 + кадрDOOR_FRAM_TOP) рисует не сам тайл ворот, а тайл над-справа от него, у нас(r−1, 0). Без этого бары ездили, а треугольник над ними стоял.
Проверка. Уровень 4, стартовая комната: нажатие плиты — решётка шва поднимается/опускается вместе с верхом (пользователь).
BUG-GUARD-COLOR-1. Страж и его полоса HP — всегда одного цвета — ЗАКРЫТ 2026-08-07
Симптом (пользователь, прогон уровня 2). Комната 4: страж не того цвета, что в SDLPoP, и полоса его HP тоже.
Корень. Оригинал держит ОДИН набор спрайтов стража и подменяет 16 цветов
палитры: redraw_screen (seg003:255) зовёт set_chtab_palette(chtab_5_guard, &guard_palettes[0x30*curr_guard_color − 0x30], 16) ПЕРЕД отрисовкой комнаты,
где curr_guard_color = level.guards_color[room−1] & 0x0F (enter_guard,
seg002:184), а guard_palettes — ресурс 10 из PRINCE.DAT (7 палитр × 16
цветов, 6-битные каналы). У нас pop_pack_guard.py брал COLOR = 2
константой (цвет стражей уровня 1) и запекал одну палитру в слоты
0x90..0x9F. Полоса HP рисуется тем же атласом — отдельного бага не было.
Фикс.
pop_pack_guard.pyвыгружает ВСЕ 7 палитр вpop_guard_pal.h(7 × 16 × 4 = 448 Б, записиB,G,R,0— форматgfx_pal_load); каналы масштабируютсяscale6to8, как вpop_pack_kidдляkid.pal.pop_guard_set_palette(color)(pop_gdraw.c) заливает 16 слотов в ОБЕ палитры дабл-буфера;color == 0— не трогать (так и оригинал).- Зовётся из
pop_guard_enterсразу после чтения данных комнаты, то есть ДО отрисовки — какredraw_screen.pop_level_guardцвет отдавал уже давно, его просто никто не использовал.
Грабли, стоившие итерации (записать на будущее). Первая версия передавала
gfx_pal_load указатель прямо на таблицу — а таблица лежит в rodata
БАНКОВОГО модуля, то есть по 0xC000+. gfx_pal_load отдаёт указатель в BIOS
($A4 через rst #0x08), а BIOS читает только #4000-#BFFF (корневой
CLAUDE.md). BIOS забирал мусор с чужой страницы, палитра уезжала в тёмное и
страж становился НЕВИДИМЫМ. Лечится копией записи в локальный буфер — стек
гарантированно в W2.
Проверено в MAME (уровень 2, ROOMNAV):
| комната | цвет из уровня | что на экране |
|---|---|---|
| 11 | 1 | страж сине-фиолетовый, полоса HP синяя |
| 7 | 3 | страж оранжевый, полоса HP оранжевая |
| ур. 1 | 2 | охра, как было (регрессии нет) |
Цвета сходятся с таблицей pop_guard_pal.h: цвет 1 = (72,145,255),
цвет 3 = (255,80,0), цвет 2 = (170,48,0).
Смежное, НЕ входит сюда: палитра КЛАДКИ уровня 3 (в оригинале зелёная) — другой механизм и другой ресурс, заведена отдельной задачей L3-COLOR.
BUG-SWORD-GHOST-1. Переход комнаты В БОЮ: Кид прячет меч и дерётся пустой рукой — ЗАКРЫТ 2026-08-07
Проверено в игре (пользователь): «вроде проблема не воспроизводится —
всё корректно». Замер на замороженном кадре: оба персонажа в комнате 2,
ряд 1, колонки 4 и 7, Kid.sword = 2, Guard.sword = 2, луч видимости
прошёл, счётчик уборок меча за прогон — 0.
Симптом. Кида вытеснили из комнаты 3 в комнату 2 в бою, он спрятал меч; страж вошёл следом, и дальше Кид «отбивался» без клинка. Зависимость от расстояния между Кидом и стражем в момент перехода: близко — бой продолжался нормально, далеко — меч убирался и страж не шёл, а в промежутке страж приходил, но меч оставался в ножнах.
Корень — мнимая стена за краем комнаты, а не боёвка. Ложным было
can_guard_see_kid. Луч видимости (check_can_guard_see_kid, seg003:688)
идёт по тайлам между колонками Кида и стража, а колонка считается из x:
floor((x − 7 − 58) / 14). На всём диапазоне x 0..255 это даёт col
от −5 до 13, то есть колонка регулярно уходит ЗА комнату. Оригинал
такие колонки резолвит через find_room_of_tile (seg006:005D), гуляя по
roomlinks на любую глубину; наш get_tile знал соседей только на две
колонки (−2..11), а дальше отдавал TILE_WALL. Луч упирался в эту
мнимую стену, can_guard_see_kid падал в 0 — и control_with_sword
(ветка «противника не видно») штатно убирал меч.
Тот же ноль не давал достать меч обратно: control_standing зовёт
draw_sword только при can_guard_see_kid >= 2. Отсюда и «драка без
меча», и зависимость от расстояния — на самом деле не от расстояния, а от
того, вышла ли колонка за кэш.
Артефакт (регистратор в памяти, снят с живой сцены). Брейкпоинт тут бесполезен — баг ловится руками и редко, поэтому в код были временно вшиты байты «кто убрал меч и почему оборвался луч»; читались после сцены:
src = 2 control_with_sword, ветка «противника не видно»
why = 2 луч упёрся в стену
tile = 0x14 = 20 = TILE_WALL
col = 12 ← колонка стража ЗА кэшем (было −2..11)
kid_col = 8 guard_col = 12
Фикс. pop_map кэширует fg соседних комнат слева и справа ЦЕЛИКОМ
(по 30 байт, раскладка комнаты) и резолвит col от −10 до 19 в реальный
тайл соседа; за этими пределами — по-прежнему стена (у оригинала там
следующий roomlink, у нас край кэша). Данные забирает сама
pop_map_set_edges(left, right, up, down), читая уровень — отдельной
копии в приложении нет. Заодно gate_modif перестал читать за границу
массива модификаторов: openness ворот на дальних колонках берётся из
room_modif СОСЕДА (pop_gate_modif, им же пользуется луч).
Цена: +48 байт в W2 (кэш 6+6 → 30+30), код W1 даже уменьшился.
Комнаты сверху/снизу оставлены как были (above_fg / below_fg): по
вертикали curr_row за −1..3 не выходит, мнимых стен там не возникает.
Попутно вернули строку оригинала, потерянную при порте: control_with_sword
сбрасывает holding_sword для живого Кида (seg005:980) — от неё зависит
индикатор HP стража.
Регресс: tests-host — все 5 наборов зелёные, характеризационные
трассы Кида не сдвинулись ([phys] ok: 1723). Новый набор проверок
char_tiles_resolve_across_rooms в t_char держит резолв колонок
−11..20 в тайлы соседей и стену на краю кэша — чтобы кэш нельзя было
молча сузить обратно.
Почему в SDLPoP не воспроизводилось (пользователь пробовал): там этой
границы просто нет — find_room_of_tile уходит по roomlinks сколько
нужно, и луч никогда не встречает мнимой стены.
BUG-GRAB-1. Прыжок с места через провал в 3 тайла: зацепа нет — ЗАКРЫТ 2026-08-05
Проверено в игре (пользователь): «зацеп работает». Физика была верна с самого начала, чинить пришлось клавиатуру — BUG-KBD-5.
Итог 2026-08-05. Физика тут ни при чём — виновата клавиатура. Зажатый Shift снимался автоповтором зажатой стрелки, поэтому к кадрам 102…106 (окно зацепа) движок видел Shift отпущенным. Полный разбор и фикс — BUG-KBD-5; поведение Shift в MAME проверено замером карты
_kbdraw_down. Осталось подтвердить сам зацеп живой игрой; версии 2 и 3 ниже проверять только если он всё ещё не выйдет.
Симптом. Уровень 2, комната 9. Перепрыгнув на (1,1), Кид должен вернуться обратно: разбегаться негде, поэтому он встаёт на самый край плиты, прыгает с места и цепляется руками за (1,5), после чего подтягивается. У нас Кид с зажатым Shift всё равно срывается.
Что уже точно известно (и не надо перепроверять).
-
Физика прыжка у нас совпадает с оригиналом кадр в кадр. Сверено по логу SDLPoP против трассы харнесса при одинаковом старте
x=95:кадр 16 18 22 23 24 25 102 103 104 105 SDLPoP 95 97 105 112 121 126 128 130 131 133 наш 95 97 105 112 121 126 128 130 131 133Совпадает и по
y, и по колонке/ряду, и по приземлению на 107–108. -
В оригинале зацеп срабатывает на кадре 106, а не 102..105.
check_grabзовётся из ДВУХ мест: ветка «в воздухе» вcheck_action(кадры 102..105) иdo_fall(seg005) дляactions_4_in_freefall. Успешная попытка из лога:GRAB try f=106 x=135 y=166 col=4 row=2 fall_y=18 GRAB probe x=127 col=4 through=0 front_above=3 modif=0 GRAB can_grab=1 GRAB OK dist=9 -> f=91 x=136 y=181 col=5 row=2 act=2 (повис)Наш
do_fall(pop_map.c)check_grab()из этой ветки тоже зовёт — то есть структура на месте, расходится что-то внутри. -
По харнессу зацеп у нас РАБОТАЕТ: окно стартовых
x = 91…95, и короткий шаг ставит Кида ровно туда (91 после первого нажатия, 95 после второго). Зафиксировано тестомt_grab.
Отсюда главный вопрос был: почему харнесс говорит «работает», а живая машина — «нет». Расхождение между ними и оказалось уликой; версии выдвигались по убыванию правдоподобия, и сработала первая:
- Shift не доезжает до движка — ПОДТВЕРЖДЕНО, это и была причина.
Харнесс подменяет клавиатуру и потому этот путь не проверяет вовсе, а у
нас есть история проблем ровно с «Shift + стрелки» (KBD-1, BUG-KBD-3/4).
Замер в MAME: при зажатом Shift и зажатой стрелке бит
LShв_kbdraw_downстоял в нуле. Разбор — BUG-KBD-5. - Сцена харнесса не равна комнате 9. Там изолированная комната
(соседи — стена), а в игре слева комната 8; кромки шва участвуют в
get_tile. Проверять чтениемKid.xв момент прыжка: попал ли он в окно 91…95 вообще. - Расхождение в
check_grab. Наш вариант зовётdetermine_col()там, где оригинал зовётload_fram_det_col()(перезагрузка кадра + колонка). Для Кида это обычно одно и то же (cur_frameв фазе физики принадлежит ему), но проверить стоит.
Инструменты готовы. В SDLPoP включена отладка (пометка DBG-GRAB):
JMP — покадровая трасса прыжка/падения/виса, GRAB try|probe|fail|OK —
вход в check_grab и причина отказа. Снимается поиском по DBG-GRAB.
Найдено попутно, отдельным наблюдением. После касания площадки на
кадрах 107–108 (x=140) оба движка снова падают, но X расходится: SDLPoP
уводит Кида на 134 (колонка 4), мы — на 141 (колонка 5). Похоже на разную
отработку in_wall() у стены (2,7). На зацеп не влияет.
BUG-GATEMOD-1. Ворота стартуют закрытыми, хотя в уровне открыты — ЗАКРЫТ 2026-08-05
Проверено в игре (пользователь).
Симптом (пользователь, 2026-08-04). Уровень 2, комната 13: решётка между (2,5) и (2,6) обязана быть ОТКРЫТА в начале и захлопнуться, когда Кид нажмёт кнопку (2,4) — после этого назад дороги нет. У нас она закрыта сразу, кнопка бессмысленна, проход не работает.
Корень. Модификатор тайла в ФАЙЛЕ уровня и модификатор в РАНТАЙМЕ —
разные величины; оригинал переводит их при загрузке в load_alter_mod
(seg008:198E), которую зовёт alter_mods_allrm из load_level:
case tiles_4_gate: *modif = (*modif == 1) ? 188 : 0; break;
case tiles_11_loose:*modif = 0; break;
case tiles_10_potion:*modif <<= 3; break;
Наш pop_trob_modif портировал из неё только зелье. Для ворот
bg = 1 — это «открыты» (Table 8 спецификации DAT), а в рантайме открытость
измеряется высотой подъёма 0..188; мы клали в рантайм-модификатор сырую
единицу, то есть «закрыты на 1/188».
Фикс. Ветки ворот и loose дописаны в ленивую инициализацию
pop_trob_modif (pop_trob.c). Ветка СТЕН не портируется намеренно: у нас
pop_bg считает связи кладки по типам соседей прямо при отрисовке
(wall_modifier), сохранённый модификатор стены не читается.
Что это ещё задевает. Решётка (0,9) комнаты 5 уровня 1 тоже имеет
bg = 1, то есть обязана стартовать открытой — Кид сваливается в комнату 1
именно через неё, и она захлопывается у него за спиной. Закрывает её
стартовый триггер do_startpos (seg003:167): для уровней с
tbl_entry_pose == 1 оригинал ВИРТУАЛЬНО ЖМЁТ кнопку комнаты 5 (0,2) —
// Special event: press button + falling entry
get_tile(5, 2, 0); trigger_button(0, 0, -1); seqtbl_offset_char(seq_7_fall);
Замер в SDLPoP (лог по кадрам): gate(5,0,9) идёт 188 → 148 → 88 → 8 → 0,
шаги 40/60/80 — это gate_close_speeds, то есть быстрое закрытие
(trigger_gate вернул тип 3). У нас этот триггер портирован, и закрытие
работает.
Полный список ворот с bg = 1: ур. 1 комн. 5 (0,9); ур. 2 комн. 13 (1,5).
Остальные ворота уровней 1–3 имеют bg = 2 → 0, и для них ничего не
меняется (при модификаторе 2 и 0 и отрисовка, и can_bump_into_gate дают
одно и то же).
Побочная находка: чит обхода комнат отматывал мир. После фикса
пользователь увидел «ворота снова открылись», пройдя + в комнату 2 и -
обратно. Причина не в воротах: ROOMNAV звал pop_trob_reset() перед
enter_room, тот обнулял room_seen[], и pop_trob_modif() перечитывал
модификаторы из уровня заново — то есть чит откатывал открытые/закрытые
ворота, выдвинутые пики и нажатые кнопки. Пока ворота с bg=1 ошибочно
стартовали закрытыми, откат был не виден. pop_trob_reset() из навигации
убран: она обязана только телепортировать, исходное состояние даёт
перезапуск уровня. Замер, который это показал: room_modif комнаты 5
после +/- = 00 00 0B 00 09 00 08 01 00 BC — последний байт 0xBC = 188,
файловое значение.
BUG-GUARD-DEAF-1. Страж не оборачивался на вернувшегося Кида — ЗАКРЫТ 2026-08-05
Наблюдение (пользователь, 2026-08-05). Уровень 2, комната 11, идёт бой со стражем. Страж выталкивает Кида в правую комнату 22. Кид заходит обратно в комнату 11 — страж стоит на (1,1), повёрнут налево и Кида не видит. Предположение пользователя: это часть большой задачи «полностью переделать поведение стража по образцу Кида».
Диагноз: большая переделка тут ни при чём, корень маленький и точный. Само возвращение стража в исходную позу — ПРАВИЛЬНОЕ поведение, порт верен; не хватает ровно одного сигнала.
Разбор по SDLPoP:
- Выход Кида из комнаты «усыпляет» стража — так и в оригинале.
leave_guard(seg002:02F5) складывает стража обратно в данные уровня (тайл,x, направление, skill, HP), аenter_guard(seg002:0112) при возврате поднимает живого стража с УБРАННЫМ мечом (sword_0_sheathed+seq_77_guard_stand_inactive) и обнуляетis_guard_notice/guard_refrac. То есть страж после возврата ВСЕГДА неактивен и смотрит в запомненную сторону — у нас так же (pop_guard_enter,pop_guard.c:127). - Дальше решает
autocontrol_guard_inactive(seg002:0876), и он Кида за спиной ИГНОРИРУЕТ. Кид вернулся справа, страж смотрит влево →char_opp_dist()отрицательна → веткаelse if (distance < 0) return;. Единственный выход из неё — флагis_guard_notice: «Кид нашумел». При нём страж оборачивается (move_4_down). Направление вcheck_can_guard_see_kidНЕ участвует вовсе, так что «не видит» — это не про луч видимости, а именно про этот флаг. - У нас
is_guard_noticeне взводится НИГДЕ.grepпо всем исходникам roomtest: объявление (pop_guard.c:20), сброс (pop_guard.c:47) и одно чтение (guards.c:171). Присваивания= 1нет ни одного — флаг мёртв, поэтому неактивный страж не обернётся НИКОГДА, что бы Кид ни делал.
Где его взводит оригинал (это и есть объём фикса):
| место | событие |
|---|---|
seg006:633..641 — play_seq, опкод SOUND |
звук SND_SILENT(0), SND_FOOTSTEP(1), SND_BUMP(2). SND_DRINK(3)/SND_LEVEL(4) — НЕ шум. Главный источник: шаги бега/приземления |
seg004:05F1 bumped_sound |
удар в стену |
seg005:185,195 |
мягкое и среднее приземление (только charid_0_kid) |
seg006:1294, seg006:1734 |
Кид обрушил loose-плиту (зацепом и наступив) |
seg007:766 |
нажата кнопка |
У нас опкод SOUND в play_seq (pop_kid.c:426) просто съедает байт
аргумента — звука нет, и флаг вместе с ним потерялся. Именно эта строка —
90 % фикса: SND_SILENT называется «silent» потому, что звука не издаёт,
но стражи его всё равно замечают, и в seqtbl он стоит, например, в
ready (доставание меча).
Ожидаемое поведение после фикса. Кид возвращается в комнату 11 бегом →
первый же SND_FOOTSTEP взводит флаг → страж оборачивается и достаёт меч.
Стоя на месте, Кид может подкрасться к стражу со спины — это НЕ баг, а
механика оригинала.
Оговорка (не проверено вживую). Разбор построен на том, что страж после
возврата неактивен (меч убран, кадр 166). Это следует из кода
pop_guard_enter, но в MAME не снималось; если окажется, что меч у него
ОБНАЖЁН, то работает другая ветка (autocontrol_guard_active, где
can_guard_see_kid == 2 направления не спрашивает) — и тогда корень другой.
Снять при фиксе: Guard.sword, Guard.frame, can_guard_see_kid сразу
после входа в комнату.
Фикс (2026-08-05): все пять мест портированы. Опкод SOUND в play_seq
(pop_kid.c) взводит флаг для звуков 0..2; bumped_fall/bumped_floor,
мягкое и среднее приземление, обрушенная плита (все три пути check_press)
— в pop_map.c; щелчок кнопки — в pop_trob.c. Заглушка is_guard_notice
добавлена в tests-host/stubs.c (автопилота стража в наборах нет).
Проверено в игре (пользователь, 2026-08-05): тот же сценарий — страж выталкивает Кида в комнату 22, Кид возвращается бегом — страж оборачивается.
Что осталось «как в оригинале» и багом не является: подкрасться к
стражу СТОЯ по-прежнему можно (шумит движение, а не присутствие), и после
возврата в комнату страж всегда поднимается с УБРАННЫМ мечом в неактивной
стойке, повёрнутый туда же, куда смотрел при выходе Кида — это enter_guard
(seg002:0112), а не потеря состояния.
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) — плита падает, но на её месте остаётся чёрный бар. Два уточнения пользователя оказались диагностическими:
- «бар попал в фоновое изображение» — Кид прыгает поверх, уходит, бар остаётся → чернота лежит в ОЗУ-копии, и heal возвращает её каждый кадр;
- «когда возвращаемся в комнату после выхода — бара нет» → статическая отрисовка комнаты рисует всё правильно, виновата ЗАПЕЧКА.
Корень. pop_ceil_bake_empty стирал полосу потолка чёрной плитой
(bar, банк NORMAL — пишет и в ОЗУ-копию) шириной 64 px, а восстанавливал
только ДВА тайла ряда −1: (−1,col) и (−1,col+1). Но куски тайлов
рисуются ВВЕРХ от своей нижней грани и свисают ВПРАВО, поэтому в стёртую
полосу попадает графика и соседа СЛЕВА, и тайлов ряда 0 — их верхушки.
Незакрашенным оставался прямоугольник ~13×3 px.
Замер (он же метод). Плита роняется без игры — записью
pop_ceil_modif[col] = 1 в отладчике MAME (это ровно то, что делает
make_loose_fall для потолка). Дальше попиксельная сверка скриншота
«после запечки» со скриншотом «комната перерисована заново» (выйти и
вернуться): различие локализовалось в прямоугольник экранные x 230..255,
y 47..52 при полосе потолка y 44..52.
Фикс. Запечка восстанавливает всё, чья графика попадает в полосу:
ряды −1 И 0, колонки col−1..col+1.
Проверено: та же попиксельная сверка после фикса даёт 0 различий в полосе; пользователь подтвердил в игре.
BUG-RJUMP-1. Разбег-прыжок не берёт провал в три тайла — ЗАКРЫТ 2026-08-04
Симптом (пользователь, приёмка уровня 2). Комната 1: Кид с разбегу обязан перелететь колодец с (0,5) на (0,1) — у нас он долетает до колодца и валится вертикально вниз. Комната 9: то же с (1,5) на (1,1). Обобщение пользователя оказалось точным: провал ровно в три пустых тайла наш Кид не перепрыгивал никогда, а провалы поменьше брал.
Почему «никогда», а не «иногда». Суммарный dx последовательности
seq_4_run_jump (кадры 34..44) — 62 пикселя при ширине тайла 14. Это
ровно 4 колонки и меньше половины тайла запаса. То есть перелёт трёх
пустых тайлов возможен ТОЛЬКО если оттолкнуться почти точно от кромки; из
случайной фазы бегового цикла он не получается никогда.
Корень. Оригинал именно поэтому и не даёт прыгать откуда попало:
run_jump (seg005:0AA8) перед стартом выравнивает Кида по кромке.
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». Полировкой это не оказалось: без выравнивания три тайла непроходимы в принципе.
Две тонкости, которые легко потерять при порте.
- Беззнаковое сравнение.
(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. В порте она записана как мёртвая, с объяснением. - Отказ НЕ гасит
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;
}
У нас все они падали в общий else → seq_7 (stepfall). Разница между
seq_7 и seq_81 в seqtbl.c и объясняет ВЕСЬ симптом:
seq_7 stepfall : dx(1) dy(3) | 102 | dx(2) dy(6) | dx(-1) dy(9) | dy(12) | dx(-2) set_fall(1,15)
seq_81 fightfall : dy(-1)| 102 | dx(-2) dy(6)| dx(-2) dy(9) | dx(-1) dy(12) | dx(-3) set_fall(0,15)
set_fall(1, 15) против set_fall(0, 15) — горизонтальный дрейф: в
seq_7 во время всего свободного падения Char.x уходит на 1 в сторону
КАЖДЫЙ кадр. За два этажа падения это и есть тот самый «почти тайл».
Оригинал в бою падает строго вниз.
Сверка по числам (лог SDLPoP DBG shot, кадры 102..105):
SDLPoP: 102 x=155 103 x=157 104 x=159 105 x=160 ← +2 +2 +1 = seq_81
у нас: 102 x=151 ← seq_7
Обратный счёт: у SDLPoP в момент решения x = 150, дальше
char_dx_forward(-5) при dir=-1 даёт +5 → 155. У нас решение при
x = 152 — то есть по самому правилу срыва расхождения нет: обе
позиции лежат в колонке 7 (dx_weight = x + 14, кадр 157 имеет
weight_x = 14; колонка 7 — это x ∈ [149,163)). Двухпиксельная разница
— фаза отхода (шаг отступления dx(-3)+dx(-2) = 5 пикселей за цикл), а она
зависит от RNG стража и между движками совпасть не обязана. «Раньше
времени» — это не срыв не там, а seq_7 вместо seq_81.
Что подтвердилось попутно. Старт уровня 2 у нас байт в байт как в
SDLPoP: frame=15 x=107 y=118 dir=-1 col=3 row=1 room=5. Таблицы кадров и
seqtbl вынуты из оригинального бинарника, так что расходиться могут
только РЕШЕНИЯ движка — искать надо всегда там.
Связь с BUG-LAND-SWORD-1. Тот фикс (land()
даёт seq_63 при вынутом мече) остаётся — он есть в оригинале, — но для
Кида он теперь почти недостижим: start_fall убирает меч в ножны, и после
приземления кадр 109 разбирает обычный control_crouched. То есть
настоящий корень вечного приседа был здесь, а не в land().
Метод. Пустышка pop_dbg_trap() в резиденте W1 (идея пользователя):
брейкпоинт на банковый код ставить нельзя — 0xC000+ это окно, куда мапятся
все банки, и точка ловит чужие функции. Оставлена в pop_state.c как
многоразовый инструмент.
BUG-CTRL-FRAME-1. Геометрия Кида считалась по кадру СТРАЖА — ЗАКРЫТ 2026-08-04
Самый неприятный класс: не косметика, а неверные РЕШЕНИЯ движка, причём плавающие — одна и та же поза Кида давала разный результат в зависимости от того, в какой фазе анимации находится страж.
Симптом (приёмка уровня 2, комната 4). Кид под сплошной плитой (1,6), справа от него дыра от упавшей loose-плиты. По ↑ он то прыгает вверх впустую (упираясь головой в плиту), то пытается зацепиться — но не с той координаты X, с которой запрыгивает оригинал. Наблюдение пользователя, оказавшееся точным: «похоже, дело в страже — в комнатах без стража то же самое рисуется и работает правильно».
Замер (брейкпоинт на входе pop_jump_up_seq, состояние снято в момент
решения).
_Kid frame=15 (стойка) x=156 y=181 dir=влево curr_col=6 curr_row=2
кадр 15 Кида (kid_data.bin): dx=0 weight_x=3
pop_gframe (кадр СТРАЖА, image 17): dx=-1 weight_x=8
Считаем dx_weight() обоими кадрами:
| кадр Кида (правильно) | кадр стража (что было) | |
|---|---|---|
dx_weight() |
156+3 = 159 | 156+9 = 165 |
m7() |
(159−7−58)/14 = 6 ост. 10 | (165−7−58)/14 = 7 ост. 2 |
curr_col |
6 | 7 ← намерено |
distance_to_edge_weight() |
10 | 2 |
ветка jump_up_or_grab |
10 ≥ 6 → шаг назад + зацеп | 2 < 6 → jump_up_plain |
| результат | x 156 → 160, seq_24/8 |
x без изменений, seq_28 ← намерено (A=28 на выходе) |
Обе строки «намерено» совпали с предсказанием по кадру стража до единицы — диагноз подтверждён, а не выведен.
Корень. cur_frame (pop_kid.c:200) — ОДИН глобал на всех персонажей
(так и в оригинале), его владелец — тот, кто последним прошёл load_frame.
В нашем кадре последним тикает страж (pop_guard_tick), поэтому к моменту
управления Кидом там лежит кадр стража. А kid_cur_dx/dy/flags читают этот
глобал напрямую, и через него считается ВСЯ геометрия управления:
dx_weight → determine_col → distance_to_edge_weight →
get_edge_distance → выбор ветки в check_jump_up.
Оригинал страхуется явно и симметрично: play_kid_frame (seg000:1209) и
play_guard_frame (seg000:1246) сразу после loadkid/loadshad зовут
load_fram_det_col() (= load_frame + determine_col, seg006:0144) —
ДО control(). У нас этого вызова не было; в pop_ctrl_tick стоял
комментарий «кадр не перезагружаем — play_seq в kid_tick сделает это
следующим шагом», и он был неверен: control() читает cur_frame РАНЬШЕ,
чем play_seq его обновит.
Фикс. pop_load_fram_det_col() (pop_kid.c) — порт связки; зовётся
после pop_loadkid() в pop_ctrl_tick и после pop_loadshad_and_opp() в
pop_guard_tick. Вторая половина связки (determine_col) выполняется
только на ветке Кида: determine_col у нас существует лишь для него —
pop_map работает с Kid напрямую, а не с абстрактным Char.
Что это объясняет задним числом. Плавающее поведение прыжка — кадр
стража меняется каждый тик, вместе с ним dx/weight_x, и distance
Кида скакал через порог 6. А «голова Кида поверх плиты (1,6)», с которой
начался разбор, — не баг отрисовки: Кид просто оказывался в позе, которой
в оригинале в этом месте не бывает.
Урок. Любой глобал, который в оригинале «принадлежит активному
Char», у нас обязан перезагружаться на КАЖДОМ входе в окно Char — иначе
между персонажами течёт состояние, и баг проявляется только когда в комнате
есть второй персонаж. Здесь такой глобал уже ловили однажды: см. запись
про кэш кадра отрисовки в load_frame (pop_kid.c:342).
BUG-LAND-SWORD-1. Кид навсегда застревает в приседе после падения с мечом — ЗАКРЫТ 2026-08-04
Симптом (приёмка уровня 2). Страж ударил Кида, Кид потерял HP, провалился через loose-плиту на этаж ниже — и сел в присед, из которого не выходит: клавиши не действуют вообще.
Что показало ЖИВОЕ состояние в MAME (мост mame-z80, чтение по
адресам из roomtest.noi — окно застало багу в момент):
_Kid frame=109 x=144 y=181 dir=-1 col=6 row=2 action=1 room=4
sword=2 (ВЫНУТ) alive=-1 curr_seq=0x2037
_holding_sword=1 _can_guard_see_kid=0 control_x/y/shift = 0
curr_seq = 0x2037 — это внутри метки softland_crouch
(SEQTBL_BASE + 1736 = 0x2036). Смотрим seqtbl.c:916:
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=61−57=4≥0 → check_bumped не бампит), но уже на пороге ухода. - Kid оседает на 57 из-за recoil отскока: seq_47 =
act(bumped), dx(-4), frame_50, 51, 52; SEQ_DX(−4) двигает Char.x на −4 суммарно; frame_50.dx=4 компенсирует ТОЛЬКО точку коллизии НА кадре 50, но при возврате в стойку (frame15, dx=0)char_x_right = Char.x = aligned−4 = 57.
Что было ИСКЛЮЧЕНО (сверено с исходниками SDLPoP, НЕ причина)
- Формула char_x:
char_x_right = obj_x/2+58 = Char.x+frame.dx(seg006 set_char_collision) — совпадает с нашим char_dx_forward. - Позиция грани ворот:
get_left_wall_xpos = wall_dist_from_left[1](10) + xpos_in_drawn_room(x_bump[9+5])+7 = 10+(184−140)+7 = 61— совпадает с нашим (x_bump[−1+5]=44, +7, +10 = 61). - Порог leave: SDLPoP leave_room looking-right
char_x_right<=57— совпадает. - Данные кадров: frame_50 (image=49,dx=4,flags=0x67) и frame_15 (image=14,dx=0,flags=0x43,sword=9) — БАЙТ-В-БАЙТ как в SDLPoP frame_table_kid.
- seq_47 (act bumped, dx(-4), frame 50/51/52) — совпадает.
Почему точечные фиксы НЕ работали
- Гард в check_leave (подавить leave на закрытых воротах) — это ОТСЕБЯТИНА, не SDLPoP (в leave_room такого нет); откачено.
exit_room_timer=2(порт seg002 exit_room — РЕАЛЬНЫЙ механизм, оставлен как pop_leave_timer): блокирует leave 2 кадра после входа в комнату. НЕ спасал: положение Char.x=57 устойчивое (Kid стоит), а не transient.
Задел, который НЕ понадобился: порт coll_room + отложенный drawn_room
Трасса показала, что в эталоне Char.room/drawn_room вообще не меняются,
поэтому план S2/S3 для этого бага не потребовался. Остаётся заготовкой под
стражей/двух персонажей в кадре:
- Раздельные комнаты.
Char.room≠drawn_room. Уже естьkid_room(S1) + рендер-смещениеpop_kid_set_render_dx(∓140). - Коллизия по Char.room через coll_room (seg004, S2). Портировать
check_collisions→get_row_collision_data→get_left_wall_xpos/get_right_wall_xpos→curr_row_coll_room[]/_flags[],bump_col_left_of_wall/bump_col_right_of_wall→check_bumped_look_*→bumped(). Тайлы — из РЕАЛЬНОЙ комнаты колонки, НЕ из снапшота. - Отрисовка по drawn_room, персонаж со сдвигом ±140.
- Отложенная смена drawn_room (seg002/seg000, S3):
leave_room→goto_other_room→exit_room(next_room) →check_the_end.
Реализовано на 2026-07-22: S1 + pop_leave_timer.
Память: pop_seam_room_model, sdlpop_odd_pixel_char_x.
Решено НЕ делать
OPT-1. Хирургический редрой левого шва (ворота соседа) — 2026-07-22
Возможность: pop_room_redraw_seam_left() (pop_bg.c) на каждое изменение
openness рисует bar(BLACK) по всему col0 + полный draw_tile(0,0)
(стены, topright, wall_pattern с prandom() — десятки блитов). Реально
анимируются только бары решётки — draw_gate_back (~9 env_b).
Стоимость: seam-блок (синий io_border) занимает ~30-50% кадрового периода,
но только пока openness меняется — во время открытия и медленного
авто-закрытия (~5 сек после схода с кнопки). В покое — 0%. Замерено в MAME:
брейк на _pop_room_redraw_seam_left (0x5A54) срабатывает ⟺ сегмент дорогой.
Приём (heal НЕ годится: печёные бары устаревшие, поэтому и стоит
bar(BLACK)+redraw): bar(BLACK) только по полосе баров (x=0,
gate_top..gate_bot) + draw_gate_back(lmod,...) + дорисовать статику тайла
(0,0), задетую полосой. Пиксель-чувствительно — обязательна выверка в
MAME по кромкам.
Решение: оставляем как есть — стоимость транзиентная, в бюджет помещаемся. Делать, только если упрёмся в кадровый бюджет на сценах с воротами.
Исправлено
-
Спуск с кнопки (room8, кромка (0,6)): Кид просвечивал в щель, ближняя рука срезана до одного пикселя — ИСПРАВЛЕНО 2026-07-28. Две причины: (а) не был портирован
clip_char()(seg006:1749) — верхняя обрезка спрайта поy_clip[curr_row+1], когда тайл над головой стена/пол; сделано (pop_clip_char_topв pop_map.c +gfx_blit_cols_partв libbgi, heal чистит уже обрезанный прямоугольник); (б)climb_overlay_tileвыбирал веткуdraw_floor_overlay(seg008:1E3A) по СЫРОМУ коду тайла — а нажатая кнопка вget_tile_to_draw(seg008:240) подменяется на floor/stuck. Тайл-кнопка не проходил тест floor, уходил вdraw_other_overlayи закрашивал Kid ЦЕЛЫМ тайлом вместо узкой кромкиfloor_left_overlay[frame-137]. Фикс —tile_code_drawn()(одна подстановка на все слои). Проверено покадрово в MAME (кадры 148..138). Этим же фиксом закрыт BUG-3 (см. выше). -
Шов ворот жёг 50% кадра в покое (закрытая решётка) — ИСПРАВЛЕНО 2026-07-22. Причина — баг кодогенератора SDCC z80 (memory
sdcc_z80_cmp_store_a_bug):if (m[9] != seam_sig) seam_sig = m[9];компилировался вsub (seam_sig)(A ← разность) +ld (seam_sig),a— сохранял РАЗНОСТЬm[9]-seam_sig, неm[9].seam_sigосциллировала (напр. 25↔231),m[9]!=seam_sigистинно каждый кадр →draw_tile(0,0)каждый кадр даже у неподвижной решётки. Фикс: store-до-сравнения (seam_sig = g;из чистогоgДОsub), подтверждён в .asm. Прочёс всех модулей PoP: других случайных compare-then-store нет. -
Пики: 2×полный
draw_tileна кадр анимации → хирургический редрой, 2026-07-22.pop_spike_redraw:heal_offуже возвращает всю печёную статику (база 127, пол, грани); поверх анимируются только два острия (SPIKES_FRAM_LEFTв своей ячейке +SPIKES_FRAM_RIGHTв соседней). Заменили 2×draw_tileна 2×env_b— пиксель-в-пиксель тот же результат, в разы дешевле (шахта из нескольких пик больше не съедает полный кадр). -
Отрисовка нажатой кнопки (0,2)/(0,3) — ИСПРАВЛЕНО. Причина:
fore_tile(pop_bg.c, fore-слой поверх Kid) рисовал переднюю грань КНОПКИ (bottom_id 149) поверх уже нарисованной грани пола (43) — «остаток нажатой кнопки». Фикс:fore_tileприменяет ту же подстановку нажатой кнопки, что иdraw_tile(opener→floor / closer→stuck при таймере связи >1). Плюсpop_button_redraw— wipe своей ячейки + правой грани (дальний угол в 0,3), низ строго yb+64 (не залезать в стену ряда 1).
НЕ БАГИ (кривая картинка, но совпадает с оригиналом — НЕ чинить)
-
Кадр падения с мечом: голова/руки поверх кромки пола — САМ РЕНДЕР верен. Сверено с SDLPoP v1.24 (2026-08-04): там кадр падения выглядит один в один. ⚠ Речь ТОЛЬКО о виде кадра. Расхождение по X, которое было отмечено здесь как открытое, разобрано и закрыто: BUG-FALL-SWORD-1 — дело не в моменте срыва (он верен), а в том, что мы играли
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=3dx_weight()=char_dx_forward(dx − weight_x), а при взгляде ВЛЕВО знак меняется — точка веса уезжает на 13 px ВПРАВО отChar.x. Замер нашего кадра падения:x=151, взгляд влево →dx_weight = 164→m7(164)= колонка 7, а (1,7) — дыра.check_on_floorчестно видит «под ногами не пол». С обычной стойкой (weight_x=3) вышло бы 154 → колонка 6 → пол.То есть с мечом персонаж «стоит» на 10 px правее, чем выглядит, и кромку переступает раньше. Данные кадров у нас совпадают с
frame_table_kid(seg006:127) байт в байт,swordfight/back_with_swordпортированы дословно (включаяcontrol_backward = CONTROL_IGNORE— один нажим = один шаг). Чинить нечего.Важное отличие от остальных записей этого раздела. Там (труп стража, падающая плита) картинка кривая, и совпадает с оригиналом лишь потому, что оригинал сам так рисует — артефакт порядка midtable. Здесь картинка ПРАВИЛЬНАЯ: Кид реально уже за кромкой, и спрайт перекрывает её ровно настолько, насколько персонаж туда зашёл (наблюдение пользователя, 2026-08-04). Геометрия и рендер согласованы; «неправильно выглядит» — только если считать позой то, что видит глаз, а не точку веса.
⚠ Не путать с тем, что БЫЛО нашим багом рядом: расширение футпринта перерисовки под клинок (
redraw_at_char, seg003:0430) — его не было, и меч оставлял след/лез поверх столба. Исправлено 2026-08-04. -
Голова стоящего Кида поверх падающей на него loose-плиты. Комната 12: зацеп не удался, Кид остался стоять, сбитая плита падает прямо на него — голова рисуется ПОВЕРХ плиты. Сверено покадрово с SDLPoP v1.24 (2026-07-29): там ровно то же самое. Артефакт оригинального движка (порядок midtable), а не наш баг; «починка» увела бы от эталона. Отличать от соседних случаев, которые БЫЛИ нашими багами и исправлены: вис/подтягивание на кромке плиты и падение вместе с плитой — там плита обязана быть поверх Кида.
-
Ноги стоящего Кида поверх головы лежащего трупа стража. Комната 21: страж убит, комната покинута и открыта заново (у трупа своя запомненная X), Кид СТОИТ на одну колонку правее тела — его ноги рисуются поверх головы. Проверено в SDLPoP (2026-08-03): там ровно то же.
Корень — устройство движка, а не наш порт.
set_objtile_at_char(seg006:13F3) приписывает персонажа РОВНО ОДНОМУ тайлу, и ширина спрайта на выбор тайла не влияет никак; дальшеredraw_needed_tiles(seg008:1B06) обходит тайлы рядами 2,1,0 и колонками 0..9, и кто позже — тот поверх. Стоящий Кид укладывается примерно в колонку, поэтому у него это незаметно, а лежащий труп занимает две-три колонки, но «принадлежит» левой. Всё, что стоит правее, перекрывает выступающую часть тела. Механизма «широкий объект участвует в нескольких тайлах» в оригинале нет — сверено сdraw_objtable_items_at_tile,sort_curr_objsи веткойtile_object_redraw == 0xFF(последняя про оверлеи пола, не про персонажей).Отличать от того, что БЫЛО нашими багами в этом же месте и исправлено (BUG-DRAWORDER-1): колонка трупа считалась из тайла вместо X, ветка
actions_1_run_jumpне была портирована, окно fore-клипа затиралось стражем. Если когда-нибудь захочется «починить» и это — минимальный вариант — брать тайл по ЦЕНТРУ габарита, а не по левой границе; но это осознанный отход от эталона, и в других позах порядок поменяется в обратную сторону. -
Комнаты 13, 18, 24 недостижимы в обычной игре — свойство ДАННЫХ уровня 1, не наш баг. Обход графа от стартовой комнаты (по
res2001.bin, links @1952) показывает: у всех трёх ссылки наружу есть, а на них не ссылается никто (24:L→9, но у 9R=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 для навигации, потиковые
трассы, скриншоты.
Оговорка о полноте проверки. Каждый корень закрыт тем, что его СОБСТВЕННЫЙ
сценарий больше не воспроизводится; сквозного прохождения уровня и поиска
регрессий в соседней механике автоматика не делала. Чек-лист для ручной
перепроверки — в BUGS_OPEN.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).
Два корня.
check_bumpedбрал ОДНУ колонку — ту, в которой оказался передний край. Для решётки в col9 окно этой колонки всего 4 px (201..204), беговой кадр его перескакивает — бампа нет, аcheck_leaveтут же уводит в соседнюю комнату. Оригинал (check_collisions, seg004:0004) считает флаги перекрытия для ВСЕХ колонок ряда и берёт колонку с переходом флага 0→1.- Наш 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), см.
BUGS_OPEN.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) в декодере трамплина).
-
Зажать LShift → байт 2 карты =
04(LSh взведён), поток12 12 12 …(Shift автоповторяется сам, пока он последняя нажатая клавиша). -
Добавить ↑ → байт 46 =
20(↑ взведена), байт 2 =00— Shift снят, хотя физически зажат. -
Добавить ещё → байт 46 =
30(обе стрелки видны, 3-key rollover в порядке), Shift по-прежнему00. -
КОРОТКИЙ тап ↑ при зажатом Shift — Shift выживает. Это и сбивало с толку: бит сносился, но тут же восстанавливался, потому что после отпускания стрелки Shift снова становился «последней клавишей» и его автоповтор
12взводил бит обратно за ~30 мс. -
Сам поток при зажатом 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 байта):
- исключать модификаторы из сброса — потерянный break Shift снять нечем, Shift залипает навсегда (BUG-KBD-3);
- сбрасывать всю карту, как 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. Кид рисовался поверх тела стража — ЗАКРЫТ
Симптом. В оригинале Кид проходит ЗА телом убитого стража; у нас — перед ним.
Три разных корня, найденные по очереди. Стоит того, чтобы перечислить: два первых захода были неполны, и каждый следующий кадр от тестера вскрывал новый слой.
-
Порядок задавался ролью, а не тайлом. Мы безусловно рисовали
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. -
Своя же регрессия: Кид полез поверх передних столбов. Окно fore-клипа (
pop_fore_set_clip) одно на всех, и его ставит каждый, кто рисует персонажа. Пока страж рисовался строго ДО Кида, к моментуpop_fore_over_kidв окне оказывался Кид и всё сходилось само. Как только порядок стал переменным, при «страж поверх» окно оставалось СТРАЖЬИМ, и fore-проход Кида отсекался целиком. Фикс:kid_fore_clip_restore()— окно восстанавливается явно, а не «по счастливому порядку вызовов». -
Колонка трупа была нулевой.
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. -
Ветка
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). Пока
поводов для этого нет — заводить обратно только по живому наблюдению.
Прогон всех комнат уровней 1 и 2 (пользователь, 2026-08-07) — ЗАКРЫЛ ТРИ РЕВИЗИИ РАЗОМ
Результат прогона: крупных багов нет. Тем самым закрыты и переехали сюда
из BUGS_OPEN.md три накопившихся хвоста — чек-листы ручной перепроверки
фиксов, таблица обхода 24 комнат уровня 1 и таблица сырых наблюдений прогона
2026-08-03. Всё, что с прогона 2026-08-07 осталось открытым, — три записи
уровня 2 в BUGS_OPEN.md (BUG-GUARD-COLOR-1,
BUG-GUARD-SPLASH-1, BUG-CHEAT-FIGHT-1); ни одна из них не мешает играть.
Сырые формулировки пользователя лежат рядом: bugs_level1.md
и bugs_level2.md.
Ручная перепроверка фиксов (2026-08-03) — ПРОЙДЕНА
Шесть корней прогона 2026-08-03 были закрыты автоматической проверкой в MAME
(мост mame-z80: чтение _Kid, потиковые трассы, скриншоты) — этого хватает,
чтобы показать, что конкретный сценарий больше не воспроизводится, но НЕ
хватает, чтобы поймать регрессии в соседней механике. Отсюда список сценариев
ровно в тех формулировках, в которых баги были заведены. Прогон 2026-08-07
прошёл все комнаты уровней 1 и 2 и ни одного из этих симптомов не показал.
| # | что проверялось | ожидаемо |
|---|---|---|
| 1 | комната 22: выпить зелье (0,6), выйти в 16/23 и вернуться | кувшина нет, пузырька над пустым местом нет |
| 2 | то же для комнат 14 (0,5) и 17 (2,3) | так же |
| 3 | комната 15: подобрать меч (2,2), выйти и вернуться | меча нет; кладка на дальней стене НЕ мигает |
| 4 | комната 12: разбить плиты (0,1)/(0,2) и потолок в 16, выйти-вернуться | остаются разбитыми, проём не закрывается |
| 5 | комната 17 из 23: разбить (1,5)/(1,6), выпить зелье (2,3), вернуться | всё остаётся |
| 6 | после смерти зайти в те же комнаты | ВСЁ восстановлено (в оригинале смерть = load_level) |
| 7 | комната 12: разбег в закрытую решётку (0,9) с полушага | не проходит насквозь; перелистывание экрана штатно (см. BUG-SEAM-DRAW-1) |
| 8 | комната 6: бег справа налево от (0,9), длинный прыжок (0,6)→(0,7) | не влетает внутрь стены |
| 9 | комната 5: с кнопки (0,6) падение на (2,7), присед, вставание | остаётся в комнате 5 (проверено трассой: fr=111 x=177 → seq_47 → fr=15 x=173) |
| 10 | комната 5: нажать кнопку (0,4) | поднимаются ОБЕ решётки — (0,5) видно на экране, (0,9) проверять из комнаты 1 |
| 11 | страж (комнаты 3, 21) убивает Кида | смерть доигрывается, респавн в стартовой позиции уровня; цикла «убил-воскрес» нет |
| 12 | клавиатура: долгая игра с Shift+стрелка | ↑ и Shift не залипают; осторожный шаг не превращается в бег |
| 17 | [BUG-KBD-4] долго играть Shift+стрелка, много тапов подряд | Shift не «отваливается»; если однажды залипнет — снимается первым же нажатием стрелки |
| 18 | [BUG-RESPAWN-2] убить стража (комнаты 3/21), умереть, вернуться в ту же комнату | страж снова жив, стоит на исходном тайле, HP полные |
Отдельно смотрели регрессии от порта check_collisions — он трогает всю
горизонтальную коллизию: бамп в стену на бегу и в прыжке, осторожный шаг у
стены, проход через ОТКРЫТЫЕ ворота, разворот в проёме решётки, переходы через
швы (старый BUG-SEAM-PINGPONG). Не всплыло.
Обход всех 24 комнат уровня 1 — ЗАКРЫТ прогоном 2026-08-07
Инструмент: #define ROOMNAV в roomtest.c — +/- (цифровой блок либо
=/- основного ряда) переключают комнату по номеру (1..24, с обёрткой),
Kid ставится на первый пол. Номер комнаты — полосками в верхнем борте: слева
десятки, справа единицы (|| |||| = 24). Инструмент остаётся в сборке:
он же нужен для приёмки уровня 3.
Таблица заполнялась по ходу отладки и осталась незакрытой на 19 строк из 24; её закрыл ручной прогон всех комнат уровней 1 и 2 (2026-08-07, крупных багов нет). Ниже — то, что таблица успела зафиксировать: это не «отчёт по комнатам», а разбор корней, полезный при похожем симптоме.
| комната | статус | что не так |
|---|---|---|
| 1 | пофикшено | падающая плита (2,6): правая грань видна через пол (2,7) и перекрывает его переднюю грань — mob_render брал ряд соседа из m->row (счётчик, уже ушедший на ряд вперёд), а не из координаты |
| 5 | пофикшено | прыжок в решётку: Kid оставался стоять на 6 px ВЫШЕ пола и без приземления-приседания — от bumped() (seg004) был портирован только хвост (seq_47), не хватало bumped_floor (прижатие Y к полу + seq_46_hardbump на кадрах прыжка 24/25/40..42/102..106) и bumped_fall |
| 9 | сделано | дверь уровня (1,3)-(1,4) рисовалась чёрным проёмом: не был портирован draw_leveldoor (seg008:1D29) — створка (слайсы 33 + верх 34), лестница за ней (99/144) и анимация подъёма по кнопке (animate_leveldoor, seg007:05F1, modif 0→43). Спрайты 33/34/99/144 добавлены в атлас явным набором (render_room.py дверь не рисует) |
| 12 | пофикшено | вис/подтягивание на кромке loose-плиты: плита рисовалась ПОД Кидом. Не хватало двух кусков draw_tile: (а) draw_loose кладёт нижнюю грань плиты И в foretable (поверх персонажа), (б) draw_tile_base подставляет верх плиты из loose_fram_left, а в нашем midtable-оверлее стоял голый base_id (у loose он 0). Голова Кида поверх падающей на него плиты — см. «НЕ БАГИ» выше |
| 15 | сделано | меч (2,2) не рисовался: тайл 22 в draw_tile_anim не был портирован. Добавлены отрисовка предмета (chtab_1 id 10/11 на draw_main_y−3), подъём по Shift (check_get_item/get_item/do_pickup: присед → seq_91 pickupsword → меч исчезает с пола) и статус pop_have_sword |
| 13, 18, 24 | недостижимы в игре | свойство данных уровня, разбор — «НЕ БАГИ» выше |
Сырые наблюдения (прогон 2026-08-03) → корень
Одиннадцать формулировок с прогона свелись к шести корням; все шесть закрыты и проверены в MAME — разборы выше по файлу.
| # | наблюдение (кратко) | корень |
|---|---|---|
| 1 | кувшин выпит, а пузырёк рисуется / кувшин возвращается (14, 22, 17) | BUG-LVLSTATE-1 |
| 2 | меч возвращается в 15 / мигает контур кладки | BUG-LVLSTATE-1 |
| 3 | комната 12: пробегает сквозь закрытую решётку (0,9) | BUG-COLL-1 |
| 4 | после respawn плиты остаются разбитыми, зелья выпитыми | BUG-RESPAWN-1 |
| 5 | плита/зелье возвращаются и БЕЗ respawn (12→16, 17, 22) | BUG-LVLSTATE-1 |
| 6 | комната 6: длинный прыжок (0,6)→(0,7) — влёт в стену, респавн | BUG-COLL-1 |
| 7 | залипает ↑ | BUG-KBD-3 |
| 8 | залипает Shift | BUG-KBD-3 / BUG-KBD-4 (поправка — BUG-KBD-5) |
| 9 | комната 5: кнопка (0,4) не открывает решётку (0,5) | BUG-GATE-ANIM-1 |
| 10 | комната 5: с кнопки (0,6) на (2,7), присед — провал в комнату 6 | BUG-STANDUP-1 |
| 11 | страж убил Кида → Кид воскресает на месте и его убивают снова | BUG-DEATH-1 |
Вторая волна того же прогона (вечер) дала ещё четыре наблюдения — BUG-KBD-4, BUG-RESPAWN-2, BUG-DRAWORDER-1, BUG-LOOSE-2, — все закрыты, разборы выше. Тогда же закрыт как невоспроизводящийся BUG-SEAM-DRAW-1 и заведён BUG-GATE-PASS-1 (он закрыт ниже, 2026-08-09).
BUG-GUARD-SPLASH-1. Нет «брызг» при попадании по стражу — ЗАКРЫТ 2026-08-07
Как было заведено (пользователь, 2026-08-07). При попадании по стражу должен рисоваться сплеш («звёздочка»), чтобы игрок видел, что удар дошёл до цели, не переводя взгляд на полосу HP. У Кида такое есть, у стража — нет.
Корень. draw_hurt_splash (seg006:16CE) в оригинале зовётся ИЗ ДВУХ
мест: draw_kid при hitp_delta < 0 (seg008:1643) и draw_guard при
guardhp_delta < 0 (seg008:1654). У нас была портирована только первая
половина — kid_draw_splash(); для стража вызова не было вовсе, хотя
guardhp_delta считался и уже использовался.
Фикс (pop_guard.c, pop_gdraw.c, roomtest.c):
- флаг
pop_guard_hurt— симметрияpop_kid_hurt: ставится вpop_do_delta_hp, когдаguardhp_delta < 0, снимается главным циклом после отрисовки (стража могло не оказаться в отрисованной комнате — тогда брызги просто пропадают, как в оригинале); - отрисовка — ВНУТРИ
pop_guard_draw, между спрайтом стража и клинком:draw_guardкладёт брызги в objtable сразу после стража и ДО клинка, а порядок записей задаёт порядок рисования. Заодно это решает fore-слой:pop_fore_over_charв конце той же функции накрывает и брызги; - спрайт —
chtab_5_guardobj_id = 1, то есть image 1 нашего атласа стража (res752.png, звезда 28×26): уadd_midtableаргументobj_id + 1, аget_imageвычитает единицу обратно — тот же off-by-one, что расписан в шапкеpop_pack_guard.py. У Кида это image 218; - смещения — три ветки оригинала: кадр 178 (chomped) брызг не даёт вовсе;
185 и 106..110 (смерть/падение) —
obj_y + 4; 177 (пики) — сдвиг НАЗАД на 5; иначеobj_y − 11(у Кида 15:((charid == kid) << 2) + 11); - свой прямоугольник heal по страницам (
qx_l/qvalid) — брызги живут один кадр, но стирать их обязана та страница, в которую рисовали.
Побочно исправлено: pop_kid_hurt взводился при ЛЮБОЙ ненулевой дельте
HP, поэтому зелье здоровья (+1) давало Киду и брызги, и красную вспышку.
И draw_kid (seg008:1643), и flash_if_hurt (seg003:785) смотрят именно
hitp_delta < 0 — условие приведено к оригиналу.
Проверено в MAME (2026-08-07), мост mame-z80, уровень 1, комната 21,
живой страж (HP 3/3):
bp 51BA ← адрес снятия pop_guard_hurt в main: срабатывает ТОЛЬКО
в кадре удара, то есть уже после отрисовки брызг
key k ← чит «убить стража» = guardhp_delta < 0
→ state=stop PC=0x51BA, guardhp: curr=0 max=3 delta=0 hurt=1
bp 8BDF (_gfx_set_visible_page) + out ← домотать до флипа страницы
snap ← звезда нарисована поверх стража
Следующие два кадра (обе страницы дабл-буфера) — чисто, следов брызг нет.
Приём на будущее: снятие флага сделано по факту (if (pop_guard_hurt) pop_guard_hurt = 0;), а не безусловно — это и экономит запись каждый кадр,
и даёт готовую точку останова для отладки в MAME. Условные брейкпоинты
мост не принимает (bp ADDR cond вешает плагин), поэтому точка останова,
срабатывающая сама по себе только в нужном кадре, — рабочий обходной путь.
Цена: _CODE 24 179 → 24 209 (+30 Б резидента), BANK4 (pop_gdraw)
2407 → 3369 (+962 Б, банк занят на 20.6 %, свободно 13 015 Б).
Цвет брызг зависит от палитры стража (слоты 0x90..0x9F), поэтому он
изменится вместе с BUG-GUARD-COLOR-1 —
отдельной работы не требует.
BUG-GATE-PASS-1. Проход сквозь закрывшуюся решётку — ЗАКРЫТ 2026-08-09
Приёмка: смоук-прогон уровня 1 (пользователь, 2026-08-09) — с проблемного
x Кид больше сквозь решётку не пробегает, пинг-понг у шва не вернулся.
ВОСПРОИЗВЕДЁН И РАЗОБРАН. Сценарий: уровень 1, комната 8, ряд 0,
Kid.x = 170, лицом ВПРАВО, решётка шва (комната 8, колонка 9) ЗАКРЫТА. Разбежаться вправо и НЕ ОТПУСКАТЬ клавишу. Кид проходит сквозь решётку и убегает вглубь комнаты 6.Ниже — новый разбор; всё, что было раньше (гипотеза про x > 205), не подтвердилось и оставлено в конце для истории.
Корень: история флагов коллизии индексируется НЕ ТАК, как в оригинале
Сняты две трассы — наша (брейкпоинт на резидентном kid_tick) и оригинала
(SDLPoP, собран с POP_TRACE=1, печать в play_kid_frame + маркеры в
bumped и exit_room). Стартовая позиция совпала до пикселя, поэтому
сравнение построчное.
Оригинал:
LEAVE dir=1 x=61 cxl=184 cxr=201 room=6 смена комнаты — ШТАТНАЯ
BUMPED dx=-2 push=-1 x=63 tile=4 col=9 и СРАЗУ бамп о решётку, откат
KID f=15 x=60 act=0 col=-1 room=6 drawn=6 сел за решёткой, стоит
Мы:
x 170 -> 196 бегом, col 7 -> 8 -> 9, НИ ОДНОГО бампа
x=198 -> room 8 -> 6
при удержании ВПРАВО доезжает до col 1 комнаты 6 и бежит дальше
Смена комнаты в этой точке правильна и у нас, и у оригинала: leave_room
(seg002:423) вправо блокируют ТОЛЬКО doortop-тайлы, решётки в списке нет, а
порог char_x_right >= 201 совпадает с плоскостью ворот
(x_bump[9+FIRST_ONSCREEN_COLUMN] + TILE_MIDX + wall_dist_from_left[1]
= 191 + 10). Оригинал полагается не на запрет ухода, а на бамп СРАЗУ ПОСЛЕ
перехода.
Почему бамп есть у них и нет у нас. check_collisions (seg004:0004)
хранит флаги так:
row_coll_flags_ptr[tile_col] = curr_flags; // индекс — колонка В РАЗРЕШЁННОЙ комнате
row_coll_room_ptr [tile_col] = curr_room; // и рядом — НОМЕР ЭТОЙ КОМНАТЫ
...
if (curr_row_coll_room[column] >= 0 &&
prev_coll_room[column] == curr_row_coll_room[column]) { /* только тогда сравниваем */ }
То есть история привязана к паре (колонка внутри своей комнаты, комната).
Решётка комнаты 8 и до перехода, и после лежит в слоте 9 с room = 8 —
переход 8->6 историю НЕ рвёт, переход флага 0->1 виден, бамп срабатывает.
У нас массив coll_prev/coll_curr индексируется колонкой ОТНОСИТЕЛЬНО
отрисованной комнаты (−2…11). При смене комнаты все индексы уезжают на 10,
сопоставить прошлый кадр с текущим нечем, и enter_room вынужден звать
pop_coll_invalidate(); тот ставит «прошлые флаги = уже перекрывал» (3), то
есть ПОДАВЛЯЕТ бамп на кадре входа. А к следующему кадру Кид уже за
плоскостью ворот — перехода 0->1 не будет никогда.
Фикс сделан 2026-08-09 (вариант B — сдвиг истории), ЖДЁТ ПРИЁМКИ В MAME
Рассматривались два пути:
- A, дословный порт. 10 слотов + параллельный массив номера комнаты,
индекс по колонке разрешённой комнаты, бамп только при совпадении номеров;
pop_coll_invalidate()тогда не нужен вовсе. Не взят: оригиналу хватает десяти слотов только потому, что он перебирает узкое окно вокруг Кида, а мы намеренно считаем все четырнадцать колонок (FIX_COLL_FLAGS, чтобы не оставалось протухших ячеек) — при таком переборе колонки −2/−1 и 8/9 сядут в одни слоты 8/9. То есть A тянет за собой ещё и сужение окна. - B, сдвиг истории (сделано). Индексацию оставляем свою, но при
БОКОВОМ переходе историю не выбрасываем, а перенумеровываем на 10 слотов:
check_leaveдвигаетxровно на ∓140 = 10 тайлов, координата грани (pop_x_bump) едет на те же 140 вместе с габаритом Кида, значит сами флаги инвариантны — меняется только номер слота.
Реализация: pop_coll_shift(int8_t dcol) в pop_map.c сдвигает
coll_curr/above/below (не prev — его на следующем кадре всё равно
перезапишет move_coll_to_prev), освободившиеся слоты = 3 («уже
перекрывал», бампа нет — ровно то, что в оригинале даёт несовпадение номера
комнаты). enter_room_side зовёт её сразу после pop_map_set_edges:
side == 0 (ушёл влево) → +10, side == 1 (вправо) → −10. Переходы
вверх/вниз и все прочие входы в комнату остаются на полной инвалидации —
там колонки не сдвигаются, но тайлы под ними уже из другой комнаты.
Расхождение с дословным вариантом A записано в ../docs/impl_diff.md (D-1).
Осторожно: это сердце коллизии, вокруг которого разбирался
BUG-SEAM-PINGPONG (см. BUGS_CLOSED.md). Приёмка:
- Комнаты 6 ↔ 8 уровня 1, закрытая решётка, обе стороны.
- Мелким шагом (упереться) И с разбега (не пройти насквозь) — сценарий
выше воспроизводится с
Kid.x = 170, комната 8, ряд 0, лицом вправо. - Пинг-понг у шва не вернулся (экран не перескакивает туда-сюда).
make -C tests-host— прошли все 5 наборов (2026-08-09).
Инструмент для сверки готов: SDLPoP собран с трассой, включается
POP_TRACE=1 (правки в seg000.c play_kid_frame, seg004.c bumped,
seg002.c exit_room, чтение переменной — main.c). Сборка на macOS:
make CFLAGS="-std=gnu99 -O2 -I/opt/homebrew/include -Wno-implicit-function-declaration" LIBS="-L/opt/homebrew/lib -lSDL2 -lSDL2_image"
(в системе нет pkg-config, штатный Makefile без этого не собирается).
История: прежний разбор (гипотеза НЕ подтвердилась)
Статус: наблюдался один раз (2026-08-03), воспроизвести не удалось ни тогда, ни прогоном всех комнат уровня 1 (2026-08-07). Заведён, чтобы наблюдение не потерялось; закрывать нельзя — ни как исправленный, ни как «не баг», пока нет надёжного сценария. Оговорка: BUG-GATEMOD-1, из-за которого решётка стартовала не в том состоянии, с тех пор закрыт, и наблюдение могло быть его следствием.
Что наблюдалось. Комната 5: Кид стоял НА тайле решётки (0,9) и ждал, пока она опустится. После закрытия пошёл вправо — прошёл в комнату 1 и упал на (1,1).
Что уже измерено и в чём загвоздка. Сразу после наблюдения повторить не
получилось: в том же месте Кид стоит на x = 196, col = 9, и решётка его
ДЕРЖИТ — то есть штатно.
Арифметика оригинала объясняет разницу. is_obstacle (seg004) ставит
плоскость блокировки в x_bump[col + FIRST_ONSCREEN_COLUMN] + TILE_MIDX, для
колонки 9 это x = 205. При этом «колонка 9» по get_tile_div_mod_m7 —
это x ∈ [191, 205). Пока curr_col == 9, Кид гарантированно левее
плоскости и обязан блокироваться; чтобы пройти, он должен оказаться правее
205, то есть уже на дальней стороне решётки, — и тогда уход вправо законен:
решётка закрылась у него за спиной, в оригинале она блокирует плоскость, а не
весь тайл.
Отсюда рабочая гипотеза: в момент наблюдения Кид стоял правее 205 (успел зайти
по тайлу дальше, пока решётка была поднята), и поведение штатное. Но повторить
эту позу и снять x пока не удалось, поэтому гипотеза НЕ подтверждена.
Что снять в следующий раз (без этих чисел вопрос не закрыть):
Kid.xиKid.curr_colв момент, когда решётка уже закрылась, а Кид ещё стоит на её тайле — до шага вправо;- модификатор решётки (openness) комнаты 5, тайл 9 —
can_bump_into_gate()считает её препятствием только пока(modif >> 2) + 6 < char_height, то есть пока она опустилась достаточно низко относительно РОСТА кадра; Kid.xпокадрово на самом шаге вправо — где именно перестал блокировать.
Быстрый способ снять первое: отладочный стоп-кадр (1 заморозить, 2
продолжить), затем чтение _Kid из отладчика MAME; подгонка позы по пикселю —
читы [/].
Возможный корень, если гипотеза не подтвердится. Проверка идёт по колонке,
которая на шве уже принадлежит СОСЕДНЕЙ комнате (curr_row_coll_room[] в
оригинале); у нас межкомнатная коллизия на шве — исторически проблемное место
(ср. закрытый BUG-SEAM-PINGPONG). Второй кандидат — char_height в
can_bump_into_gate(): если он берётся не от того кадра, решётка может
перестать считаться препятствием раньше времени.
BUG-TORCH-CHOMP-2. Застывший чомпер накрывается пламенем факела — ЗАКРЫТ 2026-08-10
Симптом (уровень 4, скриншот пользователя): пока чомпер щёлкает, всё в порядке; как только его анимация кончается, пламя соседнего факела ложится ПОВЕРХ челюстей.
Регрессия от фикса BUG-TORCH-CHOMP-1: там пламя
перевели на ЗАПЕКАНИЕ в фон (GFX_BANK_NORMAL), чтобы heal соседнего
чомпера не съедал огонь. Аргумент «запекать безопасно, кадры пламени
самонакрываются» верен для СОБСТВЕННЫХ пикселей факела, но не для чужой
графики в той же ячейке: пламя рисуется в клетке ПРАВОГО СОСЕДА
(x = (col+1)*32 + 8, seg008:560), и если сосед — чомпер, огонь ложится на
его челюсти. Пока trob чомпера жив, pop_set_redraw(tp, POP_RD_CHOMP, ...)
возвращает их поверх каждый кадр; как только анимация кончилась и trob
умер, возвращать стало нечем.
Как это устроено в оригинале (и почему у него бага нет).
animate_torch (seg007:0241) заканчивается вызовом set_redraw_anim_right()
— он метит redraw_frames_anim у ПРАВОГО СОСЕДА (seg007:0101). А
redraw_needed (seg008:0178) рисует помеченный слой строго в порядке
draw_tile_anim_topright();
draw_tile_anim_right(); <- пламя ЛЕВОГО соседа (факела)
draw_tile_anim(); <- СВОЯ графика тайла: челюсти чомпера
То есть челюсти возвращаются поверх огня каждый кадр факела, независимо
от того, жива ли собственная анимация чомпера. У нас пламя рисуется
напрямую из pop_process_trobs, минуя механизм пометок, — этой второй
половины не было.
Фикс (pop_trob.c): после pop_torch_draw метим правого соседа
pop_set_redraw(tp + 1, POP_RD_CHOMP, 1), если там чомпер. Одна страница:
факел анимируется каждый кадр, значит обе страницы дабл-буфера получат свою
перерисовку по очереди. Порядок сходится сам — блок факелов стоит ДО
pop_redraw_needed. Код соседа читается в том же префетче кодов тайлов
(trob_rcode[]), чтобы не свапать W0 второй раз за кадр. Банк 6: +104 Б.
Осознанное расхождение — оригинал метит соседа БЕЗУСЛОВНО, мы только под
чомпера. Безусловная пометка = перерисовка тайла каждый кадр на каждый
факел; слой draw_tile_anim рисует ещё пики, зелье и меч (seg008:0644),
поэтому теоретически «застыть под пламенем» могут и они — заведено открытым
в BUGS_OPEN.md (TORCH-ANIM-RIGHT). На уровнях 1–4 такого соседства не
встретилось.
T-2. Idle-skip — ЗАКРЫТ 2026-08-08
Сделан как шаг 1 задачи DRAW-COST (коммит
a25ce58), и шире, чем формулировался здесь: пропускается не только Кид, а
ЛЮБОЙ персонаж, у которого с прошлой отрисовки этой страницы дабл-буфера не
изменился ни один вход отрисовки, — включая труп стража и ждущего стража.
Условие «обе страницы уже получили это состояние», которого требовала эта
запись, выполнено само собой: снимок входов хранится ПО СТРАНИЦАМ.
Замер: комната 1.3 с трупом стража, Кид стоит — 210 % -> 116 % кадрового
периода, ноль вызовов pop_heal_fast за кадр. Контракт — шапка
pop_cdraw.h, разбор и что делать дальше — TASKS_OPEN.md#draw-cost.
Связь с T-1 сработала как и предсказано: пока Кида не перерисовываем, heal'а нет, стирать пики нечем. Но T-1 остаётся открытым — трогать пики безусловно мы всё ещё продолжаем, когда Кид рядом ДВИЖЕТСЯ.
MIRROR-FG-STALE. Зеркало и снимок комнаты — НЕ БАГ, закрыт 2026-08-11
Заводился как краевой случай от L4-MIRROR:
place_mirror (pop_trob.c) пишет тайл зеркала в ДАННЫЕ УРОВНЯ
(pop_level_set_tile), а коллизия работает со СНИМКОМ комнаты room_fg,
который pop_room_load делает при входе в комнату. Опасение: если тайл
поставлен, пока игрок УЖЕ в комнате 4, снимок останется старым и зеркало
будет невидимо для коллизии — Кид пробежит сквозь него, тень не родится.
Проверка пользователем (2026-08-11), чит ROOMNAV: нажал кнопку открытия двери, телепортировался в комнату 4 — зеркала не было вовсе; вышел из комнаты и вошёл обычным путём — зеркало появилось и непроходимо, кроме правильного прыжка, то есть коллизия видит его корректно.
Вывод: сценария нет. Тайл ставится в момент, когда дверь ДОРИСОВАЛА
открытие (animate_leveldoor, переход pop_leveldoor_open 0/2 → 1, 43
тика анимации) — телепорт успевает раньше, поэтому «постановки при игроке в
комнате» в этом прогоне не случилось вообще. А при обычном входе комната
рисуется целиком из данных уровня и room_fg снимается заново — графика и
коллизия согласованы по построению.
Решение пользователя: телепорт по комнатам — ОТЛАДОЧНЫЙ режим; в реальном прохождении дверь выхода стоит не в комнате зеркала, игрок физически не может быть там в момент постановки, и зеркало с тенью появляются всегда штатно. Чинить нечего.
Если симптом всё же всплывёт (например, появится мод/уровень, где кнопка
и зеркало в одной комнате): лечение — обновлять в place_mirror не только
данные уровня, но и живую карту (в pop_map.c уже есть внутренние точки
записи g_fg[tilepos], нужна публичная «поставить тайл в текущей комнате»).
SEAM-BUTTON-STALE. Кнопка в шве не меняла вид при нажатии/отжатии — ЗАКРЫТ
Симптом (пользователь, 2026-08-11, уровень 5). Кнопка нижних ворот комнаты 24 стоит в шве: в комнате 11 это (1,9), в комнате 24 — (1,−1). «Нажали её из комнаты 11, она проанимировалась — зайдя в 24, она ВСЕГДА нарисована нажатой, хотя по статусу отжимается. Не нажимали, вошли в 24 и наступили — ворота открываются, а визуально кнопка остаётся отжатой.»
Корень. Change-driven редрой шва (seam_sig в roomtest.c) сравнивал
room_modif колонки 9 соседа. У кнопки modif — это ИНДЕКС LINKLOC,
константа уровня; само нажатие живёт в doorlinks2
(get_doorlink_timer, seg007:0BB6 — младшие 5 бит). Изменение в сигнатуру
не приходило НИКОГДА, редрой не заказывался, и шов оставался нарисованным
таким, каким был на входе в комнату — отсюда оба симптома сразу.
Видимой разницу делает draw_tile: нажатый opener рисуется как ПОЛ
(порт get_tile_to_draw, seg008:253 — pop_room.c:256), а у пола правая
грань есть, у кнопки её нет.
Фикс. seam_row_sig() (roomtest.c) подмешивает в сигнатуру ряда бит
«кнопка нажата» для тайлов 0x0F/0x06. Оригинал шов в этом случае не
перерисовывает вовсе (get_trob_pos_in_drawn_room возвращает 30 для чужой
комнаты) — осознанное расхождение, записано как D-2 в
../docs/impl_diff.md.
Цена: резидент +200 Б (_CODE 24 739 → 24 939), куча W2 1795 → 1595 Б.
Проверено (пользователь, 2026-08-11): «кнопка теперь отрисовывается правильно в обоих случаях» — оба направления — нажать из комнаты 11 и войти в 24; войти в 24 поверху и наступить на кнопку, стоя в шве. Картинка кнопки обязана совпадать со статусом ворот.
BUG-POTION-STRIPE. Узор паласа пропадал на месте выпитого зелья — ЗАКРЫТ
Наблюдение (пользователь, 2026-08-11, уровень 5). «На стенке паласа есть узор — синяя в цветочек полоса, она запекается в фон и должна быть всегда. Но если на её фоне Кид (или Тень) выпивает зелье — полоса пропадает.» Воспроизведено в комнате 14 (два зелья в ряду 2, колонки 4 и 5): после каждого глотка узор исчезал в ячейке СПРАВА от выпитого тайла и не возвращался даже после выхода из комнаты и повторного входа.
Корень. У оригинала curr_room_tiles — это сама таблица уровня, поэтому
curr_room_tiles[curr_tilepos] = tiles_1_floor в do_pickup (seg006:1671)
виден всем в тот же миг. У нас fg живёт в ДВУХ местах — буфер отрисовки
(g_fg) и EMM-страница уровня, — и страницу правил главный цикл ПОЗЖЕ в
кадре, уже после pop_process_trobs.
В этот зазор успевал проснуться trob поднятого зелья: код тайла он читает из
страницы уровня (pop_level_tile_raw), там ещё зелье — и animate_potion
крутил фазу пузырька поверх только что обнулённого модификатора,
bubble_next_frame(0) = 1. Модификатор оставался единицей навсегда
(room_modif персистентен, room_seen не даёт переинициализации).
Дальше срабатывало правило самого оригинала: для ПОЛА в паласе modif == 1
означает «узор не рисовать» — if (num == !!level_type) return;
(seg008:499, у нас pop_room.c:322). Отсюда и «не лечится перезаходом».
Как подтверждено (MAME, чтение памяти, а не рассуждение): буфер тайлов
комнаты (0xA342) после подбора — 01 = пол на обеих позициях, всё верно;
а в модификаторах на позициях 24 и 25 стояли 01 01.
Фикс. do_pickup (pop_map.c) ставит тайл в странице уровня СРАЗУ
(pop_level_set_tile). Тогда trob того же кадра уходит в default и
снимается (type = −1), модификатор остаётся нулём. Дублирующая запись из
главного цикла убрана. Тем же фиксом лечится МЕЧ: там animate_sword
делал --mod[tp] из нуля и получал 255.
Проверено (пользователь, 2026-08-11): «узор теперь на месте».
Ложный след, для протокола: первым подозреваемым был pop_floor_bake —
после подбора он чистит bar шириной 60 px при 64-пиксельной колонке, и
уцелевший хвостик узора в 4 px выглядел как его подпись. Отвергнуто
перезаходом в комнату: полная перерисовка идёт мимо bake, а узор всё
равно не появлялся.
SHADOW-FIGHT-L5. Кид и Тень вставали в боевую стойку в комнате с зельем — ЗАКРЫТ
Наблюдение (пользователь, 2026-08-11). Тень выходит и пьёт зелье штатно, но если Кид успевал подняться в ряд 0, пока она пьёт: (а) Кид доставал меч, (б) Тень тоже вставала в боевую стойку и рисовалась спрайтами стража.
Корень. В pop_check_can_guard_see_kid (порт check_can_guard_see_kid,
seg003:702) первым множителем стояло Guard.charid != 0, тогда как оригинал
пишет:
if ((Guard.charid != charid_1_shadow || current_level == 12) && ...
То есть ТЕНЬ — боевой персонаж только на 12-м уровне; на 4/5/6 она Кида «не
видит». Это не косметика: can_guard_see_kid >= 2 разворачивается в оба
симптома через одну ветку control_standing (seg005:352) — Кид достаёт меч
сам, а Тень с charid 1 < CHARID_2_GUARD и убранным мечом идёт в
диспетчере pop_control ТОЙ ЖЕ веткой, что и Кид; с кадра 150 боевые кадры
лежат в атласе стража.
Фикс. Порт условия целиком + возвращён пропущенный множитель
Guard.direction != dir_56_none (им оригинал гасит выключенного
персонажа). Константа SHADOW_FIGHT_LEVEL в pop_guard.h.
Проверено (пользователь, 2026-08-11): тень выпивает и уходит, боя нет.
BUG-LATTICE-DOORTOP. Чёрная дыра на месте арки у ворот — ЗАКРЫТ
Наблюдение (пользователь, 2026-08-11). Уровень 5, комната 12, тайл (0,9): чёрный квадрат вместо арки.
Корень. draw_tile_base (seg008:622) начинается со СПЕЦСЛУЧАЯ, которого
у нас не было:
if (tile_left == tiles_26_lattice_down && curr_tile == tiles_12_doortop) {
id = 6; // Lattice + door A
ybottom += 3;
}
У doortop своей базы нет (base_id == 0), поэтому рядом с аркой пара
«арка + верх двери» рисуется ОДНИМ спрайтом 6, опущенным на 3 px. Без
ветки тайл не рисовал ничего, кроме полоски bottom_id 85 (32×4).
Фикс. Ветка добавлена в pop_room.c (draw_tile) И в
toolchain/render_room.py — последнее обязательно: pop_pack_bg.py
собирает атлас по обходу render_room, поэтому без правки скрипта спрайт 6
просто не попал бы в pop_env*.atl (см. memory pop_atlas_dynamic_ids).
Как найдено — методика, годная для любого «не так нарисовано»:
./prince megahit <N> --screenshot-levelв SDLPoP рисует карту ВСЕГО уровня (комнаты разложены поroomxs/roomys, клетка 320×192).- Вырезать нужную комнату и совместить со снимком MAME перебором смещения
(у нас совпало на
crop(101, 50, +640, +192), масштаб 2×1). - Разница по порогу яркости даёт карту расхождений: до фикса — белый прямоугольник ровно в (0,9) и 1892 пикселя, после — 995 (Кид, факел, статус-полоса, то есть динамика).
- Что именно рисует оригинал в тайле — трасса
POP_TRACE_BT=1(печать вadd_backtable,src/seg008.c, выводит room/row/col/id/размер). Она сразу показалаid=6 w=32 h=63рядом со «своим»id=85 w=32 h=4.
BUG-BELOWROW-WALL. Жёлтые треугольники в шахте падения — ЗАКРЫТ
Наблюдение (пользователь, 2026-08-11). Уровень 6, комната 3 (шахта под комнатой 1): в нижнем ряду под пустыми тайлами (2,4)-(2,6) торчат жёлтые треугольники.
Корень. load_rowbelow (seg008:368) при ОТСУТСТВУЮЩЕЙ комнате снизу
подставляет РАЗНЫЕ кромки: колонкам 1..9 — tiles_0_empty, и только левому
краю (тайл из room_BL) — tiles_20_wall. У нас pop_room_load забивал
стеной все одиннадцать позиций below_fg, а стена в роли соседа снизу-слева
даёт topright стены — её грань и лезла треугольниками.
Фикс. below_fg[0..9] = 0, below_fg[10] = 20.
Проверено: комната 3 совпала с картой SDLPoP — 300 пикселей расхождения,
все они силуэт Кида. Опасение «не пропадёт ли законный треугольник в (1,3)»
снято тем же сравнением: грань стены в колонке 3 и скос у её основания на
месте у обоих. Оговорка пользователя: эталон брался из --screenshot-level,
живая сверка в SDLPoP — потом.
BUG-BALCONY-RIGHT. Правая половина портала не рисовалась — ЗАКРЫТ
Наблюдение (пользователь, 2026-08-11). Уровень 6, комната 18: «закрытый портал показывается некорректно» — у арки балкона нет правой половины, решётка обрывается, справа чёрный провал.
Корень — не в движке, а в УПАКОВЩИКЕ АТЛАСОВ. id 12 стоял в
FORE_ENV_IDS (toolchain/pop_pack_bg.py), потому что в tile_table он
числится fore_id ЗЕЛЬЯ (0x0A). Но зелье рисуется из chtab_1
(add_foretable(id_chtab_1_...); у нас pop_potion_flask), а в chtab_6
под номером 12 лежит ПРАВАЯ ЧАСТЬ АРКИ БАЛКОНА 32x62, и она идёт в
backtable. Из-за списка спрайт уезжал в pal_fore.atl, а pal_env0.atl
получал дырку: idx 12: w=0 h=0. Движок берёт правую грань соседа из
env-атласа — и не рисовал ничего.
Как найдено. Трасса оригинала POP_TRACE_BT=1 показала в спорном тайле
BT room=18 r=1 c=7 ch=6 id=12 w=32 h=62; дальше — чтение каталога
pal_env0.atl напрямую (запись 12 пустая) и pal_fore.atl (она там).
Фикс. id 12 убран из FORE_ENV_IDS; в env-атласе он занял своё место.
Дифф комнаты с оригиналом 1151 -> 375 (остаток — Кид и пламя).
Проверено пользователем: «портал отрисовался корректно».
Урок: список FORE_ENV_IDS собирается по tile_table.fore_id, но
fore_id НЕ значит «спрайт из chtab_6» — у зелья, меча и пламени он
указывает в chtab_1. Прежде чем вносить номер в этот список, проверять,
из какой таблицы спрайт берётся.
BUG-MOB-MULTIROOM. Плита не пролетала несколько комнат — ЗАКРЫТ
Наблюдение (пользователь, 2026-08-11). Уровень 6: плита (1,7) комнаты 6 должна пролететь НЕСКОЛЬКО комнат и упасть на кнопку, открывающую решётку. «Плита просто падает и ничего не открывается.»
Корень. У оригинала кусок живёт в списке mobs и спускается ряд за рядом:
mob_down_a_row (seg007:1387) из НИЖНЕГО ряда переводит его в комнату снизу
(y -= 192, ряд 0), и так сколько угодно комнат. У нас кусок на границе
комнаты ГАСИЛСЯ, а «переход вниз» подменялся сигналом главному циклу — он
искал посадку в комнате снизу через pop_room_col_landing. Для колонки 7
комнаты 7 (сквозная шахта) посадки нет — кусок исчезал.
Фикс. Честный mob_down_a_row в mob_tick_one (pop_room.c);
приземление в НЕ отрисованной комнате сигналит комнатой и тайлом, а щебень
и кнопку ставит главный цикл (порт loose_land, seg007:11E8).
Путь в этом сценарии: комната 6 (1,7) -> сквозная шахта комнаты 7 -> кнопка (2,7) комнаты 11, а она по LINKLOC открывает ворота (1,9) комнаты 18 — те самые у портала. Проверено пользователем: ворота открываются.
BUG-MOB-STALE-PAGE. Улетевшая плита оставалась на одной странице — ЗАКРЫТ
Наблюдение (пользователь, 2026-08-11). Сразу после предыдущего фикса: «в одном из кадров дабл-буфера осталась падающая плита».
Корень. Чистка прошлого кадра куска стояла под гейтом «кусок в
ОТРИСОВАННОЙ комнате»: if (here && m->prev_y[pg] != MOB_Y_NONE). Как
только кусок уходил вниз через mob_down_a_row, гейт закрывался — и след,
оставленный им на этой странице, не стирал уже никто.
Фикс. Гейт убран: prev_y[pg] — след ИМЕННО НА ЭТОЙ странице, стирать
его надо независимо от того, где кусок сейчас. Плюс clean = 2 во всех
ветках гашения, чтобы дочищались обе страницы. Проверено пользователем.
BUG-CHAR-STALE-PAGE. Тень застывала на двух страницах в РАЗНЫХ позах
Наблюдение (пользователь, 2026-08-11, редкий — пойман дважды). Уровень 6, комната с тенью: «Тень застряла в двух разных кадрах в разных позициях».
Замер в отладчике (состояние было живым). Guard = кадр 15 (stand),
x = 0x51 — то есть персонаж В ПОКОЕ. А слот отрисовки pop_cd[OPP]:
страница 0: x=25 y=85 w=34 h=38 valid=1 <- ЛЕЖАЩАЯ поза
страница 1: x=41 y=78 w=12 h=41 valid=1 <- стойка
Корень. В pop_char_draw (pop_cdraw.c) прямоугольник и valid[dp]
пишутся ВНУТРИ if (w && h), а снимок состояния для пропуска перерисовки
(cd_sig_make) брался БЕЗУСЛОВНО, в самом конце функции. Если спрайт
кадра нулевой (пустая запись атласа), блок рисования пропускался — x/y/w/h
и valid оставались от ПРОШЛОГО кадра этой страницы, а сигнатура
обновлялась на текущее состояние. Дальше cd_quiet видел valid = 1 и
совпавшую сигнатуру, то есть считал «на странице нарисовано ровно то, что
надо», и слот залипал навсегда: heal не звался, старый спрайт оставался.
Фикс. cd_sig_make вызывается только когда кадр реально рисовали
(if (w && h)). Без снимка слот не будет «тихим», и следующий кадр
начнётся с heal — старое сотрётся само.
ОСТАЁТСЯ ОТКРЫТЫМ (в BUGS_OPEN): почему спрайт кадра оказался нулевым.
Фикс убирает залипание, но кадр, для которого атлас отдал 0x0, всё равно
не нарисуется. Подозрение — конкуренция за окно W0 между atlas_image и
чтением данных уровня; проверять трассировкой маппинга.
BUG-CHEAT-IMM-1. Кид залипал в боевой стойке без меча — ЗАКРЫТ 2026-08-17
Наблюдение (пользователь, уровень 2, комната 4). Кида с мечом сталкивают ударами с ряда 1 на ряд 2; страж падает следом и оказывается у него за спиной. Кид встаёт в боевую стойку, но: меча в руке нет (не отрисовывается), и к сопернику он не разворачивается — на клавиши не реагирует вовсе. «Иногда Кид успевает развернуться, а вот сейчас не успел».
Два симптома — один отказ. Диспетчер control() (pop_ctrl.c,
порт seg005:252) уходит в control_with_sword только при
Char.sword == SWORD_2_DRAWN. При sword == 0 управление проваливается в
обычные ветки, а среди них для кадров стойки с мечом (158/170/171) нет ни
одной — каждый кадр не делается НИЧЕГО. Отсюда и «не разворачивается»:
разворот к сопернику за спиной (SEQ_60_TURN_WITH_SWORD при
char_opp_dist() < -4) живёт внутри control_with_sword.
Корень — наш чит бессмертия, а не механика боя. hurt_by_sword
(guards.c, порт seg002) перехватывался читом в самом начале и
безусловно, не глядя на меч, ставил SEQ_74_HIT_BY_SWORD:
if (Char.charid == CHARID_0_KID && pop_immortal) {
pop_char_set_seq(SEQ_74_HIT_BY_SWORD); /* ← без проверки sword */
А seq_74 — это анимация «получил удар В БОЕВОЙ СТОЙКЕ», её кадры
(150..179) все с мечом. В оригинале в неё попадают ТОЛЬКО из ветки
sword == 2; безоружного там убивают (seg002, дословный комментарий:
«Being hurt when not in fighting pose means death», take_hp(100)).
Чит подменял эту смерть выживанием и оставлял Кида в кадрах с мечом при
sword == 0 — состояние, которого в оригинале не существует и для которого
у диспетчера нет ветки.
Как доказано (важно для метода — гипотез было три, и две неверные):
- Кодоген
draw_swordпроверен поbank5_pop_ctrl.asm:ld hl,#_Char+12 / ld (hl),#0x02— store меча на месте, версия «SDCC потерял запись» (ср. sdcc_z80_cmp_store_a_bug) отпала. - Watchpoint на
Kid.swordв MAME:0 → 2при доставании и2 → 0вstart_fallпроходят штатно (start_fallмеч гасит и в оригинале, seg006:1102). - Решающее: с ВЫКЛЮЧЕННЫМ читом (
pop_immortal = 0) тот же сценарий даёт корректную смерть — то есть порт совпадает с оригиналом, а баг живёт только под читом.
Грабли метода, стоившие времени: ловушки прошлой сессии печатали
b@0x992A без префикса 0x10000, то есть мимо логического вида Z80 —
charid/frame/dir в той трассе были мусором, отсюда ложный вывод «записей
в sword нет вовсе». И ловушка на Char.sword бесполезна: Char —
переключаемое окно, у стража меч вынут, поэтому 2 → 0 там происходит на
каждом переключении окна. Ещё одно: pop_char_set_seq пишет
ld ($992D),hl, запись 16-битная — байтовое условие wpdata==0xDC не
совпало бы никогда.
Фикс (решение пользователя: «повторяем поведение оригинала, наш иммортал
работает только во время боя, с мечом»). Спецветка чита удалена целиком —
она оказалась избыточной: под бессмертием pop_take_hp(1) и так
возвращает 0, и обычный путь сам приводит к seq_74 с сохранённым мечом.
Осталось запретить читу действовать в безоружной ветке:
if (Char.sword != SWORD_2_DRAWN) {
uint8_t imm = pop_immortal;
pop_immortal = 0; /* чит не действует вне боевой стойки */
pop_take_hp(100);
pop_immortal = imm;
pop_char_set_seq(SEQ_85_STABBED_TO_DEATH);
}
Глушится именно вызов, а не pop_take_hp: тот общий с физикой (падения,
пики, чомперы), и там бессмертие обязано работать при любом положении меча.
Подтверждение. Пользователь, 2026-08-17: «всё сработало как надо»,
проверено обоими концами — «во время боя удары, пропускаемые Кидом, урон не
приносят; если Кид без меча — смерть с одного удара». То есть чит в бою
работает как работал, а вне боевой стойки поведение снова совпадает с
оригиналом.
Взведена регресс-ловушка на сам инвариант: Киду пишется кадр стойки
(158/170/171), а Char.sword != 2. Условие смотрит на Char, а НЕ на
Kid: ldir копирует байт кадра (0) раньше байта меча (12), поэтому по
Kid ловушка слепа ровно в тот тик, когда меч теряется, и ложно срабатывает
на каждом законном доставании.
LEVELDOOR-PALACE-CLIP. Кид скрывался за кромкой дворцового портала раньше времени — ЗАКРЫТ 2026-08-17
Наблюдение (пользователь, уровень 4, обход после оптимизации фаз). Порталы, через которые Кид входит на уровень и уходит на следующий, в подземелье и во дворце РАЗНОЙ ширины — дворцовый шире. В анимации ухода с уровня 4 на 5-й Кид убегает по лестнице, и его контур обрезается не правой гранью портала, а раньше, «как будто он прячется проходом». Диагноз пользователя сразу верный: взята ширина подземного портала.
Корень. draw_leveldoor (pop_room.c, порт seg008:1D29) считал правую
кромку проёма без дворцовой поправки:
pop_leveldoor_right = xh * 8 + 48;
В оригинале строкой ниже стоит (seg008:1429):
leveldoor_right = (draw_xh<<3)+48;
if (custom->tbl_level_type[current_level]) leveldoor_right += 8;
Это значение читает clip_char (у нас pop_map.c:2668) как правую границу
клипа персонажа — отсюда ранняя обрезка.
Расхождение было осознанным и отложенным: в коде стоял комментарий
«+8 у palace-уровней (tbl_level_type) — на уровне 1 не применяется». На
момент написания дворцовых уровней в порту не было, и условие не дописали.
tbl_level_type[4] = 1 (pop_level_cold.c:52), то есть уровень 4 —
дворцовый, и на нём это наконец проявилось.
Фикс. if (pop_palace) pop_leveldoor_right += 8; Флаг pop_palace
выставляет pop_bg_load(set) из того же tbl_level_type, который читает
оригинал, так что эквивалент дословный и рассинхронизироваться не может.
Найдено попутно, НЕ починено: LEVELDOOR-STARTROOM-WIPE — в той же функции оригинал в СТАРТОВОЙ комнате кладёт затирающий прямоугольник вместо лестницы (и там своя дворцовая/подземная разница 48/39 и сдвиг 2 px), а мы рисуем марш 144 безусловно. Заведено отдельным багом.
SHADOW-STALE-FRAME. Тень мигала между двумя РАЗНЫМИ позами на страницах дабл-буфера — ЗАКРЫТ 2026-08-18
Наблюдение (пользователь, 2026-08-18). Уровень 6, комната 1 (Кид и через большой провал — тень). «У Тени на двух экранах отрисованы разные позиции — одна стоящая, одна бегущая; увидел сразу, как вошёл в комнату. Повторяется нечасто, но очень неприятно». Гипотеза пользователя — «в один из экранов попала случайная позиция Тени» — оказалась дословно верной.
Улика (живое состояние, снято через мост MAME до перезапуска). Слот
соперника pop_cd[POP_CH_OPP]:
стр.0: x=41 y=78 w=12 h=41 valid=1 <- кадр 15 «стойка»
стр.1: x=25 y=85 w=34 h=38 valid=1 <- ЧТО-ТО ДРУГОЕ
cd_sig[OPP][0] == cd_sig[OPP][1] == { frame=15, x=81, y=118, dir=0, charid=1 }
Обе страницы «тихие» (снимок совпал с живым Guard), поэтому ни одна больше
не перерисовывается — мигание навсегда. Прямоугольник страницы 1
восстанавливается однозначно: bx = scr_x(obj_x) − w и top = obj_y − h + 1
при Guard.x = 81, Guard.y = 118, dir = 0 дают dx = 3, dy = 4,
flags ≥ 0x80 и картинку 34×38. В таблице кадров ровно одна такая запись —
frame_tbl_guard[36], то есть кадр 185 «страж мёртв» (image = 33,
dx = 3, dy = 4, flags = 0xC9; 34×38 — это размер image 33 в атласе
КИДА, что сходится: набор спрайтов выбирается по Guard.frame = 15, а
image приходил из кэша).
Корень. Оригинал грузит кадр В САМОЙ ОТРИСОВКЕ: add_kid_to_objtable и
add_guard_to_objtable (seg008:22F0/2324) первым делом после
loadkid/loadshad зовут load_fram_det_col(). У нас kid_frame /
pop_gframe были КЭШЕМ, который наполняет тик (play_seq → load_frame), а
отрисовка брала готовое. Кэш отстаёт от Char.frame у всех, кто пишет кадр
мимо play_seq — например do_init_shad (guards.c) кладёт тени
frame = 15 прямой записью. А pop_gframe в этот момент держал кадр 185
убитого стража с прошлого уровня: после смерти соперника pop_guard_tick
выходит по charid == 0 РАНЬШЕ pop_load_fram_det_col, и кэш не трогается
вообще, сколько бы комнат Кид ни прошёл.
Само по себе это моргнуло бы один кадр. Смертельным его делает пропуск
неизменившегося кадра (DRAW-COST): снимок cd_sig пишется по
Guard.frame, то есть страница запоминает «нарисован кадр 15», хотя на ней
чужой спрайт. Тень дальше стоит, снимок совпадает — страница не
перерисовывается НИКОГДА.
Фикс. pop_char_draw зовёт pop_load_frame() сразу после
pop_loadkid/pop_loadshad — там же, где это делает оригинал. После этой
строки image однозначно определяется парой (charid, frame), то есть
снимок cd_sig снова описывает ровно то, что нарисовано, и расхождение
«пиксели против снимка» становится невозможным независимо от того, кто и как
испортил кэш. Цена: +3 байта в банке 4, кадровый бюджет не трогает
(отрисовка и так пропускается у «тихого» слота).
Заодно приведена к оригиналу ветка «кадра нет в таблице»: load_frame
(pop_kid.c) ставила cur_frame.image = 255 и выходила, НЕ обновив кэш
владельца, — то есть вместо «не рисовать» рисовалась прошлая картинка.
Оригинал кладёт туда blank_frame {255,0,0,0,0} (get_frame_internal,
seg006:507). Выход из функции теперь один на обе ветки: +46 байт резидента.
Проверка (MAME, уровень 6 комната 1). Брейкпоинт на трамплине
___sdcc_bcall_ehl с условием hl == _pop_char_draw и действием
«записать в pop_gframe кадр 185 и продолжить» — то есть кэш травится
ровно перед КАЖДОЙ отрисовкой персонажа. Слот при принудительной
перерисовке (valid[0] = valid[1] = 0) остаётся 12×41 на обеих
страницах, pop_gframe после кадра — снова кадр 15. До фикса та же
подмена дала бы 34×38 @ (25,85) — ровно числа из улики.
Что осталось невыясненным. Не удалось воспроизвести САМ МОМЕНТ порчи:
вход в комнату с заведомо испорченным кэшем (подмена pop_gframe в соседней
комнате + возврат читом обхода) самолечится — метка «фон трогали» от
перерисовки комнаты стоит на обеих страницах, и слот честно перерисовывается
следующим кадром. Значит порча случилась в кадре, где фон НЕ трогали, и
конкретный путь остался неизвестен. На вывод это не влияет: фикс закрывает
класс целиком, а не найденный путь. Ловушка на будущее, если симптом
всплывёт снова: wp <адрес pop_cd[OPP].w>,4,w — сработает на первой же
записи «чужой» ширины.