Compare commits

...

11 Commits

Author SHA1 Message Date
snark13 63bfd997a9 SprPoP: звук мигания, равномерные часы, полоса HP после рестарта, надпись ждёт мелодию
Четыре правки прогона 2026-08-31.  Две ПРОВЕРЕНЫ пользователем в MAME
(звук мигания, порядок «мелодия -> надпись»), две ждут проверки — образ
собран.

ЗВУК МИГАНИЯ (проверено).  Упаковщик научился синтезировать ноты PC-
спикера в обычный сэмпл: заголовок с темпом, тройки «частота + длина»,
меандр на нашей частоте вывода.  Берём из нот ТОЛЬКО номера, которых нет
ни в оцифровке, ни среди мелодий, — иначе синтез перекрыл бы музыку,
которую мы играем из MUS/.  На поставке SDLPoP это ровно один номер: 38,
сигнал под мигание «Press Button»; 31, 34 и 42 там пустые заглушки.
Громкость по слуховой проверке снижена вдвое (44 -> 22): на полном
размахе сигнал перекрикивал игру.  EXE не меняется — раскладка читается с
диска, набор занял те же 9 страниц.

НАДПИСЬ ЖДЁТ МЕЛОДИЮ (проверено).  Порядок оригинала: ветка мёртвого
(seg006:1351) на седьмом шаге выходит, пока звук играет, и «Press Button»
появляется только после музыки смерти.  Чтобы ожидание не было
принудительным, три быстрых пути (Ctrl+A, обе быстрые загрузки, пункты
меню) музыку глушат — оригинал при Ctrl+A делает то же (seg000:0617).
Обычная кнопка во время мелодии не действует: она ответ НА надпись.

ЧАСЫ (ждёт проверки).  Тик стоит столько кадров ЛУЧА, сколько их в кадре
режима NORMAL, поэтому FAST/FASTEST больше не ускоряют время.  Считаем
ФАКТИЧЕСКИ прошедшие кадры луча, а не ожидаемый делитель: логический кадр
не всегда укладывается в бюджет, и часы «по делителю» шли рывками (первый
прогон это показал — «несколько секунд быстро, потом притормаживание»).
Вклад одного вызова ограничен, иначе пауза и меню прыгнули бы вперёд.
Четыре новых теста: NORMAL не сдвинулся ни на тик (и вне боя, и в бою),
FAST и FASTEST держат реальное время.

ПОЛОСА HP ПОСЛЕ Ctrl+A (ждёт проверки).  Корень: счётчик считает
СТРАНИЦЫ, а тратился по КАДРАМ — между двумя вызовами переворота может не
быть, и оба прохода уходили в одну страницу, вторая оставалась с
делениями прошлого боя.  Теперь проход тратится только при смене
gfx_get_draw_page().  Плюс полная чистка всей ширины при инвалидации:
старая полоса могла заходить под статус-текст, где щадящая чистка её не
трогала; текст сразу перезапрашивается.

Все 16 наборов host-тестов зелёные, check_bank_calls чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 23:10:35 +03:00
snark13 decbec79de SprPoP: три находки прогона 2026-08-31 — звук мигания, часы в быстрых режимах, полоса HP после рестарта
Разбор без правок кода; все три отмечены как задачи по решению пользователя.

SND-SPEAKER-38 (TASKS_OPEN, P1).  Надпись «Press Button» мигает молча.
Наш код не виноват: pop_dead_prompt зовёт звук 38 на каждом появлении,
как оригинал, но слот 38 в наборе ПУСТ.  Причина — три параллельных
набора звука у оригинала: оцифровка (0-23, 44-49, 51), мелодии (24-43
частично, 50, 52-56) и ноты PC-спикера (весь диапазон).  Звук 38 есть
ТОЛЬКО среди нот спикера, поэтому провалился между нашими конвейерами.
Полная ревизия: не оцифровка и не мелодия — номера 31, 34, 38, 42, из них
31/34/42 пустые заглушки, реально звучит ровно один — 38.  Решение
выбрано пользователем: синтезировать ноты в PCM и класть в наш атлас;
формат разобран по спецификации Princed и записан в задачу.

TIME-SPEED (TASKS_OPEN, P1).  Часы уменьшаются на каждом логическом кадре
(как оригинал), но длину кадра у нас меняет режим скорости — в FAST
минута проходит на треть быстрее.  Разная длина кадра в игре и в бою есть
и в оригинале (поправка пользователя), поэтому замедление часов в бою не
трогаем; вопрос только в наших добавочных режимах.  Два варианта с
рекомендацией оставить как есть.

HP-BAR-RESTART (BUGS_OPEN).  После гибели и Ctrl+A (у нас это рестарт
уровня) на одной из страниц остаётся полоса по результатам боя.  Механизм
перерисовки на месте — счётчик страниц, все холодные пути его взводят.
Подозреваемый: пока висит статус-текст, стирание чистит только края и не
трогает середину, а полоса стража при большом запасе HP заходит именно
туда.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 22:31:32 +03:00
snark13 b0e7130d0b SprPoP: мёртвое тело больше не приземляется в присед, надпись смерти не залипает
Два независимых фикса, оба проверены в MAME.

1. ПРИЗЕМЛЕНИЕ МЁРТВОГО (симптом «убили Кида, а он сел этажом ниже»).
   Оригинал спрашивает у приземляющегося, жив ли он (seg005:0173): вся
   развязка «мягко / средне / разбиться» отведена ЖИВОМУ, телу — своя
   ветка (добить HP, звук падения насмерть, seq_22).  У нас развилки не
   было, и в глаза это не бросалось только из-за высоты: тело, сброшенное
   ударом с ОДНОГО ряда, набирает fall_y < 22 — урона нет, «последнее HP»
   не тратится, ветка «разбился» не выбирается никогда.  Труп уходил в
   мягкое приземление и садился (кадр 109).

   Цена — один тест байта на вызов land(), то есть на событие касания
   земли, а не на кадр.  Живой путь не изменился ни на операцию.

2. ЗАЛИПАВШАЯ НАДПИСЬ «Press Button to Continue».  Счётчик кадров смерти
   живёт снаружи главного витка и потому переживает возврат на заставку.
   Ответ игрока кнопкой его обнулял, а выход по таймауту (24 с молчания
   -> title) уходил мимо сброса.  Дальше счётчик оставался израсходованным
   на всю сессию, и в следующей игре ПЕРВАЯ же смерть мгновенно уводила в
   title, не показав надписи; лечилось только перезапуском программы.
   Сброс поставлен на входе в игровой маршрут — закрывает и остальные
   боковые дороги (выпадение за нижнюю границу, смена уровня).

Здесь же довезена связка находок 12/13 аудита: смерть безоружного у
обрыва уходит в свою последовательность (seq_81), а прижатие к полу
осталось страховкой для прочих веток — снять его целиком не вышло дважды,
подробности в комментарии guards.c.

Тесты: t_death дополнен обеими сторонами развилки (мёртвый обязан
разбиться, живой с той же высоты — сесть без урона), 18 проверок; все 16
наборов host-тестов зелёные.

В доску записан CLIMB-VS-GUARD: Кид подтягивается к стражу этажом выше —
у нас удар порой смертелен, в оригинале Кид срывается без урона.  Цепочка
засчитывания удара сверена с оригиналом и совпадает дословно, расходятся
входные данные.  Лучшая зацепка — ЗВУК: оригинал играет взмах клинка (11)
при любом уколе, до всякой проверки попадания, а у нас слышен упор в
стену (8) — значит страж не атакует, а сталкивается.  Набор звуков
проверен и не виноват.  Отложено по решению пользователя.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 22:17:38 +03:00
snark13 4e12aa50d1 SprPoP: падение сквозь стену больше не проходит
Портировано опциональное исправление SDLPoP (fix_glide_through_wall,
seg005 в do_fall).  В ванили персонаж, падающий после разворота в беге,
может оказаться внутри кладки и лететь «в стене» — баг оригинала,
воспроизведённый пользователем в игре и затем на host-тесте.

Решением 2026-08-31 фикс взят в ТЕКУЩИЙ билд: играбельность важнее
буквальности.  Реализация вынесена отдельной функцией
glide_through_wall_guard() в pop_map.c намеренно — при разделении
VANILLA/ENHANCED это готовая точка отвязки, достаточно не звать её в
ванильном режиме.

ПРОВЕРКА.  Набор t_wall был заранее написан так, чтобы сторожить ЧИСЛО
заходов в кладку: до фикса их было ровно два из четырнадцати стартовых
позиций, после — ноль.  Остальные 15 наборов (в том числе phys с 1733
проверками и grab) остались зелёными.  Живая проверка в MAME
пользователем: корректно.

Ожидание в тесте обновлено ОСОЗНАННО, прежнее число сохранено рядом
отдельной константой с пометкой «сколько было до фикса»: оно измерено, и
понадобится, когда появится режим VANILLA — там ожидание станет зависеть
от режима.

ЦЕНА: +57 байт в банке 3 (свободно 3043), резидент и куча не изменились.
По скорости попадание только на кадры падения: пересчёт колонки — одно
деление, дистанция до кромки считается лишь если персонаж действительно
внутри кладки.

Документы: в аудите находка 21 переведена в «портировано» с сохранением
исходного разбора; в vanilla_vs_bugfixed статус фикса стал ВЗЯТ, сводка
пересчитана (5 взято, 32 кандидата в ENHANCED).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 20:52:44 +03:00
snark13 c218e8b983 SprPoP: инвентаризация всех 43 фиксов SDLPoP со статусами
Документ vanilla_vs_bugfixed.md дополнен полным перечнем опциональных
исправлений оригинала, какие есть в SDLPoP, с их статусом у нас и
местом в коде, где решение принято.

Расклад: 4 взяты (дверь выхода, звуки ворот, перо только для Кида,
приоритеты звуков), 4 сознательно оставлены ванильными (падение на
стража, прыжок через стража, трюк 35, кровь скелета), 1 в работе
(падение сквозь стену), 33 не реализованы — кандидаты в ENHANCED.

ГЛАВНОЕ СЛЕДСТВИЕ: наш билд — это не VANILLA, а «ваниль плюс четыре
исправления».  При разделении режимов придётся пройтись по уже сделанным
отступлениям и распределить их, иначе текущее поведение нельзя считать
эталоном ни для одного режима.

ИСПРАВЛЕНА МОЯ НЕВЕРНАЯ ОЦЕНКА.  Ранее было записано, что фиксы,
требующие правки байткода seqtbl, у нас недоступны без переделки
конвейера данных.  Это неверно: байткод можно менять и у нас.  Лучший
способ — держать ОБЕ версии в одной странице EMM: kid.ani занимает около
4 КБ при странице в 16 КБ, так что обе помещаются рядом, а переключение
режима сводится к смене базового смещения — без патчей и с мгновенным
откатом.  У SDLPoP, к слову, рабочая таблица и неизменная копия
оригинала тоже существуют раздельно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 20:34:19 +03:00
snark13 a3aaa30e53 SprPoP: тесты смерти от меча (базовое поведение до правок 12/13)
КОД ИГРЫ НЕ МЕНЯЛСЯ.  Новый набор t_death — 9 проверок, всего наборов 16.

Заведён ПЕРЕД правкой находок 12/13, чтобы поймать деградацию: правка 13 в
изоляции уже ломала смерть (мёртвый оставался с ненулевой скоростью
падения, проваливался за нижнюю границу, игра уходила на рестарт, не
показав тела).  Поэтому проверяется ровно то, на что эти правки влияют:

* удар не в боевой стойке смертелен независимо от запаса HP;
* удар с мечом снимает одно HP, на последнем — убивает;
* ПЕРЕЖИВШИЙ удар ставится на пол своего ряда с нулевой скоростью падения
  (в оригинале это единственная ветка, где координата трогается);
* тело после смерти остаётся в своём ряду — и на ровном полу, и у самого
  обрыва (целевая сцена находки 12; после правки ожидание изменится
  осознанно).

По дороге тест дважды показал не баг движка, а мои ошибки в самой сцене:
обвязка выставляет признак «жив» только стражу, а урон применяется не
сразу — удар выставляет дельту, и HP меняет отдельный шаг кадра, как в
оригинале.  Оба раза чинился тест.

VANILLA/BUGFIXED: записано, что переключатель уже существует в настройках
и зафиксирован в положении VANILLA, второй заводить не нужно.  Отмечено
главное следствие — наш «ванильный» билд УЖЕ не чистая ваниль (часть
ванильных багов пофикшена), поэтому при разделении режимов придётся
пройтись по сделанным отступлениям и распределить их; отдельные фиксы
(падение сквозь стену) могут быть сделаны и в нынешнем билде.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 20:23:35 +03:00
snark13 90e304f071 SprPoP: тесты стены + задача VANILLA/BUGFIXED, находка 24 отложена
КОД ИГРЫ НЕ МЕНЯЛСЯ — правка находки 24 сделана и откачена.

ТЕСТЫ.  Набор t_wall вырос с одной проверки до трёх: добавлены проверка
X на каждом кадре падения (персонаж не должен оказываться внутри кладки)
и симметричный прыжок через провал с другой стороны.

Ключевое в них — форма ожидания.  Тест НЕ требует нуля заходов в кладку,
а сторожит их ЧИСЛО: сейчас ровно два случая из четырнадцати стартовых
позиций.  Это ванильное поведение оригинала, для которого SDLPoP держит
отдельное опциональное исправление; больше двух — значит правка сделала
нас хуже ванили, меньше — значит фикс кем-то портирован.  То есть тест
сразу готов обслуживать оба будущих режима.

По дороге тест дважды ловил не баг движка, а мою ошибку в самой сцене
(старт в пустой клетке; перелёт через площадку считался нарушением).
Оба раза чинился тест, а не движок.

