# Спрайтовое расширение 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>` — ядро

```c
/* Семантические имена банков для 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-обёртки

```c
/* Вывести спрайт (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`).

### 3.1 Атласы: рекомендация по компоновке и файл-формат (предложение)

Решение 2026-07-13: in-memory адресация кадра остаётся как есть —
атлас = обычная getimage-картинка, кадр выбирается sx/sy (единичные
изображения, ленты по горизонтали и по вертикали, сетки nx×ny).

В одном атласе могут храниться изображения разных размеров и разного
количества вариаций.  РЕКОМЕНДАЦИЯ: для одного спрайта держать кадры
одинакового размера и полностью заполнять его сетку (nx*ny == число
вариаций) — иначе в прямоугольнике атласа возникают пустые сегменты,
теряющие место и на диске, и в памяти при загрузке.  Смешивать
спрайты РАЗНЫХ размеров в одном прямоугольнике не надо вообще: для
этого предлагается файл-формат БЕЗ пустых мест — контейнер независимых
лент, где паддинг невозможен по построению.

Файл-атлас (.atl, предложение — НЕ реализовано):

```
Шапка (8 байт):   'S','P','A','1' | count u8 | резерв ×3
Каталог (count × 8 байт):
    offset u16    смещение ленты от начала файла
    fw, fh u8,u8  размер кадра
    nx, ny u8,u8  сетка кадров (вариаций = nx*ny)
    резерв u16    (id/флаги)
Данные: count лент подряд, каждая — обычный getimage-блоб
    (u16 w = fw*nx, u16 h = fh*ny, пиксели построчно)
```

ВАЖНО: offset указывает на ПЕРВЫЙ БАЙТ getimage-шапки ленты (не на
пиксели) — 4 байта `u16 w, u16 h` хранятся В ФАЙЛЕ в начале каждого
блоба.  Поэтому каждая лента — самостоятельная getimage-картинка со
своим stride: после загрузки файла целиком в память указатель
`base + dir[i].offset` напрямую годится в putsprite/sprite_init/
gfx_blit_part — рантайм не меняется вовсе, кадр (i,j) = sx=i*fw,
sy=j*fh.  (w/h ленты при этом задублированы: выводимы из fw*nx/fh*ny
каталога И лежат в шапке блоба — осознанные 4 байта на спрайт за
прямую совместимость указателя.  Каталог же несёт то, чего в шапке
нет: размер КАДРА и сетку — без них лента 128×16 неотличима от
одного кадра 128×16.)  Пустых байтов нет по
построению: ленты разных размеров просто конкатенируются.  Пример
(два спрайта 16×16 на 8 и 12 вариаций + один 24×24 на 4): шапка 8 +
каталог 24 + (4+2048) + (4+3072) + (4+2304) = 7468 байт, паддинг 0.
Привязка к W0-страницам (решение 2026-07-13, см. §9в): один атлас =
одна EMM-страница = один файл.  Размер данных атласа ограничен
16К − 0x100 (страница минус резерв ISR-стаба); если данных больше —
следующий атлас в ОТДЕЛЬНОМ файле на своей странице (sprite_t.page их
различает).  Два варианта офсетов в каталоге:

  I.  offset = адрес в W0 (0x0100-based): каталог сразу содержит
      готовые указатели, данные в файле идут после каталога впритык.
  II. файл несёт 0x100-байтовый заголовок, so that файл-офсет ==
      офсет в странице == W0-адрес: загрузчик читает файл ЦЕЛИКОМ в
      offset 0 страницы и лишь патчит стаб (3 байта JP в 0x38, RETN в
      0x66).  Раскладка заголовка: 0x00-0x07 магия+count,
      0x08-0x37 резерв, 0x38-0x3A и 0x66-0x67 — место под патч стаба,
      0x68-0xFF каталог (19 записей × 8 Б).  Файл ≤ 16384 Б ровно.

Рекомендуется II: загрузка = один read() страницы, замапленной в W3
(приём проверен в mdview2) + патч 5 байт; офсеты совпадают в файле,
странице и W0 — нечего пересчитывать.  Вариант I оставляет атласам
возможность жить в обычной памяти (malloc-буфер) без страницы.
Python-упаковщик (PNG → .atl) в toolchain и загрузчик — делать по
потребности первого реального приложения.

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

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

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

```c
/* Копирование 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.c` — `gfx_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 (~50–80Т); ядро платит на
СТРОКУ ~40–60Т CPU + burst акселератора (~байт/7МГц).  На спрайте 16×16
это порядка 10–20× (замерить в Фазе A, добавить в size-baseline).

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

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

```c
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-звук.

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

```c
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)

```c
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 сжимается до):

```c
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'
перед триггером.

**ФИКС СНЯТ 2026-07-13.** Точная dev-MAME (сборка разработчиков
Sprinter, `mame/sources/MAME`) эмулирует ПЛМ, подавляющую CPU-байт
триггера при активном burst'е — квирк был артефактом стоковой MAME
0.283.  Проверено pixel-точно: tests/blitw, col0 @(200,150) = 0x02
GREEN по байтам VRAM (read_vram через MCP-мост).  Для heal фикс был
избыточен всегда (банк 0x50 перезаписывает dst[0]).  Строки фикса
оставлены закомментированными в трёх leaf'ах (_bgi_copy_rows_raw,
_bgi_blit_rows_raw, _bgi_heal_rows_raw) — восстановить, если реальное
железо поведёт себя как MAME 0.283 (маркер: вертикальная полоса цвета
последних байтов строк на клипнутых/прозрачных спрайтах).  Регресс:
tests/blitw (визуальная секция «trig leak»).  НА ЖЕЛЕЗЕ ПЕРЕПРОВЕРИТЬ.

## 9б. Клип спрайтов: noclip-ядра + funcptr-диспетч (2026-07-12/13)

Клип в спрайтовых ядрах стоит не только сравнений (~0.85 мс/кадр на 16
шаров), но и codegen-эффекта: клип-код раздувает регистровое давление
всей функции (спиллы) — поэтому рантайм-скип внутри общего ядра
возвращает лишь малую часть.  Полный выигрыш даёт ФИЗИЧЕСКИ отдельная
функция без клип-кода: `_gfx_blit_sprite_noclip` / `_gfx_heal_sprite_noclip`.

Выбор ядра — НЕ веткой в горячем цикле (ветка `if (clip)` в
sprite_update съедала половину выигрыша: 52 вместо 57 fps на старой
MAME), а перенаправлением указателей `_gfx_blit_fn`/`_gfx_heal_fn`
(common/_gfx_sprite_fns.c, дефолт — clip-ядра) один раз в
`gfx_sprite_clip()`; sprite_update/putsprite/movesprite зовут через
указатель.  Программа без вызова `gfx_sprite_clip()` не линкует
noclip-ядра (на них ссылается только модуль gfx_sprite_clip.c).

Замер на точной dev-MAME (16 шаров, uncapped, A/B 2026-07-13):
clip 50 fps → noclip-funcptr 60-61 fps (**+20-22%**) — полный выигрыш
noclip доехал до приложения.  С vsync-капом оба варианта упираются в
~48-50 (кадр < 20 мс) — выигрыш конвертируется в запас кадра.
ВНИМАНИЕ: noclip требует гарантии приложения, что спрайты целиком на
экране — выход за край портит соседнюю память (tests/spriteclip).

## 9в. Атласы в страницах W0 (дизайн 2026-07-13; probe-тест PASS)

Атласы грузятся в EMM-страницы (mem_alloc_pages) и на время
sprite_update подключаются в W0 (OUT 0x82) — W2-heap свободен от
спрайтов, объём = EMM (~3.4 МБ).  Загрузка — read() в страницу,
замапленную в W3 (приём mdview2; вне рендера W3 свободна).

ОПАСНОСТЬ: все прерывания исполняют 0x38 текущей страницы W0 — IM1
напрямую, наш IM2-трамплин чейнит jp 0x0038 (libc/irq/_irq_tramp.c);
не ходит в W0 только личный CBL-путь.  ЗАЩИТА — стаб на каждой
странице (первые 0x100 байт зарезервированы):
  0x38: JP <w0_isr_stub в W2>;  0x66: RETN (NMI).
Стаб: свап W0 на страницу ядра DSS (снята со старта IN A,(0x82)) →
подложить возврат на стек → jp 0x0038 (честный обработчик, EI/RETI) →
хвост: ВОССТАНОВИТЬ спрайт-страницу (_w0_cur) → ret.  Restore
обязателен (закрывает EI-щель между out(0x82) движка и DI leaf'а);
один стаб покрывает IM1 и IM2-чейн; вложенное прерывание в хвосте
безопасно (W0 в этот момент = DSS, стаб не реентерится).

ПРАВИЛО: пока спрайт-страница в W0 — НИКАКИХ ESTEX/BIOS (RST-диспетчер
живёт на странице DSS).  Движок: пролог/эпилог sprite_update
сохраняет/возвращает страницу DSS; sprite_t получит поле page.

Probe-тест tests/w0page (MAME dev, 2026-07-13, ВСЕ PASS):
  P1 ESTEX READ в W3-замапленную страницу;
  P3 IM1: busy-цикл ~5 c с EI при странице в W0 — жив, сентинел цел;
  P4 IM2 (irq_install): 100 кадровых прерываний посчитаны при
     подключенной странице (полный тракт трамплин→чейн→стаб→DSS→
     restore), сентинел цел;
  P5 accel-блит src = 0x0100 (W0) банком 0x50 — пиксели верны по
     ОЗУ-копии (getpixel).
На железе перепроверить вместе с остальным.  Альтернатива без стабов —
W1, но только tiny (в small/big/huge в W1 код) — отвергнута в пользу
универсального W0.

## 9г. Авто-анимация спрайтов (РЕАЛИЗОВАНО 2026-07-13; tests/spranim PASS)

Идея пользователя: sprite_update (вызывается раз на кадр) сам тикает
анимации — приложение только описывает их декларативно.  Реализация:
sprite_anim(first,last,speed,mode: LOOP/PINGPONG/ONCE|HORIZ) /
sprite_anim_stop(frame|-1) / sprite_moveto(tx,ty,max_step,interval) /
sprite_move_stop(at_target) / статус sprite_anim_status() (биты
SPR_ANIM_ON/DONE, SPR_MOVE_ON/DONE) / sprite_anim_frame() /
sprite_moving().  Кадровая и tween независимы (одновременно — да);
тикер _sprite_tick подключается указателем _spr_tick_fn (DCE),
sprite_update зовёт его проходом 0.  Кадровая — ±an_step к оси без
умножений; tween — Брезенхэм порциями ≤max_step по большей оси.
Проверено tests/spranim (T1 LOOP / T2 PINGPONG / T3 ONCE+DONE /
T4 пример (0,0)→(100,50)/5/4 = ровно 80 кадров, Y 3/2/3/2 /
T5 стопы) — все PASS.  Цена: ~+40 Б программе с движком, sprite_t
+18 Б.  Ниже — исходный дизайн-разбор.

### Кадровая анимация (смена изображений)

«Крути кадры от M до P с интервалом N кадров»; два режима: линейный
(0/1/2/3/0/1/2/3) и маятник (0/1/2/3/2/1/0/...).

Ключ к дешёвому тику на Z80: индекс кадра НЕ хранить — переход между
соседними кадрами ленты это ±step к sx или sy (step = fw или fh),
БЕЗ умножения/деления.  Состояние: axis-смещение текущего кадра уже
живёт в s->sx/s->sy; добавляются границы и шаг:

    uint8_t an_speed;    /* кадров между сменами; 0 = анимации нет   */
    uint8_t an_timer;    /* счётчик до следующей смены               */
    uint8_t an_step;     /* fw или fh (ось ленты)                    */
    int     an_lo,an_hi; /* smin..smax по оси (M и P умножены заранее*
                          * в sprite_anim() — один раз, не в тике)   */
    флаги: ANIM_PINGPONG, ANIM_DIR (текущее направление), ANIM_ONESHOT?

