Files
Sprinter-SDCC/docs/sprite-api-design.md
T
snark13 72ce66275e libbgi: спрайтовый движок v2 + accel-блит/heal leaf'ы + noclip-путь
Спрайтовая графика поверх accel block-copy (docs/sprite-api-design.md):

- Ядро блиттинга: leaf'ы _bgi_blit_rows_raw (dst фикс, только src-страйд) /
  _bgi_copy_rows_raw (getimage) / _bgi_heal_rows_raw (src==dst). DI один на
  спрайт (санкция: малый спрайт под одним DI аудио не рвёт); src[0]-фикс
  снят (точная MAME подавляет CPU-байт триггера записи — на железе
  перепроверить; для heal был избыточен и снят безусловно).
- Общие bracket-free ядра _gfx_blit_full/_gfx_heal_full (полная ширина:
  клип по экрану + split >256 для putimage) + лин _gfx_blit_sprite/
  _gfx_heal_sprite (кадр ≤64, без split, 8-бит w/h) + noclip-варианты
  (клип-кода нет → полный codegen-win).  Имя *_full (не *_clip) — «clip»
  двусмысленно (sprite-ядра тоже клипуют; различитель — ширина/split).
- Движок retained-модели <sprite.h>: sprite_init/update/flip + inline
  move/frame/show/hide/touch; drawn[2] per-page внутри структуры; кадр —
  двухпроходно heal ВСЕ -> блит ВСЕ под одной W3-скобкой/банком на проход.
- Флаг gfx_sprite_clip(on/off): приложение, само следящее за границами,
  отключает клип (~+19% на анимации; диспетч пока через if — funcptr далее).
- putsprite/movesprite/gfx_blit/putimage(COPY)/getimage переведены на ядро.

Тесты: examples/balls (движок, дабл-буфер, boundary-тест клипа),
tests/sprites (RAM PASS, клип 4 края, атлас), tests/blitw (trig-leak),
tests/spriteclip (hardware-probe: железо НЕ режет за краем -> клип нужен),
tests/blitperf, tests/gfxbanks. size-baseline обновлён (53 программы).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:51:52 +03:00

44 KiB
Raw Blame History

Спрайтовое расширение BGI — дизайн

Статус: дизайн на ревью (2026-07-11). Реализация не начата.

Расширение libbgi для спрайтовой графики на аппаратных подрежимах видеостраниц #50..#5F + переписывание putimage/getimage через акселератор (одно ядро на всё).

Зафиксированные решения (обсуждены 2026-07-11):

  1. Два слоя: быстрое ядро в <gfx.h> (gfx_*), тонкие BGI-обёртки в <graphics.h> (putsprite/movesprite, ускоренный putimage).
  2. Примитивы + move-хелпер, без managed-движка (таблица спрайтов, z-order — «следующая версия», см. §9).
  3. Формат данных — getimage (uint16 w, uint16 h, пиксели построчно). Единственный in-memory формат; атласы — через блит под-прямоугольника, а не через новый формат.
  4. Курсор мыши — пример + MAME-тест на новых примитивах, НЕ в библиотеке.

1. Аппаратная база

1.1 Подрежимы вывода (Иван Мак §4.3, Architecture)

Страницы #50..#5F — графическая видео-область. Биты 3..0 номера страницы на АДРЕС не влияют (адрес задают PORT_Y 0x89 + 10 младших бит CPU-адреса); биты 2 и 3 задают независимые подрежимы ЗАПИСИ. Биты 0 и 1 обязаны быть 0 (зарезервированы под будущие конфигурации).

Банк bit3 bit2 Семантика записи
0x50 0 0 обычная: в видео-ОЗУ и в ОЗУ-копию
0x58 1 0 байт 0xFF не записывается вообще (прозрачность)
0x54 0 1 только в видео-ОЗУ; ОЗУ-копия не трогается
0x5C 1 1 оба эффекта — режим подвижного спрайта

Ключевые факты:

  • Прозрачность (bit3) — на пути ЗАПИСИ. «В процессе записи проверяется, не равен ли записываемый байт значению #FF. Если равен, то запись не производится» (официальный док). Проверку делает ПЛМ — блиттеру не нужно смотреть ни одного байта. ОТКРЫТЫЙ вопрос (док молчит): как видеокарта ОТОБРАЖАЕТ байт FF, реально попавший в VRAM (записанный через банк без bit3) — как обычный цвет 255 или подставляет фон из ОЗУ-копии? Проверяется шагом 3 теста Фазы 0; от ответа зависит возможность дешёвого стирания FF-заливкой (tests/fferase).
  • Чтение из #50..#5F всегда возвращает ОЗУ-копию — видео-ОЗУ write-only для CPU. Отсюда «heal»: в банке 0x50 прочитать прямоугольник (придёт фон из ОЗУ) и записать обратно (уйдёт в видео-ОЗУ) — фон под спрайтом восстановлен без save-буфера.
  • Подрежимы комбинируются с акселератором (то же оборудование ПЛМ); это их заявленное назначение — «ускорение работы со спрайтовой графикой». Подтвердить в MAME — Фаза 0.

