diff --git a/applications/PoP/docs/README.md b/applications/PoP/docs/README.md index af59f08..1c432c1 100644 --- a/applications/PoP/docs/README.md +++ b/applications/PoP/docs/README.md @@ -13,6 +13,7 @@ | [`perf_green_phase.md`](perf_green_phase.md) | **ЗЕЛЁНАЯ фаза (слой фона)**: раскладка тактов, способы ускорения (G1..G6), журнал правок — рабочий документ между сессиями. 2026-08-17 | | [`perf_cyan_phase.md`](perf_cyan_phase.md) | **ЦИАН фаза (персонажи + передний слой)**: раскладка тактов, способы ускорения (C1..C7), журнал правок — рабочий документ между сессиями. 2026-08-17 | | [`perf_backlog.md`](perf_backlog.md) | Отложенная оптимизация отрисовки с замерами 2026-08-10 + **как мерить** (wait-state'ы, границы кадра). Позиции 1–7 переехали в фазовые документы выше | +| [`quicksave_plan.md`](quicksave_plan.md) | **QuickSave/QuickLoad**: разбор (это enhancement SDLPoP, в оригинале 1989 его НЕТ), инвентаризация нашего состояния, формат снимка, шаги QS1..QS6. План, код не начат. 2026-08-17 | | [`levels_plan.md`](levels_plan.md) | Следующий этап: уровни 2+, второй тайлсет, читы SDLPoP | | [`levels_12_15_plan.md`](levels_12_15_plan.md) | **Уровни 12/13** (тень, Джафар, падающие плиты) + что такое 14/15 и 0. 2026-08-13 | | [`midtable_analysis.md`](midtable_analysis.md) | **Слои отрисовки**: как устроены back/mid/fore и objtable в оригинале, чего стоит порт, развилки. 2026-08-13 | diff --git a/applications/PoP/docs/quicksave_plan.md b/applications/PoP/docs/quicksave_plan.md new file mode 100644 index 0000000..650bce3 --- /dev/null +++ b/applications/PoP/docs/quicksave_plan.md @@ -0,0 +1,296 @@ +# 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`) через ``, флаги `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, по факту использования. diff --git a/applications/PoP/roomtest/TASKS_OPEN.md b/applications/PoP/roomtest/TASKS_OPEN.md index 6d65369..4896f96 100644 --- a/applications/PoP/roomtest/TASKS_OPEN.md +++ b/applications/PoP/roomtest/TASKS_OPEN.md @@ -864,6 +864,34 @@ tp/10 у факелов таблицей, пустой слот соперник ## P1 — берётся в любой момент +### QSAVE. QuickSave / QuickLoad + +> **План целиком — [`../docs/quicksave_plan.md`](../docs/quicksave_plan.md)** +> (разбор SDLPoP, инвентаризация нашего состояния, формат снимка, порядок +> восстановления, разбиение на шаги QS1…QS6, риски). Изучено 2026-08-17, +> **код не начат.** + +Оговорка, с которой начинается план: **в оригинале 1989 года этого нет +вовсе**, QuickSave — enhancement SDLPoP (`seg000.c`, `USE_QUICKSAVE`, +F6/F9). Значит повторяем не букву, а устройство. + +Что взять у 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. + ### L1-SPEED. Игра идёт быстрее оригинала (найдено 2026-08-01) Сверка таймингов: оригинал — `BASE_FPS = 60` при `base_speed = 5` тиков на