API: sprite_anim(s, first, last, speed, mode) / sprite_anim_stop(s).
Тик (в sprite_update, до проходов): --an_timer; при 0: sy ± an_step,
на границе — wrap (линейный: sy=an_lo) или разворот (маятник:
инверсия DIR), s->flags |= _SPR_DIRTY.  Стоимость: декремент + редкая
ветка — копейки.

### Анимированное перемещение (tween)

«Плыви к (tx,ty), максимум max_step пикселей за срабатывание, раз в
interval кадров».  Пример: (0,0)→(100,50), шаг 5, интервал 4 → 20
срабатываний × 4 кадра = 80 кадров (1.6 с); Y идёт нелинейно 2/3/2/3.

Реализация — инкрементальный Брезенхэм (как _bgi_lineseg), но
«порциями» по ≤max_step вдоль БОЛЬШЕЙ оси за срабатывание: ошибка-
аккумулятор тянет меньшую ось, деления нет.  Состояние ~10 байт:
tx,ty, err, adx,ady (абс. дельты), знаки, mv_speed/mv_timer.
Завершение: x==tx && y==ty → снять SPR_MOVING (опрос sprite_moving(s);
callback НЕ делаем — опрос проще и дешевле).

### Общее / критика / расширения

- Тик — отдельный проход в НАЧАЛЕ sprite_update (до heal: dirty должен
  взводиться раньше проходов).  ВАЖНО: тик должен идти раз на КАДР —
  sprite_update и так зовётся на кадр (дабл-буфер: попеременно на
  страницу); при просадке fps анимации замедляются вместе с кадром
  (стандартное поведение, принимаем).
