Files
Sprinter-SDCC/applications/PoP/docs/perf_registry.md
T
snark13 b6b214e225 Реестр: HEAL-WIDTH закрыт, P10 разобран без реализации
HEAL-WIDTH: плита 64->58, чомпер 64->61, габариты из каталогов атласов.
На 11/15 медиана не сдвинулась (плит нет), максимум -960.  Основной
эффект ждёт прогона 13/23, где плит шесть одновременно.

P10 разобран: «просто передать готовое из физики» не выйдет, величины
РАЗНЫЕ.  char_footprint берёт габарит кадра и расширяет диапазон на
колонку под меч; set_char_collision тот же fpw корректирует на FRAME_THIN
и меч не учитывает, а ряды у него — опорный curr_row, а не верх/низ
спрайта.  У оригинала обе задачи пользуются одними величинами, потому что
он считает их один раз; у нас они исторически разошлись.

Значит P10 — это сведение двух геометрий к одной, с риском для физики,
которая сейчас работает правильно.  Приоритет понижен до низкого, и
записано, чего не хватает: отдельного замера самого char_footprint
(сейчас известно лишь «вход + set_clip + footprint = 10 872»).

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

61 KiB
Raw Blame History

Реестр оптимизаций: всё отложенное, в одном списке

Собрано 2026-08-19 из perf_green_phase.md (G1-G9), perf_cyan_phase.md (C1-C7), perf_backlog.md (позиции 1-7), ../roomtest/TASKS_OPEN.md (HEAL-WIDTH) и из свежего разбора сцены perf_l11_room15.md.

База для процентов — работа кадра в 11/15: 801 768 тактов (замер 09f32ce). Где эффект относится к другой сцене, это сказано явно.

Оценки помечены: [замер] — измерено; [модель] — посчитано по измеренным составляющим; [гипотеза] — не мерено, нужен прогон.


1. Цена одного блита фона — разложена [замер 2026-08-19]

Метод: брейкпоинты на резидентных адресах внутри pop_blit_b (0x5C50), temp0 на входе, разница totalcycles на каждом вызове. 1603 блита.

этап такты постоянство
пролог + аргументы + грубый отсев 810 ровно, всегда
atlas_image 672 ровно, всегда
gfx_w0_map + чтение шапки ленты + арифметика клипа 2 400 ровно, всегда
ядро блита (пиксели) 4 380 … 26 592 по размеру кадра
pop_cd_touch 2 069 (пакетный путь) / 4 115 (настоящая пометка) почти ровно
gfx_w0_unmap + эпилог 175 ровно, всегда
весь блит 10 458 … 32 718, медиана 16 674

Фиксированная накладная = 6 126 тактов на любой блит, хоть 8×8. У самого дешёвого блита (10 458) это 59 % цены; у пламени факела 16×18 пиксели тянут ~1 700 из ~14 000, то есть 12 %.

Это и есть ответ на вопрос «почему маленький блит стоит 14 000». Причины ровно те, о которых спрашивал пользователь:

  • 810 на прологcall ___sdcc_enter_ix, IX-фрейм и шесть чтений N(ix): третий и четвёртый аргументы (int x, int ybottom) идут СТЕКОМ, каждое обращение 19 тактов Z80;
  • 2 069 на pop_cd_touch даже по пакетному пути, где вся работа — четыре сравнения. Сигнатура (int x, int y, int w, int h) = 8 байт аргументов, два из них через стек; w/h никогда не больше 64, y не больше 255, то есть три из четырёх могли быть uint8_t;
  • 2 400 на map + шапкуgfx_w0_map (1 086) + unmap (264, платится в конце) + ~1 000 на чтение четырёх байт заголовка и арифметику;
  • 672 на atlas_image — маппинг W3, чтение записи каталога, возврат W3; размеры при этом читаются и выбрасываются (см. §3, C6).

Блитов за кадр в 11/15: 8 [замер] — все в зелёной фазе (6 на draw_tile чомпера, 2 на пламя факелов). Синяя и циан через pop_blit_b не ходят вовсе: heal и персонажи идут своими путями. Значит фиксированные накладные блита стоят сцене 8 × 6 126 = 49 000 тактов/кадр (6,1 % работы).


2. Модель зелёной фазы 11/15 [модель, сходится с 320 916 замера]

статья такты доля фазы
8 блитов фона (из них 6 126×8 = 49 000 накладных) 133 400 41 %
диспетчер draw_tile (один тайл чомпера) 56 200 18 %
цикл pop_process_trobs без блитов пламени 59 100 18 %
pop_loose_tick (плит в комнате НЕТ) 28 872 9 %
heal 32×64 в pop_chomp_redraw 34 000 11 %
шов / ворота соседа 9 336 3 %

Больше половины фазы — не пиксели, а обвязка вокруг них.


3. Список приёмов, отсортированный по эффекту

P1. Чомпер: перерисовка неизменной позы СДЕЛАНО 2026-08-19 — 110 802

Диагноз, с которым позиция заводилась, оказался неполным. Я приписал 190 260 тактов пометке от факела; зонд pop_dbg_kind показал, что все 312 перерисовок прогона — вид POP_RD_CHOMP (полная), а пометку соседа она просто перебивала. Настоящая причина нашлась сверкой с animate_chomper (seg007:0448): оригинал перерисовывает чомпер только при фазе < 6, пять кадров из пятнадцати, потому что с фазы 5 поза не меняется (chomper_fram1 = {3,2,0,1,4,3,3}). Мы метили тайл каждый кадр.

Сделано три вещи: условие фазы (с пометкой обеих страниц на фазе 5), новый вид POP_RD_CHOMP_ANIMpop_chomp_anim_draw (три блита поверх огня, порт ветки redraw_frames_anim) и приоритет полной перерисовки над anim в pop_set_redraw. Обе половины работают: замер даёт 40 % полных перерисовок и 60 % лёгких.

