Часть 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
20 KiB
SprPoP — правила приложения
Порт Prince of Persia (DOS/Apple II) на Sprinter Sp2000 поверх нашего
sprinter-cc / libc / libbgi. Действуют правила корневого CLAUDE.md
(сборка, libc, ABI, MAME-автотест); ниже — только специфика порта.
Общение и комментарии — на русском.
Приложение автономно: код, оригинальные данные, конверторы и сборка
лежат внутри этой папки. Наружу оно знает ровно один путь — корень
тулчейна, SPRINTER_ROOT (по умолчанию ../..).
История: SprPoP выделен из applications/PoP/roomtest 2026-08-26. Та
папка ЗАМОРОЖЕНА и остаётся архивом (закрытые задачи и баги с разбором
корней — ../PoP/roomtest/TASKS_CLOSED.md и BUGS_CLOSED.md; там же
исполненные планы в ../PoP/docs/). Разработка идёт только здесь.
Главное правило: SDLPoP — источник истины. Сначала читай, потом кодь
assets/orig/SDLPoP/src/ (github.com/NagyD/SDLPoP, GPLv3) — ЕДИНСТВЕННЫЙ
авторитетный источник того, как оригинальный движок это делает. В
репозитории его нет: заполняется make fetch (make fetch-check — что уже
на месте). Правило без исключений:
- Перед реализацией ЛЮБОЙ функции (движение, коллизия, окклюзия, падение, loose-полы, стражники, отрисовка, тайминги, любые числовые константы) — СНАЧАЛА найди и прочитай соответствующий код в SDLPoP и портируй по нему. Не пиши по памяти, не выводи логику «из общих соображений», не угадывай значения — это источник багов, которые потом ловятся в MAME часами.
- По любому вопросу «как в оригинале должно быть» (что окклюдит что, в каком порядке слои, когда меняется тайл, какая скорость/задержка, что делает такой-то кадр анимации) — ответ ищи в SDLPoP, а не строй гипотезу. Если не нашёл — это повод копать дальше в исходнике, а не додумывать.
- Расхождение нашей реализации с SDLPoP — по умолчанию баг у нас, пока
не доказано обратное (наша платформа/ABI требует отличия — тогда явно
зафиксировать почему в комментарии и записью в
docs/impl_diff.md: что делает оригинал, что делаем мы, чем платим, что проверять при регрессе).
Карта сегментов: seg005 control-диспетчер, seg006 play_kid/коллизия/
seqtbl, seg007 mob/loose/падающие объекты, seg008 отрисовка тайлов/
слои/окклюзия, seg009 чтение ресурсов. Слои окклюзии у нас = слои SDLPoP.
См. memory pop_check_sdlpop_first.
Вторичные референсы (когда в SDLPoP непонятно или нужен другой ракурс)
лежат вне приложения, в ../PoP/: Prince-of-Persia-Apple-II/ —
оригинальный 6502-исходник 1989 (Мехнер), PR/ — Princed Resources,
mininim/ — независимая реализация. Все они — справочник логики,
структур и констант, но НЕ код для копирования (лицензии несовместимы,
наш ABI другой): читаем и переписываем под наш движок.
Доски
Что в работе сейчас — docs/TASKS_OPEN.md (приоритеты, критерии
готовности). Баги — docs/BUGS_OPEN.md (только ОТКРЫТЫЕ).
Правило разнесения: как только задача или баг закрыт — запись целиком
переезжает в архив roomtest (../PoP/roomtest/TASKS_CLOSED.md /
BUGS_CLOSED.md), а в открытом файле остаётся ссылка. Перед заведением
нового бага грепни архив по симптому: корень мог уже разбираться.
Открытые файлы обязаны читаться целиком за раз.
Индекс документации с отметками актуальности — docs/README.md,
начинать чтение оттуда.
Сборка и запуск
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/ (ассеты не трогает)
make distclean clean + снести assets/packed/ (вернуть — make resources)
MEMORY=huge, --gfx 256, 11 банков кода в W3. Отладочные ключи сборки:
make LEVEL=9 (стартовый уровень), make ROOM=15 POS=2 (стартовая комната
и тайл row*10+col), make PROF=1 (профиль фаз кадра полосами бордюра),
make ALLOCS=100000 (плотная упаковка регистров — для замеров размера).
Сравнивать занятость банков можно только при одном ALLOCS.
Ловушка make mame-link: он подменяет mame/v306/IMG/test_hdd.chd
символьной ссылкой на наш образ. Пока ссылка стоит, make hdd любого
другого приложения (в т.ч. замороженной roomtest) писал бы через неё в
наш build/hdd/ — если понадобится собрать чужой образ, ссылку сначала
убрать.
Раскладка папки
| Папка | Что |
|---|---|
src/ |
рукописный C: главный цикл sprpop.c, холодная половина sprpop_cold.c, движок pop_*.c |
gen/ |
генерируемые заголовки (индексы архивов, таблицы кадров, шрифт, палитры). Руками не править — их печатают упаковщики; лежат в репозитории, потому что без них src/ не собрать |
assets/orig/ |
оригинальные данные (SDLPoP, записи музыки; MSDOS — необязательно). Вне репозитория — копирайт; вместо них в репозитории лежит tools/fetch_orig.py, который их качает (make fetch). Правила — в assets/orig/README.md |
assets/packed/ |
то, что ложится на диск игры, уже в раскладке диска (BG/, KID/, …). В репозитории — иначе из чистого клона не собрать |
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 — звучит иначе, длина треков другая). Набор
входит в имя stamp'а, поэтому make music-mt32 пересобирает музыку сам, без
-B; разовая сборка без смены умолчания — make MUSIC_FMT=ogg resources-music.
Ресурсы: упаковщик пишет промежуточные атласы в build/atl/<набор>/, из них
Makefile склеивает архивы прямо в assets/packed/<КАТАЛОГ>/, а make
копирует их в build/. Порядок файлов в списках *_ATL Makefile — это
порядок индексов внутри архива, и он же напечатан в gen/*_arc.h: менять
порядок нельзя, не перегенерировав заголовок.
Каноническая спецификация форматов .DAT —
docs/PoP/POP-DAT-FormatSpecifications.pdf (грепаемая копия — .txt):
первоисточник Princed для DAT v1.0. Наши разборы
(docs/PoP/MSDOS_RESOURCE_FORMAT.md, docs/PoP/APPLEII_RESOURCE_FORMAT.md) —
практические заметки; при расхождении источник истины — спецификация.
Проверка в MAME
Только через toolchain/mame_interactive.py или MCP-мост mame-z80
(memory mame_autotest, mame_mcp_bridge, mame_hdd_test_disk).
Пересобрал образ → MAME ОБЯЗАН полный рестарт (mame_hdd_rebuild_restart).
Тайминги моста (не ждать дольше, см. docs/mame-autotest.md §10):
старт run_bridge.sh → 6 с → go → 8 с → keyseq d:{ENTER} +
keyseq sprpop{ENTER} → 5 с → программа работает.
Отладочные тумблеры в живой сессии (src/sprpop.c): 1 — заморозить
кадр, 2 — продолжить (разбор позы/окклюзии); ESC — выход. Читы
(src/pop_cheat.h): K — убить стража, I — бессмертие,
Shift+L — следующий уровень, +/− — обход комнат (ROOMNAV).
Не чит, а штатная клавиша оригинала: Ctrl+S — вкл/выкл звук (пурпурная
палочка в борте = звука нет). Целевая раскладка управления, к которой
подгоняем SprPoP, — docs/keys.txt.
Логику, которую можно проверить без железа, покрывать в tests/host/
(обвязка — testkit/). MAME остаётся для отрисовки, банков, таймингов и
клавиатуры.
Модули
| Файл | Роль (порт SDLPoP) |
|---|---|
sprpop.c |
Главный цикл: дабл-буфер (2 страницы + флип на vsync), порядок tick→draw. |
sprpop_cold.c |
Холодная половина главного цикла (банк 8): старт уровня, отладочная навигация. |
pop_tile.c/.h |
Общие ЛИСТЬЯ слоя фона — РЕЗИДЕНТ W1: блит куска атласа, чтение тайла комнаты, tile_table, метка «фон трогали вот здесь» (pop_cd_touch). Резидент потому, что их зовут обе половины слоя фона из разных банков, а const-таблицы банка из чужого банка не видны. |
pop_bg.c/.h |
ГОРЯЧАЯ половина слоя фона (банк 2, каждый кадр): fore-окклюзия поверх ЛЮБОГО персонажа (seg003 redraw_at_char/char2 — один проход на всех Char), оверлеи кромки, кладка стены (wall_pattern), клип. pop_bg.h — публичный API ВСЕГО слоя. |
pop_room.c |
ХОЛОДНАЯ половина слоя фона (банк 7, раз на комнату/событие): полная отрисовка комнаты (seg008 draw_tile), точечные перерисовки тайлов (пики, ворота, кнопки, дверь уровня, дрожащие плиты), падающие куски loose (seg007 mob), загрузка атласов. |
_pop_bg.h |
Внутренний контракт между половинами: общее состояние + три тонкие __banked-обёртки; там же почему рез прошёл именно здесь и сколько стоит. |
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). |
Банки: две мины, которых компилятор не видит
Обе стреляют молча, обе поймали нас 2026-08-27 (разбор — в
../PoP/roomtest/BUGS_CLOSED.md, поиск «FINAL-BANKCALL»).
1. Прямой вызов в чужой банк. Трамплин SDCC выбирает по __banked
в ОБЪЯВЛЕНИИ, а не по тому, где функция лежит. Функция без пометки
зовётся прямым call — верно ровно пока вызывающий и вызываемый в одном
банке. Перенесли модуль ради разгрузки банка — и тот же call уходит в
пустой хвост чужого банка, процессор ползёт по 0xFF до 0x0000, DSS убивает
процесс. Ловит toolchain/check_bank_calls.py, она встроена в сборку и
ВАЛИТ её (проверено намеренной поломкой).
2. Указатель на данные своего банка, отданный в чужой. Обратная
сторона: пока обе стороны в одном банке, указатель на литерал или
const-таблицу работает; пометили функцию __banked — и трамплин на время
вызова переключает W3, а указатель показывает уже в чужой банк. Так экран
таблицы рекордов залило знаками вопроса: в pop_text_draw_mapped (банк 9)
уходил литерал "_" из банка 10.
Правило: всё, что уходит указателем в другой банк, обязано лежать в
W2 — стек, _DATA или копия. const-таблицы и строковые литералы
банкового модуля наружу отдавать нельзя (то же, что уже записано про
pop_tile.c). Вторая проверка скрипта предупреждает о явных случаях, но
она эвристическая и гарантией не является.
Порядок слоёв в кадре (важно для окклюзии)
sprpop.c каждый тик рисует в СКРЫТУЮ страницу: pop_char_heal (стереть
прошлый кадр — по слоту на персонажа) → kid_tick → pop_phys_tick →
pop_loose_tick (loose СЛОЙ ФОНА — ДО персонажей, чтобы они были поверх
плиты) → pop_char_draw/pop_char_fore для соперника и Кида (кто позже —
тот поверх, порядок задаёт обход тайлов) → pop_room_clip_borders (чистка
бортов, гейт по флагу — только в кадрах падения) → флип на vsync.
Дабл-буфер: у каждой страницы своя видео-ОЗУ и теневая ОЗУ-копия; heal
берёт чистый фон из копии ТОЙ страницы, в которую рисуем. Любой
динамический элемент (Kid, loose-плита, падающий кусок) обязан чистить свой
прошлый кадр на КАЖДОЙ из двух страниц — иначе остаток виден через кадр как
мерцание. Типовой источник багов «остаётся кусочек» (memory
pop_fall_debug_baseline).
Ключевые memory
pop_port_project, pop_check_sdlpop_first, pop_banking_architecture,
pop_background_strategy, pop_kid_plan, pop_hang_state, pop_fore_layer,
pop_fall_debug_baseline, accel_vertical_copy, kbd_raw_fifo_drain,
pop_perf_registry.