Files
Sprinter-SDCC/applications/PoP/docs/l9_invert_plan.md
T
snark13 e11877f475 План реализации L9-INVERT (docs/l9_invert_plan.md)
Разведка железа первым шагом (смена Port_Y между burst-триггерами и
адресация двух страниц в W3), ядро копии с реверсом Y, vflip-блиты для
row-major, gfx_copy_rect экран-экран; в приложении — состояние, единая точка
пересчёта Y, фон через pop_tile.c, зеркальные кадры персонажей, клип.
Побочной задачей ENTER-ROOM-FAST на том же кирпиче.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:12:33 +03:00

304 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# L9-INVERT — план реализации зелья переворота (уровень 9)
Рабочий план, по которому задача делается с чистого контекста. Доска —
[`../roomtest/TASKS_OPEN.md#l9-invert`](../roomtest/TASKS_OPEN.md); правила
подпроекта — `../CLAUDE.md` (SDLPoP = источник истины, диагноз железа —
только артефактом).
## 0. Что и зачем
Зелья типа 4 на уровне 9 (комната 7 тайл `(1,7)`, комната 10 тайл `(0,4)`)
переворачивают картинку вверх ногами. В оригинале это `toggle_upside()`
(seg000:15E9): `upside_down = ~upside_down`, `need_redraw_because_flipped = 1`.
Больше зелье не делает НИЧЕГО — ни вспышки, ни урона (seg006:1885, ветка
`case 4`). Снимается: смертью Кида (seg000:1224, при `alive >= 0`) и стартом
уровня (seg003:38/188). Управление НЕ инвертируется. Второе зелье
переворачивает обратно.
Оригинал переворачивает готовый offscreen на выводе (`flip_screen` перед
копированием прямоугольников на экран, seg000:939/946). Нам этот путь
закрыт: страниц ровно две (`gfx_set_visible_page` → ESTEX $54 SELPAGE, бит 0),
рабочей третьей нет. Поэтому:
- **уже нарисованное** переворачиваем ОДИН РАЗ построчной копией
акселератора (решение пользователя 2026-08-12);
- **всё, что рисуется дальше**, рисуем зеркально: фон (row-major) — новыми
vflip-блитами, персонажи (column-major) — заранее подготовленными
зеркальными кадрами.
Полоса HP и лейбл комнаты живут в борту (`y = 194 + POP_YOFF`, поле —
`POP_PLAYFIELD_H = 192`) и не переворачиваются, как и в оригинале.
---
# Часть I — libbgi
## I.0 РАЗВЕДКА ЖЕЛЕЗА — делать ПЕРВОЙ, до любого кода
Два факта, на которых стоит вся схема, сейчас НЕ подтверждены артефактом.
Пока их нет, остальные пункты не начинать (memory `defer_unexplained_quirks`).
**Р1. Смена Port_Y между burst-чтением и burst-записью.** Схема требует:
армировать копию, `LD A,(HL)` (burst-чтение строки src), сменить Y,
`LD (DE),A` (burst-запись в другую строку). Между триггерами НЕЛЬЗЯ
выполнять инструкции, читающие ОПЕРАНД из памяти — fetch операнда
перезабивает буфер акселератора кодом (memory `accel_operand_fetch_retrigger`).
Значит:
- смена порта — только `out (c),a` (ED 79, регистровая форма); `out (#89),a`
(D3 89) читает immediate байт и УБЬЁТ буфер;
- новое значение Y — только из регистра (`ld a,d`), не `ld a,#n` и не из
памяти;
- `C` = 0x89 и оба значения Y должны лежать в регистрах ДО триггера чтения.
Проверка: `tests/accflip` по образцу `tests/accop` — заполнить строку
источника известным паттерном, скопировать в другую строку со сменой Y,
прочитать VRAM побайтно и сравнить. Отрицательный результат — стоп-сигнал:
переворот придётся делать через промежуточный ОЗУ-буфер (строка 320 Б), и
бюджет вырастет примерно вдвое.
**Р2. Адресация двух страниц в одном окне W3.** `gfx.h` утверждает, что
страница 1 «начинается на 320 байт дальше (0xC140)», но это комментарий, а не
замер. Нужно снять дампом: как адрес строки складывается из Port_Y и
смещения в окне, и видны ли обе страницы одновременно. От ответа зависит,
делается ли копия A→B одним проходом или через смену видеобанка.
Итог разведки записать в `libbgi/docs` (или `docs/new/06-accel.md`
дополнением) и в memory — это знание переиспользуется во всех будущих
экранных эффектах.
## I.1 Ядро копии с реверсом Y
`libbgi/bgi256/_bgi_copy_rows_raw.c` умеет только ИНКРЕМЕНТ Port_Y на строку
(`y0` + шаг вперёд; страйды патчатся SMC при входе). Нужен вариант, где
одна сторона идёт вниз, другая вверх.
- предпочтительно: новый leaf `_bgi_flip_rows_raw.c` (правило «1 функция =
1 модуль»), а не флаг в существующем — горячий путь блиттера трогать не
надо, и DCE не потащит лишнего в программы без переворота;
- вход тот же (`__sdcccall(1)`: src→HL, dst→DE, дальше стек), плюс
`y0_src` / `y0_dst` и признак направления;
- DI-окно — одна строка, как в остальных ядрах (арминг в каждой скобке:
в окне EI между чанками CBL-ISR армирует акселератор своим размером,
см. `docs/accel-fill-budget.md`).
## I.2 Публичные vflip-блиты для row-major
Все row-major блиты приложения идут ровно через три функции (`pop_tile.c:281`,
`:283`, `:286`, `:325`, `:327`):
| есть | нужен vflip-двойник |
|---|---|
| `gfx_blit_noclip` | `gfx_blit_noclip_vflip` |
| `gfx_blit_part_noclip` | `gfx_blit_part_noclip_vflip` |
| `gfx_blit_part` (с клипом) | `gfx_blit_part_vflip` |
Семантика: `(x,y)` — левый ВЕРХНИЙ угол результата на экране (как у обычных
блитов), строки источника выводятся снизу вверх. Это позволяет вызывающему
не менять арифметику позиции, а только пересчитать `y` под поле.
`gfx_blit_cols*` (column-major) vflip-двойников НЕ получают: реверс идёт
внутри колонки, а accel копирует блок только вперёд. Для персонажей —
часть II.4.
`gfx_heal*` не трогаем: heal симметричен, он восстанавливает прямоугольник из
ОЗУ-копии по экранным координатам, а координаты вызывающий уже даёт
перевёрнутые.
Тест: `tests/blitvflip` — эталонный спрайт, побайтная сверка VRAM.
## I.3 Копия прямоугольника экран→экран
```c
#define GFX_COPY_DIRECT 0
#define GFX_COPY_VFLIP 1
void gfx_copy_rect(uint8_t src_page, int sx, int sy,
uint8_t dst_page, int dx, int dy,
uint16_t w, uint16_t h, uint8_t mode);
```
- `w > 256` режется на burst'ы вызывающим ядром (320 = 160+160 либо 256+64 —
выбрать по замеру, разницы в тактах на строку почти нет);
- **горизонтального флипа не будет**: accel копирует блок только вперёд,
побайтовый реверс дал бы 320 burst'ов на строку вместо двух. Если он
когда-нибудь понадобится — только через ОЗУ-буфер с CPU-реверсом;
- клип не нужен (вызывающий гарантирует границы), но проверка «прямоугольник
в пределах экрана» в safe-варианте библиотеки — обязательна.
Тест: `tests/pagecopy` — прямой режим и VFLIP, побайтная сверка.
## I.4 Чего в списке пользователя не хватало
1. **Размерный регресс**: после каждого шага `make size-check`; новые модули
не должны утянуть за собой рост в программах, которые их не зовут (проверка
на `examples/` и `tests/`).
2. **Обе сборки библиотеки** — fast и safe (`sprinter-cc --safe`): гарды
параметров в safe, критичные — в обеих.
3. **Документация**: `docs/sprite-api-design.md` (раздел про блиттер),
`docs/TODO.md`, справочник API; факты разведки I.0 — в `docs/new/06-accel.md`.
4. **IM2/CBL**: длинные серии коротких DI-окон не должны ломать кадровые
прерывания и клавиатуру — проверить `tests/kbdpoll`-сценарием после
внедрения (клавиатура вычерпывается idle-хуком, а переворот идёт вне
ожидания кадра).
5. **Порядок «страница ↔ ОЗУ-копия»**: копия должна идти банком
`GFX_BANK_NORMAL`, иначе перевёрнутый фон не попадёт в ОЗУ-копию и heal
начнёт восстанавливать старую картинку. Это ключевой инвариант всей
схемы — вынести отдельным утверждением в тест.
---
# Часть II — приложение (roomtest)
## II.1 Состояние и момент переключения
- `pop_upside` (uint8_t) — рядом с `pop_feather` в `pop_map.c`, экспорт в
`pop_map.h`;
- тоггл — в `pop_proc_get_object`, ветка `potion_type == 4` (сейчас там TODO,
как было у пера). Ничего кроме тоггла: ни вспышки, ни звука — так в
оригинале;
- сброс: `pop_start_level` (уже переехал в `roomtest_cold.c`) и смерть Кида —
по образцу seg000:1224 (в `kid_phys`/там, где ловится `alive >= 0`);
- переключение (функция `pop_flip_screen()`, банк 8 — холодный код):
1. `gfx_copy_rect(front, …, back, …, GFX_COPY_VFLIP)` — скрытая страница
получает перевёрнутый чистый фон (accel читает ОЗУ-копию, персонажей в
ней нет);
2. флип страниц, чтобы игрок сразу увидел результат;
3. `gfx_copy_rect(new_front, …, new_back, …, GFX_COPY_DIRECT)` — вторая
страница дабл-буфера получает то же;
4. инвалидация: `pop_cd[].valid = 0` для обоих слотов, `pop_mirror_heal`
-состояние, `pop_hp_invalidate()`, метки «фон трогали» (`pop_cd_touch`)
на обе страницы, `seam_sig` — чтобы шов перерисовался.
## II.2 Геометрия — единая точка пересчёта
```c
/* pop_bg.h рядом с POP_YOFF/POP_PLAYFIELD_H */
#define POP_FLIP_Y(y, h) (2 * POP_YOFF + POP_PLAYFIELD_H - (y) - (h))
```
Применяется ТОЛЬКО к тому, что внутри поля. Правило: любая функция,
получающая экранный `y`, либо сама зеркалит его по флагу, либо принимает уже
зеркальный — смешивать нельзя, иначе получим двойной переворот (это самый
вероятный класс багов здесь). Решение: зеркалит ВЫЗЫВАЮЩИЙ, у примитивов
семантика не меняется.
## II.3 Фон
Одна точка: `pop_tile.c` (блит куска атласа) выбирает vflip-двойник по
`pop_upside` и пересчитывает `y`. Тогда автоматически попадают:
- полная отрисовка комнаты (`pop_room.c`);
- точечные редрои (`pop_redraw.c`: ворота, пики, кнопки, дверь уровня,
дрожащие плиты);
- анимации `pop_trob` (факелы, пузырьки зелий);
- fore-слой и оверлеи кромки, кладка стены (`pop_bg.c`);
- падающие куски loose (`pop_room.c`, `pop_loose_mob_*`).
Проверить отдельно: `wall_pattern` (кладка рисуется своей геометрией) и
оверлеи кромки — у них позиция считается от ряда, а не от `y` спрайта.
## II.4 Персонажи (column-major)
Нужны зеркальные кадры: 34 атласа, 228 563 Б (kid0..27 + sword, guard g0..g4).
- реверс колонки — через стек (`pop`/`push`), ~10 тактов номинала на байт;
- источник маппится в W0, приёмник — новая EMM-страница; на спрайт: прочитать
колонки в буфер W2 (максимальный кадр — 40×16 ≈ 640 Б), развернуть, писать
в приёмник (перемаппинг W0 на спрайт — один OUT);
- `pop_cdraw.c` выбирает набор атласов (оригинал/зеркальный) по `pop_upside`;
- **кэш ключуется по СТРАНИЦЕ атласа**, а не по кадру: страница = 8 кадров,
3-10 КБ, ~0.2-0.4 растрового кадра на переворот.
Стратегия наполнения кэша — см. открытый вопрос №1 внизу.
После [UI-SPRITES](../roomtest/TASKS_OPEN.md#ui-sprites) страницы `kid27` и
`g0` станут чисто «полевыми» (деления HP уедут в константы резидента), и
исключений в кэше не останется — эту задачу логично сделать ДО II.4.
## II.5 Клип
- `clip_char` (`pop_map.c:2287`, таблица `y_clip[5] = {-60,3,66,129,192}`) —
режет верх спрайта по линии ряда; при перевороте линия становится нижней.
Симметрия сама не сойдётся: нужен зеркальный расчёт `y_clip` и обмен
«сверху/снизу» местами;
- то же для зеркала уровня 4 (`pop_map.c:2732`) — на уровне 9 зеркал нет, но
код общий, поэтому ветку надо хотя бы не сломать;
- `pop_clip_sprite` (`pop_room.c:1069`) — клип падающей плиты;
- `pop_room_clip_borders` — борта симметричны (28 сверху и снизу), но
проверить, что чистится именно поле.
## II.6 Проверка
- **хост-тест** (`tests-host`): арифметика `POP_FLIP_Y` и зеркальный `y_clip`
— сцена «Кид у верхней кромки» в обычном и перевёрнутом режиме даёт
симметричные значения клипа. Логику отрисовки хост-тесты не видят, поэтому
здесь проверяем только счёт;
- **MAME**: уровень 9, комната 7 — выпить зелье `(1,7)`, проверить: картинка
перевернулась целиком, Кид ходит и цепляется корректно, факелы анимируются
перевёрнуто, ворота/пики перерисовываются перевёрнуто, полоса HP осталась
внизу и не зеркальная; второе зелье (комната 10) возвращает как было;
смерть Кида снимает эффект;
- **регресс**: обычные уровни (1-8) не должны измениться ни на пиксель —
прогнать пару комнат и сверить скриншоты с прежними.
---
# Часть III — побочная задача ENTER-ROOM-FAST
Идея пользователя, самостоятельная ценность (сейчас при входе в комнату видно,
как рисуются тайлы): рисовать комнату ОДИН раз в скрытую страницу, показать её
флипом, а во вторую страницу залить `gfx_copy_rect(…, GFX_COPY_DIRECT)`.
- сейчас `enter_room_side` рисует комнату дважды (цикл `for (pg = 0; pg < 2)`)
— вторая отрисовка (~полмиллиона тактов) меняется на копию (~115-200 К);
- процесс рисования перестаёт быть виден: игрок видит только готовый кадр;
- делается тем же `gfx_copy_rect`, что и I.3, то есть ничего дополнительно
писать не надо.
Проверять отдельно: состояние ОЗУ-копии второй страницы после копии
(инвариант из I.4 п.5), метки «фон трогали», и что heal на второй странице
работает.
---
# Порядок работ
1. I.0 разведка (`tests/accflip`) → факты в docs + memory.
2. I.1 ядро + I.3 `gfx_copy_rect` (+ `tests/pagecopy`).
3. II.1 состояние и `pop_flip_screen` — уже можно посмотреть в MAME:
картинка обязана перевернуться целиком, дальше она «поедет» по мере
перерисовок (это ожидаемо на этом шаге).
4. Часть III (ENTER-ROOM-FAST) — дешёвый выигрыш на том же кирпиче,
заодно обкатывает копию в бою.
5. I.2 vflip-блиты (+ `tests/blitvflip`) → II.3 фон. После этого сцена
должна выглядеть правильно во всём, кроме персонажей.
6. UI-SPRITES (деления HP в резидент) → II.4 зеркальные кадры персонажей.
7. II.5 клип → II.6 проверка и регресс.
Критерий готовности каждого шага — артефакт (побайтный тест в MAME либо
скриншот сцены), а не «код написан».
# Риски
- **Р1 не подтвердится** (нельзя менять Y между триггерами) — переворот
пойдёт через ОЗУ-буфер строки, бюджет вырастет вдвое (всё ещё ≈0.5
логического кадра), схема в целом выживает;
- **двойной переворот координат** — самый вероятный баг: часть кода зеркалит
`y` сама, часть получает уже зеркальный. Лечится правилом из II.2 и тем,
что зеркалит только вызывающий;
- **кэш зеркальных кадров и память**: 34 страницы EMM в худшем случае; по
`sprinter_emm_budget` на старте свободно 215 — влезает, но проверить
фактический остаток на уровне 9 (там уже загружены атласы фона, Кида,
стража и уровень);
- **fore-слой поверх персонажа**: он рисуется по футпринту спрайта, а
футпринт после переворота другой — проверить именно на сцене с колоннами.
# Открытые вопросы (ответы пользователя нужны до II.4)
1. Стратегия наполнения кэша зеркальных кадров: ленивый постраничный (по
первому обращению), разом при входе на уровень с зельем типа 4, или офлайн
в упаковщиках.
2. Время жизни кэша: освобождать страницы при снятии эффекта / при выходе с
уровня / держать до конца программы.
3. ENTER-ROOM-FAST — делать сразу (шаг 4) или отложить до закрытия L9-INVERT.