7fd7f28ffc
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 аннотации обновлены
329 lines
24 KiB
Markdown
329 lines
24 KiB
Markdown
# 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 и не удалять единственную валидную копию
|
||
до завершения новой.
|