Skip to content

Latest commit

 

History

History
214 lines (172 loc) · 15.2 KB

File metadata and controls

214 lines (172 loc) · 15.2 KB

Оптимизация ZL_DrawFrame по скорости Z80 — анализ

Дата: 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}).

Профиль (замер Unreal, dual-chain top-mask уровень)

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 бюджета.

1. Матричные эмиты — главный жир draw-проходов (−25…40K)

Факт. С переходом на глобальный 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 точнее).

2. Четыре draw-прохода сканируют весь кэш с фильтрами (−12…20K)

Факт. В 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 мультипликативно.

3. BufferPtr в регистре на весь цикл (−10…14K)

Факт. 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 внутри цикла обязан синхронизировать регистр ↔ память (две локальные точки).

4. Микрохирургия pre-pass — 51 229 тактов, ~450/шар (−12…16K)

По инструкциям цикла (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.

Чистая регистровая гигиена, механики не меняет.

5. ExplodeFrame-гейт (внедрено 2026-07-02)

Факт. 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 -> 300053 tstates, delta 12950.
  • L19 synthetic 105+105: 634927 -> 667809 tstates, delta 32882; CMD-поток 525 -> 537 words, то есть no-explosion кадр ещё и на 12 CMD-слов короче.

Абсолютные tstates Python-harness не сравнивать напрямую с patched-Unreal zprof; относительная дельта внутри одного harness валидна. Старая оценка −6K была занижена: она считала только per-ball read и не учитывала безусловочный destroy-pass.

6. HUD-текст — 8 967 тактов каждый кадр (−8K на статике)

SCORE/время меняются редко, а глифы пересобираются в CMD-поток каждый кадр. Предложение: собрать блок один раз в теневой RAM-буфер, лить LDIR'ом; пересборка — только по флагу изменения значения (ставится в местах инкремента score/времени).

7. Радикальное: dirty-frame skip (доказано дампами)

В серии 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.

Связанные факты сессии 2026-07-01/02

  • 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).