Работа 768 684 → 657 882 (медиана), зелёная 294 510 → 183 420. В 40 % кадров цена осталась прежней — там поза действительно меняется, и это уже честная работа; резать её дальше только через P7 (раскол draw_tile) или P8 (ширина heal).

Полный разбор — perf_l11_room15.md §6.

Исходная (неполная) постановка

Разбор в perf_l11_room15.md §3. Сейчас пометка от факела обрабатывается как heal 32×64 + полный draw_tile (190 260 тактов на единственный перерисованный тайл кадра); оригинал в этом случае рисует ТОЛЬКО draw_tile_anim — графику чомпера поверх свежего пламени.

Останется 1-2 блита челюстей ≈ 20 000-33 000. Риск низкий: это сближение с оригиналом, а не отход от него. Побочно снимает широкую пометку «фон трогали» вокруг тайла чомпера — см. P3.

P2. Синяя фаза разложена [ЗАМЕР 2026-08-19] — гипотеза не подтвердилась

Замер зондами внутрь обеих половин синей (286 518 тактов):

участок такты доля работы
ввод + читы 14 154 1,8 %
три спецсобытия уровней (skel / mouse / killed_shadow) 1 650 0,2 %
pop_frame_timers + луч видимости стража 37 032 4,8 %
pop_ctrl_tick 18 648 2,4 %
skip_mask + heal двух Char 67 734 8,8 %
mirror_heal + fore_heal 2 040 0,3 %
kid_tick (play_seq) 10 098 1,3 %
pop_phys_tick (физика Кида) 61 266 8,0 %
pop_guard_tick (логика стража) 19 932 2,6 %
pop_guard_phys_tick (физика стража) 43 980 5,7 %
боёвка (sword_hurting / sword_hurt / delta_hp) 9 558 1,2 %
guard_fallout + уход из комнаты 366

Что оказалось не так, как ждали. Я предполагал, что дорогие тут банковые трамплины на спецсобытиях (по аналогии с pop_clip_char_top, 8 892 такта за трамплин ради одной проверки). Замер это отверг: три спецсобытия уровней вместе стоят 1 650 — они гейтятся внутри и на уровне 11 честно выходят сразу.

Настоящие статьи — три:

  1. Физика двух Char — 105 246 (61 266 + 43 980), при том что оба персонажа СТОЯТ и кадр позы не меняется. Это 13,7 % работы кадра и самая крупная статья синей. Нужен ещё один уровень разбора — внутрь pop_phys_tick (позиция P2a, отдельным заходом).
  2. Луч видимости стража — до 37 032 (вместе с pop_frame_timers, но тот заведомо копеечный: три счётчика). Считается КАЖДЫЙ кадр, хотя ни Кид, ни страж не сдвинулись. Кандидат на гейт «пересчитывать только при смене позиции или комнаты любого из двоих» — позиция P2b, ожидание −30 000, риск низкий.
  3. heal двух Char — 67 734. Отдельной правки не требует: он платится ровно потому, что skip_mask никого не пропускает, и уйдёт вместе с P1/P3.

Новое, найдено 2026-08-19.

P15. Точность метки «фон трогали» СДЕЛАНО 2026-08-19 — 163 746 / 140 871

Постановка пользователя: не перерисовывать стража, пока он не двигается.

Две правки, и вторая оказалась решающей:

  1. метка: вместо «маска колонок × три ряда по 63 px» — диапазон y на каждую колонку (ymin/ymax, 40 байт на обе страницы). Прежняя гранулярность склеивала пламя факела (y 33..50) с клинком стоящего стража (y 59..65), между которыми девять пикселей зазора;
  2. проверка: cd_quiet сверяет спрайт и накладной (клинок, брызги) ДВУМЯ отдельными прямоугольниками вместо объединённого bbox. Объединение включает пустой угол: спрайт стража в колонке 8, клинок уходит в колонку 7 на y 59..65, пламя метит колонку 7 на y 33..50 — и прямоугольник «спрайт + клинок» цеплял метку углом.

Без второй правки первая дала почти ноль (632 676 против 628 542 до неё) — это стоит помнить: точность структуры бесполезна, пока запрос к ней остаётся грубым.

лёгкая позиция тяжёлая позиция
синяя 259 050 → 223 902 257 520 → 245 808
зелёная 181 494 → 181 494 180 870 → 181 761
циан 194 262 → 59 406 319 842 → 192 090
работа 628 542 → 464 796 758 358 → 617 487

В тяжёлой позиции вдобавок исчезли пятирастровые кадры (было 27 %).

Проверено в MAME: статика чистая, динамика (пробежка, бой, переход в соседнюю комнату) без хвостов и просвечивания; хост-тесты зелёные.

Побочно исправлены два собственных дефекта первой редакции: обе страницы обновлялись по условию, проверяющему только страницу 0 (после pop_cd_clear(0) метка второй переставала расти), и отсутствовала явная инициализация — пустая колонка обозначается ymin = 255, а нули от crt0 читались бы как «затронута строка 0».

P16. Цианные проверки СДЕЛАНО 2026-08-19 — 25 818

Раскладка остатка цианной фазы (59 406) показала, что 47 883 из них — три вызова, а не отрисовка:

вызов было стало
pop_loose_mob_draw 978 978 (гейт mobs_live работает)
guard_over_kid 14 424 0
pop_char_skip_mask 28 605 ~21 000

