Порт PoP переехал в applications/SprPoP — приложение, которое собирается само: код, оригинальные данные, конверторы ресурсов и сборка внутри одной папки. Наружу знает единственный путь — корень тулчейна (SPRINTER_ROOT, по умолчанию ../..). applications/PoP/roomtest ЗАМОРОЖЕНА и остаётся архивом закрытых задач, багов и исполненных планов. Скопировано из applications/PoP/roomtest@4b74478. Перенос проверен побайтово: собранный sprpop.exe совпал с roomtest.exe того же коммита, все 39 дисковых ресурсов и все 16 генерируемых заголовков — тоже, host- тесты зелёные (15/15). Раскладка: src/ рукописный C (roomtest.c -> sprpop.c) gen/ генерируемые заголовки, в репозитории assets/orig/ оригинальные данные игры, вне репозитория (копирайт) assets/packed/ то, что ложится на диск, в раскладке диска tools/ конверторы; все пути — в одном tools/paths.py build/ выход: exe, каталоги ресурсов, hdd/, промежуточные atl/ Сборка ресурсов: assets/packed и gen — версионируемые ВХОДЫ, а не то, что пересчитывается каждым make. Автоматика построена на ОТСУТСТВИИ файла, а не на таймстемпах: git не хранит времена, и в свежем клоне сравнение по времени превращалось бы в лотерею. Недостающий ресурс или заголовок чинится сам, рекурсивным вызовом в ветку генерации. Музыка собирается из любого из четырёх наборов записей (make music-mp3, music-mt32, ...); набор входит в имя stamp'а, поэтому смена набора сама делает музыку устаревшей. Длины реплик больше не захардкожены: упаковщик печатает их в gen/pop_music_ticks.h, и шкала сцены выражена через них — иначе mt32 (реплики на 6% длиннее) молча ломал катсцену. Тулчейн: в app.mk два обратносовместимых крючка (SRC_DIR/BUILD_DIR), HDD_IMG стал ?=; команда сборки roomtest не изменилась. Корневой make host-tests переключён на SprPoP. Подгонка тайминга катсцены с принцессой (PV_MAGIC_LEAD): сцена render-bound и идёт ~49 тиков/с вместо 60, из-за чего кода реплики приходила раньше молнии. Это обход, а не лечение; разбор с замерами — docs/BUGS_OPEN.md, записи SND-PACE-DEAD, PV-RENDER-BOUND, MUS-LEFT-TEAR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
24 KiB
QuickSave / QuickLoad — разбор оригинала и план реализации
Статус: РЕАЛИЗОВАНО и проверено в MAME (2026-08-22; F6/F9, POP.SAV +
POP.BAK — см. коммит v0.6-pop-quicksave). Документ оставлен как
справочник по формату снимка и разбору. Задача на доске —
../TASKS_OPEN.md#qsave.
0. Важная оговорка об «оригинале»
В оригинальном PoP 1989 года (DOS/Apple II) QuickSave/QuickLoad НЕТ.
Там вообще нет сохранения посреди уровня: игра рассчитана на один заход в
60 минут, а «продолжение» — это только пароль/чекпоинт уровня 7. Поэтому
Prince-of-Persia-Apple-II/ и MSDOS/ тут не источники — искать в них
нечего.
Источник истины — SDLPoP, где быстрое сохранение добавлено как
enhancement: seg000.c, блок #ifdef USE_QUICKSAVE, клавиши F6 (save)
и F9 (load). Ниже разобран именно он. Это значит, что правило
«расхождение с SDLPoP = баг у нас» здесь работает мягче: мы не обязаны
повторять его байт-в-байт, но обязаны повторить его устройство, потому
что оно решает ровно те проблемы, которые возникнут и у нас.
1. Как это устроено в SDLPoP
1.1 Точка вызова — отдельная фаза кадра, не обработчик клавиши
Клавиша только взводит флаг (need_quick_save / need_quick_load,
seg000.c:558), а вся работа делается в check_quick_op() — она вызывается
из главного цикла между кадрами, когда движок в согласованном состоянии.
Это принципиально: загрузка посреди тика переписала бы Char под ногами у
play_seq.
Отказ штатный, не фатальный: quick_save()/quick_load() возвращают
успех/неуспех, и игра печатает QUICKSAVE / NO QUICKLOAD внизу экрана и
продолжается.
1.2 Формат — плоская последовательность переменных, без структуры
#define process(x) ok = ok && process_func(&(x), sizeof(x))
Один макрос и один и тот же список обходится и на запись, и на чтение
(quick_process(process_save) / quick_process(process_load)). Поля
пишутся встык, без имён и тегов; совместимость держится ровно одним
средством — строкой версии в начале файла:
const char quick_version[] = "V1.16b4 ";
При загрузке она сравнивается, и при несовпадении файл просто отвергается
(quick_load, возврат 0). То есть формат нарочно хрупкий и нарочно
одноразовый — это снимок конкретной сборки, а не сейв-формат.
Это стоит перенять целиком. Мы платим за версионирование одним байтом и получаем право менять состав снимка при каждой правке движка.
1.3 Что именно сохраняется
Полный список — quick_process, seg000.c:257-366. По смыслу он делится
на пять групп:
| группа | поля |
|---|---|
| уровень | level (2305 Б целиком), checkpoint, upside_down, drawn_room, current_level, next_level, leveldoor_open |
| анимируемые объекты | mobs_count, mobs[14], trobs_count, trobs[30] |
| Кид | Kid, hitp_curr/max/beg_lev, grab_timer, holding_sword, united_with_shadow, have_sword, kid_sword_strike, pickup_obj_type, offguard |
| соперник | Guard, Char, Opp, guardhp_curr/max, demo_index, demo_time, curr_guard_color, guard_notice_timer, guard_skill, shadow_initialized, guard_refrac, justblocked, droppedout, is_guard_notice, can_guard_see_kid |
| прочее | кэш коллизии (*_row_coll_room/flags, prev_collision_row), вспышка (flash_color/time), звук (is_screaming, is_feather_fall, …), random_seed, время (rem_min, rem_tick), весь блок управления (control_*, ctrl1_*) |
Два наблюдения, важные для нас:
- Состояние ОТРИСОВКИ не сохраняется вообще. Ни экранных буферов, ни пометок перерисовки, ни того, что уже нарисовано. Вместо этого при загрузке комната перерисовывается с нуля. Это резко упрощает задачу и ровно то, что нам нужно при дабл-буфере.
random_seedсохраняется. Без него загрузка не воспроизводима: после неё факелы, чомперы иprandomв боёвке пойдут иначе.
1.4 Что делается при загрузке
restore_room_after_quick_load() (seg000.c:395) — это и есть вся
«сложность» операции:
load_lev_spr(current_level)— перезагрузка графики уровня (тайлсет мог смениться: подземелье/дворец);different_room = 1,next_room = drawn_room = Kid.room— принудительно «мы в другой комнате», чтобы движок перерисовал всё;load_room_links()— связи комнат заново;draw_game_frame()— отрисовать кадр (важно для состояния падения);hitp_delta = guardhp_delta = 1— принудительный редрой полос HP;- если
Guard.room != drawn_room— стража «выключить» (direction = dir_56_none,guardhp_curr = 0), как вclear_char(); loadkid_and_opp()— восстановить окноChar/Opp;- сбросить таймеры текста и
exit_room_timer.
Плюс визуальный приём: перед загрузкой экран заливается чёрным на 5 тиков — чтобы переход читался глазом и не выглядел «дёрганием».
1.5 Чего в SDLPoP решили НЕ восстанавливать
- звуки — просто
stop_sounds(); - перо (
is_feather_fall) — без фиксаfix_quicksave_during_featherсохранение под пером запрещено вовсе, а при загрузке эффект гасится; - есть опциональный штраф:
USE_QUICKLOAD_PENALTYотнимает минуту игрового времени за квиклоад. Нам не нужен (у нас пока нет игрового таймера).
2. Чем наша архитектура отличается
| SDLPoP | у нас | следствие для задачи | |
|---|---|---|---|
| уровень в памяти | level_type в ОЗУ, 2305 Б, мутабельный |
EMM-страница (pop_lvl_page), плюс рабочая копия комнаты в W2 |
снимок читает страницу через W0-маппинг, а не memcpy |
| модификаторы тайлов | внутри level.bg |
отдельный room_modif[24][30] в pop_trob.c (static) |
нужен экспортируемый сериализатор из банка 6 |
| код | один бинарник | 8 банков + резидент | сериализатор обязан жить там же, где данные, и зваться через трамплин |
| экран | один буфер | дабл-буфер, у каждой страницы своя теневая копия | после загрузки перерисовать ОБЕ страницы, иначе через кадр мелькнёт старое |
| ОЗУ | сколько угодно | куча 2969 Б, стек 1279 Б | буфер снимка целиком в ОЗУ не положить — писать потоком |
| диск | fopen |
DSS: 8 манипуляторов, 9-й ВЕШАЕТ систему (dss_fd_limit) | закрывать файл гарантированно, гард уже есть в libc |
| ГСЧ | один random_seed |
три независимых: pop_t_seed, trob_seed, pop_fight_seed |
сохранять все три, иначе загрузка невоспроизводима |
3. Инвентаризация нашего состояния
Собрано по .sprinter-cc-build/.sprinter-cc-sprpop/sprpop.map (данные всех модулей, включая
банковые, лежат в W2 — банк влияет только на код). Отмечено, что глобально
(видно снаружи), а что static и требует аксессора.
3.1 Мутабельные данные уровня
| что | где | размер | доступ |
|---|---|---|---|
тайлы fg (провалившиеся плиты, открытые двери, съеденные предметы) |
EMM-страница уровня | 720 Б | pop_level_set_tile пишет; чтения наружу нет — нужен аксессор |
room_modif[24][30] |
pop_trob.c, static |
720 Б | нужен сериализатор (банк 6) |
room_seen[24] |
pop_trob.c, static |
24 Б | там же |
trobs[30] + trobs_count |
pop_trob.c, static |
91 Б | там же |
trob_seed |
pop_trob.c, static |
4 Б | там же |
mobs[14] |
pop_room.c, глобален |
210 Б | напрямую |
mobs_live |
pop_room.c, static |
1 Б | аксессор |
3.2 Персонажи и бой
Kid, Char, Opp (pop_kid.c), Guard (pop_guard.c) — по 16 Б,
все глобальные. Рядом: hitp_curr/max/beg_lev/delta, guardhp_curr/max/delta,
guard_skill, guard_refrac, justblocked, kid_sword_strike, offguard,
holding_sword, can_guard_see_kid, is_guard_notice,
pop_guard_notice_timer, pop_guard_hurt, pop_united_shadow,
pop_shadow_init, pop_fight_seed, knock.
3.3 Прогресс и физика
pop_current_level, pop_next_level, pop_checkpoint, pop_have_sword,
pop_item_taken, pop_leveldoor_open, pop_leveldoor_right,
pop_leveldoor_ybottom, pop_kid_dead, pop_kid_hurt, pop_feather,
pop_upside / pop_upside_want, pop_flash_time / pop_flash_color,
pop_droppedout, pop_fell_out, pop_leave_dir, pop_leave_timer,
pop_loose_*, pop_ceil_modif, pop_ceil_fell, pop_debris_at,
pop_seamless, pop_jumped_mirror.
3.4 Ввод
control_x/y/shift/forward/backward/up/down/shift2 (pop_state.c) — как в
SDLPoP, сохраняются.
3.5 Что НЕ сохранять (восстанавливается перерисовкой)
room_fg/room_bg, lcol_*/rcol_*/below_fg/above_*, seam_*,
cur_room, pop_t_* (весь кэш слоя фона, окна клипа, pop_cd_*),
trob_drawn, mob_spr, слоты pop_cd, метки pop_redraw, запечки
(bake_pg). Всё это — производное; после загрузки оно обязано быть
сброшено и пересчитано, а не восстановлено.
Оценка объёма снимка: ≈ 1,9 КБ (720 + 720 + 210 + 91 + 64 + ~60 скаляров + запас).
4. Куда писать снимок: HDD-файл, а не EMM-страница
Решение: один основной слот POP.SAV на HDD; предыдущая
валидная запись хранится в POP.BAK. EMM-слота нет: программа
работает только с HDD, а главный сценарий QuickSave обязан переживать
перезапуск игры.
Пересмотрено 2026-08-21 по вопросу пользователя «почему EMM, а не файл». Первая редакция плана рекомендовала EMM — это была ошибка: она взвешивала скорость и недооценивала главный сценарий использования. Разбор оставлен целиком, потому что довод переносится и на другие «положить в память вместо диска» решения.
Решающий довод: EMM-страница не переживает рестарт программы, а именно
рестарт — тот случай, ради которого QuickSave и нужен. Пример из этого же
проекта: сцену каскада плит на 13/23 воспроизводит ТОЛЬКО ESC → запуск
заново (perf_l13_room23.md §1, где перечислено,
почему не годятся ни возврат в комнату, ни рестарт уровня, ни запись
состояния отладчиком). Тем более снимок в ОЗУ не переживает перезапуск
MAME, обязательный после каждой пересборки образа.
| сценарий | EMM | файл |
|---|---|---|
| «переиграть это место ещё раз» | работает, мгновенно | работает, на HDD быстро |
| «вернуться к багу после рестарта» | не работает | работает |
Второй сценарий не закрывается ничем другим; первый закрывается обоими, и разница в скорости там некритична — 1,9 КБ на HDD (mame_hdd_test_disk — быстрый путь против дискеты) не заметны на фоне полной перерисовки комнаты, которая при загрузке делается в любом случае и стоит дороже.
Доводы за EMM, которые при перепроверке оказались слабыми: лимит
манипуляторов DSS ни при чём (открываем и закрываем ровно один файл, гард
_fd_guard в libc и так стоит), а «не нужен путь и права» — экономия одной
строки.
Обход состояния всё равно писать с абстракцией чтения/записи, как у SDLPoP
через process_func, но второй EMM-слот в scope не входит.
Проверить ДО кодинга: пишется ли test_hdd.chd из-под MAME. Если образ
только на чтение, файловый путь упрётся в это на первом же шаге и порядок
работ придётся менять. Проверка дешёвая — записать пробный файл на D: из
SprPoP.
5. Формат снимка
+0 "PQS1" 4 Б магия
+4 версия сборки 1 Б (инкремент при ЛЮБОМ изменении состава)
+5 pop_current_level 1 Б
+6 длина полезной части 2 Б (контроль, что обход совпал)
+8 ... поля встык, ОДИН порядок на запись и на чтение ...
.. checksum 2 Б (заголовок + payload)
Версия проверяется первой; несовпадение — отказ, как в SDLPoP. Никаких тегов и выравнивания: снимок одноразовый и живёт ровно одну сборку.
Обход — один список и один макрос, как process(x):
static void qs_walk(qs_io_t io) /* io = запись или чтение */
{
QS(pop_current_level); QS(pop_checkpoint); ...
}
Так состав нельзя рассинхронизировать между сохранением и загрузкой — единственная реальная опасность плоского формата.
6. Что делать при загрузке (наш аналог restore_room_after_quick_load)
Порядок важен, каждый пункт закрывает конкретный отказ:
- Сменился уровень? →
pop_level_load_num(),pop_bg_load(tileset), атласы стража по типу. Это дорого, но ровно тот же путь, что при переходе уровня (pop_level_switch), — переиспользовать его, а не писать заново. - Залить экран чёрным (приём SDLPoP: переход должен читаться глазом).
- Восстановить состояние обходом
qs_walk. - Сбросить всё производное:
pop_trob_reset(но НЕ трогая восстановленныеroom_modif/trobs— нужен отдельный «мягкий» сброс, толькоtrob_drawn+ метки),pop_redraw_reset, слотыpop_cd,pop_bake_forget,pop_cd_clear, сигнатуры пропуска перерисовки. pop_room_load(Kid.room)— рабочая копия комнаты и срезы соседей.- Полная отрисовка комнаты в ОБЕ страницы дабл-буфера. Это наше главное отличие от SDLPoP: одной перерисовки мало, вторая страница останется со старой картинкой и мигнёт через кадр.
- Принудительный редрой полос HP (
hitp_delta = guardhp_delta = 1). - Если
Guard.room != Kid.room— выключить стража (Guard.direction = DIR_56_NONE,guardhp_curr = 0), какclear_char. pop_loadkid_and_opp()— согласовать окноChar/Opp.
7. Разбиение на шаги
| шаг | что | критерий готовности |
|---|---|---|
| QS0 | Проверить, что D: пишется из-под MAME (пробный файл из SprPoP) |
файл создался и читается обратно после рестарта программы |
| QS1 | Аксессоры/сериализаторы для static-состояния банковых модулей: pop_trob.c (room_modif, room_seen, trobs, trob_seed), pop_room.c (mobs_live), страница уровня (чтение fg) |
хост-тест tests-host/t_qsave.c: обход туда-обратно на синтетическом состоянии даёт байт-в-байт исходное |
| QS2 | Ядро: qs_walk + POP.SAV, магия/версия/checksum, безопасная замена с предыдущей валидной копией в POP.BAK |
сохранение и загрузка в той же комнате, без движения; порча SAV не портит BAK |
| QS3 | Восстановление отрисовки (§6), включая обе страницы дабл-буфера | загрузка после перехода в другую комнату; нет мерцания через кадр |
| QS4 | Клавиши F6/F9 (или свободные из pop_cheat.h) через <kbd_raw.h>, флаги need_quick_save/load, обработка между кадрами |
загрузка посреди боя/падения не ломает play_seq |
| QS5 | Загрузка с другого уровня (перезагрузка уровня и атласов) | сохранить на ур. 2, уйти на ур. 12, загрузить — тайлсет и стражи верные |
Порядок не переставлять: QS0 первым (он может изменить весь план), QS3 без QS2 нечего проверять, а QS5 обязан идти после QS3 — иначе смена тайлсета замаскирует ошибки восстановления.
Главный критерий приёмки всей задачи: сохранить состояние, выйти по
ESC, запустить SprPoP заново, загрузить — и оказаться там же. Именно
этого сценария сейчас нет ничем, и ради него задача и делается.
8. Риски и открытые вопросы
staticв банковых модулях. Их нет в карте символов, то есть отладчиком снимок не проверить. Возможно, стоит сделатьroom_modifиtrobsНЕ-static — так же, как уже сделано сmobsвpop_room.cи ровно по той же мотивации (там это записано прямым комментарием).- Место в банке 6.
pop_trobзанимает 3802/16384 — запас есть, но сериализатор лучше писать компактным обходом, а не 30 отдельными вызовами. - Три ГСЧ. Проверить, что сохранены ВСЕ: пропуск любого даст «загрузилось, но играется иначе» — самый неприятный класс бага, потому что выглядит как случайность.
- Согласованность
CharиKid. У нас окноChar— отдельная копия; если сохранить их рассогласованными (снимок посреди тика), загрузка воскресит рассогласование. Отсюда требование QS4: только между кадрами. Урок свежий — ровно на этом стыке жил BUG-CHEAT-IMM-1. - Дабл-буфер. Самый вероятный источник «почти работает»: забыть вторую
страницу. Симптом — мерцание через кадр
(см.
SprPoP/CLAUDE.md, раздел про дабл-буфер). - Транзакция SAV/BAK. До кодинга проверить на DSS семантику
rename/replace. Если атомарная замена не гарантирована, писать через
POP.NEW, проверять его после close и не удалять единственную валидную копию до завершения новой.