# QuickSave / QuickLoad — разбор оригинала и план реализации Статус: **РЕАЛИЗОВАНО и проверено в MAME** (2026-08-22; F6/F9, POP.SAV + POP.BAK — см. коммит `v0.6-pop-quicksave`). Документ оставлен как справочник по формату снимка и разбору. Задача на доске — [`../roomtest/TASKS_OPEN.md#qsave`](../roomtest/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 Формат — плоская последовательность переменных, без структуры ```c #define process(x) ok = ok && process_func(&(x), sizeof(x)) ``` Один макрос и один и тот же список обходится **и на запись, и на чтение** (`quick_process(process_save)` / `quick_process(process_load)`). Поля пишутся встык, без имён и тегов; совместимость держится ровно одним средством — **строкой версии в начале файла**: ```c 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_*`) | Два наблюдения, важные для нас: 1. **Состояние ОТРИСОВКИ не сохраняется вообще.** Ни экранных буферов, ни пометок перерисовки, ни того, что уже нарисовано. Вместо этого при загрузке комната перерисовывается с нуля. Это резко упрощает задачу и ровно то, что нам нужно при дабл-буфере. 2. **`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-roomtest/roomtest.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`](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:` из roomtest. --- ## 5. Формат снимка ``` +0 "PQS1" 4 Б магия +4 версия сборки 1 Б (инкремент при ЛЮБОМ изменении состава) +5 pop_current_level 1 Б +6 длина полезной части 2 Б (контроль, что обход совпал) +8 ... поля встык, ОДИН порядок на запись и на чтение ... .. checksum 2 Б (заголовок + payload) ``` Версия проверяется первой; несовпадение — отказ, как в SDLPoP. Никаких тегов и выравнивания: снимок одноразовый и живёт ровно одну сборку. Обход — один список и один макрос, как `process(x)`: ```c static void qs_walk(qs_io_t io) /* io = запись или чтение */ { QS(pop_current_level); QS(pop_checkpoint); ... } ``` Так состав нельзя рассинхронизировать между сохранением и загрузкой — единственная реальная опасность плоского формата. --- ## 6. Что делать при загрузке (наш аналог `restore_room_after_quick_load`) Порядок важен, каждый пункт закрывает конкретный отказ: 1. **Сменился уровень?** → `pop_level_load_num()`, `pop_bg_load(tileset)`, атласы стража по типу. Это дорого, но ровно тот же путь, что при переходе уровня (`pop_level_switch`), — переиспользовать его, а не писать заново. 2. Залить экран чёрным (приём SDLPoP: переход должен читаться глазом). 3. Восстановить состояние обходом `qs_walk`. 4. **Сбросить всё производное:** `pop_trob_reset` (но НЕ трогая восстановленные `room_modif`/`trobs` — нужен отдельный «мягкий» сброс, только `trob_drawn` + метки), `pop_redraw_reset`, слоты `pop_cd`, `pop_bake_forget`, `pop_cd_clear`, сигнатуры пропуска перерисовки. 5. `pop_room_load(Kid.room)` — рабочая копия комнаты и срезы соседей. 6. **Полная отрисовка комнаты в ОБЕ страницы дабл-буфера.** Это наше главное отличие от SDLPoP: одной перерисовки мало, вторая страница останется со старой картинкой и мигнёт через кадр. 7. Принудительный редрой полос HP (`hitp_delta = guardhp_delta = 1`). 8. Если `Guard.room != Kid.room` — выключить стража (`Guard.direction = DIR_56_NONE`, `guardhp_curr = 0`), как `clear_char`. 9. `pop_loadkid_and_opp()` — согласовать окно `Char`/`Opp`. --- ## 7. Разбиение на шаги | шаг | что | критерий готовности | |---|---|---| | **QS0** | Проверить, что `D:` пишется из-под MAME (пробный файл из roomtest) | файл создался и читается обратно после рестарта программы | | **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`) через ``, флаги `need_quick_save/load`, обработка **между кадрами** | загрузка посреди боя/падения не ломает `play_seq` | | **QS5** | Загрузка с **другого уровня** (перезагрузка уровня и атласов) | сохранить на ур. 2, уйти на ур. 12, загрузить — тайлсет и стражи верные | Порядок не переставлять: QS0 первым (он может изменить весь план), QS3 без QS2 нечего проверять, а QS5 обязан идти после QS3 — иначе смена тайлсета замаскирует ошибки восстановления. **Главный критерий приёмки всей задачи:** сохранить состояние, выйти по `ESC`, запустить roomtest заново, загрузить — и оказаться там же. Именно этого сценария сейчас нет ничем, и ради него задача и делается. --- ## 8. Риски и открытые вопросы 1. **`static` в банковых модулях.** Их нет в карте символов, то есть отладчиком снимок не проверить. Возможно, стоит сделать `room_modif` и `trobs` НЕ-static — так же, как уже сделано с `mobs` в `pop_room.c` и ровно по той же мотивации (там это записано прямым комментарием). 2. **Место в банке 6.** `pop_trob` занимает 3802/16384 — запас есть, но сериализатор лучше писать компактным обходом, а не 30 отдельными вызовами. 3. **Три ГСЧ.** Проверить, что сохранены ВСЕ: пропуск любого даст «загрузилось, но играется иначе» — самый неприятный класс бага, потому что выглядит как случайность. 4. **Согласованность `Char` и `Kid`.** У нас окно `Char` — отдельная копия; если сохранить их рассогласованными (снимок посреди тика), загрузка воскресит рассогласование. Отсюда требование QS4: только между кадрами. Урок свежий — ровно на этом стыке жил [BUG-CHEAT-IMM-1](../roomtest/BUGS_CLOSED.md#bug-cheat-imm-1). 5. **Дабл-буфер.** Самый вероятный источник «почти работает»: забыть вторую страницу. Симптом — мерцание через кадр (см. `roomtest/CLAUDE.md`, раздел про дабл-буфер). 6. **Транзакция SAV/BAK.** До кодинга проверить на DSS семантику rename/replace. Если атомарная замена не гарантирована, писать через `POP.NEW`, проверять его после close и не удалять единственную валидную копию до завершения новой.