Files
Sprinter-SDCC/applications/PoP/docs/l9_invert_plan.md
T

28 KiB
Raw Blame History

L9-INVERT — план реализации зелья переворота (уровень 9)

Рабочий план, по которому задача делается с чистого контекста. Доска — ../roomtest/TASKS_OPEN.md#l9-invert; правила подпроекта — ../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 Копия прямоугольника экран→экран

#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 до начала 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 Геометрия — единая точка пересчёта

/* 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 растрового кадра на переворот.

Стратегия наполнения — ЛЕНИВЫЙ ПОСТРАНИЧНЫЙ (решение пользователя 2026-08-12): страница переворачивается при первом обращении к ней в перевёрнутом режиме. Реально в ходу 5-10 страниц из 34, и если игрок зелье не трогал, не тратится ни такта, ни страницы.

Время жизни кэша — ДО КОНЦА УРОВНЯ: освобождаем страницы при смене уровня, а не при снятии эффекта. На уровне 9 переворот случается минимум дважды (второе зелье возвращает всё назад), плюс его снимает смерть Кида — готовить пачку заново каждый раз было бы обидно.

После 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 — СДЕЛАНО 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.