Три правки:

  1. cd_sig_same — сравнение снимка БЕЗ построения структуры. cd_sig_make записывал тринадцать полей в стековый кадр (через -n(ix)), и лишь потом шёл побайтовый цикл; теперь сравнение идёт прямо с источником и выходит на первом расхождении. 8 016;
  2. guard_over_kid по условию — вопрос «кто поверх кого» не имеет смысла, когда не рисуется никто. Вызов перенесён ПОСЛЕ skip_mask и идёт только при skip != 3. 14 118;
  3. pop_cd_hit_slot — проверка «задет ли слот» брала пять аргументов, три из них стеком (45 % тактов на IX). Теперь координаты берутся из pop_cd, а сравнение вынесено в hit_rect с file-scope аргументами. 3 684.

Отрицательный результат внутри третьей правки (не повторять): первая версия была обёрткой, которая внутри всё равно звала pop_cd_hit с пятью аргументами — стало ХУЖЕ (1799 тактов Z80 вместо 1318). Снимать аргументы со стека надо у того, кто их читает, а не этажом выше.

Отрицательные результаты 2026-08-19 — НЕ ПОВТОРЯТЬ

Три попытки подряд сделали ХУЖЕ. Общая ошибка в двух из них — я оценивал правку по СУММЕ ТАКТОВ ИНСТРУКЦИЙ в листинге, а не по реально исполняемому пути.

1. cd_sig_same блоком вместо тринадцати сравнений. Снимок был переложен так, чтобы сравнивать непрерывные 10 байт начала pop_char_t циклом do { if (*a++ != *b++) return 0; } while (--i). По листингу функция стала короче (1939 → 1290 тактов), а на машине стало хуже: 438 978 → 450 426 (+11 448).

Причина: сумма по листингу считает каждую инструкцию ОДИН раз, а тело цикла исполняется ДЕСЯТЬ раз. Тринадцать линейных сравнений выполняются по разу каждое и выходят раньше на первом же расхождении. Урок: короткий листинг ≠ быстрый код; цикл надо разворачивать в уме.

2. cd_touch_pb — пометка «для блита» из file-scope. pop_cd_touch зовётся из pop_blit_b с четырьмя аргументами, хотя тот держит те же значения в pb_x/pb_top/pb_w/pb_h. Специализированный вход без аргументов дал 438 978 → 442 242 (+3 264).

Причина: в зелёной фазе блиты идут ПАКЕТНЫМ путём (draw_tile открывает pop_cd_batch), а там нужны все четыре значения сразу — и в регистрах (x, y приходят в HL/DE) они дешевле, чем чтение из статиков. Снятие аргументов со стека помогает не всегда: если значение и так живёт в регистре, статик его туда ещё и загружать заставит.

3. Обёртка pop_cd_hit_slot поверх pop_cd_hit (описана в P16): внутри всё равно звала функцию с пятью аргументами и добавила свои — стало 1799 тактов вместо 1318. Помогло только когда сравнение переехало внутрь.

P14. Fore-проход персонажа — от 4 122 до 117 570 [замеры 2026-08-19]

Самая НЕСТАБИЛЬНАЯ статья кадра. Замеры на одной и той же сцене:

ситуация fore-проход
персонаж пропущен (skip) 4 122
стоящий страж 62 778
живой Кид у чомпера ~89 000
труп Кида в челюстях 117 570

Растёт от двух вещей: ширины футпринта (широкий кадр смерти, вынутый меч добавляет колонку) и числа тайлов с передним слоем внутри футпринта (здесь чомпер со своими зубьями). Отсюда практический вывод: в бою проход будет ближе к сотне тысяч, чем к шестидесяти — кадры выпадов и ударов широкие.

Замер трупа сделан по просьбе пользователя. Сама по себе эта ситуация не игровая («когда Кид — труп, игры нет»), но именно она показала верхнюю границу цены.

РАЗБОР 2026-08-19: P14 сводится к P4. Fore-проход Кида в тяжёлой позиции (89 268) разложен зондами:

участок такты
вход + pop_fore_set_clip + char_footprint 10 872
арифметика границ окна 3 786
шов ворот + overlay-цикл 3 294
цикл fore_tile по тайлам 67 854 (76 %)
pop_gate_over_char + хвост 3 462

А счётчик показал, что цикл обходит всего 4 тайла, и 3 из них реально рисуют (FORE_ANY != 0). То есть 67 854 — это НЕ перебор лишних тайлов (их четыре) и не проверки, а цена самих блитов переднего слоя: около четырёх блитов по ~16 000, из которых 6 765 на каждом — фиксированная накладная (см. §1).

Отсюда вывод для плана: отдельной «оптимизации fore-прохода» почти нет. Срезать там можно ровно три вещи, и только первая крупная:

  1. цену блита (P4) — 4 блита × 6 765 накладных = ~27 000 из 67 854;
  2. char_footprint из физики (P10) — часть от 10 872;
  3. слияние двух трамплинов в банк 2 — ~4 000.

Иначе говоря, P4 ускоряет и зелёную фазу (8 блитов), и fore-проход (4 блита), то есть работает и в статике, и в динамике — в отличие от P14, который я считал самостоятельной позицией.

pop_char_fore = два трамплина в банк 2 (pop_fore_set_clip + pop_fore_over_char) плюс обход тайлов футпринта, в каждом fore_tile. У Кида дороже, чем у стража, потому что в его футпринте лежит чомпер, а у чомпера есть собственный передний слой (POP_CHOMP_FRAM_FOR), который перерисовывается поверх персонажа каждый кадр.

Что можно пробовать, по возрастанию радикальности:

  1. слить два трамплина в один вызов (мелочь, ~4 000);
  2. P10 — брать футпринт из физики, а не считать заново (−11 574 на проход, то есть до −23 000 на двоих);
  3. гейт по сигнатуре: пропускать проход, если не изменились ни кадр персонажа, ни тайлы его футпринта. Это расхождение с оригиналом — он рисует foretable безусловно;
  4. P13 — objtable и отложенные таблицы: у оригинала «посетить тайл» стоит копейки именно потому, что таблицы только копят записи.

