Files
Sprinter-SDCC/applications/PoP/docs/quicksave_plan.md
T
snark13 7fd7f28ffc Доки: план меню (рендер, restart без подтверждения), QSAVE закрыт
menu_settings_plan.md:
- §10 переписан: выбран Вариант A — текстовые строки + собственный
  растровый рендерер в новом банке; референс SDLPoP (hc_small_font /
  hc_font — один рендерер, два шрифта); шрифт как ассет из паковщика,
  прототип MS4 — системный CP866 ZG; двуязычность eng/rus через пару
  (таблица строк CP866, файл шрифта)
- Restart Level / Restart Game выполняются сразу, без подтверждения
  (§3, §8, из §11 убраны диалоги RESTART *?)
- §13 MS4: текстовый рендерер + два шрифта; §14: host-тест рендерера

quicksave_plan.md: статус «РЕАЛИЗОВАНО и проверено в MAME»
(v0.6-pop-quicksave), документ оставлен справочником по формату 'POPQ'

TASKS_OPEN/TASKS_CLOSED: запись QSAVE переехала в закрытые с полным
протоколом; docs/README.md аннотации обновлены
2026-08-22 12:45:01 +03:00

24 KiB
Raw Blame History

QuickSave / QuickLoad — разбор оригинала и план реализации

Статус: РЕАЛИЗОВАНО и проверено в MAME (2026-08-22; F6/F9, POP.SAV + POP.BAK — см. коммит v0.6-pop-quicksave). Документ оставлен как справочник по формату снимка и разбору. Задача на доске — ../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 Формат — плоская последовательность переменных, без структуры

#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_*)

Два наблюдения, важные для нас:

  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 §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):

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) через <kbd_raw.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.
  5. Дабл-буфер. Самый вероятный источник «почти работает»: забыть вторую страницу. Симптом — мерцание через кадр (см. roomtest/CLAUDE.md, раздел про дабл-буфер).
  6. Транзакция SAV/BAK. До кодинга проверить на DSS семантику rename/replace. Если атомарная замена не гарантирована, писать через POP.NEW, проверять его после close и не удалять единственную валидную копию до завершения новой.