From 774b1cc7c414c2b6763fcd8c1b15a1908041d248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9F=D0=B5=D1=82=D1=80=D0=BE=D0=B2?= Date: Sat, 1 Aug 2026 15:32:48 +0300 Subject: [PATCH] =?UTF-8?q?docs(PoP):=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BA=20=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D1=83=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC=D1=83=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D1=83=20+=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D0=BD=20=D1=81=D0=BB=D0=B5=D0=B4=D1=83=D1=8E=D1=89=D0=B8?= =?UTF-8?q?=D1=85=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Документы отстали от кода: PORT_PLAN писал «PoC не начат», хотя играется весь уровень 1, а четыре плана были исполнены целиком. - PORT_PLAN: таблица статусов по разделам; фазы 0-3 сделаны, 4-6 нет; риски §8 п.1/п.3 закрыты, п.2 переформулирован под реальный движок (спрайтовый движок для персонажей не используется, лимит «21 спрайт» неприменим), п.4 — найдено расхождение таймингов: оригинал считает логический кадр за 5 тиков при BASE_FPS=60 (83.3 мс, в бою 100 мс), а мы ждём три vsync (60 мс) — игра идёт примерно на 39 % быстрее эталона. - levels_plan.md — новый: машинерия перехода между уровнями, второй тайлсет (palace), потабличные различия и читы SDLPoP, которые окупаются сразу. Инвентарь тайлов снят прямо с res200N.bin: уровень 2 не требует ни одного нового ассета и ни одной новой механики. - roomtest/TASKS.md — новый: доска текущих задач с критериями готовности. - Удалены как исполненные и перекрытые кодом: clip_char_plan, double_buffer_plan, loose_floors_plan, size_optimization_plan. Его §8 (замеры скорости отрисовки) не был перекрыт — перенесён в layout_plan_v2 §9, чтобы не потерять цифры. - KID_PLAN / gates_spikes_plan — шапки «реализовано, оставлено справочником»; room_model_plan — «S1 сделан, остальное не срочно». - docs/README.md стал индексом с отметками актуальности. - ideas_backlog: зелье переворота экрана — оригинал переворачивает готовый буфер построчно, спрайты не трогает; по данным уровней тип 4 встречается только на уровне 9, до него механика не нужна. - examples/scroll: ссылка на удалённый план вела к неверному факту «теневая копия одна — общая»; заменено на подтверждённое «у каждой страницы своя». Co-Authored-By: Claude Opus 5 --- applications/PoP/CLAUDE.md | 13 +- applications/PoP/README.md | 15 +- applications/PoP/docs/KID_PLAN.md | 17 +- applications/PoP/docs/PORT_PLAN.md | 150 +++++--- applications/PoP/docs/README.md | 42 ++- applications/PoP/docs/clip_char_plan.md | 112 ------ applications/PoP/docs/double_buffer_plan.md | 69 ---- applications/PoP/docs/gates_spikes_plan.md | 21 +- applications/PoP/docs/ideas_backlog.md | 48 +++ applications/PoP/docs/layout_plan_v2.md | 74 +++- applications/PoP/docs/levels_plan.md | 188 ++++++++++ applications/PoP/docs/loose_floors_plan.md | 143 -------- applications/PoP/docs/room_model_plan.md | 16 + .../PoP/docs/size_optimization_plan.md | 335 ------------------ applications/PoP/roomtest/CLAUDE.md | 4 + applications/PoP/roomtest/README.md | 15 +- applications/PoP/roomtest/TASKS.md | 329 +++++++++++++++++ applications/PoP/roomtest/bug_list.md | 9 + examples/scroll/scroll-impl-guide.md | 13 +- 19 files changed, 873 insertions(+), 740 deletions(-) delete mode 100644 applications/PoP/docs/clip_char_plan.md delete mode 100644 applications/PoP/docs/double_buffer_plan.md create mode 100644 applications/PoP/docs/levels_plan.md delete mode 100644 applications/PoP/docs/loose_floors_plan.md delete mode 100644 applications/PoP/docs/size_optimization_plan.md create mode 100644 applications/PoP/roomtest/TASKS.md diff --git a/applications/PoP/CLAUDE.md b/applications/PoP/CLAUDE.md index ba293e9..62d75ad 100644 --- a/applications/PoP/CLAUDE.md +++ b/applications/PoP/CLAUDE.md @@ -59,11 +59,14 @@ Princed для DAT v1.0 (контейнер/индекс/чек-сумма, ко ## Структура папки -- `docs/` — планы и форматы: `PORT_PLAN.md` (общий план фаз), - `KID_PLAN.md`, `double_buffer_plan.md`, `loose_floors_plan.md`, форматы - ресурсов. Начинать чтение отсюда. -- `roomtest/` — **активная разработка**: комната 1 + Kid (анимация, ввод, - коллизия, падение, зацеп, fore-окклюзия, loose-полы). Свой `CLAUDE.md`. +- `docs/` — планы и форматы; **индекс с отметками актуальности — + `docs/README.md`**, начинать чтение оттуда. Ключевое: + `levels_plan.md` (следующий этап), `layout_plan_v2.md` (раскладка кода по + окнам/банкам + скорость отрисовки), `PORT_PLAN.md` (карта фаз со + статусами). +- `roomtest/` — **активная разработка**: уровень 1 целиком (Kid, стражи, + ловушки, ворота, loose-полы). Свой `CLAUDE.md`; текущие задачи — + `roomtest/TASKS.md`, баги — `roomtest/bug_list.md`. - `poc/` — ранний proof-of-concept (снег/атлас/kbd_raw); ассеты в `poc/res/`. - `bgtest/`, `coltest/` — отдельные проверки фона/коллизии. - `toolchain/` — python-упаковщики ассетов + эталонные PNG (`1.1-2.png`). diff --git a/applications/PoP/README.md b/applications/PoP/README.md index 75fba6e..a9a8d55 100644 --- a/applications/PoP/README.md +++ b/applications/PoP/README.md @@ -4,15 +4,22 @@ target-слоя SDCC (sprinter-cc / libc / libbgi). Цель — 320×256×256 (режим 0x81), VGA-256 ассеты оригинала переносятся почти впрямую. -Общий план и статус фаз — [`docs/PORT_PLAN.md`](docs/PORT_PLAN.md). -Правила работы для ИИ-сессий — [`CLAUDE.md`](CLAUDE.md). +**Состояние (2026-08-01): играется весь уровень 1** — комнаты и переходы, +Kid со всем набором действий, ловушки, ворота, дверь уровня, меч и бой, +стражи с ИИ, HP и зелья. Нет: перехода на следующий уровень, звука, +таймера/HUD, сохранений. + +- Что в работе прямо сейчас — [`roomtest/TASKS.md`](roomtest/TASKS.md). +- Следующий этап (уровни 2+) — [`docs/levels_plan.md`](docs/levels_plan.md). +- Общий план и статус фаз — [`docs/PORT_PLAN.md`](docs/PORT_PLAN.md). +- Правила работы для ИИ-сессий — [`CLAUDE.md`](CLAUDE.md). ## Что где | Папка | Назначение | |-------|-----------| -| `roomtest/` | **Активная разработка.** Комната 1 уровня 1 живой композицией тайлов + Kid: анимация (seqtbl), управление с клавиатуры, коллизия, падение, зацеп/подтягивание, fore-окклюзия, проваливающиеся полы. Свой README/CLAUDE. | -| `docs/` | Планы (`PORT_PLAN`, `KID_PLAN`, `double_buffer_plan`, `loose_floors_plan`) и разбор форматов ресурсов Apple II / DOS. | +| `roomtest/` | **Активная разработка.** Уровень 1 целиком: фон композицией тайлов, Kid (seqtbl-анимация, ввод, коллизия, падение, зацеп, окклюзия), ловушки, ворота, стражи, бой. Свой README/CLAUDE/TASKS. | +| `docs/` | Планы и разбор форматов ресурсов Apple II / DOS — см. индекс в [`docs/README.md`](docs/README.md). | | `toolchain/` | Python-упаковщики ассетов под Sprinter (рендер комнат, атласы тайлов/Kid, извлечение данных анимации) + эталонные скриншоты. | | `poc/` | Ранний proof-of-concept (снег, атлас, raw-клавиатура). Ассеты в `poc/res/`. | | `bgtest/`, `coltest/` | Точечные проверки фона и коллизии. | diff --git a/applications/PoP/docs/KID_PLAN.md b/applications/PoP/docs/KID_PLAN.md index f0756f6..4a2065f 100644 --- a/applications/PoP/docs/KID_PLAN.md +++ b/applications/PoP/docs/KID_PLAN.md @@ -1,6 +1,21 @@ # Prince of Persia — Kid (персонаж): анализ и план -Статус: план (2026-07-16). Опирается на разбор `SDLPoP/src/seg006.c` +> **Статус: РЕАЛИЗОВАНО (2026-08-01).** Kid играется целиком: интерпретатор +> `seqtbl` + `frame_table` (`roomtest/pop_kid.c`), диспетчер `control()` +> (`pop_ctrl.c`), коллизия/физика/зацеп (`pop_map.c`), бой и HP. Модель +> персонажа стала общей: `Char`-окно (`pop_state.c`) обслуживает и Кида, и +> стражей. Таблицы кадров и `seqtbl` уехали из `_CODE` в EMM-страницу +> (`kid_data.bin`, см. `layout_plan_v2.md` шаг 1). +> +> Отступление от §2.1 плана: выбран ПАДДИНГ кадров (общий канвас), а не +> per-frame offset — компромисс зафиксирован в `PORT_PLAN.md §6.1`. +> +> **Документ оставлен как СПРАВОЧНИК по модели персонажа** (`char_type`, +> категории `actions_*`, устройство `play_seq`, объём спрайтов) — он нужен +> при портировании остальных акторов (скелет, тень, визирь). Текущие +> задачи — `../roomtest/TASKS.md`. + +Составлен 2026-07-16. Опирается на разбор `SDLPoP/src/seg006.c` (ядро физики/управления Kid), `seqtbl.c` (таблицы последовательностей), `types.h` (char_type, seq_*, SEQ_*, actions_*), `SDLPoP/data/KID` (спрайты). Фон уже готов и проверен на MAME (`applications/PoP/roomtest`, см. diff --git a/applications/PoP/docs/PORT_PLAN.md b/applications/PoP/docs/PORT_PLAN.md index 6249770..1bbfda7 100644 --- a/applications/PoP/docs/PORT_PLAN.md +++ b/applications/PoP/docs/PORT_PLAN.md @@ -1,8 +1,29 @@ # Prince of Persia на ZX Sprinter — план порта -Статус: план (2026-07-15). §2 (A: kbd_mod_state / B: kbd_raw) — -РЕАЛИЗОВАНО и частично проверено в MAME (tests/kbdraw, 2026-07-15, -подробности в §2.2); PoC (§5) и остальные фазы — не начаты. Опирается на +## СТАТУС (обновлено 2026-08-01) + +Документ составлен 2026-07-15 как план «с нуля» и с тех пор во многом +исполнен. Читать его надо так: + +| Раздел | Что с ним сейчас | +|--------|------------------| +| §1 возможности библиотек | актуально как обзор, но **спрайтовый движок `sprite.h` для персонажей НЕ используется**: Kid/страж рисуются прямыми блитами атласов (`gfx_blit_cols_part*`) с ручным heal — так требует модель оригинала (§6) | +| §2 held-state клавиатуры | **сделано** (`kbd_mod_state`, ``). Открытая проблема — потеря байт при аккордах Shift+стрелка; диагноз и план в `../roomtest/TASKS.md` (KBD-1) | +| §3 форматы данных | актуально; уровень читается живьём (`roomtest/pop_level.c`) | +| §4 стратегия фона | **сделано** — тайловый рендерер в рантайме (`roomtest/pop_bg.c`) | +| §5 PoC | **закрыт и превзойдён.** `poc/` (плейсхолдер-персонаж) — история; активная разработка ушла в `roomtest/` с настоящей графикой | +| §6 модель движения | **сделано**: `play_seq` + `frame_table` оригинала, не физика с нуля | +| §7 фазы | см. отметки статуса прямо в разделе | +| §8 риски | п.1 закрыт, п.3 закрыт (28 страниц-атласов Кида), п.2/п.4 — см. отметки в разделе | +| §10 режим памяти | **сделано и переросло план**: `huge` + четыре банка кода; актуальная раскладка — `layout_plan_v2.md` | + +**Где смотреть текущее состояние, а не план:** `../roomtest/README.md` +(что играется), `../roomtest/TASKS.md` (что в работе), `levels_plan.md` +(следующие уровни), `layout_plan_v2.md` (раскладка кода по окнам и банкам). + +--- + +Опирается на `APPLEII_RESOURCE_FORMAT.md` / `MSDOS_RESOURCE_FORMAT.md` / `README.md` в этой папке, на текущий sprinter-cc/libc/libbgi (см. §1) и на локальные копии `applications/PoP/SDLPoP` (github.com/NagyD/SDLPoP, GPLv3) и @@ -227,6 +248,13 @@ dirty-биты, heal против фона через ОЗУ-копию); `room_ ## 5. Proof-of-Concept — цель: доказать, что порт вообще ощущается как PoP +> **Закрыт (исторический раздел).** PoC в `poc/` свою задачу выполнил и +> дальше не развивается: управление ощущается как PoP, held-state работает. +> Всё, что ниже про плейсхолдер-персонажа и приблизительную дугу прыжка, +> — уже неправда для активной ветки: в `roomtest/` стоит настоящая графика +> Кида и авторские таблицы кадров (§6). Раздел оставлен ради истории +> решений (в частности §5.1 — почему сначала был плейсхолдер). + **Объём**: одна комната (например Level 1, экран старта Кида), без переходов между экранами, без стражников (стретч-цель, не обязательна). @@ -364,45 +392,53 @@ memory/png_strip_padding_tradeoff. ## 7. Полноценное приложение — фазы (после PoC) Порядок — по риску и зависимостям, не по геймплейной важности. +**Отметки статуса — на 2026-08-01.** -**Фаза 0 — инфраструктура порта** (расширяет PoC, не переписывает): -- Хелд-стейт клавиатуры — финальное решение и реализация по §2 (после - подтверждения пользователем). -- Полный конвертер уровней (все 15 файлов `levels.dat`/`LEVELn`) → бинарный - формат приложения (можно 1-в-1 raw dump, читать по офсетам в рантайме — - не обязательно разворачивать в C-struct с указателями). -- Полный конвертер фона (24 экрана × N уровней) в растры + конвертер - спрайт-лент Кид/стражник/скелет/тень/Джаффар в атласы `.atl` (расширение - `conv_sprites.py`/формата `.atl`, если частот кадров/атласов на актора не - хватит текущего лимита — см. риск в §8). +**Фаза 0 — инфраструктура порта** — **СДЕЛАНА**, но иначе, чем задумано: +- Хелд-стейт клавиатуры по §2 — сделан. +- Конвертер уровней не понадобился: `res200N.bin` из `SDLPoP/data/LEVELS` + кладётся на образ как есть и читается по офсетам в рантайме + (`roomtest/pop_level.c`), уровень живёт в EMM-странице. +- Конвертер фона в растры **отменён осознанно** (§4): фон собирается + тайлами в рантайме. Спрайты — `toolchain/pop_pack_bg.py` / + `pop_pack_kid.py` / `pop_pack_guard.py` → атласы `.atl` (Kid — 28 + страниц, риск §8 п.3 закрыт). -**Фаза 1 — Кид, полный набор действий**: стоять/идти/бежать/тормозить/ -разворот/прыжок (на месте, вперёд, «прыжок с разбега»)/повисание на -краю/подтягивание/спуск по свисанию/приседание/питьё зелья/смерть от -провала. Переходы между экранами (`MAP`-граф, `INFO.KidStartScrn`). +**Фаза 1 — Кид, полный набор действий** — **СДЕЛАНА**: стоять/идти/бежать/ +тормозить/разворот/прыжки/повисание/подтягивание/спуск/приседание/ +осторожный шаг/питьё зелья/смерть от провала и от пик; переходы между +комнатами во все четыре стороны. Осталось: **старт по данным уровня** +(`pop_level_start_*` реализованы, но не подключены) — задача L1-START в +`../roomtest/TASKS.md`. -**Фаза 2 — мир и ловушки**: нажимные плиты/двери через граф -`LINKLOC`/`LINKMAP` (см. `APPLEII_RESOURCE_FORMAT.md` §1.2), шипы -(выдвижение/втягивание/заклинивание), шаткие плиты (loose, обрушение), -зелья (эффект по `BLUESPEC×32`), стартовые позиции по `INFO`. +**Фаза 2 — мир и ловушки** — **СДЕЛАНА**: кнопки/ворота через +`LINKLOC`/`LINKMAP`, шипы, loose-полы (тряска, обрушение, щебень, пробой +потолка), зелья, дверь уровня (открывается), факелы. Подробности и +справочник — `gates_spikes_plan.md`. -**Фаза 3 — бой**: подбор/выхватывание меча, состояние стойки, парирование/ -удар, коллизия клинков — по логике `AUTO.S`/`seg003-006.c` (референс, не -копия). Стражник: базовое AI-поведение по `GdStartProg` (несколько -шаблонов программ), Y-сортировка слоями уже есть в движке для «кто -спереди/сзади». +**Фаза 3 — бой** — **СДЕЛАНА в объёме обычного стражника**: подбор и +выхватывание меча, стойка, удар/парирование, коллизия клинков, HP обеих +сторон, смерть; ИИ стража (замечает Кида, подходит, боевые ветки), +персистентность трупа между комнатами. -**Фаза 4 — разнообразие противников**: скелет, тень (копия анимации Кида — -подтверждено побайтовым совпадением данных, см. `MSDOS_RESOURCE_FORMAT.md` -§3), толстый стражник/визирь (общая база анимации с визирем). +**Фаза 4 — разнообразие противников** — **НЕ НАЧАТА**. Скелет нужен на +уровне 3, толстый — на 6, тень — на 12, визирь — на 13; привязка +«уровень → тип стража» (`tbl_guard_type`) описана в `levels_plan.md` §1. -**Фаза 5 — звук**: CBL-эффекты (шаги, удары, двери, падение) из -`digisnd*.dat`→PCM; PC-спикер тройки (`ibm_snd*.dat`) как опциональный -дешёвый бипер без CBL, если формат подтвердится простым парсингом. +**Фаза 5 — звук** — **НЕ НАЧАТА**: CBL-эффекты (шаги, удары, двери, +падение) из `digisnd*.dat`→PCM; PC-спикер тройки (`ibm_snd*.dat`) как +опциональный дешёвый бипер без CBL, если формат подтвердится простым +парсингом. Опкод `SOUND` в `play_seq` пока просто съедает свой аргумент — +точки вызова уже на месте. -**Фаза 6 — оболочка**: титры, меню/выбор уровня, HUD (таймер/жизни), -сохранение прогресса (FILE*), финальные катсцены — по минимуму, -геймплейно не критично. +**Фаза 6 — оболочка** — **НЕ НАЧАТА**: титры, меню/выбор уровня, HUD +(таймер/жизни), сохранение прогресса (FILE*), финальные катсцены — по +минимуму, геймплейно не критично. Полоса HP — единственное, что уже есть. + +**Между Фазами 4 и 5 вклинивается то, чего в этом плане не было: +переход между УРОВНЯМИ** (загрузка следующего уровня, второй тайлсет +palace, потабличные различия уровней). Отдельный документ — +`levels_plan.md`. **Фаза 7 — стабилизация**: полный прогон всех 14 уровней в MAME (`mame_interactive.py`), затем на реальном железе; профилирование бюджета @@ -418,23 +454,33 @@ tiny/small. (по правилу `defer_unexplained_quirks` — не гадать, проверять) -1. **Held-state клавиатуры** (§2) — блокирует даже PoC, если решать - «правильно»; иначе PoC на компромиссном варианте 2 (таймаут-эвристика). -2. **Бюджет спрайтов на насыщенный экран** — сцена с 2+ стражниками + - несколько анимированных ловушек может приблизиться к лимиту - ~21 спрайт/кадр (`sprite_engine_perf`) — нужна прикидка по реальным - уровням (сколько объектов одновременно активно в худшем экране). -3. **Ёмкость одного атласа/страницы EMM на актора** — у Кида ~220 кадров - (все действия) против 4×12 у `rpgwalk` — потребуется либо несколько - атласов на актора с переключением по фазе действия (стоять/идти отдельно - от боя), либо расширение формата `.atl`/загрузчика на мульти-страничные - атласы — оценить фактический байтовый вес конвертированных кадров Кида - прежде чем проектировать. -4. **Тайминг оригинала** — сверить логическую частоту кадров анимации - оригинала (Apple II ~60 Гц NTSC / DOS — фиксированный таймер) с 50 Гц - Sprinter; если оригинал считался на другой частоте — потребуется - коэффициент пересчёта смещений кадров (§6), иначе прыжки/бег будут - визуально быстрее/медленнее эталона. +1. ~~**Held-state клавиатуры** (§2)~~ — **закрыт** (``). Открытый + остаток — не «есть ли held-state», а потеря байт при аккордах + Shift+стрелка: `../roomtest/TASKS.md`, KBD-1. +2. **Бюджет кадра** — риск подтвердился, но не в том виде, в каком ожидался: + спрайтовый движок для персонажей не используется, поэтому лимит + «~21 спрайт/кадр» неприменим. Реальный бюджет упирается в heal+блиты и + перерисовку тайлов; замер 2026-07-30 — типичный кадр ~371 К тактов + (~86 % периода). Инструмент замера уже в коде: полосы бордюра `PROF()` + в `roomtest.c`. План выжимания — `../roomtest/TASKS.md` (CLIP-1) и + `../roomtest/bug_list.md` (T-1/T-2). +3. ~~**Ёмкость атласа на актора**~~ — **закрыт**: Kid разложен на 28 + атласов-страниц по 8 спрайтов (`pop_pack_kid.py`), страж — на 5; + мульти-страничного формата `.atl` не потребовалось. Побочно + подтвердился компромисс паддинга (§6.1). +4. **Тайминг оригинала** — **ОТКРЫТ, и сверка 2026-08-01 показывает + расхождение.** Цифры оригинала (SDLPoP): базовый таймер `BASE_FPS = 60` + (`types.h:1373`), логический кадр игры — `base_speed = 5` тиков + (`data.h:869`), то есть **83.3 мс (12 лог. кадров/с)**; в бою + `fight_speed = 6` → **100 мс (10/с)**. У нас (`roomtest.c`) — три + ожидания `gfx_wait_vsync()` на итерацию, то есть **60 мс (16.7/с)** и + без отдельной скорости боя. Значит **игра идёт примерно на 39 % + быстрее эталона**. Точное соответствие даёт 4 ожидания vsync (80 мс + против 83.3) и 5 в бою (100 мс — совпадает точно). + Проверять не «на глаз», а секундомером по одинаковому отрезку + (SDLPoP рядом на том же экране), и только после того, как кадр + перестанет иногда вылезать за период (см. п.2) — иначе замедление + спрячет проблему бюджета вместо того, чтобы её показать. --- diff --git a/applications/PoP/docs/README.md b/applications/PoP/docs/README.md index f5d8381..8376ba5 100644 --- a/applications/PoP/docs/README.md +++ b/applications/PoP/docs/README.md @@ -1,4 +1,38 @@ -# Форматы ресурсов Prince of Persia — сводка +# `applications/PoP/docs` — индекс + сводка по форматам ресурсов + +## Индекс документов (актуальность на 2026-08-01) + +**Живые планы — читать перед работой:** + +| Документ | О чём | +|----------|-------| +| [`../roomtest/TASKS.md`](../roomtest/TASKS.md) | **Что берётся в работу сейчас** (не в этой папке, но входная точка) | +| [`levels_plan.md`](levels_plan.md) | Следующий этап: уровни 2+, второй тайлсет, читы SDLPoP | +| [`layout_plan_v2.md`](layout_plan_v2.md) | Раскладка кода по окнам/банкам/страницам + замеры скорости отрисовки | +| [`room_model_plan.md`](room_model_plan.md) | `kid_room ≠ drawn_room` (straddle): сделан S1, остальное впереди | +| [`ideas_backlog.md`](ideas_backlog.md) | Осознанно отложенные гипотезы (мышь, PRNG) | +| [`prng_alternatives.md`](prng_alternatives.md) | Запасные генераторы, если упрёмся в бюджет кадра | + +**Исполненные планы, оставленные как справочники:** + +| Документ | Чем ещё полезен | +|----------|-----------------| +| [`PORT_PLAN.md`](PORT_PLAN.md) | Общая карта фаз со статусами; §6 (модель движения), §10 (режим памяти) | +| [`KID_PLAN.md`](KID_PLAN.md) | Модель персонажа: `char_type`, `actions_*`, устройство `play_seq` — нужна для скелета/тени/визиря | +| [`gates_spikes_plan.md`](gates_spikes_plan.md) | Раскладка объектов уровня 1 по комнатам, декод `LINKLOC`/`LINKMAP`, точные ссылки на seg-код | + +**Форматы ресурсов** (ниже по этому файлу): `POP-DAT-FormatSpecifications.pdf` +/ `.txt` (первоисточник), `APPLEII_RESOURCE_FORMAT.md`, +`MSDOS_RESOURCE_FORMAT.md`. + +Удалены 2026-08-01 как полностью исполненные и перекрытые кодом: +`clip_char_plan.md`, `double_buffer_plan.md`, `loose_floors_plan.md`, +`size_optimization_plan.md` (его §8 про скорость отрисовки перенесён в +`layout_plan_v2.md` §9). Ищутся в истории git, если понадобятся. + +--- + +## Форматы ресурсов — сводка **Каноническая спецификация форматов** — `POP-DAT-FormatSpecifications.pdf` (+ текстовая конверсия `POP-DAT-FormatSpecifications.txt` для grep/цитирования): @@ -70,7 +104,11 @@ DOS-упаковщика). Это значит: раскладку `BLUETYPE`/`B дворца/подземелий) практичнее взять их оттуда напрямую, чем писать свой декодер сжатия пикселей DOS `.DAT`. -## Что дальше (не сделано в этом заходе) +## Что дальше по форматам (не сделано и пока не нужно) + +Порт читает уровень напрямую из `res200N.bin` (`roomtest/pop_level.c`), а +графику берёт из распакованных PNG `SDLPoP/data/` — поэтому ни один пункт +ниже сейчас не блокирует работу. 1. Точный кодек сжатия пикселей спрайтов в сыром DOS `.DAT` (нужен только если понадобится читать именно нашу локальную копию `MSDOS/*.dat` diff --git a/applications/PoP/docs/clip_char_plan.md b/applications/PoP/docs/clip_char_plan.md deleted file mode 100644 index 61e2682..0000000 --- a/applications/PoP/docs/clip_char_plan.md +++ /dev/null @@ -1,112 +0,0 @@ -# План: порт `clip_char()` — обрезка спрайта персонажа - -Статус: в работе с 2026-07-28. Контекст: баг «спуск Кида с кнопки в комнате 8» -(Kid просвечивает в щель между кнопкой и ближним столбом, мусор на кромке). - -## Симптом - -room 8, Kid спускается (climbdown) с тайла-кнопки у ближней колонны. Спрайт -Кида нарисован ЦЕЛИКОМ, включая часть, которая в оригинале обрезана по линии -пола: видно «просвет» Кида в щели между кнопкой и колонной и мусор на кромке. - -## Корень - -Не портирован `clip_char()` (SDLPoP `seg006.c:1749`, вызывается из -`add_kid_to_objtable`/`add_guard_to_objtable`, `seg008.c:1671/1690`, ПОСЛЕ -`set_char_collision`/`set_objtile_at_char`/`redraw_at_char*` и ПЕРЕД -`add_objtable`). Оригинал кладёт в objtable не только позицию спрайта, но и -прямоугольник клипа `obj_clip_{top,bottom,left,right}`; блиттер рисует только -внутри него. Мы рисуем без клипа вообще. - -## Что делает оригинал (дословно) - -`reset_obj_clip()` → `left=0, top=0, right=320, bottom=192`. - -Дальше (упрощая C-трюк с глобалью `curr_tile2`, которую ставит каждый -`get_tile`: `X == wall || tile_is_floor(curr_tile2)` — это «тайл X = стена -ИЛИ пол»): - -``` -T_L = get_tile(room, char_col_left, char_top_row) -T_R = get_tile(room, char_col_right, char_top_row) -if (T_L — стена или пол) && - ( (action == stand && (frame == 79 || frame == 81)) /* прыжок вверх / зацеп */ - || (T_R — стена или пол) ) -{ - clip_row = Char.curr_row + 1; - clip_y = y_clip[clip_row]; /* y_clip[] = {-60, 3, 66, 129, 192} */ - if (clip_row == 1 || (clip_y < obj_y && clip_y - 15 < char_top_y)) - obj_clip_top = char_top_y = clip_y; -} -``` - -Смысл: `y_clip[row+1]` — верхняя граница СВОЕЙ полосы ряда. Если над головой -пол/стена — всё, что выше этой линии, не рисуется (персонаж «уходит под пол»). -Для ряда 0 (`clip_row == 1`) клип применяется БЕЗУСЛОВНО. - -Метрики из `set_char_collision` (seg006:0723): -- `char_x_left = obj_x/2 + 58` (и `-= char_width_half`, если смотрит вправо) -- `char_x_right = char_x_left + char_width_half`, `char_width_half = (w+1)/2` -- `char_top_y = obj_y - h + 1`; если `>= 192` → `0` -- `char_top_row = y_to_row_mod4(char_top_y)` -- `char_col_left = MAX(get_tile_div_mod(char_x_left), 0)`, - `char_col_right = MIN(get_tile_div_mod(char_x_right), 9)` - -Второй блок `clip_char` — `obj_clip_right` (doortop / стена / зеркало для -виса-полёта-подъёма при взгляде влево, кадры 137..139) и ветка кадров 224..228 -(выход в дверь уровня). **Фаза 2**, не в этом заходе. - -## Наши точки касания - -- `roomtest/pop_kid.c` `kid_draw()` — сам считает `obj_x/obj_y/w/h` и блитит - через `gfx_blit_cols(bx, top + POP_YOFF, img, flip)`; запоминает - прямоугольник в `kid_l{x,y,w,h}[page]` для `kid_heal()`. -- `roomtest/pop_map.c` — там `get_tile()`, `tile_is_floor()`, `y_to_row()`, - `get_tile_div_mod_m7()`, Kid-структура. Аналогичный расчёт габарита уже - есть в `check_spike_below()` (строка ~1198) — брать за образец. -- `libbgi/common/gfx_blit_cols.c` — клип по экрану есть (при `y < 0` - пропускает `sy` верхних строк колонки), но произвольной верхней границы нет. - -## Шаги - -1. **libbgi**: `gfx_blit_cols_part(int x, int y, const void *img, uint8_t flip, - int sy, int h)` — «пропустить `sy` верхних строк спрайта, нарисовать `h`». - Реализация = тело `gfx_blit_cols` с предустановленными `sy`/`h` (источник - `+= sy`, экранный `y += sy`). Прототип в `libbgi/include/gfx.h`. - Стоимость: 0 в общем пути (обычный `gfx_blit_cols` вызывает то же ядро). -2. **pop_map.c**: `int pop_clip_char_top(int obj_x, int obj_y, uint16_t w, - uint16_t h)` — порт первого блока `clip_char`; возвращает КОМНАТНЫЙ y - (0 = клипа нет). Экспорт в `pop_map.h`. -3. **pop_kid.c**: в `kid_draw()` после расчёта `top` — - `ct = pop_clip_char_top(...)`; если `ct > top` → `sy = ct - top`, - рисовать `gfx_blit_cols_part(bx, ct + POP_YOFF, img, flip, sy, h - sy)`; - в `kid_l*[page]` класть ОБРЕЗАННЫЙ прямоугольник (иначе heal чистит лишнее - и стирает кромку пола). То же для `kid_draw_splash` — там оригинал делает - `reset_obj_clip()`, т.е. splash НЕ клипится (ничего не менять). -4. **Сборка**: `make -C libbgi`, `make -C applications/PoP/roomtest`, - `make size-check`; вывести свободное место в W2/W3 (порог 512 Б). -5. **Проверка в MAME** (`mame_hdd_test_disk`, полный рестарт после - пересборки образа): комната 6 → переход в 8 → влезть на кнопку → спуск. - Сверять с эталоном: живой SDLPoP той же позой (см. `pop_check_sdlpop_first`). - -## Риски / что проверить отдельно - -- `char_top_row` считается `y_to_row_mod4` — у нас `y_to_row()` даёт −1 для - полосы у потолка; `get_tile(row −1)` уже умеет ряд 2 верхнего соседа. -- Kid ниже комнаты (`char_top_y >= 192` → `0`) — обязателен ресет, иначе клип - прыгнет. -- `clip_row == 1` (ряд 0) — клип БЕЗУСЛОВНЫЙ: проверить, что не режет Кида в - обычной стойке на ряду 0 (условие внешнего `if` про пол/стену над головой - должно отсекать). -- Клип меняет прямоугольник heal → возможен «хвост» на второй странице - дабл-буфера: проверять оба кадра (SPACE — выключить дабл-буфер). - -## Дальше (Фаза 2, отдельно) - -`obj_clip_right` (doortop/стена/зеркало) — нужен для виса и подъёма при -взгляде влево; и `obj_clip_left` для зеркала (уровень 4). Требует клипа по -колонкам в `gfx_blit_cols` (обрезка справа = уменьшить `w`) — дёшево, но -без тестовой сцены проверять нечем. - -См. memory: `pop_clip_char_todo`, `pop_backtable_vs_midtable`, -`pop_check_sdlpop_first`, `gfx_blit_noclip_fast`. diff --git a/applications/PoP/docs/double_buffer_plan.md b/applications/PoP/docs/double_buffer_plan.md deleted file mode 100644 index 33894c8..0000000 --- a/applications/PoP/docs/double_buffer_plan.md +++ /dev/null @@ -1,69 +0,0 @@ -# Double buffer (два экрана + флип) — план - -Цель: убрать мерцание/тиринг при перерисовке слоёв (Kid ↔ fore/пол-оверлей) -и гарантировать, что на экране ВСЕГДА готовый кадр с правильным порядком -слоёв. Нужно для отладки fore-слоя (видно момент композиции, а не -промежуточные состояния heal-рендера). **Тумблер обязателен** — -однобуферный режим удобнее для отладки багов рисования. - -## Что уже готово (libbgi — трогать НЕ нужно) - -- Две графических страницы 0/1: `gfx_set_draw_page(p)` (двигает - `_gfx_addr_base` 0xC000/0xC140 — рисуют все примитивы), - `gfx_set_visible_page(p)` (ESTEX $54 SELPAGE — display-учёт DSS). -- `gfx_wait_vsync()` — луч (bit5 порта 0xFE), момент без разрывов. -- Паттерн из gfx.h: `set_draw_page(hidden); draw(); wait_vsync(); - set_visible_page(hidden);` -- Замечание gfx.h: у каждой страницы СВОЯ плоскость палитры - (page0→pal0, page1→pal1) — для seamless грузить одну палитру в ОБЕ. - -## Текущая модель рендера roomtest (однобуфер) - -`roomtest.c`: `draw_page=0`, `visible_page=0` фиксированы. Фон комнаты -рисуется ОДИН раз в видео-ОЗУ + теневую копию (GFX_BANK_TRANSPARENT). -Цикл: 3× `gfx_wait_vsync` (пейсинг) → `pop_ctrl_tick` → `kid_heal()` -(восстановить прямоугольник Кида из тени) → `kid_tick`/`pop_phys_tick` -→ `kid_draw` → `pop_fore_over_kid`. Мерцание = heal+draw+fore длиннее -бланка, луч ловит промежуток. - -## Работа на стороне PoP - -1. **Инициализация обеих страниц**: `pop_room_draw` в page 0 И page 1 - (теневая копия одна — общая, из неё heal'ит любая страница). -2. **Палитра в обе плоскости**: сейчас `gfx_pal_fload(0,...)` + sync. - Продублировать в plane 1 (проверить сигнатуру gfx_pal_fload/sync — - plane-параметр). -3. **Per-page heal-история** (ядро): вынести `kid_lx/ly/lw/lh` в - массивы `[2]`, индекс = страница, в которую рисуем. `kid_heal(page)` - восстанавливает СВОЙ прошлый прямоугольник (кадр -2, т.к. рисуем - через страницу). То же для fore/пол-оверлея, если они рисуют вне - футпринта Кида. -4. **Ping-pong в цикле**: - ``` - uint8_t back = dbuf ? (front ^ 1) : 0; - gfx_set_draw_page(back); - kid_heal(back); kid_tick; phys; kid_draw; fore; - gfx_wait_vsync(); - if (dbuf) { gfx_set_visible_page(back); front = back; } - ``` -5. **Тумблер** `dbuf`: off → draw==visible==0, без флипа, heal[0] — - бит-в-бит текущее поведение. Управление — клавишей (напр. F2) или - compile-флагом. -6. **Пейсинг**: сейчас 3× vsync/лог.кадр. При флипе — один vsync перед - свопом; недостающий пейсинг добрать `gfx_set_fps_div(3)` или ручным - счётом кадров, чтобы скорость игры не изменилась. - -## Порядок - -- D1: обе страницы + палитра в обе плоскости; ping-pong без per-page - heal (проверить, что флип работает, фон корректен на обеих). -- D2: per-page heal-история (kid_l*[2]) — убрать «хвост» Кида. -- D3: тумблер dbuf + сверка однобуферного пути с текущим (регресс-нет). -- D4: пейсинг (fps_div) — вернуть исходную скорость. - -## Связанные - -Рендер-модель — [[pop_fore_layer]], [[pop_background_strategy]]; -heal — kid_heal/gfx_heal. Будущие динамические слои (loose-floors -[[двойной буфер требует их per-page перерисовки]]) должны рисоваться в -обе страницы по той же дисциплине. diff --git a/applications/PoP/docs/gates_spikes_plan.md b/applications/PoP/docs/gates_spikes_plan.md index a255b70..fc0377f 100644 --- a/applications/PoP/docs/gates_spikes_plan.md +++ b/applications/PoP/docs/gates_spikes_plan.md @@ -1,8 +1,23 @@ # Интерактивные объекты (кнопки/гейты/пики) + HP/смерть — ПОДРОБНЫЙ план -Статус: **план** (2026-07-20). Реализация — отдельной сессией. Документ -самодостаточный: рассчитан на старт «с чистого листа» (пустой контекст). -Всё сверено с `applications/PoP/SDLPoP/src/` и данными `res2001.bin`. +> **Статус: РЕАЛИЗОВАНО (2026-08-01).** Все фазы плана (P0 персистентный +> per-room `room_modif`, S пики, B кнопки+ворота) сделаны и играются: +> `roomtest/pop_trob.c` (trob-диспетчер, `LINKLOC`/`LINKMAP`, ворота, дверь +> уровня, факелы, зелья), `pop_map.c` (HP, смерть на пиках, урон падения), +> `pop_redraw.c` (пометки перерисовки вместо прямых блитов). Ограничения +> из §0 закрыты: тайлы персистентны, HP/смерть есть, loose обобщён в trob; +> L3-вверх (climb-up в комнату сверху) тоже сделан (`pop_leave_dir = 3`). +> Из §5 остаётся открытым только **переход на следующий уровень через дверь +> уровня** — он вынесен в `levels_plan.md`. +> +> **Документ оставлен как СПРАВОЧНИК**, а не как план: §1 (раскладка +> объектов уровня 1 по комнатам, декод связей кнопка→цель) и §2 (точные +> ссылки на механику SDLPoP) продолжают экономить время при отладке. +> Текущие задачи — `../roomtest/TASKS.md`. + +Составлен 2026-07-20. Документ самодостаточный: рассчитан на старт +«с чистого листа» (пустой контекст). Всё сверено с +`applications/PoP/SDLPoP/src/` и данными `res2001.bin`. Правило проекта (см. `applications/PoP/CLAUDE.md`): **SDLPoP — источник истины**, перед кодингом читать соответствующий код seg*.c, не гадать. diff --git a/applications/PoP/docs/ideas_backlog.md b/applications/PoP/docs/ideas_backlog.md index 133ab83..87a0878 100644 --- a/applications/PoP/docs/ideas_backlog.md +++ b/applications/PoP/docs/ideas_backlog.md @@ -4,6 +4,54 @@ гипотеза с причиной, по которой её стоит проверить, и с тем, что мешает сделать это прямо сейчас. +## Зелье «переворот экрана» (upside-down) + +**Вопрос пользователя (2026-08-01).** Тайлы фона у нас лежат строками, а +кадры Кида/стражей — КОЛОНКАМИ (`transpose_cols` в `pop_pack_kid.py`, ради +бесплатного горизонтального зеркала). Значит вертикальный переворот для +персонажей заметно сложнее, чем для фона. Верно; но прежде чем это чинить, +надо знать три факта. + +**Факт 1 — когда оно вообще нужно.** Зелье переворота — тип 4 +(`proc_get_object`, `seg006.c:1885` → `toggle_upside()`). Скан всех уровней +по данным (`res200N.bin`, тайл 10 = зелье, тип в backtable): тип 4 +встречается **впервые на уровне 9** (две склянки), и больше нигде. Тип 3 +(перо, медленное падение) — уровень 7. То есть **до уровня 9 механика не +нужна вообще**, и «на первом этапе просто не реализовывать» — не компромисс, +а точное соответствие данным уровней 1..8. + +**Факт 2 — что именно делает оригинал.** НЕ переворачивает спрайты. +`flip_screen` (`seg009.c:1042`) → `flip_not_ega` (`seg009.c:1023`) меняет +местами СТРОКИ готового offscreen-буфера (top↔bottom, порядок пикселей +внутри строки не трогает — это вертикальное зеркало, не поворот на 180°). +Вызывается вокруг отрисовки кадра целиком (`seg003.c:296..301`): перевернул +буфер → дорисовал → перевернул обратно. Так что в оригинале это +post-process всего экрана, и вопрос «как перевернуть колоночный спрайт» +там просто не возникает. + +**Факт 3 — почему нам этот приём не подходит как есть.** У нас нет шага +«готовый offscreen → экран»: рисуем прямо в видеостраницу, а heal берёт фон +из ОЗУ-копии этой же страницы. Переворот всей страницы построчно — это +320×192 Б копирования КАЖДЫЙ кадр, что мимо бюджета на порядок. + +**Варианты, которые надо будет взвесить (не сейчас):** +1. **Предпечённые перевёрнутые атласы.** Второй набор кадров + Кида/стража, перевёрнутый по вертикали ещё в `pop_pack_kid.py` (там уже + есть транспонирование — добавляется одной строкой). Рантайм: выбор + набора + зеркальная арифметика Y. Память: ещё ~28 страниц EMM при + бюджете ~3.3 МБ — не проблема. Похоже, самый дешёвый по тактам путь. +2. **Фон рисовать с обратным Y** — для row-major тайлов строка остаётся + непрерывным accel-прогоном, меняется только адрес назначения; цена — + вызов на строку вместо вызова на тайл. Померить, прежде чем закладывать. +3. **Аппаратная помощь** — до проектирования проверить, есть ли у + акселератора направление копирования «вниз» (обратный инкремент адреса); + если есть, вариант 1 может и не понадобиться. Смотреть + `docs/new/06-accel.md` и `docs/reference/accel_r.txt`. + +**Почему не сейчас.** Уровни 1..8 этого не требуют, а к уровню 9 у нас уже +будет ответ на вопрос «сколько стоит кадр» (задачи CLIP-1/T-2) — без него +выбирать между вариантами выше бессмысленно. + ## Заменить генератор псевдослучайных чисел Сейчас стоит LCG оригинала, шаг на ассемблере (~1 020 тактов), бит-в-бит diff --git a/applications/PoP/docs/layout_plan_v2.md b/applications/PoP/docs/layout_plan_v2.md index 2fb5e2b..188bd48 100644 --- a/applications/PoP/docs/layout_plan_v2.md +++ b/applications/PoP/docs/layout_plan_v2.md @@ -1,8 +1,26 @@ # roomtest — план v2: размер кода и раскладка по окнам/банкам/страницам +> **Замер 2026-08-01 (актуальная сборка).** `_CODE` 25 119 Б, `_DATA` 3 709, +> куча ~2.4 КБ. Банки: 1 (`guards.c`) 1 896, 2 (`pop_bg.c`) 13 792, +> 3 (`pop_map.c`) 6 331, 4 (`pop_gdraw.c`) 2 236 — все из 16 384. +> **Резидента `--w3` больше нет**: отрисовка уехала в банк 2, и это сняло +> главное ограничение резидента (из банка его было не достать) — банк→банк +> работает, трамплин сохраняет страницу окна на стеке. Отрисовка стража +> вынесена из банка 2 в собственный банк 4, потому что банк 2 подошёл к +> потолку (16 021 из 16 384) — коммит `2f3e854`. +> +> **Свободного места в банке 2 осталось ~2.6 КБ**, а туда же просятся +> чомперы, зеркало и второй тайлсет (palace). Прежде чем начинать +> `levels_plan.md` §3 — посчитать, куда это ляжет. Свободные номера банков +> есть, гранулярность — файл. +> +> Из плана ниже **не сделаны шаги 5 (данные: `room_modif`, `dl1/dl2`, +> `_kbdraw_down`; потенциал ~1.5 КБ) и 7 (дедуп `draw_tile`, отложен по +> решению пользователя)**. + Статус: **план для отдельной сессии**, составлен 2026-07-29 по свежему замеру. -Заменяет `size_optimization_plan.md` (v1, 2026-07-21): часть его пунктов уже -сделана, часть опиралась на неверную модель банкинга. Документ самодостаточный +Заменял `size_optimization_plan.md` (v1, 2026-07-21) — тот удалён 2026-08-01 +как полностью перекрытый этим документом. Документ самодостаточный — рассчитан на старт с пустого контекста. Повод: перед стражами и боёвкой (новый код ~5–8 КБ) надо понять, куда он @@ -442,5 +460,53 @@ dir_behind`, `y_to_row`, `char_dx_forward`, `get_tile_div_mod(_m7)`, `bank_local_data_pattern`, `sprinter_memory_modes`, `memory_modes_implemented`, `sprinter_emm_budget`, `mame_mcp_bridge`, `avoid_32bit_arith_z80`, `libc_one_function_per_module`. -- `applications/PoP/docs/size_optimization_plan.md` — v1 (замер 2026-07-21, - раздел §8 про скорость отрисовки актуален и не дублируется здесь). +- `applications/PoP/roomtest/TASKS.md` — что из этого берётся в работу сейчас. + +--- + +## 9. Скорость отрисовки: замеры и запас + +Перенесено из удалённого `size_optimization_plan.md` §8 (замер 2026-07-27) — +единственная его часть, которая не была перекрыта этим документом. +Профилирование в MAME: маркеры в порт 0xFE + `wpiset … totalcycles` (приём из +memory `mame_mcp_bridge`); в самом `roomtest.c` для этого уже стоят полосы +бордюра `PROF()`. Кадр Sprinter = **430 080 тактов**. + +**Стоимость блита почти НЕ зависит от размера** — платим за проход по цепочке +`gfx_blit → gfx_blit_part → _gfx_blit_full` (16-битная арифметика, клип, +пересчёт src, нарезка полос >256), а не за пиксели: + +| путь (спрайт 32×3) | тактов | +|---|---| +| `gfx_blit` (общее ядро, с клипом) | 13 288 | +| линейное ядро без клипа | 4 617 | + +Отсюда `draw_tile(0,0)` тайла шва (9 блитов) стоил **183 690 тактов = 43 % +кадра**; сам `bar` — только 13 308. + +**Сделано:** `gfx_blit_noclip()` в libbgi, фоновые блиты `pop_bg` уходят на +него, когда спрайт целиком на экране (~2.9×, подтверждено в MAME). Позже +тем же приёмом закрыты спрайты персонажей (`gfx_blit_cols_part_noclip`). +**Не закрыт heal** — задача CLIP-1 в `../roomtest/TASKS.md`. + +**ВАЖНО:** W3-скобку (`_bgi_begin`/`_bgi_end`) ставит САМА libbgi — вызывать +её из модуля, собранного с `--w3`, нельзя: после `_bgi_begin` окно W3 занято +видеобанком и код вызывающего исчезает из адресного пространства (проверено: +белый экран). + +**Запас, когда перестанет хватать бюджета кадра:** + +1. **Батчинг W3-скобки** — одна `_bgi_begin`/`_bgi_end` на весь `draw_tile` + вместо скобки на блит; нужен публичный batch-API в libbgi. + **Осторожно, и это стало важнее, чем было:** между begin/end стоит `DI`, + длинная серия задержит кадровое прерывание — а по разбору KBD-1 + (`../roomtest/TASKS.md`) длинные DI-окна и есть причина потери байт + клавиатуры. Батчинг эту проблему УХУДШИТ, если делать его вслепую. +2. **Решётка ворот одним спрайтом** — `draw_gate_back` рисует бары по одному + (до 7 блитов). Сгенерировать в атласе «столб решётки» и выводить одним + `gfx_blit_part` с обрезкой по фазе `gate_bot_y & 7`: 7 блитов → 1. +3. **Не перерисовывать статичные части шва** — грань ворот, пол и кромка при + анимации решётки не меняются (см. OPT-1 в `../roomtest/bug_list.md`). +4. **T-1 / T-2** (`../roomtest/bug_list.md`) — перерисовка пик по причине и + idle-skip Кида: самый большой оставшийся резерв, потому что убирает работу + целиком, а не удешевляет её. diff --git a/applications/PoP/docs/levels_plan.md b/applications/PoP/docs/levels_plan.md new file mode 100644 index 0000000..bafc12a --- /dev/null +++ b/applications/PoP/docs/levels_plan.md @@ -0,0 +1,188 @@ +# План: от одного уровня к нескольким (загрузка, переходы, тайлсеты) + +Статус: план, 2026-08-01. Продолжает `PORT_PLAN.md` §7 (Фаза 1: «переходы +между экранами» → теперь между УРОВНЯМИ). Текущая точка: `roomtest` играет +уровень 1 целиком в одной комнате-за-комнатой модели, но уровень нельзя +ни выбрать, ни закончить. + +Источник истины — `../SDLPoP/src/` (правило `../CLAUDE.md`). Ключевые +места: `seg000.c: load_lev_spr/play_level_2/init_game`, `seg005.c: +up_pressed/go_up_leveldoor`, `seg006.c: play_seq → SEQ_END_LEVEL`, +`seg002.c` (спецсобытия уровней), `data.h:835..850` (потабличные различия +уровней). + +--- + +## 0. Что уже готово (не проектировать заново) + +- **Формат и загрузчик уровня.** `pop_level.c/.h` читает сырой + `res200N.bin` (2305 Б) в отдельную EMM-страницу; путь — параметр + `pop_level_load(const char *)`. Мультиуровневость здесь стоит одной + функции формирования имени. +- **Стартовая позиция уровня** уже разобрана: `pop_level_start_room()`, + `pop_level_start_pos()`, `pop_level_start_dir()` — реализованы и пока + НЕ вызываются (см. `../roomtest/TASKS.md` L1-START). +- **Страж по данным уровня**: `pop_level_guard()` (порт `enter_guard`), + сохранение состояния между комнатами (`pop_guard_state_save`). +- **Палитра разложена по слотам ровно как в оригинале** (`pop_pack_kid.py` + `build_palette`): env 0x50, wall 0x60, pot 0x40, kid 0x70, меч 0x80, + страж 0x90. Это тот же раскрой, что `set_pal_arr(0x50/0x60)` в + `seg000.c:1140..1148`, — значит смена тайлсета не требует переиндексации + спрайтов Кида (см. §3). +- **Все 16 файлов уровней распакованы**: `../SDLPoP/data/LEVELS/res2000.. + res2015.bin` (0 — демо-уровень). + +--- + +## 1. Что реально различается между уровнями (замер по данным, не по памяти) + +Таблицы из `../SDLPoP/src/data.h:840..847` + инвентарь тайлов, снятый прямо +с `res200N.bin` (маска `fg & 0x1F`): + +| Ур. | Тайлсет | Страж | Новое против предыдущих | +|-----|---------|-------|--------------------------| +| 1 | dungeon | обычный | — (текущая база) | +| **2** | **dungeon** | **обычный** | **ничего нового: тот же набор объектов минус меч** | +| 3 | dungeon | СКЕЛЕТ | чомперы | +| 4 | palace | обычный | **тайлсет palace**, зеркало (спецсобытие `mirror_level`) | +| 5 | palace | обычный | — | +| 6 | palace | ТОЛСТЫЙ | падение на входе (спецсобытие) | +| 7 | dungeon | обычный | — | +| 8, 9 | dungeon | обычный | — | +| 10, 11 | palace | обычный | — | +| 12 | dungeon | ТЕНЬ | seamless-выход (комната 23), исчезающий меч | +| 13 | dungeon | ВИЗИРЬ | мышь, особый выход | +| 14 | palace | нет | — | +| 15 | dungeon | нет | финал | + +Прямое следствие для порядка работ: **уровень 2 не требует ни одного нового +ассета и ни одной новой механики** — он проверяет ровно машинерию перехода. +Это и есть первый шаг. + +Прочие потабличные различия, которые придётся завести массивами по 16: +`tbl_level_type` (тайлсет), `tbl_guard_type` (−1 = стражей нет), +`tbl_guard_hp`, `tbl_level_color` (вариантные палитры, 1.3), `tbl_entry_pose`. + +--- + +## 2. Шаг 1 — машинерия перехода (цель: уровни 1 → 2 → 3) + +Порядок именно такой; каждый пункт проверяем в MAME отдельно. + +**2.1 Выход с уровня.** Портировать `up_pressed()` ветку двери +(`seg005.c:410..423`) + `go_up_leveldoor()` (`seg005.c:497`): дверь рядом +(при/за/перед персонажем) И `drawn_room != level.start_room` И створка +открыта полностью (`curr_room_modif >= 42` — вариант `fix_exit_door`) → +`Char.x = x_bump[...] + 10`, направление влево, `seq_70_go_up_on_level_door`. +Затем оживить опкод `0xF1 END_LEVEL` в `play_seq` (`../roomtest/pop_kid.c:418` +— сейчас пустой `break`): `++pop_next_level`, как `seg006.c:662`. + +**2.2 Цикл уровня.** В `main()` после тика: `if (pop_next_level != +pop_current_level) → load_level(pop_next_level)`. Порядок сноса/подъёма +состояния (порт `load_lev_spr` + `play_level_2`): +`pop_level_free` → `pop_level_load("LEVELS\\res200%d.bin")` → +`pop_trob_reset` → `pop_guard_reset` → сброс tile-override'ов +(`ovr_*` в `roomtest.c`) → `enter_room(pop_level_start_room())` → +`kid_init(поза/позиция/направление из данных уровня)`. +**HP через уровень переносится** (в оригинале `hitp_beg_lev`), не сбрасывать +в максимум — сверить с `seg000.c` `init_game`/`play_level_2`. + +**2.3 Стражи по уровню.** Завести `tbl_guard_type[16]`/`tbl_guard_hp[16]`; +`-1` = стражей на уровне нет (уровни 14, 15) — `pop_guard_enter` обязан это +понимать, иначе на 14-м полезут стражи из мусора. Для шага 1 (уровни 2, 3) +достаточно обычного стража, но проверку `-1` заложить сразу. + +**2.4 Чит «следующий уровень» (Shift+L).** Реализуется ровно тем же +`pop_next_level` — и без него отладка уровней превращается в прохождение +игры руками. Делать в этом же шаге, не позже (см. §4). + +**Приёмка шага 1:** дверь уровня 1 → уровень 2 играется целиком → его дверь +→ уровень 3 стартует (чомперы могут быть ещё не портированы — тогда +фиксируем как известное ограничение, а не «баг»). + +--- + +## 3. Шаг 2 — второй тайлсет (palace, уровни 4+) + +**Ассеты.** `toolchain/pop_pack_bg.py` уже читает PNG каскадом +VDUNGEON→VPALACE (та же логика, что в игре), но печёт ОДИН набор атласов +(`pop_env0..4.atl`, `pop_wall.atl`, `pop_fore.atl` ≈ 75 КБ). Нужен второй +набор из VPALACE (`pal_env*.atl` / `pal_wall.atl`), плюс `torch_debris` — +тайл, который встречается только на palace-уровнях. По EMM это ещё ~6 +страниц при бюджете ~3.3 МБ — не проблема. + +**Палитра — главный технический вопрос, и он уже решён раскроем.** +Тайлсет живёт в слотах `0x50..0x5F` (env) и `0x60..0x6F` (wall); Кид, меч, +страж, склянки — в других слотах. Значит смена тайлсета = перезапись 32 +записей палитры (`gfx_pal_set` на обе страницы, как `flash_bg` в +`roomtest.c`), а НЕ перезагрузка `kid.pal` и не переиндексация спрайтов. +Сделать `pal_dungeon.bin` / `pal_palace.bin` (по 32 записи) и грузить при +смене типа уровня. Проверить артефактом: скриншот palace-комнаты против +рендера `render_room.py` для того же уровня. + +**Вариантные цвета уровней** (`tbl_level_color`, `level_var_palettes` — это +уже 1.3, в 1.0 их нет): по той же механике, тот же диапазон слотов. Решение +на будущее — сначала базовые два тайлсета, потом при желании цвета. + +**Выбор набора в коде.** `pop_bg_load()` сейчас грузит фиксированные имена; +превратить в `pop_bg_load(type)` с двумя таблицами имён + выгрузка старых +атласов при смене типа (`atlas_free`). Переключение — только на границе +уровня, не в кадре. + +--- + +## 4. Читы SDLPoP: что взять на следующем этапе + +Из `../SDLPoP/README.md` (раздел Cheats). У нас уже есть: **K** — убить +стража, **I** — бессмертие (наш, в оригинале нет), **S** — выдать меч (наш), +**+/−** — обход комнат (`ROOMNAV`, наш). + +**Брать сразу вместе с переходами уровней** (без них отладка дороже самой +работы): + +| Чит | Что даёт | Цена | +|-----|----------|------| +| **Shift+L — следующий уровень** | единственный вменяемый способ тестировать уровни 2..15 | тривиально: `++pop_next_level` из §2.2 | +| **R — воскресить Кида** | у нас респавн по ↑ + таймаут; порт `resurrect` ближе к оригиналу и не мешает управлению | низкая | +| **Shift+S / Shift+T — +1 HP / +максимум** | отладка боёвки без «ровно трёх попыток»; честная замена нашему читу бессмертия | низкая, HP-машинерия уже есть | +| **[ и ] — сдвинуть Кида на пиксель** (debug-чит SDLPoP) | прямо бьёт в наш класс багов «окклюзия/шов на один пиксель» — воспроизведение позы без ловли момента | тривиально | + +**Брать во вторую очередь:** + +| Чит | Почему позже | +|-----|--------------| +| **H / J / U / N + Ctrl+B — смотреть соседние комнаты** | требует честной модели `drawn_room ≠ Kid.room` (наш S3-straddle, каркас есть: `update_kid_render_dx`). Зато потом заменяет самодельный `ROOMNAV` и попутно закрывает straddle-задачу | +| **Shift+W — медленное падение (feather)** | ветка `JMP_IF_FEATHER` (опкод `0xF7`) в `play_seq` уже есть, но не проверена ничем — чит станет её единственным тестом | +| **C / Shift+C — номера комнат** | у нас номер рисуется палочками именно потому, что текст тянет 2 КБ знакогенератора в W2 (`roomtest.c`). Ждёт своего шрифта | + +**Не брать:** `Shift+I` (переворот экрана), `Shift+B` (blind mode) — +развлекательные, к отладке порта отношения не имеют. `−/+` (время) — нужен +таймер уровня, которого у нас нет (Фаза 6). + +**Отдельно, дорого, но очень ценно — `F6`/`F9` (quicksave/quickload точного +состояния).** Это сериализация `Char` + `room_modif` всех комнат + trob'ов + +состояния стражей. Даёт то, чего нам сейчас сильно не хватает: +воспроизводимый регресс в MAME («вот кадр, где баг») вместо ручного подхода +к позиции. Кандидат сразу после того, как заработают уровни. + +--- + +## 5. Риски и что проверить артефактом до кодинга + +1. **Размер кода.** Замер сборки 2026-08-01: `_CODE` 25 119 Б, + куча ~2.4 КБ, банк 2 (`pop_bg`) 13 792 / 16 384, банк 3 (`pop_map`) + 6 331, банк 1 (`guards`) 1 896, банк 4 (`pop_gdraw`) 2 236. Чомперы, + зеркало, скелет и второй тайлсет пойдут в банк 2 — там осталось 2.6 КБ. + **Прежде чем начинать §3, посчитать, куда лягут новые тайлы**, иначе + повторится история «банк 2 упёрся в потолок» (коммит 2f3e854). Свободные + номера банков есть (5+), гранулярность — файл. +2. **Спецсобытия уровней** (`seg002.c`: `level3_set_chkp`, `sword_disappears`, + `Jaffar_exit`, зеркало, мышь) — их НЕ надо портировать заранее. Для + уровней 2 и 3 нужен только чекпойнт уровня 3. Остальное — по мере + подхода к уровню. +3. **Чомперы** (уровень 3 и почти все дальше) — отдельная механика + (`animate_chomper` + коллизия + смерть); шаблон работы тот же, что у + пик/ворот, см. `gates_spikes_plan.md`. +4. **`tbl_guard_type = -1`** на уровнях 14/15: без проверки страж + «появится» из неинициализированных данных. +5. **Уровень 0 (демо)** существует в данных, но в скоуп не входит. diff --git a/applications/PoP/docs/loose_floors_plan.md b/applications/PoP/docs/loose_floors_plan.md deleted file mode 100644 index 5ab72fa..0000000 --- a/applications/PoP/docs/loose_floors_plan.md +++ /dev/null @@ -1,143 +0,0 @@ -# Loose floors (проваливающиеся полы) — план порта - -Разбор SDLPoP (seg007 loose/trob/mob, seg008 draw_loose). ПЛАН, ещё не -реализовано. Тайл в комнате 1: `[2,6] = 0x0B = tiles_11_loose`. - -## 1. Хранение состояния - -- **Тип тайла**: `curr_room_tiles[tilepos] & 0x1F == 11` (tiles_11_loose). - Бит `0x20` = «solid» loose (авто-падающий вариант, ур.13 — от шага НЕ - падает). После падения тайл → `0` (tiles_0_empty). -- **Модификатор** `curr_room_modif[tilepos]` = состояние анимации: - - `0` — покой (обычный loose-пол); - - `0x80..0x83` — **трясётся** (бит7); за ~4 кадра затухает обратно в 0; - - `1..11` — **обратный отсчёт до падения** (на нём что-то стоит); - достигает `loose_floor_delay = 11` → падает. - -## 2. Анимация тряски (shake) — когда включается - -- **Триггер = do_knock** (seg007:0FE0): на ЖЁСТКОМ приземлении в кадрах - посадки играет `SEQ_KNOCK_DOWN` → взводит `knock` → `check_knock()` → - `do_knock(room, curr_row − (knock>0))`. -- `do_knock(room, row)`: по всем колонкам ряда — если тайл loose → - `loose_make_shake()`. -- `loose_make_shake()` (seg007:0FB4): если `modif==0` (и не ур.13) → - `modif = 0x80`, `add_trob(type 1)`. -- **Отсюда кейс пользователя**: Kid падает/приземляется на `[2,4]` → - do_knock трясёт ВСЕ loose-тайлы ряда 2 → `[2,6]` трясётся. (Через - knock-смещение ряда может задеть и соседний ряд.) -- `animate_loose` (кадрово): `++modif`; при бите7 трясёт до `>=0x84` → - сброс в 0, `trob.type=-1`. `loose_shake()` играет звук - (sound 20/21/22) по таблице `loose_sound[]`. - -## 3. Анимация падения (fall) — когда включается - -- **Триггер = make_loose_fall(1)** (seg007:0EF6), вызывается когда: - - Kid СТОИТ на loose-тайле — `check_press()` (seg006): кадр с - FRAME_NEEDS_FLOOR над loose → make_loose_fall(1); - - зацеп/подтягивание на loose (`check_grab`, `check_jump_up`); - - пробой сверху: кадр 79 (jumphang) над loose → make_loose_fall(1); - - авто-падающие (ур.13) — `make_loose_fall(-(prandom&0x0F))`. -- `make_loose_fall(modifier)`: если НЕ solid (`tiles & 0x20 == 0`) и - `(sbyte)modif <= 0` → `modif = modifier`, `add_trob(type 0)`. -- `animate_loose`: `++modif`; когда `modif >= 11` (loose_floor_delay) → - `remove_loose()` (тайл → empty) + `add_mob()` (спавн падающего куска). - -## 4. Падающий кусок (mob) - -- `add_mob()` кладёт `curmob` в `mobs[]` (до 14). `do_mobs()` каждый - кадр: `move_mob()` (гравитация, y растёт) + `check_loose_fall_on_kid()` - (урон Киду/страже, если попал). -- Приземление куска → тайл под ним `curr_room_tiles[...] = tiles_14_debris` - (seg007 move_mob:1053). Т.е. **loose(11) упал → сверху empty(0), снизу - debris(14)**. - -## 5. Отрисовка по статусу - -- Куски тайла: `loose_fram_left[]={41,69,41,70,70,41,41,41,70,70,70,0}`, - `loose_fram_right[]={42,71,...}`, `loose_fram_bottom[]={43,73,...}` - (env-спрайты, seg008:518/596/608). -- Индекс кадра = `get_loose_frame(modifier)` (seg008): `0` = ровный - (41/42/43); `1..10` = дрожащие варианты (69–74); при бите7/большой - задержке — низкие индексы. -- **До падения**: рисуем loose с `get_loose_frame(modif)` (0 = ровно, - иначе колеблется). **После**: сверху empty, снизу debris(14) — обычная - статическая отрисовка (у нас уже есть tile 0x0E/14 debris в tile_table). - -## 6. Что нужно в нашем движке (сейчас НЕТ) - -Наш `pop_bg` рисует комнату СТАТИЧЕСКИ один раз. Loose-полы требуют -**динамического тайлового слоя**: -1. **Массив модификаторов** `room_modif[30]` (у нас есть `bg[30]` — можно - переиспользовать/рядом) — состояние каждого тайла. -2. **Очередь trob** (список анимируемых тайлов) + `animate_loose` пер-кадр - → перерисовка ТОЛЬКО изменившихся тайлов (как heal-прямоугольник Kid). -3. **make_loose_fall / do_knock / loose_make_shake** — триггеры (из - физики Kid: приземление→knock, стойка на loose→fall). -4. **mob-система** (падающий кусок): минимум 1–2 mob'а, гравитация, - приземление → debris. Урон Киду (`check_loose_fall_on_kid`) — можно - Фазой 2. -5. **Перерисовка тайла**: `draw_tile(row,col)` у нас уже умеет loose - (`code==11`, `loose_fram_*` в env) — нужно вызывать его выборочно с - текущим модификатором (сейчас draw_tile берёт статический bg). - -**Порядок реализации (предложение):** -- L1: room_modif[] + выборочная перерисовка тайла по модификатору - (draw_loose с get_loose_frame) — статика→динамика одного тайла. -- L2: trob-очередь + animate_loose (тряска по do_knock на приземлении). -- L3: make_loose_fall (стойка на loose) + отсчёт + remove → empty. -- L4: mob (падающий кусок → debris снизу). -- L5: урон Киду от падающего куска. - -## Конкретика из SDLPoP (сверено 2026-07-18, готово к реализации) - -Таблицы (seg008.c), индекс = `get_loose_frame(modif)`: -- `loose_fram_left[] = {41,69,41,70,70,41,41,41,70,70,70,0}` -- `loose_fram_right[] = {42,71,42,72,72,42,42,42,72,72,72,0}` -- `loose_fram_bottom[]= {43,73,43,74,74,43,43,43,74,74,74,0}` -- `get_loose_frame(m)`: если `(m&0x80)` (или delay>11) → `m&=0x7F; if(m>10) return 1;` → `return m;` -- `y_loose_land[] = {2,65,128,191,254}` (mob), `loose_floor_delay = 11`. - -Триггеры (call-sites): -- **make_loose_fall(modifier=1)** — из `check_press()` (seg006): когда Kid - СТОИТ на тайле (FRAME_NEEDS_FLOOR, action < hang_climb / turn / bumped) и - `get_tile_at_char()==11`; ИЛИ `frame==79` (прыжок вверх) и - `get_tile_above_char()==11` (пробой сверху). `tile_is_floor(11)==1` — - Kid стоит на loose (start_fall НЕ зовётся). Тело: - `if(!(tile&0x20) && (sbyte)modif<=0){ modif=modifier; add_trob(type0); }` -- **do_knock(row)** — на ЖЁСТКОМ приземлении (SEQ_KNOCK_DOWN→check_knock, - seg003): по всем колонкам ряда `if(tile==11) loose_make_shake()` - (`if(modif==0){ modif=0x80; add_trob(type1); }`). -- **animate_loose** (каждый кадр, seg007:816): `++modif`; если `&0x80` - (тряска): `if(modif>=0x84){modif=0; trob=-1;}`; иначе (отсчёт): - `if(modif>=11){ remove_loose(tile→0); trob=-1; add_mob(); } else shake`. - -## Интеграция в наш движок (roomtest) — подход - -Наш движок ПЕКЁТ комнату один раз (двойной буфер: своя ОЗУ-копия на -страницу). Loose требует динамики + общего состояния pop_map↔pop_bg: -1. **Общая МУТАБЕЛЬНАЯ копия комнаты**: roomtest.c держит `uint8_t - room_fg[30]` (копия room1_fg) и передаёт ОДИН указатель и в - `pop_room_draw`, и в `pop_map_set` → мутации loose видны обоим. - (Сейчас g_fg — `const`; сделать неконстантным.) -2. **Состояние**: `uint8_t pop_loose_modif[30]` (0 / 0x80.. / 1..11). -3. **Модель** (pop_map): `check_press()` в `pop_phys_tick` (make_loose_fall - при стоянии на 11), `animate` каждый кадр. -4. **Перерисовка** (pop_bg, на back-странице каждый кадр): - - тряска/отсчёт (код всё ещё 11): `gfx_heal(tile rect)` (вернуть - печёный фон) + нарисовать loose-кадр (left/right/bottom по - get_loose_frame) банком SPRITE; - - падение (11→0): mutate `g_fg[pos]=0` + «запечь пустоту» на ОБЕИХ - страницах (bake-счётчик 2: чёрный bar + draw_tile empty банком NORMAL - на текущей странице 2 кадра подряд) → дальше heal показывает пусто. -5. **L4 mob**: падающий кусок → debris(14) снизу (y_loose_land); пока - отложено — упавший loose = пусто. **L5** урон — позже. - -Риск: перерисовка динамического тайла в двойном буфере (per-page heal + -bake) — единственное тонкое место; остальное — прямой порт логики выше. - -## Связанные - -Триггеры завязаны на физику Kid ([[pop_hang_state]] check_press/check_grab, -приземление land/SEQ_KNOCK_DOWN). Отрисовка — [[pop_fore_layer]] / -[[pop_background_strategy]] (draw_tile уже знает loose_fram_*). diff --git a/applications/PoP/docs/room_model_plan.md b/applications/PoP/docs/room_model_plan.md index 7e5f41b..3b4cd4b 100644 --- a/applications/PoP/docs/room_model_plan.md +++ b/applications/PoP/docs/room_model_plan.md @@ -1,5 +1,21 @@ # PoP roomtest — модель `kid_room ≠ drawn_room` (баг #4) +> **Статус: ЖИВОЙ ПЛАН, сделан частично (сверено 2026-08-01).** +> - **S1 — сделан:** `kid_room` заведён отдельно от `cur_room`, +> `update_kid_render_dx()` (`roomtest.c`) даёт рендер-смещение ∓140, а +> `pop_kid_set_render_dx` применяет его в отрисовке. Фактически это пока +> каркас: `enter_room` держит `kid_room == cur_room`, так что смещение +> всегда 0. +> - **S2/S3/S4 — не сделаны и не срочны.** Исходный повод (баг #4, +> пинг-понг у шва) закрыт иначе — поправкой odd-pixel в +> `char_x_forward_edge` + `pop_leave_timer` (см. `../roomtest/bug_list.md`, +> BUG-SEAM-PINGPONG). +> +> **Зачем документ остаётся.** Полная straddle-модель понадобится для: +> (а) читов осмотра соседних комнат `H/J/U/N` (`levels_plan.md` §4), +> (б) сцен, где Кид и страж в разных комнатах кадра, (в) остатков окклюзии у +> шва (S4). Брать из `../roomtest/TASKS.md`, когда дойдёт очередь. + Порт straddle-модели SDLPoP: персонаж может находиться в СОСЕДНЕЙ комнате, пока на экране ещё ТЕКУЩАЯ (drawn_room). Источник истины — SDLPoP. diff --git a/applications/PoP/docs/size_optimization_plan.md b/applications/PoP/docs/size_optimization_plan.md deleted file mode 100644 index 90d3fa0..0000000 --- a/applications/PoP/docs/size_optimization_plan.md +++ /dev/null @@ -1,335 +0,0 @@ -# roomtest — план оптимизации по размеру + переход на huge/banking - -Статус: **план для отдельной сессии** (2026-07-21). Документ самодостаточный -(рассчитан на старт с пустого контекста). Цель — освободить место: сейчас -`applications/PoP/roomtest` в режиме `small` почти упёрся в потолок 32 КБ. - -Правило проекта (`applications/PoP/CLAUDE.md`): механику/раскладку памяти -сверять с исходником и с memory (`sprinter_memory_modes`, `sdcc_banking`, -`bank_local_data_pattern`, `pop_banking_architecture`). Перед оптимизацией — -`make size-check`-подобный замер до/после (здесь — руками по `.map`). - ---- - -## 0. Как мерить - -- Сборка: `cd applications/PoP/roomtest && make roomtest.exe` (режим `small`, - `--gfx 256`). Карта символов — `.sprinter-cc-roomtest/roomtest.map` - (адреса сдвигаются при каждой пересборке!). -- Размеры областей — из `.map` (`_CODE`, `_DATA`, `_BSS`). -- Вклад модулей в `_CODE` — атрибуция диапазонов между символами по модулю - (скрипт-однострочник на python в истории; группировать символы `.map` по - 3-й колонке-модулю и суммировать `addr[i+1]-addr[i]`). -- MAME-проверка после изменений раскладки ОБЯЗАТЕЛЬНА (режимы памяти — - типовой источник «молча ломается», см. `sprinter_memory_modes`). - -## 1. ТЕКУЩЕЕ СОСТОЯНИЕ (замер 2026-07-21) - -Режим `small` = единое пространство **W1+W2 = 0x4000..0xBFFF (32 КБ)**; CODE с -0x4100, DATA/BSS/heap цепляются ЗА CODE автоматически (`--data-loc 0` = -linker chains), стек — вверху W2. - -| Область | Размер | Диапазон | -|---------|--------|----------| -| `_CODE` | ~27 250 Б (0x6A6F) | 0x4100–0xAB6F | -| `_HOME` | 227 Б | 0xAB6F | -| `_DATA` | 3 449 Б (0x0D79) | 0xAC78–0xB9F1 | -| `_BSS` | 290 Б | | - -**Образ ≈ 31.2 КБ; до верха W2 (0xBFFF) остаётся ≈ 1.3 КБ на кучу+стек.** -Куча в roomtest почти не используется (атласы/уровень — в EMM-страницах), -но запас критично мал. - -### Вклад модулей в _CODE (по .map, приблизительно) -``` - 7003 pop_bg (вся отрисовка тайлов/слоёв/wall_pattern) - 6326 pop_kid (из них ~3745 Б — СТАТ. ТАБЛИЦЫ kid_data.h, см. ниже) - 3762 pop_map (коллизия/физика/пики) - 1455 pop_trob (кнопки/ворота/пики-каркас) - 1224 pop_level (загрузка уровня, doorlink) - 914 roomtest (главный цикл) - ~7000 libc/libbgi (gfx_blit*, atlas_load, kbd_raw, open/read, irq, div/mul…) -``` - -### Крупные СТАТИЧЕСКИЕ данные (сейчас в _CODE как `const`) -- **`kid_data.h` — самый большой кусок, ~3.7 КБ**, живёт в _CODE (атрибутируется - pop_kid): - - `kid_seqtbl[2310]` — байткод последовательностей (play_seq). - - `kid_frames[241]` × 5 Б = 1205 Б — таблица кадров (image,dx,dy,flags,sword). - - `kid_seq_off[115]` × 2 Б = 230 Б — смещения seq. -- `pop_bg`: `tile_table[31]`×12 = 372 Б + ~20 мелких const-таблиц (COL_XH, - WALL_FRAM_*, SPIKES_FRAM_{RIGHT,LEFT,FORE}, LOOSE_FRAM_*, DOOR_FRAM_SLICE, - BLUELINE_*, LPOS/RPOS, FLOOR_LEFT_OVERLAY) — суммарно ~0.5–0.7 КБ. -- `pop_map`: `x_bump[20]`, `y_land[5]`, `wall_dl/dr`, `dir_front/behind` — ~100 Б. -- В `_DATA` (W2, не CODE): `room_modif[24][30]`=720 Б + копии LINKLOC/LINKMAP=512 Б - (pop_trob/pop_level) + рабочие массивы roomtest. - ---- - -## 2. ПУТЬ A — оптимизация КОДА (без смены модели) - -1. **Компиляторные флаги** (`bin/sprinter-cc`): попробовать `--opt-code-size` - у SDCC и подобрать `--max-allocs` (сейчас дефолт 100000; меньше = мельче код, - но медленнее компиляция; см. `mdview2_size_budget` — там `--max-allocs` - давал −1.4 КБ). Замерить каждый модуль отдельно. -2. **Дедуп подстановки нажатой кнопки**: логика `opener→floor / closer→stuck` - по таймеру связи ПРОДУБЛИРОВАНА в `draw_tile` и `fore_tile` (pop_bg.c). - Вынести в `static inline`/helper `subst_pressed_button(code,mod)`. -3. **wall_pattern / prandom** (pop_bg): 32-битный LCG (`unsigned long`) — - пользователь не любит 32-бит (см. `avoid_32bit_arith_z80`); но это PRNG - оригинала (нужен для совпадения раскладки стен) — трогать осторожно, только - если найдётся 16-битный эквивалент, дающий ТУ ЖЕ последовательность. -4. **Ревизия дублей**: `y_to_row` определён в pop_bg И pop_map; мелкие - геометрические хелперы дублируются — свести в один internal-модуль. -5. `/simplify`-проход по последним правкам Фазы B (pop_trob/pop_bg). - -Ожидаемый выигрыш пути A: единицы–первые сотни байт на пункт; в сумме, -оптимистично, ~1–2 КБ. Недостаточно как единственная мера. - ---- - -## 3. ПУТЬ B — вынос СТАТ. ДАННЫХ в EMM-страницы (с атласами / с level) - -**Идея (по замечанию пользователя):** EMM-страницы атласов и уровня -использованы лишь частично (страница 16 КБ, данных меньше), в «хвосте» — -свободное место. Часть `const`-таблиц можно хранить ТАМ, а не в _CODE/_DATA, -если таблица читается ИМЕННО ТОГДА, когда нужная страница уже в W0. - -**Механика W0:** атласы блитятся из W0 (`_gfx_w0_state`: `_gfx_w0_cur` — -спрайт-страница в W0; ISR-стаб `_gfx_w0_isr` возвращает её после прерывания). -Уровень (pop_level) маппит свою страницу в W0 на время извлечения -(`gfx_w0_map`/`gfx_w0_unmap`). → пока страница в W0, CPU может читать и -данные из неё по адресам 0x0000..0x3FFF. - -**Категоризация таблиц по W0-контексту (задача сессии — уточнить по каждой):** - -- **(a) Читается, когда в W0 АТЛАС** → хранить в свободном хвосте атлас-страницы. - Кандидаты — таблицы, которые нужны В МОМЕНТ блита конкретного атласа. - ГРАБЛИ: `draw_tile` читает `tile_table`/`COL_XH` ДО блита (чтобы решить, какой - спрайт/куда) — в этот момент в W0 может быть ДРУГАЯ страница (DSS/предыдущий - атлас). Т.е. большинство draw-таблиц читаются ВНЕ W0-атлас-контекста → - «в лоб» не переносятся. Нужен аудит КАЖДОГО чтения: гарантирована ли нужная - страница в W0 в этот тик. -- **(b) Читается, когда в W0 LEVEL** → хранить с уровнем (в его странице; там - ~13.7 КБ свободно из 16). Кандидаты: константы декода doorlink, разбор - комнат — всё, что pop_level делает под `gfx_w0_map(lvl_page)`. -- **(c) Нужна и там, и там** → дублировать в обеих страницах ЛИБО оставить - резидентной (если дубли дороже экономии). -- **(d) Читается в чистой ЛОГИКЕ (W0 не важен)** → перенос требует ЯВНОГО - `gfx_w0_map` на каждое чтение (дорого, особенно в горячих циклах) → как - правило оставить резидентной. - -**Отдельно `kid_data.h` (3.7 КБ — самый жирный кандидат):** -- `kid_frames`/`kid_seqtbl` читаются в `play_seq` (ЧИСТАЯ логика, каждый тик) И - в `kid_draw` (блит из kid-атласа, kid-страница в W0). Т.е. частично (a), - частично (d). Перенос всей таблицы в kid-атлас-страницу заставит `play_seq` - делать `gfx_w0_map` на каждый шаг байткода → замерить стоимость (может убить - бюджет спрайтов, см. `sprite_engine_perf`). Вариант: держать в EMM отдельной - страницей данных Kid и маппить один раз на кадр вокруг kid_tick+kid_draw. -- Это самый большой одиночный выигрыш (−3.7 КБ из _CODE), но и самый рискованный - по скорости — приоритетный к ПРОТОТИПИРОВАНИЮ и замеру. - -**Паттерн переноса writable/const данных в банк/страницу:** см. memory -`bank_local_data_pattern` (--codeseg/--constseg/--dataseg BANKn + trampoline-fix -+ mkexe -p 0) и `sdcc_static_storage_gotcha`. - -### 3.1 Свободное место в страницах (замер 2026-07-21, страница = 16384 Б) -``` -BG-атласы: размер свободно - pop_env0.atl 10578 5806 - pop_env1.atl 12449 3935 <- САМАЯ ТЕСНАЯ из bg - pop_env2.atl 10798 5586 - pop_env3.atl 5032 11352 <- много места - pop_env4.atl 8498 7886 - pop_wall.atl 11543 4841 - pop_fore.atl 7763 8621 -Kid-атласы (28 стр): free min=6161 max=15452 avg=9722 -Level (res2001.bin): данные 2305, свободно ~13823 (16384 − 0x100 стаб − 2305) -``` - -**Выводы по вместимости:** -- **Макс. данных в ОДНОМ атлас-банке = свободный хвост ЭТОЙ страницы** (см. - таблицу). Связывающее ограничение — самая тесная нужная страница (env1 = - 3935 Б; не перегружать её). -- Если страница будет маппиться в **W0** — минус ~0x100 Б на ISR-стаб (как - level). Атлас-страницы стаб УЖЕ содержат (atlas_load патчит) → данные класть - в хвост ПОСЛЕ атласа. -- **`kid_data.h` (3.7 КБ) влезает в kid-страницу** (min free 6161) или в - отдельную выделенную страницу данных Kid — предпочтительно отдельную (маппить - раз на кадр, не конфликтуя с kid-атласами блита). -- **Level-таблицы** — вагон места в level-странице (~13.8 КБ). -- **BG draw-таблицы** (~0.7 КБ) влезут в env3/fore/env4 (много free), НО см. - граблю W0-контекста в §3(a) — читаются ли они, когда нужная страница в W0. -- **Выделенная страница ТОЛЬКО под данные** (не делить с атласом) = до ~16 КБ - (−0x100 стаб при W0-маппинге). EMM-бюджет это позволяет (см. - `sprinter_emm_budget`: 215/3440 КБ free на старте). -- **Принудительно уменьшать макс. атлас (репак мельче) — КРАЙНИЙ случай:** это - резко поднимет число атлас-банков (сейчас 5 env-страниц адресуются как id>>5; - дробление ломает эту адресацию и множит страницы). Сначала использовать - СУЩЕСТВУЮЩИЙ свободный хвост и отдельные data-страницы. - ---- - -## 4. ПУТЬ C — переход на huge (banked code) - -### 4.1 Что такое huge сейчас (`bin/sprinter-cc`, `runtime/crt0_banked`) -- `--memory huge`: `MODE_CODE_LOC=0x4100`, **`MODE_DATA_LOC=0x8000` (ФИКС.)**, - banked code в W3. crt0_banked, как crt0_small, авто-детектит W2. - Помечено `[TODO]` — не обкатано. -- Отличие от small: small цепляет DATA сразу за CODE (`--data-loc 0`); huge - ФИКСИРУЕТ DATA на 0x8000. - -### 4.2 ТРЕБОВАНИЕ (по пользователю): huge должен переносить DATA динамически -Сейчас huge жёстко кладёт DATA на 0x8000. Если РЕЗИДЕНТНЫЙ CODE вылезет за -0x8000 (W1 = только 0x4000..0x7FFF ≈ 16 КБ; резидент > 16 КБ лезет в W2) → -коллизия с DATA. **Надо научить huge класть DATA динамически ЗА резидентным -CODE (как small: `--data-loc 0` + crt0 считает старт), а не на фикс 0x8000.** -Тогда huge = «small-раскладка резидента (W1+W2, DATA за CODE) + ДОП. код в -банках W3». Это первый пункт работ по huge. - -### 4.3 КОНФЛИКТ: графика тоже хочет W3 (ключевой риск) -`pop_banking_architecture` прямо говорит: **графику нельзя в W3** (блиты/атласы -используют окна; см. §4.5). Поэтому в банки W3 можно выносить ТОЛЬКО -НЕ-графические блоки, и такой банк НЕ должен во время своего исполнения держать -графику в W3. Если W3-банкованная функция ЗОВЁТ графику (которой нужен W3), -трамплин обязан сохранить/восстановить банк вокруг вызова (проверить, что -banking-ABI это делает — `sdcc_banking`). Альтернатива без этого риска — -**big + BANK_W1** (банк кода в W1, не W3), рекомендованная в -`pop_banking_architecture` именно из-за W3-графики. Сессия должна выбрать: -huge(W3) с аккуратным save/restore ИЛИ big(BANK_W1). - -### 4.4 Какие блоки МОЖНО вынести (не работают с графикой напрямую) -Замер graphics-ref по модулям (grep `gfx_|blit|env_b|wall_b|fore_b|setfillstyle| -bar(|GFX_BANK|initgraph`): -``` -pop_bg.c : 83 — РЕЗИДЕНТ (вся отрисовка) -roomtest.c : 23 — РЕЗИДЕНТ (главный цикл + флип страниц) -pop_level.c : 17 — использует gfx_w0_map (W0, не W3-блиты) — ПОГРАНИЧНЫЙ -pop_kid.c : 12 — kid_draw = графика; НО play_seq — чистая логика (можно split) -pop_ctrl.c : 0 — КАНДИДАТ В БАНК (ввод/диспетчер control) -pop_map.c : 0 — КАНДИДАТ В БАНК (коллизия/физика, ~3.8 КБ) — лучший по объёму -pop_trob.c : 0 — КАНДИДАТ В БАНК (кнопки/ворота/пики-логика) -``` -- **Лучшие кандидаты в W3-банк(и): pop_map + pop_trob + pop_ctrl** (нет прямой - графики; вместе ~5.3 КБ CODE). Освобождают резидент → он влезает в W1. -- **Осторожно с межбанковыми вызовами:** pop_map/pop_trob ЗОВУТ pop_bg - (перерисовка loose/пик/кнопок/шва) и pop_kid (play_seq/kid_set_seq). Это - кросс-банк вызовы через трамплин (`sdcc_banking`: стек +3 байта, виртуальный - 24-битный адрес). Правило `pop_banking_architecture`: «один файл = один банк - = прямые вызовы», main резидентен. Проверить, что трамплин сохраняет W3 - вокруг вызова в графический pop_bg (см. §4.3). -- **pop_kid split** (по желанию): вынести play_seq/seqtbl-интерпретатор - (логика + таблицы kid_data.h) в банк, оставить kid_draw/kid_heal резидентными. - Даёт и −код, и −данные из резидента, но требует аккуратного разделения TU - (1 функция = 1 модуль, см. `libc_one_function_per_module`). -- **pop_level: пограничный** — не блитит, но маппит уровень в W0; банковать - можно, если W0-логика совместима с трамплином (проверить ISR-стаб взаимодействие). - -### 4.5 Почему графику нельзя в W3 (контекст) -Блиттер держит спрайт-страницу атласа в **W0** (`_gfx_w0_state`, -`_gfx_w0_isr`). Ускоритель/адресация видео — отдельная тема (см. -`sprinter_accelerator`, `sprinter_graphics`). W3 в banked-раскладке — окно -кода-банка; смешивать с окном, которое графика перемапливает, нельзя без -save/restore. Детально — `pop_banking_architecture`, `graphics_constraints`. - ---- - -## 5. РЕКОМЕНДУЕМЫЙ ПОРЯДОК РАБОТ (для след. сессии) - -1. **Замер-базлайн** (CODE/DATA/BSS + per-module) — зафиксировать до. -2. **Путь A** дешёвые пункты (флаги, дедуп кнопки, дедуп y_to_row) — быстрый −1..2 КБ. -3. **huge §4.2**: научить huge класть DATA динамически (как small) — инфраструктурный - пререквизит, без него банкинг не даст гибкости. Обкатать в MAME на текущем - резиденте (пока без выноса — просто huge-раскладка = small + пустой W3). -4. **huge §4.4**: вынести pop_map (+pop_trob, +pop_ctrl) в W3-банк(и); проверить - кросс-банк вызовы в pop_bg (§4.3) в MAME. ЛИБО выбрать big+BANK_W1. -5. **Путь B** (по остатку нужды): прототип выноса `kid_data.h` в EMM-страницу - Kid с маппингом раз на кадр; замерить скорость (`sprite_engine_perf`). - Затем аудит draw-таблиц по W0-контексту (§3 a/b/c/d). - -## 6. Ссылки -- `bin/sprinter-cc` (§162+ — резолв memory-mode → CODE_LOC/DATA_LOC). -- `runtime/crt0_small.*`, `runtime/crt0_banked.*`, `runtime/bank.s`. -- memory: `sprinter_memory_modes`, `memory_modes_implemented`, - `setwin2_for_w2_alloc`, `sdcc_banking`, `bank_local_data_pattern`, - `pop_banking_architecture`, `avoid_32bit_arith_z80`, - `libc_one_function_per_module`, `sprite_engine_perf`, `mdview2_size_budget`. -- `applications/PoP/roomtest/bug_list.md` — открытые баги Фазы B (не блокируют - оптимизацию, но держать в уме при рефакторе pop_map/pop_bg). - ---- - -## 7. Лишние блиты в горячем пути (добавлено 2026-07-27) - -Найдено при разборе окклюзии по эталону SDLPoP: **наш «передний слой» рисовал -спрайты, которых в оригинале там нет** — это и артефакты, и лишняя работа -каждый кадр. Исправлено: `fore_tile` (вызывается для КАЖДОГО тайла футпринта -Kid, обычно 2–4 за кадр) рисовал ещё и `bottom_id` — переднюю кромку пола; в -оригинале `draw_tile_fore` (seg008:690) добавляет только `add_foretable`-часть, -а `bottom` идёт через `draw_tile_bottom` в backtable (ПОД персонажем). -Итог: −2..4 блита за кадр, `_CODE` −388 Б, ушла «тень» у основания колонны. - -**Что проверить тем же методом (по одному вопросу к каждому месту: а есть ли -этот спрайт в оригинале в ЭТОЙ таблице?):** - -1. `pop_room_draw`/`draw_tile` — вызовы на входе в комнату не критичны по - скорости, но по ним стоит сверить состав слоёв (backtable vs foretable). -2. `overlay_mid_tile` — сейчас точный порт midtable-части `draw_tile2`; - проверить, не рисуем ли `base_id` там, где оригинал его не рисует - (loose: base=0, потому что кадр плиты идёт через `draw_loose` в backtable). -3. `pop_loose_mob_tick` — перерисовка соседнего тайла (`draw_tile(mob_row, - mob_col+1)`) КАЖДЫЙ кадр падения: в оригинале это `set_redraw_full` на - один кадр; можно ограничить только тайлом, который реально пересекается - с куском. -4. `pop_ceil_shake_draw` — heal 64×8 + два `draw_tile(-1,·)` на кадр тряски; - проверить, нужен ли второй тайл (правую грань loose в полосе потолка - оригинал не рисует вовсе — `draw_tile_aboveroom` без `draw_tile_anim_right`). -5. `fore_only_tile` для полосы потолка: вызывается для всех колонок габарита, - а оригинал (`redraw_needed_above`) — только для колонок с флагом - `redraw_frames_above`; сузить до колонок, реально задетых спрайтом. -6. `wall_pattern` внутри fore/overlay — тяжёлая (PRNG + до 4 блитов); проверить, - не зовём ли её там, где оригинал ограничивается `wall_fram_main`. - ---- - -## 8. Скорость отрисовки: замеры и запас (2026-07-27) - -Профилирование в MAME (маркеры в порт 0xFE + `wpiset … totalcycles`, приём из -memory `mame_mcp_bridge`). Кадр Sprinter = **430 080 тактов**. - -**Стоимость блита почти НЕ зависит от размера** — платим за проход по цепочке -`gfx_blit → gfx_blit_part → _gfx_blit_full` (16-битная арифметика, клип, -пересчёт src, нарезка полос >256), а не за пиксели: - -| путь (спрайт 32×3) | тактов | -|---|---| -| `gfx_blit` (общее ядро, с клипом) | 13 288 | -| линейное спрайтовое ядро без клипа (`putsprite` при `gfx_sprite_clip(0)`) | 4 617 | - -Отсюда `draw_tile(0,0)` тайла шва (9 блитов) стоил **183 690 тактов = 43 % -кадра**; сам `bar` — только 13 308. - -**СДЕЛАНО (шаг 1):** в libbgi добавлен `gfx_blit_noclip()` -(`common/gfx_blit_noclip.c`, прототип в `include/gfx.h`) — блит без клипа в -ТЕКУЩЕМ банке через линейное ядро; `pop_bg.blit_b` уходит на него, когда -спрайт целиком на экране и не нужен `g_clip_top`. Выигрыш ~2.9× на каждом -фоновом блите (подтверждено в MAME). -**ВАЖНО:** W3-скобку (`_bgi_begin/_bgi_end`) ставит САМА libbgi — вызывать её -из модуля, собранного с `--w3`, нельзя: после `_bgi_begin` окно W3 занято -видеобанком и код вызывающего исчезает из адресного пространства (проверено: -белый экран). - -**ЗАПАС (шаг 2), когда перестанет хватать бюджета кадра:** - -1. **Батчинг W3-скобки** — одна `_bgi_begin/_bgi_end` на весь `draw_tile` - вместо скобки на блит; нужен публичный batch-API в libbgi (как у - спрайтового движка). Осторожно: между begin/end стоит `DI` — длинная - серия задержит кадровое прерывание. -2. **Решётка ворот одним спрайтом** — `draw_gate_back` рисует бары по одному - (`env 52`, до 7 блитов). Сгенерировать в атласе «столб решётки» (повтор - бара на высоту тайла) и выводить одним `gfx_blit_part` с обрезкой по фазе - `gate_bot_y & 7`: 7 блитов → 1. -3. **Не перерисовывать статичные части шва** — грань ворот (env 47, 26×62), - пол (41) и кромка (43) при анимации решётки не меняются; если стирать - только полосу баров, уйдут ещё 3 блита из 9. -4. См. также §7 (лишние блиты, которых нет в оригинале). diff --git a/applications/PoP/roomtest/CLAUDE.md b/applications/PoP/roomtest/CLAUDE.md index f8d3cbb..9ddf782 100644 --- a/applications/PoP/roomtest/CLAUDE.md +++ b/applications/PoP/roomtest/CLAUDE.md @@ -11,6 +11,10 @@ по памяти и не угадывай константы/порядок слоёв. Расхождение с SDLPoP = по умолчанию баг у нас. +**Что в работе сейчас — `TASKS.md`** (доска задач: приоритеты, критерии +готовности). Баги — `bug_list.md` (его шапка честно говорит, что список +отстал). План следующих уровней — `../docs/levels_plan.md`. + ## Сборка и запуск ``` diff --git a/applications/PoP/roomtest/README.md b/applications/PoP/roomtest/README.md index 2ed0588..45387fe 100644 --- a/applications/PoP/roomtest/README.md +++ b/applications/PoP/roomtest/README.md @@ -39,9 +39,14 @@ ESC выход. - `room1_data.h` — карта комнаты 1; `kid_data.h` — данные анимации Kid (оба генерируются скриптами `../toolchain/`). -## Статус +## Статус (2026-08-01) -Готово и проверено в MAME: статический фон комнаты 1; Kid — анимация, -управление, коллизия/падение, зацеп/подтягивание/спуск, fore-окклюзия -пола/стены над Kid. В работе: проваливающиеся полы (loose floors) — см. -`../docs/loose_floors_plan.md`. +Играется весь уровень 1: комнаты и переходы между ними, Kid (анимация, +управление, коллизия/падение, зацеп/подтягивание/спуск, окклюзия), кнопки и +ворота, пики, проваливающиеся полы, дверь уровня, меч и бой, стражи с ИИ, +HP и зелья. + +Не сделано: выход в следующий уровень (дверь открывается, но войти в неё +нельзя), звук, таймер/HUD времени, сохранения. **Что берём в работу +сейчас — [`TASKS.md`](TASKS.md)**; план следующих уровней — +[`../docs/levels_plan.md`](../docs/levels_plan.md). diff --git a/applications/PoP/roomtest/TASKS.md b/applications/PoP/roomtest/TASKS.md new file mode 100644 index 0000000..0a91c44 --- /dev/null +++ b/applications/PoP/roomtest/TASKS.md @@ -0,0 +1,329 @@ +# roomtest — доска текущих задач (обновлено 2026-08-01) + +Не список багов (он в `bug_list.md`) и не план фаз (`../docs/PORT_PLAN.md`, +`../docs/layout_plan_v2.md`, `../docs/levels_plan.md`), а то, **что берём в +работу сейчас и в каком порядке**. Каждая запись: что сделать, почему +именно сейчас, чем подтверждать результат. + +Правило проекта в силе: механику сверять с `../SDLPoP/src/` ДО кодинга; +диагноз платформы подтверждать артефактом (брейкпоинт/дамп/.asm), а не +гипотезой (memory `defer_unexplained_quirks`). + +--- + +## P0 — делаем сейчас + +### KBD-1. Shift + стрелки: нажатия теряются — определить причину + +**Симптом (пользователь, 2026-08-01).** Залипаний почти нет, но при +УДЕРЖИВАЕМОМ Shift первые один-два нажатия ← дают осторожные шаги, дальше +нажатия ← не отрабатываются, пока Shift не отпустишь. + +**Рабочая гипотеза (механизм, а не догадка «что-то с клавиатурой»).** +Три известных факта складываются в одну картину: + +1. **PS/2 Set 2, «fake shift».** При зажатом Shift нажатие РАСШИРЕННОЙ + клавиши (стрелки — `E0`-коды) обрамляется фиктивным отпусканием/нажатием + шифта: нажатие ← шлёт `E0 F0 12` + `E0 6B` = **5 байт** (без шифта было + бы 2), отпускание — `E0 F0 6B` + `E0 12` = **5 байт** (было 3). То есть + ровно в связке Shift+стрелка трафик удваивается. +2. **Приёмный FIFO SIO — 3 байта.** Пачка в 5 байт переживает только то, + что мы успеваем вычерпывать её по ходу. Потерянный make стрелки = + «нажатие не отработало»; потерянный break = залипание (его лечит + `kbd_raw_sync`, но ценой сброса всех немодификаторных клавиш). +3. **Импульс IRQ клавиатуры в MAME живёт 32 такта CPU.** + `mame/sources/MAME/src/mame/sinclair/sprinter.cpp`: `on_kbd_data()` + выставляет `m_irqs->in_set<1>()` НА КАЖДЫЙ принятый байт (то есть старая + запись в `docs/TODO.md` «MAME не даёт per-byte INT» — неверна), но тут же + заводит `m_irq_off_timer` на 32 такта, а `irq_off()` снимает линию. + **Если в эти 32 такта мы под `DI` — прерывание пропало насовсем**, байт + остаётся в FIFO до следующего IRQ (следующий байт или кадровый 50 Гц). +4. **Наши DI-окна длинные.** Ядра акселератора держат `di` на ВЕСЬ блит + (`libbgi/bgi256/_bgi_blit_cols_raw.c:47` — «один DI на весь блит»); + порядок цены прохода — 13.6 К тактов (`libbgi/include/gfx.h`), это + сотни микросекунд, на порядки больше 32-тактового импульса. + +Отсюда: **обе версии пользователя — про одно и то же.** Логика ввода +(`pop_ctrl.c`, порт `read_user_control`/`safe_step`) сверена с SDLPoP и +выглядит корректной: `safe_step()` ставит `control_forward = CONTROL_IGNORE`, +и это снимается в `read_user_control()` при ОТПУСКАНИИ стрелки — то есть +повторные тапы ← при зажатом Shift обязаны работать. Не работают они +потому, что до нас не доезжает либо make, либо break стрелки. + +**План проверки — по шагам, каждый даёт артефакт:** + +1. Счётчики в MAME: брейк на `_kbdraw_overrun` (запись) и на ветке + `tr_kbd_drain` — сколько overrun'ов за 10 с при «Shift зажат, тапаю ←» + против «тапаю ← без Shift». Ожидание по гипотезе: с Shift кратно больше. +2. Замер максимального DI-окна кадра: брейкпоинты на `di`/`ei` в + `_bgi_blit_cols_raw` + `{printf totalcycles; g}` — получить реальную длину + в тактах и в микросекундах. +3. **Спайк «блит без DI».** `docs/new/06-accel.md §6.6`: новая прошивка + допускает работу акселератора при EI (по приходу прерывания он + отключается, по `RETI` включается обратно); старая — нет. Собрать libbgi + с убранным `di` в блит/heal-ядрах, прогнать roomtest в MAME: (а) не + рушится ли картинка, (б) падает ли счётчик overrun из п.1. Если да — + причина подтверждена, и дальше это вопрос «какая прошивка на живом + железе» (по умолчанию оставить DI, режим без DI — опцией libbgi). +4. **Независимо от п.3 — `kbd_raw_poll()`.** Вычерпывание FIFO ОПРОСОМ + (порт `0x19` бит 0 → читать `0x18`, тот же декодер make/break, что в + трамплине) из главного цикла 2–4 раза за кадр между фазами `PROF()`. + Снимает зависимость от «поймали ли мы импульс IRQ» вообще, стоит сотни + тактов, графику не трогает. Реализация: вынести drain-цикл из + `libc/irq/_irq_tramp.c` в общий кусок либо продублировать в + `libc/kbd/kbd_raw_poll.c`; тело обязано идти под `DI` (гонка с ISR за + деструктивное чтение порта 0x18). +5. Побочно сюда же играет **T-2 (idle-skip)** из `bug_list.md`: не + перерисовывать Кида, пока поза/координаты не менялись, — это минус + heal+blit (то есть минус DI-окна) в самых спокойных кадрах, где как раз + и тапают Shift+стрелку. +6. Только если после 3–4 симптом жив — копать логику + `control_shift2`/`CONTROL_IGNORE` против `seg005.c:374..390`. + +**Критерий готовности:** при зажатом Shift десять тапов ← дают десять +осторожных шагов (проверка в MAME через `:kbd:ms_naturl:*` напрямую, НЕ +через `press_key` — тот дёргает обе клавиатуры, см. `docs/libc-reference.md` +``). + +--- + +### KBD-1: ЧТО ИЗМЕРЕНО (сессия 2026-08-01) — гипотеза про DI НЕ подтвердилась + +**Методика.** Симптом «нажатие не отработало» переведён в счётчики, чтобы не +спорить с глазами. Нажимается **Home** — тоже расширенная клавиша (тот же +`E0`-префикс и тот же «fake shift», что у стрелок), но игрой игнорируется, +поэтому Кид стоит на месте и рельеф комнаты на результат не влияет. +Брейкпоинты с действием `{ b@ADDR = b@ADDR + 1 ; g }` (счёт без остановки +машины) в трёх точках: вход клавиатурной ветки трамплина, чтение порта 0x18 +внутри drain-цикла, запись make-бита для кода `0x6C`. Скратч-байты — хвост +`ovr_tile[]` (в этом сценарии не используется). + +**Симптом воспроизведён скриптом:** при зажатом Shift 10 нажатий → до +декодера дошло 9 make-байт. Без Shift потерь нет — ровно как сообщил +пользователь. + +| Прогон | make дошло / нажато | overrun | +|--------|---------------------|---------| +| игра идёт, `kbd_raw_poll` ВКЛ | 9 / 10 | 3 | +| игра идёт, `kbd_raw_poll` ВЫКЛ (патч `ret` в точке входа) | 9 / 10 | 4 | +| игра ЗАМОРОЖЕНА клавишей «1» (блитов нет вообще, значит и длинных DI нет) | **8 / 10** | 6 | + +**Вывод 1: наши DI-окна ни при чём.** В замороженном кадре, где блитов нет +и прерывания разрешены практически всё время, потерь НЕ меньше, а больше. + +**Вывод 2: `kbd_raw_poll()` в текущей расстановке бесполезен** — 9/10 и с +ним, и без. Причина понятна задним числом: шесть вызовов стоят В ТЕХ ЖЕ +точках, где прерывания и так разрешены, то есть добавляют ровно то, что +трамплин сделал бы сам. Вызовы из `roomtest.c` убраны; сама функция в libc +оставлена — она корректна и нужна как заготовка под «плотный опрос» (см. +ниже), но в горячем цикле её держать не за что. + +**Вывод 3 (главный): байт теряется НИЖЕ нашего кода.** Счётчик чтений порта +0x18: 5 нажатий Shift+Home должны дать ровно 50 байт (нажатие `E0 F0 12` + +`E0 6C`, отпускание `E0 F0 6C` + `E0 12` = по 10 на цикл). Насчитано **49** +— и ровно один make потерян. То есть до процессора байт не доехал вообще, +декодер тут ни при чём. + +**Вывод 4: прерывание на байт теряется примерно в 44 % случаев.** На тех же +49 прочитанных байтах — только **28 входов** в клавиатурную ветку трамплина +(1.75 байта за вход). То есть больше сорока процентов импульсов запроса +не были обслужены, и байты копятся в трёхбайтовом FIFO вплотную к его +потолку; одна неудачная пауза — и байт потерян. + +### KBD-1: ПОТОЛОК ПЛОТНОГО ОПРОСА ИЗМЕРЕН — приём лечит полностью + +`tests/kbdpoll` — программа, которая не делает НИЧЕГО, кроме +`kbd_raw_poll()` в бесконечном цикле (ни графики, ни vsync, ни вывода: +любая работа разредила бы опрос и испортила замер). Это физический +максимум плотности. Тот же счётный метод, те же брейкпоинты-счётчики. + +| Прогон | нажатий | make дошло | байт прочитано / ожидалось | +|--------|---------|-----------|-----------------------------| +| контроль: Shift зажат 4 с, нажатий нет | 0 | 0 | 0 (Shift сам ничего не шлёт — автоповтора у модификатора нет) | +| Shift + Home | **25** | **25** | **250 / 250** | + +**Ни одного потерянного байта.** Для сравнения: в игре при шести вызовах +за кадр терялся 1 байт из 50. При такой частоте потерь вероятность +случайно получить ноль потерь на 250 байтах ≈ 0.6 %, так что результат не +совпадение. + +**Вывод: опрос — рабочее решение, вопрос только в ПЛОТНОСТИ.** Нужно +опрашивать примерно раз в 0.5 мс (≈10 000 тактов), а шесть вызовов за +60-мс кадр давали один раз в 10 мс — в двадцать раз реже необходимого. + +**Где взять частоту:** логический тик = 60 мс, из них ~18 мс занято +работой и **~42 мс процессор простаивает внутри `gfx_wait_vsync`**, опрашивая +луч. Опрос там стоит ноль и покрывает две трети периода с запасом по +плотности. Остаётся слепым только тело одного accel-блита под DI (до +~650 мкс) — разорвать его нельзя (см. «что НЕ делать»). + +**Почему нужна именно такая частота (вопрос «PS/2 же не даёт больше 30 +нажатий в секунду»).** Частота опроса определяется НЕ темпом нажатий, а +темпом байт ВНУТРИ одного нажатия и глубиной FIFO. Одно нажатие при +зажатом Shift — это 5 байт подряд (`E0 F0 12`, `E0 6C`), отпускание — ещё 5, +и клавиатура выдаёт их со скоростью провода: 11 бит на байт при ~10–16 кГц += ~0.7–1.1 мс на байт. Воронка — 3 байта. Значит между двумя вычерпываниями +имеют право прийти максимум два байта, то есть вычерпывать надо не реже чем +раз в ~1.5–2 мс (0.5 мс взято с запасом). **Даже ОДНО нажатие в секунду +переполнит FIFO**, если в эти несколько миллисекунд его никто не разгребает. +Замер это подтверждает: 1.75 байта за одно вычерпывание — уже 58 % ёмкости. +В норме разгребает прерывание; опрос понадобился только потому, что ~44 % +импульсов здесь теряется. + +**Альтернатива, которая убирает опрос совсем — уменьшить трафик, а не +ускорять разгребание.** BIOS `$EA` (`FN_KBD_OUT`, `docs/new/09-input.md` +§9.2) шлёт байт НА клавиатуру, то есть ей можно скомандовать: +- **Scan Code Set 3** — нет ни «fake shift», ни `E0`-префиксов: make = 1 байт, + break = 2. Нажатие с шифтом перестаёт превышать FIFO в принципе. +- либо хотя бы отключить typematic (`0xF5`/`0xF7`). + +**Но проверить это в MAME НЕЛЬЗЯ:** в `sprinter.cpp` подключено только +направление клавиатура→SIO (`m_kbd->out_data_cb() → rxa_w`); обратный путь +(SIO→клавиатура) не разведён вовсе, так что команда просто уйдёт в никуда. +Плюс пришлось бы переписать все наши константы кодов под Set 3. Значит это +кандидат на «когда дойдём до реального железа», а не на сейчас. + +**Что делать (в порядке зависимостей):** +1. Idle-хук в libbgi: `gfx_set_idle_hook(fn)`, вызывается в цикле ожидания + луча внутри `gfx_wait_vsync`. Приложение ставит туда `kbd_raw_poll`. + Полезен не только нам — любой программе даёт «качать» что-то в ожидании + кадра. Осторожно с регистрами: цикл ждёт на BC-таймауте, вокруг вызова + нужен push/pop, а сам таймаут в итерациях станет длиннее по времени. + **Важно про цену: это НЕ новая нагрузка.** Опрос ставится ровно туда, + где процессор и так впустую крутит `in a,(#0xFE)` — 42 мс из 60. Полезной + работы не отнимается нисколько. + **Ограничитель области, если «постоянный опрос» всё равно не нравится:** + потери случаются ТОЛЬКО при зажатом модификаторе (замерено; без Shift + потерь нет). Значит хук можно взводить лишь пока нажат Shift/Ctrl/Alt — + тогда опрос работает исключительно в той ситуации, ради которой заведён. +2. Вернуть вызовы в занятую треть кадра (они бесплатны, просто сами по себе + ничего не решали). +3. Перемерить тем же счётным методом уже в roomtest: цель — 25/25. + +**Куда смотреть дальше, если плотного опроса не хватит.** +1. **Драйвер MAME — НЕ ТРОГАЕМ** (решение пользователя: пересборка MAME на + его машине занимает часы). Для протокола, подозрение осталось: + `sinclair/sprinter.cpp` держит запрос от клавиатуры ровно **32 такта + CPU**, и `m_irq_off_timer` — **один на два источника** (`irq_on()` экрана + заводит его же, `irq_off()` гасит разом обе линии). То есть кадровое + прерывание способно обрезать клавиатурный импульс — правдоподобное + объяснение «44 % пропущенных импульсов». +2. **Реальное железо.** Если п.1 — чисто эмуляционный артефакт, на железе + проблемы может не быть вовсе. Проверять при первом прогоне на живом + Sprinter. + +**Про совпадение кадрового и клавиатурного прерываний** (вопрос +пользователя, 2026-08-01). Документация Sprinter: оба приходят с вектором +`0FFh`, различать по биту приёма байта в порту клавиатуры — «не пришёл, +значит экран»; совпадение возможно, но «исключительно редкий случай» +(в новой версии обещают развести жёстче через ПЛМ). То есть наш трамплин +делает ровно предписанное. Известный побочный эффект: при совпадении мы +обслуживаем клавиатуру и `reti`, пропуская кадровую цепочку и DSS — на +потерю байт это не влияет (линия кадрового остаётся взведённой и вызывает +повторный вход), но кадровый тик может пропасть. Отдельная мелкая правка, +в KBD-1 не входит. + +**Что НЕ делать (проверено, стоило времени):** +- **Снимать `di` в accel-ядрах libbgi нельзя.** Патч `di`→`nop` в + `_bgi_blit_cols_raw`/`_bgi_heal_rows_raw`/`_bgi_blit_rows_raw` прямо в + памяти **уронил машину в перезагрузку**. То есть режим «акселератор + работает при EI» из `docs/new/06-accel.md §6.6` в этой прошивке/эмуляции + недоступен — вопрос закрыт артефактом, а не рассуждением. +- Дробить DI-окна по колонкам смысла тоже нет: см. вывод 1. + +--- + +### CLIP-1. Аудит блитов: где клип не нужен + +**Зачем сейчас.** Кадр занят на ~86 %; подготовка клипающего варианта +стоит ~5.6 К тактов на вызов, а общее ядро против линейного — 13 288 против +4 617 тактов на спрайт 32×3 (`libbgi/include/gfx.h`). Это самая дешёвая +оставшаяся оптимизация: не переписывание логики, а выбор ядра. + +**Что уже правильно** (шаблон, который надо распространить): блиты Кида, +стража, клинка и брызг спрашивают `pop_onscreen_cols()` и уходят в +`gfx_blit_cols_part_noclip`, иначе в клипающий вариант +(`pop_kid.c:86,591,676`, `pop_gdraw.c:105`). + +**Что чинить:** + +- `kid_heal()` (`pop_kid.c:613,615`) и `pop_guard_heal()` + (`pop_gdraw.c:65,66`) зовут `gfx_heal` — **всегда с клипом**, хотя + `gfx_heal_noclip` существует и `pop_bg.c:147` им уже пользуется по тому же + тесту. Это heal 2–4 прямоугольников КАЖДЫЙ кадр; замер общего ядра — + 11 658 тактов на heal 22×22. Тест onscreen у нас уже посчитан рядом. +- `pop_room_clip_borders()` (`pop_bg.c:1193,1194`) — `gfx_heal(0,0,320,…)`: + noclip требует w,h ≤ 255, значит либо два куска по 160, либо оставить как + есть (зовётся по гейту, только в кадрах падения — проверить, что гейт + действительно редкий, прежде чем трогать). + +**Что обязано остаться с клипом** (зафиксировать в комментарии, чтобы потом +не «оптимизировать» повторно): +- кромочные тайлы фона `pop_bg.c:132` — тайл у края экрана режется по + построению; +- спрайты при straddle (`kid_render_dx = ∓140`, комната Кида ≠ отрисованной) + и при падении ниже поля — фолбэки `pop_kid.c:594,681`, `pop_gdraw.c:107`. + +**Порядок работы:** (1) выписать полный список вызовов +`gfx_blit*`/`gfx_heal*` в roomtest с ответом «кто гарантирует on-screen»; +(2) перевести то, что можно, на noclip по существующему тесту; (3) замерить +кадр полосами бордюра ДО/ПОСЛЕ (`PROF()` уже в `roomtest.c`) и брейкпоинтом +на конкретной функции — числом, а не «стало плавнее»; (4) `make size-check`. + +--- + +## P1 — сразу после P0 (закрываем уровень 1 как ИГРУ, а не стенд) + +### L1-START. Старт по данным уровня +`roomtest.c` жёстко стартует `START_ROOM 1 / COL 3 / ROW 0`, хотя +`pop_level_start_room()` / `pop_level_start_pos()` / `pop_level_start_dir()` +в `pop_level.h` уже реализованы и НИКЕМ не вызываются. Перевести старт и +респавн на них; `#define ROOMNAV` оставить, но выключенным по умолчанию. + +### L1-EXIT. Выход с уровня (дверь уровня) +Сейчас: дверь открывается (`animate_leveldoor`), но войти в неё нельзя — +`up_pressed()` (`pop_ctrl.c:188`) не проверяет `tiles_16_level_door_left`, а +опкод `0xF1 END_LEVEL` в `play_seq` (`pop_kid.c:418`) пустой. Портировать +`up_pressed`-ветку + `go_up_leveldoor()` (`seg005.c:410..500`) и завести +`pop_next_level`, который взводит `END_LEVEL` (`seg006.c:662`). **Это же +первый шаг плана следующих уровней** — см. `../docs/levels_plan.md`. + +### L1-TRIAGE. Ревизия `bug_list.md` +Список отстал от кода: BUG-1/BUG-2 (боковой переход, ping-pong) закрываются +`pop_leave_timer` + `char_x_forward_edge` (`pop_map.c:1402..1432,1553`), +BUG-3 (окклюзия climb-up на кнопке) — фиксом `tile_code_drawn` от 2026-07-28, +но все три по-прежнему стоят как **Critical**. Пройти их в MAME, закрыть +подтверждённые, оставшиеся (BUG-CEIL-1/2/3, BUG-OCCL-1 — косметика окклюзии) +переклассифицировать. Заодно закрыть таблицу обхода 24 комнат — она +заполнена на 5 строк из 24, а инструмент (`ROOMNAV`) готов. + +### L1-SPEED. Игра идёт быстрее оригинала (найдено 2026-08-01) +Сверка таймингов: оригинал — `BASE_FPS = 60` при `base_speed = 5` тиков на +логический кадр (`SDLPoP/src/types.h:1373`, `data.h:869`) = **83.3 мс**, в бою +`fight_speed = 6` = **100 мс**. У нас `roomtest.c` ждёт **три** `gfx_wait_vsync()` += 60 мс, и отдельной скорости боя нет — то есть примерно **+39 % к скорости +эталона**. Соответствие: 4 ожидания (80 мс) обычно, 5 (100 мс) в бою. +**Делать ПОСЛЕ CLIP-1**: замедление кадра спрячет проблемы бюджета вместо +того, чтобы их показать. Проверка — секундомером по одинаковому отрезку +рядом с живым SDLPoP, не «на глаз». + +### L1-PASS. Сквозное прохождение уровня 1 +От старта до двери уровня одним заходом: подбор меча, страж, кнопки/ворота, +пики, loose-полы, зелье, падения. Это приёмка этапа 1 и одновременно +регресс-база для уровня 2. + +--- + +## Отложено осознанно (не брать, пока не появится причина) + +- **Звук** (CBL-эффекты, Фаза 5 `PORT_PLAN.md`) — геймплей не блокирует. +- **Таймер уровня / HUD времени / меню / сохранения** — Фаза 6. +- **T-1** (пики: перерисовка по причине) — `bug_list.md`; отдаётся почти + бесплатно после T-2, отдельно не окупается. +- **BUG-CEIL-2** (loose-плита в потолке из комнаты сверху) — требует + персистентного per-room modifier соседей, это Фаза P0 из + `../docs/gates_spikes_plan.md`. +- **Отключение мыши на время игры** и **замена PRNG** — + `../docs/ideas_backlog.md` (оба дают доли процента кадра). +- **OPT-1** (хирургический редрой шва) — стоимость транзиентная, решение от + 2026-07-22 «оставляем». diff --git a/applications/PoP/roomtest/bug_list.md b/applications/PoP/roomtest/bug_list.md index f2b2093..a14bb0b 100644 --- a/applications/PoP/roomtest/bug_list.md +++ b/applications/PoP/roomtest/bug_list.md @@ -1,5 +1,14 @@ # roomtest — список известных багов (Фаза B и смежное) +> **Список отстал от кода (ревизия 2026-08-01, задача L1-TRIAGE в +> [`TASKS.md`](TASKS.md)).** По исходникам похоже, что закрыты, но в MAME не +> переподтверждены: **BUG-1** и **BUG-2** (боковой переход/ping-pong — +> `pop_map.c` `pop_leave_timer` + `char_x_forward_edge`, см. также +> BUG-SEAM-PINGPONG ниже) и **BUG-3** (окклюзия climb-up на кнопке — фикс +> `tile_code_drawn` от 2026-07-28, описан в разделе «Исправлено»). Пока не +> проверены — статус **Critical** оставлен как есть, не полагаться на него. +> Текущие приоритеты работ — в [`TASKS.md`](TASKS.md), а не здесь. + Найдено при тестировании Фазы B (кнопки/ворота) в MAME. **НЕ решаем сейчас** — задача следующего этапа. Правило проекта: механику сверять с `applications/PoP/SDLPoP/src/` ДО кодинга. diff --git a/examples/scroll/scroll-impl-guide.md b/examples/scroll/scroll-impl-guide.md index beae6d4..52d9380 100644 --- a/examples/scroll/scroll-impl-guide.md +++ b/examples/scroll/scroll-impl-guide.md @@ -175,11 +175,14 @@ void gfx_scroll_v(uint8_t src_page, uint8_t dst_page, прогон: нарисовать фон, поверх спрайт банком `0x5C`, сделать `scroll_h` на dx>0, убедиться что спрайт **не размазался** (копировался фон, не VRAM со спрайтом). Заодно выяснить, нужен ли сброс Port_Y на колонку (§4). -2. **ОЗУ-копия per-page или общая.** `C-Compiler/applications/PoP/docs/double_buffer_plan.md` - пишет «теневая копия одна — общая». Если тень физически одна (а не адресуется - по базе `0xC000/0xC140` как VRAM), page0 и page1 не удержат фон **разных** - положений камеры во время скролла → пинг-понг (§8) сломается. Проверить: - записать разный фон в page0 и page1 банком `0x50`, сверить чтение обеих. +2. **ОЗУ-копия per-page или общая.** Ранний план дабл-буфера PoP исходил из + «теневая копия одна — общая»; **практикой это опровергнуто**: у каждой + страницы СВОЯ теневая ОЗУ-копия, heal берёт фон из копии той страницы, в + которую рисуем (`applications/PoP/roomtest/CLAUDE.md`, раздел + «Дабл-буфер»; `roomtest.c enter_room` рисует фон в обе страницы по + отдельности именно поэтому). Значит пинг-понг (§8) в принципе рабочий, + но перед опорой на него — подтвердить на своём коде: записать разный фон + в page0 и page1 банком `0x50`, сверить чтение обеих. 3. **(только для `scroll_v`, путь A)** accel-буфер переживает `OUT Port_Y` между fill и flush одного burst'а. Доковый straight-copy этого не проверяет.