From e11877f475feb6194111cf784f28db050b9d6dac Mon Sep 17 00:00:00 2001 From: Alexander Petrov Date: Wed, 12 Aug 2026 16:12:33 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20L9-INVERT=20(docs/?= =?UTF-8?q?l9=5Finvert=5Fplan.md)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разведка железа первым шагом (смена Port_Y между burst-триггерами и адресация двух страниц в W3), ядро копии с реверсом Y, vflip-блиты для row-major, gfx_copy_rect экран-экран; в приложении — состояние, единая точка пересчёта Y, фон через pop_tile.c, зеркальные кадры персонажей, клип. Побочной задачей ENTER-ROOM-FAST на том же кирпиче. Co-Authored-By: Claude Opus 5 --- applications/PoP/docs/l9_invert_plan.md | 303 ++++++++++++++++++++++++ applications/PoP/roomtest/TASKS_OPEN.md | 4 + 2 files changed, 307 insertions(+) create mode 100644 applications/PoP/docs/l9_invert_plan.md diff --git a/applications/PoP/docs/l9_invert_plan.md b/applications/PoP/docs/l9_invert_plan.md new file mode 100644 index 0000000..4045541 --- /dev/null +++ b/applications/PoP/docs/l9_invert_plan.md @@ -0,0 +1,303 @@ +# 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. diff --git a/applications/PoP/roomtest/TASKS_OPEN.md b/applications/PoP/roomtest/TASKS_OPEN.md index 17fa25f..bc82caf 100644 --- a/applications/PoP/roomtest/TASKS_OPEN.md +++ b/applications/PoP/roomtest/TASKS_OPEN.md @@ -50,6 +50,10 @@ ### L9-INVERT. Зелье ПЕРЕВОРОТА (уровень 9, комнаты 7 и 10) +> **Подробный план реализации — [`../docs/l9_invert_plan.md`](../docs/l9_invert_plan.md)** +> (задачи libbgi, задачи приложения, порядок работ, риски, открытые вопросы). +> Ниже — механика и обоснование решений; план исполняется по документу. + Единственное новое на уровнях 9-11: зелья типа 4 на `(1,7)` комнаты 7 и `(0,4)` комнаты 10 (сверено по `res2009.bin`). Уровни 10 и 11 не приносят ни тайлов, ни спецсобытий — только дворцовый тайлсет (есть) и стражи skill 3-4.