Files
Sprinter-SDCC/applications/SprPoP/docs/perf_l13_room23.md
T
snark13 31b82661eb SprPoP: автономное приложение, выделенное из roomtest
Порт PoP переехал в applications/SprPoP — приложение, которое собирается
само: код, оригинальные данные, конверторы ресурсов и сборка внутри одной
папки.  Наружу знает единственный путь — корень тулчейна (SPRINTER_ROOT,
по умолчанию ../..).  applications/PoP/roomtest ЗАМОРОЖЕНА и остаётся
архивом закрытых задач, багов и исполненных планов.

Скопировано из applications/PoP/roomtest@4b74478.  Перенос проверен
побайтово: собранный sprpop.exe совпал с roomtest.exe того же коммита,
все 39 дисковых ресурсов и все 16 генерируемых заголовков — тоже, host-
тесты зелёные (15/15).

Раскладка:
  src/           рукописный C (roomtest.c -> sprpop.c)
  gen/           генерируемые заголовки, в репозитории
  assets/orig/   оригинальные данные игры, вне репозитория (копирайт)
  assets/packed/ то, что ложится на диск, в раскладке диска
  tools/         конверторы; все пути — в одном tools/paths.py
  build/         выход: exe, каталоги ресурсов, hdd/, промежуточные atl/

Сборка ресурсов: assets/packed и gen — версионируемые ВХОДЫ, а не то, что
пересчитывается каждым make.  Автоматика построена на ОТСУТСТВИИ файла, а
не на таймстемпах: git не хранит времена, и в свежем клоне сравнение по
времени превращалось бы в лотерею.  Недостающий ресурс или заголовок
чинится сам, рекурсивным вызовом в ветку генерации.

Музыка собирается из любого из четырёх наборов записей (make music-mp3,
music-mt32, ...); набор входит в имя stamp'а, поэтому смена набора сама
делает музыку устаревшей.  Длины реплик больше не захардкожены: упаковщик
печатает их в gen/pop_music_ticks.h, и шкала сцены выражена через них —
иначе mt32 (реплики на 6% длиннее) молча ломал катсцену.

Тулчейн: в app.mk два обратносовместимых крючка (SRC_DIR/BUILD_DIR),
HDD_IMG стал ?=; команда сборки roomtest не изменилась.  Корневой
make host-tests переключён на SprPoP.

Подгонка тайминга катсцены с принцессой (PV_MAGIC_LEAD): сцена
render-bound и идёт ~49 тиков/с вместо 60, из-за чего кода реплики
приходила раньше молнии.  Это обход, а не лечение; разбор с замерами —
docs/BUGS_OPEN.md, записи SND-PACE-DEAD, PV-RENDER-BOUND, MUS-LEFT-TEAR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:12:28 +03:00

19 KiB
Raw Blame History

Сцена и метод замера: каскад плит, уровень 13 комната 23

Общий документ для двух фазовых: perf_green_phase.md (слой фона) и perf_cyan_phase.md (персонажи + передний слой). Здесь — как воспроизвести сцену, чем мерить, сводка по кадрам и разбор габаритов спрайтов (он общий для обеих фаз).

Сцена: старт уровня 13. Комната 23 стартовая, ряд 2 комнаты СВЕРХУ (17) — шесть loose-плит в колонках 2..7 (res2013.bin: коды 11 в позициях 22..27), check_fall_flo раздаёт им отложенный старт 0xF0..0xFF, и они сыплются вразнобой. Кид стоит у правого края и не двигается.