- DCE: тикер — в отдельном модуле, sprite_update зовёт его через
  указатель _spr_tick_fn (дефолт NULL → один if на кадр); указатель
  ставит первый вызов sprite_anim()/sprite_moveto().  Программа без
  анимации не тянет код тикера (паттерн funcptr как у clip/noclip).
- Цена памяти: sprite_t вырастает на ~16-18 байт (кадровая ~10 +
  tween ~10 с перекрытием полей?).  Вариант «отдельная anim-структура
  по указателю (NULL = нет)» экономит память статиков, но добавляет
  indirection в тик и второй массив приложению.  ПРЕДЛОЖЕНИЕ: поля в
  sprite_t (спрайтов десятки — до 64×~34 Б ≈ 2 КБ, W2 переживёт),
  но решение за пользователем.
- Расширения (потом): one-shot кадровая анимация (проиграть раз и
  замереть/скрыться); дробная скорость 8.8 fixed-point (равномерное
  медленное движение «1 пиксель в 1.5 кадра»); цепочки целей
  (waypoints) — приложение ставит новую цель по sprite_moving()==0.

## 9д. Тактовый бюджет кадра (профиль rpgwalk в dev-MAME, 2026-07-13)

Кадр Sprinter = 48.83 Гц (растр 896×320 @14МГц) = **430080 тактов**
@21МГц (такты «эффективные», с wait-state'ами ОЗУ).  Метод замера —
watchpoint на OUT-маркеры + printf/g + clog (memory/mame_mcp_bridge).

Цена анимированного спрайта 16×16 в sprite_update (heal+blit на
кадр, W0-атлас, noclip) — два среза: до и после оптимизаций A+B
2026-07-14 (кэш src/stride в sprite_t — blit не считает адрес кадра;
беззнаковый DDA вместо Брезенхэма + asm-ядро tick_move):

| фаза               | до A+B        | после A+B     |
|--------------------|---------------|---------------|
| тик (anim+tween)   | ~7.3К         | **~3.0К**     |
| heal (банк 0x50)   | ~6.4К         | ~6.4К         |
| blit (банк 0x5C)   | ~11.8К        | **~9.7К**     |
| **итого**          | **~26К**      | **~19.5К**    |

Фикс-часть цикла демо (kbhit+getdatetime+свопы страниц) ~15-20К.
Лимит на стабильные 48 fps (КАЖДЫЙ кадр в один интервал): было
**14 спрайтов**, после A+B — расчётно **~21** (15 замерено: активная
часть 307К из 430К, все периоды 1.0).  Дорогой HUD ломает бюджет:
bar()+outtextxy() FPS-плашки стоили ~210К (полкадра!) — рисовать
putimage-заготовкой/маленьким bar.

Уроки профиля:
- O(sy)-цикл `src += img_w` в blit-обёртках стоил до 64К тактов на
  кадр 11 вертикальной ленты (~85% времени блита!) — заменён на
  __mulint 2026-07-13 (O(1) ~300Т): blit упал 45К → 11.8К/спрайт,
  rpgwalk 24 fps → стабильные 48.
- bar()+outtextxy() FPS-плашки = ~210К тактов (~10 мс, полкадра!) —
  текст BGI дорогой; для HUD в бюджете рисовать заготовленным
  putimage/узким bar.
- Оптимизации A+B (2026-07-14): (A) тик — SDCC спиллит функции с
  «многими» 16-бит локалами в IX-фрейм (~100 обращений по ~19Т),
  реструктуризации C НЕ помогали → tick_move переписан на asm
  (__naked, ~2К/вызов вместо ~6.5К) поверх переформулировки
  Брезенхэм → беззнаковый DDA (mv_rem/mv_acc; знаковых 16-бит
  сравнений нет, «приехали» = rem==0); (B) кэш src/stride в sprite_t —
  адрес кадра считают только sprite_frame (одно умножение на СМЕНУ
  кадра) и тикер (± an_step байт инкрементально), блит берёт готовый.
  Итог: тик 100К → 44.6К на 15 спрайтах, блит 177К → 146К.
- ВНИМАНИЕ: правка sprite.h без пересборки libbgi молча ломала
  рантайм (stale .rel со старой раскладкой sprite_t) — libbgi/Makefile
  теперь зависит от заголовков (HDRS).  Профилировочный rpgprof
  перерос tiny (BSS за 0xC000, мгновенная смерть) — собирается
  --memory small; mkexe переполнение tiny пока не ловит.
- Y-сортировка (gfx_sprite_ysort, 2026-07-14, все идеи пользователя):
  ПЕРСИСТЕНТНАЯ asm-таблица 4-байтных записей {key16 = layer:8|
  clamp_y:8, ptr16} — каждый кадр resort вставками порядка ПРОШЛОГО
  кадра (обновив ключи), rebuild только при смене arr/count.
  Ключ младшим байтом — clamp(y,0,255): экран 256 строк, видимая зона
  точно, за краями порядок неважен; старшим — sprite_t.layer (поле В
  КОНЦЕ структуры, офсеты asm не сдвигает): слои сцены — «ходячие
  слоем 0, летающие слоем 1» В ОДНОМ массиве/одном sprite_update.
  ВАЖНО: два sprite_update на страницу ЗАПРЕЩЕНЫ (heal второй группы
  стирает первую — ОЗУ-копия чистая от спрайтов; задокументировано в
  sprite.h).  Подключение funcptr-DCE (выключено = не линкуется).
  Цена: ~28К/15 спрайтов персистентно (~2К/спрайт; с нуля было 44К,
  ожидание 12-15К не достигнуто — персонажи постоянно обгоняют друг
  друга по y, вставки не нулевые).  Компоненты порядка отключаемы
  НЕЗАВИСИМО (2026-07-14): mode = YSORT_Y | YSORT_LAYER — выключенный
  компонент затирается МАСКОЙ ключа (_spr_ysort_kmask, 2 AND на
  спрайт ≈ 1К на 15) — в сортировщике нет ветвлений по режиму.
  Тест: spranim T7 (порядок, слой поверх y, персистентный resort,
  LAYER-only).

## 9е. FPS-делитель — frame pacing (РЕАЛИЗОВАНО 2026-07-15, verified MAME)

**СТАТУС: сделано.** Файлы точно по плану ниже (common/_gfx_fps_state.c,
_gfx_frame_isr.c, gfx_set_fps_div.c, ветка в gfx_wait_vsync.c).  Через
цепочку кадровых IRQ — работает tiny/big/huge (не только tiny/big);
small для мелких программ = EINVAL.  tests/fpsdiv: n=1/2/3 → ровно
20/40/60 кадров на 20 wait'ов (drift=0); n=2 с рендер-заглушкой ~1 кадр
→ период 2 (поглощение перерасхода, наивный путь дал бы ~60); huge
идентично tiny; small = EINVAL graceful.  Один нюанс реализации:
лучевой поллинг вынесен в static gfx_wait_vsync_beam() (функция,
кончающаяся непрозрачным __asm, не должна иметь переходов «через asm» —
SDCC не эмитит эпилог-метку; ранний return делителя живёт в чистом-C
gfx_wait_vsync).  Ниже — исходный план (в силе).

---


Цель (постановка пользователя): управляемые режимы 50/25/~16.7 fps —
логический кадр занимает РОВНО n кадровых интервалов независимо от
того, плавает ли длительность отрисовки; при переполнении слота —
выравнивание на ближайший фронт (без накопления фазовой ошибки).
Пример (n=3): рендер <1 интервала → ждать 3 фронта; рендер пересёк
1 фронт → ждать ещё 2; пересёк 2+ → ждать ближайший 1.

### Почему нельзя поллингом (текущий gfx_wait_vsync)

Поллинг видит СОСТОЯНИЕ луча (бит 5 порта #FE), а не события: фронты,
прошедшие во время рендера, не наблюдаемы → «сколько интервалов занял
рендер» узнать нельзя.  Наивное «подождать n фронтов подряд» ломается
на рендере в 1.5 интервала: съеденный фронт не засчитан, период
становится n+1 — то самое плавание скорости.

### Механика: фоновый счётчик кадров (ISR)

irq_install (libc/irq, механика уже обкатана калибровкой sleep) с
однострочным обработчиком `_gfx_frame_tick++` (volatile uint8; wrap
безопасен — вся арифметика разностная по модулю 256).

Семантика ожидания при делителе n (last — тик прошлого выхода):

    elapsed = _gfx_frame_tick - last;
    if (elapsed >= n) {                       /* слот истёк — опоздали */
        t = _gfx_frame_tick;
        while (_gfx_frame_tick == t) HALT;    /* ближайший фронт       */
    } else {
        while ((uint8_t)(_gfx_frame_tick - last) < n) HALT;
    }
    last = _gfx_frame_tick;

HALT будится ЛЮБЫМ прерыванием (клавиатура/CBL) — цикл перепроверяет
счётчик; просыпание на кадровом фронте + хвост ISR-цепочки → своп
страницы попадает в верхний бордер (16 строк запаса), tear-free.
Прерывания к моменту вызова включены (_bgi_end делает EI); подстраховка
EI перед HALT обязательна (как в fallback текущего gfx_wait_vsync).

### API

    int gfx_set_fps_div(uint8_t n);  /* 1 = 50 fps (дефолт), 2 = 25,
                                        3 = ~16.7, 4 = 12.5, ...
                                        0 трактовать как 1 */

Call-sites НЕ меняются: gfx_wait_vsync() один; n<=1 — СТАРЫЙ луч-
поллинг (ISR не ставится, поведение бит-в-бит текущее), n>=2 —
счётчиковый путь.  Отдельные gfx_wait_vsync25fps/17fps НЕ вводим —
сеттер в духе gfx_sprite_clip/ysort, переключение на лету.

### Файлы (по канону 1 модуль = 1 сущность)

- common/_gfx_fps_state.c — `uint8_t _gfx_fps_div;`
  `volatile uint8_t _gfx_frame_tick;` `uint8_t _gfx_fps_last;`
  (данные; div=0/1 → старый путь; НЕ инициализировать).
- common/_gfx_frame_isr.c — ISR: `_gfx_frame_tick++` (правила irq.h:
  без ESTEX/gfx/банков — здесь чистый инкремент).
- common/gfx_set_fps_div.c — сеттер: n>=2 → irq_chain_add(_gfx_frame_isr)
  (ТАРГЕТНО свой слот; однократно, повторные вызовы только меняют div),
  n<=1 → irq_chain_remove(_gfx_frame_isr) (если ставили) + div=1.
  ВАЖНО: chain_remove, НЕ irq_remove — выключение делителя не должно
  снести собственный хендлер приложения (см. цепочку в
  docs/im2_isr_design.md).  Возврат 0 / -1+errno (EINVAL — не tiny/big;
  ENOMEM — 4 слота заняты).  ТОЛЬКО этот модуль ссылается на irq-механику
  и ISR — DCE: программа без сеттера не тянет ничего (wait ссылается
  лишь на data-модуль).
- gfx_wait_vsync.c — ветка `if (_gfx_fps_div >= 2)` перед текущим
  поллингом.

### Ограничения (задокументировать в gfx.h)

1. irq_install работает ТОЛЬКО в tiny/big; в small/huge вернёт EINVAL
   → gfx_set_fps_div(2+) там недоступен (rpgprof — small!).  СНИМАЕТСЯ
   вариантом «все режимы» (docs/im2_isr_design.md «Вариант работает во
   всех режимах памяти», 2026-07-14): трамплин → W2-резидентный, хендлер
   остаётся в W1, трамплин восстанавливает статическую базовую W1-страницу
   приложения вокруг вызова (паттерн __banked).  Тогда rpgprof (small)
   тоже получит делитель.  ПРИЧИНА исходного ограничения
   (см. docs/im2_isr_design.md): IM2-таблица/трамплин/handler обязаны
   лежать по адресам, валидным в ЛЮБОЙ момент прихода прерывания —
   стабильно только W2 (стек обязан быть там по требованию DSS, окно
   не перемапляется); W1 перемапливается: банками кода (big), и —
   ПОДТВЕРЖДЕНО артефактом 2026-07-14 (dev-MAME, wpiset порт 0xA2 +
   iff1) — самим DSS при ВКЛЮЧЁННЫХ прерываниях во время системных
   вызовов (файловые операции, загрузка exe, видео: страницы
   0xFE/0xFF/0xF3/0x50 с PC ядра DSS, iff=1) → handler в W1-коде
   (small/huge) небезопасен принципиально.  Обходы — (а) area _TRAMP_W2
   ~0xBE00 (спроектирована в im2-доке), (б) для НАШЕГО микро-ISR
   (инкремент байта) — эмит стаба прямо в W2-BSS-буфер, минуя
   C-handler в W1; (в) CTC-канал (вектор 0x06, отдельный от
   кадрового; пресет IRQ_CTC_VSYNC_DIV2/3 = 17920 тактов = точный
   период кадра 896×320, дрейфа нет; фаза не привязана к лучу — разовая
   синхронизация поллингом фронта при установке).  Делать по
   потребности.
2. ~~Один слот user-ISR (EBUSY)~~ — СНЯТО цепочкой обработчиков: сеттер
   зовёт irq_chain_add, приложение со своим хендлером — тоже слот,
   оба тикают.  Дизайн цепочки: docs/im2_isr_design.md «Цепочка
   кадровых обработчиков».  Порядок реализации: цепочка → потом
   делитель поверх неё.  (Осталось лишь: при 4 занятых слотах сеттер
   вернёт ENOMEM.)
3. W0-атласы + IM2: прерывание при замапленном атласе идёт через
   IM2-таблицу приложения (W2) → чейн на 0x0038 = ISR-стаб страницы
   атласа → DSS.  Путь совместим по построению, но ПРОВЕРИТЬ в MAME
   (пункт теста ниже).

### Тест (tests/fpsdiv)

Рендер-заглушки калиброванной длины busy-wait'ом (~0.5 / 1.5 / 2.5
интервала; калибровка циклом с известными тактами, как в sleep):
- n=1: периоды по счётчику 1/2/3 (текущее поведение);
- n=2: 2/2/3;
- n=3: 3/3/3;
плюс возврат ошибки в small (собрать вариант) и живой прогон
rpgwalk с n=2 — FPS-бар стоит на 25 без плавания; визуально скорость
персонажей стабильна.  Отдельно: rpgprof-сценарий с W0-атласами +
включённый делитель (проверка п.3 ограничений).

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

Статус 2026-07-11: пп. 1–3 закрыты для 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.
