# 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 РАЗВЕДКА ЖЕЛЕЗА — ЗАКРЫТА 2026-08-12 **Оба факта подтверждены, ядро и обёртка написаны и проверены в MAME** (`tests/pageflip`, 5/5 PASS — прямая копия, vflip, целость рамки, широкая копия 320 = два прохода, неприкосновенность источника). - **Р1 подтверждён**: Port_Y можно менять между read- и write-триггером, если ПЕРЕД вторым OUT стоит STOP (`LD B,B`) — он разоружает accel, и fetch immediate-операнда OUT уже безопасен. Приём не новый: ровно так работает `_bgi_scroll_cols_raw` (ядро `gfx_scroll_v`), то есть он был в проде задолго до этой задачи — указал пользователь. Значит и обходной путь через ОЗУ-буфер не нужен; - **Р2 подтверждён**: обе страницы адресуемы одновременно, копия между ними идёт по разнице баз (`_gfx_addr_shadow_base` / `_gfx_addr_base`), строку выбирает Port_Y. Сделано: `libbgi/bgi256/_bgi_flip_rows_raw.c` (ядро) + `libbgi/common/gfx_copy_page.c` (обёртка, режимы `GFX_COPY_DIRECT` / `GFX_COPY_VFLIP`) + `tests/pageflip`. Пункты I.1 и I.3 ниже — ЗАКРЫТЫ этим же коммитом; описание оставлено как контракт. ### Исходный текст разведки (для истории) Два факта, на которых стоит вся схема, сейчас НЕ подтверждены артефактом. Пока их нет, остальные пункты не начинать (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 — СДЕЛАНО 2026-08-12 Ассемблера не потребовалось вовсе: у row-major картинки вертикальное зеркало — это порядок строк, а он задаётся ЗНАКОМ src-страйда, который `_bgi_blit_rows_raw` и так принимает знаковым (`int sstride`) и патчит SMC в adc-цепочку. Обёртки просто дают адрес ПОСЛЕДНЕЙ строки и `-stride`, цена кадра та же, что у обычного блита. Написаны `gfx_blit_noclip_vflip`, `gfx_blit_part_noclip_vflip`, `gfx_blit_part_vflip`; `tests/pageflip` расширен до 8 проверок, все PASS. Грабли, которые поймал тест: у клипающего варианта первая строка источника обязана считаться от высоты ДО клипа (`h0`), иначе обрезка сверху экрана сдвигает картинку — сначала формула брала уже укороченную `h`. ### Контракт (исходное описание) Все 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 Состояние и момент переключения — СДЕЛАНО 2026-08-12 Проверено в MAME (уровень 9, комната 7): нажатие чита переворачивает всю комнату целиком за один кадр, повторное — возвращает. Персонажи и анимируемые тайлы (факелы) пока рисуются НЕ зеркально — это шаги II.3/II.4. Что появилось: - `pop_upside` + `pop_upside_dirty` (порт `upside_down` и `need_redraw_because_flipped`) в `pop_map.c`; - ветка `case 4` в `pop_proc_get_object`: только тоггл, без вспышки и урона (как seg006:1885); - сброс в `pop_start_level` (seg003:38/188) и по смерти Кида (seg000:1224); - `pop_flip_screen()` в банке 8: `gfx_copy_page(VFLIP)` в скрытую страницу → показать её → `gfx_copy_page(DIRECT)` во вторую → инвалидация слотов персонажей, метки «фон трогали» и полосы HP; - **чит-клавиша U** — это ПОРТ, а не костыль: в оригинале переворот тоже висит на чит-клавише (seg000:0793). Без неё эффект проверяется только честным проходом уровня 9 до комнаты 7 — чит-телепорт до зелья не дотягивается (оно на `(1,7)`, а навигация ставит Кида на нижний ряд). Цена: резидент +602 Б (новые функции libbgi линкуются в него), куча 1707 → 1050 Б. Это аргумент за [MEM-COLD2](../roomtest/TASKS_OPEN.md#mem-cold2) до начала II.4. ### Контракт (исходное описание шага) - `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 Фон — СДЕЛАНО 2026-08-12 Флаг проведён в ЕДИНСТВЕННУЮ точку — `pop_blit_b`/`blit_b_clip` (`pop_tile.c`): позиция пересчитывается макросом `FLIP_TOP` (он один на весь слой — дублировать нельзя, иначе двойной переворот), а блит выбирает vflip-двойник. Тем самым зеркалятся и полная отрисовка комнаты, и точечные редрои, и анимации trob, и fore-слой, и кладка — все они ходят через эту функцию. Тонкость клипованного пути: при зеркале обрезка СВЕРХУ экрана съедает НИЖНИЕ строки источника, поэтому кусок пересчитывается как `sy' = h - sy - dh`. Проверено в MAME (уровень 9, чит U): комната перевёрнута целиком — пол вверху, дверь уровня и кладка вверх ногами. Персонажи пока обычные (II.4). Цена: три функции libbgi ушли в резидент, куча 1574 → 450 Б, поэтому тем же заходом сделан **MEM-COLD2 п.1** — `enter_room_side`/`enter_room` уехали в банк 8 (куча вернулась к **1231 Б**, банк занят на 14.6%). Рабочие массивы комнаты остались в `_DATA` и видны обеим половинам; `enter_room` обязана быть `__banked` — её зовёт главный цикл. ### Контракт (исходное описание) Одна точка: `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 растрового кадра на переворот. **Стратегия наполнения — ЛЕНИВЫЙ ПОСТРАНИЧНЫЙ** (решение пользователя 2026-08-12): страница переворачивается при первом обращении к ней в перевёрнутом режиме. Реально в ходу 5-10 страниц из 34, и если игрок зелье не трогал, не тратится ни такта, ни страницы. **Время жизни кэша — ДО КОНЦА УРОВНЯ**: освобождаем страницы при смене уровня, а не при снятии эффекта. На уровне 9 переворот случается минимум дважды (второе зелье возвращает всё назад), плюс его снимает смерть Кида — готовить пачку заново каждый раз было бы обидно. После [UI-SPRITES](../roomtest/TASKS_OPEN.md#ui-sprites) страницы `kid27` и `g0` станут чисто «полевыми» (деления HP уедут в константы резидента), и исключений в кэше не останется — эту задачу логично сделать ДО II.4. ## II.5 Клип — ЧАСТЬ СДЕЛАНА 2026-08-12 (поле), остаётся clip_char **Клип ПОЛЯ сделан.** Симптом (нашёл пользователь): после телепорта в перевёрнутом виде ниже поля — мусор. Причина: спрайты, которые в обычном виде торчат ВЫШЕ поля (полоса кладки у потолка, её режет `pop_t_clip_top`), после отражения торчат НИЖЕ и лезут в борт, где живёт полоса HP. Фикс в `blit_b_clip`: при перевороте режем по ОБЕИМ границам поля всегда, а не по `pop_t_clip_top` — зеркальный спрайт может вылезти и там, где в обычном виде клип не ставили. Плюс в `pop_blit_b` быстрый путь (без клипа) теперь пропускает в клипующий всё, что выходит за поле. Проверено в MAME: телепорт по комнатам в перевёрнутом виде — борта чистые, комнаты рисуются зеркально (факелы, чомперы, пол). То есть **переходы между комнатами в перевёрнутом виде работают**. Осталось из этого пункта: `clip_char` (верх поля ↔ низ) и клип падающей плиты — они про ПЕРСОНАЖЕЙ и падающие объекты, то есть идут вместе с II.4. ### Контракт (исходное описание) - `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 — СДЕЛАНО 2026-08-12 Комната теперь рисуется ОДИН раз — в скрытую страницу, — тут же показывается флипом, а вторая страница получает её `gfx_copy_page(DIRECT)`. Процесс рисования тайлов больше не виден: игрок получает готовый кадр. Проверено в MAME: старт уровня и чит-переход между комнатами, два последовательных снимка идентичны (обе страницы синхронны, мерцания нет). Побочно понадобилось: видимую страницу главный цикл больше не ведёт сам, а перечитывает у железа в начале кадра (`front = gfx_get_visible_page()`) — её меняют и вход в комнату, и переворот, причём оба из банка, где локальные переменные `main` недоступны. # Часть III (исходное описание) Идея пользователя, самостоятельная ценность (сейчас при входе в комнату видно, как рисуются тайлы): рисовать комнату ОДИН раз в скрытую страницу, показать её флипом, а во вторую страницу залить `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-слой поверх персонажа**: он рисуется по футпринту спрайта, а футпринт после переворота другой — проверить именно на сцене с колоннами. # Решения пользователя (2026-08-12) 1. **Кэш зеркальных кадров — ленивый постраничный**, наполняется по первому обращению (см. II.4). 2. **Живёт до конца уровня**, освобождается при смене уровня. 3. **ENTER-ROOM-FAST делается сразу**, шагом 4 — на готовом `gfx_copy_rect`, до того как на копию завяжется переворот. Открытых вопросов не осталось: план исполняется как есть, новые развилки — только если разведка I.0 даст отрицательный результат по Р1.