Только изучение и план, кода нет. Первое, что выяснилось: в оригинале 1989 года быстрого сохранения НЕТ вовсе — это enhancement SDLPoP (seg000.c, USE_QUICKSAVE, F6/F9). Значит искать в Apple II / MSDOS нечего, и повторяем мы не букву, а устройство. Что берём у SDLPoP: плоский снимок с ОДНИМ обходом на запись и на чтение (#define process(x)); совместимость держится строкой версии и ничем больше; клавиша только взводит флаг, работа идёт между кадрами; состояние отрисовки не сохраняется вовсе — комната перерисовывается с нуля. Чем наш случай тяжелее: уровень в EMM-странице, room_modif/trobs — static в банковом pop_trob.c, ГСЧ у нас ТРИ (pop_t_seed, trob_seed, pop_fight_seed), и дабл-буфер требует перерисовать после загрузки ОБЕ страницы. Снимок ≈1,9 КБ, поэтому основной носитель — EMM-страница (мгновенно, мимо DSS и его лимита манипуляторов), файл вынесен в необязательный шаг QS6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
21 KiB
QuickSave / QuickLoad — разбор оригинала и план реализации
Статус: план, код не начат (2026-08-17). Задача на доске —
../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_*) |
Два наблюдения, важные для нас:
- Состояние ОТРИСОВКИ не сохраняется вообще. Ни экранных буферов, ни пометок перерисовки, ни того, что уже нарисовано. Вместо этого при загрузке комната перерисовывается с нуля. Это резко упрощает задачу и ровно то, что нам нужно при дабл-буфере.
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. Куда писать снимок: EMM-страница, а не файл
Рекомендация: основной путь — EMM-страница, файл опционален.
Мотивы:
- снимок 1,9 КБ, страница 16 КБ — влезает целиком, с запасом на рост;
- свободно 215 страниц / 3440 КБ на старте (sprinter_emm_budget) — одна страница не заметна;
- сохранение/загрузка становятся мгновенными (копия через W0), без обращения к DSS и без риска упереться в лимит манипуляторов;
- не нужен путь к файлу и права на запись; на дискете запись ещё и медленная.
Цена: снимок не переживает выход из программы. Для отладочного инструмента (а QuickSave у нас в первую очередь именно он — быстро вернуться к месту бага) это ровно то, что нужно.
Файловый вариант (QUICKSAVE.SAV рядом с exe) делается тем же
сериализатором и добавляется вторым шагом, если понадобится переживать
рестарт. Общий обход состояния писать сразу так, чтобы «куда» было
параметром — как у SDLPoP через process_func.
5. Формат снимка
+0 "PQS1" 4 Б магия
+4 версия сборки 1 Б (инкремент при ЛЮБОМ изменении состава)
+5 pop_current_level 1 Б
+6 длина полезной части 2 Б (контроль, что обход совпал)
+8 ... поля встык, ОДИН порядок на запись и на чтение ...
Версия проверяется первой; несовпадение — отказ, как в 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. Разбиение на шаги
| шаг | что | критерий готовности |
|---|---|---|
| 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 + запись/чтение в EMM-страницу, магия и версия, отказ при несовпадении |
сохранение и загрузка в той же комнате, без движения — картинка и состояние не изменились |
| QS3 | Восстановление отрисовки (§6), включая обе страницы дабл-буфера | загрузка после перехода в другую комнату; нет мерцания через кадр |
| QS4 | Клавиши F6/F9 (или свободные из pop_cheat.h) через <kbd_raw.h>, флаги need_quick_save/load, обработка между кадрами |
загрузка посреди боя/падения не ломает play_seq |
| QS5 | Загрузка с другого уровня (перезагрузка уровня и атласов) | сохранить на ур. 2, уйти на ур. 12, загрузить — тайлсет и стражи верные |
| QS6 | Опционально: файл QUICKSAVE.SAV тем же сериализатором |
снимок переживает рестарт программы |
Порядок не переставлять: QS3 без QS2 нечего проверять, а QS5 обязан идти после QS3 — иначе смена тайлсета замаскирует ошибки восстановления.
8. Риски и открытые вопросы
staticв банковых модулях. Их нет в карте символов, то есть отладчиком снимок не проверить. Возможно, стоит сделатьroom_modifиtrobsНЕ-static — так же, как уже сделано сmobsвpop_room.cи ровно по той же мотивации (там это записано прямым комментарием).- Место в банке 6.
pop_trobзанимает 3802/16384 — запас есть, но сериализатор лучше писать компактным обходом, а не 30 отдельными вызовами. - Три ГСЧ. Проверить, что сохранены ВСЕ: пропуск любого даст «загрузилось, но играется иначе» — самый неприятный класс бага, потому что выглядит как случайность.
- Согласованность
CharиKid. У нас окноChar— отдельная копия; если сохранить их рассогласованными (снимок посреди тика), загрузка воскресит рассогласование. Отсюда требование QS4: только между кадрами. Урок свежий — ровно на этом стыке жил BUG-CHEAT-IMM-1. - Дабл-буфер. Самый вероятный источник «почти работает»: забыть вторую
страницу. Симптом — мерцание через кадр
(см.
roomtest/CLAUDE.md, раздел про дабл-буфер). - Открытый вопрос: нужен ли снимок в файле вообще, или EMM-страницы достаточно. Решать после QS3, по факту использования.