P3. Персонажи будятся каждый кадр ЧАСТИЧНО СБЫЛОСЬ

В лёгкой позиции — да: после P1 pop_char_draw(KID) стоит 204 такта, метка от чомпера до Кида больше не дотягивается.

В тяжёлой позиции — нет: стоит Киду шагнуть на 7 пикселей вправо, и его спрайт пересекается с тайлом чомпера и пламени, skip_mask перестаёт пропускать, и он снова стоит ~144 000 (54 738 draw + ~89 000 fore). То есть выигрыш P3 держится только пока персонаж не подошёл к анимированному тайлу — а в игре он к нему подходит постоянно.

Исходная оценка (−70 000) была:

heal 141 048 + отрисовка 148 566 = 290 000 тактов (36 % работы) уходят на то, что pop_char_skip_mask не может пропустить ни Кида, ни стража: фон трогают каждый кадр.

  • Кида спасает P1: широкая пометка вокруг чомпера исчезнет;
  • стража спасти нельзя — пламя правого факела (0,7) рисуется в ячейке (0,8), где он и стоит. Там фон честно меняется, и оригинал персонажа тоже перерисовывает.

P17. 16 бит там, где хватает 8 2026-08-19 (замечание пользователя)

1. Границы экрана — беззнаковыми сравнениями. Проверка «спрайт целиком на экране» стояла как четыре ЗНАКОВЫХ 16-битных сравнения, а знаковое у SDCC z80 разворачивается в sbc плюс jp PO / xor 0x80 / jp P. Беззнаковая форма делает то же двумя: отрицательная координата становится очень большой и проваливает условие так же, как >= 0. 378.

2. Габариты спрайтов в байтах. w/h, ow/oh, fpw/fph, cw/ch в pop_cdraw_t, параметры cd_overlay_add/cd_clip_add, локали в pop_char_draw/cd_splash и чтение габарита из шапки ленты были uint16_t, хотя спрайты атласов не крупнее 64×64 (memory pop_sprite_size_limits). −276 в статике, −1 134 в циане динамики, плюс 24 байта _DATA.

P6a. Кэш указателя модификаторов 2026-08-19 — 840 (ждали 20 000)

pop_trob_modif объявлен __banked, а звался на КАЖДЫЙ trob внутри цикла pop_process_trobs, хотя комната у них в подавляющем большинстве кадров одна. Указатель теперь кэшируется между итерациями.

Цикл trobs 78 726 → 74 964, работа кадра 438 324 → 437 484.

Оценка в реестре была завышена в двадцать раз, и стоит понять почему: я перенёс её по аналогии с лучом видимости (P2b), где трамплин звался ДЕВЯТЬ раз за кадр. Здесь trob'ов в комнате всего несколько, и кэш экономит два-три вызова. Урок: «тот же паттерн» не означает «тот же порядок величины» — считать надо число вызовов, а не узнавать шаблон.

P6b. Кэш префетча кодов тайлов — НЕ ДЕЛАЛОСЬ

Префетч (pop_level_access_begin/end плюс чтение кода на каждый trob) стоит 11 058 за кадр. Кэшировать мешает инвалидация: код тайла меняет pop_level_set_tile (кнопка → пол, loose → empty), вход в комнату и добавление trob'а — пропустить хоть один источник значит получить застывшую анимацию. С учётом того, что P6a дал 840 вместо 20 000, ожидаемый выигрыш тут тоже стоит считать скромным, а риск он несёт несоразмерный.

P10. Футпринт из физики — РАЗБОР 2026-08-19 (без реализации)

Идея из perf_backlog.md §1: redraw_at_char (seg003:0430) берёт ГОТОВЫЕ char_col_left/right, char_top_row, char_bottom_row, посчитанные в том же кадре физикой (set_char_collision, seg006:0723), а наш char_footprint (pop_bg.c) считает их заново внутри fore-прохода.

Разбор показал, что «просто передать» не получится: величины разные.

char_footprint (банк 2, fore) set_char_collision (банк 3, физика)
ширина габарит КАДРА w из атласа, wh = (w+1)/2 то же fpw, но затем FRAME_THIN сдвигает края на ±4
меч расширяет диапазон на колонку (sword >= DRAWN) не расширяет
колонки cLraw до клампа (нужен для шва), затем кламп 0..9 coll_xl/coll_xr в пикселях, колонки считает уже calc_coll_window
ряды rT/rB от ВЕРХА и НИЗА спрайта, с форсом rT = rB-1 Char.curr_row — опорный ряд, это другое

То есть у оригинала обе задачи пользуются ОДНИМИ величинами, потому что он считает их один раз в set_char_collision. У нас они исторически разошлись: коллизии считают своё окно (с поправкой FRAME_THIN), fore — своё (габарит кадра плюс колонка под меч).

Значит P10 — это не «передать готовое», а сперва СВЕСТИ обе величины к одной, как в оригинале. Работа не механическая: FRAME_THIN влияет на коллизии осознанно (узкие кадры не должны цеплять стену), а fore-проходу нужен полный габарит, иначе передние грани в крайней колонке не перерисуются.

Чего не хватает для решения: отдельного замера самого char_footprint. Сейчас известно только «вход + pop_fore_set_clip + char_footprint = 10 872», а оценка 11 574 в backlog взята из старого замера другой сборки. Первым шагом нужен зонд между set_clip и char_footprint.

Оценка приоритета: низкая. Даже если char_footprint окажется всеми 10 872, он платится только когда персонаж рисуется (в статике fore-прохода нет), а сведение двух геометрий к одной — это риск для коллизий, то есть для физики, которая сейчас работает правильно.

P18. Метка «фон трогали» огрублена по X — ОТЛОЖЕНО (решение пользователя)