Все числа — такты totalcycles MAME (системный клок ~21,5 МГц, НЕ такты Z80: у ОЗУ Sprinter wait-state'ы, ≈2,4× номинала — memory sprinter_wait_states_2x). Растровый кадр = 430 000. Логический кадр спейсится тремя gfx_wait_vsync, поэтому работа сверх 430 000 стоит сразу целый лишний растровый кадр.

Сборка: make LEVEL=13 на c312e4a, _CODE = 0x4100, база модуля sprpop.c = 0x42AD. Адреса зондов меняются после КАЖДОЙ пересборки — брать заново из .sprinter-cc-build/.sprinter-cc-sprpop/sprpop.map и sprpop.lst.


1. Как воспроизвести сцену

Только перезапуском программы. Проверено и отвергнуто:

  • выход из комнаты и возврат (чит +/-) — не работает: провалившаяся плита-потолок уходит в страницу уровня насовсем (pop_level_set_tile в sprpop.c по сигналу pop_ceil_fell, плюс animate_loose в pop_trob.c), и при повторном входе check_fall_flo не находит ни одной TILE_LOOSE;
  • рестарт уровня (pop_kid_dead = 1 + чит навигации) — не работает по другой причине: pop_start_level() сам заходит в стартовую комнату 23, взводит гряду, и она доваливается ЗАОЧНО (через trob комнаты 17), пока телепорт уносит Кида в комнату 24;
  • поставить сцену руками (записать pop_ceil_modif[2..7] и копию ряда сверху pop_t_above[2..7] отладчиком) — записи ложатся, но пока машина БЕЖИТ, их успевает обнулить тот же доваливающийся trob.

Рабочий рецепт (идея пользователя, самый чистый): ESC → зонды → SprPoP. ESC выходит в DSS, запуск заново стартует уровень 13 с нуля, Кид сразу в комнате 23, каскад начинается через ~5 логических кадров после отрисовки комнаты. Зонды обязаны стоять ДО набора SprPoP — за время набора (9 клавиш ≈ 1,8 с) и загрузки атласов каскад успевает пройти целиком.

2. Канал вывода замеров

printf из действия брейкпоинта в error.log не попадает. Читается verb'ом clog N плагина mamebridge — а его нет в MCP-обёртке (mame_mcp.py знает только cmd). Годится прямой файловый IPC: положить /tmp/mame_mcp/req_<ЧИСЛО>.txt с телом команды и прочитать resp_<ЧИСЛО>.txt. Имя обязано содержать ЧИСЛО (init.lua: entry:match("^req_(%d+)%.txt$")) — с буквенным id запрос молча не обслуживается.

Скрипты сессии (в scratchpad, при необходимости пересоздать): mrpc.py — клиент IPC; run.sh — цикл «bpclearESC → зонды → SprPoPclog»; parse*.py — разбор трассы по кадрам.

Форма зонда: bpset <addr>,1,{printf "<метка> %d",totalcycles; g}. Для pop_dbg_kindprintf "K %d %d",a,totalcycles (аргумент uint8_t приходит в A, __sdcccall(1)).

3. Зонды

Адреса out (_io_border), a (полосы бордюра) из sprpop.lst плюс однобайтовые пустышки pop_dbg_* из резидентного pop_state.c. Резидент важен принципиально: у банковых функций один адрес 0xC000+ есть у восьми модулей сразу, и брейкпоинт ловит все банки (так в прошлой сессии намерили несуществующие 134 730 тактов).

зонд адрес что
A 0x43E8 PROF(2) — начало кадра (ввод + heal)
0x46AF PROF(2) — начало логики
C 0x47D1 PROF(4) — начало слоя фона (зелёная)
D 0x4BCE PROF(6) — начало спрайтов (циан)
M 0x4C26 PROF(6) — кадр Кида
F 0x4C93 PROF(6) — fore поверх Кида
E 0x4CB7 PROF(0) — конец работы, ждём vsync
m5/m6/m7 0x4DC6 / C7 / C8 границы внутри зелёной
m9..m12 0x4DCA..CD внутренности pop_loose_tick
m13/m14/m15 0x4DCE / CF / D0 pop_ceil_shake_draw: вход / heal / draw_tile
kind / m16 0x4DD1 / D2 вид и цена одной перерисовки в pop_redraw_needed
b1..b5 0x4DD3..D7 участки одного pop_blit_b

Полезные адреса состояния (из sprpop.map): pop_t_room 0x95F2, pop_current_level 0x9945, pop_ceil_modif 0x9C9E, pop_t_above 0x95EE (указатель), pop_kid_dead 0x9C4E, pop_dbg_rdmax 0x95B1. Запись в память через MCP — по адресу 0x10000 | addr (логический вид Z80); присваивание выражением дебаггера (print b@... = 1) не работает.

Грабли: проверять, что запущен РОВНО ОДИН MAME (pgrep -f mame.arm | wc -l). Мост говорит с одним, замеры собираются с другого, и точки «не срабатывают».


4. Сводка по кадрам

Базовый замер (c312e4a, ДО оптимизации):

фаза каскада работа синяя зелёная циан период (растр.)
покой в комнате 23 190 860 134 550 35 760 20 550 3
дрожат 6 плит 537 400 142 700 366 000 28 700 4
провалы + полёт, ПИК 1 437 150 142 700 663 250 631 200 56
максимум по секции 142 830 805 000 631 800

После оптимизации (af189a1, 2026-08-17):

максимум по секции работа синяя зелёная циан
было 1 437 150 142 830 805 000 631 800
стало 916 458 142 830 546 900 270 510
36 % 32 % 57 %

Регресс после обхода уровней 1-2 (ec1f384, 2026-08-17), 418 кадров:

максимум по секции работа синяя зелёная циан
после оптимизации (af189a1) 916 458 142 830 546 900 270 510
после фиксов ур. 1 (40f0d46) 873 930 158 874 417 630 379 482
после фиксов ур. 2 (ec1f384) 878 550 158 880 419 526 379 488

Фиксы второго уровня (чёрные бары, чит бессмертия) на бюджет не повлияли: разница с предыдущим замером +4 620 работы и +1 896 зелёной — шум прогона.

Регресс после обхода уровней 3-7 (3bcaf51, 2026-08-18), 2435 кадров:

максимум по секции работа синяя зелёная циан
после фиксов ур. 2 (ec1f384) 878 550 158 880 419 526 379 488
после фиксов ур. 3-7 (3bcaf51) 880 170 159 774 419 520 380 244
разница +1 620 +894 6 +756
+0,2 % +0,6 % 0,0 % +0,2 %

Все четыре секции — в пределах шума прогона (сравнить с +4 620 / +1 896 выше, которые уже признаны шумом). Зелёная совпала с точностью до 6 тактов.

Что за это время добавилось в горячий путь: pop_spike_frame и pop_chomp_pose (SPIKE-BAKED) — один резидентный call на слой и ТОЛЬКО на тайлах-ловушках, в этой комнате их нет; и снятие раннего выхода для трупа (DIED-ON-BUTTON) — цепочка физики на мёртвом Киде, а он тут жив. Замер это подтверждает: цена не сдвинулась.

Распределение периода тоже совпало с эталоном кадр в кадр: 3 растра в 2408 кадрах, 4 в 24, 5 в одном — против «3 в 392, 4 в 24, 5 в одном» у ec1f384 (кадров в этом прогоне больше просто потому, что дольше стояли в покое после каскада). То есть за бюджет вылезает ровно тот же кусок сцены и ровно на столько же кадров.

Регресс после обхода уровней 8-9 (0dd2f6a, 2026-08-18), 2701 кадр:

максимум по секции работа синяя зелёная циан
после фиксов ур. 3-7 (3bcaf51) 880 170 159 774 419 520 380 244
после фиксов ур. 8-9 880 272 159 822 419 562 380 202
разница +102 +48 +42 42

Разброс ±100 тактов на 880 000 — это 0,01 %, то есть чистый шум прогона (циан вообще ушёл в минус). Период снова совпал кадр в кадр: 4 растра в 24 кадрах, 5 в одном.

Что добавилось за это время и почему не подорожало: фиксы стража (c40ae3f) правят только вход в комнату — кода в кадре не прибавилось; фикс боя у шва (a498255) добавил два сравнения в check_leave, а в этой сцене Кид неподвижен и до порогов не доходит. Отладочная трасса DBG_KIDOBJ выключена дефайном и в сборку не попадает. Скачок циан на фиксах ПЕРВОГО уровня (270 510 → 379 482) объяснён там же: восстановлены потерянные половины слоёв (set_redraw2, ряд 1 foretable), то есть это плата за корректность, а не регрессия.

Период кадра по прогону: 3 растра в 392 кадрах, 4 в 24, 5 в одном — то есть за бюджет вылезает только сам каскад.

Цель — каждая секция ≤ 400 000. Синяя и циан в бюджете; зелёная 419 526, то есть 1,05× цели (и ниже растрового кадра 430 000), остаток разобран в perf_green_phase.md §3 (нужен раскол draw_tile на узкие части, как в оригинале) и в идее G8 (сузить инвалидацию соседнего тайла до 28-пиксельной полосы).

Где что расходуется и как это чинить — в фазовых документах: зелёная, циан.

Общий вывод по пиковому кадру базового замера (1 437 150)

такты доля
блиты (все 27–28 вызовов pop_blit_b) 488 100 34 %
из них «железный» минимум пикселей (модель 198*h + 5,96*w*h) ~150 000 10 %
синяя (ввод + heal + логика) 142 700 10 %
наши накладные: draw_tile, IX-кадры, диспетчер, пометки ~1 150 000 ~80 %

Узкое место — НЕ передача пикселей (она на пределе железа, 3+3 такта на байт, memory blit_cost_model), а 16-битная арифметика в стековых кадрах.


5. Габариты спрайтов: можно ли всё перевести на uint8_t

Просканированы каталоги ВСЕХ .atl (109 файлов) и исходные PNG наборов TITLE/PV — тех, что понадобятся для интро, финала и роликов между уровнями.

Игровой кадр — весь укладывается в байт:

набор максимум
фон подземелья/дворца (*_env*, *_wall, *_fore, pop_pot) 48 × 63
Кид (kid0..27, sword) 56 × 63 (kid3, idx 0)
страж / скелет / Джафар 53 × 42
спрайты комнаты принцессы (PV.DAT: персонажи, песочные часы, факел, звёзды) 49 × 60

Больше 255 — только полноэкранные подложки титров и сюжетных экранов. Их восемь, и все рисуются ОДИН раз при показе экрана:

ресурс размер где (data.h, full_image[])
TITLE/res51 320 × 200 TITLE_MAIN, xpos 0 ypos 0
TITLE/res41 320 × 200 STORY_FRAME, xpos 0 ypos 0
PV/res951 320 × 200 фон комнаты принцессы (chtab_9_princessbed)
TITLE/res42..res45 272 / 267 / 264 / 256 × 134..142 «presents», «Prince of Persia», «Mechner»
TITLE/res54 272 × 65 заголовок Hall of Fame

Высота нигде не превышает 200 — в байт лезет. По ширине не лезут ровно эти восемь, и ни одна из них не участвует в игровом кадре.

Вывод: горячий путь можно переводить на 8-битные габариты целиком. Для подложек — решение пользователя (2026-08-17): работу с роликами вынести в отдельный банк с версиями блита под большие спрайты либо звать libbgi напрямую — клип и проверка выхода за экран им не нужны (рисуются в x = 0/24/48/96, заведомо внутри 320×200). Ширина 320 всё равно потребует ДВУХ burst-скобок акселератора на строку — как уже сделано в pop_vflip.

Существующая страховка уже есть и остаётся: pop_blit_b уводит кадр с img[1] | img[3] != 0 на общий путь blit_b_oversize.

Регресс после дня оптимизации 11/15 (d0ac4b1, 2026-08-19), 2367 кадров:

максимум по секции эталон mob-order-B-done сейчас разница
работа 913 848 911 862 1 986
синяя 159 810 149 106 10 704
зелёная 440 418 436 494 3 924
циан 393 000 382 770 10 230

Период: 3 растра в 2341 кадре, 4 в 23, 5 в 2 — как в эталоне.

Почему сумма минусов по фазам не равна минусу по работе: максимумы разных фаз достигаются В РАЗНЫХ КАДРАХ (пик синей — не тот кадр, где пик зелёной), а «работа» здесь — максимум СУММЫ, а не сумма максимумов.

Что из правок 11/15 сюда дошло: P16 и P2b дали синюю и циан (они про проверки и луч видимости, а те работают в любой сцене), HEAL-WIDTH дал зелёную (плита 64 → 58 на шести heal'ах кадра).

Зелёная по-прежнему выше растрового кадра (436 494 против 430 000). Главный оставшийся кандидат именно для этой сцены — P9 (G8): при падении плиты помечаются ДВА тайла, и соседний перезапекается целиком и повторно (draw_tile соседа дважды на одну пометку), хотя потревожены у него только левые 28 пикселей. При шести падающих плитах это умножается на шесть.

ВАЖНО ДЛЯ ПРОЦЕССА. Этот прогон вскрыл регрессию, которую не поймали ни хост-тесты, ни сцена 11/15: гейт loose_any (позиция P5) не взводился в check_fall_flo, и плиты уровня 13 дрожали, не падая. Сцену 13/23 надо прогонять после КАЖДОЙ правки loose-механики, а не только когда меняешь её сознательно.