1.2 Accel block-copy (accelerator_doc.txt)

Режим LD L,L (горизонтальная копия): после задания размера блока (LD D,D + immediate у LD A,n — SMC, см. memory/sprinter_accelerator) триггер LD A,(HL) burst-читает блок в память акселератора, триггер LD (DE),A burst-пишет его по DE. До 256 байт за выстрел, источник и приёмник — любое ОЗУ 0x0000..0xBFFF + видеоокно (не ПЗУ/FastRAM). Размер блока переживает LD B,B (подтверждено tests/accfill) — задаём один раз на блит, дальше по паре триггеров на строку.

Канонический референс — Draw_Restangle_Data из accelerator_doc.txt: источник (строки подряд, шаг = ширина), приёмник — один и тот же CPU-адрес, строку выбирает PORT_Y (инкремент на строку). Это ровно layout формата getimage — совпадение формата и железа буквальное.

2. Одно ядро на четыре операции

Вся разница между операциями — какой банк в W3 и куда смотрят src/dst:

Операция src → dst Банк W3
putimage(COPY) буфер → экран текущий (0x50)
putsprite буфер → экран 0x5C
getimage экран (ОЗУ-копия) → буфер любой
gfx_heal экран → экран (src == dst) 0x50 (форс)

Отдельного «спрайтового блиттера» нет: прозрачный блит — это обычный блит с банком 0x58/0x5C. heal — это блит, у которого src-указатель лежит в самом видеоокне (чтение вернёт ОЗУ-копию) и совпадает с dst; шаг строк источника = 0, потому что строку и там и там выбирает один PORT_Y.

3. Публичный API

3.1 <gfx.h> — ядро

/* Семантические имена банков для gfx_set_bank (значения — номер
 * страницы W3; биты 0,1 всегда 0 — резерв конфигураций): */
#define GFX_BANK_NORMAL      0x50  /* запись в видео-ОЗУ + ОЗУ-копию   */
#define GFX_BANK_NOSHADOW    0x54  /* только видео-ОЗУ (врем. вывод)   */
#define GFX_BANK_TRANSPARENT 0x58  /* байт 0xFF не записывается        */
#define GFX_BANK_SPRITE      0x5C  /* NOSHADOW + TRANSPARENT           */

#define GFX_TRANSPARENT      0xFF  /* прозрачный цвет (256-режим: пиксель;
                                      16-режим: ПАРА пикселей цвета 15) */

/* Блит картинки getimage-формата в (x,y) ТЕКУЩИМ банком (gfx_set_bank).
 * Клиппинг по экрану есть (в отличие от putimage Turbo C).
 * Требование: img вне W3 (адрес < 0xC000) — W3 занят видеобанком. */
void gfx_blit(int x, int y, const void *img);

/* Блит под-прямоугольника картинки (атлас кадров): прямоугольник
 * (sx,sy,w,h) внутри img выводится в (x,y).  Под-прямоугольник обязан
 * лежать внутри img (по экрану — клиппится). */
void gfx_blit_part(int x, int y, const void *img,
                   int sx, int sy, int w, int h);

/* Восстановить прямоугольник экрана из ОЗУ-копии (стирание спрайта/
 * оверлея).  Всегда работает банком 0x50 независимо от gfx_set_bank.
 * Клиппится по экрану. */
void gfx_heal(int x, int y, int w, int h);

Заметки по семантике:

  • gfx_blit* уважают текущий gfx_set_bank — так же, как уже сегодня его уважают ВСЕ примитивы через _bgi_begin (т.е. gfx_set_bank (GFX_BANK_NOSHADOW); line(...) — легальный способ рисовать временную линию-перекрестье). Спрайтовые обёртки (§3.2) ставят банк сами на время вызова и восстанавливают.
  • Клиппинг в ядре всегда, в т.ч. в fast-версии: это не валидация параметров, а функциональность (спрайты штатно уходят за края). Цена — один расчёт на блит, не на пиксель.
  • Цвет 255 через 0x58/0x5C нарисовать нельзя — он и есть прозрачный. В initgraph палитра 0..15 занята EGA, 255 рекомендуется не занимать.