НАХОДКА 24 ОТЛОЖЕНА.  Перезагрузка кадра в in_wall верна по букве
оригинала, но эффекта показать не удалось: все 15 наборов host-тестов
дают одинаковый результат до и после.  При этом правка не бесплатна —
маппинг окна и перезагрузка кадра на каждое выталкивание.  Платить за
недоказанное не стали.

НОВАЯ ЗАДАЧА: docs/vanilla_vs_bugfixed.md — поддержка двух поведений,
ванильного и с багфиксами.  Туда переехали находка 24, три опциональных
фикса SDLPoP (скольжение сквозь стену, прыжок над воротами, гобелен) и
готовый детектор из t_wall.  Открытые вопросы записаны: чем переключать
(возможно, объединить с уже существующим VANILLA/ENHANCED), цена
рантайм-проверки в горячем пути, что считать умолчанием, как гонять
тесты в двух режимах.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 20:13:25 +03:00
snark13 b55d4d11e3 SprPoP: аудит — правки 12 и 13 неделимы (проверено на живой машине)
Правка 13 (перенос прижатия к полу в ветки пережитого удара, как в
оригинале) была сделана в изоляции и СЛОМАЛА смерть: страж убивает Кида,
а вместо тела и паузы идут вспышка, стопкадр и мгновенный выход в
заставку.

Причина: у нас прижатие к полу работало КОМПЕНСАЦИЕЙ отсутствующей ветки
«убит и сброшен с уступа» (находка 12).  Без неё мёртвый остаётся с
ненулевой fall_y, физика ведёт его вниз, он пересекает нижнюю границу,
взводится pop_fell_out — и приложение уходит на рестарт РАНЬШЕ отрисовки,
поэтому тела не видно вовсе.

Оценка «чистое перемещение двух строк, риск низкий» была неверной.  В
документе исправлено: риск ВЫСОКИЙ, пока ветка 12 отсутствует; обе
находки — одна правка, и порядок внутри неё обратный: сперва добавить
seq_81 с экспортом тайловых запросов, убедиться, что смерть на краю
отыгрывается ею, и только потом снимать страховку.

Правка откачена, дерево вернулось к проверенному состоянию.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 19:57:42 +03:00
snark13 ca67895667 SprPoP: починена сборка host-тестов
tests/host не линковались: не разрешались pop_chdir_home (его тянет
pop_kboot из банка 10) и kbd_raw_keypad_as_ext (тянет pop_ctrl из libc).
Поломка предсуществующая — воспроизводится и на коммите до всех правок
этой сессии.  Ни файловой системы, ни клавиатуры в хостовых тестах нет,
поэтому обе заглушены пустышками в общей обвязке.

Теперь все пятнадцать наборов проходят: geom 3144 проверки, phys 1733,
char 72, grab 55, jaffar 44, shadow 45, app 58, cfg 51, demo 30, flow 27,
timer 28, cutscene 13, gate 10, mouse 17, wall 1.

Это условие для дальнейшей работы: логику движка снова можно проверять за
секунды, без сборки образа и ручного прохождения в MAME.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 19:57:42 +03:00
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
snark13 3a0e847353 SprPoP: аудит расхождений с SDLPoP — Кид, стражи, seqtbl, отрисовка
КОД НЕ МЕНЯЛСЯ.  Построчный разбор наших реализаций против оригинала:
26 позиций за пять проходов, каждая с рангом вероятности (А..Д), с
описанием «чем грозит» и сценарием проверки.

Расхождения группируются в три узла, и это главный вывод аудита:

1. СМЕРТЬ ПРИ АКТИВНОЙ ФИЗИКЕ — здесь все находки ранга А.  У нас смерть
   это флаг, а физика продолжает вести персонажа как живого: нет ветки
   «убит и сброшен с уступа» (оригинал выбирает её по тайлу позади),
   прижатие к полу в hurt_by_sword стало безусловным (в оригинале только
   для выжившего удара), в land лишний пересчёт колонки.  Этим
   объясняется наблюдение пользователя: заколотый на краю Кид доезжает
   этажом ниже и садится в присед.

2. ГРАНИЦЫ МОДУЛЕЙ — pop_map не отдаёт наружу тайловые запросы от
   произвольного Char, wall_type и загрузку кадра.  Три ветки упрощены НЕ
   по логике, а по доступности функций: отсутствующая ветка уступа,
   «стена впереди» сужена у стражей до одного тайла (оригинал считает
   преградой ещё ворота, верх двери, зеркало и чомпер), in_wall не
   перезагружает кадр.  Чинить это заплатками неправильно — сначала
   расширять интерфейс pop_map.

3. МОМЕНТ ПОБОЧНЫХ ДЕЙСТВИЙ — делаем то же самое, но раньше или позже:
   сброс fall_x, побудка чомперов (у нас отложена до конца play_seq),
   звук удара, перезагрузка кадра.  По отдельности мелочь, вместе — сдвиг
   состояния на кадр.

Восемь позиций СВЕРЕНЫ И СОВПАДАЮТ (диспетчер control, все 15 опкодов
seqtbl, control_with_sword, parry, swordfight, sword_strike,
check_sword_hurt, check_hurting, bumped_fall, таблицы кадров) — их не
нужно перепроверять.  Дважды по ходу работы едва не записана ложная
находка из-за чтения отфильтрованного вывода; отсюда правило: фиксировать
расхождение только после чтения обеих реализаций целиком.

Отдельно: второе наблюдение пользователя (падение частично в стене) —
у SDLPoP есть ТРИ опциональных фикса ровно про это, то есть в ванили баг
присутствует, и мы его намеренно повторяем.  Но найдены два места, где мы
можем быть хуже ванили (гард curr_row<=2 в do_fall и in_wall выше).