Найдено 2026-08-19 пользователем: Кид перерисовывается, хотя с пламенем не пересекается; на пиксель левее — перестаёт.

Разбор по памяти машины. Кид x = 156, спрайт занимает x 213..224, экранные y 43..83. Метка колонки 7 — y 33..50 (пламя правого факела). Колонка считается как x >> 5, то есть по 32 пикселя, и спрайт достаёт до 224 — ровно первый пиксель колонки 7. По вертикали пересечение с меткой настоящее (43..50), поэтому слот считается задетым.

А по горизонтали пересечения НЕТ: пламя лежит в колонке 7 на x 232..247, между ним и Кидом восемь пикселей зазора. На пиксель левее спрайт кончается на 223, 223 >> 5 = 6, колонка 7 не задета — и перерисовка пропадает.

То есть P15 исправил огрубление по Y и оставил его по X.

Почему отложено (аргументы пользователя):

  • x лежит в 0..319 и в байт не влезает — нужен uint16_t на границу, то есть 4 байта на колонку (80 байт на две страницы), и 16-битные сравнения в горячем пути. А они у SDCC z80 дороги ровно настолько, что могут съесть весь выигрыш (см. отрицательные результаты выше);
  • огрубить x вдвое (x >> 1, диапазон 0..159 влезает в байт) — это лишний сдвиг и при записи, и при проверке, плюс точность падает до 2 пикселей.

Непроверенная идея на будущее: хранить границы НЕ в экранных x, а как смещение ВНУТРИ колонки (0..31, пять бит). Тогда байта хватает и сравнение 8-битное, но запись усложняется: прямоугольник, пересекающий несколько колонок, даёт частичные диапазоны у крайних и полные у средних.

Когда браться: если после других позиций бюджет всё ещё не сойдётся. Выигрыш будет именно в пограничных положениях, а их в игре много — персонаж почти всегда стоит рядом с чем-то анимированным.

P4. Накладные блита — ОТКАЧЕНО

Правка сделана и отменена по решению пользователя. Критерий: если выигрыш получен ценой сильно усложнённого кода — откатывать.

Что было: atlas_image_w0 в libbgi читал каталог из уже подключённой в W0 страницы. 408 на кадре при ожидании −5 400.

Почему откачено: цена — вторая публичная функция в API libbgi с НЕЯВНЫМ контрактом («страница обязана быть подключена до вызова»), которую легко вызвать неправильно и молча получить мусор, плюс дублирование чтения каталога. 408 тактов — 0,09 % кадра, меньше разброса между прогонами.

Что осталось знанием: сам gfx_w0_map стоит всего 324 такта, а 672 у atlas_image — это почти целиком вызов функции и арифметика idx * 8. Значит непробованная часть P4 («один map на группу блитов») имеет потолок ~2 600 за кадр, а не 10 000, как считалось.

Ожидание было −5 400 (672 такта × 8 блитов зелёной фазы), и оно НЕ оправдалось: цена блита 16 107 → 16 005, то есть −102. Причина в том, что эти 672 — почти целиком вызов функции и арифметика idx * 8, а не само переключение окна. Замер после правки показывает, что работа просто переехала между статьями:

этап до после
пролог + отсев 810 762
gfx_w0_map (в составе 2 400) 324
каталог + шапка ленты + клип 2 694
ядро 8 508 8 508
cd_touch + unmap + эпилог 2 883 2 883
фиксированная накладная 6 765 6 663

Правка оставлена: не вредит, убирает лишнее переключение W3 и делает контракт честнее (страница мапится один раз). Но как способ снять накладные она не работает.

Что осталось непробованным (и во что я теперь верю меньше): один gfx_w0_map на ГРУППУ блитов — судя по замеру, сам map стоит 324, так что потолок этой правки ~2 600 за кадр, а не 10 000, как считалось.

Исходная постановка (модель −28 000)
правка на блит источник
pop_cd_touch: uint8_t вместо int для y/w/h, ранний выход пакетного пути ~1 300 новое
один gfx_w0_map/unmap на ГРУППУ блитов ~1 350 C5 / backlog §3
размеры ленты из каталога, без atlas_image и чтения шапки ~670 C6 / backlog §2
pop_blit_b: аргументы в 8 бит, где хватает ~400 новое

Все четыре — низкий риск, механическая работа. Вместе снимают ~3 700 из 6 126 фиксированных.

P5. pop_loose_tick при пустой комнате — 28 872 → 2 760 СДЕЛАНО 2026-08-19

Получено −26 112 внутри функции, −33 840 на кадре (замер до/после в 11/15). Оценка была 28 000.

Раскладка холостого хода (замер зондами m9..m12) и что с ней стало:

участок было стало
два цикла по тайлам (30 + 10 позиций) 9 852 132
pop_loose_mob_tick (обход 14 слотов) 12 090 996
check_loose_fall_on_kid (трамплин + обход) 5 868 546
вход + хвост 1 062 1 086
итого 28 872 2 760

Сделано двумя гейтами:

  • loose_any (статик pop_map.c) — «идёт ли анимация плит». Ставится в пяти местах записи ненулевой фазы, снимается САМИМ циклом по факту прохода, где не осталось ни одной живой фазы;
  • pop_mob_busy (резидент pop_state.c) — «занят ли хоть один слот падающего куска» (active или дочистка clean). Ставит mob_alloc, снимает обход по факту пустой таблицы. В резиденте, а не в pop_room.c, потому что читает его pop_map из банка 3.

Важно про границу: гейт отвечает не на «есть ли в комнате плиты», а на «идёт ли анимация». У лежащей плиты-потолка фаза 0, и крутить нечего — вопрос пользователя 2026-08-19. Асимметрия намеренная: ложная единица стоит одного холостого прохода, ложный ноль — застывшей навсегда плиты, поэтому взвод стоит рядом с КАЖДОЙ записью, а снятие только по факту.

