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

329 lines
24 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 — разбор оригинала и план реализации
Статус: **РЕАЛИЗОВАНО и проверено в 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`) через `<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](../roomtest/BUGS_CLOSED.md#bug-cheat-imm-1).
5. **Дабл-буфер.** Самый вероятный источник «почти работает»: забыть вторую
страницу. Симптом — мерцание через кадр
(см. `roomtest/CLAUDE.md`, раздел про дабл-буфер).
6. **Транзакция SAV/BAK.** До кодинга проверить на DSS семантику
rename/replace. Если атомарная замена не гарантирована, писать через
`POP.NEW`, проверять его после close и не удалять единственную валидную копию
до завершения новой.