Files
Sprinter-SDCC/applications/PoP/docs/quicksave_plan.md
T
snark13 78b93a6b5e План QuickSave/QuickLoad: разбор SDLPoP + инвентаризация нашего состояния
Только изучение и план, кода нет.

Первое, что выяснилось: в оригинале 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>
2026-08-17 22:56:27 +03:00

297 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# QuickSave / QuickLoad — разбор оригинала и план реализации
Статус: **план, код не начат** (2026-08-17). Задача на доске —
[`../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. Куда писать снимок: 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)`:
```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. Разбиение на шаги
| шаг | что | критерий готовности |
|---|---|---|
| **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. Риски и открытые вопросы
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. **Открытый вопрос:** нужен ли снимок в файле вообще, или EMM-страницы
достаточно. Решать после QS3, по факту использования.