Compare commits

...

49 Commits

Author SHA1 Message Date
snark13 5323b168a7 docs: описать разделение тулкита, MAME и приложений 2026-09-15 17:59:13 +03:00
snark13 e4695b8281 Sprinter: добавить отладку C-исходников и интеграцию VS Code 2026-09-15 17:58:41 +03:00
snark13 50c6e56b7b Volkov: адаптировать тайминги MAME 0.287 и таймаут на macOS 2026-09-15 17:57:20 +03:00
snark13 8e389c03f8 Volkov: добавить Sprinter Commander
Реализовать двухпанельный Commander от платформенного PoC до этапов P6-P20: EMM-каталог, сортировку и выбор, операции с файлами и деревьями, транзакционное копирование, метаданные, политику конфликтов и предварительную проверку свободного места.

Добавить проектную документацию, HDD/MAME-сценарии и проверенные артефакты. Расширить libc операцией bank_write_page, исправлением режима O_RDONLY и связанными регрессионными проверками.
2026-09-10 10:45:30 +03:00
snark13 05bcd8197e check_bank_calls: ловить адрес ПОЛЯ структуры в аргументах банкового вызова
Проверка 2 искала форму «адрес метки своего банка положен в аргументы вызова в
чужой банк», но регулярка принимала только `ld hl,#_метка` и `#_метка+6`. Адрес
поля структуры SDCC пишет в скобках:

	ld	bc, #(_mstream + 6)
	push	bc
	...
	call	___sdcc_bcall_ehl

Из-за этого целый класс ошибок собирался «чисто». Поймано вживую 2026-09-03 в
порту Loom: mask.c (банк 5, --bank-data 5) отдавал res_copy (банк 11) адрес поля
своей структуры — поток маски читался в чужую страницу, маска выходила «всё
заполнено», тайлов в комнате стало 30 вместо 17. Фон при этом оставался
побайтово верным, потому что его путь идёт из резидентного кода, так что
попиксельная сверка экрана молчала.

Класс проверки не меняется: адрес метки ДАННЫХ своего банка, положенный в стек
как аргумент вызова в ЧУЖОЙ банк, неверен всегда — это по-прежнему
доказательный случай, а не эвристика.

Проверено в трёх режимах: с ошибкой срабатывает, на исправленном коде молчит, и
на всех банковых сборках самого тулчейна (banktest, w3probe, banked, bankedbg,
banklocl, w3bankgfx, roomtest, сборка SprPoP) ложных срабатываний нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qxu9rwsmyDyYPxG198nzSR
2026-09-03 11:04:27 +03:00
snark13 25abf8ea36 SprPoP: в CHANGELOG к версии и дате добавлен коммит тега
Заголовок записи стал `<имя тега> — <дата> — <коммит>`.

Зачем коммит, если есть имя тега: тег можно передвинуть или
переименовать, а ревизия, на которой релиз собран, должна оставаться в
записи однозначно — по ней билд воспроизводится точно.  Заодно это тот же
хеш, что зашит в EXE как BUILD_ID и виден на экране About, так что
принесённый пользователем скриншот сразу сопоставляется с записью
changelog.

Берётся именно `*objectname` (дереференс аннотированного тега), а не
`objectname`: последний вернул бы хеш самого объекта тега, а не коммита.
Команда целиком выписана и в шапке CHANGELOG, и в правиле в CLAUDE.md,
чтобы не восстанавливать её каждый раз.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 18:33:05 +03:00
snark13 f206a4cca6 SprPoP: завести CHANGELOG, версия и дата — из git-тега
До сих пор «что изменилось» восстанавливалось только из git log, а он
устроен по ПРИЧИНАМ правок, а не по тому, что заметит игрок.  Заводим
отдельный файл — как это уже сделано у mdview2 (changelog в репозитории
ведётся на приложение, а корневой RELEASE_NOTES.md описывает тулчейн и
живёт своей жизнью).

Начат с v0.9.6: зацеп, зацикленный взмах клинка, кнопка в паласе.  Более
ранняя история остаётся в git log и досках — переписывать её задним
числом смысла нет.

ВЕРСИЯ И ДАТА БЕРУТСЯ ИЗ GIT, а не проставляются руками: заголовок записи
= имя аннотированного тега, дата = дата его создания
(`git for-each-ref refs/tags/<тег>`).  Так запись не может разойтись с
историей.  Правило записано в CLAUDE.md рядом с дисциплиной досок, иначе
файл заведётся один раз и умрёт.

В запись идёт только то, что видно ИГРОКУ или меняет сборку/запуск, по
схеме «симптом -> причина одной фразой -> хеш»; полный разбор остаётся в
сообщении коммита и не дублируется.

Файл в UTF-8, как остальные доки SprPoP.  CP866 у mdview2 — вынужденная
мера (его changelog читает сам MDView), здесь такого требования нет.

Ссылка добавлена во входные точки docs/README.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 18:32:11 +03:00
snark13 aaa480d0f2 SprPoP: в CLAUDE.md — игра на образе лежит в D:\GAMES\SPRPOP
С обобщения HDD-сборки (ea8efdb) один образ рассчитан на несколько
приложений, и EXE переехал в подкаталог.  В инструкции по запуску это не
отразили, поэтому старая последовательность (`d:` + `sprpop`) отвечает
`Bad command or file name`, а `dir D:\` показывает только каталог GAMES —
на это можно потратить время впустую.

Записаны и сам путь, и исправленная цепочка клавиш для моста.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:41:40 +03:00
snark13 8548aa9132 SprPoP: кнопка под Кидом больше не заливается кладкой (палас)
Симптом (пользователь, 2026-09-02, уровень 4 комната 18): Кид
подтягивается на открывающую кнопку (0,4), и кромка, которая его
прячет, нарисована кирпичом вместо кнопки.

РАЗБОР ПО ЖИВОМУ КАДРУ (заморозка): Kid frame 139, x 123, col 4, row 1,
room 18 — то есть персонаж действительно под кромкой кнопки (0,4).
Отсюда путь: кадр подъёма -> climb_overlay_tile; тайл не floor-подобный
(кнопка) -> other_overlay_tile; сосед слева (0,3) пуст -> overlay_mid_tile,
полная перерисовка тайла ПОВЕРХ персонажа.

ПРИЧИНА.  draw_tile_base оригинала подменяет базу кнопки на «левую
половину без пола слева» (id 148) по ТРЁМ условиям (seg008:628): тайл —
opener, слева пусто И tbl_level_type[current_level] == 0, то есть только
в ПОДЗЕМЕЛЬЕ.  Третье условие у нас было потеряно ИМЕННО В ОВЕРЛЕЕ:
статическая отрисовка (pop_room.c, draw_tile_base) его имеет и даже
ссылается на ту же строку оригинала, а pop_bg.c — нет.  Уровень 4
дворцовый, поэтому оверлей брал спрайт 148, а в дворцовом наборе это
другая картинка.

Этим же объясняется, почему баг не виден «просто так»: пока тайл рисует
статика, кнопка правильная — кирпич появляется ровно в тот момент, когда
персонаж встаёт под кромку и включается оверлей.

Цена: +6 байт в банке 2 (8976 -> 8982, свободно 7402).

Проверять стоит на любом ДВОРЦОВОМ уровне, где у открывающей кнопки
слева пусто и персонаж лезет на неё снизу.  Живая проверка в MAME
пользователем: корректно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:41:29 +03:00
snark13 400f5cba63 SprPoP: поправка в CLIMB-VS-GUARD — звук 11 значит ПРОМАХ
В записи стояло, будто оригинал играет звук 11 «сразу на кадре укола, ДО
проверки расстояния», то есть при ЛЮБОМ уколе, попал тот или нет.  Это
было описание НАШЕГО кода, а не SDLPoP: ровно так звук у нас и стоял —
внутри ветки «не парировано» и до присвоения Opp.action = 99_hurt (снято
предыдущим коммитом).  Классическое нарушение правила «источник истины —
SDLPoP»: за оригинал приняли собственную реализацию, и вывод из неё уехал
в доску как факт.

На деле звук 11 в оригинале защищён условием Opp.action != 99_hurt, то
есть звучит ТОЛЬКО на промахе: на попадании играет боль, на парировании
Opp.frame уже 161.

Что это меняет для самого бага:

- УЦЕЛЕЛО наблюдение «в оригинале 11, у нас 8» — это данные.  Арифметика
  приоритетов их подкрепляет: snd_prio[11] = 0x12 против snd_prio[8] =
  0x4B (меньше значит важнее), взмах не мог быть заглушён упором в стену.
- ОТПАЛ вывод «оригинал ведёт стража атакой с промахом, а мы —
  столкновением»: он опирался на неверную посылку.
- УСИЛИЛАСЬ версия про ПОРОГ ДИСТАНЦИИ: раз в оригинале слышен именно 11,
  укол у стража СОСТОЯЛСЯ и ПРОМАЗАЛ.  Прежняя «версия про столкновение»
  вытеснила дистанцию зря — возвращаем её в главные подозреваемые.

Отдельно предупреждение тому, кто вернётся к багу: диагностика по звуку
теперь значит другое, старые заметки прогона будут вводить в заблуждение,
сцену надо переснимать.  Положена таблица соответствий (11 = промах, 13 =
попадание, 8 = столкновение, тишина = ранний выход из check_hurting).

На механику урона правки не влияют — расхождение «удар убивает» остаётся
открытым как было.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:41:07 +03:00
snark13 cb995bf0fc SprPoP: непрерывный взмах клинка у безоружного Кида
Симптом (пользователь, 2026-09-02): на подъёме Кида шёл нескончаемый
свист клинка, хотя меча у него нет.

ОПОЗНАНИЕ.  Звук снят с живой машины, а не угадан: snd_curr == 11
(sound_11_sword_moving).  Заодно проверено, что тракт исправен —
указатели насоса (pg 2, ptr 0x2B80, left 512) сошлись с записью 11 в
SND/snd.idx (страница 2, off 0x2880, длина 1280, конец 0x2D80) байт в
байт.  То есть играл честно заявленный эффект из своих данных, и виновата
была ЗАЯВКА, а не звук.

ПРИЧИНА.  Хвост check_hurting был портирован не до конца.  В оригинале
(seg002:1039..1044) звук 11 стоит В КОНЦЕ функции, после ОБЕИХ веток, и
защищён тремя вещами: ранним выходом по dir_56_none, кадром 154 и
условием Opp.action != actions_99_hurt.  У нас он стоял ВНУТРИ ветки «не
парировано», до присвоения Opp.action = 99_hurt, без проверки на
попадание и — главное — без гарда по dir_56_none, который автор SDLPoP
подписал прямым текстом: «Fix looping sword moving sound».  Направление
dir_56_none означает «персонаж выключен» (clear_char), махать ему нечем.

Наш движок эту константу знает и применяет в pop_guard_tick — в
check_hurting она просто не доехала.

ТОНКОСТЬ ПОРТА: хвост оригинала ПЕРЕЧИТЫВАЕТ Char.frame и Opp.frame у
персонажей, а не берёт снимки начала функции.  Ветка парирования только
что записала Opp.frame, а play_seq в ней — Char.frame; на кэшированных
cf/of условие дало бы неверный ответ.

Живая проверка в MAME пользователем: корректно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:40:45 +03:00
snark13 38fb3c03bb SprPoP: зацеп больше не срывается на кадре после захвата
Регресс от 4e12aa5: glide_through_wall_guard() звался в do_fall сразу
после check_grab и отменял ТОЛЬКО ЧТО СОСТОЯВШИЙСЯ зацеп.

Причина в имени последовательности: seq_15 — это grab_ledge_MIDAIR, и
после удачного захвата Char.action == 3, то есть персонаж формально всё
ещё «в воздухе», уже подтянутый вплотную к кромке.  А под кромкой в
комнатах оригинала стоит кладка.  Guard видел ровно её (замерено на живой
сцене: t == TILE_WALL, d == 10, col 0, row 1), считал это пролётом сквозь
стену, отбрасывал персонажа на 5 пикселей назад и гасил fall_x.  Зацеп
РИСОВАЛСЯ и тут же срывался — ловилось на длинном прыжке уровня 3
(комната 7) и в attract-демо (комната 2, прыжок с места на кромку 0,2,
после срыва Кид падал на пики).

ФИКС.  check_grab() возвращает признак «зацепился», и при нём guard не
зовётся.  Цена — один тест байта на кадр падения; фикс «падение сквозь
стену» цел, t_wall зелёный.

ПОЧЕМУ ПРЕЖНИЕ НАБОРЫ ЭТОГО НЕ ПОЙМАЛИ — и главный урок.  t_grab, t_phys
(1733 проверки) и t_wall судят по «действие стало вис», а вис-то
наступал, он просто не жил.  Первый A/B guard'а по этой же метрике дал
ЛОЖНО-ОТРИЦАТЕЛЬНЫЙ ответ, и подозрение с него было снято зря.  Разница
между «зацепился» и «зацепился и держится» — это и есть разница между
багом и нормой.

Отсюда новый набор t_hang: критерий — вис ДЕРЖИТСЯ три кадра подряд, и в
отчёте различаются «не наступило» и «наступило и сорвалось».  Окно — 34
фазы разбега из 41; при намеренно возвращённой поломке 0 из 41, тест
краснеет (проверено).  Сцена — геометрия уровня 3 комнаты 7, приведённая
внутрь одной комнаты, чтобы шов не примешивался; кромка-пол и
кромка-решётка проверяются отдельно.

Живая проверка в MAME пользователем: корректно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:40:23 +03:00
snark13 879f2bae31 size_baseline: принять atlas — эталон был записан со старым .map
atlas значился 9193 Б против 9627 фактических, и расхождение держалось
через все правки этой сессии: замерял со СПРЯТАННЫМИ правками — те же
9627, то есть к ним оно отношения не имеет.

Похоже на механику самого инструмента: size_check.py читает .map С ДИСКА,
а не пересобирает.  Программа, которую в тот раз не пересобрали, попадает
в эталон со СТАРЫМ числом — и дальше висит расхождением, пока её однажды
не соберут заново.  Сегодня после make clean впервые за долгое время
пересобралось всё, и накопленный рост libc/libbgi стал видимым разом.

Записываю честное число.  Полезный вывод на будущее: `make size-baseline`
имеет смысл только после полной пересборки тестов, иначе он фиксирует
смесь свежих и устаревших размеров.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 15:00:42 +03:00
snark13 b3f9a7430c SprPoP: тихие наборы звука и музыки в dist/
Выхлоп tools/pop_quiet.py на текущих наборах: те же snd.arc и m*.bin, но
вдвое тише (out = in/2 + 0x40, тишина 0x80 остаётся на месте), плюс
неизменённые индексы.  Раскладка совпадает с диском игры.

Это ПРОИЗВОДНЫЙ артефакт: восстанавливается из assets/packed одной
командой `python3 tools/pop_quiet.py` за секунды.  Лежит в репозитории по
той же логике, что и assets/packed — чтобы вариант был под рукой без
пересборки; если решим, что 3,6 МБ того не стоят, снимается одним
git rm --cached плюс строка в .gitignore.

На образ игры dist/ автоматически не попадает: make hdd берёт assets/packed
через build/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 15:00:12 +03:00
snark13 fee3bdb354 SprPoP: ALLOCS по умолчанию 10000; обёртка dist_to_edge — за своим хелпером
Правки пользователя, разобранные по диффу и закоммиченные как есть.

Makefile: умолчание --max-allocs поднято с 6000 до 10000 — плотнее
упаковка регистров.  Напоминание из шапки остаётся в силе: занятость
банков сравнима только при ОДНОМ значении ALLOCS, иначе сравниваются не
правки, а уровни оптимизации.

pop_map.c: банковая обёртка pop_dist_to_edge_weight() переехала НИЖЕ
статического distance_to_edge_weight(), который она зовёт, — раньше стояла
до его определения.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:59:56 +03:00
snark13 6a97124e0d sprinter-cc: --bank-data принимает номер банка
Правка пользователя, разобранная по диффу и закоммиченная как есть.

Было «всё или ничего»: --bank-data уводил писучие данные В СТРАНИЦУ для
ВСЕХ банков сразу.  Теперь флаг принимает необязательный номер и
повторяется: --bank-data 5 --bank-data 6.  Без аргумента поведение прежнее
(все банки), поэтому существующие сборки не меняются.

Зачем поштучно: данные в странице банка НЕ ВИДНЫ снаружи, поэтому модуль с
экспортируемым глобалом обязан остаться на общем _DATA в W1/W2 — а его
сосед в это же время может держать большой приватный буфер вне резидентного
бюджета.  Одним флагом на всю программу это не выражается.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:59:43 +03:00
snark13 8610a8c178 toolchain: предупреждать, когда ISR-стаб W0-страниц остался в W1
Правка пользователя, разобранная по диффу и закоммиченная как есть.

Программа может временно маппить свою EMM-страницу в окно 0 (атласы
спрайтов, gfx_w0_map).  Пока страница там, любое прерывание уходит на
#0038 ЭТОЙ страницы, поэтому gfx_w0_page_prepare прошивает туда переход на
_gfx_w0_isr.  Сам стаб живёт в _CODE, а в режимах small и huge _CODE
начинается с 0x4100 — то есть попадает в W1, окно, которое DSS перемаплет
на время СВОИХ вызовов.  Прерывание в этот момент уходит по адресу,
которого сейчас нет: W1 читается как #FF.

Ловится тяжело: собирается молча, проявляется недетерминированно —
зависанием примерно на каждом третьем холодном старте.  Поймано вживую
2026-09-01.

check_w0_isr.py берёт адрес __gfx_w0_isr из .map: символа нет — W0-путь не
слинкован, молчим; адрес >= 0x8000 — он в W2, всё хорошо; иначе WARNING.
Сборку не валит намеренно: свои страницы в W0 кладут не все.  Вызов
добавлен в app.mk рядом с check_bank_calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:59:26 +03:00
snark13 81e8ed4676 SprPoP: pop_quiet.py — тихая копия готовых наборов звука и музыки
Берёт то, что уже лежит в assets/packed, делает тише и кладёт в dist/ в
раскладке диска, перезаписывая прежнее.  Оригиналы не трогаются, поэтому
скрипт идемпотентен: сколько раз ни запусти, громкость упадёт один раз.

Формула — та, что предложил пользователь: PCM у нас 8 бит без знака с
тишиной 0x80, значит вдвое тише это out = in/2 + 0x40 (беззнаковый сдвиг).
Таблица на 256 значений строится floor-делением отклонения от центра, что
при gain = 1/2 совпадает с этой формулой байт в байт — то есть результат
можно сверять с реализацией на Z80 (srl a / add a,#0x40).  Тишина остаётся
тишиной при любом gain, клиппинга нет по построению.

Пересчитываются: MUS/m*.bin целиком (чистый PCM) и ТОЛЬКО тела записей
PBA1 в SND/snd.arc — заголовок и выравнивающие хвосты остаются как есть,
там нули упаковщика, и пересчёт превратил бы их в 0x40.  Индексы
копируются без изменений (длины те же), но с проверкой магии.

Прогон на текущих наборах: 10 записей архива и 22 трека, пик 128 -> 64,
размеры байт в байт прежние, заголовок PBA1 не тронут.

Тест закрепляет формулу и неприкосновенность служебных байт; make
test-tools — 45 тестов.  Раскладка dist/ добавлена в tools/paths.py, по
правилу «пути знает один файл».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:16:07 +03:00
snark13 8490288d79 libc/crt0: возврат из main завершает программу по-настоящему
Два бага одного пути завершения, оба видны только на железе.

1. ЗВУК ПРОДОЛЖАЛСЯ ПОСЛЕ ВЫХОДА.  cbl_close() закрывает СЕССИЮ, но не
гасит железо: bit7 порта 0x004E держит gfx_wait_vsync ради бита луча,
поэтому порт оставался включённым ("немой" режим), и CBL крутил свои 256
слотов уже под шеллом — тихо ровно до первой чужой записи в порт данных,
а дальше она зацикливалась.  Новый cbl_shutdown() гасит bit7 независимо
от держателей и центрует ЦАП обычного COVOX; pop_shutdown зовёт его
последним действием, а _cbl_open_raw регистрирует в atexit его, а не
cbl_close.

2. ЦЕПОЧКА atexit НЕ ВЫПОЛНЯЛАСЬ ПРИ ВОЗВРАТЕ ИЗ main.  crt0 уходил прямо
в ESTEX EXIT, то есть нарушал контракт C (возврат из main = exit(status)).
Молча терялись не только гашение звука и снятие vsync-ссылки, но и
_fclosall: буферизованная запись в файлы пропадала, если программа не
звала exit() явно.  Теперь crt0 после main дёргает _atexit_hook.

Косвенность обязательна: прямая ссылка crt0 на разматыватель притащила бы
его и стек хендлеров в КАЖДУЮ программу.  Указатель живёт в отдельном
data-модуле (два байта _DATA, ни байта кода), ставит его сам atexit() при
первой регистрации — нет регистраций, нет и кода.  Тот же приём, что у
_irq_cbl_hook.

Цена замерена: +14 Б всем программам (блок в crt0) и +64 Б тем
одиннадцати, что реально регистрируют хендлеры (CBL, файловые через
_fclosall, irqtest, gfx_dbuf, solidt) — у них раньше эти хендлеры были
мёртвым кодом.  Эталоны обновлены (кроме atlas: его +434 Б не отсюда,
замерен тот же и без этих правок).

Проверено в MAME: старт и звук как были, выход по F10 возвращает в шелл
чисто (текстовый режим восстановлен, зависания нет), повторный запуск
работает.  Пункт 1 проверяется только на железе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:15:48 +03:00
snark13 31d0075090 SprPoP: набор звуковых эффектов пересобран из MSDOS-оцифровки
make SND_SRC=msdos MUSIC_FMT=flac resources-rebuild — полная перегенерация
всех ресурсов.  Изменился только SND/: у MSDOS-набора оцифровка полнее,
чем у SDLPoP (там пуст звук 48 spiked и короче 10 sword_vs_sword — разбор
в docs/sound_plan.md).

Музыка и остальные архивы после перегенерации совпали с закоммиченными
байт в байт — набор flac и есть тот, из которого они сделаны.

Умолчание сборки НЕ меняется: SND_SRC ?= sdlpop, чтобы клон без
оригинального дистрибутива DOS собирался целиком (make fetch).  Вернуть
прежний набор — make resources-sound после make clean stamps, или
make SND_SRC=sdlpop -B resources-sound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 11:45:17 +03:00
snark13 6176f6da31 SprPoP: Restart Game возвращал заставку без музыки и вешал её на титуле
pop_boot_sound() был целиком под разовым флагом, а pop_sfx_start() стоял
внутри него.  До перестановки старта CBL открывал сам шаг 0 титров, и
делал это КАЖДЫЙ раз; теперь второй вызов уходил по раннему return, а
pop_title_show начинается с pop_sfx_pause() — значит после Restart Game
насос оставался выключенным и заставка шла молча.

Тем же корнем объясняется зависание: с закрытым CBL курсор трека не
двигается, pop_mus_left не убывает, и ожидания «дослушать тему» в титрах
и интро (while (pop_music_busy())) висели до нажатия клавиши — на экране
Prince of Persia.

- pop_boot_sound: под разовым флагом осталась только ЗАГРУЗКА (индекс
  звука, первая страница набора, индекс музыки, settings_apply);
  pop_sfx_start() зовётся всегда — он идемпотентен и уважает Ctrl+S;
- pop_music_busy(): признак теперь «звучит», а не «есть курсор» —
  спрашивает и про открытый вывод (pop_snd_ok).  Ждать неиграющую музыку
  нельзя в принципе, и эта строка закрывает весь класс подвисаний.

Проверено в MAME: уровень 1 -> ESC -> RESTART GAME -> титул с музыкой
(pop_snd_ok=1, курсор трека прошёл страницы 8..16, pop_mus_id=54), дальше
последовательность сама уходит в сцену с принцессой.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 11:45:02 +03:00
snark13 7377bf6a2c SprPoP: заставка и музыка сразу после запуска, а не через несколько секунд
От запуска до проявления титула проходило ~3 с чёрного экрана в тишине:
pop_boot читал ВСЕ ресурсы игры до первого пикселя, а pop_title_show перед
fade_in вычитывал целиком трек заставки (248 КБ).

Старт разбит на три фазы (sprpop_cold.c):
- pop_boot() — настройки, POP.CFG, графика, чёрный экран, FONT.ATL;
- pop_boot_sound() — snd.idx + ОДНА страница snd.arc, индекс музыки, CBL;
- pop_boot_rest() — остальные страницы звука, kid.ani, Тень, атласы Кида;
  идемпотентно, между кусками доливает трек.

pop_title_show теперь: fade_in -> pop_boot_sound -> pop_music_stream(54) ->
pop_boot_rest под стоящим титулом.  Порядок «сначала fade_in, потом тема»
сохранён как у show_title (seg000.c:1981).  Первый такт заставки укорочен
на фактически потраченное время (часы насоса 85,4 Гц -> кадры луча
сдвигами), иначе сцена уехала бы относительно музыки.  title_wait доливает
трек по полстраницы за кадр.

Музыка играет с ПЕРВОЙ страницы: pop_music_stream уже был (игровые
джинглы), кольцо won не понадобилось — m54 это 16 страниц из 20 доступных.

Звук поднимается в два приёма (pop_sfx_init_begin/finish): насосу для
тишины нужен ровно один блок, и упаковщик обязан класть его первым блоком
страницы 0 (tools/pop_idx.py) — значит для открытия CBL хватает ОДНОЙ
страницы (33 мс вместо 440).  Пока набор неполон, pop_sfx_play пропускает
эффекты; заявки на музыку проходят.

Четыре загрузки из boot УБРАНЫ, а не отложены: атлас фона, страж, уровень 1
и pop_pal_game_load — их и так делает pop_level_switch на входе в любой
уровень (а pop_new_game_load ещё и палитру перед ним), и оба маршрута в
игру, LEVEL_LOAD и DEMO, упираются в него.  Это ~0,77 с на ровном месте.
Так же устроен оригинал: init_game_main до заставки читает только меч,
пламя, звуки и палитры, а chtab_5/6/7 грузит load_lev_spr на старте уровня.
Страховка на случай пропуска заставки в первую секунду — идемпотентный
pop_boot_rest() в начале pop_new_game_load и pop_level_switch.

FONT.ATL остаётся в минимальной фазе, и это не про шрифт: снимок палитры
физически лежит в хвосте его страницы (pop_ui.c), а вся машинерия яркости
начинается с `if (!font_ready) return`.  С отложенным шрифтом заставка
возникала разом на полной яркости — поймано пользователем.

Проверено в MAME: титул виден сразу и проявляется полосами, pop_mus_left
убывает (трек стримится), pop_snd_pages=10 (набор дочитался), цепочка
титул -> интро -> пропуск -> уровень 1 работает.  Банк 8: 95,5 % (732 Б).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 11:30:53 +03:00
snark13 b34997073e libc/cbl: выключение CBL больше не оставляет железо петь одну ноту
cbl_close() не пишет в порт управления ноль: пока gfx_wait_vsync держит
ссылку ради бита луча (в PoP её берёт pop_pace_arm на всю программу),
bit7 остаётся включённым.  А буфер CBL (256 слотов) при включённом bit7
крутится по кругу и не чистится ничем — железо бесконечно поёт хвост
последнего сэмпла, тоном и громкостью по последней мелодии.  Пойман
пользователем на железе: Ctrl+S во время музыки и пропуск заставки
давали ноту до следующего cbl_open.

В MAME не воспроизводится: "немым" кодом частоты был reserved-код 2, у
которого divs[2]==0 и таймер не заводится вовсе.  На железе reserved-коды
не определены, ЦАП тактируется — тишина держалась на свойстве эмулятора,
а не железа.

Теперь тишину даёт СОДЕРЖИМОЕ БУФЕРА:
- _cbl_port_sync() после каждой записи в порт управления зовёт
  _cbl_prime(0x80).  Это закрывает и паузу звука, и включение bit7 ради
  луча на холодном старте (в буфере лежал мусор от прошлой программы), и
  полное выключение — при bit7=0 те же 256 записей уходят прямо в ЦАП
  обычного COVOX и центруют его, снимая щелчок;
- _CBL_VSYNC_FREQ переведён с reserved-кода 2 на документированный 8
  (7,8125 кГц): поведение определено и на железе, и в MAME, прерывания
  по-прежнему выключены (bit4=0), а бит 7 порта 0xFE трамплин смотрит
  только при живом хуке насоса;
- cbl_close() зовёт sync внутри той же DI-скобки, где снимает хук, иначе
  насос долил бы буфер уже после заливки.

Цена: +2 Б программам со звуком, +11 Б графическим (тянется _cbl_prime
следом за gfx_wait_vsync), 256 OUT'ов (~0,3 мс) на редкое событие —
эталоны cblstream/cbltest/cblwav/gfx_dbuf обновлены.  Рост atlas в
size-check к этой правке отношения не имеет (замерен тот же и без неё).

Проверено: кодоген _cbl_port.asm; SprPoP пересобран и прогнан в MAME
(титры → пропуск заставки → уровень 1, пейсинг по лучу жив).  Сам баг
проверяется только на железе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 10:55:15 +03:00
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
snark13 df5071a967 SprPoP: музыка без перелинковки — длины и длительности уехали на диск
Часть I плана music_runtime_index_plan.md (MI0..MI5).  gen/pop_music_tbl.h
и gen/pop_music_ticks.h УДАЛЕНЫ: длины треков и длительности реплик
читаются из MUS/mus.idx (формат PMI1, tools/pop_idx.py, тесты в
make test-tools).  Один и тот же sprpop.exe работает с любым из четырёх
наборов записей — sha256 бинарника при смене MUSIC_FMT не меняется.

ГДЕ ЖИВЁТ ИНДЕКС.  228 байт таблицы в W2 не положить (свободной кучи там
порядка двух сотен), поэтому индекс лежит в одной странице EMM, а в
резиденте от него два байта.  Данные в странице — со смещения 0x100:
gfx_w0_page_prepare пишет в неё стабы прерываний (0x38 и 0x66), и с нуля
они попали бы прямо в записи id 10 и 21.  Со смещением работает штатная
защита, а не запрет прерываний (тот же приём, что CFG_BASE в
pop_config.c).  Число страниц в индексе не хранится — считается из blocks,
чтобы не разъехалось.

ПАУЗА КОНЦА УРОВНЯ — СОСТОЯНИЕМ, А НЕ СЧЁТЧИКОМ.  pop_endmus_left и
POP_MUS_TICKS_32/41 удалены; главный цикл ждёт pop_music_active() —
«заявка лежит, идёт загрузка или трек звучит».  Одного busy мало: между
заявкой и первой нотой 190-230 мс (замер в sound_plan §9).  Прежний
счётчик закрывал эту щель ценой зависимости EXE от набора и жёсткого
делителя /4, который врал в режимах FAST/FASTEST (там логический кадр 3
кадра луча, а не 4).  Побочно исправилось расхождение с SDLPoP: при
выключенном звуке заявка не кладётся, и уровень меняется сразу, как в
оригинале (seg006:651 + seg003:387) — раньше игра держала пройденный
уровень лишние 12 секунд в тишине.

PV-СЦЕНА — на четырёх якорях (8 байт статики), которые считаются из
индекса при входе в сцену; прежние выражения шкалы не изменились.  План
предлагал протащить структуру времён через пять функций — для сцены,
которая идёт раз за запуск, это того не стоит.

ПАМЯТЬ.  За обе фазы резидент не вырос, а освободился: _CODE 23865 ->
23544, куча 239 -> 256 Б.  Банк 9 похудел на 118 Б (ушла pop_mus_tbl из
rodata), банк 11 — на длительности реплик.

ПРОВЕРЕНО В MAME: exe побайтово одинаков для flac и mt32; все 22 трека в
индексах различаются, и контрольные значения совпали с предсказанными
планом (m41 732->685, m50 831->867, m53 985->1044, m56 9865->10462
блоков, 78->82 страницы); на mt32 PV-сцена проходит целиком по его
длительностям; без mus.idx музыки нет, эффекты работают, игра проходима.

НЕ ПРОВЕРЕНО: потоковый m56 на 82 страницах — до финала надо дойти в
игре.  Единственный оставшийся пункт приёмки, отмечен в sound_plan §11.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 17:17:59 +03:00
snark13 2349481b86 SprPoP: звуковые эффекты без перелинковки — раскладка уехала на диск
Часть II плана music_runtime_index_plan.md (SI0..SI4).  gen/pop_sound_tbl.h
БОЛЬШЕ НЕ ГЕНЕРИРУЕТСЯ: раскладка набора читается из SND/snd.idx (формат
PSI1, писатель и разборщик — tools/pop_idx.py, 22 теста в make test-tools).
Один и тот же sprpop.exe работает с набором SDLPoP (9 страниц) и MSDOS
(10) — sha256 бинарника при смене набора не меняется.

Заодно умолчание источника эффектов переведено на SDLPoP (SND_SRC=sdlpop):
сборка обязана работать без оригинального дистрибутива DOS.  У кого он
есть, включает лучший набор явно — make SND_SRC=msdos (там полнее
оцифровка: в SDLPoP звук 48 spiked пустой).

Устройство: pop_snd_tbl/pop_snd_page/pop_snd_pages — резидентные данные
(pop_snd_data.c), тип и инварианты — рукописный pop_snd_tbl.h.  Записи
читаются ОДНИМ read прямо в таблицу, поэтому sizeof(pop_snd_ent_t) == 5
стало частью дискового контракта: проверяется статически и полем размера
записи в заголовке.  POP_SND_PAGES как compile-time размер набора исчез —
вместо него POP_SND_MAX_PAGES (вместимость, 16) и runtime pop_snd_pages.

Цена: таблица переехала из _CODE в _DATA, суммарный резидент почти не
изменился (куча 239 -> 229 Б); банк 8 +601 Б на чтение и валидацию.

Валидация не доверяет файлу: заголовок целиком плюс каждая запись
(страница, смещение, кратность блоку, непересечение с блоком тишины,
выход за последнюю страницу).  Последнее считается В БЛОКАХ — байтовый
адрес конца не влезает в uint16, а 32-битная арифметика на Z80 дорога.

НЕТ ИНДЕКСА — ЭФФЕКТОВ НЕТ, НО МУЗЫКА ИГРАЕТ.  Первая версия просто
возвращала ошибку, и игра становилась непроходимой: тишину льёт первый
блок набора, без набора CBL не открывался, а с ним вставала музыка (её
блоки считает тот же насос) — заставка ждала конца трека вечно.  Теперь
поднимается пустой набор с блоком тишины.  Заливается ровно 128 байт и
под DI: gfx_w0_page_prepare ставит в страницу IRQ-стабы, и заливка всей
страницы затирала их — первое же прерывание давало чёрный экран.

Грабли сборки: смена SND_SRC тихо давала неверный результат
(sdlpop -> msdos -> sdlpop оставлял чужой набор в assets/packed).  Причина
не в логике, а в секундной гранулярности mtime.  Лечение убирает время из
решения: смена варианта сносит stamp'ы своего семейства, а упаковка,
сборка архива и копия индекса делаются одним рецептом.  То же получила и
музыка (MUSIC_FMT).

Проверено в MAME: таблица в памяти совпадает с файлом из образа побайтово;
один EXE поднимает оба набора; отладочный --order reverse (30 из 31
записей отличаются от штатных) звучит правильно; битый индекс выключает
эффекты, не роняя игру; без индекса PV-сцена проходит с музыкой; Ctrl+S
работает в обоих режимах.  Разбор — docs/sound_plan.md §10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 16:14:53 +03:00
snark13 589894c50d SprPoP: автономность — внешние данные качаются, а не хранятся
В репозитории нет ни байта чужих данных, но есть знание, откуда их взять:
tools/fetch_orig.py качает SDLPoP (ресурсы + исходники-эталон) и записи
саундтрека, причём адрес архива музыки читает из самого SDLPoP
(ReadMe.amigaos4, секция «AUDIO IS SLOW/AWFULL»); запасной адрес вшит
константой.  Цели: make fetch / fetch-sdlpop / fetch-music / fetch-check /
fetch-list.

Версия SDLPoP пишется в .fetch.json вместе с манифестом sha256 всего
дерева.  По нему следующий fetch отличает НАШИ отладочные врезки
(POP_TRACE — покадровая трасса Кида, дампы палитры и спрайтов) от
нетронутых файлов и не сносит их молча: без --force каталог не трогается
вовсе, с --force старая копия уезжает в бэкап .cache/.

MSDOS/ не качается и НЕ НУЖЕН: уровни и оцифровка берутся из SDLPoP.

У SprPoP теперь свой .gitignore, написанный так, чтобы стать корневым при
выделении в отдельный репозиторий (пути от корня приложения, ничего про
applications/).  Из корневого .gitignore тулчейна SprPoP-секция убрана,
чтобы две копии не разъезжались.  Заодно закрылась дыра: шаблон
applications/*/*/*.exe не покрывал артефакты в корне SprPoP.

assets/orig/README.md выведен из-под игнора — без него в чистом клоне не
написано, откуда брать данные, а это и есть смысл затеи.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 16:14:12 +03:00
snark13 ea8efdb0fd SprPoP: обобщить HDD-сборку и очистить метаданные
Добавить общий каталог назначения для HDD и удалить локальную копию упаковщика.\n\nУбрать устаревшие generated-имена ресурсов, выводить число страниц Kid из kid.arc и ограничить звуковую таблицу горячим модулем.\n\nЗафиксировать планы runtime-индексов музыки и PCM-эффектов.
2026-08-30 16:16:06 +03:00
snark13 623199337e SprPoP: HDD-раскладка и пути от каталога EXE 2026-08-30 11:21:11 +03:00
snark13 602c3a20fa SprPoP: сняты последние глушения насоса — палитра на смене уровня и загрузки треков
Замер тем же способом (брейк на pop_sfx_fill + печать totalcycles, три окна
по 800 вызовов = 9 секунд каждое, с уже снятыми глушениями):

  период насоса   245 760 тактов (медиана во всех окнах)
  максимум        245 832 / 270 096 / 311 346 (1,00 / 1,10 / 1,27 периода)
  пропущено порций 0

Порция считается пропущенной, когда зазор доходит до ДВУХ периодов: сама
порция отдаётся железу за период до того, как она понадобится, поэтому
опоздание обработчика на 1,1 мс — джиттер, а не потеря.  Запас
десятикратный.

Поэтому сняты и оставшиеся места:
* палитра через BIOS на переходе БЕЗ катсцены (sprpop_cold.c) — то самое,
  где ловили скрежет 2026-08-25; в комментарии помечено, что при возврате
  скрежета возвращать надо именно сюда;
* массовые чтения треков и ресурсов под чёрным экраном (pop_intro.c):
  первая реплика PV, трек заставки между уровнями, «время вышло», ресурсы
  финала.

Осталось только то, что глушит звук ПО СМЫСЛУ, а не ради защиты: выходы из
сцен (pop_music_free + pause), уход в титры и в игру, выключение по Ctrl+S.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:01:27 +03:00
snark13 4ac3584bf9 SprPoP: идея «готовить следующий уровень под мелодию» — в бэклог, на дальнюю версию
Записана с оговорками, найденными при сегодняшнем разборе: загрузку придётся
разрезать на дисковую и палитро-экранную половины, шаг подкачки держать
полустраничным, проверить EMM-бюджет на два уровня разом.  Половина идеи уже
работает — трек заставки играет поверх загрузки (порядок оригинала, замер
насоса приложен в записи).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 18:55:31 +03:00
snark13 3bf28da8ee SprPoP: трек заставки переживает загрузку уровня — порядок как в оригинале
ЗАМЕР (MAME, 2026-08-28).  Брейк на pop_sfx_fill с печатью totalcycles, 500
подряд вызовов насоса через всю загрузку уровня 1->2 под звучащий трек 27:
медиана интервала 245 760 тактов, максимум 245 832 при дедлайне 251 000
(11,7 мс) — НИ ОДНОЙ пропущенной порции.  Загрузка уровня насос не морит.

Поэтому снята двойная заплатка:
* pop_level_switch больше не глушит насос перед pop_level_load_num;
* pre_cut_finish больше не досиживает трек на чёрном экране (это делалось
  только чтобы глушение не обрубило его на полуслове; ценой были ~8 секунд
  пустого экрана — трек 27 длиннее сцены: 10,7 с против 2,6 с).

Взамен восстановлен порядок оригинала (seg003:68-108, play_level):
катсцена возвращает управление сразу -> уровень грузится ПОД музыку ->
ожидание конца трека на чёрном экране (порт `while (check_sound_playing())`
+ stop_sounds) -> показ уровня.  Общая чернота теперь max(трек, загрузка), а
не их сумма, и трек не обрывается.  Ожидание со страховкой на ~20 с, чтобы
потоковый трек не подвесил переход.

Пропуск сцены по-прежнему обрывает музыку — как и было.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 18:53:44 +03:00
snark13 770b946a36 SprPoP: доски — SND-PACE-DEAD снят, PV-RENDER-BOUND исправлен
Обе записи закрыты сегодняшними правками: вторая шкала по насосу удалена
вместе с гонкой, которая её выбирала, а «сцена дороже бюджета» оказалась не
ценой кадра, а местом отсчёта интервала.  Исходные разборы оставлены под
заголовками — они объясняют, как искали.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:41:08 +03:00
snark13 89e9663753 SprPoP: сняты остальные глушения насоса — появление/закрытие меню, настройки, quicksave
Продолжение предыдущего коммита: заплатка стояла не в одном месте.

* открытие меню (snapshot палитры + затемнение фона + первая полная
  перерисовка) и закрытие (menu_erase — те же две полностраничные копии) —
  DI у копира бандами по 16 строк (~1,6 мс против дедлайна 11,7 мс), а
  палитровое затемнение идёт и в титрах с катсценами, где музыку не рвёт;
* сохранение POP.CFG и проба quickload — это десятки байт и open/close;
  прежняя осторожность «ESTEX уходит в диск надолго» относилась к загрузке
  НАБОРА страниц.  Подтверждение с поля: HOF пишется под звучащий «won»;
* quicksave/quickload (pop_qsave.c) — единственное снятое место, где по
  диску реально едут 16 КБ (~три периода насоса).  Помечено в комментарии:
  если на F6/F9 появится скрежет, вернуть pop_sfx_pause/start точечно сюда.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:36:20 +03:00
snark13 ea07a8d7b0 SprPoP: меню больше не глушит звук на время перерисовки
ui_begin() звал pop_sfx_pause() перед полностраничной accel-копией, и это
слышно: pause закрывает CBL целиком (cbl_close), курсор трека стоит, и
короткая мелодия начала уровня замирала ровно на время перерисовки меню —
а на перемещении по пунктам это повторялось на каждом кадре меню.

Обоснование заплатки устарело.  Лист, который зовёт gfx_copy_page
(_bgi_scroll_rows_raw), режет DI бандами по 16 строк — ~1,6 мс против
дедлайна насоса 11,7 мс (85,4 порции в секунду), между бандами есть окно
прерываний.  Тот же полностраничный копир каждый кадр делают игровой цикл
и катсцены, и звук там не рвётся.  Скрежет, под который заплатка ставилась,
шёл от чтения насосом мусора и вылечен отдельно.

Парный pop_sfx_start() в ui_end оставлен: он идемпотентен и чинит вход в
меню при закрытом выводе (например сразу после загрузки уровня).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:29:04 +03:00
snark13 d23126983e SprPoP: музыка не замолкает в меню и в долгих фейдах
Насос CBL идёт из прерывания (и его запрос защёлкивается до подтверждения —
проверено по MAME irqack_cb), поэтому сам звук главному циклу не нужен.  А
вот ПОТОКОВЫЙ трек — титульная тема играется кольцом — дочитывается с диска
только в pop_music_service: страница ложится в слот, который насос уже
прошёл.  Этот вызов был лишь в игровом цикле, HOF и сценах, но не в меню и
не в фейдах, а там главный цикл стоит секундами — кольцо опустошалось, и
музыка замолкала до закрытия меню (жалоба пользователя).

Добавлено: ui_wait_frame() в pop_menu.c (обслужить музыку + ждать фронт,
заменил все ожидания кадра в меню) и pop_music_service() в fade_run и
transition_ltr (pop_ui.c) — темп тот же, 50 раз в секунду, как в игровом
цикле.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:18:44 +03:00
snark13 50570881f5 SprPoP: подгонка молнии под музыку снята — причина устранена
PV_MAGIC_LEAD двигал жест заклинания (замах, шаг назад, вспышка) на 100
тиков (1,67 с) раньше сценария: сцена была render-bound, шла ~49 тиков/с
вместо 60, а реплика играла по реальному времени — кода приходила раньше
молнии.  После перевода сцены на единые часы и блочную отрисовку подгонка
стала вредной: молния била больше чем на секунду РАНЬШЕ коды (проверка
пользователем).  Ставим 0; константу оставляем на месте — если запись
другого набора (mt32/ogg) разъедется, крутить надо её.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:28:17 +03:00
snark13 f25ed37d85 SprPoP: единые часы в сцене с Джафаром + конец уровня ждёт свою мелодию
1. СЦЕНА С ДЖАФАРОМ — ОДНИ ЧАСЫ, КАДРЫ ЛУЧА.  Было две шкалы, и выбор между
   ними делался гонкой на старте сцены: одна выборка pop_snd_tick через кадр,
   «успел ли диск раскрутить звук».  От прогона к прогону сцена шла то по
   тикам насоса CBL, то по кадрам луча, и кода реплики приходилась каждый раз
   на другое место картинки (наблюдение пользователя).  Насос был нужен
   потому, что кадр рисовался дольше своего интервала; теперь отрисовка
   разложена по интервалам (pv_restore_bg), и счёт кадров честен — ветка
   насоса убрана целиком.

2. ПОДКАЧКА ТРЕКА — ПОЛСТРАНИЦЫ ЗА ШАГ (pop_music_load_step).  8 КБ ≈ 16 мс
   влезают в кадровый интервал, целая страница (33 мс) не влезала и
   растягивала кадр сцены.  В сцене шаг остаётся безусловным (иначе реплики
   не успевали грузиться, memory pv_music_stall_regression) и оплачивается
   ровно одним интервалом.

3. КОНЕЦ УРОВНЯ ЖДЁТ МЕЛОДИЮ.  Оригинал (seg003:387, play_level_2) не
   сменяет уровень, пока `check_sound_playing()`: экран пройденного уровня
   живёт с анимацией факелов, пока звучит трек.  Мы уходили на смену сразу и
   обрывали мелодию на первых нотах.  Теперь ждём большего из двух:
   pop_music_busy() и счётчика pop_endmus_left по длине записи
   (gen/pop_music_ticks.h) — второе нужно потому, что при ВЫКЛЮЧЕННОЙ музыке
   busy ложен, а оригинал выдерживает паузу и молча.

Стартовый уровень возвращён на 1 (отладочный LEVEL=14 был только для замера).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:19:43 +03:00
snark13 3449f6f8c9 SprPoP: кадр катсцены — блоками по кадровому интервалу, а не «отрисовка плюс пять»
Катсцены шли ~8,2 fps вместо десяти.  Делитель тут ни при чём: у оригинала
cutscene_frame_time = 6 тиков по 1/60 с (reset_cutscene, seg001:527; его
зовёт load_intro прямо перед сценой) = 100 мс, у нас 5 кадров луча по 50 Гц
= те же 100 мс.  Причина в том, ГДЕ отсчитывался интервал: сначала рисовали
кадр целиком, и только потом ждали vsync и ещё четыре — то есть отрисовка
ПРИБАВЛЯЛАСЬ к делителю.  Полноэкранная gfx_copy_page стоит ~547 000 тактов
= 1,27 кадра, отсюда 6+ кадров вместо 5 (замер PV-RENDER-BOUND: 49 тиков/с
вместо 60).

Теперь отрисовка разложена на блоки, каждый из которых заведомо влезает в
кадровый интервал, и после каждого честно ждём vsync:
  фон верхняя половина -> vsync | фон нижняя половина -> vsync |
  актёры и декорации -> vsync (+ флип) | служебный блок (звук, подкачка
  трека) и добор до CUT_FRAME_VSYNC.
Фон восстанавливаем только по картинке (200 строк с POP_YOFF), а не по всем
256: сверху и снизу чёрная рамка.  Общий хелпер pv_restore_bg на все три
цикла — cut_run (сцены 8/9/12 и финал), pre_room_animated (2_6/4/12 и
time_expired) и intro_pv_draw_frame (сцена с Джафаром); последний теперь
возвращает 3 кадра вместо 1 (13 вместо 11 со вспышкой), вызывающий их и так
учитывал.

Сверка делителей с SDLPoP: у всех сцен 6 тиков = 5 наших кадров; плавает
только pv_scene (6 -> 8 -> 7, seg001:434/455) — это уже сделано кумулятивно
через pv_seq_period + POP_T60, и минимальный бюджет (5 кадров) больше трёх
съедаемых блоками.

Замер в MAME пока НЕ сделан: до финальной сцены на отладочном старте
LEVEL=14 не добраться (решётка перед комнатой 5 закрывается по таймеру, а
Ctrl-комбинации через MCP-мост до игры не доходят).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:55:27 +03:00
1053 changed files with 35452 additions and 1136 deletions
+27
View File
@@ -0,0 +1,27 @@
# Настройки Codex
`config.toml` и запускной скрипт `toolchain/run-mame-mcp.sh` хранятся в Git.
Запускайте Codex из корня репозитория или любой вложенной папки.
Корень определяется через `git rev-parse --show-toplevel`.
На каждом компьютере установите `uv` и добавьте его в PATH процесса Codex.
По умолчанию сервер расположен в `mame/sources/MAME/src/mame_mcp.py`.
Каталог `mame/` не хранится в Git и должен быть установлен отдельно.
Первый запуск uv может потребовать сеть для установки Python 3.12 и mcp<2.
Если расположение отличается, создайте `.codex/mame.local.env` (игнорируется Git):
```sh
MAME_UV="/opt/homebrew/bin/uv"
MAME_MCP_SCRIPT="mame/sources/MAME/src/mame_mcp.py"
```
Путь к серверу может быть абсолютным или относительно корня проекта.
Путь к uv может быть абсолютным, относительным к корню или именем из PATH.
Переменные можно также передать через окружение Codex; локальный файл имеет
приоритет. Файл читается как shell-код запускным скриптом, а не самим Codex.
Не храните в нём секреты и не добавляйте его в Git.
После изменения настроек перезапустите подключение MCP или Codex.
Инициализация MCP не требует запущенного эмулятора; для команд отладки нужен
MAME с `mame_bridge.lua` (см. `docs/mame-autotest.md`).
+25 -8
View File
@@ -1,11 +1,28 @@
[mcp_servers.mame-z80]
command = "/Users/alex/.local/bin/uv"
command = "sh"
args = [
"run",
"--python",
"3.12",
"--no-project",
"--with",
"mcp<2",
"/Volumes/SAM8/Projects/DIY/Z80/Sprinter/C-Compiler/mame/sources/MAME/src/mame_mcp.py",
"-c",
'root=$(git rev-parse --show-toplevel) || exit; exec sh "$root/toolchain/run-mame-mcp.sh"',
]
startup_timeout_sec = 60
[mcp_servers.mame-z80.tools.clear_breakpoint]
approval_mode = "approve"
[mcp_servers.mame-z80.tools.list_breakpoints]
approval_mode = "approve"
[mcp_servers.mame-z80.tools.press_key]
approval_mode = "approve"
[mcp_servers.mame-z80.tools.step_out]
approval_mode = "approve"
[mcp_servers.mame-z80.tools.debugger_command]
approval_mode = "approve"
[mcp_servers.mame-z80.tools.pause]
approval_mode = "approve"
[mcp_servers.mame-z80.tools.status]
approval_mode = "approve"
+13 -12
View File
@@ -78,6 +78,10 @@ toolchain/mkexe/tests/*.actual
*.obj
*.dSYM/
# Python host-tools: bytecode всегда воспроизводим и не входит в исходники.
__pycache__/
*.py[cod]
# ===========================================================================
# Vendored / downloaded
# ===========================================================================
@@ -132,15 +136,12 @@ applications/PoP/PoP1_DOS_music/
# не идёт (сама roomtest и есть версируемая ветка разработки).
applications/PoP/R1/
# SprPoP — автономное приложение. В репозиторий идут исходники, генерируемые
# заголовки (gen/) и упакованные ресурсы (assets/packed/), потому что без них
# из чистого клона ничего не собрать. НЕ идут:
# assets/orig/ оригинальные данные игры — копирайт (SDLPoP, MSDOS,
# записи музыки DOS-версии); откуда их взять,
# написано в assets/orig/README.md
# assets/packed/LEVELS/ уровни res20xx.bin ложатся на диск прямо из
# оригинала, конвертации нет — то есть это тот же
# копирайтный материал, только в раскладке диска
# build/ уже покрыт общим правилом build/ выше
applications/SprPoP/assets/orig/
applications/SprPoP/assets/packed/LEVELS/
# SprPoP — автономное приложение, и правила игнора у него СВОИ:
# applications/SprPoP/.gitignore. Он написан так, чтобы стать корневым
# .gitignore, когда SprPoP выделят в отдельный репозиторий, — поэтому
# здесь его содержимое НЕ дублируется (иначе разъедется). Коротко: в
# репозиторий не идут assets/orig/ (чужие данные — их выкачивает
# `make fetch`) и assets/packed/LEVELS/ (уровни из оригинала как есть).
# Локальные пути MCP; общая конфигурация .codex остаётся в Git.
/.codex/mame.local.env
+1
View File
@@ -0,0 +1 @@
3.12
+10
View File
@@ -56,6 +56,16 @@ make size-baseline # принять текущие размеры эталон
- Справочник API — docs/libc-reference.md (обновлять при добавлении
функций).
## Документация source debugger
При изменении `<sdbg.h>`, извлечения/форматирования logMessage,
поддержанных типов/регистров, чтения указателей или маршрутов MAME/DAP
одновременно обновлять `docs/sdbg-log-macros.md` и проверяемые примеры;
ссылки и краткий статус синхронизировать с `docs/mame-source-debug.md`,
`docs/vscode-sprinter-debug.md`, `docs/mame-source-debug-status.md` и
`docs/libc-reference.md`. Финальные задачи по hex и разыменованию
указателей пока только в плане, не считать их рабочим API.
## ABI и платформа (кратко; детали в memory/)
- SDCC `__sdcccall(1)`: arg1 → HL (8-бит → A), arg2 → DE, остальные
+16 -1
View File
@@ -43,7 +43,7 @@ DATA_FILES := \
examples/mdview/SAMPLE.MD
.PHONY: all tools lib tests examples check clean sdcc floppy \
size-check size-baseline host-tests $(TESTS) $(APPS)
size-check size-baseline host-tests sdbg-tests mame-sdbg-patch mame-sdbg $(TESTS) $(APPS)
all: tools lib tests
@@ -87,6 +87,21 @@ HOST_TEST_DIRS := testkit applications/SprPoP/tests/host
host-tests:
@for d in $(HOST_TEST_DIRS); do $(MAKE) -C $$d || exit 1; done
# Отладочная карта: реальные SDCC/linker и протокол транспорта без MAME.
# Для local pyenv: pyenv exec make sdbg-tests.
sdbg-tests: tools lib
python3 -m unittest discover -s tests/sdbg -v
# Backend удерживает MAME в stopped-loop без Cocoa debugger; Lua bridge при
# этом обслуживается из periodic_check ядра. Повторный вызов безопасен.
mame-sdbg-patch:
sh toolchain/apply-mame-sdbg-patch.sh
# Инкрементально собирает patched checkout и устанавливает бинарник в v306.
mame-sdbg: mame-sdbg-patch
$(MAKE) -C mame/sources/MAME
cp mame/sources/MAME/mame $(MAME_DIR)/mame.arm
# Размерный регресс: сверить _CODE всех программ с docs/size_baseline.tsv.
size-check:
python3 toolchain/size_check.py
+40 -7
View File
@@ -12,7 +12,10 @@
# # STACK_SIZE := 2048 # bytes reserved for the stack
# # EXTRA_SRCS := helper.c util.c # additional .c files in this dir
# # EXTRA_FLAGS := --crt0=minimal # passed through to sprinter-cc
# # SRC_DEBUG := 1 # карта C/asm и проверенный debug-пакет
# # SRC_DEBUG_FILES := helper.c # либо карта только выбранных TU
# # EXTRA_DATA := test.txt # extra files to add to `make floppy`
# # HDD_DEST_DIR := games/myapp # общий каталог файлов в `make hdd`
#
# include $(PROJ_ROOT)/app.mk
#
@@ -30,6 +33,7 @@
# (in the repo root) still packs all examples.
# run floppy + launch MAME
PYTHON ?= python3
SPRINTER_CC := $(PROJ_ROOT)/bin/sprinter-cc
MKEXE := $(PROJ_ROOT)/toolchain/mkexe/mkexe
LIB := $(PROJ_ROOT)/lib/sprinter.lib
@@ -40,7 +44,7 @@ FLOPPY_IMG := $(MAME_DIR)/IMG/mc.img
# образ у себя и связывает его с MAME символьной ссылкой.
HDD_IMG ?= $(MAME_DIR)/IMG/test_hdd.chd
MAKE_DISK := $(MAME_DIR)/make_disk.py
MAKE_HDD := $(PROJ_ROOT)/toolchain/make_hdd.sh
MAKE_HDD ?= $(PROJ_ROOT)/toolchain/make_hdd.sh
RUN_MAME := $(MAME_DIR)/run_mame.sh
# Optional knobs — see top of file.
@@ -67,12 +71,21 @@ SOURCES := $(MAIN_SRC) $(EXTRA_SRCS)
# Аргументы упаковщика HDD. Обычно это exe и EXTRA_DATA; приложение со
# своей раскладкой каталогов может переопределить переменную до include.
HDD_PACK_ARGS ?= $(EXE) $(EXTRA_DATA)
# Общий каталог назначения внутри HDD. Пустое значение сохраняет прежнюю
# укладку в корень; вложенные КАТАЛОГ:файл считаются относительно него.
HDD_DEST_DIR ?=
CC_FLAGS := --memory $(MEMORY)
ifneq ($(STACK_SIZE),)
CC_FLAGS += --stack-size $(STACK_SIZE)
endif
CC_FLAGS += $(EXTRA_FLAGS)
ifeq ($(SRC_DEBUG),1)
CC_FLAGS += --src-debug
endif
ifneq ($(strip $(SRC_DEBUG_FILES)),)
CC_FLAGS += $(foreach src,$(SRC_DEBUG_FILES),--src-debug-file $(src))
endif
all: $(EXE)
@@ -88,11 +101,31 @@ RUNTIME_DEPS := $(wildcard $(PROJ_ROOT)/runtime/*.s)
# bankN_*.asm; обычным небанковым программам ничего не стоит.
BANK_CHECK := $(PROJ_ROOT)/toolchain/check_bank_calls.py
$(EXE): $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS)
$(if $(strip $(BUILD_DIR)),@mkdir -p $(dir $@))
$(SPRINTER_CC) $(CC_FLAGS) -o $@ $(SOURCES)
@d=$(dir $@).sprinter-cc-$(EXAMPLE); \
if ls $$d/bank*_*.asm >/dev/null 2>&1; then python3 $(BANK_CHECK) $$d; fi
# ПРОВЕРКА ISR-СТАБА W0-СТРАНИЦ — там же, по свежей карте. Ловит стаб
# _gfx_w0_isr, оставшийся в W1: программа, кладущая свои страницы в W0
# (атласы спрайтов, gfx_w0_map), получает недетерминированные зависания,
# когда прерывание приходит во время вызова DSS и W1 перемаплен. Только
# предупреждение: страницы в W0 кладут не все. Разбор — в шапке скрипта.
W0ISR_CHECK := $(PROJ_ROOT)/toolchain/check_w0_isr.py
# Команда/зависимости карты участвуют в пересборке, включая смену режима.
SDBG_CONFIG := $(dir $(EXE)).resource-stamps/$(EXAMPLE)-config.json
SDBG_MANIFEST := $(dir $(EXE)).sprinter-cc-$(EXAMPLE)/manifest.json
.PHONY: sdbg-config-force
SDBG_CONFIG_ARGS = $(SDBG_CONFIG) $(SPRINTER_CC) $(CC_FLAGS) $(SOURCES) $(SDBG_MANIFEST)
# Проверка содержимого обязательна: mtime make может иметь точность в секунду.
# Сохранение fingerprint только после успешной сборки позволяет повторить сбой.
$(EXE): $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS) sdbg-config-force $(SPRINTER_CC) $(wildcard $(PROJ_ROOT)/toolchain/sdbg/*.py) $(wildcard $(PROJ_ROOT)/toolchain/sdbg_*.py)
@state=$$($(PYTHON) $(PROJ_ROOT)/toolchain/sdbg_config.py check $(SDBG_CONFIG_ARGS)) || exit $$?; \
if [ "$$state" = changed ] || [ -n "$(filter-out sdbg-config-force,$?)" ]; then \
mkdir -p $(dir $@); \
SPRINTER_PYTHON="$(PYTHON)" $(SPRINTER_CC) $(CC_FLAGS) -o $@ $(SOURCES) || exit $$?; \
d=$(dir $@).sprinter-cc-$(EXAMPLE); \
if ls $$d/bank*_*.asm >/dev/null 2>&1; then $(PYTHON) $(BANK_CHECK) $$d || exit $$?; fi; \
if ls $$d/*.map >/dev/null 2>&1; then $(PYTHON) $(W0ISR_CHECK) $$d || exit $$?; fi; \
$(PYTHON) $(PROJ_ROOT)/toolchain/sdbg_config.py save $(SDBG_CONFIG_ARGS); \
fi
$(MKEXE):
$(MAKE) -C $(PROJ_ROOT)/toolchain/mkexe
@@ -125,7 +158,7 @@ run: floppy
# используется MCP-мостом к MAME (run_bridge.sh). После пересборки образа
# MAME ОБЯЗАН полный рестарт (chdman -f = новый inode; см. memory).
hdd: $(EXE)
$(MAKE_HDD) $(HDD_IMG) $(HDD_PACK_ARGS)
$(MAKE_HDD) $(if $(strip $(HDD_DEST_DIR)),--dest "$(HDD_DEST_DIR)") $(HDD_IMG) $(HDD_PACK_ARGS)
@echo
@echo "HDD (D:) ready: $(HDD_IMG) (with $(EXAMPLE).exe$(if $(EXTRA_DATA), + $(EXTRA_DATA)))"
@echo "ВНИМАНИЕ: перезапусти MAME (run_bridge.sh) — образ пересобран."
+75
View File
@@ -0,0 +1,75 @@
# ===========================================================================
# SprPoP — .gitignore приложения
# ===========================================================================
# Пока SprPoP лежит внутри репозитория C-Compiler, git читает этот файл как
# правила ДЛЯ ЭТОГО ПОДДЕРЕВА, и он дополняет корневой .gitignore тулчейна.
# Когда SprPoP выделят в самостоятельный проект, файл станет КОРНЕВЫМ и
# продолжит работать как есть: все пути написаны от корня SprPoP и ничего
# не знают ни про applications/, ни про раскладку тулчейна.
# ---------------------------------------------------------------------------
# ЧУЖИЕ ДАННЫЕ — в репозиторий не идут никогда
# ---------------------------------------------------------------------------
# assets/orig/ — оригинальные данные игры (копирайт). В репозитории вместо
# них лежит ЗНАНИЕ, откуда их взять: tools/fetch_orig.py (`make fetch`)
# качает SDLPoP и записи саундтрека, MSDOS/ пользователь кладёт сам (он
# необязателен). Скачанные архивы кэшируются в assets/orig/.cache/.
#
# Шаблон со ЗВЁЗДОЧКОЙ, а не `assets/orig/`: git не заходит внутрь
# игнорируемого каталога, и исключение ниже иначе не сработало бы, а
# README.md — единственное, что отсюда ОБЯЗАНО быть в репозитории: без
# него в чистом клоне не написано, откуда брать данные.
assets/orig/*
!assets/orig/README.md
# Уровни res20xx.bin ложатся на диск ПРЯМО из оригинала, конвертации нет —
# то есть это тот же копирайтный материал, только в раскладке диска.
# Остальное содержимое assets/packed/ версионируется намеренно: без него из
# чистого клона не собрать игру, а исходников (assets/orig/) может не быть.
assets/packed/LEVELS/
# ---------------------------------------------------------------------------
# ВЫХОД СБОРКИ
# ---------------------------------------------------------------------------
# build/ — .exe, раскладка диска, hdd/, промежуточные атласы, stamp'ы.
# Правило без слэша спереди намеренно: под ним и tests/host/build/.
build/
# Промежуточный каталог sprinter-cc.
.sprinter-cc-*/
# Артефакты компиляции, которые SDCC кладёт рядом с целью — в корень
# приложения. Только корень: единственный версионируемый ассемблерный
# файл (tests/host/bank_stub.s) лежит глубже и под правило не попадает.
/*.exe
/*.asm
/*.lst
/*.lk
/*.ihx
/*.noi
/*.sym
/*.map
/*.rel
/*.cdb
/*.mem
/*.rst
# Хостовые тесты и утилиты собираются нативным cc.
*.o
*.obj
*.dSYM/
# ---------------------------------------------------------------------------
# ОС / редакторы / локальные настройки
# ---------------------------------------------------------------------------
.DS_Store
._*
*~
*.swp
*.bak
*.orig
*.rej
.vscode/
.idea/
.kilo/
.claude/
+56
View File
@@ -0,0 +1,56 @@
# SprPoP — CHANGELOG
Заметные изменения по релизам. **Версия, дата и коммит каждой записи
берутся из git** — из аннотированного тега, а не проставляются руками,
поэтому расходиться с историей они не могут:
git for-each-ref \
--format='%(refname:short) | %(creatordate:short) | %(*objectname:short)' \
refs/tags/<тег>
Заголовок записи = `<имя тега> — <дата тега> — <коммит тега>`. Коммит —
дереференс аннотированного тега (`*objectname`), то есть та ревизия, на
которой релиз собран: по ней воспроизводится ровно этот билд.
Здесь только то, что видно ИГРОКУ или меняет способ сборки/запуска.
Разбор причин живёт в сообщениях коммитов, открытые баги — в
`docs/BUGS_OPEN.md`, закрытые — в архиве roomtest.
Файл начат с релиза v0.9.6; более ранняя история — в `git log` и в
досках задач.
## v0.9.6-hang-sound-button — 2026-09-02 — `aaa480d`
Три бага, найденные в живой игре и проверенные в MAME после исправления.
- **Зацеп срывался на следующем кадре** (регресс, тянулся с 4e12aa5).
Кид цеплялся за кромку, поза зацепа рисовалась — и он тут же падал.
Ловилось на длинном прыжке уровня 3 (комната 7) и в attract-демо, где
Кид вместо подъёма на кромку (0,2) улетал на пики. Причина: страж
«падения сквозь стену» принимал кладку под кромкой за стену, в которую
персонаж влетел, и отменял только что состоявшийся захват. Теперь
удачный зацеп отменяет этого стража. `38fb3c0`
- **Непрерывный взмах клинка у безоружного Кида.** На подъёме шёл
нескончаемый свист меча, которого у Кида нет. Причина: в порт не
доехали два условия оригинала, включая гард, который автор SDLPoP
подписал прямым текстом как «фикс зацикленного звука взмаха». `cb995bf`
- **Кнопка под Кидом заливалась кладкой** на дворцовых уровнях. Кид
подтягивается на открывающую кнопку, и кромка, которая его прячет,
нарисована кирпичом (уровень 4, комната 18). Причина: подстановка
спрайта «кнопка без пола слева» действует только в подземелье, и это
условие было потеряно в перерисовке поверх персонажа — при том что в
статической отрисовке комнаты оно есть. `8548aa9`
Сопутствующее, для разработки:
- В доске исправлено толкование звука 11: это индикатор ПРОМАХА, а не
«любого укола». Прежняя запись описывала наш собственный код вместо
оригинала, и из-за неё в CLIMB-VS-GUARD зря отодвинули версию про порог
дистанции. `400f5cb`
- Новый набор host-тестов `t_hang`: мерит не «вис наступил», а «вис
ДЕРЖИТСЯ» — прежние наборы регресс зацепа не видели именно поэтому.
Входит в `38fb3c0`.
- В `CLAUDE.md` записан путь запуска на HDD-образе: игра лежит в
`D:\GAMES\SPRPOP`, а не в корне диска. `aaa480d`
+41 -5
View File
@@ -17,8 +17,9 @@ sprinter-cc / libc / libbgi. Действуют правила корневог
## Главное правило: SDLPoP — источник истины. Сначала читай, потом кодь
**`assets/orig/SDLPoP/src/` (github.com/NagyD/SDLPoP, GPLv3) — ЕДИНСТВЕННЫЙ
авторитетный источник того, как оригинальный движок это делает.** Правило
без исключений:
авторитетный источник того, как оригинальный движок это делает.** В
репозитории его нет: заполняется `make fetch` (`make fetch-check` — что уже
на месте). Правило без исключений:
1. **Перед реализацией ЛЮБОЙ функции** (движение, коллизия, окклюзия,
падение, loose-полы, стражники, отрисовка, тайминги, любые числовые
@@ -59,6 +60,24 @@ seqtbl, `seg007` mob/loose/падающие объекты, `seg008` отрис
нового бага грепни архив по симптому: корень мог уже разбираться.
Открытые файлы обязаны читаться целиком за раз.
**CHANGELOG. Ставишь релизный тег — добавь запись в `CHANGELOG.md`.**
Версия, дата И КОММИТ в заголовке берутся ИЗ GIT, руками не
проставляются:
git for-each-ref \
--format='%(refname:short) | %(creatordate:short) | %(*objectname:short)' \
refs/tags/<тег>
Заголовок = `<имя тега> — <дата> — <коммит>`. Коммит обязателен: тег
можно передвинуть или переименовать, а ревизия, на которой релиз собран,
должна оставаться в записи однозначно. Берётся именно `*objectname`
(дереференс аннотированного тега), а не `objectname` — последний вернёт
хеш самого объекта тега, а не коммита.
В запись идёт только то, что видно ИГРОКУ или меняет сборку/запуск, — по
одному пункту «симптом -> причина одной фразой -> хеш коммита». Полный
разбор живёт в сообщении коммита, дублировать его сюда не надо.
Индекс документации с отметками актуальности — **`docs/README.md`**,
начинать чтение оттуда.
@@ -69,6 +88,8 @@ make собрать build/sprpop.exe и разложить build
make hdd + образ build/hdd/sprpop.chd
make mame-link однократно: подставить образ в MAME (см. ниже)
make -C tests/host модульные тесты движка под ucsim_z80 (секунды, без MAME)
make test-tools тесты упаковщиков на хосте (форматы, которые читает Z80)
make fetch скачать внешние данные в assets/orig/ (SDLPoP + музыка)
make resources перегенерировать ресурсы из assets/orig/
make music-mp3 музыка из другого набора (flac|mp3|ogg|mt32)
make clean снести build/ (ассеты не трогает)
@@ -93,13 +114,19 @@ make distclean clean + снести assets/packed/ (вернуть — mak
|---|---|
| `src/` | рукописный C: главный цикл `sprpop.c`, холодная половина `sprpop_cold.c`, движок `pop_*.c` |
| `gen/` | генерируемые заголовки (индексы архивов, таблицы кадров, шрифт, палитры). **Руками не править** — их печатают упаковщики; лежат в репозитории, потому что без них `src/` не собрать |
| `assets/orig/` | оригинальные данные (SDLPoP, MSDOS, записи музыки). Вне репозитория — копирайт; что и откуда взять, написано в `assets/orig/README.md` |
| `assets/orig/` | оригинальные данные (SDLPoP, записи музыки; MSDOS — необязательно). Вне репозитория — копирайт; вместо них в репозитории лежит `tools/fetch_orig.py`, который их качает (`make fetch`). Правила — в `assets/orig/README.md` |
| `assets/packed/` | то, что ложится на диск игры, уже в раскладке диска (`BG/`, `KID/`, …). В репозитории — иначе из чистого клона не собрать |
| `tools/` | конверторы ресурсов. Раскладку путей знает ОДИН файл — `tools/paths.py`; менять пути нужно там, а не в отдельных упаковщиках |
| `tools/` | конверторы ресурсов. Раскладку путей знает ОДИН файл — `tools/paths.py`; менять пути нужно там, а не в отдельных упаковщиках. Адреса ВНЕШНИХ источников — так же в одном: `tools/fetch_orig.py` |
| `docs/` | планы, доски, справочники; `docs/PoP/` — форматы ресурсов оригинала |
| `tests/host/` | модульные тесты движка под ucsim_z80 |
| `build/` | выход: `sprpop.exe`, каталоги ресурсов, `hdd/`, промежуточные `atl/` |
Звуковые ЭФФЕКТЫ по умолчанию берутся из SDLPoP (`SND_SRC=sdlpop`) — сборка
обязана работать без оригинального дистрибутива DOS. У кого лежит
`assets/orig/MSDOS/`, включает его набор явно (`make SND_SRC=msdos`): там
оцифровка полнее — в SDLPoP пуст звук 48 `spiked` (насаживание на пики).
Разбор — `docs/sound_plan.md`.
Музыка собирается из одного из четырёх наборов записей
(`assets/orig/PoP1_DOS_music/`): `flac` по умолчанию, плюс `mp3`, `ogg` и
`mt32` (исполнение Roland MT-32 — звучит иначе, длина треков другая). Набор
@@ -124,9 +151,15 @@ Makefile склеивает архивы прямо в `assets/packed/<КАТА
(memory `mame_autotest`, `mame_mcp_bridge`, `mame_hdd_test_disk`).
Пересобрал образ → MAME ОБЯЗАН полный рестарт (`mame_hdd_rebuild_restart`).
**Игра лежит на образе в `D:\GAMES\SPRPOP\`**, а НЕ в корне диска (так с
обобщения HDD-сборки, коммит ea8efdb — один образ рассчитан на несколько
приложений). `dir D:\` показывает только каталог `GAMES`; запуск из корня
отвечает `Bad command or file name`.
**Тайминги моста** (не ждать дольше, см. `docs/mame-autotest.md` §10):
старт `run_bridge.sh` → 6 с`go` → 8 с`keyseq d:{ENTER}` +
`keyseq sprpop{ENTER}` → 5 с программа работает.
`keyseq cd games\sprpop{ENTER}` + `keyseq sprpop{ENTER}` → 5 с
программа работает.
Отладочные тумблеры в живой сессии (`src/sprpop.c`): **1** — заморозить
кадр, **2** — продолжить (разбор позы/окклюзии); **ESC** — выход. Читы
@@ -153,6 +186,9 @@ Makefile склеивает архивы прямо в `assets/packed/<КАТА
| `pop_kid.c/.h` | Анимация/движение: интерпретатор seqtbl `play_seq` + frame_table (`seg006`), окна `Char` (loadkid/loadshad), атласы Кида. |
| `pop_cdraw.c/.h` | ОТРИСОВКА персонажей — одна на всех Char (порт `add_kid_to_objtable`/`add_guard_to_objtable`): спрайт, clip_char, брызги, клинок, heal; полоса HP и палитры соперника. Там же ПРОПУСК неизменившегося кадра (`pop_char_skip_mask`). |
| `pop_ctrl.c/.h` | Управление: диспетчер `control()` (`seg005`) + ввод `read_user_control` (`seg006`) поверх `<kbd_raw.h>`. |
| `pop_sfx.c` + `pop_sfx_cold.c` | Звук: насос CBL в резиденте + холодная загрузка набора. Раскладка эффектов НЕ компилируется в EXE — читается с диска (`SND/snd.idx`, формат `PSI1`); см. `docs/sound_plan.md` §10. |
| `pop_music.c` | Музыка (банк 9): загрузка треков, кольцо для финальной темы. Длины и длительности — из `MUS/mus.idx` (EMM-страница + accessor `pop_music_info`), в EXE их нет; см. `docs/sound_plan.md` §11. |
| `pop_snd_tbl.h` + `pop_snd_data.c` | Тип записи набора и инварианты (рукописный заголовок) + резидентные `pop_snd_tbl`/`pop_snd_page`/`pop_snd_pages`. Размер записи 5 байт — часть дискового контракта, проверяется статически. |
| `pop_map.c/.h` | Коллизия с картой + физика падения/приземления/стены + loose-полы (`seg005/006/007`). |
## Банки: две мины, которых компилятор не видит
+122 -23
View File
@@ -47,7 +47,7 @@ MEMORY ?= huge
#
# ВАЖНО: сравнивать занятость банков можно только при ОДНОМ ALLOCS — иначе
# сравниваются не правки, а уровни оптимизации.
ALLOCS ?= 6000
ALLOCS ?= 10000
# Build info не хранится в C: ID и дата извлекаются из текущего commit.
BUILD_ID ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo dev)
BUILD_DATE ?= $(shell git log -1 --format=%cs 2>/dev/null || echo unknown)
@@ -91,7 +91,7 @@ BANK9_SRCS := pop_menu.c pop_status.c pop_music.c pop_app.c pop_timer.c \
# стоит 51 мс, так что трамплин банк→банк на их фоне не существует.
# Отдельный банк для этого не нужен: в десятом лежал один pop_pal.c на
# 485 байт, то есть 3% страницы.
BANK10_SRCS := pop_pal.c pop_config.c pop_hof.c
BANK10_SRCS := pop_pal.c pop_config.c pop_hof.c pop_path_bank.c
BANK11_SRCS := pop_cutscene.c pop_intro.c
BANK_NUMS := 1 2 3 4 5 6 7 8 9 10 11
BANKED_SRCS := $(foreach n,$(BANK_NUMS),$(BANK$(n)_SRCS))
@@ -100,6 +100,7 @@ BANK_FLAGS := $(foreach n,$(BANK_NUMS),\
# Модули резидента (кроме sprpop.c — его подставляет app.mk).
EXTRA_SRCS := $(addprefix $(SRC_DIR)/,pop_vflip.c pop_pace.c pop_sfx.c pop_state.c \
pop_snd_data.c \
pop_draw.c pop_tile.c pop_kid.c pop_level.c pop_geom.c pop_guard.c \
pop_qsave_io.c)
@@ -166,7 +167,10 @@ SHADOW_ATL := $(foreach n,$(KID_N),$(ATL)/shadow/sk$(n).atl) \
# Звук: оригинальная оцифровка на частоте CBL (10 937,5 Гц, 8 бит моно).
# Число файлов задаёт упаковщик (POP_SND_PAGES в gen/pop_sound_tbl.h) —
# список ОБЯЗАН совпадать: недостающий файл это молча пропавшие эффекты.
SND_ATL := $(foreach n,0 1 2 3 4 5 6 7 8 9,$(ATL)/sound/s$(n).bin)
# Страниц звука СТАТИЧЕСКИ НЕ ПОСЧИТАТЬ: их число зависит от источника
# (SDLPoP даёт 9, MSDOS — 10, см. SND_SRC). Поэтому списка s0..sN здесь нет,
# а архив зависит от stamp'а упаковщика и берёт страницы из каталога уже
# после того, как тот отработал (см. правило SND/snd.arc ниже).
TITLE_ATL := $(foreach s,0 1 2 3,$(foreach p,0 1 2 3 4,$(ATL)/title/t$(s)_$(p).atl))
# PV/INTRO: story и стадии режутся на полосы; у настоящего PV intro есть ещё
# чистый фон b0_* и шесть <=16K actor-атласов — они остаются в EMM на время
@@ -189,6 +193,15 @@ MUS_IDS := 24 25 26 27 28 29 30 32 33 35 36 37 39 40 41 43 50 52 53 54 55 56
# Формат входит в имя stamp'а, поэтому смена MUSIC_FMT сама по себе делает
# музыку устаревшей — принудительная пересборка не нужна.
MUSIC_FMT ?= flac
# Источник оцифрованных ЭФФЕКТОВ (digisnd1..3): sdlpop (умолчание) или msdos.
# Умолчание — SDLPoP, потому что он выкачивается автоматически (`make fetch`)
# и сборка обязана работать вообще без оригинального дистрибутива DOS.
# Наборы НЕ одинаковы: в MSDOS оцифровка полнее (в SDLPoP `spiked` (48)
# фактически пустой, `sword_vs_sword` (10) короче — разбор в
# docs/sound_plan.md). У кого дистрибутив есть — `make SND_SRC=msdos`.
# Источник входит в имя stamp'а, поэтому смена SND_SRC сама делает звук
# устаревшим, как и смена MUSIC_FMT.
SND_SRC ?= sdlpop
# res2000 — attract-demo; res2001..res2014 — штатная игра. res2015 (copy
# protection) намеренно не пакуется и не существует в маршруте загрузчика.
LVL_NUMS := 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
@@ -209,8 +222,8 @@ DISK := README_E.TXT README_R.TXT \
BG/bg.arc BG/pop_tile.pal BG/pal_tile.pal \
KID/kid.arc KID/kid.pal KID/kid.ani \
GUARD/guard.arc SKEL/skel.arc VIZIER/vizier.arc SHADOW/shadow.arc \
SND/snd.arc \
$(foreach i,$(MUS_IDS),MUS/m$(i).bin) \
SND/snd.arc SND/snd.idx \
$(foreach i,$(MUS_IDS),MUS/m$(i).bin) MUS/mus.idx \
FONT/font.atl \
TITLE/title.arc TITLE/title.pal \
PV/pv.arc PV/story.pal PV/pv.pal \
@@ -220,16 +233,22 @@ DISK := README_E.TXT README_R.TXT \
# список — это и зависимость exe, и то, что умеет чинить себя само.
GEN_ARC_H := bg_arc.h kid_arc.h guard_arc.h skel_arc.h vizier_arc.h \
shadow_arc.h title_arc.h pv_arc.h
GEN_H := $(addprefix gen/,$(GEN_ARC_H) pop_bg_atlas.h kid_atlas.h kid_data.h \
pop_guard_pal.h pop_shadow_atlas.h pop_sound_tbl.h \
pop_music_tbl.h pop_music_ticks.h pop_font.h)
# pop_sound_tbl.h здесь БОЛЬШЕ НЕТ: раскладка эффектов уехала на диск
# (SND/snd.idx), и EXE от звукового набора не зависит — пару
# snd.idx + snd.arc можно менять без перекомпиляции.
# Ни звукового, ни музыкальных заголовков здесь БОЛЬШЕ НЕТ: раскладка
# эффектов и длины треков уехали на диск (SND/snd.idx, MUS/mus.idx), и EXE
# не зависит ни от звукового набора, ни от набора записей.
GEN_H := $(addprefix gen/,$(GEN_ARC_H) pop_bg_atlas.h kid_data.h \
pop_guard_pal.h pop_shadow_atlas.h pop_font.h)
PACKED_FILES := $(addprefix $(PACKED)/,$(DISK))
STAGED := $(addprefix $(BUILD_DIR)/,$(DISK))
EXTRA_DATA := $(STAGED)
# Запись вида КАТАЛОГ:файл кладёт файл в подкаталог образа, голый путь — в
# корень. Проверка на слэш ОБЯЗАТЕЛЬНА: без неё `README_E.TXT` превращался
# Запись вида КАТАЛОГ:файл кладёт файл в подкаталог HDD_DEST_DIR, голый путь —
# непосредственно в HDD_DEST_DIR. Проверка на слэш ОБЯЗАТЕЛЬНА: без неё
# `README_E.TXT` превращался
# в `README_E.TXT:build/README_E.TXT`, то есть упаковщик заводил каталог с
# именем файла и клал файл внутрь него (поймано пользователем на готовом
# образе).
@@ -237,6 +256,9 @@ HDD_PACK_ARGS := $(BUILD_DIR)/$(EXAMPLE).exe \
$(foreach f,$(DISK),$(if $(findstring /,$(f)),\
$(word 1,$(subst /, ,$(f))):$(BUILD_DIR)/$(f),\
$(BUILD_DIR)/$(f)))
# Всё дерево SprPoP лежит рядом с EXE, но не меняет дефолт глобального
# упаковщика: остальные приложения по-прежнему попадают в корень HDD.
HDD_DEST_DIR := games/sprpop
# Образ живёт в build/, а не в mame/v306/IMG — приложение автономно. Связь с
# MAME — одна символьная ссылка, ставится однократно: make mame-link.
HDD_IMG := $(CURDIR)/$(BUILD_DIR)/hdd/$(EXAMPLE).chd
@@ -339,6 +361,28 @@ $(STAMP)/$(1): $(2) | $(STAMP) check-orig
@touch $$@
endef
# ВАРИАНТЫ РЕСУРСА (источник звука SND_SRC, набор записей MUSIC_FMT). Оба
# варианта пишут в ОДИН выход — build/atl/sound, build/atl/music, — поэтому
# одного stamp'а на вариант мало: вернувшись к прежнему варианту, make видит
# его stamp свежим и не делает НИЧЕГО, а в выходе лежит чужое (поймано на
# sdlpop -> msdos -> sdlpop: заголовок остался с 10 страницами вместо 9).
# Лечится файлом с именем текущего варианта: он переписывается только при
# смене (cmp), а его свежесть делает stamp устаревшим.
.PHONY: variant-check
variant-check: ;
# СМЕНА ВАРИАНТА СНОСИТ STAMP'Ы СВОЕГО СЕМЕЙСТВА, а не полагается на то,
# что make заметит свежесть variant-файла. Причина — секундная
# гранулярность mtime: упаковка укладывается в одну секунду с записью
# variant, и stamp того же имени считается актуальным. Возврат к прежнему
# варианту (`sdlpop -> msdos -> sdlpop`) тогда ТИХО оставлял чужой набор.
# Нет файла — правило обязано выполниться, и время тут ни при чём.
define variant_rule
$(STAMP)/$(1).variant: variant-check | $(STAMP)
@echo "$(2)" | cmp -s - $$@ || { echo "$(2)" > $$@; rm -f $(STAMP)/$(1)-*; }
endef
$(eval $(call variant_rule,sound,$(SND_SRC)))
$(eval $(call variant_rule,music,$(MUSIC_FMT)))
$(eval $(call stamp_rule,bg,$(TOOLS)/pop_pack_bg.py $(TOOLS)/render_room.py $(TOOLS)/paths.py,pop_pack_bg.py dungeon && $(PY) pop_pack_bg.py palace))
$(eval $(call stamp_rule,kid,$(TOOLS)/pop_pack_kid.py $(TOOLS)/pop_pack_bg.py $(TOOLS)/paths.py,pop_pack_kid.py))
$(eval $(call stamp_rule,kid-bin,$(TOOLS)/pop_extract_kid_data.py $(TOOLS)/paths.py,pop_extract_kid_data.py))
@@ -346,8 +390,25 @@ $(eval $(call stamp_rule,guard,$(TOOLS)/pop_pack_guard.py $(TOOLS)/paths.py,pop_
$(eval $(call stamp_rule,skel,$(TOOLS)/pop_pack_guard.py $(TOOLS)/paths.py,pop_pack_guard.py SKEL))
$(eval $(call stamp_rule,vizier,$(TOOLS)/pop_pack_guard.py $(TOOLS)/paths.py,pop_pack_guard.py VIZIER))
$(eval $(call stamp_rule,shadow,$(TOOLS)/pop_pack_shadow.py $(TOOLS)/pop_pack_bg.py $(TOOLS)/pop_pack_kid.py $(TOOLS)/paths.py,pop_pack_shadow.py))
$(eval $(call stamp_rule,sound,$(TOOLS)/pop_pack_sound.py $(TOOLS)/paths.py,pop_pack_sound.py))
$(eval $(call stamp_rule,music-$(MUSIC_FMT),$(TOOLS)/pop_pack_music.py $(TOOLS)/paths.py,pop_pack_music.py --format $(MUSIC_FMT) $(MUS_IDS)))
# ЗВУК — ОСОБЫЙ СЛУЧАЙ: упаковка, сборка архива и копия индекса делаются
# ОДНИМ рецептом, а не тремя правилами через таймстемпы.
#
# Почему. Упаковщик и копирование укладываются в одну секунду, а make
# сравнивает mtime с секундной гранулярностью: цель, сделанная в ту же
# секунду, что и её зависимость, считается актуальной. При смене SND_SRC
# это давало ТИХО НЕВЕРНЫЙ результат — `sdlpop -> msdos -> sdlpop`
# перепаковывал build/atl, но в assets/packed оставался прежний набор
# (10 страниц вместо 9; поймано 2026-08-31, с паузой в секунду не
# воспроизводится). Внутри одного рецепта сравнивать нечего.
$(STAMP)/sound-$(SND_SRC): $(TOOLS)/pop_pack_sound.py $(TOOLS)/pop_idx.py \
$(TOOLS)/paths.py $(TOOLS)/pop_pack_arc.py \
$(STAMP)/sound.variant | $(STAMP) check-orig
cd $(TOOLS) && $(PY) pop_pack_sound.py --source $(SND_SRC)
@mkdir -p $(PACKED)/SND
$(ARC) $(PACKED)/SND/snd.arc $$(ls $(ATL)/sound/s*.bin | sort -V)
@cp $(ATL)/sound/snd.idx $(PACKED)/SND/snd.idx
@touch $@
$(eval $(call stamp_rule,music-$(MUSIC_FMT),$(TOOLS)/pop_pack_music.py $(TOOLS)/pop_idx.py $(TOOLS)/paths.py $(STAMP)/music.variant,pop_pack_music.py --format $(MUSIC_FMT) $(MUS_IDS)))
$(eval $(call stamp_rule,font,$(TOOLS)/pop_extract_font.py $(TOOLS)/paths.py,pop_extract_font.py))
$(eval $(call stamp_rule,title,$(TOOLS)/pop_pack_title.py $(TOOLS)/paths.py,pop_pack_title.py))
$(eval $(call stamp_rule,pv,$(TOOLS)/pop_pack_intro.py $(TOOLS)/paths.py,pop_pack_intro.py))
@@ -361,14 +422,13 @@ $(1): $(STAMP)/$(2)
@test -f $$@ || { $(MAKE) GENERATE=1 -B $(STAMP)/$(2); test -f $$@; }
endef
$(eval $(call from_stamp,$(BG_ATL) $(ATL)/bg/pop_tile.pal $(ATL)/bg/pal_tile.pal gen/pop_bg_atlas.h,bg))
$(eval $(call from_stamp,$(KID_ATL) $(ATL)/kid/kid.pal gen/kid_atlas.h,kid))
$(eval $(call from_stamp,$(KID_ATL) $(ATL)/kid/kid.pal,kid))
$(eval $(call from_stamp,$(ATL)/kid/kid.ani gen/kid_data.h,kid-bin))
$(eval $(call from_stamp,$(GUARD_ATL) gen/pop_guard_pal.h,guard))
$(eval $(call from_stamp,$(SKEL_ATL),skel))
$(eval $(call from_stamp,$(VIZIER_ATL),vizier))
$(eval $(call from_stamp,$(SHADOW_ATL) gen/pop_shadow_atlas.h,shadow))
$(eval $(call from_stamp,$(SND_ATL) gen/pop_sound_tbl.h,sound))
$(eval $(call from_stamp,$(foreach i,$(MUS_IDS),$(ATL)/music/m$(i).bin) gen/pop_music_tbl.h gen/pop_music_ticks.h,music-$(MUSIC_FMT)))
$(eval $(call from_stamp,$(foreach i,$(MUS_IDS),$(ATL)/music/m$(i).bin) $(ATL)/music/mus.idx,music-$(MUSIC_FMT)))
$(eval $(call from_stamp,$(ATL)/font/font.atl gen/pop_font.h,font))
$(eval $(call from_stamp,$(TITLE_ATL) $(ATL)/title/title.pal,title))
$(eval $(call from_stamp,$(PV_ATL) $(ATL)/pv/story.pal $(ATL)/pv/pv.pal,pv))
@@ -392,9 +452,15 @@ $(eval $(call arc_rule,TITLE,title,title,$(TITLE_ATL),TITLE))
$(eval $(call arc_rule,PV,pv,pv,$(PV_ATL),PV))
# У звука своего *_arc.h нет: содержимое адресуется таблицей pop_sound_tbl.h,
# которую печатает сам упаковщик звука.
$(PACKED)/SND/snd.arc: $(SND_ATL) $(TOOLS)/pop_pack_arc.py
@mkdir -p $(dir $@)
$(ARC) $@ $(SND_ATL)
# Зависимость на stamp, а не на список страниц: сколько их будет, известно
# только после запуска упаковщика (источник задаёт число). Порядок страниц
# в архиве обязан быть ЧИСЛОВЫМ — pop_snd_tbl адресует их индексом, а голый
# ls даёт s10 перед s2. sort -V, а не `-t s -k n`: буква s есть и в пути.
# Пара snd.arc + snd.idx согласована по построению: её делает ОДИН рецепт
# stamp'а выше (см. там же, почему не раздельными правилами). Здесь
# только восстановление, если файл снесли руками.
$(PACKED)/SND/snd.arc $(PACKED)/SND/snd.idx: $(STAMP)/sound-$(SND_SRC)
@test -f $@ || { $(MAKE) GENERATE=1 -B $(STAMP)/sound-$(SND_SRC); test -f $@; }
# Файлы, которые ложатся на диск как есть, — простое копирование из staging.
define copy_rule
@@ -411,10 +477,12 @@ $(eval $(call copy_rule,TITLE/title.pal,$(ATL)/title/title.pal))
$(eval $(call copy_rule,PV/story.pal,$(ATL)/pv/story.pal))
$(eval $(call copy_rule,PV/pv.pal,$(ATL)/pv/pv.pal))
$(foreach i,$(MUS_IDS),$(eval $(call copy_rule,MUS/m$(i).bin,$(ATL)/music/m$(i).bin)))
$(eval $(call copy_rule,MUS/mus.idx,$(ATL)/music/mus.idx))
# Уровни идут на диск ПРЯМО из оригинала: формат res20xx.bin наш загрузчик
# читает как есть, упаковщика для них нет. check-orig здесь тоже order-only —
# без оригиналов взять уровни неоткуда, и сказать об этом надо внятно.
LVL_ORIG := assets/orig/SDLPoP/data/LEVELS
SND_ORIG_MSDOS := assets/orig/MSDOS
define lvl_rule
$(PACKED)/LEVELS/res20$(1).bin: $(LVL_ORIG)/res20$(1).bin | check-orig
@mkdir -p $$(dir $$@)
@@ -443,8 +511,8 @@ resources-bg: $(PACKED)/BG/bg.arc $(PACKED)/BG/pop_tile.pal $(PACKED)/BG/pal
resources-kid: $(PACKED)/KID/kid.arc $(PACKED)/KID/kid.pal $(PACKED)/KID/kid.ani
resources-actors: $(PACKED)/GUARD/guard.arc $(PACKED)/SKEL/skel.arc \
$(PACKED)/VIZIER/vizier.arc $(PACKED)/SHADOW/shadow.arc
resources-sound: $(PACKED)/SND/snd.arc
resources-music: $(foreach i,$(MUS_IDS),$(PACKED)/MUS/m$(i).bin)
resources-sound: $(PACKED)/SND/snd.arc $(PACKED)/SND/snd.idx
resources-music: $(foreach i,$(MUS_IDS),$(PACKED)/MUS/m$(i).bin) $(PACKED)/MUS/mus.idx
resources-font: $(PACKED)/FONT/font.atl
resources-title: $(PACKED)/TITLE/title.arc $(PACKED)/TITLE/title.pal
resources-pv: $(PACKED)/PV/pv.arc $(PACKED)/PV/story.pal $(PACKED)/PV/pv.pal
@@ -455,8 +523,12 @@ resources: resources-bg resources-kid resources-actors resources-sound \
check-orig:
@test -d $(LVL_ORIG) || { \
echo "SprPoP: нет assets/orig/ — генерировать ресурсы не из чего." >&2; \
echo " Что и откуда положить, написано в assets/orig/README.md" >&2; false; }
echo "SprPoP: нет assets/orig/SDLPoP — генерировать ресурсы не из чего." >&2; \
echo " Скачать: make fetch (подробности — assets/orig/README.md)" >&2; false; }
@test "$(SND_SRC)" != msdos || test -d $(SND_ORIG_MSDOS) || { \
echo "SprPoP: SND_SRC=msdos, но нет $(SND_ORIG_MSDOS)." >&2; \
echo " Дистрибутив DOS не качается — положить руками, либо собирать" >&2; \
echo " со звуком из SDLPoP: make SND_SRC=sdlpop" >&2; false; }
else
# ===========================================================================
@@ -506,6 +578,33 @@ $(MUSIC_TARGETS):
@$(MAKE) MUSIC_FMT=$(patsubst music-%,%,$@) resources-music
@$(MAKE) MUSIC_FMT=$(patsubst music-%,%,$@) stage
# ---------------------------------------------------------------------------
# Внешние исходные данные (assets/orig/)
# ---------------------------------------------------------------------------
# В репозитории чужих данных нет — есть tools/fetch_orig.py, который знает,
# откуда их взять, и таблица источников внутри него. Качаем только то,
# чего нет; `FETCH_ARGS=--force` перекачивает заново, `FETCH_ARGS=--ref v1.23`
# берёт конкретный тег SDLPoP.
# make fetch SDLPoP + записи музыки
# make fetch-sdlpop только SDLPoP (data/ + src/-эталон)
# make fetch-music только записи саундтрека (43 МБ)
# make fetch-check что уже на месте
# make fetch-list таблица источников
# MSDOS/ не качается — это копирайтный дистрибутив; он НЕОБЯЗАТЕЛЕН, всё
# нужное (уровни, оцифровка) есть в SDLPoP. См. SND_SRC выше.
FETCH := $(PY) $(TOOLS)/fetch_orig.py
fetch: ; $(FETCH) all $(FETCH_ARGS)
fetch-sdlpop: ; $(FETCH) sdlpop $(FETCH_ARGS)
fetch-music: ; $(FETCH) music $(FETCH_ARGS)
fetch-check: ; @$(FETCH) --check
fetch-list: ; @$(FETCH) --list
FETCH_TARGETS := fetch fetch-sdlpop fetch-music fetch-check fetch-list
# Тесты инструментов (хост, питон): форматы, которые читает Z80. Секунды,
# ни эмулятора, ни оригиналов не нужно.
test-tools:
@cd $(CURDIR) && $(PY) -m unittest discover -s tools/tests
# Перелинковка приложения без пересборки библиотек и ресурсов.
relink:
$(MAKE) -W Makefile $(EXE)
@@ -525,4 +624,4 @@ distclean: clean
.PHONY: stage check-orig resources resources-rebuild resources-bg resources-kid \
resources-actors resources-sound resources-music resources-font \
resources-title resources-pv resources-levels $(MUSIC_TARGETS) \
relink distclean mame-link
$(FETCH_TARGETS) test-tools relink distclean mame-link
+19 -2
View File
@@ -13,12 +13,19 @@ make # build/sprpop.exe + ресурсы
make hdd # + образ build/hdd/sprpop.chd
```
Чужих данных в репозитории нет — есть адреса, откуда их взять. Чтобы
перегенерировать ресурсы (или получить эталон механики), сначала:
```sh
make fetch # SDLPoP + записи саундтрека DOS-версии в assets/orig/
```
## Что где
```
src/ рукописный C: главный цикл sprpop.c + движок pop_*.c
gen/ генерируемые заголовки — руками не править
assets/orig/ оригинальные данные игры (вне репозитория, см. там README)
assets/orig/ оригинальные данные игры (вне репозитория, качает `make fetch`)
assets/packed/ то, что ложится на диск игры, в раскладке диска
tools/ конверторы ресурсов; все пути — в tools/paths.py
docs/ планы, доски задач и багов, справочники (начинать с docs/README.md)
@@ -33,9 +40,12 @@ build/ выход: sprpop.exe, каталоги ресурсов, hdd/,
| `make` | собрать `build/sprpop.exe` и разложить ресурсы в `build/` |
| `make hdd` | + образ жёсткого диска `build/hdd/sprpop.chd` |
| `make mame-link` | однократно подставить образ в MAME (символьная ссылка на `IMG/test_hdd.chd`) |
| `make fetch` | скачать внешние исходные данные в `assets/orig/`: `fetch-sdlpop`, `fetch-music`, `fetch-check`, `fetch-list` |
| `make resources` | перегенерировать ресурсы из `assets/orig/` |
| `make music-mp3` | музыка из другого набора записей: `music-flac` (умолчание), `music-mp3`, `music-ogg`, `music-mt32` |
| `make SND_SRC=msdos` | оцифровка эффектов из дистрибутива DOS вместо SDLPoP (нужен свой `assets/orig/MSDOS/`) |
| `make -C tests/host` | модульные тесты движка (секунды, без эмулятора) |
| `make test-tools` | тесты упаковщиков на хосте: форматы файлов, которые читает Z80 |
| `make clean` | снести `build/` (ассеты не трогает) |
| `make distclean` | `clean` + снести упакованные ассеты `assets/packed/` |
@@ -54,7 +64,14 @@ build/ выход: sprpop.exe, каталоги ресурсов, hdd/,
`assets/orig/` нужен только чтобы ресурсы **перегенерировать** (`make
resources`) и как **эталон механики**: любая правка поведения сверяется с
`assets/orig/SDLPoP/src/` до кодинга, см. `CLAUDE.md`.
`assets/orig/SDLPoP/src/` до кодинга, см. `CLAUDE.md`. Заполняется он одной
командой `make fetch` — качается SDLPoP (ресурсы + исходники-эталон) и записи
саундтрека; адрес архива музыки скрипт берёт из самого SDLPoP.
Оригинальный дистрибутив DOS (`assets/orig/MSDOS/`) **не нужен**: и уровни, и
оцифровка звука берутся из SDLPoP. Он даёт ровно одно — более полный набор
эффектов (в SDLPoP пуст звук `spiked`, 48); у кого дистрибутив есть, включает
его явно: `make SND_SRC=msdos`. Подробности — `assets/orig/README.md`.
## Происхождение
+82
View File
@@ -0,0 +1,82 @@
# assets/orig — оригинальные данные
Здесь лежат **исходные** данные Prince of Persia, из которых `make resources`
делает ресурсы Sprinter-версии. В репозиторий этот каталог не идёт —
материал копирайтный (см. `.gitignore`). Вместо данных в репозитории лежит
**знание, откуда они берутся**: `tools/fetch_orig.py` и таблица источников
внутри него.
## Как заполнить
```
make fetch # SDLPoP + записи саундтрека (всё, что качается)
make fetch-check # что уже на месте
make fetch-list # таблица источников с адресами
```
Тонкости:
```
make fetch-sdlpop только SDLPoP
make fetch-music только музыка (43 МБ)
make FETCH_ARGS=--force fetch перекачать заново
make FETCH_ARGS="--ref v1.23" fetch-sdlpop конкретный тег SDLPoP
```
Скачанные архивы кэшируются в `assets/orig/.cache/` — повторный `make fetch`
сеть не трогает. Версия SDLPoP записывается в `assets/orig/SDLPoP/.fetch.json`
(ветка/тег, sha, дата), и следующий `make fetch` по ней понимает, последняя
у нас версия или нет.
Собрать игру без `assets/orig/` **можно**: упакованные ресурсы лежат в
`../packed/`, а генерируемые заголовки — в `../../gen/`. `assets/orig` нужен
только чтобы ресурсы **перегенерировать**.
Исключение — `LEVELS`: файлы `res20xx.bin` ложатся на диск игры как есть, без
конвертации, поэтому `assets/packed/LEVELS/` тоже вне репозитория и заполняется
из `orig` командой `make resources-levels`.
## Что и откуда
| Каталог | Что это | Где взять |
|---|---|---|
| `SDLPoP/data/` | Распакованные ресурсы DOS-версии: PNG-спрайты (`VDUNGEON`, `VPALACE`, `KID`, `GUARD`, `SKEL`, `VIZIER`, `SHADOW`, `TITLE`, `PV`, `PRINCE`), палитры, `LEVELS/res20xx.bin`, оцифровка `DIGISND*.DAT` | `make fetch-sdlpop` — <https://github.com/NagyD/SDLPoP> (GPLv3) |
| `SDLPoP/src/` | Исходники SDLPoP. Нужны не только упаковщикам (`menu.c` и `seg009.c` содержат встроенные шрифты, из `seg005/seg006` берутся таблицы кадров и `seqtbl`), но и как **эталон механики**: расхождение в поведении всегда сверяется с ними | тем же `make fetch-sdlpop` |
| `PoP1_DOS_music/` | Записи музыки DOS-версии в четырёх наборах: `flac/` (по умолчанию), `mp3/`, `ogg/`, `ogg_MT-32/` (исполнение Roland MT-32). Имена треков во всех наборах одинаковые; какой брать — задаёт `MUSIC_FMT` | `make fetch-music`. Адрес архива скрипт читает из самого SDLPoP (`ReadMe.amigaos4`, секция «AUDIO IS SLOW/AWFULL»), запасной — popot.org |
| `MSDOS/` | **Необязательно.** Оригинальная поставка PoP 1.0 для MS-DOS. Используется только `digisnd*.dat` со звуком, и только если попросить: `make SND_SRC=msdos` | Не качается — дистрибутив игры, положить руками |
## MSDOS: зачем он и почему без него живётся
По умолчанию **всё** берётся из SDLPoP — и уровни, и оцифровка звука. Так
сборка работает на чистой машине сразу после `make fetch`.
`MSDOS/` даёт ровно одно: более полный набор оцифрованных эффектов. Из 31
звука 28 совпадают побайтно, а три — нет, и все три лучше в дистрибутиве:
`spiked` (48, насаживание на пики) в наборе SDLPoP длиной 7 сэмплов, то есть
его нет; `sword_vs_sword` (10) короче; `sword_moving` (11) — другая запись.
Разбор — `docs/sound_plan.md`. Включается это явно:
```
make SND_SRC=msdos resources-sound # перепаковать звук из дистрибутива
make SND_SRC=msdos # и собрать с ним
```
Источник входит в имя stamp'а, поэтому переключение `SND_SRC` само делает
звук устаревшим — `-B` не нужен.
## Кто что читает
| Упаковщик | Читает |
|---|---|
| `pop_pack_bg.py`, `render_room.py` | `SDLPoP/data/{VDUNGEON,VPALACE,LEVELS}` |
| `pop_pack_kid.py` | `SDLPoP/data/KID` |
| `pop_pack_guard.py` | `SDLPoP/data/{GUARD,SKEL,VIZIER,PRINCE}` |
| `pop_pack_shadow.py` | `SDLPoP/data/{KID,SHADOW}` |
| `pop_pack_title.py`, `pop_pack_intro.py` | `SDLPoP/data/{TITLE,PV,PRINCE,KID}` |
| `pop_extract_font.py` | `SDLPoP/src/{menu.c,seg009.c}` |
| `pop_extract_kid_data.py` | `SDLPoP/src/` (таблицы кадров и `seqtbl`) |
| `pop_pack_sound.py` | `SDLPoP/data/DIGISND*.DAT` (`--source sdlpop`, умолчание) либо `MSDOS/digisnd*.dat` (`--source msdos`) |
| `pop_pack_music.py` | `PoP1_DOS_music/<набор>/` (`--format flac\|mp3\|ogg\|mt32`) |
Точная раскладка путей — в `tools/paths.py`; менять её нужно там, а не в
отдельных упаковщиках. Адреса внешних источников — в `tools/fetch_orig.py`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
+191 -4
View File
@@ -35,9 +35,11 @@
| [KBD-STUCK-WAIT](#kbd-stuck-wait) | потерянный break-код клавиши вешает игру НАСМЕРТЬ в межуровневой заставке | клавиатура/клин | **корень найден и устранён** 2026-08-28 (байты крал KEYSCAN DSS); ждёт полевой проверки |
| [L10-BUTTON-DEBRIS](#l10-button-debris) | ур.10 к.1: плита упала на кнопку (1,8) — щебень есть, а решётки закрыты | кнопки/ворота | открыт: **не воспроизводится**, проверено многое (см. разбор) |
| [LOOSE-SEAM-ANIM](#loose-seam-anim) | плита в шве (col −1), уроненная в прошлой комнате, исчезает без анимации падения | **мелкий** | открыт по решению пользователя: «пока пусть будет так» |
| [SND-PACE-DEAD](#snd-pace-dead) | пейсинг сцен по насосу CBL не включается: признак «часы идут» вычисляется двумя чтениями подряд | **тайминг/звук** | открыт: диагноз полный, симптом обойдён подгонкой (2026-08-27) |
| [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | открыт: замер есть, лечение — удешевить кадр |
| [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-09-02 (звук 11 = промах, а не «любой укол») — главный подозреваемый снова ПОРОГ ДИСТАНЦИИ |
| [HP-BAR-RESTART](#hp-bar-restart) | после гибели и Ctrl+A на ОДНОЙ из двух страниц остаётся полоса HP по результатам боя | дабл-буфер/UI | КОРЕНЬ НАЙДЕН, фикс есть, ждёт проверки (2026-08-31) |
---
@@ -1206,7 +1208,15 @@ fade_in по построению пишет все 256 записей из сн
---
<a id="snd-pace-dead"></a>
## SND-PACE-DEAD. Пейсинг сцен по насосу CBL не включается
## SND-PACE-DEAD. Пейсинг сцен по насосу CBL не включается — СНЯТ 2026-08-28
> **Закрыт удалением самой ветки.** Сцена с Джафаром переведена на ЕДИНЫЕ
> часы — кадры луча (`intro_pv_animated`): вторая шкала по насосу и гонка,
> которая её выбирала, убраны целиком. Насос как часы был нужен потому,
> что кадр сцены рисовался дольше своего интервала; теперь отрисовка
> разложена по интервалам (`pv_restore_bg`), и счёт кадров честен.
> Побочный эффект гонки — «кода реплики каждый прогон в другом месте» —
> исчез; проверено пользователем. Ниже — исходный разбор.
**Замер (MAME, 2026-08-27).** В сцене `intro_pv_scene()` зонды показали
`snd_done == 0` на всём прогоне — то есть ветка `if (snd_pace)`, ради
@@ -1237,7 +1247,17 @@ snd_pace = (uint8_t)(pop_snd_tick != snd_prev); /* часы реально и
---
<a id="pv-render-bound"></a>
## PV-RENDER-BOUND. Сцена с принцессой не укладывается в свой бюджет кадра
## PV-RENDER-BOUND. Сцена с принцессой не укладывается в свой бюджет кадра — ИСПРАВЛЕНО 2026-08-28
> **Причина оказалась не в цене кадра, а в том, ГДЕ отсчитывался интервал.**
> Кадр рисовался целиком, и только потом ждали vsync и ещё четыре — то есть
> отрисовка ПРИБАВЛЯЛАСЬ к делителю. Теперь она разложена на блоки, каждый
> из которых влезает в кадровый интервал, и после каждого ждём vsync
> (`pv_restore_bg` + раскладка кадра в `cut_run` / `pre_room_animated` /
> `intro_pv_draw_frame`); фон восстанавливаем только по картинке, 200 строк
> вместо 256. Удешевлять сцену не понадобилось: отрисовка ~2 кадра при
> бюджете 5. Подгонка `PV_MAGIC_LEAD` снята (0) — проверено пользователем:
> кода попадает в нужный кадр. Ниже — исходный замер.
**Замер (MAME, 2026-08-27).** Реплика Джафара `m53` — 1403 порции насоса,
то есть 16,42 с реального времени. За это время шкала сцены прошла
@@ -1295,3 +1315,170 @@ 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`).
Набор звуков проверен и НЕ виноват: в `assets/packed/SND/snd.idx` слот 11
на месте и содержит собственный короткий сэмпл (1280 Б), слот 8 — другой
(1664 Б). Раскладка не сдвинута.
**ПОПРАВКА 2026-09-02 — прежнее толкование звука было ОШИБОЧНЫМ.**
Здесь стояло, будто оригинал играет звук 11 «сразу на кадре укола, ДО
проверки расстояния», то есть при ЛЮБОМ уколе, попал тот или нет. Это
описание НАШЕГО кода, а не оригинала: ровно так звук стоял у нас, внутри
ветки «не парировано» и до присвоения `Opp.action = 99_hurt`. В SDLPoP
(seg002:1039..1044) он стоит в ХВОСТЕ `check_hurting` и защищён условием
`Opp.action != actions_99_hurt`:
if (Char.direction == dir_56_none) return; // Fix looping "sword moving" sound.
if (Char.frame == frame_154_poking && Opp.frame != frame_161_parry &&
Opp.action != actions_99_hurt)
play_sound(sound_11_sword_moving);
То есть звук 11 в оригинале — это индикатор **ПРОМАХА**, а не укола: на
попадании его нет (играет боль), на парировании нет (`Opp.frame` уже 161).
Расхождение найдено и исправлено 2026-09-02 по симптому «непрерывный взмах
клинка у безоружного Кида на подъёме» (звук опознан в живой сессии по
`snd_curr == 11`; указатели насоса при этом сошлись с `snd.idx` байт в
байт, то есть тракт звука был исправен). Заодно приехал и пропущенный
ранний выход по `dir_56_none` — тот самый, что автор SDLPoP подписал
«Fix looping sword moving sound».
**Что из прежних выводов уцелело, а что нет.**
* УЦЕЛЕЛО: наблюдение «в оригинале слышен 11, у нас 8» — это данные, и они
остаются. Арифметика приоритетов их подкрепляет: `snd_prio[11] = 0x12`
(18) против `snd_prio[8] = 0x4B` (75), меньше значит важнее, поэтому
взмах не мог быть заглушён упором в стену. При ТОГДАШНЕМ коде звук 11
звучал на любом уколе, значит его отсутствие действительно означало, что
до кадра укола дело не дошло.
* ОТПАЛО: вывод «оригинал ведёт стража атакой с промахом, а мы —
столкновением» опирался на неверную посылку и больше не следует из звука
сам по себе.
* УСИЛИЛОСЬ: то, что в оригинале в этой сцене слышен ИМЕННО 11, теперь
доказывает, что укол у стража СОСТОЯЛСЯ и ПРОМАЗАЛ. А это ровно версия
про ПОРОГ ДИСТАНЦИИ (см. выше про пару пикселей и нижний порог 8), а не
про потерянную ветку. Прежняя «версия про столкновение» её вытеснила
зря — возвращаем дистанцию в главные подозреваемые.
**ВНИМАНИЕ тому, кто вернётся к этому багу: диагностика по звуку с
2026-09-02 ЗНАЧИТ ДРУГОЕ.** Старые заметки прогона будут вводить в
заблуждение — переснимать сцену заново. Новая таблица:
| слышно | что это значит |
|---|---|
| 11 (взмах) | укол состоялся и ПРОМАЗАЛ — поведение оригинала в этой сцене |
| 13 (боль Кида) | укол ПОПАЛ, `Opp.action = 99_hurt` |
| 8 (упор в стену) | укола не было вовсе, сработало столкновение |
| тишина | `check_hurting` вышел раньше: меч не вынут / ряды не совпали / кадр не 153-154 |
На саму механику урона правка 2026-09-02 НЕ влияет (тронут только звук),
поэтому расхождение «удар убивает» остаётся открытым как было.
**Решение пользователя:** отложено на будущее (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`), либо запоминать максимальную ширину
прошлой полосы и стирать по ней.
+3
View File
@@ -10,9 +10,11 @@
|----------|-------|
| [`TASKS_OPEN.md`](TASKS_OPEN.md) | **Что берётся в работу сейчас** — начинать отсюда |
| [`BUGS_OPEN.md`](BUGS_OPEN.md) | Открытые баги и незакрытые оптимизации |
| [`../CHANGELOG.md`](../CHANGELOG.md) | **Что менялось по релизам** глазами игрока; версия и дата берутся из git-тега |
| [`impl_diff.md`](impl_diff.md) | **Осознанные расхождения с SDLPoP**: где сделано не дословно и почему. Новое расхождение — записью сюда, а не только комментарием в коде |
| [`keys.txt`](keys.txt) | **Целевая раскладка управления**, к которой подгоняем SprPoP |
| [`keys_plan.md`](keys_plan.md) | План перевода на неё: что совпадает, что переназначить, каких функций нет вовсе |
| [`../assets/orig/README.md`](../assets/orig/README.md) | **Откуда берутся исходные данные**: `make fetch` (SDLPoP + музыка), зачем нужен и почему необязателен `MSDOS/`, `SND_SRC` |
## Производительность
@@ -41,6 +43,7 @@
| [`palette_plan.md`](palette_plan.md) | Карта всех 256 слотов палитры + механика fade |
| [`status_line_text.md`](status_line_text.md) | Строка HP как статус-строка: инвентаризация ВСЕХ текстов SDLPoP |
| [`sound_plan.md`](sound_plan.md) | Звук через CBL: разбор и архитектура |
| [`music_runtime_index_plan.md`](music_runtime_index_plan.md) | Будущее улучшение: runtime-индексы музыки и PCM-эффектов, смена аудионаборов без перелинковки EXE |
| [`shadow_render.md`](shadow_render.md) | Вид Тени (OR+XOR) — отложено: почему XOR несовместим с прозрачностью `#FF` |
| [`roomnav_skip.md`](roomnav_skip.md) | Комнаты для отладочного телепорта `+`/``: какие пропускать и почему |
| [`host_tests_plan.md`](host_tests_plan.md) | Модульные тесты движка под ucsim_z80: два шва, регрессии, дифф против SDLPoP |
+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):** «пока мы тестируем на регресс
+92
View File
@@ -89,3 +89,95 @@ raw-клавиатура (`<kbd_raw.h>`), которую мы и так заби
после выхода — заметный. Делать после того, как закроем стражей и
займёмся бюджетом всерьёз (там же, где батчинг кроссбанковых вызовов и
возможный возврат `pop_bg` в резидент `--w3`).
## Готовить следующий уровень, пока играет мелодия конца текущего
**САМАЯ ДАЛЬНЯЯ ВЕРСИЯ.** Не полишинг и не порт: это улучшение ПРОТИВ
оригинала. Планируем, но не раньше, чем закроем уровни и полишинг.
**Идея (пользователь, 2026-08-28).** Пока звучит мелодия конца уровня (и
трек заставки между уровнями), экран не меняется — значит в это время можно
успеть прочитать с диска следующий уровень, чтобы после музыки он появлялся
сразу, а не через секунду-другую загрузки.
**Что УЖЕ сделано и мерено (2026-08-28).** Половина этого уже работает:
трек заставки играет ПОВЕРХ загрузки уровня — так же, как в оригинале
(`seg003:68-108`: `load_intro` возвращает управление, `load_level()` идёт под
музыку, и только потом `while (check_sound_playing())` на чёрном экране).
Насос это переживает: 500 подряд вызовов `pop_sfx_fill` через всю загрузку —
максимальный зазор 245 832 такта при дедлайне 251 000, ни одной пропущенной
порции. Осталась вторая половина: мелодия конца уровня досиживается на
ЖИВОМ экране пройденного уровня (как в оригинале, `play_level_2`), и только
после неё начинается загрузка.
**Оговорки, найденные при разборе.**
1. **Загрузку придётся разрезать надвое.** Сейчас `pop_level_switch` мешает
дисковую работу (файл уровня в EMM-страницу, атласы стража и тайлсета) с
экранно-палитровой (`pop_bg_load` переписывает записи палитры и атласы,
`pop_guard_load` физически применяет цветовые слоты, обе страницы
заливаются, `pop_pal_black`). Вторую половину НЕЛЬЗЯ выполнять, пока на
экране ещё живёт пройденный уровень — иначе палитра поедет прямо на
картинке. То есть в «музыкальное окно» можно вынести только диск, а всё,
что трогает палитру и VRAM, остаётся после.
2. **Бюджет кадра.** Ожидание мелодии идёт в игровом цикле (гейт
`pop_endmus_left` + `pop_music_busy()`), кадр там обычный, 80-100 мс.
Шаг подкачки должен быть такого же размера, как музыкальный — полстраницы
(~16 мс), а не страница целиком; иначе кадры на экране пройденного уровня
начнут дёргаться.
3. **Память.** Страницы следующего уровня придётся держать одновременно со
страницами текущего — проверить EMM-бюджет (memory `sprinter_emm_budget`),
тяжёлые тут не файл уровня, а атласы тайлсета и стража.
4. **Риск невелик, но он в самом хрупком месте.** Палитро-атласный обмен —
источник уже пойманных багов (`PAL-DUNGEON-STALE`, `PAL-L1-AFTER-INTRO`),
и трогать его ради экономии секунды стоит только на спокойную голову.
**Выигрыш.** Одна-две секунды один раз на уровень.
## Музыка одним постоянно открытым архивом (замер 2026-08-31, НЕ сейчас)
**Идея.** Сейчас каждый трек — отдельный файл `MUS/mNN.bin`, и
`pop_music_load_begin` открывает свой на каждый запуск. Свести треки в
один файл и держать его `fd` открытым: вместо `chdir`+`open`+`chdir`
останется `lseek` к смещению трека.
**Почему это стоит внимания — разложение окна старта музыки** (полный
замер и метод — `sound_plan.md` §9):
| шаг | цена | уйдёт? |
|---|---:|---|
| ожидание `pop_music_service` в кадре | 22,6 мс | нет |
| `chdir` #1 | 46,1 мс | **да** |
| `open` файла трека | 34,5 мс | **да** |
| `chdir` #2 | 59,0 мс | **да** |
| чтение первых 8 КБ | 34,0 мс | нет |
| **итого** | **196,7 мс** | |
Уходит **139,6 мс** — но это ВЕРХНЯЯ граница: появляется `lseek` к
смещению трека, которого сейчас нет вовсе (файл читается последовательно
с нуля), и его цена НЕ ИЗМЕРЕНА. Реальный выигрыш = 139,6 минус `lseek`;
померить можно тем же способом, `lseek` уже используется в
`pop_arc.c:89`. Остаётся ≈ 57 мс, то есть старт трека ускоряется
примерно вчетверо.
**Где заметно.** Реплики PV-сцены идут встык (три трека подряд), плюс
каждый игровой джингл — смерть, зелье, подобранный меч.
**Чем осложнено.**
1. **`PBA1` под это не годится.** Размер элемента там `uint16` (≤ 64 КБ),
а трек — до 1,2 МБ (m56, 78 страниц); резать по страницам нельзя,
элементов вышло бы ~240 при `POP_ARC_MAX = 126`. Нужен свой индекс со
смещениями — то есть эта задача СМЫКАЕТСЯ с `MUS/MUSIC.IDX` из
`music_runtime_index_plan.md`: одно изменение раскладки, а не два.
2. **Постоянно занятый файловый манипулятор** — один из восьми (memory
`dss_fd_limit`, девятый `open` вешает DSS). Рядом свои открывают
`POP.CFG`, quicksave и загрузка уровня.
3. **`chdir` убрать нельзя** — он и есть половина выигрыша, но нужен
старым DSS (`POP_PATH_CALL`); экономия берётся не его удалением, а тем,
что открытие вообще перестаёт выполняться на каждый трек.
**Оговорка.** Задержка старта музыки сама по себе НЕ является дефектом:
трек начинается на границе события, на слух это не сбой. У эффектов
такой задержки нет вовсе — они целиком в EMM (см. `sound_plan.md` §9.1).
Так что это оптимизация, а не исправление.
@@ -0,0 +1,592 @@
# Аудио без перелинковки: runtime-индексы музыки и эффектов
> Статус: **ОБЕ ЧАСТИ СДЕЛАНЫ 2026-08-31** — эффекты (SI0..SI4) и музыка
> (MI0..MI5). Разбор и результаты: `sound_plan.md` §10 (эффекты) и §11
> (музыка). Ниже — исходный план; расхождения перечислены следом.
>
> Что в реализации разошлось с планом ниже: файл назван `SND/snd.idx`
> (единый basename с `snd.arc`, оба в нижнем регистре), а при отсутствии
> индекса поднимается ПУСТОЙ набор с блоком тишины — иначе встаёт музыка
> и игра непроходима (§10.5 sound_plan). Про `ticks60` в `PMI1` решено:
> поле в формате оставить даже если сценарии перейдут на ожидание звука —
> загрузчик волен не тащить его в память.
>
> Расхождения части I с планом ниже:
> * файл назван `MUS/mus.idx` (единый стиль с `snd.idx`);
> * пауза конца уровня переведена НЕ на runtime-тики, а на состояние
> «заявка/загрузка/звучание» (`pop_music_active`) — §11.2: это снимает и
> зависимость от набора, и враньё делителя `/4` в быстрых режимах, и
> расхождение с SDLPoP при выключенном звуке;
> * PV-сцена (§8 плана) — не структура времён через пять функций, а четыре
> якоря-статики (8 байт), от которых отсчитываются прежние выражения;
> * `pages` в PMI1 не хранится — считается из `blocks`.
## 1. Цель
Один и тот же `sprpop.exe` должен корректно работать с любым штатным набором
музыки (`flac`, `mp3`, `ogg`, `mt32`) без перекомпиляции и перелинковки.
Замена набора должна сводиться к замене файлов в `MUS/` вместе с описывающим
их индексом.
Сейчас это невозможно: упаковщик печатает зависящие от набора значения в
`gen/pop_music_tbl.h` и `gen/pop_music_ticks.h`, после чего они становятся
частью EXE. Если заменить только `MUS/mNN.bin`, загрузчик продолжает верить
размерам старого набора, а сценарии — его длительностям.
## 2. Подтверждённое текущее состояние
После преобразования в PCM наборы `flac`, `mp3` и `ogg` имеют одинаковые
длины. Исполнение MT-32 отличается. Значимые примеры:
| трек | FLAC/MP3/OGG | MT-32 |
|---|---:|---:|
| m32, конец уровня 4 | 438 тиков | 449 тиков |
| m41, обычный конец уровня | 732 | 685 |
| m50, принцесса ждёт | 831 | 867 |
| m53, реплика Джафара | 985 | 1044 |
| m56, финальная тема | 9865 блоков / 78 страниц | 10462 / 82 |
Все обычные треки каждого из четырёх наборов по-прежнему помещаются в
`POP_MUS_PAGES == 20`. Только m56 требует кольцевого проигрывателя.
`uint16_t` достаточно и для самого длинного MT-32-трека: 10462 блока.
Compile-time длительности используются не повсюду:
- `pop_music.c` читает из `pop_music_tbl.h` наличие, число страниц и блоков;
- `pop_intro.c` использует длительности m50 и m53 для шкалы PV-сцены;
- `pop_kid.c` использует длительности m32 и m41 для паузы конца уровня,
включая режим с выключенной музыкой;
- остальные ожидания уже опираются на `pop_music_busy()` и автоматически
заработают с правильным runtime-числом блоков.
## 3. Что должно остаться константами программы
В индекс переносятся только свойства конкретного набора. Инварианты формата
остаются в коде, а заголовок IDX лишь подтверждает их:
- PCM unsigned 8-bit mono;
- частота `CBL_FREQ_10K9`;
- один блок насоса — 128 байт;
- пространство оригинальных sound id — 0..56;
- `POP_MUS_PAGES == 20` для обычного загрузчика;
- размер кольца потокового проигрывателя.
Это важно: IDX не должен обещать программе другой формат PCM, который насос
физически не умеет воспроизводить.
## 4. Предлагаемый файл `MUS/MUSIC.IDX`
Предпочтителен отдельный индекс набора, а не заголовок в каждом треке:
- все сценарные длительности доступны сразу после старта;
- `mNN.bin` остаются простыми сырыми PCM-потоками;
- загрузчик трека меняется минимально;
- один индекс легко заменить вместе с набором;
- формат помещается в один 512-байтовый сектор DSS.
Предлагаемая версия `PMI1`:
```text
0..3 "PMI1" magic и версия
4 57 число плотных записей (id 0..56)
5 4 размер записи
6 7 log2 размера блока: 1 << 7 = 128
7 1 формат PCM: u8 mono, CBL_FREQ_10K9
8..15 0 резерв будущих версий
16.. 57 записей по 4 байта:
+0..1 uint16 blocks длина PCM в блоках по 128 байт
+2..3 uint16 ticks60 длительность ожидания в тиках оригинала
244..511 нулевой резерв до одного сектора
```
Нулевые `blocks` и `ticks60` означают, что трека с таким id в наборе нет.
`pages` в IDX не хранится: это производная величина, и её дублирование может
разойтись с `blocks`:
```c
pages = blocks / 128 + ((blocks & 127) != 0);
```
По умолчанию упаковщик вычисляет тики той же формулой, что сейчас:
```text
ticks60 = round(blocks * 40 / 57)
```
Хранить `ticks60` отдельно всё же полезно: в будущем момент окончания
сценарной реплики можно будет уточнить независимо от технического хвоста PCM.
Генератор обязан печатать предупреждение, если явно заданные тики заметно
отличаются от длительности файла.
## 5. Размещение индекса в памяти
Загружать таблицу в обычный изменяемый `static` нельзя. Данные банковых
модулей сейчас попадают в общий `_DATA`/W2, а свободная куча составляет около
238 байт. Даже таблица из 228 байт практически уничтожит этот запас.
Индекс следует держать в одной EMM-странице:
1. `pop_music_init()` выделяет блок из одной страницы;
2. загружает туда первые 512 байт `MUS\MUSIC.IDX`;
3. проверяет magic, версию, размеры полей и формат PCM;
4. готовит страницу для безопасного временного отображения в W0;
5. сохраняет в W2 только номер блока/страницы и флаг готовности.
Потеря 16 КБ EMM ради маленькой таблицы допустима: EMM у игры с запасом, а
W1/W2 — самый дефицитный ресурс. Не следует ради экономии страницы прятать
индекс в хвост `kid.ani` или звукового набора: это создаст ненужную связь
между независимыми ресурсами.
Доступ к записи предоставляет банковая функция наподобие:
```c
int8_t pop_music_info(uint8_t id, pop_music_info_t *out) __banked;
```
Она на короткое время отображает страницу IDX в W0, копирует четыре байта в
буфер вызывающего и сразу восстанавливает окно. Наружу указатель на EMM не
выдаётся.
Индекс живёт до выхода из программы. `pop_music_free()`, который вызывается
между сценами, освобождать его не должен; для полного завершения нужен
отдельный shutdown либо освобождение в общем маршруте выхода.
## 6. Изменения загрузчика музыки
`pop_music_load_begin(id)` должен получать из runtime-индекса:
- наличие трека;
- число блоков;
- вычисленное число EMM-страниц.
Далее существующая архитектура почти не меняется:
- `slot_blocks[]` уже хранит runtime-длину загруженного трека;
- `pop_mus_left` получает её при `play`/`stream`;
- насос сам останавливается на правильном блоке;
- `pop_music_busy()` автоматически отражает фактический конец;
- кольцевой проигрыватель получает правильные 82 страницы MT-32 m56 вместо
78 страниц FLAC.
При каждом открытии `mNN.bin` надо без дополнительного `open` проверить
фактический размер:
1. `lseek(fd, 0, SEEK_END)`;
2. убедиться, что размер положительный и кратен 128;
3. сравнить его с `blocks * 128` из IDX;
4. вернуть позицию через `lseek(fd, 0, SEEK_SET)`.
`lseek(SEEK_END)` уже реализован в libc поверх DSS `MOVE_FP $15` и
используется самой игрой для `POP.CFG`.
Рекомендуемая политика несовпадения:
- вывести диагностическое сообщение;
- считать фактический размер файла главным;
- пересчитать `blocks/pages/ticks60` в загруженной EMM-копии индекса;
- продолжить работу, если размер проходит ограничения.
Так случайно забытый старый IDX не приведёт к чтению чужой EMM-страницы или
обрыву трека. При этом штатная поставка обязана всегда включать согласованные
IDX и PCM.
## 7. Runtime-тики без 32-битного переполнения
Если тики приходится восстанавливать из фактического размера, прямое
`blocks * 40` может переполнить `uint16_t`. Та же формула считается только
16-битной арифметикой:
```c
ticks = (blocks / 57) * 40
+ ((blocks % 57) * 40 + 28) / 57;
```
Даже при `blocks == 65535` промежуточные значения остаются в `uint16_t`.
Это холодный путь, поэтому небольшая цена деления допустима.
## 8. Перевод сценариев с compile-time на runtime
### PV-сцена (`pop_intro.c`)
Текущий большой `enum` смешивает два класса величин:
- неизменные интервалы сценария;
- абсолютные точки, сдвигаемые длительностями m50 и m53.
Надо сохранить относительные сценарные константы, а при входе в PV-сцену
один раз собрать локальную структуру `pv_timing_t`:
```text
m50_end = MUS_2_START + ticks(m50)
wait_end = m50_end + 40
...
dialog1_start = предыдущая фиксированная цепочка
exit_start = dialog1_start + ticks(m53)
anim_end = exit_start + 469
```
Структуру лучше держать на стеке `intro_pv_animated()` и передавать нужным
helper-функциям указателем. File-scope изменяемая таблица снова попала бы в
W2.
m50 уже загружен до начала PV. m53 начинает подгружаться задолго до своего
старта, поэтому метаданные обоих треков к моменту использования доступны.
### Конец уровня (`pop_kid.c`)
Вместо `POP_MUS_TICKS_32/41` редкое событие окончания уровня вызывает
банковый accessor IDX и вычисляет `pop_endmus_left` из runtime-тиков.
Индекс используется даже при выключенной музыке: оригинал выдерживает эту
паузу молча. Поэтому нельзя заменять её одним `pop_music_busy()`.
## 9. Генератор и сборка
`tools/pop_pack_music.py` должен:
1. как сейчас преобразовать выбранный набор в `mNN.bin`;
2. проверить кратность каждого результата 128 байтам;
3. собрать плотные записи 0..56;
4. записать `MUS/MUSIC.IDX` в формате `PMI1`;
5. больше не генерировать `pop_music_tbl.h` и `pop_music_ticks.h`.
Makefile должен:
- добавить `MUS/MUSIC.IDX` в `DISK` и staging;
- убрать музыкальные generated-header'ы из `GEN_H`;
- убрать их из зависимостей EXE;
- оставить `MUSIC_FMT` зависимостью только музыкальных ресурсов;
- гарантировать, что `make music-flac/mp3/ogg/mt32` заменяет и PCM, и IDX,
но не перелинковывает EXE.
Критерий архитектуры: SHA/дата `sprpop.exe` не меняется при переключении
между четырьмя музыкальными целями.
## 10. Ошибки и совместимость
Игра не должна падать из-за необязательной музыки:
- нет IDX — музыка отключена, игра продолжает работать;
- неверная magic/версия/формат — музыка отключена с диагностикой;
- записи нет — конкретный трек считается отсутствующим;
- файла нет — запрос трека завершается молча/с диагностикой, как сейчас;
- обычный трек требует больше 20 страниц — не загружать обычным путём;
- потоковый трек имеет больше 255 страниц — отвергнуть, потому что текущие
счётчики страниц восьмибитные;
- IDX и файл расходятся — применить политику §6;
- все файловые операции выполнять через `POP_PATH_CALL`, чтобы сохранить
работу на старых DSS с повреждением текущего каталога.
Для отсутствующего/повреждённого IDX остаётся выбрать поведение немой паузы
конца уровня: нулевая пауза либо небольшой канонический fallback. Это не
мешает основной архитектуре, но решение надо принять до реализации.
## 11. Этапы реализации
### MI0 — формат и host-тест
- вынести writer/reader `PMI1` в тестируемый код упаковщика;
- проверить magic, размеры, LE-поля, нулевые записи и padding;
- для всех четырёх наборов сверить `blocks/ticks` с фактическими PCM;
- зафиксировать тестом значения MT-32 m50/m53/m56 как отличающиеся от FLAC.
### MI1 — runtime-загрузка IDX
- `pop_music_init()`/shutdown;
- одна EMM-страница, загрузка и валидация;
- accessor одной записи;
- отказ без порчи W0/W3, EMM и файловых дескрипторов.
### MI2 — loader/pump
- заменить `pop_mus_tbl[]` runtime-записью;
- проверять реальный размер файла;
- обычный, немедленный и кольцевой пути должны использовать одну метаинформацию;
- удалить `pop_music_tbl.h`.
### MI3 — runtime-шкала сцен
- перевести m50/m53 в `pop_intro.c` на локальную runtime-шкалу;
- перевести m32/m41 в `pop_kid.c`;
- удалить `pop_music_ticks.h`.
### MI4 — сборка и образы
- добавить IDX в каждый музыкальный набор;
- исключить музыку из зависимостей линковки;
- собрать четыре HDD-варианта с одним EXE.
### MI5 — приёмка в MAME/на железе
- FLAC: title -> story -> PV, обычный конец уровня, конец уровня 4, ending;
- MT-32: те же маршруты, особенно m50/m53 и полный m56;
- музыка выключена: пауза конца уровня берётся из IDX;
- замена набора без пересборки EXE;
- повреждённый IDX, отсутствующий трек, несовпадающий размер;
- контроль, что не запущено более одного MAME и предыдущий экземпляр закрыт
перед новым прогоном.
## 12. Критерии готовности
- один бинарник запускается со всеми четырьмя наборами;
- ни один музыкальный generated-header не входит в сборку C;
- начало следующей сцены/уровня соответствует фактической записи;
- MT-32 m56 проигрывает все 82 страницы и не обрывается как FLAC-вариант;
- обычные треки не читают за пределами выделенного EMM-блока;
- IDX не расходует сотни байт W2;
- ошибочный набор отключает музыку безопасно;
- переключение `music-*` не меняет EXE;
- старые DSS продолжают работать через восстановление каталога приложения.
---
# Часть II. Звуковые эффекты без перелинковки
## 13. Цель и отличие от музыки
Один и тот же `sprpop.exe` должен работать с разными наборами PCM-эффектов,
если в них сохранена исходная нумерация sound id 0..56. Замена набора должна
сводиться к замене согласованной пары `SND/snd.idx` + `SND/snd.arc`, без
генерации C-заголовка и перелинковки EXE.
Набор состоит из двух согласованных файлов:
- `SND/snd.arc` — обычный архив PBA1 с PCM-страницами;
- `SND/snd.idx` — описание раскладки эффектов внутри этих страниц.
Отдельный IDX предпочтительнее расширения заголовка `snd.arc`: PBA1 остаётся
универсальным и не получает специального варианта только для звука, а
формат индекса можно независимо версионировать и проверять тем же способом,
что будущий `MUS/MUSIC.IDX`.
Существующий формат записи менять не требуется:
```c
typedef struct {
uint8_t page;
uint16_t off;
uint16_t len;
} pop_snd_ent_t;
```
Это ровно необходимые проигрывателю номер логической страницы, смещение в
ней и длина PCM. В текущем ABI SDCC/z80 запись занимает 5 байт; дисковый
формат обязан описывать эти пять байт явно (`uint16` little-endian), а код
должен проверять `sizeof(pop_snd_ent_t) == 5` на этапе сборки.
## 14. Подтверждённое текущее состояние
Сейчас `tools/pop_pack_sound.py` печатает раскладку в
`gen/pop_sound_tbl.h`, а `pop_sfx.c` включает её как
`static const pop_snd_tbl[57]`. Поэтому конкретные `page/off/len` становятся
частью EXE.
В текущей сборке таблица занимает 285 байт в `_CODE`, по адресам
`0x52D8..0x53F4`. Это общий резидентный диапазон игры: при huge-модели
`_CODE`, `_DATA`, heap и stack совместно используют плоские 32 КБ
`0x4000..0xBFFF`; граница W1/W2 отдельного бюджета здесь не создаёт.
Проверены оба имеющихся исходных набора DIGISND:
| набор | PCM-эффектов | страниц после упаковки | отличия раскладки |
|---|---:|---:|---:|
| MSDOS 1.3/1.4 | 31 из 57 | 10 | эталон текущей сборки |
| SDLPoP 1.0/1.1 | 31 из 57 | 9 | отличаются 8 id |
Максимальный эффект занимает 17 664 байта, или 138 блоков по 128 байт.
У SDLPoP эффект 48 содержит только 7 исходных сэмплов; это свойство самого
набора, а не ошибка runtime-индекса.
## 15. Файл `SND/snd.idx`
IDX занимает один 512-байтовый сектор и после короткого заголовка является
точным дисковым дампом 57 записей `pop_snd_tbl`:
```text
0..3 "PSI1" magic и версия индекса эффектов
4 57 число плотных записей, id 0..56
5 5 размер записи
6 7 log2 размера блока: 1 << 7 = 128
7 1 формат: unsigned 8-bit mono, CBL_FREQ_10K9
8 pages фактическое число PCM-страниц в snd.arc
9..15 0 резерв
16..300 57 записей по 5 байт:
+0 uint8 page
+1..2 uint16 off, little-endian
+3..4 uint16 len, little-endian
301..511 0 резерв до одного сектора
```
`pages` — фактический размер конкретного набора, например 10 для MSDOS или
9 для SDLPoP. Его не следует называть или трактовать как
`POP_SND_MAX_PAGES`: максимум 16 является compile-time-вместимостью
загрузчика, а IDX сообщает число реально нужных страниц и обязан укладываться
в этот предел.
Загрузчик сверяет `pages` с числом элементов внешнего PBA1 и не доверяет
расходящимся файлам. `off` и `len` обязаны быть кратны 128; нулевая длина
означает отсутствие PCM для данного id.
IDX загружается прямо в резидентный `pop_snd_tbl`, поэтому отдельной страницы
EMM для него не требуется. Цена решения — одно дополнительное открытие и
чтение 512 байт при старте звука; на фоне загрузки 9–10 страниц PCM это
приемлемый холодный расход.
## 16. Размещение runtime-таблицы в памяти
`pop_snd_tbl` становится обычным изменяемым внутренним объектом:
```c
pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT];
```
Его следует определить в отдельном internal data-модуле и объявить через
`extern` в `_pop_sfx.h`, потому что таблицу заполняет холодный загрузчик из
банка 8, а читает резидентный `pop_sfx.c`.
После снятия `const` SDCC перенесёт эти 285 байт из `_CODE` в `_DATA`. Это
не добавляет 285 байт к общему резидентному расходу: `_CODE` одновременно
уменьшается на тот же размер, а `_CODE` и `_DATA` последовательно лежат в
одном диапазоне `0x4000..0xBFFF`. Возможна лишь небольшая разница из-за
выравнивания, которую надо проверить итоговой map-картой и size-check.
Банк 8 может читать индекс напрямую в этот буфер: весь диапазон
`0x4000..0xBFFF` остаётся доступен, пока банковый код исполняется в W3.
Горячие обращения `pop_snd_tbl[id].page/off/len` и ISR не требуют новых
маппингов или accessor-функций.
## 17. Фактическое число страниц
Инвариантами программы остаются:
```c
#define POP_SND_MAX_PAGES 16
#define POP_SND_COUNT 57
#define POP_SND_BLOCK 128
```
`POP_SND_PAGES == 10` больше не должен означать размер конкретного набора.
Вместо него появляется runtime-состояние:
```c
uint8_t pop_snd_pages;
uint8_t pop_snd_page[POP_SND_MAX_PAGES];
```
Загрузчик выделяет `mem_alloc_pages(pop_snd_pages)` и заполняет только
фактическое число элементов массива. MSDOS-набор займёт 10 страниц,
SDLPoP-набор — 9. Предел 16 оставляет запас будущим наборам без
перекомпиляции и увеличивает постоянный массив лишь на 6 байт относительно
текущего; ещё один байт занимает `pop_snd_pages`.
Насос при переходе длинного эффекта через границу страницы сравнивает
`sfx_pg + 1` с `pop_snd_pages`, а не с compile-time-константой.
## 18. Загрузка и валидация
`pop_sfx_init()` должен выполнять следующую последовательность:
1. открыть `SND/snd.idx` через существующий путь `POP_PATH_CALL`;
2. прочитать 16-байтовый заголовок, затем 285 байт записей прямо в
`pop_snd_tbl`, и проверить полный размер IDX;
3. проверить magic, версию, число/размер записей, формат и размер блока;
4. получить из IDX фактическое число страниц и проверить диапазон 1..16;
5. открыть `SND/snd.arc` через `POP_PATH_CALL`;
6. прочитать обычную таблицу PBA1 с ёмкостью `POP_SND_MAX_PAGES`;
7. сверить `PSI1.pages` с числом элементов PBA1;
8. проверить каждую ненулевую запись;
9. только после этого выделить фактическое число EMM-страниц и загрузить их.
Для каждой записи проверяются:
- `page < pop_snd_pages`;
- `off < 16384` и `off % POP_SND_BLOCK == 0`;
- `len % POP_SND_BLOCK == 0`;
- диапазон `page/off/len` не выходит за загруженный набор;
- переход длинного эффекта на следующие страницы не превышает
`pop_snd_pages`.
При любой ошибке блок EMM и файловый дескриптор освобождаются, таблица не
используется, эффекты остаются выключенными. Набор без `snd.idx` считается
неполным; хранить в EXE старую таблицу как fallback не следует, иначе
зависимость бинарника от конкретного набора останется.
## 19. Генератор и сборка
`tools/pop_pack_sound.py` должен:
1. как сейчас разобрать DIGISND, привести PCM к CBL_FREQ_10K9 и выровнять
начала/длины на 128 байт;
2. вычислить фактическое число страниц и отвергнуть набор больше 16;
3. сформировать плотные 57 записей `page/off/len`;
4. записать `SND/snd.idx` в формате `PSI1`, включая фактическое число
страниц;
5. собрать обычный `SND/snd.arc` из полученных страниц;
6. больше не генерировать `gen/pop_sound_tbl.h`.
Чтобы число страниц не было захардкожено списком `s0.bin`..`s9.bin` в
Makefile, упаковщик звука предпочтительно должен сразу формировать
`assets/packed/SND/snd.arc` либо передавать упаковщику архивов динамический
список результатов без сохранения устаревшей десятой страницы.
Стабильные тип и константы переносятся в обычный internal-заголовок. EXE не
должен зависеть от результата упаковки звука: цель выбора набора меняет
согласованную пару `snd.idx` + `snd.arc`.
## 20. Этапы реализации эффектов
### SI0 — формат и host-тест
- writer/reader отдельного `snd.idx` в формате `PSI1`;
- проверка точного пятибайтового LE-формата записи;
- сборка и разбор обоих имеющихся DIGISND-наборов;
- проверки 10 страниц MSDOS, 9 страниц SDLPoP и отличающихся записей.
### SI1 — runtime-таблица
- изменяемый `pop_snd_tbl` в общем резидентном CODE/DATA-диапазоне;
- загрузка и полная валидация `PSI1`;
- удаление generated `pop_sound_tbl.h`;
- проверка map-карты: перенос CODE -> DATA не должен съесть heap.
### SI2 — гибкое выделение страниц
- `POP_SND_MAX_PAGES == 16` и runtime `pop_snd_pages`;
- фактический размер стека таблицы PBA1;
- выделение/освобождение 9, 10 и тестовых 16 страниц;
- runtime-гард перехода длинного эффекта между страницами.
### SI3 — сборка без перелинковки
- убрать фиксированный `SND_ATL` из десяти имён;
- сделать `snd.idx` и `snd.arc` согласованными результатами выбора набора;
- проверить неизменность SHA/даты `sprpop.exe` при смене набора.
### SI4 — приёмка
- проиграть короткий, обычный и переходящий страницу эффекты;
- проверить приоритеты и перебивание звуков;
- проверить музыку поверх общего CBL после runtime-загрузки эффектов;
- отсутствующий IDX и повреждённые magic/count/pages/page/off/len должны
безопасно отключать эффекты;
- перед каждым MAME-прогоном завершать предыдущий экземпляр и никогда не
запускать две копии одновременно.
## 21. Критерии готовности эффектов
- один `sprpop.exe` работает с MSDOS- и SDLPoP-наборами;
- замена согласованной пары `SND/snd.idx` + `SND/snd.arc` не требует
компиляции C и не меняет EXE;
- `pop_snd_tbl` больше не генерируется как C-код;
- загружается фактическое число страниц в диапазоне 1..16;
- таблица не расходует дополнительную страницу EMM и не увеличивает
суммарный резидентный CODE+DATA на свои 285 байт;
- горячий путь и ISR используют прежние прямые `page/off/len`;
- повреждённый индекс не приводит к чтению за пределами EMM-блока;
- файловые операции сохраняют совместимость со старыми DSS через
восстановление каталога приложения.
+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 — битовой маской рядов.
+329 -6
View File
@@ -163,10 +163,16 @@ B имеет смысл только если C окажется неподъё
1.3/1.4) и распакованные ассеты `SDLPoP/data/` (версия 1.0/1.1). Разница
есть, и она влияет на выбор источника.
### Оцифровка: берём MSDOS
### Оцифровка: по умолчанию SDLPoP, MSDOS — опция
Заголовок разный (`digi_new_type` против `digi_type`), но **28 звуков из
31 совпадают побайтно**. Различаются три, и все не в пользу SDLPoP:
**С 2026-08-31 умолчание — SDLPoP** (`SND_SRC=sdlpop`): он выкачивается
автоматически (`make fetch`), а оригинального дистрибутива DOS у сборки
может не быть вовсе — и она обязана работать без него. У кого дистрибутив
есть, включает лучший набор явно: `make SND_SRC=msdos`.
Платим за это ровно тем, что разобрано ниже. Заголовок в поставках разный
(`digi_new_type` против `digi_type`), но **28 звуков из 31 совпадают
побайтно**. Различаются три, и все не в пользу SDLPoP:
| id | звук | MSDOS | SDLPoP |
|---|---|---:|---:|
@@ -174,9 +180,13 @@ B имеет смысл только если C окажется неподъё
| 11 | `sword_moving` | 1 172 | 1 172, но **другие байты** |
| 48 | `spiked` | 5 069 | **7** — то есть звука нет |
`spiked` в наборе SDLPoP фактически пустой. Поэтому упаковщик читает
`MSDOS/digisnd*.dat`, а не распакованные ассеты — в отличие от графики,
где источник наоборот SDLPoP.
`spiked` в наборе SDLPoP фактически пустой: это звук насаживания на пики
(`spiked()`, seg005.c:245, кадр 177), и с умолчанием он не звучит — от пик
остаётся только шипение выдвижения (49 `spikes`). Звон клинков (10) на
секунду короче, а 11 просто другая запись.
Всё остальное — включая уровни `LEVELS/res20xx.bin` — берётся из SDLPoP
всегда: `MSDOS/` нужен ровно для этих трёх звуков и ни для чего больше.
### MIDI: если дойдём до музыки — брать SDLPoP
@@ -961,3 +971,316 @@ load_intro у оригинала и так начинается с ожидан
Проверено в MAME на сборке `LEVEL=14`: после встречи с принцессой звучит
тема победы (`pop_mus_id` = 56, `pop_mus_ring` = 6), курсор уходит далеко
за размер кольца — то есть подкачка успевает.
## 9. ЗАДЕРЖКА СТАРТА ЗВУКА: ЗАМЕР (2026-08-31)
Замерено в MAME на живой сборке: точка отсчёта — подстановка заявки
отладчиком при ОСТАНОВЛЕННОЙ машине, конец — условный watchpoint
`wpset 19FF6,1,w,{wpdata != 0}` на старший байт `pop_mus_left`, то есть
момент, когда трек реально зазвучал. Масштаб тактов откалиброван не по
памяти, а по аппаратным часам: период тика насоса CBL — ровно
`128/10937,5 = 11,7029` мс, измерено 245 748 и 245 778 тактов, отсюда
**21,00 МГц** (кадр луча — 429 973 такта, развёртка 48,8 Гц, не 50).
### 9.1 Эффекты стартуют без задержки, музыка — через ~200 мс
Разница принципиальная и вытекает из устройства:
* **Оцифрованные эффекты диска не касаются вовсе.** `pop_sfx_init()`
вызывается ОДИН раз за запуск (`sprpop_cold.c`), вычитывает весь
`SND/snd.arc` в EMM и закрывает файл. Дальше `pop_sfx_play` только
номинирует, а `pop_sfx_tick` берёт `page/off/len` из таблицы в памяти.
Задержка = остаток текущего кадра + ближайший запрос CBL (≤ 11,7 мс).
Держать архив открытым НЕЧЕГО: после старта он не нужен.
* **Музыка читается с диска на каждый трек**`mNN.bin` лежат
отдельными файлами, и `pop_music_load_begin` открывает свой.
### 9.2 Разложение окна музыки
От заявки (`pop_mus_req`) до звучания (`pop_mus_left != 0`), одна
загрузка по шагам:
| шаг | цена |
|---|---:|
| ожидание `pop_music_service` в кадре | 22,6 мс |
| `chdir` #1 (вход в `POP_PATH_CALL`) | **46,1 мс** |
| `open` файла трека | 34,5 мс |
| `chdir` #2 (восстановление каталога) | **59,0 мс** |
| чтение первых 8 КБ (`bank_read_page`) | 34,0 мс |
| **итого** | **196,7 мс** |
**Главное здесь: два `chdir` стоят 105 мс — больше половины окна, и
каждый дороже самого `open`.** Убрать их нельзя: старые DSS оставляют
рабочий каталог внутри последнего подкаталога, и без восстановления
ломаются относительные пути (ради этого `POP_PATH_CALL` и заведён).
Теоретический запас — снять ОДИН из двух (≈ 50 мс), но это риск для
других мест, которые полагаются на восстановленный каталог.
Для музыки задержка непринципиальна: трек и так начинается на границе
события. Критична она была бы для эффектов — там её нет по построению.
### 9.3 Разброс и режимы скорости
Семь прогонов: **189,3 … 232,8 мс**, среднее 200,1. Величина
ФИЗИЧЕСКАЯ и от режима скорости не зависит, меняется только её пересчёт
в логические кадры:
| режим | логический кадр (замерено) | окно в кадрах |
|---|---:|---:|
| NORMAL вне боя | 81,93 мс = 4,00 кадра луча | 2,31 … 2,84 |
| FAST / FASTEST вне боя | 61,4 мс = 3,00 кадра луча | 3,09 … 3,42 |
Худший случай (232,8 мс в коротком кадре) — 3,79 логического кадра.
Пейсер при этом работает ровно как задумано: `pop_pace_n` заказывает 4
кадра луча вне боя при NORMAL и 3 при FAST/FASTEST, замер это
подтвердил. Подкачка трека кадр НЕ просаживает.
### 9.4 Следствие для паузы конца уровня
Ждать окончания мелодии по одному `pop_music_busy()` НЕЛЬЗЯ: в этом окне
он ещё ложен, и уровень сменился бы до первой ноты. Правильный признак
— состояние «аудио-событие в работе», которое выводится из уже
существующих переменных без новых сущностей:
```c
uint8_t pop_music_active(void) __banked /* банк 9 — там ld_busy */
{
return (uint8_t)(pop_mus_req || ld_busy || pop_mus_left);
}
```
Переход «запрошен → грузится → звучит» снаружи непрерывен: `service`
обнуляет `pop_mus_req` и тут же в `load_begin` взводит `ld_busy`, а
главный цикл между этими операторами не выполняется.
Это лучше фиксированной задержки «на раскачку»: не нужна подобранная
константа, нет зависимости от режима скорости, а при провале загрузки
(нет файла, нет памяти) все три признака ложны и уровень меняется сразу,
вместо слепого ожидания. Заодно чинится расхождение с SDLPoP при
ВЫКЛЮЧЕННОМ звуке: `pop_sfx_play` возвращается рано при `!pop_snd_ok`,
заявка не кладётся, паузы нет — как в оригинале (`seg006:651` ставит
трек только `if (is_sound_on)`, а `seg003:387` ждёт
`check_sound_playing()`). Сейчас `pop_endmus_left` взводится
безусловно, и при выключенном звуке игра держит пройденный уровень
лишние 12 с молча.
## 10. ЭФФЕКТЫ БЕЗ ПЕРЕЛИНКОВКИ: РАСКЛАДКА УЕХАЛА НА ДИСК (2026-08-31)
Сделана часть II плана `music_runtime_index_plan.md` (этапы SI0..SI4).
Раскладка звукового набора больше не компилируется в EXE: пара
`SND/snd.arc` + `SND/snd.idx` меняется целиком, бинарник остаётся прежним.
### 10.1 Что именно изменилось
`gen/pop_sound_tbl.h` УДАЛЁН — упаковщик его не печатает. Вместо него:
* `src/pop_snd_tbl.h` — рукописный: тип записи и инварианты программы
(`POP_SND_COUNT` 57, `POP_SND_BLOCK` 128, `POP_SND_MAX_PAGES` 16,
константы блока тишины). От набора не зависит НИЧЕГО из этого;
* `src/pop_snd_data.c` — резидентные данные: `pop_snd_tbl[57]`,
`pop_snd_page[16]`, `pop_snd_pages`;
* `SND/snd.idx` — дисковый индекс, формат `PSI1` (писатель и разборщик —
`tools/pop_idx.py`, покрыт `make test-tools`, 22 теста);
* `pop_sfx_cold.c` — чтение и полная валидация индекса.
`POP_SND_PAGES` как compile-time размер набора БОЛЬШЕ НЕ СУЩЕСТВУЕТ. Его
заменили две разные величины: `POP_SND_MAX_PAGES` — вместимость
загрузчика (16, запас под чужие наборы), и runtime `pop_snd_pages`
сколько страниц у ЭТОГО набора. Насос сверяет переход длинного эффекта
через границу страницы со вторым, а не с первым.
### 10.2 Формат `PSI1` и почему запись ровно 5 байт
Один сектор DSS: 16 байт заголовка (магия, число записей, размер записи,
log2 блока, формат PCM, число страниц) и 57 записей по 5 байт —
`page` (u8), `off` (u16 LE), `len` (u16 LE), — то есть побайтовый образ
`pop_snd_tbl[]`. Записи читаются ОДНИМ `read` прямо в резидентную
таблицу, без разбора по полям.
Поэтому `sizeof(pop_snd_ent_t) == 5` в ABI SDCC z80 — часть ДИСКОВОГО
контракта, а не деталь компилятора. Ловится с двух сторон: статически
(`pop_snd_data.c`) и полем размера записи в заголовке, которое загрузчик
сверяет со своим `sizeof`.
### 10.3 Цена в памяти
Таблица была `static const` в `_CODE`, стала изменяемой в `_DATA`.
Замер до/после:
| | было | стало | Δ |
|---|---:|---:|---:|
| `_CODE` | 23865 | 23582 | 283 |
| данные | 7128 | 7421 | +293 |
| куча резидента | 239 Б | 229 Б | 10 |
| банк 8 | 14824 | 15425 | +601 |
Суммарный резидентный расход почти не изменился — в huge-модели `_CODE`
и данные лежат подряд в одном диапазоне `0x4000..0xBFFF`. Реальные
10 байт — это расширение `pop_snd_page` с размера набора до
`POP_SND_MAX_PAGES` плюс байт счётчика.
### 10.4 Валидация: чему именно нельзя доверять
Индекс — файл на диске, и битый индекс увёл бы насос за пределы
выделенного блока EMM. Проверяются заголовок целиком и КАЖДАЯ запись:
`page < pop_snd_pages`, `off` внутри страницы, кратность `off`/`len`
блоку 128, непересечение с блоком тишины и то, что длинный эффект не
выходит за последнюю страницу набора.
Последняя проверка считается В БЛОКАХ, а не в байтах: байтовый адрес
конца (`page * 16384 + off + len`) не влезает в `uint16`, а 32-битная
арифметика на Z80 дорога. В блоках весь набор — не больше
`16 * 128 = 2048`, и всё считается шестнадцатью битами.
Число страниц из индекса дополнительно сверяется с числом элементов в
`snd.arc`: расходящаяся пара означает, что файлы из разных наборов.
### 10.5 Нет индекса — эффектов нет, но МУЗЫКА ОСТАЁТСЯ
Первая версия просто возвращала ошибку, и это оказалось непроходимой
игрой: тишину в паузах насос льёт из первого блока набора, поэтому без
набора CBL не открывался вовсе — а вместе с ним вставала музыка, чьи
блоки отсчитывает тот же насос. Заставка ждала конца трека, который не
начинал играть, и `pop_music_busy()` не гас никогда.
Теперь при отказе индекса поднимается ПУСТОЙ набор: одна страница EMM с
блоком тишины. Все `len` нулевые, поэтому `pop_sfx_play` уходит по ветке
«нет оцифровки» — эффекты молчат, музыка играет, игра проходима.
Две тонкости этой заливки, обе выяснены на живой машине:
1. **Заливается только первый блок (128 байт), а не вся страница.**
`gfx_w0_page_prepare` ставит в страницу IRQ/NMI-стабы, чтобы
прерывание при замапленном W0 нашло валидный код. Заливка всей
страницы затирает их, и первое же прерывание уводит процессор в
мусор — чёрный экран на старте. Насосу больше и не нужно: он берёт
ровно блок тишины, да и то через W3.
2. **Запись под `IRQ_DISABLE`** — 128 байт накрывают стаб RST38, и до
`gfx_w0_unmap` прерываний быть не должно.
### 10.6 Грабли сборки: секундная гранулярность mtime
Смена `SND_SRC` тихо давала НЕВЕРНЫЙ результат: `sdlpop -> msdos ->
sdlpop` перепаковывал `build/atl`, а в `assets/packed` оставался прежний
набор. Причина не в логике, а во времени: упаковка укладывается в одну
секунду с записью stamp'а, а `make` считает цель, сделанную в ту же
секунду, что и зависимость, актуальной. С паузой в секунду не
воспроизводится вовсе.
Лечится двумя приёмами, и оба убирают время из решения:
* смена варианта СНОСИТ stamp'ы своего семейства (`rm -f
$(STAMP)/sound-*`) — нет файла, значит правило обязано выполниться;
* упаковка, сборка архива и копия индекса делаются ОДНИМ рецептом:
внутри рецепта сравнивать нечего.
То же лечение получила и музыка (`MUSIC_FMT`) — механизм общий.
### 10.7 Что проверено на живой машине
* таблица в памяти совпадает с файлом из образа ПОБАЙТОВО (285 байт);
* один и тот же EXE (sha256 не меняется) поднимает SDLPoP (9 страниц) и
MSDOS (10 страниц);
* СТРОГАЯ проверка: у двух реальных наборов совпадают 23 записи из 31,
поэтому сделан отладочный ключ `pop_pack_sound.py --order reverse`
набор с обратным порядком укладки, где от штатного отличаются 30 из 31
записей. Игра на нём звучит правильно, значит раскладка берётся с
диска, а не из памяти;
* битый индекс (испорченная магия) — эффекты выключаются, игра жива;
* отсутствующий индекс — музыка играет, эффектов нет, PV-сцена проходит;
* Ctrl+S в обоих режимах работает штатно.
Маркер для ручной проверки: звук 51 `princess_door_opening` (дверь в
сцене с принцессой) лежит у наборов по РАЗНЫМ страницам — 8 у SDLPoP,
9 у MSDOS. А вот 4 `gate_closing` из той же сцены совпадает побайтно и
для проверки подмены не годится.
## 11. МУЗЫКА БЕЗ ПЕРЕЛИНКОВКИ: ДЛИНЫ И ДЛИТЕЛЬНОСТИ УЕХАЛИ НА ДИСК (2026-08-31)
Сделана часть I плана `music_runtime_index_plan.md` (этапы MI0..MI5, у нас
B0..B7). `gen/pop_music_tbl.h` и `gen/pop_music_ticks.h` УДАЛЕНЫ: длины
треков и длительности реплик читаются из `MUS/mus.idx` (формат `PMI1`).
Один и тот же `sprpop.exe` работает с любым из четырёх наборов записей.
### 11.1 Где живёт индекс и почему именно там
228 байт таблицы в W2 положить нельзя — там свободной кучи порядка двух
сотен. Поэтому индекс лежит в ОДНОЙ странице EMM, а в резиденте от него
остаются два байта (номер блока и страницы). Наружу указатель на EMM не
выдаётся: `pop_music_info()` подключает страницу в W0, копирует четыре
байта в буфер вызывающего и сразу снимает окно.
**Данные в странице лежат со смещения `POP_MUS_IDX_BASE` (0x100), а не с
нуля.** Причина в `gfx_w0_page_prepare`: он делает страницу безопасной
для W0, записывая В НЕЁ переход на IRQ-восстановитель (0x38) и RETN для
NMI (0x66). С нуля эти байты легли бы прямо в записи (id 10 и 21) и
испортили индекс; со смещением стабы и данные не пересекаются, и работает
штатная защита от прерываний, а не запрет их. Тот же приём и по той же
причине — в `pop_config.c` (`CFG_BASE`).
Числа страниц в индексе НЕТ: это производная от `blocks`, и дублирование
однажды разъехалось бы с ним. Считает `pop_music_pages()`.
### 11.2 Пауза конца уровня: состояние вместо счётчика
`pop_endmus_left` и `POP_MUS_TICKS_32/41` УДАЛЕНЫ. Главный цикл ждёт
`pop_music_active()` — «заявка лежит, идёт загрузка или трек звучит».
Одного `pop_music_busy()` там мало: между заявкой и первой нотой проходит
190230 мс (§9), и в этой щели он ложен. Раньше щель закрывал счётчик по
длине записи, но он тянул за собой две беды: зависимость EXE от набора и
жёсткий делитель `/4`, который врал в быстрых режимах (там логический кадр
3 кадра луча, а не 4). Три признака подряд закрывают щель без единой
подобранной константы, потому что `pop_music_service` обнуляет `req` и
тут же взводит `ld_busy`, а тот держится до записи `pop_mus_left`.
Побочно исправилось расхождение с оригиналом: при ВЫКЛЮЧЕННОМ звуке
`pop_sfx_play` не кладёт заявку, все три признака ложны, и уровень
меняется сразу — как в SDLPoP, где трек ставится под `if (is_sound_on)`
(seg006:651), а ожидание это `check_sound_playing()` (seg003:387).
Прежний счётчик взводился безусловно и держал пройденный уровень лишние
12 секунд в тишине.
### 11.3 PV-сцена: четыре якоря вместо compile-time шкалы
Шкала сцены осталась абсолютной (переход на ожидание звука — отдельный
разговор, см. план), но точки, зависящие от длины реплик, считаются при
входе в сцену: `pv_m50_end`, `pv_dialog1`, `pv_exit`, `pv_anim_end`.
Остальные выражения не изменились — они отсчитываются от якорей.
Восемь байт статики вместо протаскивания структуры времён через пять
функций (`pv_princess_step`, `pv_jaffar_step`, `pv_seq_period`,
`intro_pv_draw_frame`, `intro_pv_animated`), как предлагал план: сцена
одна на запуск, и переделка всех сигнатур того не стоит.
Нет индекса — длительности нулевые, и сцена просто идёт без пауз на
реплики: ждать нечего, звука-то нет.
### 11.4 Цена в памяти
За обе фазы (эффекты и музыка) резидент не вырос, а освободился:
| | до фазы A | после фазы B |
|---|---:|---:|
| `_CODE` | 23865 | 23544 |
| куча резидента | 239 Б | 256 Б |
Банк 9 похудел на 118 Б (ушла `pop_mus_tbl` из его rodata), банк 11 — на
длительности реплик. Прибавка: 2 байта на страницу индекса музыки и 8 на
якоря PV.
### 11.5 Что проверено на живой машине
* `sprpop.exe` ПОБАЙТОВО одинаков для наборов flac и mt32;
* все 22 трека в индексах различаются, и контрольные значения совпали с
тем, что план предсказывал по замерам: m32 438→449, m41 732→685,
m50 831→867, m53 985→1044, m56 9865→10462 блоков (78→82 страницы);
* на mt32-наборе PV-сцена проходит целиком: m50 → m53 → m52 звучат по
длительностям mt32, хотя в EXE их больше нет;
* без `mus.idx` музыки нет, эффекты работают, игра проходима — сцены
проскакивают без пауз на реплики.
**НЕ ПРОВЕРЕНО:** потоковый трек m56 на 82 страницах (у flac 78).
Кольцевой проигрыватель получает число страниц из индекса, и код к этому
готов, но живого прогона финала на mt32 не было — до него надо дойти в
игре. Это оставшийся пункт приёмки.
@@ -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
View File
@@ -1,41 +0,0 @@
/* kid_atlas.h — раскладка атласов Kid. Сгенерировано pop_pack_kid.py. */
#ifndef KID_ATLAS_H
#define KID_ATLAS_H
#define KID_SHIFT 3
#define KID_MASK 7
#define KID_PAGES 28
#define KID_PAL 0x70
static const char *const kid_atl[KID_PAGES] = {
"kid0.atl",
"kid1.atl",
"kid2.atl",
"kid3.atl",
"kid4.atl",
"kid5.atl",
"kid6.atl",
"kid7.atl",
"kid8.atl",
"kid9.atl",
"kid10.atl",
"kid11.atl",
"kid12.atl",
"kid13.atl",
"kid14.atl",
"kid15.atl",
"kid16.atl",
"kid17.atl",
"kid18.atl",
"kid19.atl",
"kid20.atl",
"kid21.atl",
"kid22.atl",
"kid23.atl",
"kid24.atl",
"kid25.atl",
"kid26.atl",
"kid27.atl",
};
#define KID_PAL_FILE "kid.pal"
#define KID_SWORD_ATL "sword.atl" /* chtab_0: меч в руке */
#define KID_SWORD_ID0 0 /* индекс в атласе = sword_tbl.id - ID0 */
#endif
+3 -13
View File
@@ -1,5 +1,5 @@
/* pop_bg_atlas.h — раскладка атласов статического фона PoP.
* Сгенерировано toolchain/pop_pack_bg.py НЕ править вручную.
* Сгенерировано tools/pop_pack_bg.py НЕ править вручную.
*
* Прямая адресация (ноль remap-таблиц в W2):
* ENV фон id N -> atlas env_bg[N>>4], idx N&15
@@ -23,25 +23,15 @@
#define POP_PAL_ENV 0x50
#define POP_PAL_WALL 0x60
/* Имена файлов атласов по тайлсету (грузятся atlas_load). Таблицы
* видны только тому, кто попросил POP_BG_ATLAS_NAMES: иначе копия
* строк уедет в каждый включивший заголовок модуль. */
/* Палитры пока остаются отдельными файлами рядом с bg.arc. Имена
* видит только модуль загрузки, чтобы не размножать строки. */
#ifdef POP_BG_ATLAS_NAMES
static const char *const pop_env_atl[POP_TILESETS][POP_ENV_PAGES] = {
{ "pop_env0.atl", "pop_env1.atl", "pop_env2.atl", "pop_env3.atl", "pop_env4.atl", "pop_env5.atl", "pop_env6.atl", "pop_env7.atl", "pop_env8.atl", "pop_env9.atl" },
{ "pal_env0.atl", "pal_env1.atl", "pal_env2.atl", "pal_env3.atl", "pal_env4.atl", "pal_env5.atl", "pal_env6.atl", "pal_env7.atl", "pal_env8.atl", "pal_env9.atl" },
};
static const char *const pop_wall_atl[POP_TILESETS] = { "pop_wall.atl", "pal_wall.atl" };
static const char *const pop_fore_atl[POP_TILESETS] = { "pop_fore.atl", "pal_fore.atl" };
static const char *const pop_tile_pal[POP_TILESETS] = { "pop_tile.pal", "pal_tile.pal" };
#endif /* POP_BG_ATLAS_NAMES */
#define POP_POT_ATL "pop_pot.atl" /* chtab_1: зелья, от набора не зависит */
#define POP_PAL_POT 0x40
/* Пузырёк зелья: красный набор = id 16..22 (кадры оригинала),
зелёный (перо/переворот) и синий (вред/открыть) = те же кадры
под id 30..36 и 40..46 (draw_tile_anim, seg008:652). */
#define POP_POT_BUBB_GREEN 30
#define POP_POT_BUBB_BLUE 40
#define POP_BG_PAL "pop_bg.pal"
#endif
-104
View File
@@ -1,104 +0,0 @@
/*
* pop_music_tbl.h каталог музыкальных треков, СГЕНЕРИРОВАН.
* Источник: assets/orig/PoP1_DOS_music/flac (записи DOS-версии).
* Генератор: tools/pop_pack_music.py. Руками не править.
*
* Нумерация id оригинала (SDLPoP data/music/names.txt). pages
* сколько кусков m<id>_<nn>.bin лежит на диске, blocks длина трека
* в порциях насоса по 128 байт (в байтах не влезло бы в uint16).
*
* Длительности реплик в тиках вынесены в pop_music_ticks.h: таблица
* ниже 171 байт ДАННЫХ, и модулю, которому нужна одна константа,
* платить за неё копией таблицы незачем (поймано на pop_intro.c:
* +172 байта в банк 11).
*
* POP_MUS_TICKS_<id> та же длина в ТИКАХ СЦЕНЫ (60 Гц оригинала).
* Нужна там, где сценарий ждёт окончания реплики: у оригинала это
* `do { frame(1) } while (check_sound_playing())`, у нас пауза
* длиной в нашу запись. Считается ЗДЕСЬ, а не в C, по двум
* причинам: blocks*40 переполняет uint16 на won (9855 порций), и
* число обязано соответствовать НАБОРУ, которым собрали (flac и
* mt32 разные исполнения разной длины).
*
* Отношение 40/57 обратное к тому, которым шкала сцены переводит
* тики в порции насоса (57/40, см. pop_intro.c). Берём именно его,
* а не точное 10937,5/128/60, чтобы длина реплики и ход шкалы
* считались одной и той же арифметикой. Округление к ближайшему
* (+28 перед делением): отбрасывание вниз теряло по тику на реплику.
*/
#ifndef POP_MUSIC_TBL_H
#define POP_MUSIC_TBL_H
#include <stdint.h>
#define POP_MUS_COUNT 57
#define POP_MUS_BLOCK 128
#define POP_MUS_MAX_PAGES 78
typedef struct {
uint8_t pages; /* 0 — трека нет */
uint16_t blocks; /* длина в порциях по 128 байт */
} pop_mus_ent_t;
static const pop_mus_ent_t pop_mus_tbl[POP_MUS_COUNT] = {
{ 0, 0 }, /* 0 */
{ 0, 0 }, /* 1 */
{ 0, 0 }, /* 2 */
{ 0, 0 }, /* 3 */
{ 0, 0 }, /* 4 */
{ 0, 0 }, /* 5 */
{ 0, 0 }, /* 6 */
{ 0, 0 }, /* 7 */
{ 0, 0 }, /* 8 */
{ 0, 0 }, /* 9 */
{ 0, 0 }, /* 10 */
{ 0, 0 }, /* 11 */
{ 0, 0 }, /* 12 */
{ 0, 0 }, /* 13 */
{ 0, 0 }, /* 14 */
{ 0, 0 }, /* 15 */
{ 0, 0 }, /* 16 */
{ 0, 0 }, /* 17 */
{ 0, 0 }, /* 18 */
{ 0, 0 }, /* 19 */
{ 0, 0 }, /* 20 */
{ 0, 0 }, /* 21 */
{ 0, 0 }, /* 22 */
{ 0, 0 }, /* 23 */
{ 3, 305 }, /* 24 regular_death */
{ 2, 196 }, /* 25 find_mirror_and_find_shadow */
{ 9, 1132 }, /* 26 embrace */
{ 8, 915 }, /* 27 cutscene_before_2_4_6_12 */
{ 6, 656 }, /* 28 fight_death */
{ 4, 428 }, /* 29 meet_Jaffar */
{ 3, 299 }, /* 30 life_potion */
{ 0, 0 }, /* 31 */
{ 5, 624 }, /* 32 end_level_4_and_killed_by_shadow */
{ 2, 205 }, /* 33 potion */
{ 0, 0 }, /* 34 */
{ 7, 888 }, /* 35 cutscene_before_8_9 */
{ 10, 1256 }, /* 36 out_of_time */
{ 3, 280 }, /* 37 guard_death_and_obtaining_the_sword */
{ 0, 0 }, /* 38 */
{ 13, 1591 }, /* 39 floating */
{ 5, 596 }, /* 40 cutscene_not_much_time_left */
{ 9, 1043 }, /* 41 story_5_level_end_and_merge_with_shadow */
{ 0, 0 }, /* 42 */
{ 3, 285 }, /* 43 killed_Jaffar */
{ 0, 0 }, /* 44 */
{ 0, 0 }, /* 45 */
{ 0, 0 }, /* 46 */
{ 0, 0 }, /* 47 */
{ 0, 0 }, /* 48 */
{ 0, 0 }, /* 49 */
{ 10, 1184 }, /* 50 story_2_princess */
{ 0, 0 }, /* 51 */
{ 20, 2443 }, /* 52 story_4_Jaffar_leaves */
{ 11, 1403 }, /* 53 story_3_Jaffar_enters */
{ 17, 2064 }, /* 54 intro_theme */
{ 11, 1350 }, /* 55 story_1_absence */
{ 78, 9865 }, /* 56 won */
};
#endif /* POP_MUSIC_TBL_H */
-44
View File
@@ -1,44 +0,0 @@
/*
* pop_music_ticks.h длительности треков в ТИКАХ СЦЕНЫ, СГЕНЕРИРОВАН.
* Набор: flac. Генератор: tools/pop_pack_music.py.
* Руками не править.
*
* Нужны там, где сценарий ждёт окончания реплики: у оригинала это
* `do { frame(1) } while (check_sound_playing())`, у нас пауза длиной
* в НАШУ запись. Число обязано соответствовать набору, которым собрана
* сборка: одна и та же реплика на flac и mt32 разной длины (m53 985
* тиков против 1044), и захардкоженная константа молча ломает сцену.
*
* Считается здесь, а не в C: blocks*40 переполняет uint16 на won
* (9855 порций). Отношение 40/57 обратное к тому, которым шкала
* сцены переводит тики в порции насоса (57/40, pop_intro.c); берём
* именно его, чтобы длина реплики и ход шкалы считались одной
* арифметикой. Округление к ближайшему: вниз теряло по тику.
*/
#ifndef POP_MUSIC_TICKS_H
#define POP_MUSIC_TICKS_H
#define POP_MUS_TICKS_24 214 /* regular_death */
#define POP_MUS_TICKS_25 138 /* find_mirror_and_find_shadow */
#define POP_MUS_TICKS_26 794 /* embrace */
#define POP_MUS_TICKS_27 642 /* cutscene_before_2_4_6_12 */
#define POP_MUS_TICKS_28 460 /* fight_death */
#define POP_MUS_TICKS_29 300 /* meet_Jaffar */
#define POP_MUS_TICKS_30 210 /* life_potion */
#define POP_MUS_TICKS_32 438 /* end_level_4_and_killed_by_shadow */
#define POP_MUS_TICKS_33 144 /* potion */
#define POP_MUS_TICKS_35 623 /* cutscene_before_8_9 */
#define POP_MUS_TICKS_36 881 /* out_of_time */
#define POP_MUS_TICKS_37 196 /* guard_death_and_obtaining_the_sword */
#define POP_MUS_TICKS_39 1116 /* floating */
#define POP_MUS_TICKS_40 418 /* cutscene_not_much_time_left */
#define POP_MUS_TICKS_41 732 /* story_5_level_end_and_merge_with_shadow */
#define POP_MUS_TICKS_43 200 /* killed_Jaffar */
#define POP_MUS_TICKS_50 831 /* story_2_princess */
#define POP_MUS_TICKS_52 1714 /* story_4_Jaffar_leaves */
#define POP_MUS_TICKS_53 985 /* story_3_Jaffar_enters */
#define POP_MUS_TICKS_54 1448 /* intro_theme */
#define POP_MUS_TICKS_55 947 /* story_1_absence */
#define POP_MUS_TICKS_56 6923 /* won */
#endif /* POP_MUSIC_TICKS_H */
+1 -39
View File
@@ -1,5 +1,5 @@
/* pop_shadow_atlas.h — раскладка атласа ТЕНИ.
* СГЕНЕРЕНО toolchain/pop_pack_shadow.py НЕ править вручную.
* СГЕНЕРЕНО tools/pop_pack_shadow.py НЕ править вручную.
*
* Две половины, потому что и у оригинала их две (см. шапку
* упаковщика): кадры вне боя адресуют chtab_2 (спрайты Кида),
@@ -25,44 +25,6 @@
/* Половина «в бою» — SHADOW.DAT (chtab_5, кадры 150..189). */
#define POP_SHADOW_FGT_PAGES 4
static const char *const pop_shadow_kid_atl[POP_SHADOW_KID_PAGES] = {
"SHADOW\\sk0.atl",
"SHADOW\\sk1.atl",
"SHADOW\\sk2.atl",
"SHADOW\\sk3.atl",
"SHADOW\\sk4.atl",
"SHADOW\\sk5.atl",
"SHADOW\\sk6.atl",
"SHADOW\\sk7.atl",
"SHADOW\\sk8.atl",
"SHADOW\\sk9.atl",
"SHADOW\\sk10.atl",
"SHADOW\\sk11.atl",
"SHADOW\\sk12.atl",
"SHADOW\\sk13.atl",
"SHADOW\\sk14.atl",
"SHADOW\\sk15.atl",
"SHADOW\\sk16.atl",
"SHADOW\\sk17.atl",
"SHADOW\\sk18.atl",
"SHADOW\\sk19.atl",
"SHADOW\\sk20.atl",
"SHADOW\\sk21.atl",
"SHADOW\\sk22.atl",
"SHADOW\\sk23.atl",
"SHADOW\\sk24.atl",
"SHADOW\\sk25.atl",
"SHADOW\\sk26.atl",
"SHADOW\\sk27.atl",
};
static const char *const pop_shadow_fgt_atl[POP_SHADOW_FGT_PAGES] = {
"SHADOW\\sf0.atl",
"SHADOW\\sf1.atl",
"SHADOW\\sf2.atl",
"SHADOW\\sf3.atl",
};
/* 16 цветов, запись 4 байта (B, G, R, 0) — формат gfx_pal_load. */
static const uint8_t pop_shadow_pal[64] = {
0x00,0x00,0x00,0x00,0xD8,0xFC,0xFC,0x00,0x6C,0x80,0xDC,0x00,0x78,0x44,0x24,0x00,0xA0,0xB8,0xD8,0x00,0xB5,0x7C,0x21,0x00,0x00,0x90,0xB8,0x00,0x5D,0x6D,0xAB,0x00,0x0C,0xF0,0x6C,0x00,0xCC,0x38,0x05,0x00,0x38,0xCC,0xC0,0x00,0x58,0xDB,0xA7,0x00,0xBB,0x9F,0x54,0x00,0x5D,0x18,0x60,0x00,0x37,0xD7,0x37,0x00,0x2F,0x17,0xD2,0x00,
-113
View File
@@ -1,113 +0,0 @@
/* pop_sound_tbl.h — раскладка оцифрованных эффектов PoP.
* СГЕНЕРЕНО toolchain/pop_pack_sound.py НЕ править вручную.
*
* Данные: 8 бит беззнаковые, МОНО, 10937.5 Гц (CBL_FREQ_10K9) формат
* CBL один в один, конвертировать в рантайме нечего.
*
* Начало каждого звука кратно 128 размеру блока запроса CBL, а
* страница (16384) кратна ему же. Поэтому НИ ОДИН блок не пересекает
* границу страницы. Больше того: с 2026-08-25 каждый звук лежит
* ЦЕЛИКОМ в одной странице не влезает в остаток, значит начинаем
* новую (страницы EMM у нас с запасом). Единственное исключение
* эффект, который сам ДЛИННЕЕ страницы: для него насос переключает
* страницу на ходу, и эта ветка в pop_sfx_fill остаётся ради него.
*
* Смещение хранится РАЗОБРАННЫМ на страницу и адрес в окне: на Z80
* 32-битная арифметика дорога, а так всё влезает в 8/16 бит.
* Длина округлена вверх до блока неполных блоков не бывает.
*/
#ifndef POP_SOUND_TBL_H
#define POP_SOUND_TBL_H
#include <stdint.h>
#define POP_SND_PAGES 10
#define POP_SND_COUNT 57
#define POP_SND_BLOCK 128
/* Первый блок страницы 0 — тишина для насоса (см. упаковщик). */
#define POP_SND_SILENCE_PAGE 0
#define POP_SND_SILENCE_OFF 0x0000
typedef struct { uint8_t page; uint16_t off; uint16_t len; } pop_snd_ent_t;
/* len == 0 — у звука нет оцифровки (это музыка, см. sound_plan.md). */
static const pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT] = {
{ 0, 0x0080, 1664 }, /* 0 */
{ 0, 0x0700, 12032 }, /* 1 */
{ 1, 0x0000, 6272 }, /* 2 */
{ 1, 0x1880, 1280 }, /* 3 */
{ 1, 0x1D80, 1152 }, /* 4 */
{ 1, 0x2200, 1408 }, /* 5 */
{ 1, 0x2780, 5504 }, /* 6 */
{ 2, 0x0000, 896 }, /* 7 */
{ 2, 0x0380, 1664 }, /* 8 */
{ 2, 0x0A00, 2816 }, /* 9 */
{ 2, 0x1500, 4992 }, /* 10 */
{ 2, 0x2880, 1280 }, /* 11 */
{ 2, 0x2D80, 1280 }, /* 12 */
{ 3, 0x0000, 3840 }, /* 13 */
{ 3, 0x0F00, 7552 }, /* 14 */
{ 4, 0x0000, 17664 }, /* 15 */
{ 5, 0x0500, 4736 }, /* 16 */
{ 5, 0x1780, 4224 }, /* 17 */
{ 6, 0x0000, 6912 }, /* 18 */
{ 6, 0x1B00, 2816 }, /* 19 */
{ 6, 0x2600, 1408 }, /* 20 */
{ 6, 0x2B80, 1152 }, /* 21 */
{ 6, 0x3000, 1280 }, /* 22 */
{ 6, 0x3500, 1408 }, /* 23 */
{ 0, 0x0000, 0 }, /* 24 */
{ 0, 0x0000, 0 }, /* 25 */
{ 0, 0x0000, 0 }, /* 26 */
{ 0, 0x0000, 0 }, /* 27 */
{ 0, 0x0000, 0 }, /* 28 */
{ 0, 0x0000, 0 }, /* 29 */
{ 0, 0x0000, 0 }, /* 30 */
{ 0, 0x0000, 0 }, /* 31 */
{ 0, 0x0000, 0 }, /* 32 */
{ 0, 0x0000, 0 }, /* 33 */
{ 0, 0x0000, 0 }, /* 34 */
{ 0, 0x0000, 0 }, /* 35 */
{ 0, 0x0000, 0 }, /* 36 */
{ 0, 0x0000, 0 }, /* 37 */
{ 0, 0x0000, 0 }, /* 38 */
{ 0, 0x0000, 0 }, /* 39 */
{ 0, 0x0000, 0 }, /* 40 */
{ 0, 0x0000, 0 }, /* 41 */
{ 0, 0x0000, 0 }, /* 42 */
{ 0, 0x0000, 0 }, /* 43 */
{ 7, 0x0000, 2688 }, /* 44 */
{ 7, 0x0A80, 4224 }, /* 45 */
{ 7, 0x1B00, 4608 }, /* 46 */
{ 8, 0x0000, 5120 }, /* 47 */
{ 8, 0x1400, 5120 }, /* 48 */
{ 8, 0x2800, 4480 }, /* 49 */
{ 0, 0x0000, 0 }, /* 50 */
{ 9, 0x0000, 4864 }, /* 51 */
{ 0, 0x0000, 0 }, /* 52 */
{ 0, 0x0000, 0 }, /* 53 */
{ 0, 0x0000, 0 }, /* 54 */
{ 0, 0x0000, 0 }, /* 55 */
{ 0, 0x0000, 0 }, /* 56 */
};
/* Имена файлов видит ТОЛЬКО тот, кто попросил POP_SND_FILES (холодная
* половина): иначе копия строк уезжает в каждый включивший заголовок
* модуль, а в резиденте W1/W2 каждая сотня байт на счету. Тот же
* приём, что у POP_BG_ATLAS_NAMES в pop_bg_atlas.h. */
#ifdef POP_SND_FILES
static const char *const pop_snd_files[POP_SND_PAGES] = {
"SND\\s0.bin",
"SND\\s1.bin",
"SND\\s2.bin",
"SND\\s3.bin",
"SND\\s4.bin",
"SND\\s5.bin",
"SND\\s6.bin",
"SND\\s7.bin",
"SND\\s8.bin",
"SND\\s9.bin",
};
#endif
#endif
+33
View File
@@ -0,0 +1,33 @@
/*
* _pop_file.h внутренний файловый слой SprPoP для старых версий DSS.
*
* DSS до исправления относительных путей оставляет рабочий каталог внутри
* последнего подкаталога. POP_PATH_CALL восстанавливает абсолютный каталог
* приложения до и после операции. Это макрос намеренно: код исполняется в
* банке вызывающего, где доступен переданный указатель, и не расходует W1.
*/
#ifndef _POP_FILE_H
#define _POP_FILE_H
#include <stdint.h>
#include <errno.h>
/* BANK10: получить каталог EXE через APPINFO B=1 и сделать его текущим. */
int8_t pop_path_init(void) __banked;
int8_t pop_chdir_home(void) __banked;
/* expression вычисляется только после успешного chdir. errno файловой
* операции сохраняется через завершающее восстановление каталога. */
#define POP_PATH_CALL(result, expression) do { \
if (pop_chdir_home() != 0) { \
(result) = -1; \
} else { \
int _pop_path_errno; \
(result) = (expression); \
_pop_path_errno = errno; \
(void)pop_chdir_home(); \
errno = _pop_path_errno; \
} \
} while (0)
#endif
+10 -5
View File
@@ -1,17 +1,22 @@
/*
* _pop_sfx.h внутренний контракт между половинами звука (НЕ публичный,
* публичный pop_sfx.h). Горячая половина живёт в резиденте
* (pop_sfx.c), холодная в банке (pop_sfx_cold.c); делят они таблицу
* звуков, массив физических страниц и насос.
* (pop_sfx.c), холодная в банке (pop_sfx_cold.c); делят они массив
* физических страниц и насос.
*/
#ifndef _POP_SFX_INTERNAL_H
#define _POP_SFX_INTERNAL_H
#include <stdint.h>
#include "pop_sound_tbl.h" /* pop_snd_tbl, POP_SND_* — генерит упаковщик */
#include "pop_snd_tbl.h" /* тип записи и инварианты (НЕ генерируется) */
/* Физические страницы набора, по индексу из pop_snd_tbl[].page. */
extern uint8_t pop_snd_page[POP_SND_PAGES];
/* РАСКЛАДКА НАБОРА и его страницы — общие данные обеих половин, живут в
* pop_snd_data.c. Значения приезжают с диска (SND/snd.idx): холодная
* половина их читает и проверяет, горячая только пользуется. */
extern pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT];
extern uint8_t pop_snd_page[POP_SND_MAX_PAGES];
/* Сколько страниц у загруженного набора; 0 — набор не поднят. */
extern uint8_t pop_snd_pages;
/* 1 — набор поднят и CBL открыт; 0 — играем молча. */
extern uint8_t pop_snd_ok;
+64 -9
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();
@@ -1068,8 +1102,6 @@ static void check_hurting(void)
(of != FRAME_161_PARRY && of != FRAME_150_PARRY)) {
/* Соперник НЕ парирует. */
if (cf == FRAME_154_POKING) {
/* seg002:0DAE — свист клинка мимо цели. */
pop_sfx_play(11);
min_range = (uint8_t)(Opp.sword < SWORD_2_DRAWN ? 8 : 12);
distance = pop_char_opp_dist();
if (distance >= (int16_t)min_range && distance < 29)
@@ -1082,6 +1114,29 @@ static void check_hurting(void)
pop_char_set_seq(SEQ_69_ATTACK_WAS_PARRIED);
play_seq();
}
/* СВИСТ КЛИНКА МИМО ЦЕЛИ (seg002:1039..1044) — в оригинале он стоит в
* ХВОСТЕ функции, после ОБЕИХ веток, а не внутри ветки «не парировано».
* Мы его туда и переносим; раньше он звучал раньше времени и без двух
* условий оригинала, отчего на подъёме Кида шёл непрерывный взмах
* клинка у безоружного (наблюдение пользователя 2026-09-02, звук
* опознан по snd_curr == 11 в живой сессии).
*
* Три отличия от прежнего кода, все из оригинала:
*
* 1. Ранний выход по dir_56_none это ИМЕННО анти-зацикливание, автор
* SDLPoP так его и подписал («Fix looping sword moving sound»).
* Направление dir_56_none означает «персонаж выключен» (clear_char),
* и махать клинком ему нечем.
* 2. Условие `Opp.action != ACTION_99_HURT`: попали играет звук боли,
* а не свист мимо. Поэтому звук обязан идти ПОСЛЕ присвоения выше.
* 3. Кадры перечитываются у персонажей, а не берутся из cf/of: ветка
* парирования только что записала Opp.frame, а play_seq в ней
* Char.frame, и снимки выше уже устарели. */
if (Char.direction == DIR_56_NONE) return;
if (Char.frame == FRAME_154_POKING && Opp.frame != FRAME_161_PARRY &&
Opp.action != ACTION_99_HURT)
pop_sfx_play(11);
}
/* check_sword_hurting (seg002:0D1A): прогнать проверку с ОБЕИХ сторон. */
+2 -1
View File
@@ -12,6 +12,7 @@
#include <string.h>
#include <sprinter_mem.h>
#include "pop_arc.h"
#include "_pop_file.h"
/* Пути 8.3, как требует DSS. Держим их ЗДЕСЬ: строка обязана лежать в том
* же банке, что и код, который её читает (см. pop_arc.h). */
@@ -42,7 +43,7 @@ int8_t pop_arc_open(pop_arc_t *a, const char *path,
a->fd = -1;
a->count = 0;
fd = open(path, O_RDONLY);
POP_PATH_CALL(fd, open(path, O_RDONLY));
if (fd < 0) return -1;
if (read(fd, head, sizeof(head)) != (int)sizeof(head) ||
memcmp(head, "PBA1", 4) != 0 ||
+14 -3
View File
@@ -771,15 +771,26 @@ static void overlay_mid_tile(int row, int col)
pop_env_b(42, x, pop_tile_table[1].right_y + dmy);
{ /* draw_tile_base (seg008:0A8E) — ЦЕЛИКОМ, вместе с подстановками id:
* у loose верх плиты берётся из loose_fram_left (в pop_tile_table base_id=0),
* у opener'а без пола слева 148. Раньше здесь стоял голый base_id, и
* у opener'а без пола слева 148, НО только в ПОДЗЕМЕЛЬЕ. Условие
* `tbl_level_type[current_level] == 0` в оригинале стоит третьим
* (seg008:628) и у нас было потеряно ИМЕННО ЗДЕСЬ: статическая
* отрисовка (pop_room.c, draw_tile_base) его имеет, а оверлей нет.
* Отсюда симптом «кнопка, на которую лезет Кид, залита кирпичом»:
* пока тайл рисует статика, кнопка правильная, но как только Kid
* встаёт под кромку, оверлей перерисовывает её поверх него уже
* спрайтом 148, а во дворце это другая картинка (кладку палас
* вообще рисует заливками, а не спрайтами). Уровень 4, комната 18,
* кнопка (0,4) найдено пользователем 2026-09-02.
*
* Раньше здесь стоял голый base_id, и
* верх loose-плиты в оверлей не попадал: передняя грань ложилась поверх
* Kid (foretable), а сама плита нет, и подтягивающийся Kid рисовался
* поверх её верхней плоскости (комната 12). */
uint8_t base_id = t->base_id;
if (code == 11 && row >= 0)
base_id = POP_LOOSE_FRAM_LEFT[pop_loose_frame(pop_loose_modif[row * 10 + col])];
else if (code == 0x0F && lcode == 0)
base_id = 148;
else if (code == 0x0F && lcode == 0 && !pop_palace)
base_id = 148; /* ТОЛЬКО ПОДЗЕМЕЛЬЕ (seg008:628) */
if (base_id)
pop_env_b(base_id, x, t->base_y + dmy);
}
+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 {
+4 -3
View File
@@ -29,6 +29,7 @@
#include "pop_cheat.h"
#include "pop_sfx.h"
#include "_pop_config.h"
#include "_pop_file.h"
#define CFG_BASE 0x100
@@ -151,7 +152,7 @@ static uint8_t cfg_read_file(uint8_t *buf, uint16_t *size)
int fd, got;
long n;
fd = open("POP.CFG", O_RDONLY);
POP_PATH_CALL(fd, open("POP.CFG", O_RDONLY));
if (fd < 0) return 0;
n = lseek(fd, 0, SEEK_END);
if (n < POP_CFG_MIN_SIZE || n > POP_CFG_MAX_SIZE ||
@@ -172,7 +173,7 @@ int8_t pop_cfg_load(void) __banked
if (!cfg_read_file(buf, &size) || !pop_cfg_decode(buf, size)) return 0;
if (buf[9] != POP_PROFILE_VANILLA)
printf("POP.CFG: ENHANCED profile -> VANILLA\n");
puts("POP.CFG: ENHANCED profile -> VANILLA\n");
return 1;
}
@@ -192,7 +193,7 @@ int8_t pop_cfg_save(void) __banked
gfx_w0_unmap();
/* Не unlink: bank_save_file сам делает O_TRUNC и не создаёт лишнего
* окна, когда POP.CFG вообще отсутствует после ошибки unlink. */
rc = bank_save_file(page, CFG_BASE, "POP.CFG", sizeof(buf));
POP_PATH_CALL(rc, bank_save_file(page, CFG_BASE, "POP.CFG", sizeof(buf)));
mem_free_block(blk);
return (rc == sizeof(buf)) ? 1 : 0;
}
+7 -2
View File
@@ -31,6 +31,7 @@
#include "pop_title.h" /* титульная картинка после ввода имени */
#include "pop_music.h" /* подкачка потоковой темы победы */
#include "pop_hof.h"
#include "_pop_file.h"
/* MAX_HOF_COUNT оригинала — шесть строк. */
#define HOF_COUNT 6
@@ -162,7 +163,7 @@ static void hof_load(void)
if (!blk) return;
page = mem_get_page(blk, 0);
gfx_w0_page_prepare(page);
got = bank_load_file(page, HOF_BASE, "POP.HOF", HOF_SIZE);
POP_PATH_CALL(got, bank_load_file(page, HOF_BASE, "POP.HOF", HOF_SIZE));
if (got == HOF_SIZE) {
gfx_w0_map(page);
for (i = 0; i < HOF_SIZE; i++)
@@ -187,7 +188,11 @@ static void hof_save(void)
gfx_w0_unmap();
/* Неудача записи не меняет только что показанную таблицу: следующее
* открытие разумно начнёт с прежнего файла или с пустого списка. */
bank_save_file(page, HOF_BASE, "POP.HOF", HOF_SIZE);
{
int saved;
POP_PATH_CALL(saved,
bank_save_file(page, HOF_BASE, "POP.HOF", HOF_SIZE));
}
mem_free_block(blk);
}
+200 -136
View File
@@ -15,7 +15,6 @@
#include "pop_shadow.h"
#include "pop_sfx.h"
#include "pop_music.h"
#include "pop_music_ticks.h" /* POP_MUS_TICKS_<id> — длина реплик набора */
#include "pop_arc.h"
#include "pv_arc.h"
#include "pop_cutscene.h"
@@ -60,9 +59,10 @@
* своего куска (например, всё поведение Джафара от PV_DIALOG1_START).
* 2) «ЖДЁМ КОНЦА РЕПЛИКИ» PV_M50_END и PV_EXIT_START. Это целиком
* длина НАШЕЙ записи, и она у каждого набора своя: m50 831 тик на
* flac против 867 на mt32, m53 985 против 1044. Поэтому берём их
* из POP_MUS_TICKS_<id>, которые печатает упаковщик того набора,
* которым собрана сборка (gen/pop_music_tbl.h).
* flac против 867 на mt32, m53 985 против 1044. Раньше их
* печатал упаковщик в gen/pop_music_ticks.h, из-за чего EXE был
* привязан к набору; теперь они приезжают из индекса набора
* (MUS/mus.idx) при входе в сцену см. ЯКОРЯ ниже.
*
* Всё, что стоит ПОСЛЕ реплики, сдвигается вместе с её концом отсюда
* сложения от PV_M50_END и PV_EXIT_START вместо литералов. */
@@ -72,48 +72,76 @@ enum {
/* Конец m50: кадр становится 8. Прежний литерал 846 отвечал длине 834
* тика на три больше нашей записи (831); расхождение осталось с
* времён, когда длину подставляли руками. */
PV_M50_END = PV_MUS_2_START + POP_MUS_TICKS_50,
PV_WAIT_END = PV_M50_END + 40, /* +frame(5) -> створка ворот */
PV_GATE_END = PV_WAIT_END + 48,
PV_DOOR_END = PV_GATE_END + 24,
PV_TURN_START = PV_DOOR_END,
PV_WALK1_START = PV_DOOR_END + 40,
PV_DIALOG1_START = PV_WALK1_START + 48, /* Джафар входит — реплика m53 */
/* Ниже — биты сценария внутри реплики Джафара, все от её начала. */
PV_MUS_4_LOAD = PV_DIALOG1_START + 54, /* подкачка m52 */
PV_WALK2_START = PV_DIALOG1_START + 176,
PV_DIALOG2_START = PV_DIALOG1_START + 416,
/* ЗАКЛИНАНИЕ СДВИНУТО РАНЬШЕ СЦЕНАРИЯ — ПОДГОНКА ПОД МУЗЫКУ.
*
* Сцена render-bound: кадр рисуется дороже своего бюджета, кадровая
* ветка пейсинга догона не имеет, и анимация идёт ~49 тиков в секунду
* вместо 60. Реплика Джафара при этом играет по РЕАЛЬНОМУ времени и
* успевает кончиться, когда шкала стоит на 1854 вместо 2028 (замер в
* MAME 2026-08-27 зондом на конец трека: длина насосу отдана полная,
* 1403 порции, курсор дошёл до последней страницы трек доигрывает
* целиком, отстаёт именно картинка).
*
* Из-за этого кода реплики приходила примерно на секунду раньше
* молнии. Честное лечение удешевить кадр сцены; до тех пор двигаем
* жест целиком (замах, шаг назад, вспышка), чтобы он не сломался
* внутри себя. Величина подобрана НА СЛУХ и уточняется прогоном:
* расчётные полсотни тиков (секунда отставания при ~49 тиках/с) на
* слух почти не изменили картины, поэтому взято вдвое больше.
*
* Эффектов дверей это не касается: у них и звук, и картинка идут по
* одной и той же шкале и разъехаться не могут. */
PV_MAGIC_LEAD = 100,
PV_RAISE_START = PV_DIALOG1_START + 696 - PV_MAGIC_LEAD,
PV_STEPBACK_START = PV_DIALOG1_START + 703 - PV_MAGIC_LEAD,
PV_MAGIC_START = PV_DIALOG1_START + 822 - PV_MAGIC_LEAD,
/* Конец m53 — снова длина записи, а не бит сценария. */
PV_EXIT_START = PV_DIALOG1_START + POP_MUS_TICKS_53,
PV_MUS_4_START = PV_EXIT_START + 42, /* +frame(6) -> «Джафар уходит» */
PV_GLASS_DONE = PV_EXIT_START + 210,
PV_SLUMP_START = PV_EXIT_START + 273,
PV_ANIM_TICKS = PV_EXIT_START + 469
PV_MAGIC_LEAD = 0
};
/* ПОДГОНКА ЗАКЛИНАНИЯ ПОД МУЗЫКУ СНЯТА (2026-08-28): теперь 0.
*
* История: сцена была render-bound кадр рисовался дороже своего
* бюджета, кадровая ветка пейсинга догона не имела, и анимация шла
* ~49 тиков в секунду вместо 60 (замер 2026-08-27). Реплика Джафара
* при этом играет по РЕАЛЬНОМУ времени, поэтому кода приходила
* примерно на секунду раньше молнии, и жест целиком (замах, шаг
* назад, вспышка) сдвигали раньше сценария на подобранные на слух
* 100 тиков.
*
* Причину убрали: отрисовка разложена по кадровым интервалам
* (pv_restore_bg), сцена идёт по единственным часам кадрам луча,
* и подкачка трека оплачивается интервалом (pop_music_load_step).
* После этого подгонка стала вредной: молния била больше чем на
* секунду раньше коды (проверка пользователем 2026-08-28). Константу
* оставляем на месте если запись другого набора (mt32/ogg) снова
* разъедется, крутить надо её, а не тайминги сценария. */
/* ЯКОРЯ ШКАЛЫ. Четыре точки, от которых отсчитывается всё остальное;
* считаются ОДИН РАЗ при входе в сцену из длительностей набора
* (pop_music_info). Держать их статикой, а не тащить структуру времён
* через пять функций: восемь байт против переделки всех сигнатур, а
* сцена всё равно одна на запуск.
*
* Нет индекса или трека длительность нулевая, и сцена просто проходит
* без пауз на реплики: ждать нечего, звука-то нет. */
static uint16_t pv_m50_end; /* конец «принцесса ждёт» (m50) */
static uint16_t pv_dialog1; /* Джафар входит — начало реплики m53 */
static uint16_t pv_exit; /* конец m53: Джафар уходит */
static uint16_t pv_anim_end; /* конец всей сцены */
/* Ниже — те же выражения, что были в enum: биты сценария отсчитываются от
* своего якоря и от записи НЕ зависят. */
#define PV_M50_END pv_m50_end
#define PV_WAIT_END ((uint16_t)(pv_m50_end + 40))
#define PV_GATE_END ((uint16_t)(pv_m50_end + 88))
#define PV_DOOR_END ((uint16_t)(pv_m50_end + 112))
#define PV_TURN_START PV_DOOR_END
#define PV_WALK1_START ((uint16_t)(pv_m50_end + 152))
#define PV_DIALOG1_START pv_dialog1
#define PV_MUS_4_LOAD ((uint16_t)(pv_dialog1 + 54))
#define PV_WALK2_START ((uint16_t)(pv_dialog1 + 176))
#define PV_DIALOG2_START ((uint16_t)(pv_dialog1 + 416))
#define PV_RAISE_START ((uint16_t)(pv_dialog1 + 696 - PV_MAGIC_LEAD))
#define PV_STEPBACK_START ((uint16_t)(pv_dialog1 + 703 - PV_MAGIC_LEAD))
#define PV_MAGIC_START ((uint16_t)(pv_dialog1 + 822 - PV_MAGIC_LEAD))
#define PV_EXIT_START pv_exit
#define PV_MUS_4_START ((uint16_t)(pv_exit + 42))
#define PV_GLASS_DONE ((uint16_t)(pv_exit + 210))
#define PV_SLUMP_START ((uint16_t)(pv_exit + 273))
#define PV_ANIM_TICKS pv_anim_end
/* Собрать шкалу под НАШ набор записей. 200 — цепочка битов сценария от
* конца m50 до входа Джафара (40+48+24+40+48), 469 хвост после его
* ухода; оба от записи не зависят. */
static void pv_timing_init(void)
{
pop_mus_info_t m50, m53;
(void)pop_music_info(POP_MUS_STORY_2, &m50);
(void)pop_music_info(POP_MUS_STORY_3, &m53);
pv_m50_end = (uint16_t)(PV_MUS_2_START + m50.ticks);
pv_dialog1 = (uint16_t)(pv_m50_end + 200);
pv_exit = (uint16_t)(pv_dialog1 + m53.ticks);
pv_anim_end = (uint16_t)(pv_exit + 469);
}
/* Возврат долга шкалы (см. цикл сцены): сколько порций насоса отдаём за один
* кадр сцены и сколько их вообще имеет смысл копить. 1 порция 11,7 мс;
* 128 порций полторы секунды, дальше догонять уже нечего. */
@@ -430,6 +458,44 @@ static int intro_pv_draw_base(void)
return 0;
}
/* ВОССТАНОВЛЕНИЕ ФОНА ЗАДНЕЙ СТРАНИЦЫ — ДВУМЯ БЛОКАМИ ПО КАДРОВОМУ ИНТЕРВАЛУ.
*
* Персонажи и живой фон рисуются банком спрайтов и в ОЗУ-копию не оседают,
* поэтому «стереть прошлый кадр» = скопировать чистый фон из теневой копии
* соседней страницы. Раньше это была ОДНА копия на весь экран, и она стоила
* ~547 000 тактов = 1,27 кадра луча (модель blit_cost_model). Хвост кадра
* сцены ждал vsync ПОСЛЕ отрисовки, так что эти 1,27 кадра ПРИБАВЛЯЛИСЬ к
* пяти интервалам делителя: сцена шла 6+ кадров вместо 5, то есть ~8,2 fps
* вместо десяти (замер 49/60 тиков docs/BUGS_OPEN.md PV-RENDER-BOUND).
*
* Лечение не «удешевить», а РАЗЛОЖИТЬ по интервалам: каждый блок заведомо
* влезает в кадр, и после него честно ждём vsync. Тогда отрисовка живёт
* ВНУТРИ делителя, а не сверх него.
* - копируем только картинку (200 строк с POP_YOFF), а не все 256:
* сверху и снизу чёрная рамка, туда никто не рисует;
* - двумя половинами по 100 строк: ~219 000 тактов каждая, чуть больше
* половины кадра запас двукратный.
*
* ТРАТИТ РОВНО ДВА кадровых интервала (по gfx_wait_vsync на блок) это
* учтено в раскладке кадра у всех трёх сцен (см. вызовы). */
#define PV_BG_Y0 POP_YOFF
#define PV_BG_H 200
static void pv_restore_bg(void)
{
gfx_rect_t r;
r.x = 0;
r.w = GFX_WIDTH;
r.h = PV_BG_H / 2;
r.y = PV_BG_Y0;
gfx_copy_page(&r, GFX_COPY_DIRECT);
gfx_wait_vsync();
r.y = PV_BG_Y0 + PV_BG_H / 2;
gfx_copy_page(&r, GFX_COPY_DIRECT);
gfx_wait_vsync();
}
static void pv_blit_floor(const atlas_t *at, uint8_t frame, int x, int floor)
{
const uint8_t *img;
@@ -701,12 +767,10 @@ static uint8_t intro_pv_draw_frame(const atlas_t *princess, const atlas_t *j0,
uint8_t sand_frame, uint8_t flash,
uint16_t tick)
{
gfx_rect_t full;
uint8_t back = (uint8_t)(gfx_get_visible_page() ^ 1);
gfx_set_draw_page(back);
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
gfx_copy_page(&full, GFX_COPY_DIRECT);
pv_restore_bg(); /* блоки 1-2: фон, два интервала */
gfx_set_bank(GFX_BANK_SPRITE);
/* y=116 в princess_room_torch — нижняя строка, не верх спрайта. */
pv_blit_floor(torch, torch_left, 93, 116);
@@ -730,7 +794,7 @@ static uint8_t intro_pv_draw_frame(const atlas_t *princess, const atlas_t *j0,
* его надо повторить ПОСЛЕ актёров, иначе Jaffar проходит перед
* правой передней колонной. */
pv_blit_floor(hourglass, 0, 240, 167);
gfx_wait_vsync();
gfx_wait_vsync(); /* блок 3: актёры и декорации */
gfx_set_visible_page(back);
if (flash) {
uint8_t pulse;
@@ -743,9 +807,9 @@ static uint8_t intro_pv_draw_frame(const atlas_t *princess, const atlas_t *j0,
pop_pal_flash_white(0);
gfx_wait_vsync();
}
return 11;
return 13; /* три интервала кадра + десять на молнию */
}
return 1;
return 3; /* сколько кадровых интервалов съели */
}
/* Сцена pv_scene() SDLPoP: ожидание Princess с факелами/звёздами, поворот,
@@ -759,12 +823,7 @@ static int intro_pv_animated(void)
uint16_t tick;
uint16_t frame_end;
uint16_t render_tick;
uint16_t beam = 0; /* запасной путь: кадров луча ждали */
uint16_t snd_done = 0; /* тиков насоса прошло с начала сцены */
uint16_t snd_want = 0; /* сколько их должно было пройти */
uint16_t snd_acc = 0; /* дробный остаток пересчёта 57/40 */
uint8_t snd_prev = 0;
uint8_t snd_pace = 0;
uint16_t beam = 0; /* кадров луча потрачено с начала сцены */
uint8_t period;
uint8_t anim_step = 0;
uint8_t torch_left = 1;
@@ -813,18 +872,22 @@ static int intro_pv_animated(void)
* Полноэкранная accel-копия кадра насосу не мешает: leaf режет DI
* бандами по 16 строк. */
(void)pop_sfx_start();
/* ЧЕМ МЕРИТЬ ВРЕМЯ. Пока сцена была немой, кадры просто считали
* ожидания vsync и этого хватало. С музыкой выяснилось, что так
* считаются НАШИ ожидания, а не прошедшее время: полноэкранная копия
* страницы (~0,55 млн тактов) съедает больше кадра луча, и сцена
* незаметно отстаёт в MAME молния била уже ПОСЛЕ конца реплики.
* Часы берём у насоса CBL: он идёт от расхода буфера железом, 85,4 Гц,
* и ему безразлично, чем занят главный цикл. Если звук выключен,
* часы стоят тогда работает прежний путь по кадрам луча. */
snd_prev = pop_snd_tick;
gfx_wait_vsync();
snd_pace = (uint8_t)(pop_snd_tick != snd_prev); /* часы реально идут? */
snd_prev = pop_snd_tick;
/* ЧЕМ МЕРИТЬ ВРЕМЯ — ЕДИНЫЕ ЧАСЫ: КАДРЫ ЛУЧА.
*
* Была вторая шкала тики насоса CBL, и выбор между ними делался
* гонкой на старте сцены (одна выборка `pop_snd_tick` через кадр:
* успел ли диск раскрутить звук). Значит от прогона к прогону сцена
* шла то по одним часам, то по другим, и кода реплики приходилась
* каждый раз на другое место картинки (наблюдение пользователя
* 2026-08-28; баг SND-PACE-DEAD). Насос как часы был нужен потому,
* что кадр сцены рисовался ДОЛЬШЕ своего интервала и счёт наших
* ожиданий врал; теперь отрисовка разложена на блоки по кадровому
* интервалу (см. pv_restore_bg), и счёт кадров луча честен.
*
* Осталось учесть единственную работу, которая в интервал не влезала
* сама, подкачку страницы трека: шаг читает ПОЛСТРАНИЦЫ (~16 мс,
* pop_music_load_step) и оплачивается ровно одним интервалом, как
* любой другой блок кадра. */
result = 0;
princess_actor.x = 120;
princess_actor.frame = 11; /* Pstand, facing left */
@@ -837,6 +900,7 @@ static int intro_pv_animated(void)
jaffar_actor.visible = 0;
jaffar_actor.step = 0;
tick = 0;
pv_timing_init(); /* шкала под длительности НАШЕГО набора */
intro_skip_begin(&skip);
while (tick < PV_ANIM_TICKS) {
if (intro_skip_requested(&skip)) {
@@ -892,59 +956,42 @@ static int intro_pv_animated(void)
torch_right, sand_frame, flash,
render_tick);
pop_sfx_tick(); /* как в оригинале — в конце отрисовки кадра */
/* ГАРАНТИРОВАННЫЙ ШАГ ПОДКАЧКИ, а не «если останется время».
*
* Ниже страница следующей реплики читается в ПАУЗЕ кадра, и это
* Раньше страница следующей реплики читалась в ПАУЗЕ кадра, и это
* верно ровно до тех пор, пока пауза есть. В этой сцене её почти
* нет: кадр дорогой, шкала работает по принципу «долг не догоняем»
* (snd_want подтягивается к snd_done), и цикл ожидания выходит
* сразу. Замер в MAME 2026-08-26: ОДНА страница за семь секунд,
* то есть одиннадцать страниц m53 не успевали за всю сцену
* pop_music_play() на своём тике не находил готового трека, и
* реплики Джафара пропадали совсем.
* не было: кадр дорогой, шкала работает по принципу «долг не
* догоняем», и цикл ожидания выходил сразу. Замер в MAME
* 2026-08-26: ОДНА страница за семь секунд, то есть одиннадцать
* страниц m53 не успевали за всю сцену pop_music_play() на своём
* тике не находил готового трека, и реплики Джафара пропадали
* совсем (memory pv_music_stall_regression).
*
* Страница стоит 33 мс против кадра сцены в 100-133 мс, поэтому
* один безусловный шаг в кадр сцену не ломает: он лишь тратит
* часть той же паузы, а если паузы не было добавляет к кадру
* четверть его длины, и шкала это учтёт сама. */
if (pop_music_loading()) (void)pop_music_load_step();
* Поэтому шаг безусловный и оплачен как обычный блок кадра: он
* читает полстраницы (~16 мс), а следующий gfx_wait_vsync ставит
* нас на край интервала, который мы этим и заняли. */
if (pop_music_loading()) {
(void)pop_music_load_step();
gfx_wait_vsync();
beam++;
}
/* Первый кадр собран под чёрной палитрой — теперь проявляем его
* целиком, вместе с принцессой и факелами. */
if (!faded_in) {
faded_in = 1;
if (pop_pal_fade_in_skip(INTRO_FADE)) { result = 1; break; }
}
/* ПЕЙСИНГ ПО ОРИГИНАЛЬНОЙ ШКАЛЕ. Кадр cutscene — 6/8/7 тиков по
* 1/60 с; один тик = 85,4/60 = 1,424 тика насоса, и 57/40 = 1,425
* повторяет это с точностью 0,07 % (30 мс на всю сцену) без
* деления в кадре и без 32-битной арифметики. */
if (snd_pace) {
snd_acc += (uint16_t)period * 57u;
while (snd_acc >= 40u) { snd_acc -= 40u; snd_want++; }
for (;;) {
uint8_t now = pop_snd_tick;
/* Разность в uint8_t: счётчик переполняется каждые 3 с, а
* заглядываем мы в него не реже раза в кадр. */
snd_done += (uint8_t)(now - snd_prev);
snd_prev = now;
if (snd_done >= snd_want) break;
/* Страницу следующей реплики читаем ЗДЕСЬ, в паузе кадра:
* 33 мс диска укладываются в ожидание (кадр сцены 133 мс),
* и подкачка не добавляется к длительности кадра, а
* растворяется в ней. */
if (pop_music_loading()) (void)pop_music_load_step();
else gfx_wait_vsync();
}
/* ДОЛГ НЕ ДОГОНЯЕМ. Подкачка следующей реплики (страница — 33 мс)
* и любой тяжёлый кадр оставляют snd_done впереди snd_want, и
* дальше цикл гнал кадры без единого ожидания: пламя факелов
* мелькало, а события соседних тиков (створка ворот и дверь
* покоев) слипались в один кадр заявка перезаписывала заявку, и
* один из звуков пропадал. Тот же принцип, что у pop_pace_end:
* якорь ставится ПО ФАКТУ, пропущенное время не наверстывается. */
if (snd_done > snd_want) snd_want = snd_done;
} else {
/* ПЕЙСИНГ ПО ОРИГИНАЛЬНОЙ ШКАЛЕ. Кадр сцены — 6/8/7 тиков по
* 1/60 с (pv_seq_period), в кадры луча переводит POP_T60, и цель
* КУМУЛЯТИВНА: нецелые 6,67 и 5,83 не копят ошибку.
*
* ДОЛГ НЕ ДОГОНЯЕМ: если кадр вышел дороже своего бюджета, цикл
* ожидания просто не сработает, но beam уже учёл потраченное и
* следующая цель считается от той же общей шкалы. Наверстывать
* пропущенное нельзя: события соседних тиков (створка ворот, дверь
* покоев) слиплись бы в один кадр, и один из звуков пропал бы. */
{
uint16_t target = POP_T60(tick);
while (beam < target) { gfx_wait_vsync(); beam++; }
@@ -995,7 +1042,6 @@ static uint8_t pre_hourglass_state(void)
static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
{
atlas_t hourglass, torch;
gfx_rect_t full;
uint8_t torch_left = 1, torch_right = 6, sand = 0, frame;
uint8_t star_phase[6] = { 1, 1, 1, 1, 1, 1 };
intro_skip_t skip;
@@ -1025,7 +1071,6 @@ static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
pop_screen_present(1);
result = 0;
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
intro_skip_begin(&skip);
for (frame = 0; frame < frames; frame++) {
uint8_t back, hold, star;
@@ -1033,7 +1078,7 @@ static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
if (intro_skip_requested(&skip)) { result = 1; break; }
back = (uint8_t)(gfx_get_visible_page() ^ 1);
gfx_set_draw_page(back);
gfx_copy_page(&full, GFX_COPY_DIRECT);
pv_restore_bg(); /* блоки 1-2: фон, два интервала */
gfx_set_bank(GFX_BANK_SPRITE);
pv_blit_floor(&torch, torch_left, 93, 116);
pv_blit_floor(&torch, torch_right, 211, 116);
@@ -1046,10 +1091,11 @@ static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
pv_blit_floor(&hourglass, (uint8_t)(8 + sand), 160, 164);
}
pv_blit_floor(&hourglass, 0, 240, 167); /* передняя колонна */
gfx_wait_vsync();
gfx_wait_vsync(); /* блок 3: живой фон */
gfx_set_visible_page(back);
/* Первый тик уже потрачен на commit: ещё четыре = 5/50 с. */
for (hold = 1; hold < 5; hold++) gfx_wait_vsync();
/* Три интервала уже потрачены на блоки отрисовки — добираем до
* пяти (5/50 с = кадр катсцены оригинала). */
for (hold = 3; hold < 5; hold++) gfx_wait_vsync();
/* Проявляем ПОСЛЕ первого кадра: иначе две секунды fade идут по
* пустой комнате, а часы и пламя возникают уже на свету. */
@@ -1128,8 +1174,14 @@ static void intro_restore_game_palette(void)
/* Набор PV2 (res901..930) не влезает в одну EMM: image 0..16 в a1,
* 17..29 в a2. Номер кадра при этом остаётся исходным image-id. */
#define CUT_PV2_SPLIT 17
/* Один логический кадр катсцены = 5 vsync, как у остальных PV-сцен
* (cutscene_frame_time SDLPoP; см. pre_room_animated). */
/* Один логический кадр катсцены = 5 vsync, как у остальных PV-сцен: у
* оригинала `cutscene_frame_time = 6` тиков по 1/60 с (reset_cutscene,
* seg001:527, и его зовёт load_intro прямо перед сценой) = 100 мс, у нас
* 5 кадров луча по 50 Гц = те же 100 мс.
*
* РАСКЛАДКА ПО ИНТЕРВАЛАМ (иначе отрисовка прибавлялась к делителю, а не
* жила внутри него см. pv_restore_bg): фон половинами 1-2, актёры и
* декорации 3, служебный блок (звук/подкачка музыки) 4-5. */
#define CUT_FRAME_VSYNC 5
static atlas_t cut_pv1, cut_pv2a, cut_pv2b, cut_hg, cut_torch;
@@ -1290,11 +1342,9 @@ static void cut_shad_init(uint8_t x, uint8_t y, uint8_t seq)
* прервал сцену (любое НОВОЕ нажатие). */
static uint8_t cut_run(uint16_t frames, uint8_t kid_visible)
{
gfx_rect_t full;
uint16_t i;
uint8_t hold;
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
for (i = 0; i < frames; i++) {
uint8_t back;
kframe kid_fr, pr_fr;
@@ -1316,8 +1366,9 @@ static uint8_t cut_run(uint16_t frames, uint8_t kid_visible)
back = (uint8_t)(gfx_get_visible_page() ^ 1);
gfx_set_draw_page(back);
/* Чистый фон обеих страниц лежит в shadow-копии; персонажи рисуются
* банком спрайтов и потому в неё не оседают. */
gfx_copy_page(&full, GFX_COPY_DIRECT);
* банком спрайтов и потому в неё не оседают. Двумя блоками по
* кадровому интервалу см. pv_restore_bg. */
pv_restore_bg(); /* блоки 1-2 */
gfx_set_bank(GFX_BANK_SPRITE);
pv_blit_floor(&cut_torch, cut_torch_l, 93, 116);
pv_blit_floor(&cut_torch, cut_torch_r, 211, 116);
@@ -1339,12 +1390,17 @@ static uint8_t cut_run(uint16_t frames, uint8_t kid_visible)
* сквозь неё поймано пользователем 2026-08-25). */
pv_blit_floor(&cut_hg, 0, 240, 167);
gfx_set_bank(GFX_BANK_NORMAL);
gfx_wait_vsync();
gfx_wait_vsync(); /* блок 3: актёры и декорации */
gfx_set_visible_page(back);
for (hold = 1; hold < CUT_FRAME_VSYNC; hold++) gfx_wait_vsync();
/* Блок 4 — служебный, и он НЕ добавляется к кадру, а живёт внутри
* оставшихся интервалов: подкачка страницы трека стоит ~33 мс, то
* есть меньше двух интервалов, которые всё равно надо переждать.
* Порядок как в сцене с Джафаром: шаг подкачки безусловный (см.
* memory pv_music_stall_regression). */
pop_sfx_tick();
pop_music_service(); /* «встреча с принцессой» — трек, а не эффект */
for (hold = 3; hold < CUT_FRAME_VSYNC; hold++) gfx_wait_vsync();
cut_torch_l = (uint8_t)((cut_torch_l + 1) % 9);
cut_torch_r = (uint8_t)((cut_torch_r + 1) % 9);
cut_sand = (uint8_t)((cut_sand + 1) % 3);
@@ -1662,7 +1718,9 @@ uint8_t pop_intro_show(void) __banked
/* Первая реплика PV читается ЗДЕСЬ, под чёрным экраном и в тишине:
* дальше внутри сцены остаётся только пуск, а следующие две реплики
* подкачиваются по странице за кадр. */
pop_sfx_pause();
/* Глушение снято 2026-08-28: массовое чтение страниц насос не морит
* (замер на загрузке уровня ни одной пропущенной порции), а трек
* титров может и должен доигрывать. */
(void)pop_music_load(POP_MUS_STORY_2);
pv_result = intro_pv_animated();
if (pv_result > 0) {
@@ -1740,15 +1798,21 @@ uint8_t pop_intro_show(void) __banked
* дальше, а не чтобы досидеть до конца трека. */
static void pre_cut_finish(uint8_t aborted)
{
if (!aborted) {
intro_skip_t tail;
intro_skip_begin(&tail);
while (pop_music_busy() && !intro_skip_requested(&tail))
pop_wait_edge();
}
/* ТРЕК ПЕРЕЖИВАЕТ СЦЕНУ — как в оригинале. load_intro (seg001:690)
* ничего не ждёт: гасит экран и возвращает управление, а звук играет
* дальше поверх загрузки уровня и первых секунд игры. Мы вместо этого
* ДОСИЖИВАЛИ трек на чёрном экране (27 10,7 с против 2,6 с картинки),
* то есть дарили игроку восемь секунд пустого экрана, и только потом
* глушили. ЗАМЕР 2026-08-28: снято вместе с глушением на загрузке
* уровня; если загрузка не укладывается в дедлайн насоса (251 000
* тактов), вернуть оба места разом.
*
* Пропуск сцены по-прежнему обрывает и музыку: игрок нажал клавишу,
* чтобы идти дальше, а не чтобы дослушать. */
if (aborted) {
pop_music_free();
pop_sfx_pause();
}
intro_restore_game_palette();
}
@@ -1756,7 +1820,7 @@ void pop_pre_cutscene_show(pop_pre_cutscene_t scene) __banked
{
const pop_cs_cmd_t *script = 0;
pop_sfx_pause();
/* Глушение перед чтением трека снято 2026-08-28 (см. замер насоса). */
/* МУЗЫКА ПЕРЕЖИВАЕТ СЦЕНУ. Треки заставок между уровнями длиннее самих
* сцен (27 10,7 с против 2,6 с картинки), и оригинал их не обрывает:
* load_intro просто гасит экран и возвращает управление, а звук
@@ -1824,7 +1888,7 @@ int pop_intro_hof_bg(uint8_t page) __banked
void pop_time_expired_show(void) __banked
{
pop_sfx_pause();
/* Глушение снято 2026-08-28 (см. замер насоса). */
/* time_expired (seg001:04D3) начинается с play_sound(sound_36). */
if (pop_music_load(36) == 0) {
(void)pop_sfx_start();
@@ -1853,7 +1917,7 @@ void pop_time_expired_show(void) __banked
void pop_ending_show(void) __banked
{
pop_sfx_pause();
/* Глушение снято 2026-08-28 (см. замер насоса). */
/* Экран гасим ПЕРВЫМ действием — как load_intro оригинала, который
* начинается с draw_rect(&screen_rect, color_0_black). Иначе загрузка
* ресурсов сцены идёт поверх ЖИВОЙ комнаты уровня 14, и она «мелькает»
+6 -9
View File
@@ -18,18 +18,14 @@
#include "pop_guard.h" /* pop_guard_vflip_load — зеркала соперника */
#include "pop_arc.h"
#include "kid_arc.h"
#include "_pop_file.h"
/* Серии в архиве обязаны лежать подряд — так их кладёт список в Makefile,
* и только поэтому индекс считается сложением. Если порядок собьётся,
* сборка встанет здесь, а не рассыплется картинкой на экране. */
#if (ARC_KID_KID27 - ARC_KID_KID0) != 27
#error "kid0..kid27 в архиве не подряд"
#if (ARC_KID_SWORD - ARC_KID_KID0_V) != KID_PAGES
#error "обычная и зеркальная серии Kid должны иметь одинаковую длину"
#endif
#if (ARC_KID_KID27_V - ARC_KID_KID0_V) != 27
#error "kid0_v..kid27_v в архиве не подряд"
#endif
#define KID_MAXPAGES 28
/* Таблица архива живёт на стеке вызывающего (4 байта на запись, здесь 232)
* держать её постоянно незачем, а W2 у игры и без того тесный. */
@@ -39,7 +35,7 @@ int pop_kid_load(uint8_t npages) __banked
pop_arc_t arc;
uint8_t i;
if (npages > KID_MAXPAGES) npages = KID_MAXPAGES;
if (npages > KID_PAGES) npages = KID_PAGES;
if (pop_arc_open_id(&arc, POP_ARC_KID, tbl, ARC_KID_COUNT) < 0) return -1;
for (i = 0; i < npages; i++)
if (pop_arc_atlas(&arc, tbl, (uint8_t)(ARC_KID_KID0 + i),
@@ -75,7 +71,8 @@ int pop_kid_data_load(const char *path) __banked
blk=mem_alloc_pages(1);
if (!blk) return -1;
page=mem_get_page(blk,0);
n=bank_load_file(page,KD_DATA_OFF,path,16384-KD_DATA_OFF);
POP_PATH_CALL(n, bank_load_file(page,KD_DATA_OFF,path,
16384-KD_DATA_OFF));
if (n < (int)(KID_BIN_SEQTBL_OFF + KID_SEQTBL_LEN)) {
mem_free_block(blk); return -1;
}
+7 -3
View File
@@ -18,6 +18,7 @@
#include "pop_map.h" /* pop_clip_char_top (clip_char) */
#include "pop_geom.h"
#include "pop_level.h" /* POP_LEVEL_LAST — level 15 исключён */
#include "pop_cutscene.h" /* POP_T60 — тики оригинала в кадры луча */
#include <fcntl.h>
#include <unistd.h>
#include <sprinter_mem.h>
@@ -42,8 +43,7 @@ uint8_t pop_next_level;
* НЕ static: набор спрайтов слота POP_CH_KID общей отрисовки персонажей
* (pop_cdraw.c, банк 4). Отдан как есть, а не через аксессор: читается раз
* в кадр из другого банка, и вызов стоил бы дороже самого чтения. */
#define KID_MAXPAGES 28
atlas_t kidp[KID_MAXPAGES];
atlas_t kidp[KID_PAGES];
uint8_t kid_npages;
/* Таблица «seq_id -> адрес в seqtbl» (original_seqtbl_offsets). Определена
@@ -358,7 +358,11 @@ void play_seq(void)
else if (ev == 4) {
/* seg006:646. На четвёртом уровне уход за дверь — это
* встреча с тенью, и трек там свой; на 13-м (Джафар) и
* 15-м оригинал молчит. */
* 15-м оригинал молчит. Держать уровень на экране,
* пока трек звучит, забота главного цикла: он ждёт
* pop_music_active(), и своей задержки здесь больше
* нет (была по длине записи из gen/pop_music_ticks.h,
* из-за чего EXE зависел от набора музыки). */
if (pop_current_level == 4) pop_sfx_play(32);
else if (pop_current_level != 13) pop_sfx_play(41);
}
+6
View File
@@ -13,8 +13,13 @@
#include <stdint.h>
#include "pop_char.h"
#include "kid_data.h" /* kframe — тип кадра для pop_sword_draw */
#include "kid_arc.h" /* границы серий атласов в kid.arc */
#include <sprite.h> /* atlas_t — набор спрайтов Кида (см. kidp ниже) */
/* Число страниц Кида берём из метаданных архива: зеркальная серия начинается
* сразу после обычной. Не дублировать литералом в загрузчике/хранилище. */
#define KID_PAGES (ARC_KID_KID0_V - ARC_KID_KID0)
/* Состояние персонажа — общий тип для Кида, стража и Char (pop_char.h). */
extern pop_char_t Kid;
@@ -26,6 +31,7 @@ extern int8_t knock;
* пройден». Читает и обнуляет главный цикл. Пока уровень один, поэтому
* там рестарт того же уровня; загрузка следующего levels_plan.md. */
extern uint8_t pop_next_level;
/* Задержка смены уровня на время мелодии конца (см. pop_kid.c). */
/* Загрузить npages атласов Kid (kid0..kid{npages-1}.atl). ДО initgraph.
* 0 OK, -1 ошибка. */
+1 -1
View File
@@ -45,7 +45,7 @@ extern uint8_t pop_current_level;
void pop_level_qsave(pop_qs_io_t *io);
/* Загрузить уровень НОМЕРОМ: освободить страницу прошлого, взять
* `LEVELS\res20NN.bin` (fallback `a:\res20NN.bin`) и выставить
* `LEVELS\res20NN.bin` относительно каталога EXE и выставить
* pop_current_level. 0 OK, -1 файла нет / не влез (тогда
* pop_current_level и страница НЕ тронуты играем дальше на старом).
* Порт связки load_lev_spr + load_level (seg000:1098/1169). */
+6 -5
View File
@@ -17,6 +17,7 @@
#include "pop_level.h"
#include "_pop_level.h"
#include "pop_geom.h" /* pop_x_bump / FIRST_ONSCREEN_COLUMN — pos_guards */
#include "_pop_file.h"
/* Низкоуровневое чтение через W3 выполняет резидентный bank_load_file();
* остальная одноразовая подготовка уровня безопасно остаётся в банке 8. */
@@ -58,7 +59,8 @@ static int level_read_file(const char *path)
blk=mem_alloc_pages(1);
if (!blk) return -1;
page=mem_get_page(blk,0);
n=bank_load_file(page,LVL_DATA_OFF,path,16384-LVL_DATA_OFF);
POP_PATH_CALL(n, bank_load_file(page,LVL_DATA_OFF,path,
16384-LVL_DATA_OFF));
if (n < (int)MIN_SIZE) { mem_free_block(blk); return -1; }
gfx_w0_page_prepare(page);
gfx_w0_map(page);
@@ -80,18 +82,17 @@ int pop_level_load_num(uint8_t n) __banked
/* Имена патчим на месте, а не через sprintf: printf-семейство тянет в
* резидент W1 форматтер, которого здесь больше негде применить. */
static char pri[] = "LEVELS\\res2000.bin"; /* цифры — [12],[13] */
static char alt[] = "a:\\res2000.bin"; /* цифры — [8],[9] */
uint8_t saved_blk = pop_lvl_blk, saved_page = pop_lvl_page, saved_ok = pop_lvl_ok;
if (n > POP_LEVEL_LAST) return -1;
pri[12] = alt[8] = (char)('0' + n / 10);
pri[13] = alt[9] = (char)('0' + n % 10);
pri[12] = (char)('0' + n / 10);
pri[13] = (char)('0' + n % 10);
/* Старую страницу отпускаем ТОЛЬКО после успешной загрузки новой:
* level_read_file сам аллоцирует страницу, и если файла нет остаёмся
* на текущем уровне вместо падения в пустой уровень. */
pop_lvl_ok = 0; /* чтобы level_load_path не мешался */
if (level_read_file(pri) != 0 && level_read_file(alt) != 0) {
if (level_read_file(pri) != 0) {
pop_lvl_blk = saved_blk; pop_lvl_page = saved_page; pop_lvl_ok = saved_ok;
return -1;
}
+87 -8
View File
@@ -549,6 +549,13 @@ 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(); }
/* расстояние до края тайла (для in_wall). */
static int distance_to_edge(int xpos)
{
@@ -558,8 +565,15 @@ static int distance_to_edge(int xpos)
if (Char.direction == 0) d = TILE_RIGHTX - d; /* dir_0_right */
return d;
}
static int distance_to_edge_weight(void) { return distance_to_edge(dx_weight()); }
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));
}
/* ---- ТОЧНАЯ коллизия со стеной (seg004) --------------------------- *
* Передний край персонажа = char_x_right (взгляд вправо) / char_x_left
* (влево) из set_char_collision (seg006:0723): obj_x/2 + 58, где obj_x =
@@ -682,6 +696,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 +725,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);
@@ -808,20 +839,25 @@ static uint8_t can_grab_front_above(void)
/* check_grab (seg006:0A28): в падении при зажатом Shift — зацепиться за
* уступ спереди-сверху (seq_15), если скорость падения ещё мала и высота
* подходящая. Выравнивает передний край по кромке, гасит fall_y, взводит
* grab_timer (блок climb-up на несколько кадров). */
static void check_grab(void)
* grab_timer (блок climb-up на несколько кадров).
*
* ВОЗВРАТ: 1 зацеп СОСТОЯЛСЯ на этом кадре. Нужен вызывающему (do_fall):
* дальше по кадру идёт glide_through_wall_guard, и ему нельзя трогать
* персонажа, которого мы только что повесили на кромку (см. там же). */
static uint8_t check_grab(void)
{
uint8_t old_x;
if (!pop_ctrl_shift_held()) return; /* Shift не зажат */
if ((uint8_t)Char.fall_y >= 32) return; /* падает слишком быстро */
if ((uint16_t)pop_y_land[Char.curr_row + 1] > (uint16_t)(Char.y + 25)) return;
if (!pop_ctrl_shift_held()) return 0; /* Shift не зажат */
if ((uint8_t)Char.fall_y >= 32) return 0; /* падает слишком быстро */
if ((uint16_t)pop_y_land[Char.curr_row + 1] > (uint16_t)(Char.y + 25)) return 0;
old_x = Char.x;
Char.x = (uint8_t)char_dx_forward(-8);
determine_col();
if (!can_grab_front_above()) {
Char.x = old_x; /* не за что — назад */
determine_col();
} else {
return 0;
}
Char.x = (uint8_t)char_dx_forward((int8_t)distance_to_edge_weight());
Char.y = (uint8_t)pop_y_land[Char.curr_row + 1];
Char.fall_y = 0;
@@ -831,7 +867,7 @@ static void check_grab(void)
grab_timer = 12;
pop_sfx_play(9); /* seg006 check_grab */
is_screaming = 0; /* seg006:1219 */
}
return 1;
}
static void make_loose_fall(int pos, uint8_t modifier);
@@ -898,6 +934,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);
@@ -907,7 +977,16 @@ 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(); /* ещё летит — попытка зацепа */
/* ЗАЦЕП ОТМЕНЯЕТ GUARD. seq_15 — «grab ledge MIDAIR», то есть после
* удачного зацепа Char.action == 3, персонаж уже подтянут вплотную к
* кромке, а под кромкой в оригинальных комнатах стоит КЛАДКА. Guard,
* позванный следом, видел ровно её (t == TILE_WALL, d == 10), считал
* это «пролётом сквозь стену», отбрасывал Кида на 5 пикселей назад и
* гасил fall_x зацеп рисовался и тут же срывался. Ловилось на
* длинном прыжке уровня 3 (комната 7 -> кромка комнаты 2) и в
* attract-демо; регресс-набор tests/host/t_glide.c. */
if (!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/
* респавн). */
+62 -21
View File
@@ -27,6 +27,7 @@
#include "pop_settings.h"
#include "pop_cfg.h"
#include "pop_qsave.h"
#include "pop_music.h" /* pop_music_service — подкачка кольца, см. ui_wait_frame */
#include "pop_status.h"
#include "pop_sfx.h"
@@ -196,10 +197,21 @@ static uint8_t ui_begin(void)
gfx_rect_t full;
uint8_t back = (uint8_t)(gfx_get_visible_page() ^ 1);
/* Полностраничная accel-копия держит DI дольше половины CBL-буфера.
* Закрываем вывод, иначе железо повторяет старые 128 байт (скрежет).
* ui_end откроет его уже с заново заполненной тишиной. */
pop_sfx_pause();
/* ЗВУК ЗДЕСЬ БОЛЬШЕ НЕ ГЛУШИМ (снято 2026-08-28).
*
* Был вызов pop_sfx_pause() с обоснованием «полностраничная accel-копия
* держит DI дольше половины CBL-буфера, иначе железо повторяет старые
* 128 байт». Обоснование устарело: лист, который зовёт gfx_copy_page
* (_bgi_scroll_rows_raw), режет DI бандами по 16 строк это ~1,6 мс
* против дедлайна насоса 11,7 мс, и окно прерываний между бандами есть.
* Тот же полностраничный копир каждый кадр делают игровой цикл и
* катсцены, и звук там не рвётся.
*
* А цена была слышимой: pause закрывает CBL целиком (cbl_close), курсор
* трека остаётся на месте, и короткая мелодия начала уровня замирала
* ровно на время перерисовки меню «тормозится на кусочек» (жалоба
* пользователя 2026-08-28). У оригинала такого нет и быть не может:
* там нет насоса, который надо кормить руками. */
gfx_set_draw_page(back);
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
gfx_copy_page(&full, GFX_COPY_DIRECT);
@@ -207,10 +219,32 @@ static uint8_t ui_begin(void)
return back;
}
/* КАДР МЕНЮ: перед ожиданием фронта — обслужить музыку.
*
* Насос CBL живёт в прерывании и главный цикл ему не нужен, НО потоковый
* трек (титульная тема играется КОЛЬЦОМ, pop_music.c) дочитывается с диска
* именно из pop_music_service: очередная страница попадает в слот, который
* насос уже прошёл. Пока открыто меню, главный цикл стоит, и без этого
* вызова кольцо опустошалось за несколько секунд музыка замолкала до
* закрытия меню, а на перемещениях по пунктам это слышно как паузы
* (жалоба пользователя 2026-08-28).
*
* Место рядом с ожиданием кадра: получается тот же темп 50 раз в
* секунду, что и в игровом цикле, и подкачка не чаще, чем нужна. */
static void ui_wait_frame(void)
{
pop_music_service();
pop_wait_edge();
}
static void ui_end(uint8_t back)
{
pop_wait_edge(); /* готовый кадр показать только на фронте */
ui_wait_frame(); /* готовый кадр показать только на фронте */
gfx_set_visible_page(back);
/* Парный к снятому pop_sfx_pause (см. ui_begin) вызов оставлен: он
* идемпотентен (открытый CBL возвращает 0 сразу) и чинит случай, когда
* в меню вошли при закрытом выводе например сразу после загрузки
* уровня, которая глушит звук намеренно. */
(void)pop_sfx_start();
}
@@ -227,7 +261,7 @@ static void ui_wait_release(void)
kbd_raw_down(KBD_SPACE) ||
kbd_raw_down(KBD_UP) || kbd_raw_down(KBD_DOWN) ||
kbd_raw_down(KBD_LEFT) || kbd_raw_down(KBD_RIGHT))
pop_wait_edge();
ui_wait_frame();
}
/* Действие = Enter ИЛИ Space: в оригинале выбор подтверждается обеими, и
@@ -250,7 +284,7 @@ static void ui_wait_ack(void)
return;
}
esc_prev = esc; act_prev = act;
pop_wait_edge();
ui_wait_frame();
}
}
@@ -478,7 +512,7 @@ static uint8_t ui_confirm(const char *question)
confirm_draw(question, sel);
menu_sound(MENU_SND_MOVE);
}
pop_wait_edge();
ui_wait_frame();
}
}
@@ -522,10 +556,12 @@ static uint8_t settings_finish(uint8_t changed)
int8_t rc;
if (!changed) return 0;
pop_settings_apply(1);
/* ESTEX может уйти в диск на много CBL-периодов. */
pop_sfx_pause();
/* Звук не глушим: POP.CFG — десятки байт, а на практике HOF пишется
* под звучащий «won» и насос это переживает (проверка пользователя
* 2026-08-28). Прежняя осторожность «ESTEX может уйти в диск на много
* CBL-периодов» относилась к загрузке НАБОРА страниц, а не к записи
* одного мелкого файла. */
rc = pop_cfg_save();
(void)pop_sfx_start();
ui_notice(rc > 0 ? "SETTINGS SAVED" : "SAVE ERROR");
return 1;
}
@@ -609,17 +645,16 @@ static uint8_t settings_process(void)
settings_draw(sel);
menu_sound(sound);
}
pop_wait_edge();
ui_wait_frame();
}
}
static int8_t menu_quickload(void)
{
uint8_t slots;
/* probe делает open/close на D:, поэтому насос в это время выключен. */
pop_sfx_pause();
/* probe — это только open/close на D:, данных не читает; глушить насос
* ради него незачем (см. settings_finish). */
slots = pop_qsave_probe();
(void)pop_sfx_start();
if (slots & POP_QS_SLOT_SAVE) return POP_MENU_QUICKLOAD;
if ((slots & POP_QS_SLOT_BACKUP) && ui_confirm("LOAD BACKUP?"))
return POP_MENU_QUICKLOAD_BACKUP;
@@ -641,14 +676,14 @@ static void menu_erase(uint8_t entry_page)
/* Из shadow видимой страницы берём кадр без текстов. */
gfx_set_draw_page(hidden);
gfx_copy_page(&full, GFX_COPY_DIRECT);
pop_wait_edge();
ui_wait_frame();
gfx_set_visible_page(hidden);
/* Теперь чистая hidden — источник для второй страницы. */
gfx_set_draw_page(visible);
gfx_copy_page(&full, GFX_COPY_DIRECT);
if (entry_page != hidden) {
pop_wait_edge();
ui_wait_frame();
gfx_set_visible_page(entry_page);
}
}
@@ -679,13 +714,18 @@ int8_t pop_menu_process(void) __banked
/* Открыли по НАЖАТИЮ Esc: дождаться отпускания, иначе то же нажатие
* закроет меню раньше первого кадра. pop_wait_edge опрашивает FIFO. */
while (kbd_raw_down(KBD_ESC)) pop_wait_edge();
while (kbd_raw_down(KBD_ESC)) ui_wait_frame();
entry_page = gfx_get_visible_page();
saved_page = gfx_get_draw_page();
saved_bank = gfx_get_bank();
getfillsettings(&saved_fill);
pop_sfx_pause(); /* snapshot/dim + первый full redraw */
/* Звук НЕ глушим (снято 2026-08-28, вторая половина той же заплатки —
* см. ui_begin). Здесь под ней были снимок палитры, затемнение фона и
* первая полная перерисовка: копия режет DI бандами по 16 строк, а
* палитровое затемнение идёт и в титрах с катсценами, где музыку оно не
* рвёт. Цена глушения была слышна как пауза в мелодии ровно на
* появление меню. */
pop_pal_snapshot();
/* 50 % яркости; диапазон UI остаётся ярким. Долю берём от шкалы, а не
* числом: ступеней стало 32 вместо четырёх, и прежняя «2» означала бы
@@ -742,11 +782,12 @@ int8_t pop_menu_process(void) __banked
menu_draw(sel);
menu_sound(sound);
}
pop_wait_edge();
ui_wait_frame();
pop_pace_end(pop_pace_n(0)); /* игра стоит — обычный темп */
}
pop_sfx_pause(); /* две full-page копии menu_erase */
/* И на закрытии не глушим: menu_erase — те же две полностраничные
* копии, что и в ui_begin, с DI бандами по 16 строк. */
menu_erase(entry_page);
/* menu_erase вернул обе страницы из тени, снятой ДО меню: всё, что
* статус-полосы нарисовали за это время (или сообщение, дотикавшее в
+142 -16
View File
@@ -13,13 +13,82 @@
#include <fcntl.h>
#include <unistd.h>
#include <sprinter_mem.h>
#include <string.h>
#include <sprite.h> /* gfx_w0_map/unmap — доступ к странице индекса */
#include "pop_music.h"
#include "pop_music_tbl.h"
#include "pop_sfx.h"
#include "_pop_sfx.h" /* pop_snd_ok — открыт ли вывод (см. pop_music_busy) */
#include "pop_app.h" /* POP_APP_DEMO — в демке музыки нет */
#include "_pop_file.h"
uint8_t pop_mus_page[POP_MUS_PAGES];
/* --- ИНДЕКС НАБОРА ---------------------------------------------------- *
*
* Страница EMM с прочитанным mus.idx; 0 индекса нет. В W2 от него
* остаются ровно эти два байта, сама таблица (228 Б) лежит в странице.
*
* Данные лежат со смещения POP_MUS_IDX_BASE, а не с начала страницы:
* gfx_w0_page_prepare пишет туда стабы прерываний (0x38 и 0x66), и с
* нуля они попали бы прямо в записи. Со смещением всё расходится, и
* страница подключается штатно без запрета прерываний. */
static uint8_t mus_idx_blk;
static uint8_t mus_idx_page;
/* "MUS\mus.idx" — имя знает сам модуль (тот же приём, что в pop_arc.c:
* литерал лежит в rodata банка 9, и open() читает его, пока в W3 стоит
* этот банк). */
static const char mus_idx_path[] = "MUS\\mus.idx";
int8_t pop_music_index_load(void) __banked
{
uint8_t *w0 = (uint8_t *)POP_MUS_IDX_BASE;
uint8_t blk, page, ok;
int fd;
if (mus_idx_page) return 0;
POP_PATH_CALL(fd, open(mus_idx_path, O_RDONLY));
if (fd < 0) return -1;
blk = mem_alloc_pages(1);
if (!blk) { close(fd); return -1; }
page = mem_get_page(blk, 0);
if (!page ||
bank_read_page(fd, page, POP_MUS_IDX_BASE, POP_MUS_IDX_SIZE) < 0) {
close(fd);
mem_free_block(blk);
return -1;
}
close(fd);
gfx_w0_page_prepare(page); /* стабы лягут вне данных, см. BASE */
gfx_w0_map(page);
ok = (uint8_t)(memcmp(w0, "PMI1", 4) == 0 &&
w0[4] == POP_MUS_COUNT &&
w0[5] == POP_MUS_IDX_REC &&
w0[6] == 7 && /* log2(128) — блок насоса */
w0[7] == 1); /* u8 mono, CBL_FREQ_10K9 */
gfx_w0_unmap();
if (!ok) { mem_free_block(blk); return -1; }
mus_idx_blk = blk;
mus_idx_page = page;
return 0;
}
int8_t pop_music_info(uint8_t id, pop_mus_info_t *out) __banked
{
const uint8_t *rec;
const uint8_t *w0 = (const uint8_t *)POP_MUS_IDX_BASE;
out->blocks = 0;
out->ticks = 0;
if (!mus_idx_page || id >= POP_MUS_COUNT) return -1;
gfx_w0_map(mus_idx_page);
rec = w0 + POP_MUS_IDX_HDR + (uint16_t)id * POP_MUS_IDX_REC;
out->blocks = (uint16_t)(rec[0] | ((uint16_t)rec[1] << 8));
out->ticks = (uint16_t)(rec[2] | ((uint16_t)rec[3] << 8));
gfx_w0_unmap();
return out->blocks ? 0 : -1;
}
/* Два слота: играющий и подгружаемый. Своей таблицы страниц слот НЕ
* держит её и так хранит блок EMM, и mem_get_page отдаёт номер по
* индексу. Резидентный pop_mus_page[] заполняется на pop_music_play, для
@@ -49,8 +118,12 @@ static uint8_t ld_busy;
static int ld_fd;
static uint8_t ld_slot;
static uint8_t ld_next; /* следующая страница к чтению */
static uint8_t ld_half; /* 0 = впереди первая половина страницы */
static uint8_t ld_pages;
static uint8_t ld_id;
/* Длина трека из индекса, снятая при открытии: лезть в страницу индекса
* на каждом шаге загрузки незачем она не меняется. */
static uint16_t ld_blocks;
static void slot_release(uint8_t n)
{
@@ -110,26 +183,31 @@ void pop_music_free(void) __banked
* ПОСТРАНИЧНО, А НЕ ЦЕЛИКОМ. Реплики сцены с принцессой идут встык, и
* следующую надо читать, пока звучит текущая. Трек до 320 КБ, целиком
* это 0,7 с диска: на 7,5 кадрах в секунду сцена встала бы на пять кадров.
* Одна страница стоит 33 мс меньше четверти кадра, и подкачка растворя-
* ется в анимации. Кто может позволить себе паузу (титры, чёрный экран
* Шаг стоит 16 мс (полстраницы, см. pop_music_load_step) меньше одного
* кадрового интервала, и подкачка растворяется в анимации. Кто может позволить себе паузу (титры, чёрный экран
* между сценами), зовёт pop_music_load тот же цикл, но целиком. */
static void load_abort(void)
{
if (ld_busy) close(ld_fd);
ld_busy = 0;
ld_half = 0;
slot_release(ld_slot);
}
int8_t pop_music_load_begin(uint8_t id) __banked
{
char name[12];
pop_mus_info_t info;
uint8_t pages, n;
if (ring_on) pop_music_ring_stop(); /* потоковый трек и слоты не дружат */
if (ld_busy) load_abort();
if (id >= POP_MUS_COUNT) return -1;
pages = pop_mus_tbl[id].pages;
if (!pages || pages > POP_MUS_PAGES) return -1;
/* Длину и число страниц знает ИНДЕКС НАБОРА, а не EXE: у mt32 те же
* треки другой длины (docs/music_runtime_index_plan.md). */
if (pop_music_info(id, &info) != 0) return -1;
pages = pop_music_pages(info.blocks);
if (pages > POP_MUS_PAGES) return -1;
/* Грузим ВСЕГДА в не текущий слот: играющий трек читает свои страницы
* из прерывания, и отобрать их у него нельзя. */
@@ -139,36 +217,49 @@ int8_t pop_music_load_begin(uint8_t id) __banked
/* Трек лежит ОДНИМ файлом, и fd остаётся открытым до последней
* страницы: `open` стоит дороже самого чтения (51,4 мс против 32,6 мс
* на 16 КБ), и платить его на каждую страницу чистые потери. */
ld_fd = open(name, O_RDONLY);
POP_PATH_CALL(ld_fd, open(name, O_RDONLY));
if (ld_fd < 0) return -1;
slot_blk[n] = mem_alloc_pages(pages);
if (!slot_blk[n]) { close(ld_fd); return -1; }
ld_busy = 1;
ld_slot = n;
ld_next = 0;
ld_half = 0;
ld_pages = pages;
ld_id = id;
ld_blocks = info.blocks;
return 0;
}
/* ШАГ = ПОЛСТРАНИЦЫ (8 КБ, ~16 мс), а не страница (~33 мс).
*
* Шаг вызывают раз в кадр из циклов, где кадр разложен на блоки по одному
* кадровому интервалу (20 мс): катсцены после перехода на блочную
* отрисовку, см. pop_intro.c/pv_restore_bg. Целая страница в интервал не
* влезала и незаметно растягивала кадр сцены; половина влезает, а цена
* вдвое больше вызовов (11 страниц реплики = 22 кадра вместо 11, то есть
* меньше полусекунды). Игровому циклу это безразлично: там логический
* кадр 80-100 мс. */
int8_t pop_music_load_step(void) __banked
{
uint8_t p;
if (!ld_busy) return 1; /* нечего грузить */
p = mem_get_page(slot_blk[ld_slot], ld_next);
/* Последняя страница короче — читаем сколько есть; ошибку ловим по
* отрицательному коду, а не по «не 16384». */
if (!p || bank_read_page(ld_fd, p, 0, 16384) < 0) {
/* Последняя страница короче — читаем сколько есть (вторая половина
* может дать и ноль байт); ошибку ловим по отрицательному коду. */
if (!p || bank_read_page(ld_fd, p, ld_half ? 8192 : 0, 8192) < 0) {
load_abort();
return -1;
}
if (!ld_half) { ld_half = 1; return 0; } /* дочитаем в следующий раз */
ld_half = 0;
if (++ld_next < ld_pages) return 0;
close(ld_fd);
ld_busy = 0;
slot_pages[ld_slot] = ld_pages;
slot_blocks[ld_slot] = pop_mus_tbl[ld_id].blocks;
slot_blocks[ld_slot] = ld_blocks;
slot_id[ld_slot] = ld_id;
mus_ready = (uint8_t)(ld_slot + 1);
return 1;
@@ -214,7 +305,7 @@ int8_t pop_music_stream(uint8_t id) __banked
if (n != mus_cur) slot_release(mus_cur);
mus_cur = n;
slot_pages[n] = ld_pages;
slot_blocks[n] = pop_mus_tbl[id].blocks;
slot_blocks[n] = ld_blocks;
slot_id[n] = id;
pop_mus_id = id;
pop_mus_pg = 0;
@@ -268,19 +359,25 @@ void pop_music_ring_stop(void) __banked
int8_t pop_music_ring_start(uint8_t id) __banked
{
char name[12];
pop_mus_info_t info;
uint8_t i, page;
if (id >= POP_MUS_COUNT || !pop_mus_tbl[id].pages) return -1;
if (id >= POP_MUS_COUNT || pop_music_info(id, &info) != 0) return -1;
/* Потоковый трек НЕ ограничен POP_MUS_PAGES: он и не помещается в
* память целиком, ради того и кольцо. Предел здесь восьмибитные
* счётчики страниц (у mt32 финальная тема на 82 страницы против 78
* у flac). */
if (pop_music_pages(info.blocks) > 255) return -1;
pop_music_free(); /* обычные слоты кольцу не нужны */
pop_music_ring_stop();
mus_name(name, id);
ring_fd = open(name, O_RDONLY);
POP_PATH_CALL(ring_fd, open(name, O_RDONLY));
if (ring_fd < 0) return -1;
ring_fd_open = 1;
ring_blk = mem_alloc_pages(RING_PAGES);
if (!ring_blk) { ring_close_file(); return -1; }
ring_file_pages = pop_mus_tbl[id].pages;
ring_total = pop_mus_tbl[id].blocks;
ring_file_pages = (uint8_t)pop_music_pages(info.blocks);
ring_total = info.blocks;
/* Заполняем кольцо целиком: старт с полным буфером даёт девять секунд
* форы, и первая же тяжёлая сцена финала не догонит насос. */
for (i = 0; i < RING_PAGES; i++) {
@@ -358,5 +455,34 @@ void pop_music_service(void) __banked
uint8_t pop_music_busy(void) __banked
{
return (uint8_t)(pop_mus_left != 0);
/* «ЗВУЧИТ», А НЕ «ЕСТЬ КУРСОР». Курсор трека двигает насос CBL, и пока
* вывод закрыт (pop_sfx_pause на загрузке уровня, Ctrl+S, показ заставки
* после Restart Game) pop_mus_left не убывает НИКОГДА. Вызывающий,
* который ждёт конца мелодии, в этом состоянии висит вечно ровно так
* заставка после Restart Game вставала на экране Prince of Persia
* (поймано пользователем 2026-09-02). Корень того случая был в другом
* (CBL не открывался заново, см. pop_boot_sound), но ждать неиграющую
* музыку нельзя в принципе, поэтому признак спрашивает и про вывод. */
return (uint8_t)(pop_snd_ok && pop_mus_left != 0);
}
/* ТРЕК В РАБОТЕ — заявка лежит, идёт загрузка или он звучит.
*
* Одного pop_music_busy() там, где ждут КОНЦА мелодии, мало: между
* заявкой (pop_sfx_play кладёт pop_mus_req) и первым звуком проходит
* 190-230 мс открытие файла и чтение первой полустраницы (замер и
* разбор docs/sound_plan.md §9). В этой щели busy ещё ложен, и
* ожидание закончилось бы, не начавшись.
*
* Три признака подряд закрывают её без единой подобранной константы:
* pop_music_service обнуляет req и тут же взводит ld_busy в load_begin,
* а тот держится до самой записи pop_mus_left. Снаружи переход
* «запрошен -> грузится -> звучит» непрерывен, потому что между этими
* операторами главный цикл не выполняется.
*
* И наоборот: если трека нет или память кончилась, все три ложны сразу
* ждать нечего, как и должно быть. */
uint8_t pop_music_active(void) __banked
{
return (uint8_t)(pop_mus_req || ld_busy || pop_mus_left);
}
+45
View File
@@ -28,6 +28,47 @@
* либо резать, либо стримить, и это отдельная задача. */
#define POP_MUS_PAGES 20
/* --- ИНДЕКС НАБОРА (MUS/mus.idx, формат PMI1) ------------------------- *
*
* Длины треков и длительности реплик приезжают С ДИСКА, а не из EXE: так
* набор записей (flac/mp3/ogg/mt32) меняется без перекомпиляции игры.
* Формат пишет tools/pop_idx.py, он же покрыт make test-tools.
*
* Индекс живёт в ОДНОЙ странице EMM, а не в W2: таблица на 57 записей
* 228 байт, а свободной кучи в резиденте порядка двух сотен. Наружу
* указатель на EMM не выдаётся: pop_music_info копирует запись в буфер
* вызывающего, на короткое время подключая страницу в W0. */
#define POP_MUS_COUNT 57 /* пространство id оригинала, 0..56 */
#define POP_MUS_IDX_REC 4 /* blocks (u16) + ticks60 (u16) */
#define POP_MUS_IDX_HDR 16
#define POP_MUS_IDX_SIZE 512 /* сектор DSS */
/* Индекс кладётся в страницу СО СМЕЩЕНИЯ 0x100, а не с нуля. Причина в
* gfx_w0_page_prepare: он делает страницу безопасной в W0, записывая в
* неё переход на IRQ-восстановитель (0x38) и RETN для NMI (0x66). С
* нуля эти байты легли бы прямо в записи (id 10 и 21) и испортили
* индекс; со смещением 0x100 стабы и данные не пересекаются, и работает
* штатная защита от прерываний, а не запрет их. Тот же приём и по той
* же причине в pop_config.c (CFG_BASE). */
#define POP_MUS_IDX_BASE 0x100
typedef struct {
uint16_t blocks; /* длина трека в порциях насоса по 128 байт */
uint16_t ticks; /* та же длина в тиках сцены (60 Гц оригинала) */
} pop_mus_info_t;
/* Поднять индекс (один раз за запуск). 0 — ок, -1 — нет файла либо он
* не той версии; во втором случае музыки не будет, но игра работает. */
int8_t pop_music_index_load(void) __banked;
/* Запись трека в буфер вызывающего. -1 — индекса нет, id вне диапазона
* или трека нет в наборе (тогда поля нулевые). */
int8_t pop_music_info(uint8_t id, pop_mus_info_t *out) __banked;
/* Сколько EMM-страниц занимает трек такой длины. Считается, а НЕ хранится
* в индексе: дублирование однажды разъедется с blocks. */
#define pop_music_pages(blocks) \
(uint16_t)(((blocks) >> 7) + (((blocks) & 127) != 0))
/* Номера треков — нумерация оригинала (SDLPoP data/music/names.txt). */
#define POP_MUS_INTRO 54 /* тема заставки, звучит на титрах */
#define POP_MUS_STORY_1 55 /* «In the absence of the Sultan...» */
@@ -102,4 +143,8 @@ void pop_music_ring_stop(void) __banked;
/* Трек ещё звучит? 0 — доиграл или не запускался. */
uint8_t pop_music_busy(void) __banked;
/* «Трек в работе»: заявка/загрузка/звучание. Для мест, где ждут КОНЦА
* мелодии, busy там ложен в окне запуска (см. pop_music.c). */
uint8_t pop_music_active(void) __banked;
#endif /* POP_MUSIC_H */
+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, а читают циклы
* ожидания перечитывать обязаны каждый оборот. */
+8 -6
View File
@@ -14,10 +14,12 @@
#include "pop_guard.h"
#include "pop_pal.h"
#include "pop_ui.h" /* POP_COL_OUTSIDE */
#include "_pop_file.h"
static void pal_file_load(const char *path, const char *fallback)
static void pal_file_load(const char *path)
{
if (gfx_pal_fload(0, path) < 0) gfx_pal_fload(0, fallback);
int rc;
POP_PATH_CALL(rc, gfx_pal_fload(0, path));
gfx_pal_sync();
pop_ui_palette_snapshot();
}
@@ -84,7 +86,7 @@ void pop_pal_menu_dim(uint8_t fade) __banked
void pop_pal_game_load(void) __banked
{
pal_file_load("KID\\kid.pal", "a:\\kid.pal");
pal_file_load("KID\\kid.pal");
/* kid.pal содержит все 256 записей и намеренно затирает эти три
* динамических диапазона (тайлсет, Тень, набор соперника).
* Восстановить их надо ДО snapshot. */
@@ -110,7 +112,7 @@ void pop_pal_level_load(uint8_t full) __banked
void pop_pal_title_load(void) __banked
{
pal_file_load("TITLE\\title.pal", "a:\\title.pal");
pal_file_load("TITLE\\title.pal");
}
/* ЕДИНСТВЕННОЕ ОТЛИЧИЕ ФИНАЛА ОТ ИНТРО — цвет фона текстовой рамки.
@@ -120,7 +122,7 @@ void pop_pal_title_load(void) __banked
* файла и до нового снимка как тайлсет/Тень в pop_pal_game_load. */
void pop_pal_story_load(uint8_t ending) __banked
{
pal_file_load("PV\\story.pal", "a:\\story.pal");
pal_file_load("PV\\story.pal");
if (!ending) return;
gfx_pal_set(0, POP_PAL_STORY_BG, 0x80, 0, 0);
gfx_pal_set(1, POP_PAL_STORY_BG, 0x80, 0, 0);
@@ -129,5 +131,5 @@ void pop_pal_story_load(uint8_t ending) __banked
void pop_pal_pv_load(void) __banked
{
pal_file_load("PV\\pv.pal", "a:\\pv.pal");
pal_file_load("PV\\pv.pal");
}
+52
View File
@@ -0,0 +1,52 @@
/*
* pop_path_bank.c каталог приложения и восстановление cwd, BANK10.
*
* APPINFO B=1 возвращает абсолютный каталог запущенного EXE вместе с диском.
* Получаем его один раз при старте. Буфер объявлен const намеренно: у
* Sprinter банковые страницы RAM, а --constseg кладёт объект в BANK10,
* не расходуя дефицитную резидентную _DATA в W2. Записывает его только
* ESTEX во время pop_path_init().
*
* ABI: публичные функции __banked; их вызывает POP_PATH_CALL из банка
* файлового клиента. После возврата трамплин восстанавливает прежнюю W3,
* поэтому переданный самой файловой операции указатель снова доступен.
*/
#include <stdint.h>
#include <unistd.h>
#include "_pop_file.h"
static const char pop_app_dir[256] = { 0 };
/* ESTEX APPINFO ($47), B=1: HL=буфер, CF/A=ошибка. */
static int appinfo_dir(const char *buf) __naked
{
(void)buf;
__asm
push ix
ld b, #1
ld c, #0x47
rst #0x10
pop ix
jr c, _paid_err
ld de, #0
ret
_paid_err:
call __errno_set
ld de, #-1
ret
__endasm;
}
int8_t pop_path_init(void) __banked
{
const char *dir = pop_app_dir;
if (appinfo_dir(dir) != 0 || !dir[0]) return -1;
return (int8_t)chdir(dir);
}
int8_t pop_chdir_home(void) __banked
{
return (int8_t)chdir((const char *)pop_app_dir);
}
+21 -12
View File
@@ -22,6 +22,7 @@
#include "pop_pace.h"
#include "pop_timer.h"
#include "sprpop_cold.h"
#include "_pop_file.h"
#define QS_VERSION 4
#define QS_SAVE 1
@@ -154,6 +155,7 @@ static int8_t qs_save(uint8_t page)
uint8_t tail[4];
const uint8_t h[7]={'P','O','P','Q',QS_VERSION,pop_current_level,0};
uint16_t size;
int rc;
qs_init(&io,page,QS_LIMIT,0,0);
pop_qs_raw_put(&io,h,sizeof(h));
qs_payload(&io);
@@ -162,14 +164,18 @@ static int8_t qs_save(uint8_t page)
tail[2]=(uint8_t)io.checksum; tail[3]=(uint8_t)(io.checksum >> 8);
pop_qs_raw_put(&io,tail,4); size=(uint16_t)(io.pos-QS_BASE);
if (io.failed) return -1;
unlink("POP.NEW");
if (bank_save_file(page,QS_BASE,"POP.NEW",size) != size) {
unlink("POP.NEW"); return -1;
POP_PATH_CALL(rc, unlink("POP.NEW"));
POP_PATH_CALL(rc, bank_save_file(page,QS_BASE,"POP.NEW",size));
if (rc != size) {
POP_PATH_CALL(rc, unlink("POP.NEW"));
return -1;
}
unlink("POP.BAK");
(void)rename("POP.SAV", "POP.BAK");
if (rename("POP.NEW", "POP.SAV") != 0) {
(void)rename("POP.BAK", "POP.SAV"); return -1;
POP_PATH_CALL(rc, unlink("POP.BAK"));
POP_PATH_CALL(rc, rename("POP.SAV", "POP.BAK"));
POP_PATH_CALL(rc, rename("POP.NEW", "POP.SAV"));
if (rc != 0) {
POP_PATH_CALL(rc, rename("POP.BAK", "POP.SAV"));
return -1;
}
return 1;
}
@@ -178,7 +184,8 @@ static int8_t qs_load_one(const char *name, uint8_t page)
{
uint8_t level;
uint16_t payload_size;
int size=bank_load_file(page,QS_BASE,name,QS_LIMIT);
int size;
POP_PATH_CALL(size, bank_load_file(page,QS_BASE,name,QS_LIMIT));
if (size <= 0 || !qs_validate_page(page,(uint16_t)size,&level,
&payload_size)) return -1;
if (level != pop_current_level) {
@@ -205,11 +212,11 @@ uint8_t pop_qsave_probe(void) __banked
page = mem_get_page(blk, 0);
gfx_w0_page_prepare(page);
size = bank_load_file(page, QS_BASE, "POP.SAV", QS_LIMIT);
POP_PATH_CALL(size, bank_load_file(page, QS_BASE, "POP.SAV", QS_LIMIT));
if (size > 0 && qs_validate_page(page, (uint16_t)size, &level,
&payload_size))
slots |= POP_QS_SLOT_SAVE;
size = bank_load_file(page, QS_BASE, "POP.BAK", QS_LIMIT);
POP_PATH_CALL(size, bank_load_file(page, QS_BASE, "POP.BAK", QS_LIMIT));
if (size > 0 && qs_validate_page(page, (uint16_t)size, &level,
&payload_size))
slots |= POP_QS_SLOT_BACKUP;
@@ -234,14 +241,16 @@ int8_t pop_qsave_process(void) __banked
if (!blk) return -1;
page=mem_get_page(blk,0);
gfx_w0_page_prepare(page);
pop_sfx_pause();
/* Звук не глушим (2026-08-28). Единственное место из снятых, где по
* диску реально едут 16 КБ (~33 мс, три периода насоса): если на F6/F9
* появится скрежет вернуть сюда pop_sfx_pause/pop_sfx_start, это
* лечится точечно и только здесь. */
if (request == QS_SAVE) rc = qs_save(page);
else if (request == QS_LOAD_BACKUP) rc = qs_load_one("POP.BAK", page);
else {
rc = qs_load_one("POP.SAV",page);
if (rc < 0) rc = qs_load_one("POP.BAK",page);
}
pop_sfx_start();
mem_free_block(blk);
/* Успех ничего не меняет: нужная строка висит с начала операции.
* Отказ переписывает её на NO QUICKSAVE/NO QUICKLOAD обычной
+3 -1
View File
@@ -31,6 +31,7 @@
#include "pop_kid.h" /* Kid.curr_col/curr_row — порядок оверлеев */
#include "pop_cdraw.h" /* pop_cd — габарит кадра Кида (y-порядок плиты) */
#include "pop_map.h" /* pop_upside — переворот (зелье инверсии) */
#include "_pop_file.h"
#include <fcntl.h>
#include <unistd.h>
@@ -83,7 +84,8 @@ static const char *bg_path(const char *name)
static int bg_load_tile_pal(const char *name)
{
uint8_t buf[64];
int fd = open(bg_path(name), O_RDONLY);
int fd;
POP_PATH_CALL(fd, open(bg_path(name), O_RDONLY));
if (fd < 0) return -1;
if (read(fd, buf, 64) != 64) { close(fd); return -1; }
gfx_pal_load(0, POP_PAL_ENV, 16, buf);
+8 -3
View File
@@ -23,8 +23,6 @@
#include "pop_music.h"
#include "_pop_sfx.h"
/* Физические страницы набора; заполняет холодная половина. */
uint8_t pop_snd_page[POP_SND_PAGES];
uint8_t pop_snd_ok;
/* ХОЧЕТ ЛИ ЗВУКА ПОЛЬЗОВАТЕЛЬ (Ctrl+S) — отдельно от pop_snd_ok.
@@ -169,7 +167,7 @@ int pop_sfx_fill(uint16_t n)
* целиком в одну). Гард обязателен: без него ошибка в длине увела
* бы насос за pop_snd_page[] и он читал бы чужую память на слух
* это «играет не тот звук», причём каждый раз другой. */
if ((uint8_t)(sfx_pg + 1) < POP_SND_PAGES) sfx_pg++;
if ((uint8_t)(sfx_pg + 1) < pop_snd_pages) sfx_pg++;
else sfx_left = n; /* обрываем */
}
sfx_left -= n;
@@ -265,6 +263,13 @@ void pop_sfx_play(uint8_t id)
if (pop_mus_want && id >= 24 && id <= 43) pop_mus_req = id;
return;
}
/* НАБОР ЕЩЁ ДОЧИТЫВАЕТСЯ. На старте программы звук открывается после
* ОДНОЙ страницы (в ней блок тишины), чтобы музыка заставки заиграла
* почти сразу; остальные приезжают позже (pop_sfx_init_begin/finish).
* Страницы под них уже выделены, но не прочитаны эффект из такой
* страницы был бы шумом из чужих данных. Заявку на МУЗЫКУ выше это
* не трогает: её данные лежат отдельно. */
if (!pop_snd_pages) return;
if (!snd_next1 || snd_prio[id] <= snd_prio[snd_next1 - 1])
snd_next1 = (uint8_t)(id + 1);
}
+22 -4
View File
@@ -21,10 +21,28 @@
#include <stdint.h>
/* Поднять набор: занять 8 EMM-страниц и вычитать в них данные. ВЫВОД НЕ
* ВКЛЮЧАЕТ см. pop_sfx_start. 0 OK, -1 не получилось (тогда всё
* дальнейшее молча ничего не делает, игра идёт без звука). */
int pop_sfx_init(void) __banked;
/* ПОДЪЁМ НАБОРА РАЗБИТ НАДВОЕ — ради быстрого старта программы.
*
* Насосу, чтобы открыть CBL и заиграть МУЗЫКУ, нужен из всего набора
* ровно один блок тишина, и упаковщик обязан класть её первым блоком
* СТРАНИЦЫ 0 (tools/pop_idx.py, проверка `_check_entry`). Поэтому:
*
* pop_sfx_init_begin() индекс (snd.idx) + ОДНА страница (33 мс).
* После него можно звать pop_sfx_start() и
* играть музыку; эффектов ещё нет.
* pop_sfx_init_finish() остальные страницы (~230 мс) и публикация
* pop_snd_pages: с этого момента есть эффекты.
*
* Пока набор неполон, pop_sfx_play() молча игнорирует эффекты (заявку на
* музыку принимает по-прежнему): страницы под них выделены, но ещё не
* прочитаны, и играть из них это шум из чужих данных.
*
* ВЫВОД НИ ТА, НИ ДРУГАЯ НЕ ВКЛЮЧАЕТ см. pop_sfx_start. 0 OK, -1
* не получилось (тогда всё дальнейшее молча ничего не делает, игра идёт
* без звука; begin при отсутствии индекса поднимает набор из одной
* страницы тишины, чтобы не встала музыка). */
int pop_sfx_init_begin(void) __banked;
int pop_sfx_init_finish(void) __banked;
/* Включить вывод (открыть CBL). Звать ПОСЛЕ всей загрузки: пока идёт
* чтение файлов, ESTEX уходит в диск надолго, насос не успевает долить
+202 -13
View File
@@ -5,20 +5,169 @@
*/
#include <stdint.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sprinter_mem.h>
#include <cbl.h>
#include "pop_sfx.h"
#include "pop_arc.h"
#define POP_SND_FILES /* имена файлов нужны ТОЛЬКО здесь, см. pop_sound_tbl.h */
#include "_pop_sfx.h"
#include "_pop_file.h" /* POP_PATH_CALL — старые DSS и пути */
#include <irq.h> /* IRQ_DISABLE вокруг записи в W0 */
#include <sprite.h> /* gfx_w0_map — залить блок тишины */
static uint8_t snd_blk; /* блок EMM под весь набор */
/* Раздельная загрузка (см. шапку pop_sfx.h): сколько страниц обещал индекс
* и сколько уже прочитано. 0 в want begin не проходил либо поднялась
* только страница тишины (набора нет), и дочитывать нечего. */
static uint8_t snd_want_pages;
static uint8_t snd_done_pages;
int pop_sfx_init(void) __banked
/* --- ИНДЕКС НАБОРА (SND/snd.idx, формат PSI1) ----------------------- *
*
* Раскладка эффектов приезжает с диска, а не из EXE: так пара
* snd.idx + snd.arc заменяется целиком (SDLPoP <-> MSDOS) без
* перекомпиляции игры. Писатель формата tools/pop_idx.py, он же
* покрыт тестом; проверки ниже повторяют его один в один, потому что
* доверять содержимому файла нельзя: битый индекс увёл бы насос за
* пределы выделенного блока EMM.
*
* Заголовок: 16 байт, дальше POP_SND_COUNT записей по 5 байт подряд
* ровно в том виде, в каком лежит pop_snd_tbl[], поэтому читаются они
* ОДНИМ read'ом прямо туда, без разбора по полям. */
#define IDX_HDR_SIZE 16
#define IDX_BLOCK_LOG2 7 /* 1 << 7 == POP_SND_BLOCK */
#define IDX_PCM_FMT 1 /* u8 mono, CBL_FREQ_10K9 */
#define IDX_TBL_SIZE (POP_SND_COUNT * (int)sizeof(pop_snd_ent_t))
/* Имя знает сам модуль — литерал лежит в rodata банка 8, и open() читает
* его, пока в W3 стоит этот же банк (тот же приём, что в pop_arc.c). */
static const char idx_path[] = "SND\\snd.idx";
/* Одна запись: ровно те проверки, что делает упаковщик.
*
* СЧИТАЕМ В БЛОКАХ, А НЕ В БАЙТАХ. Байтовый адрес конца эффекта
* (page * 16384 + off + len) не влезает в uint16, а 32-битная арифметика
* на Z80 дорога (memory avoid_32bit_arith_z80). В блоках по 128 весь
* набор не больше POP_SND_MAX_PAGES * 128 = 2048, и всё считается
* шестнадцатью битами. */
static int8_t idx_entry_ok(const pop_snd_ent_t *e, uint8_t pages)
{
uint8_t i, blk;
uint16_t start, end;
if (pop_snd_ok) return 0;
if (e->len == 0) /* нет оцифровки — это музыка */
return (e->page == 0 && e->off == 0) ? 0 : -1;
if (e->page >= pages) return -1;
if (e->off >= 0x4000) return -1;
if ((e->off | e->len) & (POP_SND_BLOCK - 1)) return -1;
/* Блок тишины неприкосновенен: насос льёт его, когда играть нечего. */
if (e->page == POP_SND_SILENCE_PAGE && e->off < POP_SND_BLOCK) return -1;
start = (uint16_t)((uint16_t)e->page << 7) + (uint16_t)(e->off >> 7);
end = (uint16_t)(start + (uint16_t)(e->len >> 7));
/* Длинный эффект переливается на следующие страницы — они обязаны
* быть загружены, иначе насос уйдёт в чужую память. */
if (end > (uint16_t)((uint16_t)pages << 7)) return -1;
return 0;
}
/* Прочитать и проверить индекс. Возвращает число страниц набора либо 0. */
static uint8_t idx_load(void)
{
uint8_t head[IDX_HDR_SIZE];
uint8_t pages, i;
int fd;
POP_PATH_CALL(fd, open(idx_path, O_RDONLY));
if (fd < 0) return 0;
if (read(fd, head, IDX_HDR_SIZE) != IDX_HDR_SIZE ||
memcmp(head, "PSI1", 4) != 0 ||
head[4] != POP_SND_COUNT ||
head[5] != (uint8_t)sizeof(pop_snd_ent_t) ||
head[6] != IDX_BLOCK_LOG2 ||
head[7] != IDX_PCM_FMT) {
close(fd);
return 0;
}
pages = head[8];
if (pages == 0 || pages > POP_SND_MAX_PAGES) { close(fd); return 0; }
/* Записи ложатся прямо в резидентную таблицу: дисковый формат и
* структура совпадают побайтово (проверено статически в
* pop_snd_data.c и полем head[5] выше). */
if (read(fd, pop_snd_tbl, IDX_TBL_SIZE) != IDX_TBL_SIZE) {
close(fd);
return 0;
}
close(fd);
for (i = 0; i < POP_SND_COUNT; i++)
if (idx_entry_ok(&pop_snd_tbl[i], pages) != 0) return 0;
return pages;
}
/* НАБОРА НЕТ — НО НАСОС ВСЁ РАВНО ОБЯЗАН РАБОТАТЬ.
*
* Тишину в паузах насос льёт из ПЕРВОГО БЛОКА НАБОРА, поэтому без набора
* CBL не открывался вовсе. А вместе с ним вставала и МУЗЫКА: её блоки
* отсчитывает тот же насос (pop_mus_left убывает только в pop_sfx_fill).
* Заставка при этом висла намертво ждала конца трека, который не
* начинал играть, и pop_music_busy() не гас никогда (поймано
* пользователем 2026-08-31 на образе без snd.idx).
*
* Поэтому при отказе индекса поднимаем ПУСТОЙ набор: одна страница EMM с
* блоком тишины. Эффектов нет все len в таблице нулевые, и
* pop_sfx_play уходит по ветке «нет оцифровки», а музыка играет.
*
* ЗАЛИВАЕМ ТОЛЬКО ПЕРВЫЙ БЛОК, и это не экономия: gfx_w0_page_prepare
* ставит в страницу IRQ/NMI-стабы, чтобы прерывание при замапленном W0
* нашло валидный код. Заливка ВСЕЙ страницы затирает их, и первое же
* прерывание уводит процессор в мусор чёрный экран на старте (поймано
* при первой попытке этого фикса). Насосу больше 128 байт и не нужно:
* он берёт ровно блок тишины, да и то через W3, а не через W0.
* Запись под DI: 128 байт накрывают стаб RST38, и до unmap прерываний
* быть не должно. */
static int8_t snd_silence_only(void)
{
uint8_t blk, page;
uint8_t *w0 = (uint8_t *)0x0000; /* окно 0 — сюда ляжет страница */
memset(pop_snd_tbl, 0, sizeof(pop_snd_tbl));
blk = mem_alloc_pages(1);
if (!blk) return -1;
page = mem_get_page(blk, 0);
if (!page) { mem_free_block(blk); return -1; }
gfx_w0_page_prepare(page);
IRQ_DISABLE();
gfx_w0_map(page);
memset(w0, 0x80, POP_SND_BLOCK); /* 0x80 — тишина в 8-бит без знака */
gfx_w0_unmap();
IRQ_ENABLE();
pop_snd_page[POP_SND_SILENCE_PAGE] = page;
snd_blk = blk;
pop_snd_pages = 1;
return 0;
}
/* ПЕРВАЯ ПОЛОВИНА: индекс + страница 0 (в ней блок тишины). После неё
* звук можно открывать, и музыка играет; эффекты после finish. */
int pop_sfx_init_begin(void) __banked
{
uint8_t blk, pages;
if (snd_blk) return 0; /* набор уже поднимали */
/* СНАЧАЛА ИНДЕКС, ПОТОМ ДАННЫЕ: сколько страниц читать и куда какой
* эффект лёг знает только он. Набора без индекса не бывает:
* держать в EXE запасную таблицу нельзя, иначе привязка бинарника к
* конкретному набору вернётся через чёрный ход. */
pages = idx_load();
if (!pages) {
/* Набор эффектов не поднялся — но звук как таковой поднять надо,
* иначе встанет музыка (см. snd_silence_only). Возвращаем -1:
* эффектов действительно нет, и вызывающий вправе об этом знать. */
if (snd_silence_only() != 0) return -1;
pop_snd_want = 1;
return -1;
}
/* Одним блоком: страницы блока идут подряд по логическому индексу, и
* таблица звуков адресует их именно так (page = смещение >> 14).
@@ -29,29 +178,69 @@ int pop_sfx_init(void) __banked
* pop_arc.h. */
{
pop_arc_t arc;
uint8_t tbl[POP_SND_PAGES * POP_ARC_REC];
uint8_t tbl[POP_SND_MAX_PAGES * POP_ARC_REC];
if (pop_arc_open_id(&arc, POP_ARC_SND, tbl, POP_SND_PAGES) !=
POP_SND_PAGES)
/* Архив обязан содержать РОВНО столько страниц, сколько обещал
* индекс: расходящейся паре доверять нельзя она означает, что
* snd.idx и snd.arc из разных наборов. */
if (pop_arc_open_id(&arc, POP_ARC_SND, tbl, POP_SND_MAX_PAGES) !=
(int8_t)pages)
return -1;
blk = mem_alloc_pages(POP_SND_PAGES);
/* Блок выделяем СРАЗУ на весь набор: номера страниц отдаёт
* mem_get_page по индексу, и finish дочитает в те же самые. */
blk = mem_alloc_pages(pages);
if (!blk) { pop_arc_close(&arc); return -1; }
for (i = 0; i < POP_SND_PAGES; i++) {
pop_snd_page[i] = mem_get_page(blk, i);
if (!pop_snd_page[i] ||
pop_arc_read(&arc, tbl, i, 0, pop_snd_page[i], 0, 0) != 16384) {
pop_snd_page[POP_SND_SILENCE_PAGE] = mem_get_page(blk, 0);
if (!pop_snd_page[POP_SND_SILENCE_PAGE] ||
pop_arc_read(&arc, tbl, 0, 0,
pop_snd_page[POP_SND_SILENCE_PAGE], 0, 0) != 16384) {
pop_arc_close(&arc);
mem_free_block(blk);
return -1;
}
}
pop_arc_close(&arc);
}
snd_blk = blk;
snd_want_pages = pages;
snd_done_pages = 1;
/* pop_snd_pages НЕ ставим: набор ещё неполон, и до finish эффекты
* запускать нельзя (страницы выделены, но не прочитаны). */
pop_snd_want = 1; /* набор есть — звук по умолчанию включён */
return 0;
}
/* ВТОРАЯ ПОЛОВИНА: дочитать остальные страницы и разрешить эффекты.
* Идемпотентна; архив открывается второй раз (51 мс) это дешевле, чем
* держать открытый манипулятор через всю заставку при лимите в восемь. */
int pop_sfx_init_finish(void) __banked
{
pop_arc_t arc;
uint8_t tbl[POP_SND_MAX_PAGES * POP_ARC_REC];
uint8_t i;
if (pop_snd_pages) return 0; /* набор уже полный */
if (!snd_want_pages) return -1; /* begin не проходил либо только тишина */
if (snd_done_pages < snd_want_pages) {
if (pop_arc_open_id(&arc, POP_ARC_SND, tbl, POP_SND_MAX_PAGES) !=
(int8_t)snd_want_pages)
return -1;
for (i = snd_done_pages; i < snd_want_pages; i++) {
pop_snd_page[i] = mem_get_page(snd_blk, i);
if (!pop_snd_page[i] ||
pop_arc_read(&arc, tbl, i, 0, pop_snd_page[i], 0, 0) != 16384) {
pop_arc_close(&arc);
return -1; /* страницы не отдаём: тишина в стр.0 жива */
}
snd_done_pages = (uint8_t)(i + 1);
}
pop_arc_close(&arc);
}
/* Число страниц ставим ПОСЛЕДНИМ: до этого момента насос обязан
* видеть 0 и не пытаться ходить по полузаполненной таблице. */
pop_snd_pages = snd_want_pages;
return 0;
}
/* ЗАПУСТИТЬ ВЫВОД — ОТДЕЛЬНО ОТ ЗАГРУЗКИ, и это не косметика.
*
* Пока идёт чтение файлов, CBL держать открытым НЕЛЬЗЯ: ESTEX уходит в
+34
View File
@@ -0,0 +1,34 @@
/*
* pop_snd_data.c РЕЗИДЕНТНЫЕ ДАННЫЕ звукового набора.
*
* Отдельным модулем, потому что стороны у этих данных разные: заполняет
* их ХОЛОДНЫЙ загрузчик из банка 8 (pop_sfx_cold.c), а читает ГОРЯЧИЙ
* насос из резидента (pop_sfx.c) в том числе из прерывания. Держать
* их в любой из половин значило бы тянуть чужую в резидент.
*
* Инициализаторов нет намеренно: crt0 зануляет _DATA (memory
* sdcc_static_storage_gotcha), а «набор не загружен» это и есть нули.
*
* ЦЕНА ПЕРЕЕЗДА. Таблица была `static const` в _CODE (285 Б); сняв
* const, мы переносим её в _DATA суммарный резидентный расход не
* меняется, потому что в huge-модели _CODE и данные лежат подряд в одном
* диапазоне 0x4000..0xBFFF. Реально добавилось 8 байт: pop_snd_page
* вырос с размера набора до POP_SND_MAX_PAGES (запас под чужие наборы)
* плюс байт самого счётчика.
*/
#include <stdint.h>
#include "pop_snd_tbl.h"
/* РАСКЛАДКА НАБОРА — приезжает из SND/snd.idx целиком, одним read'ом
* прямо сюда. Поэтому размер записи обязан совпадать с дисковым: если
* ABI когда-нибудь добавит выравнивание, сборка упадёт здесь, а не
* рассыплется звуком в рантайме. */
pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT];
typedef char pop_snd_ent_is_5_bytes[(sizeof(pop_snd_ent_t) == 5) ? 1 : -1];
/* Физические страницы EMM, по индексу из pop_snd_tbl[].page. */
uint8_t pop_snd_page[POP_SND_MAX_PAGES];
/* Сколько страниц у ЭТОГО набора (1..POP_SND_MAX_PAGES). Насос сверяет
* с ним переход длинного эффекта через границу страницы. */
uint8_t pop_snd_pages;
+50
View File
@@ -0,0 +1,50 @@
/*
* pop_snd_tbl.h РАСКЛАДКА ОЦИФРОВАННЫХ ЭФФЕКТОВ: тип и инварианты.
*
* Раньше этот файл ПЕЧАТАЛ упаковщик (gen/pop_sound_tbl.h), и конкретные
* page/off/len становились частью EXE из-за чего замена звукового набора
* (SDLPoP <-> MSDOS) требовала перекомпиляции игры. Теперь значения
* приезжают с диска (SND/snd.idx, формат PSI1 tools/pop_idx.py), а
* здесь остаётся только то, что от набора НЕ зависит: устройство записи и
* пределы, на которые рассчитан загрузчик.
*
* ЗАПИСЬ ЧИТАЕТСЯ С ДИСКА ПОБАЙТОВО, прямо в pop_snd_tbl[]. Значит
* раскладка структуры в ABI SDCC z80 (uint8 + uint16 + uint16 подряд, без
* выравнивания = 5 байт) часть дискового контракта, а не деталь
* компилятора. Расхождение молча разъехало бы весь набор, поэтому оно
* ловится статически (pop_snd_data.c) и проверяется загрузчиком по полю
* размера записи в заголовке индекса.
*/
#ifndef POP_SND_TBL_H
#define POP_SND_TBL_H
#include <stdint.h>
/* Пространство id оригинала: 0..56. От набора не зависит. */
#define POP_SND_COUNT 57
/* Блок запроса CBL при 8 бит; страница EMM кратна ему, поэтому ни один
* блок не пересекает границу страницы (см. docs/sound_plan.md). */
#define POP_SND_BLOCK 128
/* СКОЛЬКО СТРАНИЦ ГОТОВ ПРИНЯТЬ ЗАГРУЗЧИК — вместимость, а НЕ размер
* конкретного набора: тот приходит в индексе (pop_snd_pages). Запас
* ради будущих наборов без перекомпиляции; цена 16 байт таблицы
* страниц вместо 9-10. */
#define POP_SND_MAX_PAGES 16
/* ПЕРВЫЙ БЛОК СТРАНИЦЫ 0 — ТИШИНА. Насос льёт её, когда играть нечего:
* без этого железо доигрывает по кругу хвост своего буфера (жужжание).
* Это инвариант ПРОГРАММЫ упаковщик обязан оставить блок свободным, а
* загрузчик проверяет, что ни одна запись в него не залезла. */
#define POP_SND_SILENCE_PAGE 0
#define POP_SND_SILENCE_OFF 0x0000
/* page — индекс в pop_snd_page[] (физические страницы EMM), off — начало
* внутри страницы, len длина PCM. Оба кратны POP_SND_BLOCK; len == 0
* значит «у этого id нет оцифровки» (это музыка, см. sound_plan.md). */
typedef struct {
uint8_t page;
uint16_t off;
uint16_t len;
} pop_snd_ent_t;
#endif /* POP_SND_TBL_H */
+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,
* плюс добавляет минуту. Они меняют счётчик и при выключенном лимите
+44 -10
View File
@@ -21,6 +21,7 @@
#include "pop_pal.h"
#include "pop_title.h"
#include "pop_ui.h"
#include "sprpop_cold.h" /* pop_boot_sound/pop_boot_rest — фазы старта */
#define TITLE_W 320
#define TITLE_H 200
@@ -110,10 +111,14 @@ int pop_title_logo_ltr(void) __banked
return pop_screen_present_ltr() ? 1 : 0;
}
/* Ждать ticks кадров луча, доливая трек. Тема заставки играет с ПЕРВОЙ
* страницы (pop_music_stream), остальные обязаны приезжать по ходу шаг
* стоит 16 мс и укладывается в кадровый интервал. */
static uint8_t title_wait(uint16_t ticks)
{
while (ticks--) {
if (title_skip_pressed()) return 1;
if (pop_music_loading()) (void)pop_music_load_step();
gfx_wait_vsync();
}
return 0;
@@ -130,7 +135,8 @@ static void title_load_palette_dimmed(void)
uint8_t pop_title_show(void) __banked
{
uint8_t step;
uint8_t skipped = 0, music_ok = 0;
uint8_t skipped = 0;
uint16_t first_wait = 0; /* такт 0 за вычетом времени загрузки */
int prepared;
uint8_t cold_start = (uint8_t)(!title_shown &&
!pop_settings.show_build_info);
@@ -154,22 +160,50 @@ uint8_t pop_title_show(void) __banked
pop_screen_present((uint8_t)prepared);
title_shown = 1;
/* Тему читаем с диска ЗАРАНЕЕ (пока экран ещё тёмный), а пускаем ниже —
* после fade_in, ровно как show_title: fade_in_2, затем
* play_sound(sound_54). */
music_ok = (uint8_t)(pop_music_load(POP_MUS_INTRO) == 0);
/* ЗДЕСЬ ЖЕ ДОГРУЖАЕТСЯ ВСЯ ИГРА — и это не случайное место.
*
* До 2026-09-02 pop_boot читал ВСЕ ресурсы перед первым пикселем: от
* запуска до проявления титула проходило несколько секунд чёрного
* экрана в тишине, потому что перед fade_in ещё и вычитывался целиком
* трек заставки (248 КБ). Теперь порядок обратный и совпадает с
* оригиналом (init_game_main грузит до заставки только мелочь,
* seg000.c:156):
* 1) картинка титула и fade_in первое, что происходит;
* 2) звук: индекс + ОДНА страница набора, CBL (pop_boot_sound);
* 3) тема с первой же страницы (pop_music_stream), не дожидаясь
* всех шестнадцати;
* 4) остальные ресурсы под стоящим титулом и звучащей темой.
*
* Порядок «fade_in, потом тема» сохранён как у show_title (fade_in_2,
* затем play_sound(sound_54)). */
for (step = 0; step < sizeof(title_scene); step++) {
if (step && title_draw(title_scene[step], 1) < 0)
break;
if (step == 0) {
uint8_t t0;
if (pop_pal_fade_in_skip(TITLE_FADE)) { skipped = 1; break; }
if (music_ok) {
(void)pop_sfx_start();
pop_music_play();
(void)pop_boot_sound();
(void)pop_music_stream(POP_MUS_INTRO);
/* ЧЕМ ЗАПЛАЧЕН ПЕРВЫЙ ТАКТ. Загрузка занимает около секунды, и
* без поправки титул простоял бы на первом кадре дольше
* оригинала, а сцена уехала бы относительно темы. Считаем
* потраченное часами насоса (85,4 Гц, идут от расхода буфера
* CBL единственные, кому не мешает диск) и переводим в кадры
* луча сдвигами: 48,83/85,4 = 0,572 ~ 1/2 + 1/16. Без звука
* часы стоят, поправка выходит нулевой и правильно, тогда и
* синхронизировать не с чем. */
t0 = pop_snd_tick;
(void)pop_boot_rest();
{
uint16_t spent = (uint8_t)(pop_snd_tick - t0);
spent = (uint16_t)((spent >> 1) + (spent >> 4));
first_wait = (spent >= title_wait_ticks[0])
? 0 : (uint16_t)(title_wait_ticks[0] - spent);
}
}
if (title_wait(title_wait_ticks[step])) {
if (title_wait(step ? title_wait_ticks[step] : first_wait)) {
skipped = 1;
break;
}
+18 -4
View File
@@ -16,6 +16,8 @@
#include "pop_arc.h"
#include "pop_ui.h"
#include "pop_status.h"
#include "pop_music.h" /* pop_music_service — подкачка кольца в долгих фейдах */
#include "_pop_file.h"
typedef struct {
uint8_t base;
@@ -53,8 +55,10 @@ static uint8_t text_index(const pop_font_desc_t *font, uint8_t ch)
int pop_ui_init(void) __banked
{
int rc;
if (font_ready) return 0;
if (atlas_load(&font_at, "FONT\\font.atl") != 0) return -1;
POP_PATH_CALL(rc, atlas_load(&font_at, "FONT\\font.atl"));
if (rc != 0) return -1;
font_ready = 1;
return 0;
}
@@ -241,8 +245,10 @@ void pop_ui_palette_black(void) __banked
uint8_t *black = pop_pal_buf;
/* BIOS принимает максимум 64 записи за вызов; одним буфером покрываем
* все четыре четверти палитры, не занимая постоянную память. */
for (i = 0; i < sizeof(black); i++) black[i] = 0;
* все четыре четверти палитры, не занимая постоянную память.
* sizeof(black) здесь нельзя: black 16-битный указатель, и тогда
* обнулятся лишь первые два байта, а цвет заливки останется случайным. */
for (i = 0; i < sizeof(pop_pal_buf); i++) black[i] = 0;
for (chunk = 0; chunk < 4; chunk++) {
uint8_t start = (uint8_t)(chunk << 6);
gfx_pal_load(0, start, 64, black);
@@ -292,6 +298,11 @@ static uint8_t fade_run(uint8_t frames, uint8_t up, uint8_t skip)
pop_ui_palette_dim(step, 0);
shown = step;
}
/* Fade — это 2,13 с, и всё это время главный цикл стоит. Кольцо
* потокового трека дочитывает pop_music_service, иначе музыка
* замолкает прямо посреди перехода (та же причина, что у меню:
* ui_wait_frame в pop_menu.c). */
pop_music_service();
gfx_wait_vsync();
if (skip) {
kbd_raw_sync();
@@ -429,6 +440,7 @@ uint8_t pop_screen_present_ltr(void) __banked
stripe.x = x;
stripe.w = (uint16_t)((GFX_WIDTH - x < 4) ? GFX_WIDTH - x : 4);
gfx_copy_page(&stripe, GFX_COPY_DIRECT);
pop_music_service(); /* 80 кадров перехода — см. fade_run */
gfx_wait_vsync();
}
return 0;
@@ -461,8 +473,10 @@ int pop_screen_blit_atlas_strips(const char * const *parts,
gfx_set_bank(GFX_BANK_NORMAL);
for (part = 0; part < count; part++) {
atlas_t at;
int rc;
if (atlas_load(&at, parts[part]) != 0) return -1;
POP_PATH_CALL(rc, atlas_load(&at, parts[part]));
if (rc != 0) return -1;
strip_blit(&at, x, &y);
}
return 0;
-9
View File
@@ -40,15 +40,6 @@ int pop_vflip_take(const atlas_t *orig, const atlas_t *mirror)
return 0;
}
int pop_vflip_add(const atlas_t *orig, const char *path)
{
atlas_t m;
if (!orig || vf_n >= VF_MAX) return -1;
if (atlas_load(&m, path) != 0) return -1;
return pop_vflip_take(orig, &m);
}
uint8_t pop_vflip_page(uint8_t src)
{
uint8_t i;
+1 -5
View File
@@ -26,11 +26,7 @@
#include <stdint.h>
#include <sprite.h>
/* Загрузить зеркальный файл path и связать его с атласом orig.
* 0 OK, -1 файла нет / не хватило EMM / таблица полна. */
int pop_vflip_add(const atlas_t *orig, const char *path);
/* То же, но зеркало УЖЕ загружено (например, прочитано из архива): таблица
/* Зеркало УЖЕ загружено из архива: таблица
* забирает его себе, и дальше он освобождается вместе с остальными. */
int pop_vflip_take(const atlas_t *orig, const atlas_t *mirror);
+67 -3
View File
@@ -41,6 +41,10 @@
#include "pop_pal.h" /* lifecycle палитры title/intro/level */
#include "pop_hof.h" /* persistent Hall of Fame — bank 9 */
#include "pop_ui.h" /* чёрные обе страницы перед сменой палитры */
#include "_pop_file.h" /* cwd приложения для старых DSS */
#include <sprinter_exit.h>
#include <unistd.h>
__sfr __at (0xFE) io_border;
@@ -236,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);
@@ -260,6 +279,11 @@ int main(void)
uint8_t xdec_prev = 0, xinc_prev = 0, upsd_prev = 0;
uint8_t failed = 0;
/* До первой файловой операции запоминаем абсолютный каталог EXE.
* Без этого старый DSS теряет последний компонент cwd после OPEN пути
* с подкаталогом и следующий файл ищет уровнем выше. */
if (pop_path_init() != 0) return 1;
/* Загрузка ресурсов, палитра, первый экран — В БАНКЕ (sprpop_cold.c):
* отрабатывает ровно один раз за игру, а кода там на полкилобайта. */
pop_app_reset();
@@ -382,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)) {
@@ -590,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() у оригинала стоит
@@ -765,10 +802,37 @@ int main(void)
/* Смена уровня целиком уехала в банк 8 (pop_level_change): это
* событие раз в несколько минут, а кода на нём было под сотню строк
* в самом горячем месте программы резидент W1 у нас в дефиците. */
if (!is_demo && pop_next_level != pop_current_level &&
pop_level_change(back) != 0) {
if (!is_demo && pop_next_level != pop_current_level) {
/* КОНЕЦ УРОВНЯ ЖДЁТ МЕЛОДИЮ (порт seg003:387, play_level_2):
*
* if (next_level == current_level || check_sound_playing())
* draw_game_frame(); ... // экран уровня живёт
* else { stop_sounds(); return next_level; }
*
* То есть пройденный уровень остаётся на экране с анимацией
* факелов, идущим таймером и всем прочим ровно столько,
* сколько звучит трек конца уровня; мы же уходили на смену
* уровня сразу и обрывали мелодию на первых нотах.
*
* «Звучит» это pop_music_active(), а не busy: между заявкой
* на трек и первой нотой проходит 190-230 мс на открытие
* файла и чтение первой полустраницы, и в этой щели busy ещё
* ложен (замер docs/sound_plan.md §9). Раньше щель
* закрывал счётчик по длине записи, но он же тянул за собой
* зависимость EXE от набора музыки и жёсткий делитель `/4`,
* враньё которого вылезало в быстрых режимах (там кадр 3
* луча, а не 4). Состояние «заявка/загрузка/звучание»
* закрывает щель без единой константы.
*
* ЗВУК ВЫКЛЮЧЕН ПАУЗЫ НЕТ, и это тоже верно: pop_sfx_play
* не кладёт заявку при !pop_snd_ok, все три признака ложны, и
* уровень меняется сразу. Ровно так в оригинале, где трек
* ставится под `if (is_sound_on)` (seg006:651), а ожидание
* `check_sound_playing()` (seg003:387). */
if (!pop_music_active() && pop_level_change(back) != 0) {
failed = 1; quit = 1; break;
}
}
if (!is_demo && pop_kid_dead) { /* demo после смерти завершается, без respawn */
dead_frames++;
/* Просьба нажать кнопку, мигание и таймаут — в банке
+195 -62
View File
@@ -25,6 +25,8 @@
#include "pop_ctrl.h" /* pop_ctrl_init/close — ввод поднимает и закрывает boot */
#include "pop_shadow.h" /* запечённый набор Тени */
#include "pop_sfx.h" /* звуковые эффекты через CBL */
#include <cbl.h> /* cbl_shutdown — погасить железо звука на выходе */
#include "pop_music.h" /* pop_music_busy/free — ждём трек заставки */
#include "pop_vflip.h" /* сброс зеркальных страниц на смене уровня */
#include "pop_cheat.h" /* pop_cheats — режим разработки включает pop_boot */
#include "pop_pace.h" /* pop_speed_mode — режим скорости в отладочной метке */
@@ -114,10 +116,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)
@@ -136,10 +138,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»
@@ -180,7 +187,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;
}
@@ -226,7 +233,12 @@ int8_t pop_level_change(uint8_t back) __banked
* почти всегда поймано пользователем 2026-08-25. Ветка с
* катсценой свободна от этого: pop_pre_cutscene_show глушит
* звук первым действием. */
pop_sfx_pause();
/* Глушение снято 2026-08-28 (замер: загрузка уровня насос не
* морит 500 вызовов подряд, максимум 245 832 такта при
* дедлайне 251 000). Прежнее обоснование скрежет на палитре
* через BIOS ($A4 с запрещёнными прерываниями), пойманный на
* переходе без катсцены 2026-08-25. Если он вернётся, это
* единственное место, куда его надо возвращать. */
/* Без fade: старый кадр гасим сразу и обязательно даём
* показать один чёрный растр ДО диска/перерисовки. */
pop_pal_apply(POP_PAL_DIM_BLACK);
@@ -1103,6 +1115,36 @@ void pop_flip_screen(void) __banked
* уровень). В резиденте от них оставались только вызовы сам код здесь.
*/
/* Фазы старта (см. шапку pop_boot): обе половины идемпотентны, а
* boot_rest_fail нужен, чтобы повторный вызов не пытался читать заново
* то, что уже не прочиталось. */
static uint8_t boot_sound_done;
static uint8_t boot_rest_done;
static uint8_t boot_rest_fail;
/* СТАРТ РАЗБИТ НА ТРИ ФАЗЫ — чтобы игрок увидел заставку и услышал музыку
* сразу, а не через несколько секунд чёрного экрана в тишине.
*
* pop_boot() только настройки и графика: чёрный экран за ~0,1 с.
* pop_boot_sound() индекс+первая страница звука, индекс музыки, CBL:
* после него титул уже показан, и тема заставки может
* начать играть, не дожидаясь остальных ресурсов.
* pop_boot_rest() всё тяжёлое (эффекты, Кид, Тень, шрифт) ПОД стоящим
* титулом и звучащей темой; идемпотентно.
*
* ЧЕТЫРЕ ЗАГРУЗКИ ОТСЮДА ПРОСТО УБРАНЫ, а не отложены: атлас фона, страж,
* уровень 1 и игровая палитра это дубликаты того, что и так делает
* pop_level_switch() на входе в ЛЮБОЙ уровень (а pop_new_game_load ещё и
* pop_pal_game_load перед ним). Оба маршрута в игру, LEVEL_LOAD и DEMO,
* упираются в pop_level_switch, так что второй раз читать их до заставки
* незачем это ~0,77 с на ровном месте. Так же устроен и оригинал: у
* SDLPoP chtab_5/6/7 грузит load_lev_spr() на старте уровня, а init_game_main
* до заставки читает только меч, пламя, звуки и палитры (seg000.c:156).
*
* ПАЛИТРУ ТРОГАТЬ НЕЛЬЗЯ, пока на экране титул: и kid.pal (все 256 записей),
* и палитра тайлсета пишутся прямо в железо и перекрасили бы заставку.
* Поэтому в отложенной части нет ни одной палитровой операции они все
* живут на маршруте входа в уровень, за чёрным экраном. */
int pop_boot(void) __banked
{
/* Настройки: дефолты, затем POP.CFG, если он есть и корректен. */
@@ -1130,73 +1172,114 @@ int pop_boot(void) __banked
gfx_set_visible_page(0);
pop_pal_black();
/* Набор фона — по типу СТАРТОВОГО уровня; atlas_load безопасен и после
* initgraph: при первом вызове он сохраняет страницу DSS W0. Уровень к
* этому моменту ещё не прочитан, поэтому тип берём напрямую от номера. */
pop_current_level = FIRST_LEVEL;
if (pop_bg_load(pop_level_type()) != 0) {
puts("pop_bg_load failed");
return -1;
}
/* pop_bg_load установил только свои слоты палитры. До конца загрузки
* всё равно держим экран чёрным. */
pop_pal_black();
pop_cd_init(); /* метка «фон трогали» — пустые диапазоны */
/* pop_cheats/speed применит pop_settings_apply после pop_sfx_init. */
pop_guard_reset();
if (pop_kid_data_load("KID\\kid.ani") != 0) { /* кадры+seqtbl в EMM-странице */
printf("kid.ani load failed\n");
return -1;
}
pop_guard_load(); /* атласы стража (GUARD\\g0..g4) */
pop_shadow_load(); /* запечённый набор Тени — один раз на запуск */
pop_sfx_init(); /* звук: 8 страниц оцифровки (вывод пока молчит) */
pop_settings_apply(1); /* CFG -> speed/cheats/CBL */
if (pop_kid_load(28) != 0) { /* ВСЕ страницы: бег+стоп+разворот+присед+прыжок */
puts("kid atlas load failed");
return -1;
}
if (pop_level_load_num(FIRST_LEVEL) != 0) { /* сырой уровень в EMM-страницу */
puts("level load failed");
return -1;
}
pop_next_level = pop_current_level; /* current_level = next_level (load_lev_spr) */
pop_kid_hp_newgame(); /* init_game: hitp_beg_lev = start_hitp */
pop_checkpoint = 0; /* init_game (seg003:37) */
/* FONT.ATL нужен palette snapshot/dim; загрузка идёт ещё на чёрной
* палитре и поэтому не открывает игроку старое содержимое VRAM. */
/* FONT.ATL — В МИНИМАЛЬНОЙ ФАЗЕ, И ОТКЛАДЫВАТЬ ЕГО НЕЛЬЗЯ.
*
* Дело не в шрифте: СНИМОК ПАЛИТРЫ физически живёт в хвосте страницы
* шрифта (pop_ui.c, POP_FONT_SNAPSHOT_OFF в W2 килобайта нет), и вся
* машинерия яркости начинается с `if (!font_ready) return`. Без него
* pop_pal_apply(DIM_BLACK) не гасит, а fade_in нечего проявлять
* заставка возникает разом на полной яркости (поймано пользователем
* сразу после первой версии этой перестановки, 2026-09-02).
* Цена вопроса 12 КБ, около 75 мс. */
if (pop_menu_init() != 0) {
puts("menu font atlas load failed");
return -1;
}
/* kid.pal + динамические slots tileset/Shadow — один логический
* источник. pop_pal_game_load делает снимок ПОСЛЕ всех доплат. */
pop_pal_game_load();
/* Сохраняем ИМЕННО игровую палитру: после стартового title/intro она
* понадобится как источник обычного fade-in. Для build-info экран
* остаётся штатный яркий игровой фон. */
if (!pop_settings.show_build_info) {
pop_pal_apply(POP_PAL_DIM_BLACK);
}
/* Стартовая комната до title НЕ НУЖНА: LEVEL_LOAD всё равно заново
* выполняет pop_level_switch. Если нарисовать её сейчас в обе страницы,
* она становится сохранённой VRAM и может мелькнуть перед первым title
* при следующем запуске приложения. Единственное исключение явно
* включённый build-info: его фон по контракту является игровой сценой. */
pop_current_level = FIRST_LEVEL;
pop_cd_init(); /* метка «фон трогали» — пустые диапазоны */
pop_guard_reset();
pop_next_level = pop_current_level; /* current_level = next_level (load_lev_spr) */
pop_kid_hp_newgame(); /* init_game: hitp_beg_lev = start_hitp */
pop_checkpoint = 0; /* init_game (seg003:37) */
if (!pop_settings.show_build_info) pop_pal_apply(POP_PAL_DIM_BLACK);
return 0;
}
/* ЗВУК ВПЕРЁД ОСТАЛЬНЫХ РЕСУРСОВ. Насосу для тишины (а значит и для того,
* чтобы CBL вообще открылся) нужен ровно ОДИН блок набора, и упаковщик
* обязан класть его первым блоком страницы 0 поэтому здесь читается одна
* страница вместо восьми (33 мс против 440). Остальные приезжают в
* pop_boot_rest, а до тех пор pop_sfx_play молча пропускает эффекты.
*
* Звать ПОСЛЕ того, как титул уже проявлен: чтение всё равно занимает
* полсекунды, и лучше, чтобы она прошла под картинкой. */
int pop_boot_sound(void) __banked
{
if (!boot_sound_done) {
boot_sound_done = 1;
(void)pop_sfx_init_begin(); /* эффектов может и не быть — не фатально */
/* Индекс музыкального набора — одна страница EMM. Без него музыки не
* будет (pop_music_info вернёт -1 на любой трек), но игра работает:
* это необязательный ресурс, как и сами треки. */
(void)pop_music_index_load();
pop_settings_apply(1); /* CFG -> speed/cheats/CBL */
}
/* ОТКРЫТИЕ CBL — ВНЕ РАЗОВОГО ГАРДА, и это не мелочь. Загрузка нужна
* один раз за запуск, а вот вывод к моменту следующего показа заставки
* почти наверняка закрыт: pop_title_show начинается с pop_sfx_pause, да
* и уровень оставляет CBL закрытым. Пока start стоял под флагом,
* Restart Game приводил к заставке БЕЗ музыки (поймано пользователем
* 2026-09-02), а заодно подвешивал ожидания «дослушать тему»: с
* выключенным насосом pop_mus_left не убывает, и pop_music_busy()
* истинен вечно. Идемпотентно и уважает Ctrl+S (pop_snd_want). */
(void)pop_sfx_start();
return 0;
}
/* Шаг подкачки трека между кусками загрузки: заставочная тема играет уже
* с первой страницы (pop_music_stream), остальные обязаны приезжать по
* ходу иначе насос уйдёт в ещё не прочитанную страницу. Запас после
* шага 0,75 с звучания на каждые 8 КБ, куски загрузки короче. */
static void boot_music_step(void)
{
if (pop_music_loading()) (void)pop_music_load_step();
}
int pop_boot_rest(void) __banked
{
if (boot_rest_done) return boot_rest_fail ? -1 : 0;
boot_rest_done = 1;
/* Заставку могли пропустить в первую же секунду — тогда звук ещё не
* поднимался. Идемпотентно. */
(void)pop_boot_sound();
(void)pop_sfx_init_finish(); /* дочитать эффекты (музыка уже играет) */
boot_music_step();
if (pop_kid_data_load("KID\\kid.ani") != 0) { /* кадры+seqtbl в EMM-странице */
puts("kid.ani load failed\n");
boot_rest_fail = 1;
return -1;
}
boot_music_step();
pop_shadow_load(); /* запечённый набор Тени — один раз на запуск */
boot_music_step();
if (pop_kid_load(KID_PAGES) != 0) { /* ВСЕ страницы: бег+стоп+разворот+присед+прыжок */
puts("kid atlas load failed");
boot_rest_fail = 1;
return -1;
}
boot_music_step();
/* Стартовая комната до title НЕ НУЖНА: LEVEL_LOAD всё равно выполняет
* pop_level_switch. Единственное исключение явно включённый
* build-info: его фон по контракту является игровой сценой, поэтому
* только здесь читаются фон с уровнем и применяется игровая палитра. */
if (pop_settings.show_build_info) {
if (pop_bg_load(pop_level_type()) != 0 ||
pop_level_load_num(FIRST_LEVEL) != 0) {
puts("build-info scene load failed");
boot_rest_fail = 1;
return -1;
}
pop_guard_load();
pop_pal_game_load();
pop_start_level();
if (pop_current_level == POP_UPSIDE_LEVEL) pop_vflip_load_all();
gfx_set_draw_page(0);
pop_char_draw(POP_CH_KID);
pop_char_fore(POP_CH_KID);
}
/* Звук включаем ПОСЛЕДНИМ действием инициализации — когда все атласы,
* уровень и палитры уже прочитаны. Раньше нельзя: ESTEX уходит в диск
* надолго, насос не успевает долить блок, и на слух это скрежет
* (pop_sfx.h). Парная к pop_sfx_pause в pop_level_switch. */
pop_sfx_start();
return 0;
}
@@ -1209,6 +1292,11 @@ int pop_level_switch(void) __banked
uint8_t old_page;
gfx_rect_t full;
/* РЕСУРСЫ МОГУТ БЫТЬ ЕЩЁ НЕ ПРОЧИТАНЫ. Кид, Тень и шрифт грузятся под
* заставкой (см. шапку pop_boot), а её можно пропустить клавишей в
* первую же секунду. Идемпотентно: со второго раза это проверка байта. */
if (pop_boot_rest() != 0) return -1;
/* Граница FG0: повреждённый snapshot или старый внешний вызов не должен
* даже пытаться открыть res2015.bin. Финальный маршрут 14 -> ENDING
* добавит автомат приложения (FG11); пока просто оставляем уровень. */
@@ -1227,7 +1315,10 @@ int pop_level_switch(void) __banked
* дальше, а урон нет (do_startpos дольёт до потолка). */
hitp_beg_lev = hitp_max;
pop_checkpoint = 0; /* уровень пройден (seg003:413) */
pop_sfx_pause(); /* загрузка уровня — ESTEX надолго, звук глушим */
/* ЗАМЕР 2026-08-28: глушение снято ВРЕМЕННО, чтобы измерить
* зазоры насоса на загрузке уровня (дедлайн 251 000 тактов).
* Если зазоры укладываются снятие остаётся, и трек заставки
* доигрывает поверх загрузки, как в оригинале (seg001:690). */
if (pop_level_load_num(pop_next_level) != 0) {
pop_next_level = pop_current_level; /* файла нет — остаёмся */
} else {
@@ -1294,6 +1385,34 @@ int pop_level_switch(void) __banked
/* Атомарный commit на границе растра: впервые показать
* готовую комнату уже с окончательной палитрой уровня.
* Вторую страницу синхронизировать аппаратной копией. */
/* ЖДЁМ КОНЦА ТРЕКА ЗАСТАВКИ НА ЧЁРНОМ ЭКРАНЕ — порт
* seg003:107:
* while (check_sound_playing() && !do_paused()) idle();
* stop_sounds();
* draw_level_first();
* Порядок оригинала важен: загрузка уровня идёт ПОД
* звучащий трек, и только потом чернота до его конца. Тогда
* общее время черноты = max(трек, загрузка), а не их сумма,
* и трек не обрывается на полуслове. Раньше у нас было
* наоборот: pre_cut_finish досиживал трек, потом глушил
* насос и грузил уровень.
*
* ЗАМЕР 2026-08-28: 500 подряд вызовов насоса через всю
* загрузку максимальный зазор 245 832 такта при дедлайне
* 251 000, ни одной пропущенной порции. Поэтому глушение на
* загрузке снято и музыка её переживает.
*
* Счётчик страховка от вечного ожидания (потоковый трек,
* который никогда не кончится): ~20 с и идём дальше. */
{
uint16_t mus_guard = 1000;
while (pop_music_busy() && mus_guard) {
mus_guard--;
pop_wait_edge();
}
pop_music_free(); /* порт stop_sounds() */
}
pop_wait_edge();
gfx_set_visible_page(level_prepared_page);
gfx_set_draw_page(old_page);
@@ -1322,6 +1441,10 @@ int pop_new_game_load(void) __banked
gfx_wait_vsync();
}
/* ДО pop_pal_game_load: она доплачивает слоты палитры по загруженным
* наборам (Тень, страж, тайлсет), и звать её раньше их чтения нельзя. */
if (pop_boot_rest() != 0) return -1;
pop_kid_hp_newgame();
hitp_max = POP_START_HITP;
pop_timer_new_game();
@@ -1368,4 +1491,14 @@ void pop_shutdown(void) __banked
pop_guard_free();
pop_kid_data_free();
pop_level_free();
/* ЖЕЛЕЗО ЗВУКА ГАСИМ ПОСЛЕДНИМ ДЕЙСТВИЕМ, и pop_sfx_close выше для этого
* НЕ ДОСТАТОЧНО: он закрывает сессию, а bit7 порта CBL остаётся включён,
* пока его держит gfx_wait_vsync ради бита луча. Дальше main просто
* возвращается, а crt0 при возврате уходит прямо в ESTEX EXIT, минуя
* цепочку atexit (runtime/crt0_banked.s) то есть vsync-ссылку снять
* уже некому. В результате мы уходили в шелл с включённым CBL, и он
* крутил свои 256 слотов: тихо до первой чужой записи в порт данных, а
* дальше она зацикливалась (поймано пользователем на железе 2026-09-02;
* в MAME не воспроизводится). */
cbl_shutdown();
}
+14 -2
View File
@@ -83,9 +83,21 @@ void pop_flip_screen(void) __banked;
* Три куска главного цикла с частотой «раз за игру» или «раз на уровень».
* В резиденте от них остались только вызовы. */
/* Загрузить ресурсы, поднять графику и палитру, поставить Кида на старт и
* нарисовать первый экран. 0 OK, -1 фатально (сообщение уже выведено). */
/* СТАРТ В ТРИ ФАЗЫ — ради быстрого появления заставки; подробности и
* причины (в том числе почему часть загрузок убрана совсем) в шапке
* pop_boot в sprpop_cold.c. 0 OK, -1 фатально (сообщение выведено).
*
* pop_boot() настройки + графика, чёрный экран;
* pop_boot_sound() индекс+первая страница звука, индекс музыки, CBL
* звать, когда титул уже на экране: после него можно
* пускать тему заставки;
* pop_boot_rest() всё тяжёлое (эффекты, Кид, Тень, шрифт) под стоящим
* титулом; идемпотентно, страхуется вызовом из
* pop_new_game_load/pop_level_switch на случай, если
* заставку пропустили сразу. */
int pop_boot(void) __banked;
int pop_boot_sound(void) __banked;
int pop_boot_rest(void) __banked;
/* Перейти на pop_next_level: файл уровня, спрайты соперника, тайлсет,
* старт. На время I/O закрывает CBL и оставляет его закрытым: caller обязан
+5
View File
@@ -49,6 +49,8 @@ OBJS_phys := build/eng_pop_geom.rel build/eng_pop_kid.rel \
OBJS_grab := $(OBJS_phys)
# t_wall — удар о стену в воздухе (BUG-JUMPWALL-1), состав тот же.
OBJS_wall := $(OBJS_phys)
# t_hang — зацеп обязан ДЕРЖАТЬСЯ (регресс glide_through_wall_guard).
OBJS_hang := $(OBJS_phys)
# t_gate — открытая решётка не должна считаться стеной у осторожного шага
# (нужны pop_map + физика, то есть тот же состав).
OBJS_gate := $(OBJS_phys)
@@ -65,6 +67,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;
}
+95
View File
@@ -0,0 +1,95 @@
/*
* t_hang.c ЗАЦЕП ОБЯЗАН ДЕРЖАТЬСЯ, а не срываться на следующем кадре.
*
* Регресс 2026-09-02. glide_through_wall_guard() (порт fix_glide_through_wall,
* взят в билд 2026-08-31) звался в do_fall СРАЗУ ПОСЛЕ check_grab. Беда в
* том, что seq_15 называется grab_ledge_MIDAIR: после удачного зацепа
* Char.action == 3 (в воздухе), персонаж уже подтянут вплотную к кромке а
* под кромкой в комнатах оригинала стоит КЛАДКА. Guard видел ровно её
* (t == TILE_WALL, d == 10), считал это «пролётом сквозь стену», отбрасывал
* персонажа на 5 пикселей назад и гасил fall_x. Зацеп РИСОВАЛСЯ и тут же
* срывался уровень 3 (комната 7, длинный прыжок через четыре пролёта) и
* attract-демо (комната 2, прыжок с места).
*
* ПОЧЕМУ ПРЕЖНИЕ НАБОРЫ ЭТОГО НЕ ПОЙМАЛИ. t_grab судит по «действие стало
* вис» а вис-то наступал, он просто не жил. Отсюда мера здесь другая:
* вис обязан ДЕРЖАТЬСЯ подряд несколько кадров. Разница между «зацепился»
* и «зацепился и держится» это и есть разница между багом и нормой.
*
* СЦЕНА (геометрия уровня 3, комната 7, приведённая внутрь одной комнаты,
* чтобы шов не примешивался): кромка на (0,0), под ней кладка (1,0), четыре
* пролёта (0,1..4), толчок с плиты (0,5), разбег справа.
*/
#include <stdint.h>
#include "tcheck.h"
#include "scene.h"
#include "stubs.h"
#include "pop_kid.h"
#include "pop_map.h"
#define E 0x00
#define F 0x01
#define G 0x04
#define L 0x0B
#define T 0x13
#define W 0x14
static uint8_t room[30] = {
F, E, E, E, E, L, F, T, F, T,
W, E, E, E, E, W, W, W, W, W,
W, E, E, E, E, W, W, W, W, W,
};
#define ACT_HANG_CLIMB 2
#define ACT_HANG_STRAIGHT 6
#define HOLD_FRAMES 3
/* Вис ДЕРЖИТСЯ: действие «вис» стоит HOLD_FRAMES кадров подряд. */
static uint8_t hang_holds(uint8_t ledge, uint8_t nrun)
{
uint8_t i, run = 0;
room[0] = ledge;
sc_room(room, 7);
sc_kid_at(9, 0, -1); /* лицом влево, у правого края */
sc_run(SC_L, nrun); /* разбег */
sc_run(SC_L | SC_U, 6); /* толчок в прыжок */
sc_trace_clear();
sc_run(SC_SHIFT, 30); /* полёт, зацеп и жизнь после него */
for (i = 0; i < sc_len; i++) {
uint8_t a = sc_trace[i].action;
if (a == ACT_HANG_CLIMB || a == ACT_HANG_STRAIGHT) {
if (++run >= HOLD_FRAMES) return 1;
} else run = 0;
}
return 0;
}
/* Сколько фаз разбега из 41 дают ЖИВОЙ вис. До регресса — все, кроме
* первой (слишком близкий толчок), то есть 34; при сорванном зацепе ноль. */
static uint8_t window(uint8_t ledge)
{
uint8_t k, n = 0;
for (k = 6; k <= 46; k++) if (hang_holds(ledge, k)) n++;
return n;
}
TC_TEST(long_jump_hang_survives_wall_under_ledge)
{
/* Кромка-пол: под ней кладка — та самая, на которую срабатывал guard. */
TC_TRUE(window(F) >= 30);
}
TC_TEST(long_jump_hang_survives_on_gate)
{
/* Кромка-решётка: тайл другой, кладка под ним та же. Проверяется
* отдельно, потому что зацеп за решётку идёт своей веткой can_grab. */
TC_TRUE(window(G) >= 30);
}
int main(void)
{
sc_init();
TC_RUN(long_jump_hang_survives_wall_under_ledge);
TC_RUN(long_jump_hang_survives_on_gate);
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;
}

Some files were not shown because too many files have changed in this diff Show More