Дата: 2026-07-02. Основа: реальный замер патченного Unreal (Z80-профайлер),
чтение актуального кода Source/ASM/MainLoop.asm / shared_render.asm /
Docs/TSLib/Include/FT/Coprocessor/BufferMacro.inc, дампы full-state
рекордера (unreal_x64/Debug/NNNNNN.{bmp,dl,z80}).
ZL_DrawFrame 137 415 бюджет кадра 69 888 → кадр стоит ~2 видеокадра
├─ ZLDF_chains_total 116 068 84% — всё в цепях
│ ├─ prepare_caches 51 229 pre-pass: построение кэша ОБЕИХ цепей
│ │ └─ spin12 4 223 уже LUT — дёшево
│ ├─ chain1_under 11 517
│ ├─ chain2_under 9 712
│ ├─ chain1_over 17 209
│ └─ chain2_over 19 339
├─ ZLDF_afterchains 19 147
│ ├─ hud_top_text 8 967 почти половина afterchains — текст HUD
│ └─ frog_layer 5 679
Соседний VDC_UpdateAllChains = 36 175 (вне ZL_DrawFrame).
Итого игровой кадр ≈ 174K ≈ 2.5 бюджета.
Факт. С переходом на глобальный PALETTED4444-атлас ВСЕ уровни идут по
ветке .l19_native (ZL_EmitBallMatrixFromBRAD, MainLoop.asm ~2058):
каждая смена tangent-бакета = 5 команд копроцессору (10 CMD-слов):
CMD_LOADIDENTITY
CMD_TRANSLATE(+25.5, +25.5) ; в центр ячейки 51px
CMD_ROTATE(угол)
CMD_TRANSLATE(−25.5, −25.5)
CMD_SETMATRIX
Копроцессор FT812 считает sin/cos, перемножает матрицы и по SETMATRIX
компилирует итог в 6 DL-слов BITMAP_TRANSFORM_A..F (в дампе DL — по 65
блоков за кадр). Цена Z80: 10 слов × ~104 такта (FT_CMD_BUF каждое слово
перечитывает/перезаписывает BufferPtr из памяти) + CALL-обвязка ≈
~1200 тактов за эмит.
Ключевое наблюдение. Угол квантован маской AND #F0 → всего 16
бакетов → существует всего 16 различных итоговых матриц, все известны
заранее. Копроцессор каждый кадр заново вычисляет одну из шестнадцати
констант.
Предложение (TRANSFORM-LUT). Посчитать 16 матриц один раз на этапе
сборки (Python: translate·rotate·translate, с учётом +192 face-offset из
ZL_EmitRotate) и положить таблицу 16 × 24 байта = 384 байта: готовые
6 DL-слов TRANSFORM_A..F на бакет. Эмит = копия 24 байт из таблицы в
CMD-буфер (~550 тактов вместо ~1200). Копроцессор сырые DL-слова пропускает
в RAM_DL как есть. Бонус: FT812 освобождается от sin/cos → короче
WaitFlush-хвосты (в Z80-профиле не виден, но реален).
Почему точно работает: это уже сделано в этом же файле. Старый
ARGB4-путь использует ZL_ChainMatrixLUT (stride 24, LDIR-ветка в том же
ZL_EmitBallMatrixFromBRAD), генератор — make_chain_matrix_lut.py в
билд-цепочке. Когда шары перевели на глобальный атлас,
ZL_BallsPalettedActive стал всегда 1 — и все уровни молча съехали с
дешёвой LUT-ветки на дорогую копроцессорную (написанную только для L19).
LUT-путь остался в коде мёртвым. Возврат существующей техники с новой
таблицей: центр 25.5px (ячейка 51), 16 бакетов.
Проверка корректности — рекордером: собрать оба варианта, снять .dl с
одинакового состояния; TRANSFORM-слова из таблицы должны совпасть с
копроцессорными бит-в-бит или в LSB округления (Python во float точнее).
Факт. В top-mask пути каждый из 4 проходов (under1/under2/over1/over2)
идёт по ВСЕМ записям кэша цепи и per-ball фильтрует «мой/не мой» (флаги
IX+6). Плюс каждый проход сбрасывает ZL_TmpLastTangent в sentinel —
lazy-skip матриц стартует с нуля 4 раза за кадр. Отсюда 65 эмитов SETMATRIX
на ~112 шаров (смена «бакета» каждые 1.7 шара — артефакт рестартов и
фильтров, а не геометрии трека).
Предложение. Партиционировать кэш на этапе pre-pass: tunnel-шары с головы буфера, over-шары с хвоста (или два региона со счётчиками). Draw становится линейным забегом ровно по своим шарам: исчезают ~200 холостых итераций фильтра, и матричные последовательности становятся непрерывными (соседи по треку в одном бакете) — число SETMATRIX падает примерно вдвое. Усиливает п.1 мультипликативно.
Факт. FT.Coprocessor.Cell / Vertex2f / ColorA: каждый вызов =
CALL + LD HL,(BufferPtr) + 4 байта + LD (BufferPtr),HL + RET ≈ 111
тактов на слово. На видимый шар минимум 2 слова (CELL+VERTEX2F) = ~220
тактов бухгалтерии указателя, который между вызовами никто не меняет.
Предложение. На входе в draw-цикл поднять указатель в регистровую пару
(или EXX-набор), эмитить LD (HL),r / INC HL инлайн, вернуть в память
один раз на выходе и на flush-границах. Слово ~111 → ~52 такта.
Flush-guard CP #58 превращается из чтения памяти в CP по регистру.
Аккуратность: ZL_FlushCommandBufferMidFrame внутри цикла обязан
синхронизировать регистр ↔ память (две локальные точки).
По инструкциям цикла (MainLoop.asm:995-1196), на каждый шар:
VDC_LastT/LastTangent/LastTrackFlagsпишутся в память (наследие ABI резидентного ридера), причёмLastTangentтут же читается обратно — запись→чтение памяти вместо пересылки регистра. ~40/шар ≈ 4.5K.- Стековый трафик: PUSH BC + 2–3 PUSH/POP HL на шар. Счётчики и baseT в теневой набор (EXX): ~50–60/шар ≈ 6K.
- Декремент baseT через ячейку памяти вместо регистровой пары: ~30/шар ≈ 3.4K.
- Cull полными 16-бит сравнениями там, где различимо по старшему байту (диапазоны −832..16384 / −832..12288): ~25/шар ≈ 3K.
Чистая регистровая гигиена, механики не меняет.
Факт. Draw-цикл на каждый шар строит адрес (VDC_pExplodeFrame)+i и
читает байт (~60 тактов), почти всегда видя 0 (взрывы — редкие кадры).
Кроме этого ZL_DrawCachedActiveChain раньше вызывал ZL_DrawExplosions
на каждом non-under pass даже при полностью пустых ExplodeFrame[]: scale
matrix + destroy bitmap setup + линейный scan кэша.
Сделано. Добавлен per-chain флаг VDC_ExplodeActive в VDC_ChainLocal
(сохраняется через VDC_SwapChains). VDC_ApplyMatch3 ставит его сразу
при запуске match-3; VDC_AnimateChain обновляет флаг в том же проходе,
где инкрементирует/финализирует ExplodeFrame. В draw-pass один раз на
цепь выбирается PBExplodeCheck или прямой переход к head-absorb/normal
draw; ZL_DrawExplosions вызывается только если флаг активен.
Замер headless Z80 на текущем build, один и тот же state, active=0/1:
- L19 warm state
36+36:287103 -> 300053tstates, delta12950. - L19 synthetic
105+105:634927 -> 667809tstates, delta32882; CMD-поток525 -> 537words, то есть no-explosion кадр ещё и на 12 CMD-слов короче.
Абсолютные tstates Python-harness не сравнивать напрямую с patched-Unreal
zprof; относительная дельта внутри одного harness валидна. Старая оценка
−6K была занижена: она считала только per-ball read и не учитывала
безусловочный destroy-pass.
SCORE/время меняются редко, а глифы пересобираются в CMD-поток каждый кадр. Предложение: собрать блок один раз в теневой RAM-буфер, лить LDIR'ом; пересборка — только по флагу изменения значения (ставится в местах инкремента score/времени).
В серии full-state рекордера кадры 780–791 байт-идентичны — 12 кадров подряд игра не меняет ни пикселя, а Z80 жжёт 137K тактов на пересборку того же DL. Дайджест из ~десятка байт (HSA/HSub/SlotsLen обеих цепей, Frog_Angle, Bullet_Active, счётчик взрывов, DialogState, курсор X/Y, gauge) ≈ 200 тактов на сравнение; совпал → не строить и не слать кадр, DLSWAP не трогать. На медленных фазах срезает целые кадры. Риск — забыть поле в дайджесте; лечится сборкой дайджеста рядом с местами записи полей.
- Квантование углов — грубые бакеты только для ортогональных треков (L18/L19); на кривых уровнях не наглеть. Все оценки выше — БЕЗ огрубления.
- Spin12 — уже LUT (4 223 суммарно).
- Инлайн-ридер сэмплов — уже сделан; Track V2 отдаёт запечённые Vx/Vy.
- Flush-on-pressure — сторожевой контракт (комментарий-сторож в силе), не выхолащивать: вернётся обрыв хвоста на длинных цепях.
| # | Направление | Оценка |
|---|---|---|
| 1 | TRANSFORM-LUT вместо копроцессорного ROTATE | −25…40K |
| 2 | Партиционирование under/over, непрерыв. бакеты | −12…20K |
| 3 | BufferPtr в регистре, инлайн-эмиссия | −10…14K |
| 4 | Регистровая гигиена pre-pass | −12…16K |
| 5 | ExplodeFrame-гейт | DONE: −13K на 36+36, −33K на synthetic 105+105 |
| 6 | HUD-кэш | −8K (статика) |
| 7 | Dirty-skip | −весь кадр на неизменных |
Пп. 1–5 независимы и складываются: реалистичная цель 137K → 60–75K (живой кадр в бюджете одного видеокадра) без единой уступки в картинке. П.7 сверху делает медленные фазы почти бесплатными.
Отдельный резерв вне ZL_DrawFrame: VDC_UpdateAllChains 36K — из них
4×VDC_SwapChains ≈ 10K чистой бухгалтерии свапа (лечится расширением
указательного свапа на скаляры/блок ChainLocal).
4 → 3 → 5 (механические, низкий риск, сразу ≈ −30K) → 1 (главный выигрыш,
выверка запечённой матрицы против копроцессорной по .dl-дампам) → 2 → 6 → 7.
- Full-state рекордер: галочка
[X] REC FULL STATEв правом-нижнем углу окна дебаггера патченного Unreal; пишет каждый кадрunreal_x64\Debug\NNNNNN.{bmp,dl,z80}(кадр чипа + RAM_DL + Z80 страницы 4/5/6). Запись идёт при закрытом дебаггере (открытый = CPU стоит). Исходник патча:Backups\cleanup-2026-06-24\diagnostics\UnrealZ80ProfileL19\src(ft812.cpp/h, input.cpp, debugger/debug.cpp). - Разбор дампов: страница 4 = 0xC000-окно (VDC_Slots, HSA, вся цепь 2), страница 5 = 0x4000 (SlotsLen/CurrentLevel/GameState), страница 6 = 0x8000.
- «Недорисовка хвоста у устья» оказалась НЕ рендером: root-cause (найден
пользователем) — при
VDC_GLOBAL_SPEED_FACTOR=2первый SpeedAdvance мог перевести HSub 31→0, второй тик уводил дальше, иVDC_TrySpawnв конце кадра не видел HSub==0 → спавн на границе клетки пропускался, накопительный недобор хвоста (2–4+ шара). Баг воспроизводился и на чистом TS-Config (без FT812).