Files
Sprinter-SDCC/applications/SprPoP/docs/sdlpop_audit.md
T
snark13 a5252b1c60 SprPoP: глубокое ревью находок А/Б — исправимость и цена по скорости
КОД НЕ МЕНЯЛСЯ.  Разбор одиннадцати находок рангов А и Б: что менять, во
что это обойдётся по скорости и памяти, каков риск.

СНЯТО ГЛАВНОЕ ПРЕПЯТСТВИЕ.  Обоснование двух упрощений (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
2026-08-31 18:31:34 +03:00

51 KiB
Raw Blame History

Аудит расхождений с 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. Разбор цепочки:

  1. start_fall (seg006:1099) ПЕРВЫМ ДЕЛОМ убирает меч (Char.sword = sword_0_sheathed) — для любого падения, независимо от здоровья. Значит к моменту приземления меч уже в ножнах.
  2. land (seg005:114) при падении на один ряд выбирает seq_63_guard_active_after_fall, только если charid >= guard ИЛИ меч вынут; иначе — seq_17_soft_land, то есть ПРИСЕД. Из-за п.1 для Кида это всегда присед.
  3. land НЕ смотрит ни на alive, ни на hitp_curr вовсе.
  4. 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:664fell_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/loadshadload_fram_det_colload_frame_to_objstuck_lowerset_char_collisionset_objtile_at_charredraw_at_charredraw_at_char2clip_charadd_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_range 8/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.pygen/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. Смерть при активной физике (1, 12, 13, 4, 5). Здесь все находки ранга А. Общая причина: у нас смерть — это флаг, а физика продолжает работать с персонажем как с живым.
  2. Границы модулей (12, 20, 24). pop_map не отдаёт наружу то, что нужно guards.c и работе с произвольным Char: тайловые запросы от Char, wall_type, загрузку кадра. Ветки упрощались не по логике, а по доступности функций.
  3. Момент побочных действий (2, 8, 18, 24). Делаем то же самое, но раньше или позже оригинала: сброс скорости, побудка чомперов, звук, перезагрузка кадра. По отдельности мелочь, вместе — сдвиг состояния на кадр.

Что делать дальше

  1. Проверить находки А и Б в MAME по сценариям из их описаний — начиная с 12/13 (смерть на краю) и 24 (выталкивание из стены).
  2. Те же сцены прогнать на живом SDLPoP: часть наблюдений может оказаться ванильным поведением (как находка 21).
  3. Подтверждённые осознанные отличия записать в docs/impl_diff.md — сейчас там нет ни одного из найденных, хотя правило проекта требует.

Порядок по ожидаемой отдаче:

  1. Бой — ПРОЙДЕН. Находки: 12, 13 (ранг А), 18 (Б); совпадают control_with_sword, parry, swordfight, sword_strike, check_sword_hurt, check_hurting (кроме звука). Не сверены мелочи: back_with_sword, forward_with_sword, check_skel.
  2. play_seq и опкоды — самая опасная область: ошибка в одном опкоде меняет все последовательности разом.
  3. Отрисовкаadd_kid_to_objtable/add_guard_to_objtable, порядок слоёв, clip_char.
  4. do_fall/check_bumped/check_grab — остаток физики.
  5. ИИ стражей и особенности скелета/Тени/Джафара.

Глубокое ревью находок А и Б: можно ли починить и чем платим

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 — битовой маской рядов.