Незакрытое перечислено в файле: тела autocontrol_*, check_grab,
check_bumped_look_left, старшие биты байта клинка.  Также отмечено, что
ни одно найденное осознанное отличие не занесено в docs/impl_diff.md,
хотя правило проекта этого требует.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 18:25:02 +03:00
23 changed files with 2031 additions and 27 deletions
Binary file not shown.
Binary file not shown.
+129
View File
@@ -38,6 +38,8 @@
| [SND-PACE-DEAD](#snd-pace-dead) | пейсинг сцен по насосу CBL не включается: признак «часы идут» вычисляется двумя чтениями подряд | **тайминг/звук** | **снят 2026-08-28**: ветка удалена, сцена на единых часах по лучу |
| [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | **исправлено 2026-08-28**: кадр разложен на блоки по интервалу, удешевлять не понадобилось |
| [MUS-LEFT-TEAR](#mus-left-tear) | `pop_mus_left` (16 бит, пишет прерывание) читается из главного цикла неатомарно — возможен ложный «трек кончился» | **потенциальный** | открыт: хазард показан рассуждением, в прогоне не проявился |
| [CLIMB-VS-GUARD](#climb-vs-guard) | Кид подтягивается к стражу этажом выше: у нас удар засчитывается и убивает, в оригинале Кид просто срывается без урона; страж при этом способен провалиться сквозь пол вслед за Кидом | бой/физика | открыт: цепочка удара сверена — совпадает, расходятся входные данные (2026-08-31) |
| [HP-BAR-RESTART](#hp-bar-restart) | после гибели и Ctrl+A на ОДНОЙ из двух страниц остаётся полоса HP по результатам боя | дабл-буфер/UI | КОРЕНЬ НАЙДЕН, фикс есть, ждёт проверки (2026-08-31) |
---
@@ -1313,3 +1315,130 @@ uint16_t pop_music_left(void) { uint16_t a, b;
раз в кадр, задержка прерывания ничтожна против 11,7 мс периода насоса),
но двойное чтение не трогает состояние прерываний вовсе — на фоне
[cbl_w0_bios_conflict](../../PoP/roomtest/BUGS_CLOSED.md) это плюс.
## CLIMB-VS-GUARD
Кид стоит рядом 2,6, страж — этажом выше на 1,6. Кид тянется подтянуться
на 1,6.
**Оригинал (SDLPoP).** Страж машет мечом, Кид срывается обратно на 2,6
**без потери HP**.
**У нас.** Тот же замах убивает Кида на месте. Если Кид безоружен, это
мгновенная смерть (ветка «заколот без меча», HP разом в ноль) — и дальше
тело падает, что до 2026-08-31 давало отдельный симптом «мёртвый
вприсядку» (закрыт развилкой в `land()`, см. коммит того же дня).
**Наблюдение сверх того (пользователь, 2026-08-31).** В этой же связке
поведение расходится сильнее: страж провалился с ряда 1 на ряд 2 ВМЕСТЕ с
Кидом — причём Кид ушёл в провал в колонке 7 (там дыра), а страж между
колонками 5 и 6, где на ряду 1 пол ЕСТЬ. То есть страж проваливается
сквозь целый пол. Это может быть тем же корнем, что и удар: обоим нужен
корректный ряд/колонка персонажа в момент подтягивания.
**Что уже проверено статически (2026-08-31).** Вся цепочка засчитывания
удара сверена с оригиналом и совпадает ДОСЛОВНО:
- гейт по кадру Кида (нулевой кадр и кадры выхода по лестнице пропускают
разбор целиком) — `pop_check_sword_hurting`;
- приоритет стража при встречном попадании (отметка «ранен» у Кида
снимается) — `pop_check_sword_hurt`;
- требование ОДНОГО ряда у бьющего и жертвы — `check_hurting`;
- кадры удара (укол/третий удар), диапазон дистанции (8 для безоружной
жертвы, 12 для вооружённой, верхняя граница 29);
- формула расстояния `pop_char_opp_dist` — совпадает с `char_opp_dist`.
Значит расходятся не правила, а ВХОДНЫЕ данные: ряд, колонка или X
персонажа во время виса/подтягивания. Наиболее вероятный кандидат —
дистанция: висящий вплотную под кромкой в оригинале не дотягивает до
нижнего порога 8, а у нас пара пикселей переводит его через порог.
**Как чинить.** Не гадать — замерить на живой сцене: в момент, когда
`Opp.action` становится «ранен», снять у обоих `curr_row`, `curr_col`, `x`,
`direction` и сравнить с теми же величинами в SDLPoP на том же кадре
(метод — lldb-трасса живого SDLPoP, как в
[[sdlpop_odd_pixel_char_x]]). Отдельно снять, почему страж теряет опору:
`get_tile_at_char` под ним в кадре провала.
**Уточнение пользователя (2026-08-31, важное).** Оригинальное поведение —
Кид сорвался без потери HP — ВОСПРОИЗВОДИТСЯ и у нас: исход сильно зависит
от того, где именно стоит страж. То есть расхождение не абсолютное, а
пороговое, и это прямо подкрепляет версию про ДИСТАНЦИЮ: пара пикселей
переводит расстояние через нижний порог засчитывания удара (8 для
безоружной жертвы), и удар из «мимо» становится смертельным. Значит
искать надо не потерянную ветку, а сдвиг координаты/порога — сравнивать
`pop_char_opp_dist` в момент замаха при ОДИНАКОВОЙ расстановке.
**ЗВУК КАК ИНДИКАТОР ПУТИ (пользователь, 2026-08-31) — лучшая зацепка.**
В той же сцене оригинал играет ВЗМАХ (звук 11, «клинок движется»), а у нас
слышен звук, похожий на упор Кида в стену (звук 8, `bumped`).
Почему это важно. Звук 11 оригинал играет в `check_hurting` СРАЗУ на
кадре укола — ДО проверки расстояния и до отметки «ранен». То есть он
звучит при ЛЮБОМ уколе, попал тот или нет. Значит его отсутствие
означает не «промахнулись», а «до укола дело вообще не дошло»: страж не в
кадре укола, либо разбор вышел раньше (меч не вынут / ряды не совпали).
А звучащий вместо него упор в стену говорит, что у нас сработало
СТОЛКНОВЕНИЕ, а не атака.
Набор звуков проверен и НЕ виноват: в `assets/packed/SND/snd.idx` слот 11
на месте и содержит собственный короткий сэмпл (1280 Б), слот 8 — другой
(1664 Б). Раскладка не сдвинута.
Отсюда рабочая версия: в этой связке оригинал ведёт стража по ветке
«атака с промахом», а мы — по ветке «столкновение с персонажем»
(`bump_into_opponent` / `check_bumped`). Это же объясняет и провалившегося
сквозь пол стража: столкновение двигает его координату, а не атака.
Проверять надо ветку выбора действия стража, а не только дистанцию.
**Решение пользователя:** отложено на будущее (2026-08-31) — поведение в
этой связке расходится широко, чинить нужно целиком, а не по одному
симптому.
## HP-BAR-RESTART
**Симптом (пользователь, 2026-08-31).** Гибель на 2-м уровне, возврат к
началу по Ctrl+A: строка HP на одном из двух экранов остаётся отрисованной
по результатам боя. Замечание там же: «в начале уровня она отрисовывается
только в один экран».
**Что уже известно.**
Ctrl+A у нас — РЕСТАРТ УРОВНЯ (`sprpop_cold.c`, тот же путь, что пункт меню
`POP_MENU_RESTART_LEVEL`), а не возврат в заставку.
Перерисовка полосы устроена счётчиком страниц, а не флагом:
`pop_hp_invalidate` ставит `hp_todo = 2`, и `pop_hp_draw` тратит по одной
странице за кадр (`src/pop_cdraw.c`). Все четыре холодных пути
(старт уровня, вход в комнату, быстрая загрузка, возврат из меню)
инвалидацию зовут — механизм на месте.
**Главный подозреваемый — зона статус-строки.** Полоса HP делит строку со
статус-текстом, и пока текст висит (`pop_status_ticks != 0`), стирание
чистит ТОЛЬКО края — левее `POP_STATUS_L` и правее `POP_STATUS_R`, —
а середину не трогает, чтобы текст не мигал. При старте уровня текст как
раз висит («LEVEL 2»), поэтому всё, что от прошлой полосы попало в
середину, там и остаётся. Полоса стража рисуется справа налево от 314 и
при большом запасе HP заходит именно в эту незачищаемую зону.
**КОРЕНЬ (2026-08-31).** Счётчик считает СТРАНИЦЫ, но тратился по КАДРАМ,
а это не одно и то же: между двумя вызовами отрисовки переворота может не
быть, и оба прохода уходили в одну страницу — вторая оставалась с
делениями прошлого боя. Фикс: проход тратится только когда
`gfx_get_draw_page()` отличается от страницы прошлого прохода (первый
проход идёт всегда). Полная чистка всей ширины при инвалидации добавлена
там же — старая полоса могла заходить под статус-текст, где щадящая чистка
её не трогала; текст сразу перезапрашивается, чтобы не пропал.
**Что проверить при взятии в работу.**
1. Значения `POP_STATUS_L`/`POP_STATUS_R` против реальной ширины полос:
при скольких делениях полоса стража (или Кида) заходит под текст.
2. Уходят ли оба прохода `hp_todo` в РАЗНЫЕ страницы на старте уровня —
если между ними нет переворота, обе перерисовки лягут в одну.
3. Возможное решение: на старте уровня чистить полосу во всю ширину
независимо от статус-текста (текст всё равно перерисовывается заново
через `pop_status_invalidate`), либо запоминать максимальную ширину
прошлой полосы и стирать по ней.
+118
View File
@@ -1009,6 +1009,124 @@ tp/10 у факелов таблицей, пустой слот соперник
## P1 — берётся в любой момент
### <a id="time-speed-modes"></a>TIME-SPEED. Часы бегут быстрее в FAST/FASTEST — СДЕЛАНО 2026-08-31, остался вариант 3 (RTC)
**Наблюдение (пользователь, 2026-08-31):** «при режиме FAST/FASTEST время
начинает бежать быстрее — похоже, время мы считаем в наших логических
кадрах, и если они отрисовываются чаще, то и время быстрее».
Так и есть. `pop_timer_tick` (`src/pop_timer.c`) уменьшает счётчик на
КАЖДОМ логическом кадре — ровно как оригинал. Но длина логического кадра
у нас зависит от режима скорости (`src/pop_pace.h`):
| режим | обычный кадр | бой |
|---|---|---|
| NORMAL | 4 кадра луча (81,9 мс) | 5 (102,4 мс) |
| FAST | 3 (61,4 мс) | 4 (81,9 мс) |
| FASTEST | 3 (61,4 мс) | 3 (61,4 мс) |
Разная длина кадра в игре и в бою — ПОВЕДЕНИЕ ОРИГИНАЛА (подтверждено
пользователем), и часы, идущие в бою медленнее, трогать не нужно.
Расхождение только в наших добавочных режимах: NORMAL повторяет оригинал,
а FAST/FASTEST ускоряют всё разом, включая ход часов — минута игрового
времени проходит примерно на треть быстрее реальной.
**Варианты.**
1. Оставить как есть: быстрый режим ускоряет игру целиком, это честно и
предсказуемо. Ноль работы и ноль риска.
2. Развязать часы от темпа: тикать не по логическому кадру, а по
накопленным кадрам ЛУЧА (4 кадра луча = 1 тик). Тогда минута остаётся
минутой в любом режиме, а в бою часы по-прежнему замедляются, как в
оригинале. Цена — счётчик-накопитель в `pop_timer_tick`.
3. **Часы от RTC** (идея пользователя, 2026-08-31). Брать время из
часов реального времени, а не считать кадры вовсе.
**Почему третий вариант интереснее, чем кажется.** Погрешность есть уже
СЕЙЧАС и без всяких режимов: логический кадр NORMAL — 4 кадра луча, а это
81,93 мс, то есть 12,2 кадра в секунду вместо ровных 12. Оригинальная
минута из 720 тиков проходит у нас за 58,99 с — почти на секунду быстрее.
За час игры набегает около минуты. Ни один из первых двух вариантов этого
не лечит: они выравнивают режимы между собой, но обе шкалы остаются
привязанными к лучу, а луч не кратен игровой секунде.
RTC (`ESTEX $21 SYSTIME`, memory `sprinter_systime_dow`) даёт абсолютную
шкалу и снимает накопление полностью. Подводные камни, которые надо
решить при взятии в работу:
- вызов ESTEX стоит дорого и клобберит регистры — читать раз в тик, не в
кадре, и не из горячего пути (memory `estex_bios_abi`);
- разрешение RTC — секунда, а тик игры — 1/12 секунды: нужен гибрид
«кадры внутри секунды, синхронизация по RTC на границе», иначе часы
задёргаются;
- пауза, меню и загрузка НЕ должны съедать игровое время — при часах от
RTC это перестаёт получаться само собой и требует явного вычитания;
- быстрая загрузка/сохранение обязаны сохранять смещение, а не абсолютное
время.
**Решать пользователю.** Порядок по цене: вариант 1 (ничего), вариант 2
(счётчик кадров луча, лечит только разбег режимов), вариант 3 (RTC, лечит
и накопление — но требует разобраться с паузами).
### <a id="snd-speaker-38"></a>SND-SPEAKER-38. Звук мигания надписи — СДЕЛАНО и ПРОВЕРЕНО 2026-08-31
**Постановка (пользователь, 2026-08-31):** «когда надпись Press Button
начинает мигать — в SDLPoP воспроизводится звук на каждое моргание, у нас
тишина». Решение выбрано там же: «проще синтезировать как PCM и добавить
в наш SND атлас».
**Наш код НЕ виноват и правки не требует.** `pop_dead_prompt`
(`src/pop_status.c`) уже зовёт звук 38 на каждом появлении строки — ровно
как оригинал. Пусто в НАБОРЕ: в `assets/packed/SND/snd.idx` слот 38 имеет
длину 0, играть нечего.
**Почему его нет.** У оригинала три параллельных набора звука, и номера
разложены по ним не подряд:
| набор | что | номера | у нас |
|---|---|---|---|
| `DIGISND1..3.DAT` | оцифровка | 0–23, 44–49, 51 | берём, это и есть наш SND |
| `MIDISND1..2.DAT` | мелодии | 2430, 32, 33, 3537, 3941, 43, 50, 5256 | берём отдельно, как музыку в `MUS/` |
| `IBM_SND1..2` | ноты PC-спикера | 0–56 (весь диапазон) | НЕ берём вовсе |
Звук 38 есть ТОЛЬКО в наборе спикера — ни оцифровки, ни мелодии для него
не существует, поэтому он и провалился между двумя нашими конвейерами.
**Полная ревизия недостающего (сделана 2026-08-31).** Номера, которых нет
ни в оцифровке, ни среди мелодий: **31, 34, 38, 42**. Из них 31, 34 и 42 —
пустые заглушки в один байт, нот внутри нет. **Реально звучит ровно один
номер — 38.** То есть задача закрывает единственную дыру в наборе, а не
открывает семейство.
**Формат ресурса** (канон — `docs/PoP/POP-DAT-FormatSpecifications.pdf`,
раздел «Internal PC Speaker»): заголовок 3 байта, из них байт 1 — темп в
долях на две секунды; далее тройки «частота в герцах (2 байта) + длина в
долях (1 байт)», нулевая частота = пауза; в конце маркер `12 00`.
Звук 38 (`IBM_SND1/res10038.bin`, 17 байт) — нисходящий сигнал из четырёх
нот: 2500, 2000, 1500, 1000 Гц по одной доле, темп 72 → около 110 мс.
Для сверки разбора: звук 17 (мягкое приземление) — одна нота 49 Гц на три
доли.
**Что делать.**
1. В `tools/pop_pack_sound.py` — генератор PCM из нот: меандр на нашей
частоте вывода (`RATE`), амплитуда умеренная (сигнал короткий и резкий,
полный размах будет колоть ухо), пауза = уровень тишины `0x80`.
2. Брать из спикера ТОЛЬКО те номера, которых нет ни в оцифровке, ни в
мелодиях — сейчас это ровно 38. Правило важнее списка: если брать всё
подряд, синтез перекроет собой мелодии, которые мы играем из `MUS/`.
3. Дальше всё уже готово: звук ложится в атлас и индекс общим путём,
`snd.idx` пересобирается, EXE не меняется (раскладка читается с диска).
4. Проверка: `make resources` → слот 38 в индексе получил ненулевую длину;
в MAME дождаться мигания надписи после смерти — должен звучать сигнал.
**Цена.** Один короткий звук в наборе (~2 КБ после выравнивания на блок),
кода в игре — ноль.
### <a id="perf-sweep"></a>PERF-SWEEP. Поиск узких мест по ВСЕЙ игре, а не в одной сцене
**Постановка (пользователь, 2026-08-18):** «пока мы тестируем на регресс
+755
View File
@@ -0,0 +1,755 @@
# Аудит расхождений с 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: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_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. Фикс «скольжения сквозь стену» — ПОРТИРОВАН 2026-08-31
> Решением пользователя исправление взято в ТЕКУЩИЙ билд: играбельность
> важнее буквальности. Реализация — `glide_through_wall_guard()` в
> `pop_map.c`, вызывается из `do_fall` в ветке «ещё летим». Цена: +57
> байт в банке 3, резидент и куча не изменились.
>
> Подтверждено host-тестом: в наборе `t_wall` число заходов в кладку
> упало с двух до нуля, остальные 15 наборов остались зелёными.
>
> Ниже — исходный разбор, по которому принималось решение.
### 21a. Исходный разбор: было соответствие ванили — ранг **Г**
*Оригинал:* в `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`: не перезагружается кадр — ранг **Б**, ОТЛОЖЕНА
> **Правка сделана и откачена 2026-08-31.** По букве оригинала находка
> верна, но практического эффекта показать не удалось: все 15 наборов
> host-тестов дали одинаковый результат до и после, включая специально
> написанный тест на заход в кладку (`t_wall`). При этом правка не
> бесплатна — добавляет маппинг окна и перезагрузку кадра на каждое
> выталкивание из стены. Платить за недоказанное не стали.
>
> Задача переехала в `docs/vanilla_vs_bugfixed.md`: вернуться к ней при
> работе над двумя режимами поведения, где появится сценарий, в котором
> кадр меняется перед выталкиванием.
*Оригинал:* `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. **Смерть при активной физике** (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 (ранг А) — смерть на краю. ТОЛЬКО ВМЕСТЕ, НЕ ПООТДЕЛЬНОСТИ
> **Проверено на живой машине 2026-08-31 и провалилось.** Правка 13 была
> сделана в изоляции (прижатие к полу перенесено в ветки пережитого
> удара) — и сломала смерть: страж бьёт Кида, тот погибает, а вместо
> нормальной смерти идут вспышка, стопкадр и немедленный выход в
> заставку.
>
> Причина: у нас прижатие к полу работало КОМПЕНСАЦИЕЙ отсутствующей
> ветки «убит и сброшен с уступа» (находка 12). Убрав компенсацию и не
> добавив то, что она компенсировала, мы оставляем мёртвого персонажа с
> ненулевой `fall_y` и незакреплённой `Char.y` — физика продолжает вести
> его вниз, он проваливается за пределы уровня, и срабатывает аварийный
> путь.
>
> Вывод: обе находки — ОДНА правка. Оценка «чистое перемещение строк,
> риск низкий» была неверной; риск ВЫСОКИЙ, пока ветка 12 отсутствует.
> Порядок внутри правки: сперва добавить ветку `seq_81` (с экспортом
> тайловых запросов), убедиться, что смерть на краю отыгрывается ею, и
> только затем убирать безусловное прижатие.
*Место:* `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 | 24 | одна строка, риск низкий, готовая экспортированная функция |
| 2 | 12+13 ВМЕСТЕ | порознь ломают смерть (проверено); нужен экспорт двух функций |
| 3 | 18, 8 | обе УСКОРЯЮТ или бесплатны; 8 — по дешёвому варианту |
| 4 | 2, 3, 20 | перестановки и копия таблицы; нужны прогоны |
| 5 | 1, 7 | риск средний: обе могут компенсировать наши отличия в другом месте |
**Ни один фикс не требует переделки архитектуры и ни один не ложится на
горячий путь с накладными расходами** — при условии, что находка 20
делается копией таблицы, а находка 8 — битовой маской рядов.
@@ -0,0 +1,275 @@
# Два поведения: VANILLA и BUGFIXED
> Заведено 2026-08-31. Задача поставлена, работа НЕ начата.
## Зачем
У оригинального PoP есть баги, которые игроки знают наизусть и на
которых построены известные трюки. SDLPoP чинит их не молча, а
ОПЦИОНАЛЬНО: каждое исправление отдельным переключателем, по умолчанию
часть включена, часть нет. Мы до сих пор повторяли ваниль — сознательно,
чтобы порт вёл себя как оригинал.
Задача: дать ДВА поведения на выбор, не размазывая условия по всему коду:
* **VANILLA** — как в оригинале 1989 года, со всеми его багами;
* **BUGFIXED** — с портированными исправлениями SDLPoP.
## Что уже известно (из аудита)
Разбор расхождений — `docs/sdlpop_audit.md`. Оттуда прямо в эту задачу
переезжает следующее.
### Опциональные фиксы SDLPoP, которых у нас НЕТ
Все три относятся к прохождению сквозь препятствия и живут в `do_fall`
(seg005) и рядом:
| фикс SDLPoP | что чинит |
|---|---|
| «скольжение сквозь стену» | Кид падает сквозь стены после разворота в беге, особенно под зельем медленного падения |
| «прыжок сквозь стену над воротами» | пролёт в тайл над воротами |
| «проваливание сквозь гобелен» | падение сквозь тайл гобелена |
Ни один не портирован — это и есть наше нынешнее VANILLA.
### Находка 24 — отложена сюда
`in_wall` у нас пересчитывает только колонку, а оригинал перезагружает
ещё и данные кадра (`load_fram_det_col`, seg006). Правка сделана и
ОТКАЧЕНА 2026-08-31 по такой причине:
* по букве оригинала находка верна;
* но практического эффекта показать НЕ УДАЛОСЬ — все 15 наборов
host-тестов дают одинаковый результат до и после, включая специально
написанный тест на заход в кладку;
* при этом правка не бесплатна: добавляет маппинг окна и перезагрузку
кадра на каждое выталкивание из стены.
Платить за недоказанное не стали. Вернуться к ней имеет смысл именно
здесь: при работе над BUGFIXED появится сценарий, где кадр меняется перед
выталкиванием, и тогда эффект станет наблюдаемым.
### Готовый детектор поведения
`tests/host/t_wall.c` расширен с одной проверки до трёх. Ключевая —
`wall_stops_jump_from_left_side`: она НЕ требует нуля заходов в кладку, а
сторожит их ЧИСЛО (сейчас ровно два случая из четырнадцати стартовых
позиций).
Это и есть переключатель ожиданий между режимами:
* больше двух — правка сделала нас хуже ванили, регресс;
* ровно два — ведём себя как оригинал (режим VANILLA);
* меньше двух — кто-то портировал фикс; в режиме BUGFIXED тест должен
ждать нуля.
То есть когда появится BUGFIXED, этому тесту понадобится ожидание,
зависящее от режима, — и он готов стать первым таким.
## Переключатель уже есть — второго не нужно
Уточнено 2026-08-31: в настройках игры ПЕРЕКЛЮЧАТЕЛЬ VANILLA/ENHANCED
СУЩЕСТВУЕТ (`docs/menu_settings_plan.md`), просто сейчас он жёстко
зафиксирован в положении VANILLA. Отдельную сущность заводить не надо —
эта задача про то, чтобы наполнить смыслом уже имеющееся положение
ENHANCED.
**Наш «ванильный» билд уже не чистая ваниль.** Часть ванильных багов у
нас пофикшена по ходу портирования. Значит:
* нельзя считать текущее поведение эталоном ванили — оно смешанное;
* при разделении режимов придётся пройтись по уже сделанным фиксам и
решить по каждому, остаётся он в VANILLA или уезжает в ENHANCED;
* и наоборот: отдельные исправления (например, падение сквозь стену)
вполне могут быть сделаны прямо в нынешнем «ванильном» билде, если
сочтём, что играбельность важнее буквальности.
## Что предстоит решить
1. **Что считать ванилью на практике.** Составить список уже сделанных
отступлений от оригинала и распределить их между режимами.
2. **Цена рантайм-проверки.** Условия попадают в физику и `play_seq`, то
есть в горячий путь. Если переключать в рантайме, проверка должна
быть дешевле самого фикса: флаг в резиденте, а не вызов через банк.
3. **Что считать умолчанием.** Оригинальное поведение честнее для порта,
но часть фиксов SDLPoP включает по умолчанию.
4. **Как тестировать оба режима.** Host-тесты гоняются одним прогоном;
для двух режимов нужен либо параметр сборки тестов, либо ожидания,
зависящие от флага.
## Инвентаризация: что уже решено по каждому фиксу SDLPoP
Составлено 2026-08-31 обходом кода. В движке эти решения УЖЕ приняты и
задокументированы прямо в комментариях — таблица лишь сводит их в одно
место, чтобы при разделении режимов не перечитывать исходники.
| фикс SDLPoP | где у нас | что взято |
|---|---|---|
| `fix_feather_fall_affects_guards` | `pop_map.c:941` | **ФИКС** — перо действует только на Кида |
| `fix_exit_door` | `pop_map.c:1212` | **ФИКС** — ветка фикса вместо ванильного глобала |
| `FIX_GATE_SOUNDS` | `pop_trob.c:579` | **ФИКС** — условия через ИЛИ |
| `fix_sound_priorities` | `pop_sfx.c:196` | **ФИКС** — в SDLPoP включён безусловно, сравниваемся с исправленным |
| `FIX_STAND_ON_THIN_AIR` | `pop_map.c:1499` | **ВАНИЛЬ** — взяты 2 части из 3, третья ждёт патча `seqtbl` |
| `fix_painless_fall_on_guard` | `pop_map.c:1611` | **ВАНИЛЬ** — намеренно |
| `fix_jumping_over_guard` | `pop_map.c:1612` | **ВАНИЛЬ** — намеренно |
| `FIX_RETREAT_WITHOUT_LEAVING_ROOM` | `pop_map.c:3036` | **ВАНИЛЬ** — в SDLPoP выключен по умолчанию; трюк 35 сохраняем |
| `fix_skeleton_chomper_blood` | `pop_map.c:3177` | **ВАНИЛЬ** — кровь скелета в ванили есть |
| потеря HP скелетом при падении с двух этажей | `pop_map.c:676` | **ВАНИЛЬ** — баг оригинала, сохраняем сознательно |
### Фиксы, которым нужна правка байткода
`FIX_STAND_ON_THIN_AIR` не взят НЕ потому, что мы выбрали ваниль, а
потому что его нельзя применить наполовину: он состоит из трёх частей, и
третья — правка самого байткода `seqtbl` (смещения в последовательности
вставания). Взяв только первые две, мы получим вставание, уносящее
весовую точку в стену, то есть ХУЖЕ ванили.
**Это выполнимо, и раньше здесь стояла неверная оценка** (уточнено
2026-08-31): байткод можно менять и у нас. Способов три:
1. **Патч в рантайме** — после загрузки `kid.ani` в EMM-страницу
пропатчить нужные байты прямо там. Речь о единицах байт, страница уже
наша, и патч обратим — то есть режим переключается без пересборки
ресурсов. Это и делает фикс пригодным для VANILLA/ENHANCED.
2. **Патч в упаковщике** — готовить два варианта `kid.ani`. Привязывает
режим к файлам на диске, поэтому хуже: переключатель в меню перестаёт
быть чисто кодовым.
3. **Две копии в одной странице** — и это, пожалуй, лучший вариант
(решено 2026-08-31). `kid.ani` целиком около 4 КБ, а страница EMM —
16 КБ, то есть обе версии байткода спокойно помещаются рядом в уже
выделенной странице. Переключение режима сводится к смене базового
смещения, патчить ничего не нужно, откат мгновенный.
Для сравнения: у SDLPoP рабочая таблица и неизменная копия оригинала
существуют раздельно (`seqtbl` и `original_seqtbl`), причём вторая нужна
для сверки — то есть сама идея «оригинальный байткод отдельно, рабочий
отдельно» там уже заложена.
Общее правило для BUGFIXED: фиксы, требующие правки `seqtbl`, доступны
через рантайм-патч страницы; закладывать это стоит сразу, чтобы не
упереться при первом же таком фиксе.
### Вывод для разделения режимов
Четыре фикса уже взяты, шесть позиций оставлены ванильными. Значит
нынешний билд — это не VANILLA, а «ваниль плюс четыре исправления». При
разделении:
* взятые четыре надо либо оставить в обоих режимах (если считаем их
безусловными улучшениями), либо увести в ENHANCED и вернуть ванильное
поведение в VANILLA — второе честнее, но потребует обратной работы;
* ванильные шесть — кандидаты в ENHANCED; `FIX_STAND_ON_THIN_AIR` тоже,
но ему дополнительно нужен рантайм-патч байткода.
## ВСЕ исправления SDLPoP и их статус у нас
Полный перечень опциональных исправлений оригинала, какие есть в SDLPoP
(43 позиции), со статусом в нашем порте. Названия — идентификаторы
опций SDLPoP, описание — своими словами.
Статусы: **ВЗЯТ** — портирован; **ВАНИЛЬ** — сознательно не берём, держим
поведение оригинала; **НЕТ** — не реализован, кандидат в ENHANCED;
**НЕДОСТУПЕН** — требует правки байткода `seqtbl` (см. ограничение выше);
**В РАБОТЕ** — решено делать сейчас.
### Стены и препятствия
| опция | что чинит | статус |
|---|---|---|
| `fix_glide_through_wall` | проход сквозь стену при падении после разворота в беге | **ВЗЯТ** 2026-08-31 — `glide_through_wall_guard()` в `pop_map.c`, точка отвязки для VANILLA |
| `fix_jump_through_wall_above_gate` | прыжок в тайл над воротами | НЕТ |
| `fix_drop_through_tapestry` | проваливание сквозь гобелен | НЕТ |
| `fix_running_jump_through_tapestry` | прыжок с разбега сквозь гобелен | НЕТ |
| `fix_turn_running_near_wall` | разворот в беге вплотную к стене | НЕТ |
| `fix_wall_bump_triggers_tile_below` | удар о стену срабатывает на тайл ниже | НЕТ |
| `fix_bigpillar_climb` | подъём на большую колонну | НЕТ |
| `fix_land_against_gate_or_tapestry` | приземление вплотную к воротам или гобелену | НЕТ |
| `fix_caped_prince_sliding_through_gate` | проскальзывание сквозь ворота | НЕТ |
### Падение, прыжки, зацепы
| опция | что чинит | статус |
|---|---|---|
| `fix_stand_on_thin_air` | стояние на воздухе после отмены падения | НЕТ — нужен рантайм-патч `seqtbl` (см. выше) |
| `fix_jump_distance_at_edge` | дальность прыжка у самой кромки | НЕТ |
| `fix_edge_distance_check_when_climbing` | проверка расстояния до кромки при подъёме | НЕТ |
| `fix_grab_falling_speed` | зацеп на слишком большой скорости падения | НЕТ |
| `fix_drop_2_rooms_climbing_loose_tile` | провал через две комнаты при подъёме на шаткой плите | НЕТ |
| `fix_infinite_down_bug` | бесконечное падение вниз | НЕТ |
| `fix_falling_through_floor_during_sword_strike` | провал сквозь пол во время удара мечом | НЕТ |
| `fix_safe_landing_on_spikes` | безопасное приземление на невыдвинутые пики | НЕТ |
| `fix_dead_floating_in_air` | мёртвый зависает в воздухе | НЕТ |
### Бой и стражи
| опция | что чинит | статус |
|---|---|---|
| `fix_painless_fall_on_guard` | падение на стража с высоты без урона | **ВАНИЛЬ** |
| `fix_jumping_over_guard` | перепрыгивание через стража | **ВАНИЛЬ** |
| `fix_skeleton_chomper_blood` | кровь скелета в челюстях | **ВАНИЛЬ** |
| `fix_push_guard_into_wall` | вталкивание стража в стену | НЕТ |
| `fix_guard_following_through_closed_gates` | страж идёт сквозь закрытые ворота | НЕТ |
| `fix_doortop_disabling_guard` | верх двери отключает стража | НЕТ |
| `fix_offscreen_guards_disappearing` | стражи пропадают за краем экрана | НЕТ |
| `fix_unintended_sword_strike` | непреднамеренный удар мечом | НЕТ |
| `fix_two_coll_bug` | двойная проверка столкновения | НЕТ |
| `fix_move_after_sheathe` | движение сразу после убирания меча | НЕТ |
### Ворота, двери, плиты
| опция | что чинит | статус |
|---|---|---|
| `fix_exit_door` | дверь выхода с уровня | **ВЗЯТ** |
| `fix_gate_sounds` | звуки ворот | **ВЗЯТ** |
| `fix_gate_drawing_bug` | отрисовка ворот | НЕТ |
| `fix_press_through_closed_gates` | нажатие плиты сквозь закрытые ворота | НЕТ |
| `fix_chompers_not_starting` | челюсти не заводятся | НЕТ |
| `fix_loose_left_of_potion` | шаткая плита слева от зелья | НЕТ |
| `fix_hidden_floors_during_flashing` | скрытые полы во время вспышки | НЕТ |
| `fix_retreat_without_leaving_room` | отступление без смены комнаты (трюк 35) | **ВАНИЛЬ** |
### Зелья, перо, спецэффекты
| опция | что чинит | статус |
|---|---|---|
| `fix_feather_fall_affects_guards` | перо действует и на стражей | **ВЗЯТ** |
| `fix_feather_interrupted_by_leveldoor` | перо прерывается дверью уровня | НЕТ |
| `fix_move_after_drink` | движение сразу после питья | НЕТ |
| `fix_quicksave_during_feather` | быстрое сохранение под пером | НЕТ |
| `fix_hang_on_teleport` | зависание при телепорте | НЕТ (телепортов у нас нет) |
### Интерфейс и ввод
| опция | что чинит | статус |
|---|---|---|
| `fix_one_hp_stops_blinking` | индикатор перестаёт мигать на одном HP | НЕТ |
| `fix_register_quick_input` | учёт быстрого ввода | НЕТ |
### Отдельно: приоритеты звуков
`fix_sound_priorities` в SDLPoP включён БЕЗУСЛОВНО (не опция), и мы
сравниваемся с исправленным вариантом — `pop_sfx.c:196`. Статус:
**ВЗЯТ**, вернуть ванильное поведение отдельным режимом было бы
дополнительной работой.
### Сводка
| статус | сколько |
|---|---:|
| ВЗЯТ | 5 |
| ВАНИЛЬ (сознательно) | 4 |
| требует патча `seqtbl` (выполнимо) | 1 |
| НЕТ (кандидаты в ENHANCED) | 32 |
## Список кандидатов на BUGFIXED
Пополняется по мере аудита. Пока:
* три опциональных фикса SDLPoP выше;
* находка 24 (перезагрузка кадра в `in_wall`);
* находка 21 из аудита — общая рамка «мы намеренно повторяем ваниль».
Не относятся сюда находки, где мы расходимся с оригиналом НЕ в его
пользу (ранги А и Б аудита): их надо чинить в обоих режимах, потому что
это не баги оригинала, а наши.
+41 -7
View File
@@ -999,6 +999,7 @@ uint8_t pop_demo_kid_ai(void) __banked
#define FRAME_154_POKING 154
#define SEQ_69_ATTACK_WAS_PARRIED 69
#define SEQ_74_HIT_BY_SWORD 74
#define SEQ_81_PUSHED_OFF_LEDGE 81 /* заколот у обрыва — падает замертво */
#define SEQ_85_STABBED_TO_DEATH 85
/* refractimer (seg002:36) — «отдышка» стража после того, как его ранили. */
@@ -1008,10 +1009,40 @@ static const uint8_t REFRACTIMER[NUM_GUARD_SKILLS] =
/* hurt_by_sword (seg002): применить попадание к АКТИВНОМУ персонажу.
* Без меча в руке любое попадание смертельно; с мечом минус 1 HP и кадр
* «получил удар».
* УПРОЩЕНИЕ: ветку «сбит с уступа» (seq_81, когда сзади пусто и до кромки
* меньше 4) не портируем ей нужны тайловые запросы ОТ Char, а pop_map
* пока умеет только от Kid. На ровном полу (тайл сзади не пустой) оригинал
* идёт ровно нашей веткой. */
*
* СМЕРТЬ БЕЗОРУЖНОГО БЫВАЕТ ДВУХ ВИДОВ, и выбор между ними делается по
* обстановке ПОЗАДИ (seg002): если сзади есть опора или до кромки меньше
* четырёх «заколот на месте» (seq_85); если сзади пусто и от кромки
* далеко «сброшен с уступа» (seq_81), отдельная последовательность,
* которая сама отыгрывает падение замертво.
*
* Вторая ветка появилась 2026-08-31 (docs/sdlpop_audit.md, находка 12):
* раньше её не было, потому что считалось, будто тайловые запросы от Char
* недоступны на деле они давно работают, не хватало объявлений.
*
* Замечание к сцене: падение первым делом убирает меч (start_fall), так
* что персонаж, сбитый в пропасть во время боя, к следующему удару уже
* безоружен и попадает сюда же. */
/* ПРИЖАТЬ К ПОЛУ СВОЕГО РЯДА — только для ПЕРЕЖИВШЕГО удар.
*
* В оригинале (seg002, ветка seq_74_hit_by_sword) эти две строки стоят
* ВНУТРИ ветки выжившего; смертельные ветки координату не трогают.
*
* У нас они долго выполнялись безусловно и работали СТРАХОВКОЙ за
* отсутствующую ветку «сброшен с уступа»: убитого в воздухе прижимали к
* полу, иначе он продолжал падать и выпадал за нижнюю границу, а игра
* уходила на рестарт, не показав тела (проверено на живой машине
* 2026-08-31 правка в одиночку ломала смерть).
*
* Снято ПОСЛЕ появления ветки seq_81: теперь смерть у обрыва отыгрывает
* своя последовательность, и страховка больше не нужна. Порядок именно
* такой и важен сперва ветка, потом снятие. */
static void hurt_stand_on_floor(void)
{
Char.y = (uint8_t)pop_y_land[Char.curr_row + 1];
Char.fall_y = 0;
}
static void hurt_by_sword(void)
{
if (Char.alive >= 0) return;
@@ -1032,19 +1063,22 @@ static void hurt_by_sword(void)
* Костыля «снять бессмертие на время вызова» здесь БОЛЬШЕ НЕТ:
* pop_take_hp гасит только урон меньше 100, а тут ровно 100. */
pop_take_hp(100);
pop_char_set_seq(SEQ_85_STABBED_TO_DEATH);
if (pop_tile_behind_char() != 0 || pop_dist_to_edge_weight() < 4)
pop_char_set_seq(SEQ_85_STABBED_TO_DEATH); /* есть опора сзади */
else
pop_char_set_seq(SEQ_81_PUSHED_OFF_LEDGE); /* сзади обрыв */
} else if (Char.charid == CHARID_0_KID && pop_immortal) {
/* ЧИТ, уровень 1 и выше: в боевой стойке удары не отнимают HP.
* Кадр «получил удар» оставляем иначе бой перестаёт читаться,
* да и оригинал на выживший удар ставит ровно его. */
pop_char_set_seq(SEQ_74_HIT_BY_SWORD);
hurt_stand_on_floor();
} else if (Char.charid != CHARID_4_SKELETON && pop_take_hp(1)) {
pop_char_set_seq(SEQ_85_STABBED_TO_DEATH); /* HP кончились */
} else {
pop_char_set_seq(SEQ_74_HIT_BY_SWORD);
hurt_stand_on_floor();
}
Char.y = (uint8_t)pop_y_land[Char.curr_row + 1];
Char.fall_y = 0;
/* seg002:0C1F: у Кида свой звук боли, у соперника свой. */
pop_sfx_play((uint8_t)(Char.charid == CHARID_0_KID ? 13 : 12));
play_seq();
+29 -2
View File
@@ -989,11 +989,24 @@ void pop_mirror_draw(int clip_top) __banked
* собирает неверно SUB затирает A, и в s уезжает разность (memory
* sdcc_z80_cmp_store_a_bug). */
static uint8_t hp_todo; /* сколько страниц ещё обновить */
/* Сколько страниц ещё стереть ЦЕЛИКОМ. Обычное обновление щадит зону
* статус-текста (иначе он мигал бы на каждом изменении жизней), но после
* рестарта уровня в этой зоне остаются деления ПРОШЛОГО боя: полоса стража
* при большом запасе HP заходит под текст, и щадящая чистка их не трогает.
* Симптом «после гибели и Ctrl+A на одной из страниц осталась полоса по
* результатам боя» (BUGS_OPEN, HP-BAR-RESTART). */
static uint8_t hp_wipe; /* сколько страниц ещё стереть */
static uint8_t hp_kid_prev, hp_kidmax_prev, hp_gd_prev, hp_gdmax_prev;
/* Страница, в которую лёг прошлый проход. Счётчики выше считают СТРАНИЦЫ,
* но кадр и страница не одно и то же: между двумя вызовами переворота
* может не быть, и тогда оба прохода уходили в ОДНУ страницу, а вторая
* оставалась с делениями прошлого боя. Ровно этим полоса и переживала
* Ctrl+A (BUGS_OPEN, HP-BAR-RESTART). */
static uint8_t hp_page_prev = 0xFF;
/* Заставить перерисовать полосу на обеих страницах: звать при входе в
* комнату (фон перерисован целиком и стёр её) и при старте. */
void pop_hp_invalidate(void) __banked { hp_todo = 2; }
void pop_hp_invalidate(void) __banked { hp_todo = 2; hp_wipe = 2; }
void pop_hp_draw(void) __banked
{
@@ -1010,6 +1023,13 @@ void pop_hp_draw(void) __banked
if (changed) hp_todo = 2;
}
if (hp_todo == 0) return;
{ /* Пока страница та же, что у прошлого прохода, — ждём переворота:
* иначе потратим оба прохода на одну страницу. Первый проход
* (hp_todo == 2) идёт всегда, ему сравнивать не с чем. */
uint8_t pg = gfx_get_draw_page();
if (hp_todo < 2 && pg == hp_page_prev) return;
hp_page_prev = pg;
}
hp_todo--;
/* Стереть прошлую полосу. Фон под ней — НЕ цвет 0, а POP_COL_OUTSIDE
@@ -1021,7 +1041,14 @@ void pop_hp_draw(void) __banked
* текст, его зону не трогаем иначе он мигал бы при каждом изменении
* жизней. Деления Кида левее POP_STATUS_L, стража правее POP_STATUS_R,
* так что чистить края по отдельности достаточно. */
if (pop_status_ticks) {
if (hp_wipe) {
/* Полная чистка обеих страниц: старая полоса могла заходить под
* текст. Текст при этом стирается тоже, поэтому сразу просим
* перерисовать и его иначе строка уровня пропала бы. */
hp_wipe--;
bar(0, HP_Y + POP_YOFF, 319, HP_Y + POP_YOFF + 6);
if (pop_status_ticks) pop_status_invalidate();
} else if (pop_status_ticks) {
bar(0, HP_Y + POP_YOFF, POP_STATUS_L - 1, HP_Y + POP_YOFF + 6);
bar(POP_STATUS_R + 1, HP_Y + POP_YOFF, 319, HP_Y + POP_YOFF + 6);
} else {
+64
View File
@@ -549,6 +549,18 @@ static void determine_col(void)
* control(). См. разбор там. */
void pop_determine_col(void) __banked { determine_col(); }
/* Тайл ПОЗАДИ персонажа и расстояние до кромки — наружу, для боевой
* половины (guards.c): по ним оригинал выбирает, какой смертью умирает
* заколотый на месте или сброшенным с уступа (seg002, hurt_by_sword).
* Обе давно работают от Char, а не от Kid; не хватало только объявлений
* (docs/sdlpop_audit.md, находка 12). */
uint8_t pop_tile_behind_char(void) __banked { return get_tile_behind_char(); }
int8_t pop_dist_to_edge_weight(void) __banked
{
int d = distance_to_edge_weight();
return (int8_t)(d > 127 ? 127 : (d < -128 ? -128 : d));
}
/* расстояние до края тайла (для in_wall). */
static int distance_to_edge(int xpos)
{
@@ -682,6 +694,19 @@ static void land(void)
* control_with_sword и до него не доходит Кид садится в присед
* НАВСЕГДА (BUG-LAND-SWORD-1). */
is_screaming = 0; /* seg005:116 */
/* seg005:0173 — ВСЯ развязка приземления (мягко/средне/разбиться) у
* оригинала заперта за `if (Char.alive < 0)`, то есть выполняется
* только для ЖИВОГО. Мёртвому телу отведена своя дорога (seg005 ветка
* else, loc_5F6C): добить HP, звук падения насмерть и seq_22.
*
* Развилки у нас не было, и труп шёл по живой дороге. Смертельно это
* не выглядело только из-за высоты: тело, сброшенное ударом с ОДНОГО
* ряда, набирает fall_y < 22 урона нет, «последнее HP» не тратится,
* ветка «разбился» не выбирается никогда, и мёртвый Кид приземлялся
* в ПРИСЕД (кадр 109, seq_17). Отсюда симптом «убитого Кида уронили,
* а он сел этажом ниже, и только потом обнаружилось, что он мёртв».
* Цена проверки один тест байта на приземление. */
if (Char.alive >= 0) goto crushed;
if (Char.fall_y < 22) {
soft_land:
if (Char.charid >= CHARID_2_GUARD || Char.sword == SWORD_2_DRAWN) {
@@ -698,6 +723,10 @@ static void land(void)
if (!deadly && Char.charid == CHARID_2_GUARD) deadly = 1; /* seg005:190 */
Char.fall_x = Char.fall_y = 0;
if (deadly || pop_take_hp(1)) { /* 3+ этажа или последнее HP */
crushed:
/* Сюда же приходит УЖЕ мёртвое тело (см. развилку выше); для
* него обнуление ниже первое, живой путь его уже сделал. */
Char.fall_x = Char.fall_y = 0;
pop_take_hp(100);
if (Char.charid == CHARID_0_KID) pop_sfx_play(0); /* разбился */
pop_char_set_seq(SEQ_22_CRUSHED);
@@ -898,6 +927,40 @@ static uint8_t check_grab_run_jump(void)
return 1;
}
/* НЕ ДАТЬ ПРОЛЕТЕТЬ СКВОЗЬ СТЕНУ В ПАДЕНИИ.
*
* Порт опционального исправления SDLPoP (fix_glide_through_wall, seg005 в
* do_fall). В ванили personаж, падающий после разворота в беге, может
* оказаться внутри кладки и лететь «в стене» баг оригинала, известный и
* воспроизводимый; у нас он ловится host-тестом (tests/host/t_wall.c,
* набор wall_stops_jump_from_left_side).
*
* ВЗЯТО В ТЕКУЩИЙ БИЛД по решению 2026-08-31: играбельность важнее
* буквальности. При разделении VANILLA/ENHANCED эта функция готовая
* точка отвязки: достаточно не звать её в ванильном режиме
* (docs/vanilla_vs_bugfixed.md).
*
* Условие оригинала: персонаж внутри тайла стены, либо внутри верха двери
* (в обоих вариантах) при движении ВЛЕВО. Порог 8 и сдвиг на 15 назад
* из исправления; они выбраны так, чтобы вытолкнуть на ту же дистанцию,
* что даёт выталкивание из стены при приземлении. Горизонтальную
* скорость гасим: иначе следующий кадр внесёт персонажа обратно. */
static void glide_through_wall_guard(void)
{
uint8_t t;
int d;
determine_col();
t = get_tile_at_char();
if (t != TILE_WALL &&
!((t == TILE_DOORTOP || t == TILE_DOORTOP_FLOOR) && Char.direction < 0))
return;
d = distance_to_edge_weight();
if (d < 8) return;
Char.x = (uint8_t)char_dx_forward((int8_t)(d - 15));
Char.fall_x = 0;
}
static void do_fall(void)
{
uint8_t nrow = (uint8_t)(Char.curr_row + 1);
@@ -908,6 +971,7 @@ static void do_fall(void)
if (nrow > 4) nrow = 4; /* защита pop_y_land[] от выхода */
if ((uint16_t)pop_y_land[nrow] > (uint16_t)Char.y) {
check_grab(); /* ещё летит — попытка зацепа */
glide_through_wall_guard(); /* и не сквозь кладку (см. выше) */
} else if (Char.curr_row <= 2) {
if (get_tile_at_char() == TILE_WALL)
in_wall();
+5
View File
@@ -67,6 +67,11 @@ void pop_row_tiles(int8_t row, int8_t c0, int8_t c1, uint8_t *out) __banked;
* pop_load_fram_det_col (pop_kid.c), порт load_fram_det_col. */
void pop_determine_col(void) __banked;
/* Тайл позади АКТИВНОГО персонажа и расстояние до кромки его тайла.
* Нужны боевой половине (guards.c) для выбора смерти у обрыва. */
uint8_t pop_tile_behind_char(void) __banked;
int8_t pop_dist_to_edge_weight(void) __banked;
/* HP/смерть. pop_kid_dead=1 когда Kid убит (пики); hitp_curr — текущее HP.
* pop_kid_hp_reset() ставит старт HP и снимает смерть (звать в kid_init/
* респавн). */
+6
View File
@@ -71,6 +71,12 @@ extern uint8_t pop_speed_mode; /* POP_SPEED_*; дефолт — NORMAL */
/* Делитель для текущего режима. fight = «у Кида вынут меч». */
uint8_t pop_pace_n(uint8_t fight);
/* ЭТАЛОН ХОДА ЧАСОВ — делитель РЕЖИМА NORMAL для того же признака боя.
* Игровое время меряется им, а не фактическим темпом: иначе FAST/FASTEST
* ускоряли бы и часы (минута проходила за две трети минуты). При NORMAL
* фактический делитель равен эталону, поэтому его ход не меняется вовсе
* включая замедление в бою, которое есть и в оригинале. */
#define POP_PACE_BASE(fight) ((uint8_t)((fight) ? 5 : 4))
/* Счётчик кадров. volatile: его правит pop_beam_sample, а читают циклы
* ожидания перечитывать обязаны каждый оборот. */
+17 -3
View File
@@ -40,6 +40,7 @@
#include <gfx.h>
#include <kbd_raw.h>
#include "pop_status.h"
#include "pop_music.h" /* ждём конец музыки смерти и глушим её по ответу */
#include "pop_ui.h"
#include "pop_font.h"
#include "pop_bg.h" /* POP_YOFF */
@@ -461,14 +462,25 @@ static void dbg_scan(uint8_t room)
#define DEAD_BLINK 72 /* последние 72 тика строка мигает */
#define SND_BLINK 38 /* sound_38_blink на каждом появлении */
/* Кадр, с которого пошёл отсчёт надписи. НЕ равен DEAD_SETTLE: оригинал
* не показывает «Press Button», пока звучит музыка смерти ветка мёртвого
* (seg006:1351) на седьмом шаге просто выходит, если звук ещё играет, и
* надпись появляется только после него. Поэтому момент старта отсчёта
* заранее не известен и запоминается здесь. */
static uint16_t dead_base;
static uint8_t dead_armed; /* всё отпущено — можно принимать нажатие */
uint8_t pop_dead_prompt(uint16_t frames) __banked
{
uint16_t rem;
if (frames < DEAD_SETTLE) return 0;
if (frames == DEAD_SETTLE) {
if (frames < DEAD_SETTLE) { dead_base = 0; return 0; }
if (dead_base == 0) {
/* Ждём, пока домолчит музыка смерти — порядок оригинала. Пока она
* играет, надписи нет и отсчёт 288 не идёт; прервать ожидание можно
* Ctrl+A или быстрой загрузкой, они музыку глушат. */
if (pop_music_active()) return 0;
dead_base = frames;
msg_set(POP_MSG_PRESS_BUTTON, 0, MSG_HOLD);
pop_show_time = 0; /* иначе поверх ляжет время (seg006:1365) */
dead_armed = 0;
@@ -477,7 +489,7 @@ uint8_t pop_dead_prompt(uint16_t frames) __banked
/* Свой отсчёт, а не pop_status_ticks: тот 8-битный, а здесь нужно 288.
* Кадры смерти считает вызывающий, так что хватает вычитания. */
rem = (uint16_t)(frames - DEAD_SETTLE);
rem = (uint16_t)(frames - dead_base);
rem = (uint16_t)(rem >= DEAD_TICKS ? 0 : DEAD_TICKS - rem);
if (rem == 0) {
/* Игрок промолчал все 24 секунды — оригинал зовёт start_game(),
@@ -519,6 +531,8 @@ uint8_t pop_dead_prompt(uint16_t frames) __banked
if (!any) return 0;
}
dead_armed = 0;
dead_base = 0;
pop_music_stop(); /* игрок ответил — доигрывать не заставляем */
msg_set(POP_MSG_NONE, 0, 0);
return 1;
}
+20 -1
View File
@@ -14,16 +14,35 @@ uint8_t pop_timer_minutes;
uint16_t pop_timer_ticks;
uint8_t pop_show_time;
/* Накопленные кадры луча, ещё не сложившиеся в тик. Живёт между кадрами:
* при FAST логический кадр короче эталона, и остаток переносится вперёд. */
static uint8_t tick_acc;
void pop_timer_new_game(void) __banked
{
pop_timer_minutes = POP_TIMER_START_MINUTES;
pop_timer_ticks = POP_TIMER_START_TICKS;
pop_show_time = 0;
tick_acc = 0;
}
uint8_t pop_timer_tick(uint8_t enabled, uint8_t may_run) __banked
uint8_t pop_timer_tick(uint8_t enabled, uint8_t may_run,
uint8_t spent, uint8_t base) __banked
{
if (!enabled || !may_run || pop_timer_minutes == 0) return 0;
/* ХОД ЧАСОВ ОТВЯЗАН ОТ ТЕМПА. Тик стоит `base` кадров луча — столько,
* сколько их в кадре режима NORMAL. При NORMAL spent == base, и тик
* приходится ровно на кадр, как было всегда; в быстрых режимах кадр
* короче, остаток копится, и за то же РЕАЛЬНОЕ время выходит столько
* же тиков. Цикла не нужно: spent никогда не больше base (быстрые
* режимы кадр только УКОРАЧИВАЮТ) значит не больше тика за вызов. */
/* Пауза, меню и загрузка сюда не заходят вовсе, поэтому за время их
* работы кадры луча накапливаются мимо нас. Ограничиваем вклад одного
* вызова: иначе после меню часы прыгнули бы вперёд на всю паузу. */
if (spent > (uint8_t)(base + base)) spent = base;
tick_acc = (uint8_t)(tick_acc + spent);
if (tick_acc < base) return 0;
tick_acc = (uint8_t)(tick_acc - base);
--pop_timer_ticks;
if (pop_timer_ticks != 0) {
+5 -1
View File
@@ -35,7 +35,11 @@ void pop_timer_new_game(void) __banked;
* may_run правила текущей сцены/уровня. Возвращает 1 ОДИН раз, когда
* отсчёт дошёл до нуля; на паузе, HDD/QuickSave и при выключенном лимите
* вызывать можно состояние останется неизменным. */
uint8_t pop_timer_tick(uint8_t enabled, uint8_t may_run) __banked;
/* spent — сколько кадров ЛУЧА стоил этот логический кадр (pop_pace_n),
* base сколько их было бы при NORMAL (POP_PACE_BASE). Тик отсчитывается
* по base, поэтому режим скорости на ход часов не влияет. */
uint8_t pop_timer_tick(uint8_t enabled, uint8_t may_run,
uint8_t spent, uint8_t base) __banked;
/* Читы, повторяющие +/- SDLPoP: минус не даёт искусственно поставить 0,
* плюс добавляет минуту. Они меняют счётчик и при выключенном лимите
+29 -1
View File
@@ -240,6 +240,21 @@ static uint8_t item_pos, item_bake, flash_on;
* Через функцию с параметрами, а не пятью литералами в месте вызова: на
* gfx_pal_set(0,0,0,0,0) SDCC 4.5 выдаёт невалидный `ld hl, a`
* (см. build/obj/SprPoP.asm) и ассемблер падает. */
/* Сколько кадров ЛУЧА прошло с прошлого игрового кадра. Часы считают
* именно их, а не ожидаемый делитель темпа: логический кадр не всегда
* укладывается в свой бюджет (дорогая сцена, загрузка звука), и часы,
* считавшие «по делителю», шли рывками несколько секунд быстро, потом
* притормаживание (наблюдение пользователя 2026-08-31). Луч же идёт
* ровно, поэтому по нему время течёт равномерно. */
static uint8_t timer_beam_delta(void)
{
static uint8_t prev;
uint8_t now = pop_frame_tick;
uint8_t d = (uint8_t)(now - prev);
prev = now;
return d;
}
static void flash_bg(uint8_t r, uint8_t g, uint8_t b)
{
gfx_pal_set(0, 0, r, g, b);
@@ -391,6 +406,15 @@ int main(void)
if (!is_demo && pop_app_state != POP_APP_PLAYING) break;
demo_new_game = 0;
demo_finished = 0;
/* Счётчик кадров смерти живёт СНАРУЖИ витка, поэтому переживает возврат
* на заставку. Ответ игрока кнопкой его обнуляет, а вот выход по
* таймауту (24 с молчания -> title, pop_dead_prompt вернул 2) уходит
* мимо этого сброса. Без строки ниже счётчик оставался израсходованным
* на всю сессию, и в следующей игре ПЕРВАЯ же смерть мгновенно уводила
* в title, не показав «Press Button to Continue». Сбрасываем на входе
* в игровой маршрут это закрывает и любую другую боковую дорогу
* (выпадение за нижнюю границу, смена уровня), а не только таймаут. */
dead_frames = 0;
while (!quit && !pop_quit_req && (pop_app_state == POP_APP_PLAYING ||
pop_app_state == POP_APP_DEMO)) {
@@ -599,7 +623,11 @@ int main(void)
if (!is_demo && pop_timer_tick(pop_settings.time_limit_enabled,
(uint8_t)(Kid.alive < 0 &&
(pop_current_level < 13 ||
(pop_current_level == 13 && pop_leveldoor_open == 0)))) &&
(pop_current_level == 13 && pop_leveldoor_open == 0))),
/* ХОД ЧАСОВ — по эталону NORMAL, а не по фактическому
* темпу: иначе FAST/FASTEST ускоряли бы и время. */
timer_beam_delta(),
POP_PACE_BASE(Kid.sword == SWORD_2_DRAWN)) &&
pop_current_level < 13) {
/* Время ИДЁТ и на уровне Джафара (до открытия двери), но
* КОНЧИТЬСЯ игра там уже не может: expired() у оригинала стоит
+10 -5
View File
@@ -115,10 +115,10 @@ int8_t pop_frame_ui(uint8_t *restart_level, uint8_t *dead_reset) __banked
mrc = pop_menu_process();
}
if (mrc == POP_MENU_QUICKSAVE) pop_qsave_request_save();
else if (mrc == POP_MENU_QUICKLOAD) pop_qsave_request_load();
else if (mrc == POP_MENU_QUICKLOAD) { pop_music_stop(); pop_qsave_request_load(); }
else if (mrc == POP_MENU_QUICKLOAD_BACKUP)
pop_qsave_request_load_backup();
else if (mrc == POP_MENU_RESTART_LEVEL) *restart_level = 1;
{ pop_music_stop(); pop_qsave_request_load_backup(); }
else if (mrc == POP_MENU_RESTART_LEVEL) { pop_music_stop(); *restart_level = 1; }
else if (mrc == POP_MENU_RESTART_GAME)
return (int8_t)(pop_app_dispatch(POP_APP_EV_RESTART_INTRO) ? 1 : -1);
else if (mrc == POP_MENU_QUIT)
@@ -137,10 +137,15 @@ int8_t pop_frame_ui(uint8_t *restart_level, uint8_t *dead_reset) __banked
if (ctrl && kbd_raw_down(KBD_QUIT))
return (int8_t)(pop_app_dispatch(POP_APP_EV_QUIT) ? 2 : -1);
/* Ctrl+A — рестарт уровня: тот же путь, что и пунктом меню. */
/* БЫСТРЫЙ ВЫХОД ИЗ СМЕРТИ. Надпись «Press Button» у оригинала ждёт,
* пока домолчит музыка смерти (seg006:1351), так же и у нас. Чтобы
* ожидание не было принудительным, три быстрых пути (Ctrl+A, обе
* быстрые загрузки и пункты меню) музыку ГЛУШАТ: оригинал при Ctrl+A
* делает то же самое (seg000:0617, stop_sounds). */
{
uint8_t r = (uint8_t)(ctrl && kbd_raw_down(KBD_RESTART));
if (r && !restart_prev) *restart_level = 1;
if (r && !restart_prev) { pop_music_stop(); *restart_level = 1; }
restart_prev = r;
}
/* Ctrl+R — вернуться в заставку. Тот же переход, что «Restart Game»
@@ -181,7 +186,7 @@ int8_t pop_frame_ui(uint8_t *restart_level, uint8_t *dead_reset) __banked
uint8_t l = kbd_raw_down(KBD_QUICKLOAD);
if (s && !qs_save_prev) pop_qsave_request_save();
if (l && !qs_load_prev) pop_qsave_request_load();
if (l && !qs_load_prev) { pop_music_stop(); pop_qsave_request_load(); }
qs_save_prev = s; qs_load_prev = l;
if (pop_qsave_process() > 0) *dead_reset = 1;
}
+3
View File
@@ -65,6 +65,9 @@ OBJS_mouse := $(OBJS_phys) build/eng_guards.rel
OBJS_shadow := $(OBJS_phys) build/eng_guards.rel
# t_jaffar — спецсобытия уровня 13 (встреча, победа, выход, падающая гряда).
OBJS_jaffar := $(OBJS_phys) build/eng_guards.rel
# t_death — смерть от меча: HP, признак смерти и, главное, координата с
# остаточной скоростью падения (находки 12/13 в docs/sdlpop_audit.md).
OBJS_death := $(OBJS_phys) build/eng_guards.rel
# t_cfg — весь codec POP.CFG из единственного прикладного модуля.
OBJS_cfg := build/eng_pop_config.rel build/bank_stub.rel
# pop_app — чистая таблица переходов, без DSS/графики.
+10
View File
@@ -411,3 +411,13 @@ void pop_vflip_reset(void) { }
int pop_sword_take(const atlas_t *a) __banked { (void)a; return 0; }
int pop_sword_vflip_take(const atlas_t *m) __banked { (void)m; return 0; }
int pop_guard_vflip_load(void) __banked { return 0; }
/* --- заглушки, без которых не линковались t_char/t_gate и прочие ------ *
*
* pop_chdir_home живёт в pop_path_bank.c (банк 10), а его тянет pop_kboot;
* kbd_raw_keypad_as_ext из libc, её тянет pop_ctrl. Ни файловой
* системы, ни клавиатуры в хостовых тестах нет, поэтому обе пустышки.
* До этого сборка tests/host падала на неразрешённых символах, и логику
* движка нельзя было проверить без эмулятора. */
int8_t pop_chdir_home(void) __banked { return 0; }
void kbd_raw_keypad_as_ext(uint8_t on) { (void)on; }
+225
View File
@@ -0,0 +1,225 @@
/*
* t_death смерть от меча: что происходит с координатой и состоянием.
*
* Набор заведён ПЕРЕД правкой находок 12/13 (docs/sdlpop_audit.md), чтобы
* зафиксировать нынешнее поведение и поймать деградацию. История вопроса:
* правка 13 в изоляции уже ломала смерть мёртвый Кид оставался с
* ненулевой скоростью падения, проваливался за нижнюю границу и игра
* уходила на рестарт, не показав тела.
*
* Проверяем ровно то, на что эти правки влияют:
* - HP и признак смерти;
* - КООРДИНАТУ по Y и остаточную скорость падения (в этом вся суть);
* - что смерть наступает при ударе не в боевой стойке (порт оригинала:
* «ранение вне боевой стойки означает смерть»).
*/
#include "tcheck.h"
#include "scene.h"
#include "stubs.h"
#include "pop_kid.h"
#include "pop_map.h"
#include "pop_guard.h"
#define E 0
#define F 1
#define W 20
/* Ровный пол во всю ширину: ряд 2 — пол, выше пусто. Комната без краёв,
* чтобы смерть не смешивалась с падением. */
static const uint8_t room_flat[30] = {
E, E, E, E, E, E, E, E, E, E,
E, E, E, E, E, E, E, E, E, E,
F, F, F, F, F, F, F, F, F, F,
};
/* Площадка, обрывающаяся справа: колонки 0-4 — пол, 5-9 пусто. Кид у
* самого края спиной к обрыву целевая сцена находки 12 (в оригинале
* такой удар отправляет в отдельную последовательность падения замертво). */
static const uint8_t room_ledge[30] = {
E, E, E, E, E, E, E, E, E, E,
E, E, E, E, E, E, E, E, E, E,
F, F, F, F, F, E, E, E, E, E,
};
/* Поставить сцену «страж бьёт Кида» и нанести удар. */
static void strike_kid(const uint8_t *room, uint8_t col, uint8_t hp, uint8_t sword)
{
sc_room(room, 1);
sc_kid_at(col, 2, 1 /* лицом вправо */);
hitp_curr = hp;
Kid.sword = sword;
pop_kid_dead = 0;
/* Признак «жив» обвязка ставит только стражу, поэтому Киду задаём его
* сами: обработчик удара первым делом отсекает уже мёртвого. */
Kid.alive = -1;
/* Удар оформляется действием 99 «ранен» — так его помечает боевая
* проверка оригинала, а разбирает pop_check_sword_hurt. */
Kid.action = 99;
pop_check_sword_hurt();
/* Удар выставляет ДЕЛЬТУ, а HP меняет отдельный шаг кадра — как в
* оригинале. Без него hitp_curr остался бы прежним, и тест мерил бы
* не то. */
pop_do_delta_hp();
}
TC_TEST(death_unarmed_hit_is_lethal)
{
strike_kid(room_flat, 4, 3 /* HP */, 0 /* без меча */);
/* Удар не в боевой стойке смертелен независимо от запаса HP. */
TC_EQ(hitp_curr, 0);
}
TC_TEST(death_armed_hit_costs_one_hp)
{
strike_kid(room_flat, 4, 3, 2 /* меч вынут */);
TC_EQ(hitp_curr, 2);
}
TC_TEST(death_armed_last_hp_kills)
{
strike_kid(room_flat, 4, 1, 2);
TC_EQ(hitp_curr, 0);
}
/* --- КООРДИНАТА ПОСЛЕ УДАРА: суть находок 12 и 13 -------------------- */
TC_TEST(survivor_is_placed_on_floor)
{
/* Пережил удар — оригинал ставит его на пол своего ряда и гасит
* скорость падения. Это единственная ветка, где он так делает. */
strike_kid(room_flat, 4, 3, 2);
TC_EQ(Kid.fall_y, 0);
TC_EQ(Kid.y, pop_y_land[Kid.curr_row + 1]);
}
TC_TEST(death_on_flat_floor_keeps_body_in_place)
{
/* Смерть на ровном полу: тело обязано остаться в своём ряду. Если
* координата или скорость падения уедут, физика утащит труп вниз
* ровно так ломалась игра при правке 13 в одиночку. */
strike_kid(room_flat, 4, 1, 2);
TC_EQ(Kid.curr_row, 2);
TC_EQ(Kid.fall_y, 0);
}
TC_TEST(death_at_ledge_keeps_body_in_place)
{
/* Смерть у самого обрыва — целевая сцена находки 12. Сейчас мы
* ставим «заколот на месте» и удерживаем тело; в оригинале здесь
* своя последовательность падения замертво. Тест фиксирует НЫНЕШНЕЕ
* поведение: после правки 12+13 ожидание изменится осознанно. */
strike_kid(room_ledge, 4, 1, 2);
TC_EQ(Kid.curr_row, 2);
TC_EQ(Kid.fall_y, 0);
}
/* --- ДВЕ СМЕРТИ БЕЗОРУЖНОГО: на месте и сброшенным с уступа ---------- *
*
* Оригинал выбирает между ними по обстановке ПОЗАДИ: есть опора или до
* кромки меньше четырёх «заколот на месте»; сзади обрыв отдельная
* последовательность падения замертво (docs/sdlpop_audit.md, находка 12).
*
* Проверяем сам ВЫБОР: последовательности должны различаться. Сравнение
* с конкретным номером было бы хрупким смещения приходят из таблицы,
* извлечённой из данных оригинала. */
static uint16_t seq_after_unarmed_death(const uint8_t *room, uint8_t col, int8_t dir)
{
sc_room(room, 1);
sc_kid_at(col, 2, dir);
hitp_curr = 3;
Kid.sword = 0; /* безоружен: падение уже спрятало меч */
Kid.alive = -1;
pop_kid_dead = 0;
Kid.action = 99;
pop_check_sword_hurt();
pop_do_delta_hp();
return Kid.curr_seq;
}
TC_TEST(unarmed_death_with_floor_behind_differs_from_ledge)
{
uint16_t on_floor, at_ledge;
/* Лицом ВПРАВО в середине сплошного пола: сзади (слева) опора. */
on_floor = seq_after_unarmed_death(room_flat, 4, 1);
/* Лицом ВЛЕВО у кромки: сзади (справа) обрыв — колонки 5..9 пусты. */
at_ledge = seq_after_unarmed_death(room_ledge, 4, -1);
TC_TRUE(on_floor != at_ledge);
}
TC_TEST(unarmed_death_at_ledge_is_lethal_too)
{
(void)seq_after_unarmed_death(room_ledge, 4, -1);
TC_EQ(hitp_curr, 0);
}
/* --- ПРИЗЕМЛЕНИЕ МЁРТВОГО ТЕЛА ---------------------------------------
*
* Оригинал спрашивает у приземляющегося, жив ли он (seg005:0173): вся
* развязка «мягко / средне / разбиться» отведена ЖИВОМУ, а телу своя
* ветка (добить HP, звук падения насмерть, seq_22).
*
* У нас развилки не было, и это не бросалось в глаза только из-за высоты:
* тело, сброшенное ударом с ОДНОГО ряда, набирает fall_y < 22 урона нет,
* «последнее HP» не тратится, ветка «разбился» не выбирается никогда, и
* труп приземлялся в ПРИСЕД (кадр 109). Отсюда симптом «убитого Кида
* уронили, а он сел этажом ниже».
*
* Пара тестов держит обе стороны развилки: мёртвый обязан разбиться,
* живой на той же высоте сесть, как и раньше. */
#define FRAME_109_CROUCH 109
/* Уронить персонажа с ряда 1 на пол ряда 2 и дать физике доиграть. */
static void drop_from_row1(const uint8_t *room, uint8_t col, uint8_t alive, uint8_t hp)
{
uint8_t i;
sc_room(room, 1);
sc_kid_at(col, 1, 1 /* лицом вправо */);
hitp_curr = hp;
Kid.alive = (int8_t)alive; /* -1 жив, 0 мёртв (соглашение оригинала) */
Kid.sword = 0;
pop_kid_dead = 0;
Kid.action = 4; /* свободное падение */
for (i = 0; i < 40; i++) pop_phys_tick();
}
TC_TEST(dead_body_falls_crushed_not_crouched)
{
/* Мёртвое тело падает на ОДИН ряд: высоты не хватает ни на урон, ни на
* «последнее HP», поэтому до фикса оно уходило в мягкое приземление. */
drop_from_row1(room_flat, 4, 0 /* мёртв */, 0);
TC_EQ(Kid.curr_row, 2); /* долетело до пола */
TC_EQ(Kid.fall_y, 0); /* скорость погашена */
TC_FALSE(Kid.frame == FRAME_109_CROUCH); /* НЕ присед */
TC_EQ(pop_kid_dead, 1); /* пошли дорогой «разбился» */
}
TC_TEST(living_soft_land_from_row1_still_crouches)
{
/* Обратная сторона развилки: живой с той же высоты обязан сесть, как и
* до фикса. Если этот тест покраснеет развилка съела живую ветку. */
drop_from_row1(room_flat, 4, (uint8_t)-1 /* жив */, 3);
TC_EQ(Kid.curr_row, 2);
/* Кадр не проверяем: присед — это ПОСЛЕДОВАТЕЛЬНОСТЬ, и к сороковому
* тику она уже ушла с начального 109 (первый прогон теста поймал это
* на кадре 107). Держим то, что действительно различает ветки. */
TC_EQ(pop_kid_dead, 0); /* живого не разбило */
TC_EQ(hitp_curr, 3); /* один ряд — без урона */
}
int main(void)
{
sc_init();
TC_RUN(death_unarmed_hit_is_lethal);
TC_RUN(death_armed_hit_costs_one_hp);
TC_RUN(death_armed_last_hp_kills);
TC_RUN(survivor_is_placed_on_floor);
TC_RUN(death_on_flat_floor_keeps_body_in_place);
TC_RUN(death_at_ledge_keeps_body_in_place);
TC_RUN(unarmed_death_with_floor_behind_differs_from_ledge);
TC_RUN(unarmed_death_at_ledge_is_lethal_too);
TC_RUN(dead_body_falls_crushed_not_crouched);
TC_RUN(living_soft_land_from_row1_still_crouches);
return 0;
}
+60 -7
View File
@@ -35,17 +35,17 @@ TC_TEST(timer_starts_and_only_runs_when_allowed)
pop_timer_new_game();
TC_EQ(pop_timer_minutes, POP_TIMER_START_MINUTES);
TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS);
TC_FALSE(pop_timer_tick(0, 1)); /* Settings: unlimited */
TC_FALSE(pop_timer_tick(0, 1, 4, 4)); /* Settings: unlimited */
TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS);
TC_FALSE(pop_timer_tick(1, 0)); /* pause/HDD/cutscene */
TC_FALSE(pop_timer_tick(1, 0, 4, 4)); /* pause/HDD/cutscene */
TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS);
pop_timer_minutes = 2;
pop_timer_ticks = 2;
TC_FALSE(pop_timer_tick(1, 1));
TC_FALSE(pop_timer_tick(1, 1, 4, 4));
TC_EQ(pop_timer_minutes, 2);
TC_EQ(pop_timer_ticks, 1);
TC_FALSE(pop_timer_tick(1, 1));
TC_FALSE(pop_timer_tick(1, 1, 4, 4));
TC_EQ(pop_timer_minutes, 1);
TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS);
}
@@ -54,11 +54,11 @@ TC_TEST(timer_expiry_is_one_shot)
{
pop_timer_minutes = 1;
pop_timer_ticks = 1;
TC_TRUE(pop_timer_tick(1, 1));
TC_TRUE(pop_timer_tick(1, 1, 4, 4));
TC_EQ(pop_timer_minutes, 0);
TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS);
TC_FALSE(pop_timer_tick(1, 1));
TC_FALSE(pop_timer_tick(0, 1));
TC_FALSE(pop_timer_tick(1, 1, 4, 4));
TC_FALSE(pop_timer_tick(0, 1, 4, 4));
}
TC_TEST(timer_cheats_match_sdlpop_rules)
@@ -107,10 +107,63 @@ TC_TEST(timer_qsave_restores_exact_state_and_rejects_bad_tick)
TC_EQ(pop_timer_ticks, 444);
}
/* --- ХОД ЧАСОВ НЕ ЗАВИСИТ ОТ РЕЖИМА СКОРОСТИ ------------------------- *
*
* Тик стоит `base` кадров луча столько, сколько их в кадре режима
* NORMAL. Быстрые режимы только УКОРАЧИВАЮТ кадр (spent < base), поэтому
* за одно и то же реальное время выходит одинаковое число тиков.
* Наблюдение пользователя, из которого выросла правка: «при FAST/FASTEST
* время начинает бежать быстрее». */
/* Сколько тиков насчитается за `frames` логических кадров темпа spent/base. */
static uint16_t ticks_over(uint8_t frames, uint8_t spent, uint8_t base)
{
uint16_t before;
uint8_t i;
pop_timer_new_game();
before = pop_timer_ticks;
for (i = 0; i < frames; i++) pop_timer_tick(1, 1, spent, base);
return (uint16_t)(before - pop_timer_ticks);
}
TC_TEST(timer_normal_ticks_every_frame)
{
/* NORMAL вне боя: кадр равен эталону — тик на каждом кадре, как было до
* правки. Страховка, что NORMAL не изменился ни на тик. */
TC_EQ(ticks_over(12, 4, 4), 12);
}
TC_TEST(timer_normal_fight_ticks_every_frame)
{
/* NORMAL в бою: кадр длиннее (5), но и эталон 5 — часы по-прежнему идут
* по кадру. В РЕАЛЬНОМ времени это медленнее, и ровно так же ведёт себя
* оригинал, поэтому трогать нечего. */
TC_EQ(ticks_over(12, 5, 5), 12);
}
TC_TEST(timer_fast_keeps_real_time)
{
/* FAST вне боя: кадр 3 при эталоне 4. За 12 кадров (36 кадров луча) —
* 9 тиков, ровно столько же, сколько NORMAL насчитает за те же 36 кадров
* луча. До правки было бы 12: часы бежали на треть быстрее. */
TC_EQ(ticks_over(12, 3, 4), 9);
}
TC_TEST(timer_fastest_fight_keeps_real_time)
{
/* FASTEST в бою — самый короткий кадр против самого длинного эталона: 3 против
* 5. За 20 кадров ждём 12 тиков. */
TC_EQ(ticks_over(20, 3, 5), 12);
}
void main(void)
{
TC_RUN(timer_starts_and_only_runs_when_allowed);
TC_RUN(timer_expiry_is_one_shot);
TC_RUN(timer_cheats_match_sdlpop_rules);
TC_RUN(timer_qsave_restores_exact_state_and_rejects_bad_tick);
TC_RUN(timer_normal_ticks_every_frame);
TC_RUN(timer_normal_fight_ticks_every_frame);
TC_RUN(timer_fast_keeps_real_time);
TC_RUN(timer_fastest_fight_keeps_real_time);
}
+111
View File
@@ -87,9 +87,120 @@ TC_TEST(wall_stops_undershot_jump)
TC_EQ(bad, 0);
}
/* --- ГДЕ КОНЧАЕТСЯ СТЕНА ПО X ---------------------------------------- *
*
* Колонка 5 кладка (ряды 1-2). Тайл шириной 14, колонка c занимает
* [58 + 14*c, 58 + 14*(c+1)). Для колонки 5 это [128, 142). Персонаж
* НИКОГДА не должен оказаться внутри этого отрезка ниже верхнего ряда:
* там сплошная кладка, и «падение частично в стене» как раз оно.
*
* Проверка отдельная от той, что выше: та смотрит КОЛОНКУ в конце сцены,
* а эта X на КАЖДОМ кадре падения. Персонаж может кончить падение в
* законной колонке, успев по дороге пройти сквозь кладку. */
#define WALL_X_LO 128
#define WALL_X_HI 142
/* Сколько кадров трассы имеют X внутри кладки, будучи ниже верхнего ряда. */
static uint8_t frames_inside_wall(uint8_t x)
{
uint8_t i, n = 0;
sc_room(room14, 14);
sc_kid_at_x(8, 0, x, -1);
sc_trace_clear();
sc_run(SC_L | SC_U, 6);
sc_run(0, 34);
for (i = 0; i < sc_len; i++) {
if (sc_trace[i].row == 0) continue; /* верхний ряд — не кладка */
if (sc_trace[i].x >= WALL_X_LO && sc_trace[i].x < WALL_X_HI) n++;
}
return n;
}
TC_TEST(wall_x_never_inside_masonry)
{
uint8_t x, bad = 0;
puts_("inside x=177..196: ");
for (x = X_LO; x <= X_HI; x++) {
uint8_t n = frames_inside_wall(x);
put((char)(n ? ('0' + (n > 9 ? 9 : n)) : '.'));
if (n) bad++;
}
put('\n');
TC_EQ(bad, 0);
}
/* Симметричный случай: прыжок ВПРАВО через провал колонок 3-4 к площадке
* (0,5). Стартовая площадка колонки 0-2 верхнего ряда; под провалом
* дно (ряд 2), по бокам кладка. Недолёт обязан кончиться на дне, а не
* внутри кладки: выталкивание из стены работает в обе стороны. */
static char jump_right_from(uint8_t x)
{
sc_room(room14, 14);
sc_kid_at_x(2, 0, x, 1 /* лицом вправо */);
sc_trace_clear();
sc_run(SC_R | SC_U, 6);
sc_run(0, 34);
if (!sc_len) return '?';
{
int8_t col = sc_trace[sc_len - 1].col;
int8_t row = sc_trace[sc_len - 1].row;
if (row == 0) return 'R'; /* остался на верхнем ряду */
/* Ниже верхнего ряда законны ОБА провала — слева от кладки
* (колонки 3-4, недолёт) и справа (6-7, перелёт через площадку).
* Незаконна только сама кладка колонки 5. */
if (col != 5) return '.';
/* Вис на уступе площадки (0,5) — законное состояние, и колонка
* при нём как раз 5. Отличаем по действию персонажа. */
{
uint8_t act = sc_trace[sc_len - 1].action;
if (act == 2 || act == 6) return 'h'; /* hang_climb / hang_straight */
}
/* Осталось одно: персонаж НИЖЕ верхнего ряда, в колонке кладки и
* не висит то есть находится ВНУТРИ стены. Именно так выглядит
* «падение частично в стене» (docs/sdlpop_audit.md, находка 24). */
return '5';
}
}
/* ФИКС ПАДЕНИЯ СКВОЗЬ СТЕНУ ВЗЯТ — ЖДЁМ НОЛЬ.
*
* В ванили прыжок вправо через провал давал ДВА случая из четырнадцати,
* где Кид оказывается в колонке кладки, будучи в воздухе, «падение
* частично в стене». Это баг оригинального PoP, для которого SDLPoP
* держит опциональное исправление (fix_glide_through_wall, seg005 в
* do_fall).
*
* 2026-08-31 исправление ПОРТИРОВАНО в текущий билд
* (glide_through_wall_guard в pop_map.c), и оба случая исчезли. Поэтому
* ожидание теперь НОЛЬ.
*
* Если когда-нибудь появится режим VANILLA без этого фикса, ожидание
* станет зависеть от режима: 0 в ENHANCED и 2 в ванильном
* (docs/vanilla_vs_bugfixed.md). Число 2 сохранено в имени константы
* именно поэтому оно измерено, а не выдумано. */
#define WALL_VANILLA_GLIDE_CASES 2 /* сколько их было ДО фикса */
#define WALL_EXPECTED_GLIDE_CASES 0 /* сколько ожидаем СЕЙЧАС */
TC_TEST(wall_stops_jump_from_left_side)
{
uint8_t x, bad = 0;
/* Плита колонок 3-4: [100, 128). */
/* Плита колонок 0-2: колонка 2 — это [86, 100). */
puts_("right x=86..99: ");
for (x = 86; x <= 99; x++) {
char r = jump_right_from(x);
put(r);
if (r != 'R' && r != '.' && r != 'h') bad++;
}
put('\n');
TC_EQ(bad, WALL_EXPECTED_GLIDE_CASES);
}
int main(void)
{
sc_init();
TC_RUN(wall_stops_undershot_jump);
TC_RUN(wall_x_never_inside_masonry);
TC_RUN(wall_stops_jump_from_left_side);
return 0;
}
+29
View File
@@ -66,6 +66,35 @@ SND_SOURCES = {
}
# Ноты PC-СПИКЕРА и МЕЛОДИИ — два других набора того же движка. Нужны нам
# по разным причинам: мелодии мы играем отдельно (MUS/), а из нот спикера
# добираем те номера, которых нет НИ в оцифровке, НИ среди мелодий — сейчас
# это ровно звук 38 (мигание надписи «Press Button»). В поставке SDLPoP
# наборы лежат распакованными каталогами res1XXXX.bin, в дистрибутиве DOS —
# контейнерами .DAT; поддерживаем оба вида.
SND_MIDI = {
"sdlpop": (SDLPOP_DATA, "MIDISND%d.DAT", 2),
"msdos": (MSDOS, "midisnd%d.dat", 2),
}
SND_SPEAKER = {
"sdlpop": (SDLPOP_DATA, "IBM_SND%d", 2),
"msdos": (MSDOS, "ibm_snd%d.dat", 2),
}
def snd_extra(kind, name="sdlpop"):
"""(каталог, шаблон, сколько контейнеров) для набора нот или мелодий.
kind "midi" или "speaker". Набора может не быть вовсе (у пользователя
нет MSDOS, поставка неполная) это НЕ ошибка: вызывающий просто
пропускает добор. Возвращает None, если источник неизвестен.
"""
table = {"midi": SND_MIDI, "speaker": SND_SPEAKER}.get(kind)
if table is None:
raise SystemExit("неизвестный набор %r; есть: midi, speaker" % kind)
return table.get(name)
def snd_src(name="sdlpop"):
"""(каталог, шаблон имени) контейнеров оцифровки для источника."""
if name not in SND_SOURCES:
@@ -108,6 +108,69 @@ def resample(src, src_rate, dst_rate):
return bytes(out)
def load_bundle(dat_dir, pattern, count):
"""id -> тело ресурса из набора любого вида.
Наборы приезжают в двух видах: контейнером `.DAT` (дистрибутив DOS) и
распакованным каталогом `res1XXXX.bin` (поставка SDLPoP). Тело в обоих
случаях начинается с байта типа, так что дальше разбор общий.
"""
out = {}
for i in range(1, count + 1):
p = dat_dir / (pattern % i)
if p.is_dir():
for f in sorted(p.glob("res1*.bin")):
try:
rid = int(f.stem[3:]) - 10000
except ValueError:
continue
out[rid] = f.read_bytes()
elif p.is_file():
out.update(dat_resources(p))
return out
def speaker(body):
"""(темп, [(частота Гц, длина в долях), ...]) или None — если это не ноты.
Формат канон Princed, «Internal PC Speaker»: заголовок 3 байта, из них
байт 1 доли на ДВЕ секунды; дальше тройки «частота (2 байта) + длина
(1 байт)»; нулевая частота = пауза, 1 и 2 маркеры; в конце маркер
`12 00`, который сам отсекается недобором до целой тройки.
"""
if not body or (body[0] & 7) != 0 or len(body) < 6:
return None
bps = body[1] or 1
notes = []
i = 3
while i + 3 <= len(body):
notes.append((struct.unpack("<H", body[i:i + 2])[0], body[i + 2]))
i += 3
return bps, notes
def speaker_pcm(bps, notes, rate, amp=22):
"""Меандр по нотам -> те же 8 бит без знака, что и оцифровка.
Амплитуда НАМЕРЕННО маленькая: сигналы спикера короткие и резкие, и на
полном размахе они колют ухо рядом с оцифровкой, которая заметно тише.
Значение снижено вдвое по слуховой проверке в MAME (решение пользователя
2026-08-31): на 44 сигнал мигания перекрикивал игру.
"""
out = bytearray()
for freq, beats in notes:
n = int(round(beats * (2.0 / bps) * rate))
if n <= 0:
continue
if freq <= 2: # 0 — пауза, 1 и 2 — маркеры
out += bytes([0x80]) * n
continue
period = rate / freq
for i in range(n):
out.append(0x80 + amp if (i % period) < period / 2 else 0x80 - amp)
return bytes(out)
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
ap.add_argument("--source", default="sdlpop", choices=sorted(P.SND_SOURCES),
@@ -151,6 +214,33 @@ def main():
if rate != 11000:
print(f" звук {sid}: {rate} Гц -> {RATE:g} Гц, {len(samples)} -> {len(pcm)} сэмплов")
# ДОБОР ИЗ НОТ PC-СПИКЕРА (docs/TASKS_OPEN.md, SND-SPEAKER-38).
# У оригинала три параллельных набора звука, и номера разложены по ним
# не подряд: оцифровка, мелодии и ноты спикера. Берём из нот ТОЛЬКО те
# номера, которых нет ни там, ни там — иначе синтез перекрыл бы собой
# мелодии, которые мы играем отдельно, из MUS/. На поставке SDLPoP это
# ровно один номер: 38, сигнал под мигание надписи «Press Button»
# (31, 34 и 42 в наборе есть, но они пустые заглушки без нот).
midi_cfg = P.snd_extra("midi", args.source)
spk_cfg = P.snd_extra("speaker", args.source)
midi_ids = set(load_bundle(*midi_cfg)) if midi_cfg else set()
added = []
for sid, body in sorted((load_bundle(*spk_cfg) if spk_cfg else {}).items()):
if sid in sounds or sid in midi_ids:
continue
sp = speaker(body)
if sp is None or not sp[1]:
continue
pcm = speaker_pcm(sp[0], sp[1], RATE)
if not pcm:
continue
sounds[sid] = pcm
added.append("%d (%d нот, %d Б)" % (sid, len(sp[1]), len(pcm)))
if added:
print(" из нот спикера: " + ", ".join(added))
elif spk_cfg:
print(" из нот спикера: добирать нечего")
# Раскладка: начало каждого звука кратно BLOCK, страницы по PAGE.
# ПЕРВЫЙ БЛОК — ТИШИНА. Он нужен насосу: когда эффект кончился, в CBL
# всё равно надо что-то отдавать, иначе железо доигрывает по кругу хвост