Покрытие: phys_loose_floor_breaks (взвод от шага и сотрясения) и новый phys_loose_gate_survives_room_change — на пятое место взвода (фаза восстановлена входом в комнату), которое не покрывал никто. Мутационная проверка: со снятым взводом тест падает.

P6. pop_process_trobs разложен [ЗАМЕР 2026-08-19] — 89 784

участок такты
вход + префетч кодов тайлов (маппинг окна 0) 11 058
цикл: два pop_torch_draw ~36 000
цикл: обход самих trob'ов ~43 000

Цена одного pop_pot_b (пламя факела) измерена отдельно, брейкпоинтами на резидентных адресах: 17 346 тактов, и это ЕДИНСТВЕННАЯ группа в распределении — то есть pop_pot_b в кадре зовут только два факела. При канвасе пламени 16×18 сами пиксели там 1 716, то есть 10 % цены; всё остальное — накладные (см. §1) плюс ~6 900 сверх pop_blit_b на самом pop_pot_b.

Направления:

  • P6a: pop_trob_modif(room) зовётся банковым вызовом на КАЖДЫЙ trob внутри цикла, хотя комната у них одна и та же — вынести наружу;
  • P6b: префетч 11 058 маппит окно 0 каждый кадр, а коды тайлов trob'ов меняются редко — кэшировать с инвалидацией по смене тайла/комнаты;
  • P6c: цена факела — это цена блита, то есть позиция P4.

Новое, найдено 2026-08-19.

P7. G5. Раскол draw_tile на узкие части [оценка дока: −50 000 … 60 000]

Диспетчер + контекст оплачиваются целиком всегда; у оригинала это девять независимых функций. В 11/15 это те самые 56 200 на один тайл — но если сделан P1, draw_tile чомпера вообще не вызывается, и здесь эффект пропадёт. Ценность приёма — в ДРУГИХ сценах (13/23, любая комната с плитами).

Риск средний: в draw_tile собрано много инвариантов (BUG-LOOSE-3, BUG-LATTICE-DOORTOP, BUG-SEAM-WEDGE-1) — только отдельным заходом с прогоном всех уровней.

P8. HEAL-WIDTH — ширина heal'ов по фактическому следу [оценка: 5-6 % цены heal'ов]

ОБЯЗАТЕЛЬНАЯ по решению пользователя (2026-08-18). След плиты 58 px в подземелье / 57 во дворце против используемых 60 и 64. Постановка — TASKS_OPEN.md, якорь heal-width.

P9. G8 — пометку СОСЕДА ставить узкой полосой (28 px), а не тайлом [гипотеза]

Парная к HEAL-WIDTH. Относится к сценам с падающими плитами (13/23), в 11/15 не играет. Разбор — perf_green_phase.md §G8, там же три условия, из-за которых это не «просто уменьшить число».

P10. Футпринт персонажа — из физики, а не считать заново [замер: −11 574 на fore-проход]

backlog §1. С двумя персонажами — ~23 000 за кадр. Мешает то, что физика (банк 3) держит char_col_left/right в статиках, а слой фона — банк 2. Риск средний: окно fore-клипа заводилось под клинок и брызги.

P11. Мелочи с известной ценой [замер, backlog §7]

что цена где
pop_clip_char_top — трамплин банк 4 → банк 3 ради одной проверки 8 892 pop_cdraw.c
cd_sig_make + возврат из pop_char_draw 7 944 pop_cdraw.c
pop_loadkid + расчёт координат кадра 7 410 pop_cdraw.c
obj_x * 8 / 7 — последнее __divsint в горячем пути ~2 400 pop_char_draw

P12. G9 — снять временную оснастку [замер: −6 000]

pop_dbg_b1..b6 в pop_blit_b (~400 на блит), pop_dbg_kind/m16, pop_dbg_m5..m15, счётчик rd_cnt в pop_redraw_needed. Только ПОСЛЕ окончания оптимизации — без них не мерить.

P13. Крупные рефакторинги — брать, только если понадобится ещё запас

Чем P13 НЕ является (вопрос пользователя 2026-08-19). Это не «рисовать комнату заново каждый кадр в скрытый буфер». Такой вариант исключён арифметикой: 30 тайлов по 5-6 спрайтов при цене блита 16 674 (и 179 914 за полную запечку одного тайла) дают порядка 3 000 000 тактов — семь растровых кадров. Оригинал так тоже не делает: у него та же инкрементальная схема с пометками (redraw_frames_full / _anim / _fore), перерисовываются только помеченные тайлы.

Разница не в объёме отрисовки, а в цене ПОСЕЩЕНИЯ тайла: у нас fore_tile(r, c) сразу блитит (со всеми 6 126 фиксированных накладных), а у оригинала add_backtable/add_midtable/add_foretable только кладут запись в массив, и рисует один draw_table() в конце. Плюс у него ОДИН обход тайлов за кадр против наших трёх.

  • C7 / backlog §5-6: objtable + отложенные таблицы back/mid/fore. У оригинала «посетить тайл» стоит копейки, потому что таблицы только копят записи, а рисует один draw_table() в конце. У нас блит идёт сразу из обхода, и fore-проход отдельный НА КАЖДОГО персонажа.
  • backlog §4: единый проход по тайлам вместо трёх (pop_redraw_needed, pop_process_trobs, pop_fore_over_char) и семь счётчиков причин перерисовки вместо одного kind.
  • G6: меньше блитов в RD_FLOOR, G7: mob_tick_one в file-scope.

4. ПЛАН РАБОТ — состояние между сессиями

