КОД НЕ МЕНЯЛСЯ. Разбор одиннадцати находок рангов А и Б: что менять, во что это обойдётся по скорости и памяти, каков риск. СНЯТО ГЛАВНОЕ ПРЕПЯТСТВИЕ. Обоснование двух упрощений (guards.c:1011 — «нужны тайловые запросы от Char, а pop_map умеет только от Kid») УСТАРЕЛО: get_tile_at_char, get_tile_infrontof_char, get_tile_behind_char и distance_to_edge_weight в pop_map.c уже работают от Char, они лишь не выведены в заголовок. Данные тоже на месте — pop_char_set_seq ставит любую из 115 последовательностей, то есть seq_81 и seq_64 доступны без единого нового байта. Три находки упираются не в архитектуру, а в четыре строки объявлений. СКОРОСТЬ. Места классифицированы по частоте вызова: play_seq и ИИ стража — горячие, land/in_wall/bumped/hurt_by_sword — событийные. Из одиннадцати правок две УСКОРЯЮТ код (уходит условие из горячего цикла; звук перестаёт играть в двух случаях из трёх), большинство бесплатны (перестановка строк), и ни одна не требует переделки архитектуры. Единственный конфликт со скоростью — отложенная побудка чомперов: play_seq маппит страницу байткода в W0 один раз перед циклом, и звать start_chompers внутри цикла значило бы снимать и возвращать окно на каждый переход ряда. Дешёвая замена: копить не один флаг, а битовую маску рядов и разбудить их после цикла — теряться ряды перестанут, цена в цикле нулевая. Для стражей аналогично: не межбанковый вызов wall_type, а копия таблицы в 32 байта в своём банке. Порядок работ — от «одна-две строки, низкий риск» (13, 24) к тем, где правка может компенсировать наши отличия в другом месте (1, 7). Политика: для критичных фиксов скорость не вето — такие выносятся в отдельный разбор с поиском дешёвого способа. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
51 KiB
Аудит расхождений с SDLPoP: Кид, стражи, seqtbl, отрисовка
Начат 2026-08-31. КОД НЕ МЕНЯЛСЯ — это только разбор. Задача: найти места, где наш движок может вести себя иначе, чем оригинал, и оценить вероятность того, что расхождение реально.
Как читать
Ранги вероятности того, что расхождение ЕСТЬ и проявляется в игре:
| ранг | смысл |
|---|---|
| А | гарантированное различие: код объективно разный, эффект понятен |
| Б | весьма вероятное: код разный, эффект вероятен, но не доказан |
| В | средневероятное: код разный, но эффект может гаситься другим местом |
| Г | маловероятное: различие есть в форме, эффект скорее отсутствует |
| Д | почти невероятное: сходство подтверждено, остаётся крайний случай |
Ссылки вида seg005:114 — строка в assets/orig/SDLPoP/src/. Наши
ссылки — файл:строка в src/.
Метод и охват
Сравниваются НАШИ реализации с оригиналом построчно по функциям. Первый проход (2026-08-31) охватил:
- диспетчер
control()(seg005:251) — целиком; land()(seg005:114) иstart_fall()(seg006:1099);- цепочку смерти:
control_kid(seg006:1390),play_kid(seg006:1348),take_hp(seg006:986),control()веткаalive >= 0.
НЕ охвачено первым проходом (список для следующих):
- интерпретатор
play_seqи полный набор опкодов seqtbl; frame_tableи модификаторы кадров;- бой целиком:
control_with_sword, парирование,strike,hurt_by_sword; - ИИ стражей (
guard_ai), особенности скелета, Тени, Джафара; add_kid_to_objtable/add_guard_to_objtable, порядок слоёв,clip_char;check_bumped— сверен ЧАСТИЧНО (находки 14, 15);check_grab,in_wall,check_bumped_look_left— нет;do_fallцеликом (проверен только вход).
Симптом, с которого начат аудит
Наблюдение (пользователь, 2026-08-31): идёт бой, за Кидом провал на этаж. Страж колет на последнем HP, Кид отшатывается назад и падает. Кид умирает — но на экране он этажом ниже В ПРИСЕДЕ, как после мягкого приземления.
Что говорит код SDLPoP. Разбор цепочки:
start_fall(seg006:1099) ПЕРВЫМ ДЕЛОМ убирает меч (Char.sword = sword_0_sheathed) — для любого падения, независимо от здоровья. Значит к моменту приземления меч уже в ножнах.land(seg005:114) при падении на один ряд выбираетseq_63_guard_active_after_fall, только еслиcharid >= guardИЛИ меч вынут; иначе —seq_17_soft_land, то есть ПРИСЕД. Из-за п.1 для Кида это всегда присед.landНЕ смотрит ни наalive, ни наhitp_currвовсе.control(seg005:251) при мёртвом персонаже (alive >= 0) не диспетчеризует ничего; он переводит вseq_71_dyingТОЛЬКО из четырёх кадров стойки (15, 166, 158, 171). Присед в этот список не входит.
Вывод: по букве оригинала мёртвый Кид, застигнутый смертью в полёте,
тоже долетает, приземляется в присед и остаётся в нём — control его не
трогает. То есть наблюдаемое, СКОРЕЕ ВСЕГО, воспроизводится и в SDLPoP.
Гипотеза «hp стал нулевым, поэтому спрятали меч» кодом НЕ подтверждается:
единственное место, где SDLPoP связывает hitp_curr == 0 с чем-либо, —
control_kid (seg006:1395), и там взводится только Char.alive = 0.
Как проверить окончательно: прогнать сцену на живом SDLPoP (он
собирается в assets/orig/SDLPoP/), где для этого уже есть наши врезки
POP_TRACE. До проверки считаю симптом НЕ доказанным расхождением —
ранг В (см. находку 5).
Находки первого прохода
1. land(): лишний determine_col() — ранг А
Оригинал: land (seg005:114) заканчивается тремя действиями:
seqtbl_offset_char(seq_id), play_seq(), Char.fall_y = 0. Никакого
пересчёта колонки.
У нас: pop_map.c:715 (и в ветке смерти :705) после play_seq()
дополнительно вызывается determine_col().
Чем грозит: determine_col пересчитывает Char.curr_col из Char.x.
Оригинал делает это в другом месте и в другой момент —
load_fram_det_col (seg006:0144) перед разбором кадра. Лишний пересчёт
сразу после приземления может дать другую колонку, если play_seq
успел сдвинуть x первым же dx. Колонка — вход для проверок тайла,
пик и коллизий.
Замечание: приём применён у нас системно (pop_map.c — 8 вызовов), то
есть это, вероятно, осознанная адаптация, но в docs/impl_diff.md она НЕ
записана. Нужно либо обосновать и записать, либо снять.
2. land(): обнуляется ещё и fall_x — ранг Б
Оригинал: в land обнуляется ТОЛЬКО fall_y, и притом в самом конце,
ПОСЛЕ play_seq().
У нас: pop_map.c:697 и :713 обнуляют пару Char.fall_x = Char.fall_y = 0,
причём ДО pop_char_set_seq/play_seq.
Чем грозит: два отличия сразу. Во-первых, fall_x в оригинале
переживает приземление — если наш сброс лишний, теряется горизонтальный
импульс, влияющий на последующие кадры. Во-вторых, момент: если
play_seq читает fall_y (а он читает при обработке своих опкодов
движения), оригинал видит ещё НЕ обнулённое значение, а мы — уже ноль.
3. land(): проверка пик до коррекции X — ранг Б
Оригинал: сначала (внутри ветки «тайл под ногами не пика») делается
коррекция Char.x = char_dx_forward(-3) при distance_to_edge_weight() < 3,
и лишь ПОТОМ проверяется падение на пики, причём условие для пики ПОЗАДИ
использует distance_to_edge_weight() >= 12.
У нас: pop_map.c:664 — fell_on_spikes() вызывается ПЕРВЫМ, до
коррекции X.
Чем грозит: distance_to_edge_weight() считается от Char.x, а
коррекция этот x меняет на 3 пикселя. У края тайла порядок решает,
попадёт ли персонаж в ветку «пика позади» — то есть умрёт он или нет.
4. Смерть: у нас свой флаг вместо счётчика — ранг В
Оригинал: play_kid (seg006:1348) после смерти ведёт СЧЁТЧИК
Char.alive, и по его значениям запускает музыку смерти (alive == 6) и
надпись «Press Button to Continue» (alive == 7), причём переход
задерживается, пока играет звук (check_sound_playing).
У нас: pop_ctrl.c (ctrl_kid_death) взводит pop_kid_dead — сигнал
главному циклу на респавн; счётчика стадий нет.
Чем грозит: момент респавна и порядок «музыка смерти → сообщение → рестарт» могут отличаться, особенно если смерть застала персонажа в длинной анимации (падение). Сюда же относится симптом выше: в оригинале поза сохраняется, пока крутится счётчик.
5. Мёртвый доигрывает приземление — ранг В
Разобрано выше. Код у нас и в оригинале В ЭТОМ МЕСТЕ совпадает, поэтому
ранг не выше среднего: расхождение может сидеть не в land, а в моменте
взведения смерти (находка 4) — тогда оригинал успевает поставить кадр
смерти до приземления, а мы нет. Проверяется прогоном на живом SDLPoP.
6. Диспетчер control() — ранг Д
Сверен ветка в ветку (seg005:251 против pop_ctrl.c:618): совпадают и
порядок проверок (bumped/freefall → меч → charid → кадры), и границы
диапазонов кадров, и обработка мёртвого. Расхождений не видно; остаются
только опциональные #ifdef-фиксы SDLPoP, которых у нас нет намеренно.
7. JMP_IF_FEATHER: у нас эффект только для Кида — ранг Б
Оригинал: опкод SEQ_JMP_IF_FEATHER (seg006, play_seq) смотрит ТОЛЬКО
на глобальный is_feather_fall. Кто именно проигрывает последовательность,
роли не играет.
У нас: pop_kid.c:306 добавляет условие Char.charid != CHARID_0_KID —
для всех, кроме Кида, ветка «пера» не берётся никогда.
Чем грозит: под зельем медленного падения любой НЕ-Кид, попавший в
последовательности stepfloat/bumpfloat, у нас пойдёт по обычной ветке
(с уроном), а в оригинале — по парящей. Практически это Тень (charid 1) на
уровне 4-6 и скелет; страж в эти seq попадает редко, но попадает через
bumpfloat при отскоке.
Замечание: отличие ОСОЗНАННОЕ (в комментарии сказано «эффект достаётся
только Киду — как и сама физика пера»), но в docs/impl_diff.md не
записано, хотя правило проекта этого требует.
8. start_chompers отложен до конца play_seq — ранг Б
Оригинал: опкоды SEQ_UP/SEQ_DOWN меняют ряд и ТУТ ЖЕ зовут
start_chompers() — то есть внутри цикла интерпретатора, до разбора
следующих опкодов.
У нас: pop_kid.c:318-325 только взводит chomp_pending, а сам вызов
происходит после выхода из цикла (:396). Причина архитектурная и
описана в коде: seqtbl читается через окно W0, а start_chompers лезет в
другое окно.
Чем грозит: два следствия. Во-первых, последовательность с ДВУМЯ
сменами ряда (UP UP, спуск/подъём по лестнице) в оригинале будит
чомперов в обоих рядах, у нас — только в конечном. Во-вторых, между
SEQ_UP и концом цикла успевают отработать dx/dy/action, то есть
оригинал будит чомперов с ДРУГИМИ координатами персонажа.
Симптом «челюсти не заводятся» уже ловился в этом проекте (memory
pop_chomper_needs_trigger), и это место — кандидат в его причины.
9. Набор опкодов seqtbl — ранг Д
Сверены все пятнадцать кодов (0xF1..0xFF): совпадают и значения, и
семантика, включая проваливание JMP_IF_FEATHER в JMP и то, что
SEQ_DIE — пустышка в обоих движках. Отличия только в находках 7 и 8.
10. Отрисовка: шаги те же, но разнесены — ранг В
Оригинал: add_kid_to_objtable (seg008:1667) и его двойник для стража —
это строго упорядоченная цепочка: loadkid/loadshad →
load_fram_det_col → load_frame_to_obj → stuck_lower →
set_char_collision → set_objtile_at_char → redraw_at_char →
redraw_at_char2 → clip_char → add_objtable.
У нас: все звенья присутствуют, но распределены по слоям: clip_char,
load_frame_to_obj, check_mirror, брызги — в pop_cdraw.c;
redraw_at_char/set_objtile_at_char/set_char_collision — в pop_bg.c
(единый проход на всех Char, см. CLAUDE.md).
Чем грозит: сам по себе перенос не ошибка, но ПОРЯДОК внутри цепочки
влияет на результат: set_char_collision и set_objtile_at_char готовят
данные, которыми пользуются redraw_at_char и clip_char. Если наш
общий проход выполняет их для ОБОИХ персонажей до отрисовки, а оригинал —
для каждого непосредственно перед его выводом, то при наложении Кида и
стража состояние на момент клипа будет разным.
Отдельно: stuck_lower найден только в pop_cdraw.h — надо убедиться,
что он реализован, а не только объявлен. Если его нет, персонаж,
застрявший на границе тайла, будет рисоваться на пиксель выше.
11. Порядок вывода Кида и стража — ранг В
Оригинал: draw_people (seg008:1635) всегда ставит сначала Кида
(draw_kid), затем стража (draw_guard), а КТО ОКАЖЕТСЯ СВЕРХУ решает
add_objtable — таблица объектов упорядочена по позиции тайла.
У нас: по CLAUDE.md порядок задаёт обход тайлов, «кто позже — тот поверх». Это близко по смыслу, но не тождественно сортировке objtable.
Чем грозит: при наложении персонажей (бой вплотную, страж перед Кидом)
верхний может оказаться другим. Проверять сравнением кадров боя вплотную
с эталонным SDLPoP (метод — memory pop_pixel_diff_vs_sdlpop).
12. hurt_by_sword: ветка «сбит с уступа» не портирована — ранг А
Оригинал: hurt_by_sword (seg002:911) при уколе ВООРУЖЁННОГО персонажа
выбирает одну из двух смертей по обстановке ПОЗАДИ:
- тайл позади не пустой ИЛИ до кромки меньше 4 →
seq_85_stabbed_to_death(заколот на месте); - иначе →
seq_81_kid_pushed_off_ledge— отдельная последовательность «убит и сброшен с уступа», которая сама отыгрывает падение замертво.
У нас: guards.c:1015 — ветки seq_81 НЕТ вовсе, всегда seq_85.
Упрощение ЗАДОКУМЕНТИРОВАНО в комментарии (guards.c:1011): ей нужны
тайловые запросы от Char, а pop_map умеет их только от Kid.
Чем грозит: именно тем, что наблюдал пользователь. Заколотый на краю
обрыва Кид в оригинале уходит в собственную анимацию падения с уступа; у
нас он получает «смерть на месте», продолжая при этом висеть в воздухе —
дальше им распоряжается обычная физика падения, и он приземляется этажом
ниже по общим правилам (а с убранным в start_fall мечом — в присед,
находка 5).
Как проверить: поставить Кида спиной к обрыву с 1 HP и дать стражу уколоть. В оригинале — падение замертво (кадры seq_81), у нас — смерть на месте с последующим отдельным падением.
13. hurt_by_sword: прижатие к полу стало безусловным — ранг А
Оригинал: Char.y = y_land[Char.curr_row + 1] и Char.fall_y = 0
выполняются ТОЛЬКО в ветке выжившего удара (seg002:962, рядом с
seq_74_hit_by_sword). Смертельные ветки координату не трогают.
У нас: guards.c — те же две строки стоят ПОСЛЕ всего if/else, то
есть выполняются и при смерти тоже.
Чем грозит: персонажа, убитого в воздухе, мы принудительно ставим на
пол текущего ряда и обнуляем накопленную скорость падения. Дальше физика
обнаруживает, что пола под ним нет, и запускает падение ЗАНОВО — уже без
fall_y, то есть с другой высотой и другим исходом приземления. Это
вторая половина механизма из находки 12 и вероятная причина того, что
мёртвый Кид доезжает до нижнего этажа «своим ходом».
Как проверить: тот же сценарий; в отладчике смотреть Char.y и
fall_y сразу после попадания — оригинал их не меняет.
14. Отскок с мечом: нет seq_64 — ранг Б
Оригинал: при отскоке (bumped, seg004:328) живой персонаж с вынутым
мечом получает ОДНУ ИЗ ДВУХ последовательностей по направлению толчка:
толкнули вперёд — seq_65_bump_forward_with_sword, отбросило назад —
seq_64_pushed_back_with_sword.
У нас: pop_map.c знает только SEQ_65_BUMP_FWD_SWORD (объявлен на
:103, используется на :2231); константы и ветки seq_64 нет вовсе.
Чем грозит: персонаж, отброшенный назад с мечом (страж у стены, Кид в
тесной комнате), проигрывает не ту анимацию — либо ветку без меча, либо
seq_65. Кадры разные, а вместе с ними расходятся и смещения dx в
последовательности, то есть итоговая позиция после отскока.
Как проверить: бой вплотную к стене, толчок в сторону стены и от неё; сверять кадры с эталонным прогоном SDLPoP.
15. check_bumped_look_right: гейт по направлению — ранг В
В нашей реализации (pop_map.c:2148) стоит ранний выход по
Char.direction с пометкой «(меча в руке у нас нет)». Пометка означает,
что ветка писалась до появления боя, а оригинал в этом месте учитывает и
меч, и push_direction (находка 14). Область check_bumped_look_left
не сверялась вовсе — её надо пройти отдельно.
16. control_with_sword — ранг Д
Сверен целиком (seg005:964 против pop_ctrl.c:587): гейт по action,
условие «пол под ногами loose ИЛИ страж видит Кида», пороги дистанции
(90 и −4), seq_60_turn_with_sword, ветка «соперник умер» с
seq_92_put_sword_away, разделение по charid. Совпадает.
Отдельно отмечу: в оригинале сравнение дистанции сделано ЗНАКОВО-НЕЯВНО
(приведением к word), из-за чего ветка «соперник за спиной» вообще
достижима. У нас то же самое выражено явными знаковыми сравнениями — и
диапазоны совпадают, включая «вплотную за спиной» (−4..−1), где обе
реализации ведут бой, а не разворачиваются.
17. parry — ранг Д
Сверен целиком (seg005:1064 против pop_ctrl.c:513): список кадров
стойки, порог 32 для не-Кида, обработка кадров соперника (151/152/162,
особый случай 153 с отложенным play_seq), ветка стража по кадру 152,
ветка frame_167_blocked с seq_61, сброс автоповтора control_up.
Совпадает вплоть до порядка условий.
18. check_hurting: звук «меч в движении» в других условиях — ранг Б
Оригинал: звук 11 играется в САМОМ КОНЦЕ check_hurting (seg002) и
только при трёх условиях сразу: направление персонажа не none, его кадр
— укол (154), а соперник при этом НЕ парирует и НЕ ранен. Первое условие
добавлено в SDLPoP специально против зацикливания звука.
У нас: guards.c:1068 — звук играется в начале ветки укола,
безусловно, ещё до того, как определено попадание.
Чем грозит: лишние срабатывания в двух ситуациях, где оригинал молчит —
когда удар парирован и когда он попал. То есть в самой частой части боя
звук звучит чаще, чем должен. Плюс отсутствует защита от зацикливания
при direction == none.
Как проверить: бой с парирующим стражем; считать срабатывания звука 11 на серии ударов и сравнить с эталонным прогоном SDLPoP.
19. Остальной бой сверен — ранг Д
Прочитаны целиком и совпадают:
swordfight(seg005:998) — включая ветку кадра 161,sword_strike, побочные эффекты уборки меча (offguard,guard_refrac,holding_sword), разделениеseq_93/seq_92/seq_87поcharidи хвост (parry/forward_with_sword/back_with_sword);sword_strike(seg005:1037) — список кадров, выборseq_75/seq_58,seq_66после парирования, сброс автоповтора;check_sword_hurt(seg002:971) — включая ПРИОРИТЕТ СТРАЖА при одновременном ранении и сбросKid.actionв бег, а такжеrefractimerпо навыку;check_hurtingв основной части — гейты по мечу, ряду и кадрам, пороги дистанции (29),min_hurt_range8/12 по мечу соперника, ветка парирования сjustblockedиseq_69. Единственное расхождение — звук, находка 18.
20. Стражи: «стена впереди» сужена до одного тайла — ранг Б
Оригинал: guard_follows_kid_down (seg002:811) и соседние ветки ИИ
спрашивают wall_type(tile) != 0. Эта функция (seg006:1626) считает
преградой ПЯТЬ видов тайлов: ворота, верх двери с полом, верх двери,
зеркало, чомпер и собственно стену — с разной стороной блокировки.
У нас: guards.c:683 и :685 сравнивают тайл напрямую с TILE_WALL
(тип 20). Ворота, верх двери, зеркало и чомпер преградой не считаются.
Причина: wall_type реализована у нас (pop_map.c:504, таблица на
:498), но НЕ экспортирована — в pop_map.h её нет, поэтому guards.c
до неё не дотягивается. То есть это не пробел в портировании логики, а
следствие границы модулей.
Чем грозит: страж, преследующий упавшего Кида, у нас шагнёт вперёд там, где оригинал отступает — перед закрытыми воротами, верхом двери, зеркалом и чомпером. Отсюда возможны и проход стража сквозь препятствие, и падение туда, куда оригинал его не пускает.
Как проверить: уровень с воротами (например, 3-й) — заманить стража к закрытым воротам после падения Кида и сравнить, отступает ли он.
Замечание: в guards.c таких мест ЧЕТЫРЕ (:148, :683, :685);
одно из них (:148) уже перечисляет три тайла вручную, то есть
расхождение частично компенсировано, но не везде одинаково.
Область: столкновение со стенами и падение внутри стены
Заведена по наблюдению пользователя (2026-08-31): разбег, прыжок сделан рано, Кид не долетел, врезался в стену и начал падать — но по X он оказался ВНУТРИ стены и падал частично в ней.
21. Фикс «скольжения сквозь стену» не портирован — ранг Г (соответствие ванили)
Оригинал: в do_fall (seg005:37) есть блок FIX_GLIDE_THROUGH_WALL с
собственным комментарием SDLPoP: «Кид падает сквозь стены после разворота
в беге, особенно в невесомости». Блок опциональный — то есть в ВАНИЛЬНОЙ
игре этот баг ЕСТЬ, а SDLPoP его чинит по желанию. Рядом такие же
опциональные FIX_JUMP_THROUGH_WALL_ABOVE_GATE и FIX_DROP_THROUGH_TAPESTRY.
У нас: ни один из трёх не портирован — мы намеренно повторяем ваниль.
Вывод по симптому: «падение частично в стене» — с большой вероятностью ОРИГИНАЛЬНОЕ поведение PoP, а не наша ошибка. Ранг Г означает: различия с ванилью, скорее всего, нет. Но проверить стоит другое — не ХУЖЕ ли у нас, чем в ванили (см. находки 22 и 15).
Как проверить: повторить сцену на живом SDLPoP с выключенными фиксами (они выключаемы в его настройках) и сравнить глубину захода в стену.
22. do_fall: наш гард curr_row <= 2 — ранг В
Оригинал: в do_fall ветка «достигли нового ряда» выполняется БЕЗ
условия на номер ряда: проверка тайла стены с вызовом выталкивания, затем
land() либо переход на ряд ниже.
У нас: pop_map.c:909 — вся ветка обёрнута в if (Char.curr_row <= 2).
Причина задокументирована (:768): наш get_tile за нижней кромкой
комнаты отдаёт СТЕНУ как сентинель, тогда как в оригинале там комната
снизу, и без гарда выталкивание срабатывало ложно, смещая падение на тайл.
Чем грозит: гард гасит не только ложные срабатывания. Если персонаж
достиг curr_row == 3 легитимно (падение между комнатами по вертикали),
у нас не выполнится ни выталкивание из стены, ни land(), ни переход
ряда — всё это ляжет на следующий кадр и другую ветку. Именно такая
комбинация (падение у границы комнаты рядом со стеной) даёт кандидата в
причины наблюдения пользователя.
Как проверить: падение вдоль стены точно на стыке комнат по вертикали;
в отладчике смотреть curr_row, Char.x и факт вызова выталкивания.
23. bumped_fall — ранг Д
Сверен (seg004 против pop_map.c): откат X на 4 пикселя назад, обнуление
горизонтальной скорости в свободном падении, иначе seq_45_bumpfall с
проигрыванием, звук удара. Совпадает; у нас добавлен только флаг «стражи
услышали», который в оригинале ставится внутри звуковой функции.
Замечание по области: глубина отката при столкновении — ровно 4 пикселя в обоих движках. Если Кид вошёл в стену глубже (а при недолёте с разбега скорость по X велика), одного отката не хватит ни там, ни у нас — и дальше всё зависит от того, сработает ли выталкивание из стены на следующем кадре. У нас его может съесть гард из находки 22. Это главная зацепка по симптому.
24. in_wall: не перезагружается кадр — ранг Б
Оригинал: in_wall (seg006) после выталкивания персонажа из стены
делает load_fram_det_col() — ЗАГРУЖАЕТ КАДР и следом определяет колонку,
затем перечитывает тайл.
У нас: pop_map.c (in_wall) вызывает только determine_col().
Пороги (>= 8), формулы смещения (6 - d и d + 4), условие по тайлу
впереди и финальное чтение тайла совпадают — расходится только этот шаг.
Чем грозит: после выталкивания данные кадра (картинка, смещения, флаги — включая «нужен пол» и «чётный пиксель») остаются от позиции ДО коррекции, а ими пользуются проверки того же кадра: падение, клип, коллизия. Это ровно область, где наблюдалось падение внутри стены (находки 21, 22).
Как проверить: недолёт с разбега в стену; в отладчике сравнить Char.x,
колонку и поля текущего кадра сразу после выталкивания.
25. Таблицы кадров и seqtbl — ранг Д
frame_table_kid, original_seqtbl и таблица смещений извлекаются
АВТОМАТИЧЕСКИ из исходников SDLPoP (tools/pop_extract_kid_data.py →
gen/kid_data.h), поэтому расхождение в данных маловероятно по
построению. Применение тоже сверено: используются все четыре флага кадра
(«нужен пол» 0x40, вес по X 0x1F, «тонкий» 0x20, чётный пиксель 0x80), а
байт клинка маскируется как в оригинале (& 0x3F, pop_kdraw.c:31).
Не сверено: старшие два бита байта клинка (номер набора спрайтов) — у Кида он нулевой, у прочих персонажей стоит проверить отдельно.
26. Полнота автоуправления — ранг Д
Из двенадцати функций autocontrol_* оригинала у нас есть одиннадцать.
Отсутствующая — тривиальная обёртка над общей логикой стража; у нас она
встроена в вызывающего. Расхождения нет.
Не сверены ПОСТРОЧНО тела: autocontrol_guard_kid_armed,
autocontrol_guard_kid_far, autocontrol_shadow*, autocontrol_skeleton,
check_grab, check_bumped_look_left, back_with_sword,
forward_with_sword.
ИТОГ АУДИТА
Проверено 26 позиций за пять проходов.
| ранг | находки | суть |
|---|---|---|
| А | 1, 12, 13 | лишний пересчёт колонки в land; нет ветки «убит и сброшен с уступа»; безусловное прижатие к полу при смерти |
| Б | 2, 3, 7, 8, 14, 18, 20, 24 | fall_x и момент сброса; порядок «пики / коррекция X»; перо только для Кида; отложенные чомперы; нет seq_64; звук удара; «стена» сужена до одного тайла; нет перезагрузки кадра в in_wall |
| В | 4, 5, 10, 11, 15, 22 | флаг смерти вместо счётчика стадий; мёртвый доигрывает приземление; разнесённая цепочка отрисовки; порядок Кид/страж; гейт в check_bumped_look_right; гард curr_row <= 2 в do_fall |
| Г | 21 | опциональные фиксы SDLPoP не портированы — соответствие ванили |
| Д | 6, 9, 16, 17, 19, 23, 25, 26 | сверено и совпадает |
Три узла, вокруг которых группируются расхождения
- Смерть при активной физике (1, 12, 13, 4, 5). Здесь все находки ранга А. Общая причина: у нас смерть — это флаг, а физика продолжает работать с персонажем как с живым.
- Границы модулей (12, 20, 24).
pop_mapне отдаёт наружу то, что нужноguards.cи работе с произвольнымChar: тайловые запросы отChar,wall_type, загрузку кадра. Ветки упрощались не по логике, а по доступности функций. - Момент побочных действий (2, 8, 18, 24). Делаем то же самое, но раньше или позже оригинала: сброс скорости, побудка чомперов, звук, перезагрузка кадра. По отдельности мелочь, вместе — сдвиг состояния на кадр.
Что делать дальше
- Проверить находки А и Б в MAME по сценариям из их описаний — начиная с 12/13 (смерть на краю) и 24 (выталкивание из стены).
- Те же сцены прогнать на живом SDLPoP: часть наблюдений может оказаться ванильным поведением (как находка 21).
- Подтверждённые осознанные отличия записать в
docs/impl_diff.md— сейчас там нет ни одного из найденных, хотя правило проекта требует.
Порядок по ожидаемой отдаче:
- Бой — ПРОЙДЕН. Находки: 12, 13 (ранг А), 18 (Б); совпадают
control_with_sword,parry,swordfight,sword_strike,check_sword_hurt,check_hurting(кроме звука). Не сверены мелочи:back_with_sword,forward_with_sword,check_skel. play_seqи опкоды — самая опасная область: ошибка в одном опкоде меняет все последовательности разом.- Отрисовка —
add_kid_to_objtable/add_guard_to_objtable, порядок слоёв,clip_char. do_fall/check_bumped/check_grab— остаток физики.- ИИ стражей и особенности скелета/Тени/Джафара.
Глубокое ревью находок А и Б: можно ли починить и чем платим
2026-08-31. КОД ПО-ПРЕЖНЕМУ НЕ МЕНЯЛСЯ. Здесь только оценка.
Главное ограничение (требование пользователя): фикс не должен заметно замедлять игру. Поэтому у каждой находки первым делом указана ЧАСТОТА вызова места, а уже потом сама правка.
Оговорка к ограничению: для КРИТИЧНЫХ фиксов скорость — не вето. Если такой фикс всерьёз бьёт по производительности, он выносится в отдельный разбор, где ищется способ получить правильное поведение дёшево (иной момент вызова, кэш, предвычисление, перенос в холодный путь). То есть порядок такой: сначала решаем, критично ли поведение, и только потом — какой ценой его добиться.
Снятое препятствие
Обоснование сразу двух упрощений (guards.c:1011 — «нужны тайловые
запросы ОТ Char, а pop_map умеет только от Kid») устарело. Проверено:
get_tile_at_char, get_tile_infrontof_char, get_tile_behind_char и
distance_to_edge_weight в pop_map.c УЖЕ работают от Char
(строки 460, 465, 477, 561). Они лишь не выведены в pop_map.h.
Так же обстоит с данными: pop_char_set_seq() ставит любую из 115
последовательностей по индексу, то есть seq_81 и seq_64 доступны без
единого нового байта данных — таблица генерируется из оригинала целиком.
То есть три находки (12, 14, 20) упираются не в архитектуру, а в четыре строки объявлений.
Классификация мест по частоте вызова
| место | частота | вывод |
|---|---|---|
play_seq (находки 7, 8) |
КАЖДЫЙ кадр каждого персонажа | правка обязана быть бесплатной |
check_hurting (18) |
каждый кадр боя, дважды | почти горячий |
| ИИ стража (20) | каждый кадр, пока страж активен | почти горячий |
land, in_wall, bumped (1, 2, 3, 14, 24) |
событие раз в несколько секунд | холодный, цена не важна |
hurt_by_sword (12, 13) |
момент попадания | холодный |
Разбор по находкам
12 + 13 (ранг А) — смерть на краю. Чинится, цена нулевая
Место: guards.c, hurt_by_sword — холодный путь.
Правка: (а) перенести две строки прижатия к полу внутрь ветки
выжившего удара — это чистое перемещение, минус ноль байт; (б) добавить
ветку выбора seq_81 по тайлу позади и расстоянию до кромки.
Что нужно: экспорт get_tile_behind_char() и distance_to_edge_weight()
из pop_map.c в pop_map.h как __banked.
Цена скорости: два межбанковых вызова (guards.c — банк 1, pop_map.c
— банк 3) в момент попадания мечом, то есть несколько раз за бой.
Незаметно.
Цена памяти: банк 1 занят на 19,8 % (13 142 Б свободно) — места вдоволь; банк 3 занят на 81,1 % (3 100 Б), но там прибавятся только две обёртки.
Риск: низкий. Ветка симметрична существующей, данные есть.
1 (ранг А) — лишний determine_col() в land
Место: холодный путь. Правка: убрать вызов и проверить, не
понадобился ли он нам вместо оригинального load_fram_det_col, который
оригинал делает в другом месте цепочки. Цена: отрицательная (кода
меньше). Риск: СРЕДНИЙ — вызов мог компенсировать наш иной порядок
загрузки кадра; убирать только с прогоном сцен падения и приземления.
2, 3 (ранг Б) — land: fall_x и порядок проверки пик
Место: холодный. Правка 2: сбрасывать только fall_y и после
play_seq, как оригинал. Правка 3: перенести проверку пик после
коррекции X. Цена: нулевая, это перестановка строк. Риск: низкий,
но обе меняют поведение на краю тайла — нужны прогоны с пиками.
24 (ранг Б) — in_wall не перезагружает кадр. Одна строка
Место: холодный. Правка: заменить determine_col() на
pop_load_fram_det_col() — он УЖЕ экспортирован (pop_kid.h:109) и, что
важно, НЕ банковый, то есть вызов прямой. Цена скорости: одна
перезагрузка кадра при выталкивании из стены — доли процента кадра.
Риск: низкий; это возврат к оригиналу.
14 (ранг Б) — нет seq_64
Место: bumped, холодный. Правка: добавить выбор между 64 и 65 по
направлению толчка. Цена: нулевая. Риск: низкий.
18 (ранг Б) — звук удара
Место: check_hurting — дважды за кадр боя. Правка: перенести звук
в конец функции и обвесить тремя условиями оригинала. Цена:
ОТРИЦАТЕЛЬНАЯ — звук перестанет играть в двух случаях из трёх, то есть
уменьшится и число обращений к звуковой очереди. Риск: низкий.
20 (ранг Б) — «стена» у стражей. Требует осторожности со скоростью
Место: ИИ стража — вызывается каждый кадр, пока страж активен.
Плохой вариант: экспортировать wall_type из pop_map.c и звать из
guards.c. Это МЕЖБАНКОВЫЙ вызов (банк 1 → банк 3) в почти горячем
пути — трамплин с переключением W3 на каждый шаг ИИ. Против требования
по скорости.
Хороший вариант: завести копию таблицы wall_type_tbl (32 байта) в
rodata банка 1 и обращаться к ней напрямую — стоимость чтения байта,
ноль переключений банка. Дублирование данных здесь оправдано: таблица
константная и вшита в формат уровней.
Риск: низкий, но нужно следить, чтобы копия не разошлась с оригиналом — лучше генерировать обе из одного места или снабдить перекрёстным комментарием.
7 (ранг Б) — перо только для Кида. Правка ускоряет
Место: play_seq, самый горячий путь. Правка: убрать лишнее условие
по charid. Цена: ОТРИЦАТЕЛЬНАЯ — из горячего цикла уходит сравнение.
Риск: средний: надо убедиться, что физика пера у нас применяется к
любому персонажу так же, как в оригинале, иначе анимация разойдётся с
физикой.
8 (ранг Б) — отложенные чомперы. Чинить ДЕШЁВЫМ способом
Место: play_seq, горячий путь.
Почему отложено: play_seq маппит страницу байткода в окно W0 ОДИН раз
перед циклом (pop_kid.c:284) и снимает после (:389). Вызвать
start_chompers внутри цикла — значит снять окно, позвать, вернуть окно,
и так на каждый переход ряда. Это прямая деградация горячего пути и
против требования по скорости.
Дешёвая замена: сейчас копится ОДИН флаг, из-за чего теряются промежуточные ряды. Достаточно копить не флаг, а НОМЕРА рядов — один байт-битовую маску (рядов всего 0..3) плюс запомненную колонку. После цикла пройти по взведённым битам и разбудить чомперов в каждом. Цена в цикле: одна операция «выставить бит» вместо присваивания флага, то есть ноль. Разница с оригиналом останется только в МОМЕНТЕ побудки (после цикла, а не внутри), но ряды перестанут теряться.
Риск: низкий. Полное совпадение с оригиналом здесь недостижимо без
потери скорости — это осознанный компромисс, который надо записать в
docs/impl_diff.md.
Сводка: что делать в каком порядке
| приоритет | находки | почему |
|---|---|---|
| 1 | 13, 24 | по одной-две строки, риск низкий, оба в узле «смерть/стена» |
| 2 | 12, 14 | нужен экспорт двух функций; закрывают наблюдения пользователя |
| 3 | 18, 8 | обе УСКОРЯЮТ или бесплатны; 8 — по дешёвому варианту |
| 4 | 2, 3, 20 | перестановки и копия таблицы; нужны прогоны |
| 5 | 1, 7 | риск средний: обе могут компенсировать наши отличия в другом месте |
Ни один фикс не требует переделки архитектуры и ни один не ложится на горячий путь с накладными расходами — при условии, что находка 20 делается копией таблицы, а находка 8 — битовой маской рядов.