Files
Sprinter-SDCC/applications/PoP/docs/quicksave_plan.md
T
snark13 0cae2cb32a QuickSave: носитель — файл, а не EMM-страница
Пересмотр по вопросу пользователя.  Первая редакция плана рекомендовала
EMM-страницу — ошибка: взвешивала скорость и недооценивала главный
сценарий.

EMM-страница не переживает рестарт программы, а именно рестарт — тот
случай, ради которого QuickSave и нужен: сцену каскада плит на 13/23
воспроизводит ТОЛЬКО ESC → запуск заново (perf_l13_room23.md §1).  Снимок
в ОЗУ там не помогает вовсе.

Доводы за EMM при перепроверке оказались слабыми: лимит манипуляторов DSS
ни при чём (один файл, гард _fd_guard и так стоит), а экономия на пути к
файлу — одна строка.  Разница в скорости некритична: 1,9 КБ на HDD не
заметны на фоне полной перерисовки комнаты при загрузке.

Добавлен шаг QS0 — проверить, что D: вообще пишется из-под MAME: если
образ только на чтение, это меняет весь план, поэтому идёт первым.
Критерий приёмки задачи: сохранить, выйти, запустить заново, загрузить —
и оказаться там же.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:01:14 +03:00

325 lines
23 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-страница
Рекомендация: **основной путь — файл `QUICKSAVE.SAV`; EMM-страница —
необязательный второй слот.**
> Пересмотрено 2026-08-17 по вопросу пользователя «почему 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 и так стоит), а «не нужен путь и права» — экономия одной
строки.
Что остаётся за EMM: мгновенный слот для «переиграть» без обращения к диску.
Делается тем же сериализатором и добавляется, если понадобится. Поэтому
обход состояния писать сразу так, чтобы «куда» было параметром — как у
SDLPoP через `process_func`.
**Проверить ДО кодинга:** пишется ли `test_hdd.chd` из-под MAME. Если образ
только на чтение, файловый путь упрётся в это на первом же шаге и порядок
работ придётся менять. Проверка дешёвая — записать пробный файл на `D:` из
roomtest.
---
## 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`: обход туда-обратно на синтетическом состоянии даёт байт-в-байт исходное |
| **QS0** | Проверить, что `D:` пишется из-под MAME (пробный файл из roomtest) | файл создался и читается обратно после рестарта программы |
| **QS2** | Ядро: `qs_walk` + запись/чтение файла `QUICKSAVE.SAV`, магия и версия, отказ при несовпадении | сохранение и загрузка **в той же комнате, без движения** — картинка и состояние не изменились |
| **QS3** | Восстановление отрисовки (§6), включая обе страницы дабл-буфера | загрузка после перехода в другую комнату; нет мерцания через кадр |
| **QS4** | Клавиши **F6/F9** (или свободные из `pop_cheat.h`) через `<kbd_raw.h>`, флаги `need_quick_save/load`, обработка **между кадрами** | загрузка посреди боя/падения не ломает `play_seq` |
| **QS5** | Загрузка с **другого уровня** (перезагрузка уровня и атласов) | сохранить на ур. 2, уйти на ур. 12, загрузить — тайлсет и стражи верные |
| **QS6** | Опционально: второй слот в EMM-странице тем же сериализатором | мгновенное «переиграть» без обращения к диску |
Порядок не переставлять: 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. **Открытый вопрос:** нужен ли снимок в файле вообще, или EMM-страницы
достаточно. Решать после QS3, по факту использования.