Рабочий чеклист. Правило: одна позиция = один заход = один коммит с замером до/после на сцене 11/15. Замер обязателен даже когда «очевидно» — из семи закрытых позиций ТРИ дали не то, что ожидалось (P1 — вдвое меньше, P2a — почти ничего, таблицы порогов — регресс).

Закрыто

# что факт
P15 точность метки «фон трогали» + раздельная проверка клинка 163 746 лёгкая / 140 871 тяжёлая
P16 цианные проверки: снимок без структуры, guard_over_kid по условию, hit_slot без пяти аргументов 25 818
P5 loose_tick: гейты холостого хода 33 840 (ждали 28 000)
P1 чомпер: перерисовка только при фазе < 6 110 802 (ждали 160 000)
P2b луч видимости: колонки + один банковый вызов 26 448 (ждали 30 000)
P2a coll_scan в 8 бит + снят с IX 2 892 (крупной статьи в физике нет)
P3 Кид перестал будиться каждый кадр сбылось само после P1 — но только в ЛЁГКОЙ позиции
P2/P6 замеры синей фазы и process_trobs гипотеза «трамплины на спецсобытиях» отвергнута
замер цианной фазы крупного лишнего в отрисовке персонажа нет

Осталось, по убыванию ожидаемого эффекта

# что ожидание риск комментарий

| P4 | накладные блита — 6 663 на КАЖДЫЙ блит | частично сделано: 408 | низкий | из четырёх правок сработала слабо; разбор ниже | | P14 | fore-проход персонажа | сводится к P4 + P10 | — | разбор ниже: цикл обходит всего 4 тайла | | P11 | мелочи с известной ценой | −26 000 | низкий | clip_char_top 8 658 подтверждён замером | | P10 | футпринт персонажа из физики | ? (нужен замер) | высокий | разбор ниже: величины физики и fore РАЗНЫЕ | | P6a/P6b | trob_modif из цикла, кэш префетча | −20 000 | низкий | тот же паттерн трамплина в цикле | | P7 | раскол draw_tile (G5) | 50 000 в 13/23 | средний | в 11/15 не играет | | P8 | HEAL-WIDTH | сделано: плита 64→58, чомпер 64→61 | — | эффект ждёт прогона 13/23 | | P9 | G8 — пометка соседа полосой | не оценено | средний | для сцен с плитами | | P13 | objtable + отложенные таблицы, единый проход по тайлам | не оценено | очень высокий | большой рефакторинг слоя фона | | P12 | снять оснастку | −6 000 | нулевой | последней: без неё не мерить |

Текущее состояние бюджета

работа синяя зелёная циан период
до оптимизации 801 768 293 238 320 916 187 758 4 растра
после P5 767 928 285 864 294 384 187 764 4
после P1 (медиана) 657 882 286 503 183 420 187 761 4
после P2a 654 990 283 215 183 798 187 812 4
после P2b (лёгкая позиция) 628 542 259 500 181 068 187 761 4
ТЯЖЁЛАЯ позиция (Кид на шаг правее) 758 358 257 520 180 870 319 842 4 и 5
после P15, лёгкая 464 796 223 902 181 494 59 406 4
после P15, тяжёлая 617 487 245 808 181 761 192 090 4 везде
после P16, лёгкая 438 978 218 052 181 494 39 438 4
после P4 438 570 правка ОТКАЧЕНА
после P17, лёгкая 438 324 217 590 181 098 39 192 4
после P6a, лёгкая 437 484 217 704 180 252 39 306 4
после P17, тяжёлая 603 684 241 956 181 464 180 270 4

Итог восьми позиций: 801 768 → 464 796 в лёгкой позиции (−42 %) и 758 358 → 617 487 в тяжёлой (19 %). Отдельно важно: в тяжёлой позиции исчезли пятирастровые кадры (было 27 %), период стал ровно 4 — рывки ушли.

Достижима ли цель — арифметика на 2026-08-19

Цель: работа ≤ 430 000, тогда период станет 3 растра (хвост кадра — три gfx_wait_vsync).

  • в ЛЁГКОЙ позиции снять надо 7 484;
  • в ТЯЖЁЛОЙ — 173 684.

Лёгких путей больше не осталось. За 2026-08-19 отвергнуто ЧЕТЫРЕ правки подряд (три с регрессом, одна почти без эффекта), и все они целили в накладные проверок и блита. Фиксированная часть блита 6 663 держится ядром gfx_w0_map/cd_touch/чтения шапки, а не «лишними» вызовами.

Всё оставшееся в списке, кроме P13, даёт по оценкам порядка 100 000 — и это оптимистично. Арифметика не сходится: сцена с двумя персонажами, чомпером и двумя факелами в три растра не укладывается без одного из трёх решений:

  1. P13 — переход на objtable и отложенные таблицы, как в оригинале (единственный резерв нужного размера, но это переписывание слоя фона);
  2. осознанное расхождение с оригиналом — например, не перерисовывать передний слой персонажа, пока не изменились ни персонаж, ни тайлы под ним (гейт по сигнатуре футпринта);
  3. принять 4 растра как рабочий режим для сцен такой плотности и выравнивать период, чтобы не было рывков 4/5.

Решение за пользователем — это выбор между точностью порта и скоростью.

Как воспроизвести сцену (важно для следующей сессии)

Сборка стартует прямо в ней: make (дефолты LEVEL=11 ROOM=15 POS=2) → make hddполный рестарт MAME (chdman -f даёт новый inode, memory mame_hdd_rebuild_restart) → в DSS набрать d: и roomtest. Кид встаёт в (0,2) лицом к чомперу, справа факел и страж — та самая сцена замеров. Штатный старт уровня возвращается через make ROOM=.

Проверка, что программа ЖИВА, обязательна перед любым чтением памяти: cur_room (0x97AA) должен лежать в 1..24 — на этом уже был сорван один замер (прочитаны два случайных байта остановленной машины).