3.2 <graphics.h> — BGI-обёртки

/* Вывести спрайт (getimage-формат, 0xFF = прозрачно) в (x,y).
 * Банк на время вызова — GFX_BANK_SPRITE (0x5C): прозрачные точки не
 * пишутся, фон в ОЗУ-копии не портится → стирается gfx_heal'ом /
 * movesprite'ом.  Клиппится по экрану. */
void putsprite(int x, int y, const void *img);

/* Переместить спрайт: heal прямоугольника (oldx,oldy,w,h) по размерам
 * img + putsprite в (x,y).  Порядок heal→draw; перекрытие старой и
 * новой позиций безопасно (heal берёт фон из ОЗУ-копии). */
void movesprite(int oldx, int oldy, int x, int y, const void *img);

putimage/getimage/imagesize — сигнатуры и формат без изменений, putimage(COPY_PUT) и getimage переезжают на accel-ядро (§5).

Хотспот и кадры атласа НЕ кодируются в данных — это аргументы вызова: putsprite(x - HOT_X, y - HOT_Y, img); кадр N — gfx_blit_part(x, y, sheet, n*FRAME_W, 0, FRAME_W, FRAME_H) (банк выставить GFX_BANK_SPRITE вокруг — или см. §9 про putsprite_part).

4. Внутренности

4.1 Leaf-примитив (per-driver, asm)

Один новый leaf в bgi256/ (и позже bgi16/), объявление в _bgi.h:

/* Копирование h строк по w пикселей через accel block-copy.
 * dst/src — CPU-адреса ПЕРВОЙ строки; dstride/sstride — шаг адреса
 * между строками (0 = адрес не двигается, строку выбирает PORT_Y —
 * сторона, живущая в видеоокне); y0 — стартовый PORT_Y (инкремент
 * на строку).  Raw: без клиппинга, W3 уже замаплен (_bgi_begin),
 * вызывающий гарантирует 1 <= w <= 256 (полоса).
 * DI/EI — вокруг каждой пары триггеров (как в fill-сегментах). */
void _bgi_copy_rows_raw(uint8_t *dst, const uint8_t *src,
                        uint8_t w /*0=256*/, uint8_t h /*0=256*/,
                        int dstride, int sstride, uint8_t y0);

