libbgi: спрайты — кэш адреса кадра, DDA+asm тик, Y-сортировка со слоями

Оптимизации A+B (профиль rpgwalk-15: активная часть кадра 410К → 307К
тактов из 430080; лимит спрайтов 16×16 на стабильные 48.8 fps: 14 → ~21):

- (B) sprite_t.src/stride — готовый адрес кадра: считают только
  sprite_frame (теперь функция, одно умножение на СМЕНУ кадра) и тикер
  (±an_step БАЙТ инкрементально); блит-ядра принимают src+stride,
  img/sx/sy из сигнатуры ушли.  Блит 177К → 146К на кадр.
- (A) тик 100К → 37.7К: tween переформулирован Брезенхэм → беззнаковый
  DDA (mv_rem/mv_acc, «приехали» = rem==0 — без знаковых 16-бит
  сравнений), tick_move и tick_anim — ручной asm (SDCC спиллит такие
  функции в IX-фрейм ~100 обращений; C-реструктуризации не помогали —
  проверено кодогеном).  Биты an_flags переименованы по категориям
  (_SPR_STRIP_HORZ, _SPR_PP_BACK).

Y-сортировка (gfx_sprite_ysort, идеи пользователя — 8-бит ключ,
персистентность):

- painter's algorithm по ключу {layer:8, clamp_y:8}; поле
  sprite_t.layer (в КОНЦЕ структуры — asm-офсеты не сдвигает): слои
  сцены в одном массиве/одном sprite_update;
- ПЕРСИСТЕНТНАЯ asm-таблица {key16, ptr16}: resort порядка прошлого
  кадра (почти линейно), rebuild при смене arr/count; массив
  приложения не трогается; ~28К/15 спрайтов (с нуля было 44К);
- компоненты YSORT_Y/YSORT_LAYER отключаемы независимо масками ключа
  (без ветвлений в сортировщике); ВНИМАНИЕ: mode=1 значит Y-only,
  полный порядок = YSORT_Y|YSORT_LAYER;
- funcptr-DCE: выключено = код и таблица не линкуются (rpgwalk −190 Б);
- ПРАВИЛО в sprite.h: два sprite_update на страницу запрещены (heal
  второй группы стирает спрайты первой — ОЗУ-копия чистая).

Попутные фиксы:

- libbgi/Makefile: .rel зависят от заголовков (HDRS) — stale .rel со
  старой раскладкой sprite_t молча ломал рантайм;
- rpgprof: --memory small (перерос tiny: BSS вылезал за W2 → мгновенный
  «Unexpected application termination»; mkexe это пока не ловит);
- tests/spranim: проверки переведены на кэш src, добавлены T6 (reframe
  после тикера) и T7 (Y-сортировка: порядок, слои, персистентный
  resort, LAYER-only) — 7/7 PASS в MAME.

Доки: §9д — новый бюджет (19.5К/спрайт), §9е — ПЛАН FPS-делителя
(frame pacing, gfx_set_fps_div); TODO — дизайн цепочки irq-обработчиков.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 17:19:24 +03:00
parent 0eec977630
commit b9ddce8d34
24 changed files with 1339 additions and 304 deletions
+157 -15
View File
@@ -782,19 +782,23 @@ callback НЕ делаем — опрос проще и дешевле).
watchpoint на OUT-маркеры + printf/g + clog (memory/mame_mcp_bridge).
Цена анимированного спрайта 16×16 в sprite_update (heal+blit на
кадр, W0-атлас, noclip):
кадр, W0-атлас, noclip) — два среза: до и после оптимизаций A+B
2026-07-14 (кэш src/stride в sprite_t — blit не считает адрес кадра;
беззнаковый DDA вместо Брезенхэма + asm-ядро tick_move):
| фаза | тактов/спрайт |
|--------------------|---------------|
| тик (anim+tween) | ~7.3К (4.3-9.2К) |
| heal (банк 0x50) | ~6.4К |
| blit (банк 0x5C) | ~11.8К |
| **итого** | **~26К** |
| фаза | до 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+свопы страниц) ~14-19К.
Отсюда лимит на стабильные 48 fps (КАЖДЫЙ кадр в один интервал):
**14 спрайтов** (замерено: все обычные кадры ≤406К, 10/232 двойных —
только кадры с FPS-плашкой); 15 — 94% кадров, 16 — на грани (50%).
Фикс-часть цикла демо (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К тактов на
@@ -802,10 +806,148 @@ watchpoint на OUT-маркеры + printf/g + clog (memory/mame_mcp_bridge).
__mulint 2026-07-13 (O(1) ~300Т): blit упал 45К → 11.8К/спрайт,
rpgwalk 24 fps → стабильные 48.
- bar()+outtextxy() FPS-плашки = ~210К тактов (~10 мс, полкадра!) —
текст BGI дорогой; на 14 спрайтах кадры с плашкой уходят в два
интервала. Для HUD в бюджете рисовать заготовленным putimage.
- Резерв: тик в C ~7.3К/спрайт — asm/упрощение подняло бы лимит к
~17-18 спрайтам.
текст 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 «может swap'нуться» (банки кода big/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)