Files
Sprinter-SDCC/docs/sprite-api-design.md
T
snark13 6b6986d9b6 docs: W1-перемапы DSS при EI подтверждены артефактом; дизайн цепочки irq
wpiset на порт W1 (0xA2) + iff1 в dev-MAME: DSS перемапливает W1 при
ВКЛЮЧЁННЫХ прерываниях во время системных вызовов (файловые, загрузка
exe, видео; страницы 0xFE/FF/F3/0x50, PC ядра 0x15xx-0x2Exx) —
ограничение irq_install «только tiny/big» обосновано, handler в
W1-коде небезопасен принципиально.  §9е дополнен фактом; в TODO —
дизайн цепочки irq-обработчиков (массив слотов в W2, chain_add/remove,
irq_install как обёртка; реализация по потребности).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:34:21 +03:00

977 lines
73 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Спрайтовое расширение 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 (~5080Т); ядро платит на
СТРОКУ ~4060Т 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-14, НЕ реализовано)
Цель (постановка пользователя): управляемые режимы 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_install(_gfx_frame_isr)
(однократно; повторные вызовы только меняют div), n<=1 →
irq_remove (если ставили) + div=1. Возврат 0 / -1+errno
(EINVAL/EBUSY от irq_install). ТОЛЬКО этот модуль ссылается на
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): 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: приложение со СВОИМ irq_install получит EBUSY
от сеттера. Обход v1: приложение инкрементирует _gfx_frame_tick из
своего обработчика и выставляет _gfx_fps_div напрямую (внутренние
символы задокументировать); правильное решение — ЦЕПОЧКА
обработчиков в libc/irq (дизайн записан в docs/TODO.md 2026-07-14,
реализация по потребности).
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: пп. 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.