Схема тела (референс — Draw_Restangle_Data + существующий SMC-паттерн _gfx_hfill256_segment):

  SMC: размер блока <- w  (LD D,D ; LD A,#imm ; LD B,B — один раз)
  loop h раз:
      out (0x89), y ; y++
      di
      LD L,L            ; режим копии
      LD A,(HL)         ; burst-чтение src
      LD (DE),A         ; burst-запись dst
      LD B,B            ; стоп
      ei
      HL += sstride ; DE += dstride

Один leaf покрывает все четыре операции §2:

Вызов dst, dstride src, sstride
блит экран (base+x), 0 буфер (row0), img_w
getimage буфер, w экран (base+x), 0
heal экран (base+x), 0 тот же адрес экрана, 0

Регистровая раскладка — на этапе реализации; ориентир: HL=src, DE=dst внутри цикла (триггеры именно такие), w через SMC, остальное со стека в локальные регистры/IX (вызывается один-два раза на блит — не горячий ABI, в отличие от fill-сегментов).

4.2 Ядро (common/)

gfx_blit_part — единственная «умная» функция (реальная логика):

  1. прочитать w,h из заголовка img (для gfx_blit: весь rect);
  2. клиппинг: x<0 → сдвиг sx и сужение w; правый/нижний край → сужение; пустой результат → выход;
  3. src0 = img + 4 + sy*img_w + sx;
  4. полосы: если w > 256 — разрезать на вертикальные полосы ≤ 256 байт (при экране 320 полос максимум две; каждая полоса = один SMC размера блока);
  5. _bgi_begin()_bgi_copy_rows_raw(...) на полосу → _bgi_end().

Остальное — тонкие модули (1 функция = 1 .rel):

  • gfx_blit.c — читает w,h, зовёт gfx_blit_part(x,y,img,0,0,w,h);
  • gfx_heal.c — клип; банк: сохранить _gfx_bank, форс 0x50, _bgi_begin; leaf с dst=src=base+x, strides 0; восстановить банк;
  • putsprite.c — сохранить _gfx_bank, | → 0x5C, gfx_blit, восстановить (именно save/restore, а не тупо 0x50 — уважаем вложенность и пользовательский temp-режим);
  • movesprite.cgfx_heal(oldx,oldy,w,h) (w,h из заголовка img) + putsprite(x,y,img).

4.3 Совместимость с существующим кодом

  • _bgi_begin/_bgi_end не меняются (банк уже берут из _gfx_bank).
  • gfx_set_bank/gfx_get_bank не меняются; в gfx.h добавляются только GFX_BANK_*/GFX_TRANSPARENT и три прототипа.
  • Довесок к контракту (задокументировать у putimage/getimage тоже): буферы картинок обязаны лежать вне W3 (< 0xC000) — на время операции W3 замаплен на видеобанк. Это верно и сегодня (per-pixel путь), просто не было записано.
  • Второй draw-page: ядро использует _gfx_addr_base — двойная буферизация работает автоматически.

5. Переезд putimage/getimage на ядро

  • putimage(COPY_PUT)gfx_blit (текущим банком — поведение обратно-совместимо: дефолтный банк 0x50).
  • getimage_bgi_copy_rows_raw (dst=буфер). Клиппинга нет, как в BGI (контракт: rect валиден); safe-версия сохраняет текущие проверки.
  • XOR/OR/AND/NOT_PUT — остаются на per-pixel пути (v1). У акселератора есть блочные AND/OR/XOR (пример «encode» в accelerator_doc.txt) — ускорение этих op — «следующая версия» (§9).
  • imagesize — без изменений (256-режим); для 16-режима станет mode-specific (§8).

Ожидание по скорости: per-pixel путь платит на КАЖДЫЙ пиксель out Port_Y + пересчёт адреса + bounds-check (~5080Т); ядро платит на СТРОКУ ~40–60Т CPU + burst акселератора (~байт/7МГц). На спрайте 16×16 это порядка 10–20× (замерить в Фазе A, добавить в size-baseline).

6. Паттерны использования (войдут в libc-reference)

Подвижный спрайт (канон):

initgraph();
/* фон рисуем обычным банком — он попадает и в ОЗУ-копию (бэкап) */
draw_background();
putsprite(x, y, hero);                 /* 0x5C: фон в ОЗУ цел */
while (game) {
    int nx = x + dx, ny = y + dy;
    gfx_wait_vsync();
    movesprite(x, y, nx, ny, hero);    /* heal старого + блит нового */
    x = nx; y = ny;
}

Курсор мыши (пример examples/, не API): то же самое с GFX_BANK_SPRITE; фон под курсором живёт в ОЗУ-копии, никакой getimage/буфер не нужен. Перерисовка — из главного цикла по mouse_getxy().

Впечатать спрайт в фон навсегда (декорация): gfx_set_bank(GFX_BANK_TRANSPARENT); gfx_blit(...); gfx_set_bank(GFX_BANK_NORMAL); — 0x58 без bit2: спрайт уходит и в ОЗУ-копию, heal его уже «не сотрёт». ВНИМАНИЕ: на MAME 0.283 этот паттерн ломается — FF-байты спрайта попадают в ОЗУ-копию (частичный скип, см. результаты Фазы 0); до подтверждения на железе печатать декорации без FF в данных.

ВАЖНО про cleardevice/bar поверх спрайтов: пока действует банк с bit2 (0x54/0x5C), «фоновые» операции не обновляют ОЗУ-копию. Правило: сцена/фон — только обычным банком (или 0x58), спрайты/оверлеи — 0x5C.

7. План работ

  • Фаза 0 — верификация подрежимов (tests/gfxbanks). ВНИМАНИЕ: поведение подрежимов в MAME может отличаться от реального устройства (прецедент — Port_Y banking, memory/gfx_port_y_banking). Результат в MAME НЕ финален: тест гоняется и в MAME, и на железе; до прогона на железе вердикты считаются предварительными. Последовательный сценарий на одном экране, скриншот после каждого шага (палитра[255] = синий; фон — узнаваемый паттерн, не заливка):

    1. Фон через 0x50 (уходит и в VRAM, и в ОЗУ-копию).
    2. Спрайт через 0x5C (bit2+bit3): квадрат, внутри зелёный круг, вокруг круга — байты 0xFF. Ожидание по доку: виден круг поверх фона, FF-точки скипнуты (фон вокруг круга цел). Проверяет bit3-скип на CPU-пути; если вместо фона вокруг круга синяя рамка — bit3 не эмулируется/не работает.
    3. Ключевой шаг — FF реально в VRAM: через 0x54 (bit2, БЕЗ bit3 — скипа нет, FF пишется) залить квадрат байтом 0xFF поверх круга. Что отображается в квадрате:
      • синий → FF в VRAM — обычный цвет 255, подстановки при отображении нет (семантика дока полная) → стирание только heal;
      • фон → видеокарта подставляет байт из ОЗУ-копии, когда в VRAM лежит FF → «слой спрайтов» существует на уровне отображения, дешёвое стирание FF-заливкой работает (tests/fferase становится штатным путём);
      • круг остался → запись через 0x54 не произошла — bit2/банк не эмулируется.
    4. Heal через 0x50 (чтение+запись того же rect): фон должен восстановиться полностью. Заодно различает bit2: если bit2 не работал, шаги 2–3 испортили ОЗУ-копию и heal вернёт не фон.
    5. Accel-путь: повторить шаги 2–3 записью через акселератор (copy/fill) — работает ли bit3-скип и поведение FF на burst-пути. Если MAME не эмулирует биты 2/3 — API остаётся как есть (семантика задана железом/доком), но функциональные тесты уезжают в раздел «проверить на железе» TODO, а пример курсора делаем без bit2 (fallback: save/restore через getimage).

    РЕЗУЛЬТАТЫ (MAME 0.283 = сборка v306, 2026-07-11; железо — TODO):

    Вопрос Вердикт в MAME
    bit2 (0x54/0x5C): ОЗУ-копия не трогается РАБОТАЕТ (A: PASS, B: PASS)
    bit3 через 0x5C: скип FF РАБОТАЕТ, полный no-op (CPU и accel)
    bit3 через 0x58: скип FF ЧАСТИЧНО: VRAM скипается, но теневое ОЗУ ПОЛУЧАЕТ FF (C: FAIL) — код драйвера 0.283 гасит только vram_w; в master-драйвере исправлено на полное подавление (по доку)
    Отображение FF в VRAM (шаг 3) обычный цвет 255 (синий), подстановки ОЗУ нет → стирание = heal; FF-заливка НЕ работает
    heal (чтение ОЗУ + запись 0x50) РАБОТАЕТ (полосы восстановлены; FF-порчу ОЗУ честно переносит в VRAM)
    accel-путь vs CPU-путь идентичны во всех подрежимах (в MAME оба через ram_w)

    Следствия: (1) putsprite через 0x5C работает одинаково во всех версиях — расхождение 0.283 не задевает; (2) паттерн «впечатать декорацию через 0x58» на 0.283 портит ОЗУ-копию FF-байтами спрайта — использовать только после проверки на железе; (3) tests/fferase в MAME заведомо FAIL — остаётся для прогона на железе. Квирк инфраструктуры: delayms/sleep в --memory small не работают (irq_install → EINVAL, калибровка молча не происходит) — паузы в тесте сделаны по RTC (getdatetime).

  • tests/fferase — альтернативное стирание FF-заливкой через 0x54 (существует ДО подтверждения итога шага 3 и на MAME, И на железе; после — либо удаляется, либо становится основой оптимизации). Сценарий: фон → спрайт через 0x5C → залить прямоугольник спрайта байтом 0xFF через 0x54 (bit3=0 — FF реально пишется в VRAM) → скриншот: восстановился ли фон. Если да (на железе!) — стирание вдвое дешевле heal (fill-burst без фазы чтения, готовый _gfx_rectfill256), реализацию gfx_heal можно переключить, сигнатура и movesprite не меняются. Основной механизм move в любом случае — heal (чтение/запись через 0x50): он работает при ОБЕИХ семантиках.

  • Фаза A — ядро + переезд (без нового публичного API, чистое ускорение): _bgi_copy_rows_raw (bgi256), gfx_blit_part + полосы + клиппинг, порт putimage(COPY)/getimage. Регресс — tests/bgitest + make size-check.

    ГОТОВО 2026-07-11: leaf bgi256/_bgi_copy_rows_raw.c (регистры BC = счётчик строк/y, stride-сложения через push bc; SMC размера блока один раз на вызов), ядро common/gfx_blit_part.c (клиппинг всегда, sy без __mulint — циклом сложений, полосы ≤256), putimage (COPY_PUT → ядро; XOR/OR/AND/NOT — прежний per-pixel путь) и getimage (grab-leaf, dstride = w) переведены. Прототип gfx_blit_part пока в _bgi.h (Фаза B перенесёт в gfx.h как есть). Проверено в MAME: tests/bgi_img финальный кадр 1:1 с per-pixel эталоном (src == COPY, XOR×2 чист); tests/gfxbanks — прозрачность FF и bit2 работают на accel-COPY пути идентично CPU-пути (круг с прозрачными полями через putimage/0x5C, FF→VRAM через putimage/0x54). Размер: bgi_img +592 Б (_CODE; leaf + клиппинг-ядро + ветка COPY), baseline принят. Попутно: pgrep-фильтр в mame_interactive.py сужен до эмулятора (ложно срабатывал на параллельную сборку MAME из исходников).

  • Фаза B — спрайтовый API: GFX_BANK_*, gfx_blit, gfx_heal, putsprite, movesprite; тест tests/sprites (анимация по синусоиде поверх пёстрого фона, скриншоты «фон не разрушен»).

    ГОТОВО 2026-07-11: константы GFX_BANK_*/GFX_TRANSPARENT и gfx_blit/gfx_blit_part/gfx_heal — в gfx.h; putsprite/movesprite — в graphics.h; модули common/gfx_blit.c, gfx_heal.c, putsprite.c, movesprite.c (тонкие, поверх ядра Фазы A). putsprite/gfx_heal сохраняют и восстанавливают пользовательский банк (heal — форс 0x50, putsprite — форс 0x5C на время вызова). Проверено в MAME (tests/sprites): прозрачные углы, клиппинг во все 4 края, heal-стирание (accel src==dst — риск §10.4 закрыт), movesprite 12 шагов с чистым следом, кадр атласа через gfx_blit_part; ОЗУ-копия цела (проверки A/B PASS). size-check: роста существующих программ нет (новые модули тянутся только пользователями API).

    Демо examples/balls (2026-07-11): 8 разноцветных шаров 16×16 (по спрайту на цвет, прозрачные углы) над чёрно-белой шахматкой; скорости 1..4 привязаны к кадрам (gfx_wait_vsync, speed = кадров на шаг: 50/25/~17/12.5 px/с — подтверждено покадровыми скриншотами), отражение от краёв, Esc — выход. Двойная буферизация по образцу docs/samples/balls (реф. asm-демо Sprinter): рисование только на скрытой странице (heal ВСЕХ старых позиций этой страницы → блит ВСЕХ шаров), флип по vsync — одиночная страница мерцала (heal+блит на видимой ловились лучом; пользователь заметил, реф не мерцает). Требует зеркалирования палитры в палитру 1 (initgraph грузит EGA только в 0) и координат drawn[2][N] per-page. Побочно ушли и одно-кадровые артефакты перекрытий/снапшотов.

  • Фаза C — пример курсора (examples/): мышь + putsprite/gfx_heal, он же живой тест temp-режима.

  • Документация: libc-reference (раздел «Спрайты»), обновить TODO.

8. Режим 16 цветов (Фаза 2 libbgi — заметки на будущее)

  • Прозрачная единица — БАЙТ = пара пикселей цвета 15 (0xFF). Фигурные края спрайта квантуются парами; прозрачного «одиночного» пикселя нет.
  • Leaf bgi16/_bgi_copy_rows_raw: те же триггеры, адресация x/2; клиппинг и координаты x — по чётным границам (нечётные края потребуют RMW-кромок per-row, v1 16-режима: требовать чётные x/w).
  • imagesize/формат: данные packed (2 пикселя/байт) — imagesize станет mode-specific leaf'ом.

9. Не в этой версии (кандидаты в следующую)

  • Managed-движок: см. эскиз §9.1 (sprite_t + retained-модель).
  • putsprite_part (кадр атласа одним вызовом, без ручного банка) — добавить, как только появится первый пользователь-игра.
  • .spr-ресурс: файловый заголовок-обёртка (магия/версия/hotspot) вокруг getimage-payload + загрузчик + PC-конвертер (PNG→spr, iconv- стиль пайплайн). In-memory формат не меняется.
  • XOR/OR/AND_PUT через акселератор (блочные режимы есть в железе).
  • RLE-сжатие (распаковка при загрузке — фича загрузчика).
  • Вертикальный блит (LD A,A-режим) — для повёрнутых спрайтов не хватает и его; не тянем.
  • ISR-курсор (перерисовка из IM2-тика) — после Audio/IM2 v2.

9.1 Эскиз managed-движка: sprite_t + retained-модель (v2, НЕ реализовано)

Мотивация — опыт examples/balls (2026-07-11): при двойной буферизации приложение обязано помнить, где каждый спрайт РЕАЛЬНО нарисован на КАЖДОЙ из двух страниц (drawn[2][N]), и соблюдать двухпроходную дисциплину «heal все → блит все». Оба правила легко нарушить (heal по позиции прошлого кадра вместо позапрошлого → фон зарастает кромками; слияние heal+блит per-sprite → укусы на перекрытиях — проверено экспериментально). Этот учёт — работа библиотеки, не приложения.

Разбор asm-референса (docs/samples/balls, 2026-07-12) подтвердил направление: наш ~2× разрыв — не W3-скобка (_bgi_begin = 5 инструкций), а per-call C-обвязка (клип 528/362 Б, парс заголовка, save/restore банка, SDCC-фрейм 7-арг), повторяемая 2×N раз. Референс платит это ОДИН раз на проход: W3 замаплен на видео на всю программу, банк-подрежим ставит один out на проход (0x5C рисовать всё → флип → 0x50 лечить всё), клипа нет вообще (адрес спрайта = сдвиги), heal — полный 16×16 (restore_bg). Движок v2 повторяет ту же per-pass структуру (одна W3-скобка + один банк + клип-fast-path на проход; w,h из кэша структуры). ЕДИНСТВЕННОЕ отличие, которое НЕ копируем: референс держит DI на весь проход (32 шара) — у него нет аудио-ISR; у нас DI остаётся гранулярным (по строке в leaf), иначе длинный DI сорвёт CBL/IM2-звук.

Структура (черновик)

typedef struct {
    const void *img;        /* getimage-формат / атлас-лента          */
    int   x, y;             /* ЛОГИЧЕСКАЯ позиция (куда хочет)        */
    int   sx, sy;           /* кадр атласа (под-прямоугольник img)    */
    uint8_t w, h;           /* размер кадра, 0 = 256 (кэш заголовка)  */
    uint8_t flags;          /* bit0 SPR_VISIBLE; bit1/2 dirty[page]   */
    /* --- внутреннее (владеет движок) --- */
    struct {
        int x, y;           /* где нарисован на странице p            */
        uint8_t on;         /* нарисован ли вообще                    */
    } drawn[2];
} sprite_t;                 /* ~24 байта на спрайт                    */

Инварианты: img живёт, пока спрайт активен; размер кадра постоянен (смена кадра = смена sx/sy в той же ленте); прозрачность — 0xFF в данных, как везде.

API (черновик; префикс sprite_, заголовок <sprite.h> в libbgi)

void sprite_init (sprite_t *s, const void *img);      /* w,h из заголовка,
                                                         невидим, не рисует */
void sprite_move (sprite_t *s, int x, int y);         /* O(1): только state */
void sprite_frame(sprite_t *s, int sx, int sy);       /* кадр атласа, O(1)  */
void sprite_show (sprite_t *s);                       /* O(1) */
void sprite_hide (sprite_t *s);                       /* O(1) */
void sprite_touch(sprite_t *s);                       /* O(1): принудительная
                                                         перерисовка на обеих
                                                         страницах (dirty)  */
/* flags: SPR_ALWAYS — перерисовывать каждый кадр (эквивалент touch
 * на каждом кадре; для статики, над которой постоянно ходят). */

/* Вся реальная работа — один вызов на кадр.  Рисует на ТЕКУЩЕЙ
 * draw-странице p (для дабл-буфера вызывать после
 * gfx_set_draw_page(hidden); для одиночной страницы/курсора — прямо
 * на видимой):
 *   проход 1: heal всех, у кого drawn[p].on и (скрыт ИЛИ сместился
 *             относительно drawn[p] ИЛИ dirty[p]);
 *   проход 2: блит всех видимых из них же (gfx_blit_part через 0x5C)
 *             в порядке массива (индекс = z-order, последний сверху);
 *             обновить drawn[p]/dirty[p].
 * Спрайты, не менявшиеся с прошлого визита этой страницы (и без
 * touch/SPR_ALWAYS), не трогаются. */
void sprite_update(sprite_t *arr, uint8_t count);

/* Сахар поверх update для канонического дабл-буфер-кадра:
 * set_draw_page(hidden) + sprite_update + gfx_wait_vsync +
 * set_visible_page(hidden).  Приложение, рисующее свой HUD/фон,
 * зовёт составные части само (HUD банком 0x50 НА СКРЫТОЙ странице —
 * тогда heal учитывает его автоматически через ОЗУ-копию). */
void sprite_flip(sprite_t *arr, uint8_t count);

Пример (balls сжимается до):

sprite_t sp[N];
... sprite_init/sprite_move/sprite_show ...
for (;;) {
    for (i...) sprite_move(&sp[i], new_x, new_y);   /* физика */
    sprite_flip(sp, N);                             /* весь кадр */
}

Решения и границы

  • Retained-модель: move/frame/show/hide меняют только state (O(1), сколько угодно раз за кадр) — рисование один раз, в update. Это железно закрепляет дисциплину двух проходов внутри библиотеки.
  • Пересечения со статикой — ответственность приложения (touch / SPR_ALWAYS), автодетекта НЕТ — осознанно: heal активного спрайта выкусывает статичный под ним, но автоматическое обнаружение — это не только O(N²) rect-проверок; для корректного z-порядка пришлось бы перерисовывать и пересекающих НОВЫЕ позиции, а перерисовка статика полным блитом затирает спрайты выше него уже ВНЕ грязной зоны → транзитивный каскад либо клиппинг блита по произвольной области. На Z80 это дороже самого рисования; приложение же знает сцену и помечает дёшево (обсуждено с пользователем 2026-07-11: ручное назначение «активных» выгоднее координатных проверок).
  • Массив, а не handle-таблица: владеет приложение, без malloc и лимитов; z-order = индекс. Пересортировка z = перестановка массива (drawn-состояние едет вместе со структурой — безопасно).
  • Интеграция со страницами: движок НЕ владеет флипом (sprite_flip лишь сахар) — приложение может рисовать динамический фон/HUD на скрытой странице до sprite_update. Правило прежнее: всё «фоновое» — банком 0x50, спрайты — движком.
  • Оптимизация внутри update, невидимая снаружи: объединение heal-прямоугольников соседей и т.п. — меняется реализация update, не API. ВНИМАНИЕ: «heal только открывшейся L-полоски» для ПРОЗРАЧНЫХ спрайтов НЕ годится (отвергнуто 2026-07-12): в зоне перекрытия старой и новой позиций дырки нового кадра не перекрывают старые непрозрачные пиксели → трейлинг-«полумесяц» остаётся внутри bbox-перекрытия, куда L-полоска не достаёт. Точный набор для heal = old_opaque AND NOT new_opaque (маска по форме, дороже full-box heal). Референс docs/samples/balls (restore_bg) лечит полный 16×16 — full-box heal обязателен. Экономия — только амортизация обвязки (batch-пасс).
  • Вне скоупа v2: скроллящийся фон (инвалидирует ОЗУ-копию целиком — другой движок), ISR-перерисовка (курсор из IM2 — после Audio/IM2), коллизии (у приложения есть x/y — пусть считает само).

9а. Квирк CPU-байта write-триггера accel-копии (найден 2026-07-11)

Триггер записи LD (DE),A — реальная CPU-инструкция: её собственный цикл записи кладёт байт A в dst[0] ДО burst'а FSM. После burst-чтения LD A,(HL) в A остаётся ПОСЛЕДНИЙ байт строки. При банке 0x50 утечка невидима (burst перезаписывает dst[0] правильным src[0]), но при 0x58/0x5C, если src[0] == 0xFF, перезапись скипается — на экране вертикальная полоса цвета последних байтов строк (симптом: клипнутые спрайты в tests/sprites). Нейтрализовано в _bgi_copy_rows_raw: src[0] предчитывается до армирования и подставляется в A через EX AF,AF' перед триггером — CPU-байт становится src[0], корректным при любом банке и любой семантике первого цикла. Регресс: tests/blitw (визуальная секция «trig leak»). Подтверждено в MAME 0.283; на железе проверить вместе с остальным (полоса цвета — маркер, что железо делает так же; чистый col0 — что CPU-цикл триггера подавляется ПЛМ).

10. Риски / что проверить артефактом (Фаза 0)

Статус 2026-07-11: пп. 13 закрыты для MAME 0.283 (tests/gfxbanks, результаты в §7); на железе — всё ещё TODO.

  1. Эмулирует ли MAME биты 2/3 — эмулирует (с квирком 0x58: FF протекает в теневое ОЗУ; в master-драйвере исправлено). Железо — проверить.
  2. Отображение байта FF в VRAM — в MAME обычный цвет 255, подстановки нет → стирание = heal. Железо может отличаться — tests/fferase остаётся до прогона на реальном Sprinter.
  3. Подавление 0xFF на accel-пути — в MAME accel-путь идентичен CPU-пути (оба через ram_w). Железо — проверить (в ПЛМ пути физически разные).
  4. Копия accel'ом при src в видеоокне и при src == dst — оба подтверждены: getimage через accel (образ 1:1, tests/bgi_img) и gfx_heal (стирание чисто, tests/sprites S2/S3). Железо — как всё остальное, перепроверить.
  5. LD A,(nn)/LD A,(HL) как источник размера блока (quick-win из TODO) — если работает, SMC в leaf'е не нужен.
  6. Долгие burst'ы и IM2/CBL: пара триггеров на строку под DI — бюджет как у fill (docs/accel-fill-budget.md), пересчитать для copy.