Метод замера

Зонды — out (_io_border) в roomtest.c (база модуля 0x42AD) плюс резидентные пустышки pop_dbg_m* из pop_state.c. Адреса брать ЗАНОВО из .sprinter-cc-roomtest/roomtest.map после каждой пересборки. Скрипты сессии: perfrun.py <out> <сек> tag=addr ... и parseseq.py <файл> ПОСЛЕД.

Цену отдельной РЕЗИДЕНТНОЙ функции можно снять вообще без пересборки: bpset <вход>,1,{temp0=totalcycles; g} плюс bpset <точка>,1,{printf "… %d",totalcycles-temp0; g}. Так разложен блит в §1.


5. Сводка: что сколько даёт в 11/15

# приём эффект тип оценки риск
P1 чомпер: только anim-слой 160 000 модель низкий
P3 Кид перестанет будиться 70 000 модель следствие P1
P2 логика двух Char 40 000 … 70 000 гипотеза ?
P4 накладные блита (4 правки) 28 000 модель низкий
P5 loose_tick без плит 33 840 ФАКТ сделано
P6 цикл process_trobs 20 000 … 40 000 гипотеза ?
P10 футпринт из физики 23 000 замер средний
P11 мелочи (4 штуки) 26 000 замер низкий
P12 снять оснастку 6 000 замер нулевой
P7 раскол draw_tile 0 здесь (50 000 в 13/23) оценка средний
P8/P9 HEAL-WIDTH / G8 0 здесь (сцены с плитами) оценка низкий/средний

Верхняя часть списка (P1 + P3 + P4 + P5) — около 286 000 из 801 768, то есть 36 % работы кадра, и вся она низкого риска. Этого хватит, чтобы сцена ушла с 1,86 растрового кадра до ~1,2 — но НЕ хватит, чтобы период кадра упал с 4 растров до 3: для этого работа должна уложиться в 430 000, то есть нужны ещё ~90 000 сверху (P2 или P6).


5б. Цианная фаза разложена [ЗАМЕР 2026-08-19]

Фаза 188 004 тактов, и она НЕ менялась ни от P1, ни от P5, ни от P2b.

участок такты
check_mirror 3 198
loose_mob_draw + guard_over_kid + skip_mask 34 374
pop_char_draw(KID) 204
соперник: char_draw + char_fore 145 896
fore_needed + hp_draw 2 598
char_fore(KID) + борта 1 758

Кид уже пропускается — 204 такта, то есть надежда P3 всё-таки сбылась после P1: метка от чомпера до него больше не дотягивается. А страж перерисовывается каждый кадр, и это ЧЕСТНО: пламя правого факела (0,7) рисуется в ячейке (0,8), где он стоит, и реально накрывает ему голову (пламя занимает y 5..22, страж 12..62).

Отрисовка стража (148 302) по частям:

участок такты доля
pop_char_fore (два трамплина в банк 2 + обход тайлов) 62 778 42 %
клинок: pop_sword_draw + cd_overlay_add + cd_clip_add 27 522 19 %
блит спрайта + clip_char_right 20 982 14 %
загрузка кадра и геометрия 12 696 9 %
pop_clip_char_top (трамплин банк 4 → банк 3) 8 658 6 %
снимок прямоугольника + cd_clip_add 7 890 5 %
gfx_w0_unmap + cd_sig_make 4 968 3 %
вход + cd_heal 2 946 2 %

Вывод: крупного лишнего здесь нет. Единственная явно лишняя статья — трамплин clip_char_top (8 658), и убрать его непросто: функции нужны get_tile и таблицы деления из банка 3, а перенос в резидент вернёт тот же трамплин внутрь. Всё остальное — работа, которую персонаж действительно делает: рисует себя, клинок и передний слой поверх себя.

6. Иерархия референсов (уточнена 2026-08-19)

Сравнение трёх реализаций луча видимости показало, что источники не равноценны, и это важно для ЛЮБОЙ будущей оптимизации:

источник что берём чего НЕ берём
Apple II (Prince-of-Persia-Apple-II) как это делается на 8 битах: таблицы вместо делений, борьба за такты ничего — но код на 6502, читать сложнее
SDLPoP эталон ПОВЕДЕНИЯ (декомпиляция DOS-версии) реализацию: она нарочно «расслаблена» под 32 бита
mininim разбор краевых случаев, второе мнение о замысле алгоритмы — переписан с нуля, механика местами своя

Доказательство на конкретном месте: get_tile_div_mod в SDLPoP содержит комментарий

// DOS PoP does this:
//	obj_xl = tile_mod_tbl[xpos];
//	return tile_div_tbl[xpos];

а вместо этого делает x % TILE_SIZEX и x / TILE_SIZEX. Таблицы в файле лежат, но нужны только для эмуляции чтения DOS-версии ЗА ГРАНИЦЕЙ массива. Apple II (CTRLSUBS.S, GETBLOCKX) читает ровно BlockTable[x].

Правило: сверять поведение по SDLPoP, а реализацию под 8 бит — по Apple II и по комментариям вида «DOS PoP does this» в самом SDLPoP.

7. Повторяющийся источник цены: банковый трамплин в цикле

Уже трижды крупнейшей статьёй оказывался не алгоритм, а вызов __banked- функции ИЗ ЦИКЛА, идущего в другом банке:

место цена лечение
луч видимости: pop_tile_at по колонке (P2b) 36 786 → 13 002 один вызов на весь отрезок
pop_clip_char_top — банк 4 → банк 3 ради одной проверки 8 892 не сделано (P11)
pop_trob_modif(room) на каждый trob в цикле не мерено не сделано (P6a)

Что проверять в первую очередь при новом «дорогом» месте: не сколько там арифметики, а сколько раз за кадр пересекается граница банка.