# ЗЕЛЁНАЯ фаза (слой фона) — анализ и оптимизация Рабочий документ: живёт между сессиями. Внизу **журнал правок** — каждая запись с замером до/после. Сцена, рецепт воспроизведения и зонды — [`perf_l13_room23.md`](perf_l13_room23.md); там же ответ про 8-битные габариты. Парная фаза — [`perf_cyan_phase.md`](perf_cyan_phase.md) (её цель достигнута). Границы фазы в `roomtest.c`: от `PROF(4)` (строка 450) до `PROF(6)` (строка 620). Содержимое: `pop_loose_tick`, `pop_process_trobs`, `pop_redraw_needed`, шов, смена уровня, сигналы провалов, вспышка. **Бюджет: ≤ 400 000 тактов (растровый кадр = 430 000).** | состояние | было (`c312e4a`) | стало (`af189a1`) | |---|---:|---:| | покой в комнате 23 | 35 760 | 35 760 | | дрожат 6 плит-потолков | 366 000 | 335 400 | | **пик каскада** | **805 000** | **546 900** | **ЦЕЛЬ ФАЗЫ НЕ ДОСТИГНУТА: 546 900 против 400 000 (1,37×).** Что осталось сделать и почему это именно раскол `draw_tile` — §3. --- ## 1. Раскладка ПОСЛЕ правок (замер `af189a1`) Пик — кадры 24-28 (посадки плит), больше не кадры провалов. | участок | покой | дрожь | пик | |---|---:|---:|---:| | `pop_loose_tick` | 36 234 | 36 234 | **156 762** | | `pop_process_trobs` | 1 050 | 1 050 | 1 050 | | **`pop_redraw_needed`** | 924 | 288 800 | **380 568** | | хвост (шов, смена уровня, сигналы, вспышка) | 9 336 | 9 336 | 10 776 | `pop_loose_tick` изнутри на пике: два цикла по тайлам 9 852, **`pop_loose_mob_tick` 154 074** (heal шести летящих кусков), остальное мелочь. ### Цена одной перерисовки: было → стало | вид | было | стало | чем | |---|---:|---:|---| | `RDA_CEIL` — дрожащая плита-потолок | 48 785 | **43 536** | G2 + G3 | | `RDA_CEIL_GONE` — запечь колодец | 251 335 | **138 318** | **G1** (клип полосы) + G2 + G3 | | `RD_FLOOR` — щебень на месте посадки | 198 805 | **179 914** | G2 + G3 + снятая двойная пометка | Штук за кадр: `RDA_CEIL` до 6, `RDA_CEIL_GONE` до 2, `RD_FLOOR` до 2. ### Детальный профиль `RD_FLOOR` (179 914) — главная оставшаяся статья Снят зондами по каждому блиту (`pop_dbg_b1/b5`) и по рамкам (`pop_bar_black`, `pop_cd_batch_begin/end`): | участок | такты | |---|---:| | вход `pop_floor_bake` + `gfx_set_bank` | 2 382 | | `pop_bar_black` 60×39 (включая `pop_cd_touch` 4 502) | ~15 500 | | **контекст `draw_tile` #1** (5 чтений тайлов, `63*row`, индексация таблицы) | **13 584** | | 4 блита тайла #1 | 50 718 | | **диспетчер между блитами #1** (все `if (code == …)`) | **11 388** | | **контекст `draw_tile` #2** | **13 584** | | 4 блита тайла #2 | ~54 000 | | **диспетчер между блитами #2** | **11 388** | | хвост | 6 474 | Итого: **105 500 — сами блиты (реальные пиксели), 74 400 — накладные**, из которых 27 168 контекст двух `draw_tile` и 22 776 их диспетчер. --- ## 2. Что сделано (с чем сравнивать) ### G1. Окно клипа для точечной перерисовки — −90 000 `pop_t_win_set(x, ytop, w, h)` / `pop_t_win_clear()` в `pop_tile.c`: ставит уже существующее окно `pop_t_fclip_*` на прямоугольник, который перерисовка восстанавливает. Работает в обе стороны — и предфильтр `pop_blit_b` отсеивает куски мимо окна ДО `atlas_image`/`gfx_w0_map`, и `blit_b_clip` режет остальные по нему. Где сработало: `pop_ceil_bake_empty` — куски ряда 0 высотой 63 px рисовались целиком, хотя восстановить надо девять строк полосы. **Блит 21 447 → 7 619**, вся перерисовка 251 335 → 138 318. Где НЕ сработало — см. §4, отрицательные результаты. Побочно: пока окно стоит, `pop_blit_b` не ставит пометку «фон трогали» (признак fore-прохода), поэтому вызывающий обязан пометить прямоугольник сам. В `pop_ceil_shake_draw` добавлен явный `pop_cd_touch` на область heal'а; в `pop_ceil_bake_empty` и `pop_floor_bake` метит `pop_bar_black`, а лишний второй вызов на ту же область снят. ### G2. Контекст тайла — file-scope, а не локали `draw_tile` — −55 000 Порт `load_curr_and_left_tile` (seg008:0339): у оригинала это `curr_tile`/`curr_modifier`/`draw_xh`/`draw_main_y`/`draw_bottom_y` — переменные модуля, а не локали. Причина в кодогене: в `draw_tile` **57 вызовов**, и каждое живое через вызов значение SDCC спиливал в стековый кадр — 26 байт кадра и **513 обращений `-N(ix)`** (при ~46 замеренных тактах на обращение это ~23 600, что и намерено). Стало **33 обращения**, кадра нет, банк 7 −703 Б. ### G3. `blit_b_clip` — байтовый габарит + file-scope Два шага, и важен порядок наблюдений: 1. **Байтового габарита ОДНОГО НЕ ХВАТИЛО.** `sx/sy/dw/dh` → `uint8_t` (корректно: кадры атласов ≤ 56×63) дало 211 → 173 обращения, а 22-байтовый кадр остался: значений, живых через шесть вызовов ядер libbgi, всё равно больше, чем регистров у Z80. 2. **Решило вынесение из локалей** (`bc_*`): 51 обращение, кадр 22 → 12 Б. Клипованный блит 14 088 → 11 848. Заодно `blit_b_oversize` больше не ходит через `blit_b_clip` (там теперь байтовый габарит) — рисует напрямую `gfx_blit_part`; это путь под полноэкранные подложки интро/финала. ### G4. Мелочи - `pop_blit_b`: аргументы в file-scope (третий и дальше SDCC передаёт стеком, каждое чтение шло через `-N(ix)`) — 76 → 11 обращений. - `pop_loose_mob_tick`: пометки всех кусков ОДНИМ пакетом (`pop_cd_batch_begin/end`) — было по 4 502 такта на кусок. 176 772 → 168 600. - Коридор heal куска — по фактической высоте СОБРАННОГО композита (было 24 строки константой, стало 20). 168 600 → 156 762. --- ## 3. Что осталось: раскол `draw_tile` (позиция G5) **Оставшийся разрыв: −147 000.** Он весь в двух местах. ### G5. Расколоть `draw_tile` на узкие части, как в оригинале **Ожидание: −50 000 … −60 000.** У оригинала `draw_tile` (seg008:01C7) — это девять независимых вызовов: `draw_tile_floorright`, `draw_tile_anim_topright`, `draw_tile_right`, `draw_tile_anim_right`, `draw_tile_bottom`, `draw_loose`, `draw_tile_base`, `draw_tile_anim`, `draw_tile_fore`. Для ряда −1 он зовёт шесть из них (`draw_tile_aboveroom`, seg008:01F2), для полосы у потолка — те же шесть плюс `draw_tile_wipe(3)` (`redraw_needed_above`, seg008:02C1). У нас всё это — ветки `if (row >= 0)` ВНУТРИ одной функции, то есть контекст (13 584) и диспетчер (11 388) оплачиваются целиком всегда. Расколов, каждая точечная перерисовка сможет звать только нужные части: - `pop_floor_bake`: вместо второго полного `draw_tile(row, col+1)` — только его правую грань и базу; - `pop_ceil_shake_draw` / `pop_ceil_bake_empty`: дословный `draw_tile_aboveroom`; - `pop_loose_shake_draw`, `pop_spike_redraw`, `pop_gate_redraw` — то же. Риск средний: у `draw_tile` собрано много инвариантов (BUG-LOOSE-3, BUG-LATTICE-DOORTOP, BUG-SEAM-WEDGE-1), проверять придётся прогонами всех уровней. Поэтому делать отдельным заходом, а не хвостом другой правки. ### G6. Меньше блитов в `RD_FLOOR` **Ожидание: неизвестно, надо мерить.** 105 500 из 179 914 — это 7,6 блита, и они рисуют настоящие пиксели. Сократить можно только сократив то, что восстанавливается: бар сейчас 60×39 от `yb+26`, а плита занимает по вертикали меньше (её куски: левая грань `POP_LOOSE_FRAM_LEFT` 32×13 на `dmy = yb+62`, низ `POP_LOOSE_FRAM_BOTTOM` 32×3 на `dby = yb+65`, правая грань в соседе 26×16 на `dby−1`). То есть плита живёт в `yb+47 .. yb+65`, а бар начинается с `yb+26` — **21 лишняя строка сверху**. Проверять осторожно: бар заодно стирает и то, что рисует ДРУГИЕ куски тайла (орнаментная лента `stripe_id` на `dmy−27 = yb+35` попадает как раз в «лишнюю» часть). Сузишь бар — надо убедиться, что ничего не осталось. ### G7. heal летящих кусков — 154 074 (28 % фазы) Шесть кусков × ~25 700: сам heal 64×20 (по модели ~19 700) + пакетная пометка + накладные `mob_tick_one` (16-байтовый кадр, 99 обращений `(ix)`). **Сам heal у предела железа** — это 1 280 пикселей на кусок, оптимизировать нечего, кроме площади. Площадь уже подрезана до габарита композита. Остаётся `mob_tick_one` (~4 500 на кусок = 27 000 на кадр) — то же лечение file-scope, что у `draw_tile`. ### G8. Пометка соседа — узкой полосой, а не полным тайлом (идея пользователя) **Ожидание: заметное, но не мерено. Взять ПОСЛЕ обхода всех уровней** (решение пользователя 2026-08-17: пока идёт отлов багов слоёв, каждая правка добавляет переменных в картину). Когда плита (1,8) падает, помечаются ДВА тайла: | пометка | что делает | |---|---| | `(1,8)` → `RD_LOOSE_GONE` | бар 40 на своём x, бар 32 на соседе, `draw_tile(1,8)` + `draw_tile(1,9)` | | `(1,9)` → `RD_FLOOR` | бар **60** на x соседа, `draw_tile(1,9)` ЕЩЁ РАЗ | То есть сосед перезапекается ЦЕЛИКОМ и ПОВТОРНО, хотя потревожили у него только левые 28 пикселей — там, куда свисает правая грань упавшего тайла. `draw_tile(1,9)` при этом вызывается дважды на одну пометку. Что такое эти числа (чтобы не сузить лишнего): - **60 = 32 свой тайл + 28 СОБСТВЕННЫЙ свес.** Правая грань пола (кадр 42, 26 px) рисуется в клетке соседа с `x+32`, занимая `x+32..x+57`. Для запечки САМОГО тайла 60 уже минимальны — сужать их нельзя; - сузить можно только тот случай, когда тайл помечен ПОТОМУ ЧТО ИЗМЕНИЛСЯ ЕГО ЛЕВЫЙ СОСЕД: тогда нужна полоса 28 px у левого края, а не весь тайл. Почему выигрыш не символический: `pop_floor_bake` стоит **179 914** тактов, из них 105 500 — сами блиты. Узкая полоса срезала бы и площадь бара (60×39 → 28×39), и часть блитов — окно клипа там теперь стоит обязательным (см. §4), так что отсев достаётся даром. **Условия, из-за которых это не «просто уменьшить число»:** 1. `pop_floor_bake` — ОБЩАЯ функция: её же зовут кнопка (`pop_button_redraw`), зеркало, подобранный предмет и щебень на месте посадки. Там меняется сам тайл и 60 нужны целиком. Значит нужен отдельный вход (напр. `pop_floor_bake_edge(row, col)`) или параметр-прямоугольник — именно под пометку «изменился мой левый сосед». 2. Прежде чем выкидывать вторую пометку целиком, сверить ВЕРТИКАЛЬНЫЕ диапазоны: `pop_loose_bake_empty` кроет `63*row+46 .. +65` (20 строк), а `pop_floor_bake` — `yb+26 .. yb+64` (39 строк). То есть сосед покрыт ВТОРЫМ баром не полностью, и просто снять пометку нельзя. 3. Ширина полосы = свес ЛЕВОГО тайла, а он зависит от типа тайла (у loose это 8 px по комментарию в `pop_loose_bake_empty`, у пола 26). Брать по максимуму (28) — безопасно. ### G9. Снять временную оснастку Шесть `pop_dbg_b1..b6` внутри `pop_blit_b` — ~400 такта на блит; при 15 блитах зелёной это 6 000 на кадр. Плюс `pop_dbg_kind`/`m16` (2 вызова на перерисовку) и `pop_dbg_m5..m15`. Снимать ПОСЛЕ окончания оптимизации: без них не мерить. --- ## 4. Копия второй страницы — и почему она ТРЕБУЕТ окна клипа Точечные запечки ставятся с `pages = 2`, срабатывают два кадра подряд (по разу на страницу дабл-буфера) и оба раза считают одно и то же. После первого раза нужный прямоугольник уже лежит в ОЗУ-копии первой страницы, и его можно скопировать: `gfx_copy_page` берёт источником ОЗУ-копию НЕактивной страницы (то есть ЧИСТЫЙ фон — спрайты рисуются банком без тени и в копию не попадают), а приёмник обновляет и в видео-ОЗУ, и в ОЗУ-копии. Идея пользователя: тот же приём, что при перевороте экрана (зелёное зелье), только без зеркала. | | полная запечка | копия | |---|---:|---:| | щебень / кнопка (60×39) | 179 914 | ~35 000 | | колодец полосы потолка (64×9) | 138 318 | ~17 500 | **ДВА УСЛОВИЯ КОРРЕКТНОСТИ.** Оба нарушались и оба дали видимые баги. 1. **Запечка обязана быть ОГРАНИЧЕНА копируемым прямоугольником.** `draw_tile` рисует тайлы ЦЕЛИКОМ, то есть пишет ШИРЕ бара; копия переносит ровно бар, и всё, что легло вне него, на второй странице остаётся прежним — страницы расходятся, это видно как МЕРЦАНИЕ через кадр. У полосы потолка окно стояло с самого начала (G1), у `pop_floor_bake` — нет, и он мерцал торцами полов, плит и кнопок (найдено пользователем 2026-08-17: уровень 1, комната 6, Кид на кнопке (0,2)). Поэтому в `pop_floor_bake` окно теперь стоит КАК УСЛОВИЕ КОРРЕКТНОСТИ, хотя по скорости само по себе убыточно (см. §5) — снимать его нельзя. 2. **Копия годится только если содержимое тайла между двумя кадрами не изменилось.** У анимированного тайла (кнопка с идущим таймером связи) пометка обновляется КАЖДЫЙ кадр и картинка каждый раз другая. Поэтому `pop_set_redraw`/`pop_set_redraw_above` гасят слот копии при ПЕРЕпометке (`pop_bake_slot_reset*`). Плюс слот `bake_pg`/`bake_pg_above` помнит, НА КАКОЙ странице сделана первая запечка: копируем только если первая была на ДРУГОЙ странице и дабл-буфер включён. Это покрывает переплетение двух запечек в одном кадре, однобуфер (чит SPACE) и смену комнаты (`pop_bake_forget`). --- ## 5. Отрицательные результаты — НЕ повторять ### Окно клипа в `pop_floor_bake` — по СКОРОСТИ проверено ТРИ раза, каждый раз хуже **Но оно всё равно стоит на месте: без него ломается копия второй страницы (§4).** Ниже — только про скорость самого окна. | попытка | было | стало | |---|---:|---:| | до G3 | 187 266 | 198 279 | | после G3 | 182 124 | 188 460 | | после `pop_blit_b` file-scope, с детальным зондом | 179 914 | 188 417 | Третья попытка объяснила причину: клипованный путь стоит **+1 500 такта на КАЖДОМ** из 7,6 блитов (+11 400), а режет он только редкие высокие куски — в трассе такие нашлись (34 878 → 25 872 и 28 818 → 24 090, всего −13 700), но в среднем по 12 перерисовкам их нет. Запись стоит в коде. ### Прочее (проверено раньше) - **Не откладывать запекание на другой кадр** — запечка пишет ОЗУ-копию, из которой восстанавливает heal; отложенная даёт призрак плиты на месте дыры. - **Не батчить смежные колонки в `pop_ceil_shake_draw`** — плиты стартуют со случайными задержками, в кадре дрожат разрозненные колонки, пробег почти всегда длиной в одну. - **Не ускорять передачу пикселей** — предел железа (3+3 такта на байт, memory `blit_cost_model`). В пиковом кадре «железный» минимум всех блитов ≈150 000 из 916 458. - **`LOOSE-SHAKE-RUNS`** (пометки по сменам кадра): наивный вариант выигрыша НЕ даёт — пять смен × две страницы = те же десять перерисовок. Работает только версия «пары и тройки», ~20 % и только на дрожащих плитах; оценка 2026-08-13, не перемерена. Подробности — `roomtest/TASKS_OPEN.md#loose-shake-runs`. --- ## 6. Журнал правок | дата | что сделано | зелёная: покой / дрожь / пик | коммит | |---|---|---|---| | 2026-08-17 | базовый замер | 35 760 / 366 000 / **805 000** | `c312e4a` | | 2026-08-17 | G2 контекст тайла в file-scope | — / — / **747 954** | `a3c473d` | | 2026-08-17 | G1 окно клипа в `pop_ceil_bake_empty` | — / — / **663 250** | `a3c473d` | | 2026-08-17 | G3 `blit_b_clip` байты + file-scope | — / 335 400 / **575 730** | `a3c473d` | | 2026-08-17 | `pop_blit_b` file-scope; пакетная пометка кусков | — / — / **557 706** | `b2da0b8` | | 2026-08-17 | коридор heal по высоте композита | 35 760 / 335 400 / **546 900** | `9a50ab2` | | 2026-08-17 | **копия второй страницы вместо второй запечки** | — / — / **423 558** | `5ef721e` | | 2026-08-17 | `mob_tick_one` в file-scope; снята оснастка из горячих путей | 35 760 / 326 130 / **414 456** | `18ee60e` | | 2026-08-17 | фикс мерцания: окно клипа в `pop_floor_bake` как условие корректности копии | замер после фикса — ниже | `35b7cd5` | | 2026-08-17 | замер после фиксов уровня 1 (мерцание торцов, потолочный fore, сосед под плитой, блеск меча) | 35 760 / — / **417 630** | `40f0d46` | | 2026-08-17 | **регресс после фиксов уровня 2** (чёрные бары, чит бессмертия) — в пределах шума | — / — / **419 526** | `ec1f384` |