Files
Sprinter-SDCC/applications/PoP/roomtest/TASKS_OPEN.md
T
snark13 a3c5c600da Таблица рекордов: оба показа оригинала, ввод имени, переходы полосами
pop_hof.c был написан, но никуда не вызывался: после финала автомат уходил
мимо него в title, а на титрах экран получался чёрным. Теперь это хвост
end_sequence и шаг show_title между credits и attract-demo, как в оригинале.

Два корня пустого экрана:

- story.pal — 256 записей, и запись 0x3F (цвет глифов шрифта) в ней чёрная:
  текст рисовался, но был не виден. Генератор кладёт туда золотой 0xB7;
- полосовой переход копирует страницу акселератором, а тот читает ОЗУ-копию,
  куда прозрачный блит текста не пишет. Строки рисуются после перехода,
  прямо в видимую страницу.

Попутно:

- s5 в архиве PV — фон таблицы (рамка story + логотип на y=24, HOF_POP);
- отдельный индекс палитры под фон текстовой рамки: финал красит его в
  #800000, титры оставляют #100060 (load_title_images(bgcolor)). Прежний
  ремап в индекс 9 подменить было нельзя — им нарисована сама титульная
  картинка;
- второй набор глифов в font.atl цветом 0x3E: у SDLPoP шрифт маска и
  show_hof_text рисует текст дважды разным цветом, у нас цвет запечён в
  пиксели. Каталог SPA1 держит счётчик в байте, поэтому набор обрезан по '_';
- общий pop_screen_present_ltr(): им теперь пользуются story-переход интро,
  титры, таблица и титульная картинка финала. Заодно вернулся пропущенный
  шаг show_title — экран с логотипом и Jordan Mechner между «свадьбой» и
  титрами (BUGS_CLOSED#cutscene-ltr).

Формат POP.HOF — 6 записей, имя до 15 символов, версия 2. Проверено в MAME
на сборке LEVEL=14: HAIL → титульная картинка → таблица с полосой ввода и
временем справа → ввод имени → титульная картинка; на следующем запуске
таблица показывается между credits и demo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:41:41 +03:00

1512 lines
122 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# roomtest — доска ОТКРЫТЫХ задач (обновлено 2026-08-11)
Что берём в работу сейчас и в каком порядке. Каждая запись: что сделать,
почему именно сейчас, чем подтверждать результат.
- закрытые задачи с протоколами и замерами — [`TASKS_CLOSED.md`](TASKS_CLOSED.md);
- открытые баги — [`BUGS_OPEN.md`](BUGS_OPEN.md), закрытые с разбором корней —
[`BUGS_CLOSED.md`](BUGS_CLOSED.md);
- планы фаз — `../docs/PORT_PLAN.md`, `../docs/layout_plan_v2.md`,
`../docs/levels_plan.md`.
**Состояние на 2026-08-11 (сверено с кодом, не только с доской):**
- **уровни 1-4 приняты smoke-тестами** (пользователь). Полные обходы всех
комнат делаются по готовности ВСЕХ уровней — политика приёмок в
[`TASKS_CLOSED.md`](TASKS_CLOSED.md#pass-policy); отдельных задач
`L3-PASS`/`L4-PASS` больше нет. Уже сделанные полные обходы уровней 1 и 2
остаются регресс-базой;
- **[L4-MIRROR](TASKS_CLOSED.md#l4-mirror) закрыта**: зеркало, отражение,
прыжок сквозь него и рождение тени проверены в MAME. Хвосты — вид тени
(отложен, [`../docs/shadow_render.md`](../docs/shadow_render.md)) и
[MIRROR-FG-STALE](BUGS_CLOSED.md#mirror-fg-stale) (закрыт как не баг);
- **[L3-CHOMP](TASKS_CLOSED.md#l3-chomp) и [L3-SKEL](TASKS_CLOSED.md#l3-skel)
закрыты** (2026-08-08 / 2026-08-07);
- **тайлсет palace сделан** — `pop_bg_load(type)`, `pal_*.atl`, дворцовая
кладка `wall_pattern`, решётчатые тайлы 25-29 и в `tile_table`, и в
коллизии (`tile_is_floor` совпадает с seg006:0628). То есть шаг 2
`levels_plan.md` закрыт;
- **libbgi:** блочные AND/OR/XOR/NOT акселератора (2026-08-11, `tests/accop`)
— задел под вид тени и под любые эффекты «поверх того, что уже нарисовано».
**Обход уровней после оптимизации фаз — идёт сейчас (начат 2026-08-17).**
Оптимизация зелёной/циан фаз тронула порядок пометок и перерисовок, поэтому
уровни проходятся заново подряд:
| уровень | статус | что нашлось и закрыто по дороге |
|---|---|---|
| 1 | **принят** (2026-08-17) | мерцание торцов плит/полов/кнопок (запечка шире копируемого прямоугольника); потолочный fore поверх падающей плиты (не было ряда −1 в проходе foretable); соседний пол под плитой (потерянная половина `draw_mob``set_redraw2`); залипший блеск меча (`trob_drawn` не совпадал с тем, что нарисует полная отрисовка) |
| 2 | **принят** (2026-08-17) | чёрные бары поверх стража — `bar` с `x + w > 320` заворачивался в соседнюю страницу видеопамяти (`dd40c24`); [BUG-CHEAT-IMM-1](BUGS_CLOSED.md#bug-cheat-imm-1) — боевая стойка без меча под читом бессмертия |
| 3 | **принят** (2026-08-17) | критичных багов не найдено — правок не потребовалось |
| 4 | **предварительно пройден** (2026-08-17) | [LEVELDOOR-PALACE-CLIP](BUGS_CLOSED.md#leveldoor-palace-clip) — во дворце проём двери уровня на 8 px шире, и контур Кида в анимации ухода обрезался раньше времени (`89b603a`) |
| 5 | **предварительно пройден** (2026-08-18) | багов не найдено |
| 6 | **предварительно пройден** (2026-08-18) | [SHADOW-STALE-FRAME](BUGS_CLOSED.md#shadow-stale-frame) — тень залипала на страницах дабл-буфера в РАЗНЫХ позах (кадр брался из кэша, снимок писался по `Guard.frame`, `085a198`); [BLUELINE-NOBLUE](BUGS_CLOSED.md#blueline-noblue) — кусок синего узора на стене: модификатор стены не переводился при загрузке (`af4e644`) |
| 7 | **предварительно пройден** (2026-08-18) | [SPIKE-BAKED](BUGS_CLOSED.md#spike-baked) — выдвинутые пики консервировались в фон запечкой соседней кнопки и оставались навсегда (`980d48c`); [DIED-ON-BUTTON](BUGS_CLOSED.md#died-on-button) — смерть на кнопке не ломала её насовсем: до `check_press` труп не доходил, самой `died_on_button` не было, а таймер связи переживал респавн (`6feab5d`) |
| 8 | **предварительно пройден** (2026-08-18) | [GUARD-RESPAWN-COL0](BUGS_CLOSED.md#guard-respawn-col0) — при возврате в комнату страж телепортировался в колонку 0 и падал насмерть: колонку несёт `guards_x`, а не тайл (`c40ae3f`); [SEAM-FIGHT-FLICKER](BUGS_CLOSED.md#seam-fight-flicker) — бой у шва перерисовывал комнату туда-обратно: `leave_room` запрещает уход ещё и на кадрах меча (`a498255`, сверено с SDLPoP покадрово) |
| 9 | **предварительно пройден** (2026-08-18) | багов не найдено |
| 10 | **предварительно пройден** (2026-08-18) | багов не найдено |
| 11 | **предварительно пройден** (2026-08-18) | [MOB-NEIGHBOUR-ROOM](BUGS_CLOSED.md#mob-neighbour-room) — плиты нижнего ряда пропадали без кадров падения (кусок в соседней комнате не рисовался, `ed5615a`); порядок куска относительно соседней плиты и Кида — порт корзины 30 (`4db60c7`) |
| 12 | **предварительно пройден** (2026-08-18) | багов не найдено |
| 13+ | не начат | |
«Предварительно пройден» ≠ «принят»: уровни 4-6 пройдены прогоном по
сценарию, а не обходом ВСЕХ комнат — полные обходы делаются по готовности
всех уровней ([политика приёмок](TASKS_CLOSED.md#pass-policy)).
Регресс-базой остаются полные обходы уровней 1 и 2.
Критичных багов на уровнях 1-6 не осталось. Следом — регресс тактов на
23/13 (документы фаз: [`../docs/perf_green_phase.md`](../docs/perf_green_phase.md),
[`../docs/perf_cyan_phase.md`](../docs/perf_cyan_phase.md), сцена и метод —
[`../docs/perf_l13_room23.md`](../docs/perf_l13_room23.md)).
Правило проекта в силе: механику сверять с `../SDLPoP/src/` ДО кодинга;
диагноз платформы подтверждать артефактом (брейкпоинт/дамп/.asm), а не
гипотезой (memory `defer_unexplained_quirks`).
---
## ТЕКУЩАЯ ЦЕЛЬ: уровень 8
Уровни 1-7 играются (smoke; зелья уровней 2 и 7 приняты пользователем
2026-08-12). Уровень 8 **новых тайлов не приносит вовсе** (инвентарь
`res2008.bin` целиком покрыт уровнями 1-7), тайлсет — подземелье.
Спецсобытие уровня — мышь, [L8-MOUSE](TASKS_CLOSED.md#l8-mouse) закрыта
2026-08-12 (хост-набор `t_mouse` + живая проверка в MAME).
Остаётся smoke-прогон самого уровня: он длинный и с возвратом через два
чомпера комнаты 4, а стражи там сильнее прежних (комната 5 — skill **7**,
это максимум из встречавшихся; 12/22/24 — skill 2/3/3).
<a id="l9-invert"></a>
### L9-INVERT. Зелье ПЕРЕВОРОТА — ЗАКРЫТА 2026-08-26
> Проверено пользователем в живой игре.
> **Подробный план реализации — [`../docs/l9_invert_plan.md`](../docs/l9_invert_plan.md)**
> (задачи libbgi, задачи приложения, порядок работ, риски, открытые вопросы).
> Ниже — механика и обоснование решений; план исполняется по документу.
Единственное новое на уровнях 9-11: зелья типа 4 на `(1,7)` комнаты 7 и
`(0,4)` комнаты 10 (сверено по `res2009.bin`). Уровни 10 и 11 не приносят ни
тайлов, ни спецсобытий — только дворцовый тайлсет (есть) и стражи skill 3-4.
Механика оригинала (seg000:15E9 `toggle_upside`, seg006:1886):
`upside_down = ~upside_down`, снимается смертью Кида (seg000:1224, при
`alive >= 0`) и стартом уровня (seg003:38/188); второе зелье переворачивает
обратно. Управление НЕ инвертируется. Переворот у оригинала — чисто вывод:
`flip_screen(offscreen)` перед копированием прямоугольников на экран и обратно
после (seg000:939/946); полоса HP рисуется прямо на экран (y = 194) и НЕ
переворачивается.
**Приём оригинала — переворачивать буфер на выводе КАЖДЫЙ кадр — нам не
подходит:** страниц ровно две (`gfx_set_visible_page` → ESTEX $54 SELPAGE,
бит 0), рабочей третьей нет, а переворот в видимую страницу порвёт картинку
(проход не влезает в vblank). Значит переворачиваем ОДИН РАЗ в момент смены
флага, а дальше рисуем зеркально:
`y' = POP_YOFF + POP_PLAYFIELD_H - (y - POP_YOFF) - h` плюс вертикальное
зеркало самого спрайта.
#### Момент переключения — accel-копия экрана (ОСНОВНОЙ ВАРИАНТ)
Решение пользователя 2026-08-12. Уже нарисованное переворачиваем не
перерисовкой комнаты, а построчной копией акселератора:
1. **проход A → B с реверсом Y**: строка `y` читается, пишется в `191-y`
(смена Port_Y между burst-чтением и burst-записью). 320 байт в один burst
не лезут — две скобки на строку;
2. **проход B → A без реверса**: вторая страница дабл-буфера получает то же
изображение.
Почему это работает на нашем железе, а не только на бумаге: **accel читает
ОЗУ-КОПИЮ, а не VRAM** (memory `accel_block_ops`), то есть копируется ЧИСТЫЙ
фон без персонажей, а запись банком `GFX_BANK_NORMAL` идёт и в видео, и в
ОЗУ-копию приёмника. После двух проходов обе страницы имеют перевёрнутый фон
в обеих плоскостях — **heal не меняется ни на строку**, он и дальше
восстанавливает правильный фон по фактическим координатам отрисовки.
Бюджет: 192 строки × 2 burst'а × 2 прохода = 768 burst-строк. По нашим
замерам строка-burst стоит ~300-530Т (`gfx_heal_noclip` 22×22 = 11 658Т
530Т/строку; у заливки линия 51-53Т, но там нет данных) — итого
**230-410 К тактов, то есть 0.2-0.3 логического кадра** (логический = 3
растровых ≈ 1.29 млн). Перерисовка комнаты в обе страницы стоила бы под
миллион тактов И требовала бы vflip-версии тайлового блита — здесь она не
нужна вовсе.
Что дописать в libbgi: `_bgi_copy_rows_raw` умеет только ИНКРЕМЕНТ Port_Y на
строку (`y0` + шаг вперёд, страйды патчатся SMC) — нужен вариант с
декрементом одной стороны (параметр «шаг Y» либо отдельное ядро
`_bgi_flip_rows_raw`). Перед реализацией подтвердить ДАМПОМ, что обе
страницы одновременно адресуемы в W3: `gfx.h` говорит, что страница 1
начинается на 320 байт дальше (0xC140), но это комментарий, а не проверка
(memory `defer_unexplained_quirks`).
Зеркало по вертикали стоит по-разному в зависимости от раскладки спрайта:
- **row-major** (весь слой фона: тайлы, кладка, факелы, зелья, обломки,
двери) — бесплатно: внешний цикл идёт по строкам источника снизу вверх, сама
строка копируется вперёд. В libbgi такого варианта СЕЙЧАС НЕТ (флип есть
только у `gfx_blit_cols*`, и он горизонтальный) — нужна пара
`gfx_blit*_vflip`;
- **column-major** (Кид, стражи, мышь, меч — 34 атласа, 228 563 Б) — реверс
ВНУТРИ колонки, а accel копирует блок только вперёд. Нужны перевёрнутые
копии в отдельных EMM-страницах (реверс через стек `pop`/`push`, ~10 тактов
на байт).
**Когда делать копии — решать при реализации,** варианты в порядке
предпочтения:
1. **ленивый постраничный кэш**: страница атласа (8 кадров, 3-10 КБ)
переворачивается при первом обращении в перевёрнутом режиме — ~0.2-0.4
растрового кадра на страницу, размазано по времени; реально нужны 5-10
страниц из 34, и если зелье не выпито, не тратится ничего;
2. на входе в уровень, где в данных есть зелье типа 4 (скан `bg` уровня) —
вся пачка разом, ~5-6 млн тактов ≈ 0.3 с, пауза прячется в загрузку;
3. офлайн в `pop_pack_kid.py`/`pop_pack_guard.py` — рантайм ноль, но +228 КБ
на диске и +34 страницы всегда, а на дискете это заметная загрузка.
Не забыть при реализации: зеркалить надо и heal-прямоугольники, и футпринт
fore-слоя, и клип (`clip_char`), и брызги; полоса HP и лейбл комнаты остаются
как есть (они вне поля 192).
<a id="ui-sprites"></a>
### UI-SPRITES. Деления HP — из атласов персонажей в константы резидента
`pop_kid_img_blit` (**334 Б в W1/W2**) существует ради двух картинок 6×5:
полосе HP нужен блит по image id из кидовских атласов, а те column-major, и
обычным `gfx_blit_noclip` их не нарисовать. Деления Кида (images 216/217,
`kid27.atl`) и стража (idx 0 в `g0.atl`) — по 30 байт каждое.
**Решение пользователя 2026-08-12: положить их const-массивами в РЕЗИДЕНТ**
(~68 Б данных), а не подселять в фоновый атлас: резидент всегда ниже
0xC000, значит `gfx_blit*` их видит (буфер обязан быть вне W3), EMM-страница
не тратится, упаковщики не трогаются. Чистая экономия ~266 Б.
Побочный плюс для [L9-INVERT](#l9-invert): страницы `kid27` и `g0` сейчас
смешанные — деления HP (борт, не переворачиваются) лежат рядом с брызгами
крови 28×26 (поле, переворачиваются). После переноса обе страницы
становятся чисто «полевыми», и постраничный кэш перевёрнутых кадров получается
однородным, без спрайтов-исключений.
<a id="mem-cold2"></a>
### MEM-COLD2. Второй шаг разгрузки резидента
> **Пункт 2 сделан 2026-08-12**: `guard_over_kid` с хелперами
> (`objtile_at_char` / `char_x_left_of` / `tile_div_mod`) уехал в банк 8 —
> куча 1005 → **1574 Б**. Зовётся раз в кадр, то есть цена — один трамплин.
> Остались пункты 1 (`enter_room_side`) и 3 (блок редроя шва).
После [MEM-COLD1](TASKS_CLOSED.md#mem-cold1) куча 1707 Б. Когда упрётся
снова, в банк 8 переезжают следующие кандидаты (в порядке выгоды):
1. `enter_room_side` — самая большая функция `roomtest.c`, зовётся раз на
комнату. Требует вынести наружу рабочие массивы комнаты
(`room_bg`/`lcol_*`/`rcol_*`/`below_fg`/`above_*`) — сами данные останутся
в `_DATA`, уедет только код;
2. `guard_over_kid` + `objtile_at_char`/`char_x_left_of`/`tile_div_mod`
зовётся КАЖДЫЙ кадр, но ровно один раз: цена выноса — один трамплин;
3. блок редроя левого шва в `main` (`seam_row_sig` + разбор сигнатуры).
Если и этого не хватит — следующим уходит сам кадровый порядок отрисовки,
но тогда уже стоит мерить, а не выносить наугад.
---
## ПРЕДЫДУЩАЯ ЦЕЛЬ: уровень 5
Уровни 1-4 играются (smoke). Дальше идём по порядку уровней; уровень 5 —
следующий.
**Хорошая новость по ассетам: уровень 5 не приносит НИ ОДНОГО нового тайла.**
Инвентарь, снятый перебором `res2005.bin` (fg & 0x1F):
```
ур. 5: empty, floor, spike, pillar, gate, closer, doortop_with_floor(7),
bigpillar_bottom(8), bigpillar_top(9), potion, loose, doortop(12),
debris, opener, level_door L/R, chomper(18), torch, wall,
lattice_pillar(25)…lattice_right(29)
```
— всё это уже встречалось на уровнях 1-4 и портировано. Единственное новое
на уровне 5 — **спецсобытие «тень крадёт зелье»**.
| # | Задача | Что | Блокирует |
|---|--------|-----|-----------|
| — | [DRAW-COST](#draw-cost) | **кадр уложился в бюджет 2026-08-09**: 470 964 -> 425 600 тактов, период цикла 4 растровых кадра -> **3**. Дальнейшее — запас, не срочность | плавность на ВСЕХ уровнях |
| ✔ | [L1-SPEED](#l1-speed) | **закрыта 2026-08-19** режимами скорости (NORMAL = 4/5 растров, как оригинал) | — |
| — | [TUNE-1](#tune-1) | параметры движка → cfg-файл (сейчас `pop_tune.h`) | отладка таймингов и моды; берётся по мере надобности |
| — | [TUNE-2](#tune-2) | параметры СТРАЖЕЙ (6 таблиц по 12 градаций мастерства + HP) → cfg-файл | подбор сложности боя без пересборки |
| — | [SND](#snd) | звук: эффекты через CBL, музыка на AY | разбор готов — `../docs/sound_plan.md` |
| — | [MEM](#mem-next) | следующий шаг разгрузки W1/W2 | берётся по факту нехватки места |
Сделанное — в [`TASKS_CLOSED.md`](TASKS_CLOSED.md): L4-MIRROR, L3-CHOMP,
L3-SKEL, L3-CHKP, L2 (машинерия уровней), L2-PASS, L1-PASS, DRAW-CHAR,
MEM-BANK2, MEM-BANK5, CLIP-1, KBD-1, DBG-CHEATS.
---
## Ждёт ФИНАЛЬНОЙ приёмки (полные обходы по готовности всех уровней)
Сюда попадает то, что уже работает в проверочном прогоне, но должно быть
подтверждено на сквозных прогонах уровней — потому что задевает механику
шире, чем собственный сценарий.
- **Зацеп ПРЯМО В ПРЫЖКЕ** (`POP_ENABLE_JUMP_GRAB`, `pop_tune.h`, сделан
2026-08-06, предварительно проверен пользователем). Почему нужен именно
финальный прогон: точки вызова стоят не только в `check_action`, но и в
ОБЕИХ ветках `check_bumped` — то есть код вклинивается перед обычным
ударом о стену. Регрессия проявится не в самом зацепе, а рядом: удар о
стену с зажатым Shift, осторожный шаг у стены, отскок в прыжке. На
уровнях 1–3 это надо специально потрогать в паре мест каждого уровня.
Напоминание: в ВАНИЛИ этого зацепа нет (у SDLPoP — `enable_jump_grab`),
так что сверять его с оригиналом «как есть» нельзя — только с SDLPoP при
включённых enhancements.
Прогон 2026-08-07 (уровни 1 и 2) регрессий рядом не показал, но специально
на удар о стену с Shift не проверялся.
## P0 — делаем сейчас
### <a id="char-partial-redraw"></a>CHAR-PARTIAL-REDRAW. Неподвижный персонаж перерисовывается зря: метка «фон трогали» слишком груба — ОБЯЗАТЕЛЬНО
**Постановка (пользователь, 2026-08-19).** Проверять, нужна ли отрисовка
стража, когда он НЕ ДВИГАЕТСЯ. Если движется — лишние ~150 000 тактов
приемлемы: в оригинале во время боя число физических кадров на логический
тоже растёт на единицу.
**Измерено** (сцена 11/15, чтение `pop_cd` из памяти машины):
| объект | x | y |
|---|---|---|
| страж, спрайт | 257..284 | 18..56 |
| страж, клинок (накладной) | 241..261 | 31..37 |
| пламя факела (колонка 6 → рисуется в ячейке 7) | 232..247 | 5..22 |
**Физического перекрытия НЕТ.** По x клинок и пламя пересекаются
(241..247), но по y расходятся: пламя кончается на 22, клинок начинается с
31 — девять пикселей чистого зазора. Пользователь увидел это на экране
раньше, чем я в числах: «страж полностью правее пламени, только меч может
пересекаться».
**Почему тогда он перерисовывается.** Метка «фон трогали» (`pop_cd_touch`
в `pop_tile.c`) хранится как битовая маска КОЛОНОК по 32 px, отдельно на
каждый из ТРЁХ рядов по 63 px (`cd_row_of`). Пламя и клинок попадают в
один ряд 0 и в одну колонку 7 — и `cd_quiet` считает слот задетым.
Расплата: **148 302 такта, 23 % работы кадра** (85 524 спрайт с клинком и
снимком + 62 778 fore-проход) за перекрытие, которого нет.
**Решение — поднять вертикальную точность метки.** Вместо «маска колонок ×
3 ряда» хранить на каждую колонку ДИАПАЗОН y (`ymin`/`ymax`): 10 колонок ×
2 байта × 2 страницы = 40 байт. `pop_cd_touch` расширяет диапазон
затронутых колонок, `pop_cd_hit` проверяет пересечение диапазонов.
Проверка решения на обоих случаях сцены:
- **страж:** клинок в колонках 7-8. В колонке 7 у метки лежит y 5..22
(пламя), у клинка 31..37 — не пересекаются, колонку 8 пламя не трогало.
Слот остаётся «тихим» → экономия 148 302;
- **Кид в тяжёлой позиции:** пламя левого факела y 5..22, Кид y 15..55 —
пересечение НАСТОЯЩЕЕ, и он честно перерисовывается. Так и должно быть.
Вариант с 8-пиксельными полосами вместо диапазонов тоже работает
(24 полосы), но 16-пиксельные УЖЕ НЕТ: пламя и клинок снова слипаются в
одной полосе. Диапазон на колонку точнее и не требует битовой возни.
**Чего делать НЕ надо** (проверено расчётом, не повторять): частичную
перерисовку персонажа по пересечению и обрезку фона под ним. Обе правки
решают задачу, которой нет — перекрытия не существует.
**Проверка:** замер 11/15 в обеих позициях Кида (лёгкая x = 99, тяжёлая
x = 106; разбор — [`../docs/perf_l11_room15.md`](../docs/perf_l11_room15.md)
§7), плюс визуальный прогон боя и прохода Кида под факелами: персонаж не
должен оставлять хвостов и не должен просвечивать сквозь пламя.
**Место в очереди — [`../docs/perf_registry.md`](../docs/perf_registry.md),
позиция P15.** Смежное: P14 (fore-проход — вторая половина той же цены).
### <a id="heal-width"></a>HEAL-WIDTH. Ширина точечных heal'ов не совпадает со следом перерисовки — ОБЯЗАТЕЛЬНО
**Постановка (пользователь, 2026-08-18).** Ширины heal'ов взяты «на глаз по
клеткам», а не по реальному следу. Сжатие до фактического следа даёт около
**10 % площади** самой дорогой операции акселератора; по оценке пользователя
5-6 % реального выигрыша. Задача обязательная.
**Измерено** (каталоги атласов, спрайты плиты):
| часть плиты | подземелье | дворец |
|---|---|---|
| верх (`41/69/70`) | 32 × 13-14 | 32 × 13-14 |
| правая грань (`42/71/72`) | **26** × 15-16 | **25** × 15-16 |
То есть анимированный след ОДНОЙ плиты — `32 + 26 = 58` пикселей в
подземелье и `57` во дворце.
**Что стоит сейчас.** В коде уживаются ДВЕ разные договорённости:
- **60** = «свой тайл + свес соседа» — `pop_floor_bake` (там это записано
комментарием);
- **64** = «две клетки» — `pop_loose_shake_draw`, `pop_spike_redraw`
(«ширина 64 = обе ячейки»).
Для трясущейся плиты 64 избыточны: хватает 58 (подземелье) / 57 (дворец),
то есть **6 пикселей из 64 — впустую, 9,4 % площади heal'а**.
**И обратная сторона, которую тоже надо разобрать.** Ни 60, ни 64 не
покрывают того, что перерисовка РИСУЕТ: два полных `draw_tile` красят до
**90** пикселей (32 свои + 32 соседа + 26 правой грани соседа, уходящей в
третью клетку). Видимого артефакта отсюда пока нет — содержимое третьей
клетки статично и перерисовывается одинаково, а трясущийся сосед накрывает
эту область своим heal'ом, — но несоответствие «heal уже, чем след» надо
либо закрыть, либо записать как осознанное.
**Что сделать.** Свести ВСЕ точечные heal'ы (`pop_loose_shake_draw`,
`pop_spike_redraw`, `pop_chomp_redraw`, `pop_floor_bake`, `pop_loose_bake_empty`,
`pop_ceil_*`, коридор `mob_heal_*`) в таблицу «что рисует / что чистит» и
привести ширину каждого к фактическому следу. Ширины брать ИЗ АТЛАСА, а не
из номера клетки — они разные у двух тайлсетов (26 против 25).
**Проверка:** замер 13/23 до/после (heal'ы — зелёная фаза) плюс визуальный
прогон комнат с плитами, пиками и чомперами: сужение heal'а — самый прямой
способ получить «недочищенный хвост».
**Родня — [G8](../docs/perf_green_phase.md) (пометка соседа узкой полосой).**
Это две половины одной темы, и брать их логично вместе: G8 про ширину
ЗАПЕЧКИ соседнего тайла (60 вместо 28 нужных, да ещё `draw_tile` соседа
дважды на пометку), HEAL-WIDTH — про ширину HEAL'ов (64 вместо 58/57).
Числа там уже разобраны: 60 = 32 свой тайл + 28 собственный свес, и для
запечки САМОГО тайла 60 минимальны — сужать можно только пометку СОСЕДА.
**Место в общей очереди — [`../docs/perf_registry.md`](../docs/perf_registry.md)
(позиция P8).** Там же собраны ВСЕ отложенные оптимизации из трёх фазовых
доков, отсортированные по измеренному эффекту, и разложена цена одного блита
(6 126 тактов фиксированной накладной на любой блит, независимо от размера).
### <a id="l12-shadow"></a>L12-SHADOW. Уровень 12: тень, бой, слияние — ЗАКРЫТА 2026-08-26
> Проверено пользователем в живой игре.
> **Статус 2026-08-13: код написан, хост-тесты зелёные (45 проверок,
> `tests-host/t_shadow.c`), ЖИВОЙ ПРОВЕРКИ В MAME ЕЩЁ НЕ БЫЛО.** Для неё
> нужна сборка с `FIRST_LEVEL=12` и полный рестарт MAME после `make hdd`.
План и сверка констант с оригинальными данными —
[`../docs/levels_12_15_plan.md`](../docs/levels_12_15_plan.md) §1 и §7.
Сделано ровно по нему:
| кусок | где | порт |
|---|---|---|
| подъём тени в комнате 15 (условие «меч уже подобран») | `guards.c` `pop_check_shadow` | seg002:0070 |
| `init_shad_12` + вход ПАДЕНИЕМ (`seq_7`) | `guards.c` | data:0EFA |
| ИИ тени: ждать Кида / драться / сойтись / слиться | `guards.c` `autocontrol_shadow_level12` | seg002:1184 |
| «ранил тень — ранил себя» | `pop_guard.c` `pop_do_delta_hp` | seg000:1518 |
| «убил тень — убил себя» | `guards.c` `pop_check_killed_shadow` | seg006:2033 |
| таймер вспышки слияния (0 → счётчик → −1) | `pop_guard.c` `pop_shadow_timer` | seg003:0735 |
| меч исчезает при уходе вправо из комнаты 18 | `guards.c` `pop_sword_disappears` | seg002:0536 |
| появление плит в комнатах 2/13 после слияния | `pop_map.c` `floors_appear` | seg006:1063 |
| бесшовный переход 12 → 13 (комната 23, без двери) | `roomtest.c` + `pop_start_level` | seg000:0900 |
Осознанное расхождение: мигания Кида спрайтами тени во время вспышки нет —
запись в [`../docs/impl_diff.md`](../docs/impl_diff.md).
<a id="loose-shake-runs"></a>
### LOOSE-SHAKE-RUNS. Дрожание плит: пометки по сменам кадра, а не по кадрам
**Идея пользователя 2026-08-13, на этап полиша.**
`pop_loose_tick` на КАЖДОМ кадре отсчёта ставит `pop_set_redraw(pos,
POP_RD_LOOSE, 1)` — полную перерисовку тайла, десять раз за отсчёт. А
кадр дрожания меняется не каждую фазу: таблицы
(`POP_LOOSE_FRAM_BOTTOM` = 43,73,43,74,74,43,43,43,74,74,74) дают
фаза 1 2 3 4 5 6 7 8 9 10
кадр 73 43 74 74 43 43 43 74 74 74
^^^^^ ^^^^^^^^ ^^^^^^^^
парами и тройками одно и то же
С фазы 5 кадры идут ТРОЙКАМИ. Значит вместо шести пометок с `pages = 1`
хватит двух — на фазах 5 и 8 — но с `pages = 2` (обе страницы дабл-буфера,
иначе на второй застынет старый спрайт и пойдёт мерцание через кадр).
**Выигрыш ~20 % работы зелёного блока и ТОЛЬКО на дрожащих плитах.**
Наивный вариант «ставить лишь на смене кадра» выигрыша НЕ даёт: пять смен
по две страницы — те же десять перерисовок (проверено арифметикой
2026-08-13, до правки).
Вопрос к этапу полиша: стоит ли неочевидный код такого узкого выигрыша.
<a id="bg-once"></a>
### BG-ONCE. Задний фон рисуется ОДИН раз; вместо чёрных баров — heal
**Предложение пользователя 2026-08-13. Не делать сейчас — обязательно к
проверке и реализации на этапе полиша/оптимизации.**
Суть: элементы САМОГО заднего фона — факелы без пламени, окна, узор кладки,
решётчатая кладка и что ещё найдётся — впечатываются в фон один раз и больше
не перерисовываются никогда. Ни один объект за ними оказаться не может, а
значит и возвращать их поверх чего-либо не нужно. Если такой элемент оказался
затёрт, это САМО ПО СЕБЕ сигнал об ошибке: либо бар/прямоугольник слишком
велик, либо что-то рисуется не в своём слое.
Вторая половина предложения — и она про скорость: там, где мы гасим область
чёрным баром перед выводом спрайта, звать вместо бара **heal по координатам
бара**. Бар всё равно приходится потом закрывать фоном, то есть выходит две
операции; heal делает то же одной и сразу правильным содержимым.
**Оценка: идея верная по направлению, но есть два известных препятствия.**
1. **Узкий heal уже пробовали — и откатили.** В `pop_room.c` (`pop_clip_sprite`)
записано: per-спрайтовый heal не используется, потому что выравнивание
блока акселератора оставляет 1-2 px слайверы правой грани, и на одной
странице дабл-буфера это мерцает. Поэтому борта чистит полоса
`pop_room_clip_borders` с гейтом по флагу. Прежде чем менять бары на heal,
надо решить именно эту задачу — иначе получим мерцание вместо ускорения.
2. **Полный запрет «не перерисовывать фон» противоречит foretable.**
`draw_tile_fore` (seg008:715) кладёт в передний слой ГЛАВНУЮ ГРАНЬ стены
вместе с её узором — она обязана перекрывать персонажа. То есть запрет
должен различать «декор задней стены» и «передняя грань», а это ровно
различие backtable/foretable оригинала. Фактически предложение сводится к
«привести наши слои в соответствие с таблицами оригинала» — тот же вывод,
к которому пришёл разбор `overlay_mid_tile` (см. ниже).
**Известное исключение**, которое надо не сломать: Кид, уходящий по лестнице
в дверь уровня, действительно уезжает за передний план — но он и рисуется
отдельно (`clip_char`, правая грань doortop; см. memory `pop_clip_char_todo`).
**Критерий приёмки этапа:** пройти уровни с падающими плитами (13) и с
факелами/окнами (10-12) и убедиться, что ни один фоновый элемент не
перерисовывается в кадре, где его тайл не менялся; замерить кадр до и после.
<a id="l12-shadow-spr"></a>
### L12-SHADOW-SPR. Тень дерётся спрайтами СТРАЖА, а не своими
**Найдено прогоном 2026-08-13.** `tbl_guard_type[12] = 4` — это SHADOW.DAT,
отдельный набор спрайтов (силуэт Кида), а `pop_guard_load` (`pop_cdraw.c`)
разбирает только тип 2 (SKEL) и 3 (VIZIER); тип 4 сваливается в набор
обычного стража. Ассеты есть: `SDLPoP/data/SHADOW` (32 кадра + `res750.pal`).
Правка по образцу VIZIER (набор в `pop_pack_guard.py`, каталог в Makefile,
ветка в `pop_guard_load`), НО сначала проверить две вещи, иначе спрайты
поедут: (1) какую таблицу кадров получает `CHARID_1_SHADOW` в
`pop_frame_tbl_is_guard` — Кида или стража, и (2) сколько кадров в наборе
(32 против 34 у стража) и не выходит ли индекс за его пределы.
Это НЕ полный «вид тени» (OR/XOR-блиттеры, [`../docs/shadow_render.md`]) —
только правильный набор спрайтов вместо чужого.
<a id="l12-seam-polish"></a>
### L12-SEAM-POLISH. Переход 12 -> 13 выглядит коряво
Механика работает (комната 23, без заставки и без сброса HP), но сама
анимация прохода рваная — замечено пользователем 2026-08-13. Полишинг,
берётся вместе с остальной шлифовкой переходов.
<a id="l13-jaffar"></a>
### L13-JAFFAR. Уровень 13: Джафар, победа, выход — ЗАКРЫТА 2026-08-26
> Проверено пользователем в живой игре.
> **Статус 2026-08-13: код написан, хост-тесты зелёные (44 проверки,
> `tests-host/t_jaffar.c`), ЖИВОЙ ПРОВЕРКИ В MAME ЕЩЁ НЕ БЫЛО.**
Своего ИИ у Джафара нет — это обычный страж (seg002:085A), skill 9 и 6 HP уже
лежат в `pop_level_cold.c`. Сделаны только события и данные:
| кусок | где | порт |
|---|---|---|
| фора после встречи (`guard_notice_timer`) | `guards.c` `pop_meet_jaffar` + `autocontrol_guard_inactive` | seg002:0544 / 0736 |
| победа: белая вспышка + «выход разрешён» | `guards.c` `on_guard_killed` | seg006:1927 |
| выход: кнопка комнаты 24 при уходе ВЛЕВО | `guards.c` `pop_jaffar_exit` | seg002:0517 |
| гряда плит сверху при входе в комнаты 23/16 | `pop_map.c` `pop_check_fall_flo` | seg000:1317 |
| фазу со старшим битом на 13-м НЕ гасить | `pop_map.c` `pop_loose_tick`, `pop_trob.c` `animate_loose` | seg007:823 |
| сотрясение на 13-м плиты НЕ трясёт | `pop_map.c` `do_knock` | seg007:949 |
| плита бьёт и В БЕГЕ | `pop_map.c` `fell_on_your_head` | seg007:1218 |
| СПРАЙТЫ Джафара (VIZIER.DAT, своя палитра) | `pop_pack_guard.py`, `pop_cdraw.c`, `Makefile` | seg000:1092 |
По ходу нашлась и починена смежная ошибка: цвет стража из данных комнаты
применялся к ЛЮБОМУ набору, хотя оригинал берёт его только у обычного стража
(`curr_guard_color = 0` при `tbl_guard_type != 0`, seg002:183) — скелета это
не задевало случайно, Джафара покрасило бы.
Осознанное расхождение (представление фазы отложенного старта) — запись в
[`../docs/impl_diff.md`](../docs/impl_diff.md).
**Не портировано намеренно:** `is_show_time` в `on_guard_killed` — это показ
ОСТАВШЕГОСЯ ВРЕМЕНИ, а часов на 60 минут у нас пока нет вовсе.
**Что проверять в MAME** (`make LEVEL=13`):
1. комната 23 (стартовая): плиты сверху сыплются ВРАЗНОБОЙ с первых кадров,
и они бьют, даже если пробегать под ними;
2. приземление Кида плиты НЕ трясёт (иначе гряда посыплется разом);
3. уход вправо из комнаты 3: Джафар виден СВОИМИ спрайтами и своей палитрой,
~2 секунды стоит, не доставая клинок;
4. смерть Джафара — белая вспышка; уход ВЛЕВО открывает дверь уровня.
**Что проверять в MAME** (по порядку прохождения уровня):
1. комната 15: пока меч лежит — тени нет; подобрал меч, вышел и вернулся —
тень СВАЛИВАЕТСЯ сверху, а не стоит готовая;
2. бой: каждый удар по тени отнимает HP и у Кида; добить её нельзя — на её
смерти умирает и Кид (белая вспышка);
3. слияние: убрать меч (`Shift`+вниз), подойти — белая вспышка, тень исчезла,
потолок HP вырос на единицу;
4. после слияния в комнатах 2 и 13 (верхний ряд) под ногами появляются плиты;
5. уход ВПРАВО из комнаты 18 убирает меч из комнаты 15 (вернуться и взять
второй раз нельзя);
6. комната 23 — уровень молча становится 13-м, HP НЕ сбрасывается.
### <a id="l7-feather"></a>L7-FEATHER. Зелье МЕДЛЕННОГО ПАДЕНИЯ — ЗАКРЫТА 2026-08-26
> Проверено пользователем в живой игре.
> **Статус 2026-08-12: код написан, ждёт живой проверки в MAME.** Сделаны все
> шесть шагов ниже плюс синие пузырьки зелья «−HP» (тип 5) — они всплыли по
> ходу: пользователь напомнил, что такое зелье стоит уже на уровне 2 (комната
> 13, тайл `(1,3)`; ещё ур.8 комн.2 и весь ур.15), а цвет у него был красный.
> Хост-тесты: `phys_feather_fall_is_slow_and_harmless` (скорость ≤ 4 и HP
> целое против обычного падения с двух рядов). **Осталось**: проверить в MAME
> — ур.7 комн.1 (зелёные пузырьки, зелёная вспышка, медленный спуск в шахту) и
> ур.2 комн.13 (синие пузырьки, −1 HP, экран краснеет один раз).
Зелье на `(2,8)` комнаты 1 уровня 7 (сверено по `res2007.bin`: modifier 3, то
есть `potion_type = 3` — «slow fall»). Сейчас `pop_proc_get_object`
(`pop_map.c`) для типов 3/4/6 не делает НИЧЕГО (явный TODO): зелье выпивается
без эффекта.
Механика оригинала (прочитана в SDLPoP до планирования, правило проекта):
| что | где в SDLPoP | значение |
|---|---|---|
| включение | `feather_fall()`, seg000:15F8 | `is_feather_fall = 1`, зелёная вспышка (`flash_color = 2`, `flash_time = 3`), `stop_sounds` + `sound_39_low_weight` |
| физика | `fall_accel()`, seg006:057C | ускорение **1** вместо 3, потолок скорости **4** вместо 33 (`FALLING_SPEED_*_FEATHER`, types.h:1435) |
| анимация | опкод `SEQ_JMP_IF_FEATHER`, seg006:586 | в seqtbl есть ветки `stepfloat` / `bumpfloat` — «плавные» кадры падения и удара; таблица у нас из данных оригинала, ветки УЖЕ ЛЕЖАТ в ней |
| длительность | `do_timers`, seg003:517 | ваниль: пока играет звук (или 225 тиков); фикс SDLPoP `fix_quicksave_during_feather` — таймер `FEATHER_FALL_LENGTH = 18.75 c` |
| сброс | seg003:189 (`start_level`) | на старте уровня и, по фиксу, при смерти Кида |
| вид склянки | `draw_tile_fore`, seg008:740 | типы 2..4 — БОЛЬШАЯ склянка (id 13) — **у нас уже так** |
| цвет пузырьков | `draw_tile_anim`, seg008:652 | типы 3/4 — **зелёный** (`color = 10`), 5/6 — синий, остальные — красный (12) |
Шаги (в порядке выполнения, каждый проверяем отдельно):
1. **Состояние.** `pop_feather` (счётчик кадров) в `pop_map.c` рядом с
`fall_accel`; экспорт в `pop_map.h` для `play_seq`. Длительность — по
таймеру (ванильная привязка к звуку нам не подходит: звука нет), значение
пересчитать из 18,75 с в наши кадры и завести в `pop_tune.h`. Сброс — в
`pop_start_level` и по смерти Кида.
2. **Физика.** `fall_accel()`: при `pop_feather``FALL_ACCEL_FEATHER 1` /
`FALL_MAX_FEATHER 4`. Только для Кида (`charid == CHARID_0_KID`) — так
правильнее по смыслу, но это ОСОЗНАННОЕ расхождение с ванилью (там эффект
ловят все, и SDLPoP чинит это опцией) → запись в `../docs/impl_diff.md`.
3. **Анимация.** Опкод `0xF7` в `play_seq` (`pop_kid.c`) сейчас БЕЗУСЛОВНО
пропускает адрес; сделать как в оригинале: при `pop_feather` — прыжок по
адресу (это и даёт `stepfloat`/`bumpfloat`, то есть отсутствие урона и
«парение»). Правка на 3 строки, но именно она даёт весь визуальный эффект
падения.
4. **Вспышка.** Сейчас цвет вспышки — булев `pop_flash_red` (жёлтая/красная);
расширить до кода цвета и добавить ЗЕЛЁНУЮ (`flash_time = 3`).
5. **Зелёные пузырьки.** `pop_pack_bg.py` уже красит пузырёк mono-цветом
(`POT_BUBBLE_COLOR = VGA16 + 12`, красный). Добавить второй набор кадров
16..22 в зелёном (`+10`) под своими id в атласе `pop_pot` и выбирать набор
по `potion_type` в `pop_potion_draw` (`pop_room.c`): 3/4 — зелёный, 5/6 —
синий, иначе красный. Цена — 7 маленьких спрайтов.
6. **Проверка.** Хост-тест: падение с трёх рядов под пером — скорость не выше
4, урона нет, Кид жив (сцена в `t_phys`/`t_char`). MAME: комната 1 уровня
7 — выпить, спрыгнуть в шахту, убедиться в плавном спуске и в том, что
эффект кончается по таймеру.
### <a id="l3-color"></a>L3-COLOR. Палитра КЛАДКИ уровня 3 (в оригинале он зелёный)
**Наблюдение (пользователь, 2026-08-07, со сравнением карт VGA).** В
оригинальной VGA-версии кладка уровня 3 ЗЕЛЁНАЯ, а уровней 1-2 — серо-синяя.
В SDLPoP все подземелья одинаковые, поэтому по нему разницу не увидеть.
**Почему в SDLPoP её нет — проверено, не гипотеза.** Механизм там ЕСТЬ
(seg000:1140, «Level colors (1.3)»):
```c
int level_color = custom->tbl_level_color[current_level];
if (level_color != 0) {
byte* env_pal = level_var_palettes + 0x30*(level_color-1);
byte* wall_pal = env_pal + 0x30 * custom->tbl_level_type[current_level];
set_pal_arr(0x50, 0x10, (rgb_type*)env_pal); /* chtab_6 environment */
set_pal_arr(0x60, 0x10, (rgb_type*)wall_pal); /* chtab_7 wall */
}
```
`tbl_level_color` (data.h:842) = `{0,0,0,1,0,0,0,1,2,2,0,0,3,3,4,0}`у
уровня 3 цвет **1**, у 7 тоже 1, у 8/9 — 2, у 12/13 — 3, у 14 — 4. Но
`level_var_palettes` — это ресурс **20** из `PRINCE.DAT` (только версии
1.3/1.4), а в `SDLPoP/data/PRINCE/` его НЕТ: там лежит лишь `res10.bin`
(палитры стражей). Значит `level_var_palettes == NULL` и вся ветка молча
пропускается — отсюда одинаковые подземелья.
**Данные у нас есть.** В `../MSDOS/PRINCE.DAT` ресурс 20 присутствует:
offset 22790, **240 байт** = 5 палитр × 16 цветов × 3 байта (6-битные
каналы, как res10).
**Что делать (когда дойдём до вида уровня 3).**
1. Достать ресурс 20 из `MSDOS/PRINCE.DAT` (упаковщику придётся читать сам
`.DAT` — сейчас все скрипты берут распакованные PNG из SDLPoP);
2. сгенерировать таблицу палитр рядом с `pop_guard_pal.h`;
3. при загрузке уровня заливать слоты **0x50..0x5F** (env) и **0x60..0x6F**
(wall) — у нас ровно эти базы (`pop_pack_bg.load_indexed`: `pal_base =
0x60` для WALL, `0x50` для env), то есть совпадение со `set_pal_arr`
один в один;
4. `wall_pal = env_pal + 0x30 * tbl_level_type[level]` — для подземелья
(`level_type == 0`) обе палитры одинаковые.
**Грабли, уже пойманные на цвете стражей:** `gfx_pal_load` отдаёт указатель
в BIOS (`$A4` через `rst #0x08`), а BIOS читает только `#4000-#BFFF`
таблицу нельзя передавать прямо из банка (0xC000+), надо копировать в
W1/W2 (см. [BUG-GUARD-COLOR-1](BUGS_CLOSED.md#bug-guard-color-1)).
### <a id="draw-cost"></a>DRAW-COST. Кадр НЕ УКЛАДЫВАЕТСЯ в бюджет — нужна оптимизация
> **ШАГ 1 СДЕЛАН 2026-08-08: пропуск неизменившегося персонажа.** Комната
> 1.3, труп стража, Кид стоит: было **210 %** кадрового периода, стало
> **116 %** (500 772 такта при бюджете 430 000). Отрисовка перестала быть
> узким местом: персонажи в покое не рисуются ВООБЩЕ (ноль вызовов
> `pop_heal_fast` за кадр), весь фон — ДВА блита факелов (44 136 тактов).
> Механизм и почему метка позиционная — в шапке `pop_cdraw.h`.
**Исходные замеры пользователя (полосы бордюра, до шага 1).** Уровень 1,
Кид СТОИТ — то есть НЕ худший случай, ни боя, ни движения:
| комната | синяя (ввод+heal+логика) | зелёная (фон) | циан (спрайты) | итого |
|---|---|---|---|---|
| 3, страж УБИТ | ~80 % | ~20 % | ~110 % | **~210 %** |
| 2, стража НЕТ | ~60 % | ~20 % | ~60 % | ~140 % |
Разница ровно в теле убитого стража: +20 % синей и +50 % циана. Труп
сохраняет `charid != 0`, поэтому каждый кадр честно проходил весь путь
живого персонажа (heal → спрайт+clip_char+брызги+клинок → fore-проход), хотя
его кадр постоянен до выхода из комнаты.
**Что сделано (шаг 1).** Не спецкейс «мёртвый», а общее правило: у каждой
страницы дабл-буфера свой снимок ВХОДОВ отрисовки слота; совпал снимок,
спрайт этой страницы цел и фон в его прямоугольнике не трогали — heal, блит
и fore-проход пропускаются целиком. Покрывает и труп, и стоящего Кида, и
ждущего стража. Детали контракта — `pop_cdraw.h`, реализация —
`pop_char_skip_mask` / `cd_quiet` в `pop_cdraw.c`, метка фона —
`pop_cd_touch` в резидентном `pop_tile.c`.
Грабли, на которые наступили по дороге: сначала метка была ФЛАГОМ «фон
трогали хоть где-то» — и выигрыш оказался ровно нулевым, потому что факелы
анимируются каждый кадр и гасили пропуск для всех персонажей сразу (замер:
597 684 такта, как без оптимизации). Метка обязана быть ПОЗИЦИОННОЙ.
**Остаточный эффект от объединения прямоугольников.** Метка одна на
страницу — объединение всех правок фона. В комнате 1 два факела дают
прямоугольник x 40…88, а стоящий там Кид занимает x 32…44: пересечение 4 px,
и он перерисовывается каждый кадр (140 % против 94 %, если отойти). Если
понадобится — держать не один прямоугольник, а 3-4 отдельных и мержить
только при переполнении.
### Шаг 2 сделан частично: ЛОГИКА (синяя полоса) 2026-08-09
Пользователь: «на стоящем Киде с двумя факелами на логику уходит 60 %
кадрового периода — недопустимо». Разобрано брейкпоинтами в MAME
(`z80_profiling_method`), сцена: уровень 1 комната 1, Кид СТОИТ вплотную к
левому факелу (то есть пропуск персонажа НЕ срабатывает — худший случай).
**Калибровка, которую надо знать заранее.** Один такт `totalcycles` в MAME
— НЕ один номинальный T-такт Z80: у Sprinter на обращениях к ОЗУ есть
wait-state'ы, и замеренная стоимость выходит **≈ 2,4× номинала**
(`get_tile`: 574 номинальных против 1 422 замеренных). Считать бюджет по
таблице T-тактов из справочника нельзя — только мерить. Кадр растра =
430 000; главный цикл спейсится тремя `gfx_wait_vsync`, поэтому работа
СВЫШЕ 430 000 стоит сразу целый лишний кадр.
**Что нашли и починили:**
| правка | что было | стало |
|---|---|---|
| окно перебора коллизии как в оригинале (было: все 14 колонок каждый кадр) | `check_collisions` 60 888 | 50 940 |
| `get_tile_div_mod` — таблицей (`tile_div_tbl`/`tile_mod_tbl`), было `/14` и `%14` | 5 400 тактов на вызов, 13 вызовов за кадр ≈ 70 000 = **16 % кадра** | ~250 на вызов |
| разрешение ряда вынесено из цикла колонок + грань шагом 14 + `wall_type` таблицей | `check_collisions` 58 026 | 42 750 |
| `move_coll_to_prev``memcpy` (LDIR) вместо цикла на C | 14 байт за 5 514 тактов (390 на байт!) | ~1 200 |
| окно режется на непрерывные пробеги (левый сосед / своя / правый), пролог ряда вынесен на кадр | `check_collisions` 44 022 | **38 334** |
Замер одной итерации перебора: **пустая колонка 750 тактов, колонка-стена
~1 700** (две 16-битные знаковые сверки граней — SDCC пишет их через
`jp PO / xor 0x80 / jp P`). Ловушка, на которую наступили: «быстрый путь для
окна внутри комнаты» не срабатывал ПОЧТИ НИКОГДА — Кид, стоящий в колонке 0,
даёт окно с −1, и шёл медленный сбор во временный буфер с тернарником на
колонку (1 340 тактов на колонку). Отсюда разбиение на пробеги: вопрос «чья
это колонка» решается раз на пробег, а `coll_scan` сам двигает `scan_left`.
Самое дорогое было НЕ там, где ожидалось: `/14` и `%14` SDCC разворачивает
в `__divsint` + `__modsint`, а `__modsint` внутри зовёт `__divsint` ещё раз —
два полноценных 16-битных деления на каждый вопрос «в какой колонке точка».
Оригинал делит таблицей (seg006:702) — мы просто не портировали это место.
**ГЛАВНОЕ: логический кадр уложился в бюджет.** Главный цикл спейсится
тремя `gfx_wait_vsync`, поэтому работа сверх 430 000 тактов стоит СРАЗУ
целый лишний растровый кадр. Было 470 964 (период цикла 4 кадра), стало
**409 956 + ~15 600 на ввод = 425 600** — период цикла **3 растровых кадра**.
Игра стала быстрее на треть (16,7 логических кадров/с против 12,5).
Что дало последние тысячи (по убыванию):
| правка | экономия |
|---|---|
| `pop_y_to_row` — цепочка сравнений вместо `/63 % 4` | ~12 000 |
| расширение окна fore-прохода арифметикой вместо перебора 10 колонок и 3 рядов | ~8 500 |
| `col_from_x` — таблицей (те же `POP_TILE_DIV`, вынесены в резидент) | ~11 000 |
| `pop_cd_touch` — развёрнутый цикл по страницам, `x+w`/`y+h` один раз | ~8 600 (зовётся с каждого блита фона) |
| `tp / 10`, `tp % 10` у факелов — таблицей | ~4 000 |
| пустой слот соперника считается «тихим» | ~8 200 |
**Ловушка SDCC, на которой я потерял один прогон:** в `pop_y_to_row` одно и
то же выражение `t / 63 % 4 - 1` стояло в двух ветках, и компилятор поднял
деление В ВЕРШИНУ функции — быстрые возвраты не спасали, `__divsint` звался
всё равно. Лечится выносом медленного хвоста в ОТДЕЛЬНУЮ функцию. Тот же
эффект уже был описан в `pop_loose_tick`; теперь ясно, что это правило, а не
частный случай: **любое деление, встречающееся дважды, SDCC поднимает выше
всех проверок.**
Приём, которым это ловится: брейкпоинт на `__divsint`/`__divuint`/
`__divuchar` с печатью адреса возврата (`printf "%04X", w@(sp)`) — сразу
видно, кто и сколько раз делит за кадр.
**Хвост подобран 2026-08-10.** После переписи `pop_y_to_row` в pop_room.c
осталось ТРИ места, считавших `(y+60)/63 % 4 - 1` вручную (927 в
`mob_tick_one`, 976/977 в `mob_render`) — сгенерированный asm подтвердил
пару `__divsint`+`__modsint` в каждом. Это ~16 200 тактов (3,8 % кадра), но
только пока кусок плиты в полёте — то есть ровно в самом тяжёлом кадре.
Заменены вызовом `pop_y_to_row`; в банке 7 теперь НОЛЬ `__divsint`.
Эквивалентность закреплена тестом `geom_y_to_row_matches_formula`
(перебор −400..400 против исходной формулы) — вызовы разбросаны по трём
банкам, и соблазн написать деление «по месту» возвращается.
### Полная инвентаризация делений 2026-08-10
Способ (повторяемый одной командой из `.sprinter-cc-roomtest/`):
```
awk '/^;[a-z_0-9]+\.[ch]:[0-9]+:/{s=$0} /^\tcall\t__(div|mod|mul)/{printf "%-22s %-12s %s\n",FILENAME,$2,s}' *.asm
```
Найден **31 вызов в 9 модулях**. Прибрано три места, остальное разобрано и
осознанно оставлено.
Убрано:
| место | что было | почему стоило |
|---|---|---|
| `pop_cdraw.c` `calc_screen_x_coord` (2 вызова) | `x * 8 / 7` = `__divsint`, **2 400 тактов НА ПЕРСОНАЖА КАЖДЫЙ КАДР** | последнее деление в горячем пути; ~4 800/кадр при живом сопернике |
| `pop_guard.c` `guard_col_from_x` | `/14` + `%14` **безусловно**, мимо `POP_TILE_DIV` | единственное 16-битное деление, у которого таблица вообще не была подключена |
| `pop_trob.c` `animate_chomper` | `tp / 10` = `__divuchar` на чомпера каждый кадр | таблицы `TP_ROW`/`TP_COL` уже лежали в ЭТОМ ЖЕ файле, но ниже по тексту — чомперы их не видели |
Приём для `×8/7`: **таблица** `SCRX7[1152]` (int8_t, хранит `x/7`) в банке 4,
индекс `x + 448`, результат `x + SCRX7[i]`. 1 152 байта, резидент не тронут.
Два решения по дороге, оба проверены, а не угаданы:
1. **Диапазон — весь, включая отрицательные.** Первая версия крыла 0..255 по
тождеству `8x/7 == x + x/7` с байтовой таблицей `x/7`. Ошибка: `obj_x =
2*fwd 116` уходит в минус, как только `fwd < 58` (левее `x_bump[5]`) —
то есть у ЛЕВОЙ КРОМКИ комнаты, а это не экзотика, и там мы продолжали
делить. Границы взяты из фактических данных: `kid_data.bin` даёт `dx`
кадров Кида −5..+10, стража −2..+10; при `Char.x` типа uint8_t и
`render_dx ∈ {140, 0, +140}` полный диапазон `obj_x` = **416..695**.
Таблица кроет −448..703, деление стало недостижимым (оставлено
страховкой на третьего персонажа / другой `render_dx`).
2. **Хранить `x/7` байтом, а не готовое `x*8/7` словом** — по тождеству
`8x/7 == x + x/7`. Первая версия хранила готовое, «раз всё равно
индексация двухбайтная». Собраны ОБА варианта, такты посчитаны по
сгенерированному asm:
| вариант | хвост после проверки границ | тактов | байт |
|---|---|---|---|
| `int16_t` готовое | `add hl,hl / add hl,de / ld e,(hl) / inc hl / ld d,(hl)` | **132** | 2 304 |
| `int8_t` `x/7` | `add hl,bc / ld a,(hl) / ld l,a / rlca / sbc a,a / ld h,a / add hl,de` | **131** | 1 152 |
Расширение знака и 16-битное сложение стоят ровно столько же, сколько
лишний `add hl,hl` при двухбайтном индексе, а обращений к памяти на одно
меньше — под wait-state'ами Sprinter (такт ≈ 2,4× номинала именно на
обращениях к ОЗУ) байтовый вариант ещё чуть выгоднее номинала. Итог:
байтовая таблица не хуже по скорости и на килобайт меньше. **Урок:
«двухбайтный индекс съест выигрыш» — гипотеза; она не подтвердилась.**
Кодоген проверен глазами (`bank4_pop_cdraw.asm`): `ld hl,#0x01C0; add hl,de`,
16-битное беззнаковое сравнение — индекс полный, старший байт не теряется
(грабли `sdcc_z80_const_ptr_index_bug` обойдены отдельной
`uint16_t`-переменной). 131 такт номинала против ~1 000 у `__divsint`.
Проверка таблицы: все 1 152 записи сверены питоном обратно из `.c`, а ПРАВИЛА
генерации (тождество + усечение к нулю) — тестом `geom_mul8div7_table_rules`
на целевом компиляторе: округляй SDCC к минус бесконечности, вся
отрицательная половина уехала бы на пиксель, и поймалось бы это только
глазами на левой кромке.
Оставлено сознательно (НЕ трогать, это не забытые места):
- **Хвосты за таблицей** — `guards.c:84`, `pop_bg.c:497`, `roomtest.c:163`,
`pop_map.c:412`: срабатывают только при x вне 0..255, то есть когда
персонаж в соседней комнате. Убирать их — это расширять `POP_TILE_DIV` до
140..395 (+280 Б резидента) ради редкого пути.
- **`pop_geom.c:21`** — намеренный медленный хвост `pop_y_to_row` (см. выше
про подъём деления SDCC).
- **`pop_geom.c:52`** — `v % n` в `pop_rnd_fit` для не-степени двойки:
оригинал зовёт `prandom(1)/(255)/(0xFF)`, все три идут веткой с маской,
сюда управление не приходит вовсе.
- **Холодные, раз на комнату/уровень/событие**: `pop_guard.c:266/268/269`
(вход стража), `pop_map.c:310` (пробуждение скелета), `pop_trob.c` дверь
уровня, `roomtest.c:473/663/887` (читы и старт), `pop_level.c:131/132`
(имя файла уровня), `roomtest.c:976/977` (отладочный HUD номера комнаты).
Каждое — единицы вызовов за секунды игры; таблицы под них только раздули
бы код.
Итог: **в горячем пути делений не осталось**. В банках 4, 6, 7 — ноль
`__div*`; всё, что видно в списке выше, либо за быстрым путём, либо холодное.
### Замер в MAME: A/B со сборкой `ec3cca5` (до правок)
Обе сборки прогнаны через полный цикл (`make hdd` → рестарт MAME → загрузка
уровня 1), сцена — **комната 1, Кид стоит, соперника нет**; скриншоты обеих
сборок идентичны. Фазы сняты брейкпоинтами на инструкциях `out
(_io_border), a` профилировочного бордюра, медиана по 60 логическим кадрам:
| фаза | до | после | Δ |
|---|---|---|---|
| ввод + heal | 62 361 | 62 364 | +3 |
| логика | 79 878 | 79 878 | 0 |
| фон | 119 502 | 119 502 | 0 |
| **спрайты** | **129 568** | **127 510** | **2 058** |
| РАБОТА за кадр | 391 258 | 389 221 | 2 037 |
Счётчик делений (брейкпоинты на `__divsint`/`__modsint`/`__divuchar`/
`__moduchar` с печатью адреса возврата):
| сцена | до | после |
|---|---|---|
| комната 1, только Кид | **1,00 `__divsint`/кадр** (возврат 0xD3AA = `pop_cdraw`) | **0** |
| комната 3, бой со стражем | **2,01 `__divsint`/кадр** | **0** (82 кадра боя) |
Всё сходится в одну картину: единственное деление горячего пути — `×8/7` на
персонажа, по одному вызову на каждого Char. Экономия ровно в фазе спрайтов
и ровно на стоимость одного вызова: **2 058 тактов** (документированная
оценка была ~2 400). С соперником на сцене — вдвое.
**Чего этот замер НЕ показывает, и это важно:**
- Период цикла как был 3 растровых кадра, так и остался — 2 000 тактов его не
двигают. Выигрыш ушёл в запас: до нижней границы 430 000 стало 40 800
тактов вместо 38 700.
- Остальные правки (три `y_to_row` в `pop_room`, `guard_col_from_x`,
`tp/10` у чомпера) в этой сцене **не срабатывают вовсе** — им нужны
падающая плита, переход стража между комнатами и уровень с чомперами.
Их стоимость известна поштучно, но в бою я их не ловил.
- Работа в комнате 3 между прогонами **несравнима** (391 204 против 318 145):
страж живой, фаза боя и срабатывание `pop_char_skip_mask` от прогона к
прогону разные. Оттуда взят только СЧЁТЧИК делений — он от таймингов не
зависит.
**Профиль работы за логический кадр СЕЙЧАС (409 956 тактов + ~15 600 ввод):**
| блок | тактов | % растрового кадра |
|---|---|---|
| see_kid + ctrl_tick + skip + heal + kid_tick | 52 536 | 12 |
| физика Кида + страж + боёвка | 73 452 | 17 |
| `pop_loose_tick` | 27 438 | 6,4 |
| **`pop_process_trobs`** (два факела) | **75 720** | **17,6** |
| `pop_redraw_needed` + шов + skip_mask | 10 932 | 2,5 |
| **`pop_char_draw` Кида** | **56 250** | **13** |
| **`pop_char_fore` Кида + борта** | **113 628** | **26** |
Синяя полоса (ввод + логика) была ~60 % → стала ~29 %.
**Оптимизация закрыта по решению пользователя 2026-08-10.** Всё, что
осталось неcделанным, вынесено с замерами в
[`../docs/perf_backlog.md`](../docs/perf_backlog.md) — там же протокол «как
мерить», чтобы не наступать заново на wait-state'ы и на устаревшие адреса
символов. Что доделано после таблицы выше: `pop_cd_touch` развёрнут,
tp/10 у факелов таблицей, пустой слот соперника считается тихим, ранний
выход в fore-проходе (нулевой эффект, оставлен как порт), быстрый путь без
клипа в `pop_blit_b` (факел 41 778 -> 31 218 тактов).
**Что осталось (запас на будущее, срочности больше нет):**
1. **`pop_char_fore` 113 628.** Внутри: `char_footprint` + расширение окна
~21 000, дальше шесть `fore_tile`, из которых два реально рисуют (по
~32 000). Дальше резать — кэш «в этом тайле переднего слоя нет вовсе».
2. **`pop_process_trobs` 75 720 на два факела** (~31 000 на факел). Внутри
одного факела: `gfx_blit_noclip` 8 700, чтение w/h и клип 6 400,
`pop_cd_touch` (теперь дешевле), маппинг окна 0 и возвраты. Пламя
перерисовывается каждый кадр обязательно (`TORCH_ANIM_DIV = 1`, кадр
меняется), так что пропуск тут не поможет — только удешевление блита.
3. `pop_loose_tick` 27 438 при полном отсутствии падающих плит.
4. Одно `__divsint` осталось в `pop_char_draw` (`obj_x * 8 / 7`) — ~2 400.
### Шаг 2 (СЛЕДУЮЩИЙ, назначен пользователем): ДВИЖУЩИЙСЯ Кид
Пропуск закрывает только покой. Наблюдение пользователя 2026-08-08: как
только Кид побежал, циан-полоса (спрайт + fore-проход) вырастает почти до
**100 % кадрового периода** — и это на ОДНОГО персонажа. То есть цена
одной перерисовки персонажа сама по себе непозволительно велика, и её надо
резать по существу, а не пропусками.
Куда смотреть (мерить каждое, метод — `z80_profiling_method`):
1. разделить замером спрайт-блит и fore-проход: у fore уже была история
78 % кадра до кэша кладки (`pop_fore_layer_cost`), он и сейчас главный
подозреваемый;
2. fore-проход перебирает 2×2…3 тайла и в каждом разбирает кладку заново —
кэш «в этом тайле переднего слоя нет вовсе» снял бы половину;
3. расширять перебор ТОЛЬКО по накладным спрайтам (клинок/брызги), а не по
всему окну клипа: габарит кадра уже покрыт футпринтом + правилом меча;
4. heal + блит спрайта: сейчас это два прохода по одной площади; посмотреть,
нельзя ли стирать только РАЗНОСТЬ прямоугольников при мелком сдвиге.
**Куда идти дальше в ПОКОЕ — там это ЛОГИКА, а не отрисовка.** Разбивка
работы кадра брейкпоинтами (комната 3, труп, Кид стоит; всего 500 772
такта):
| участок | тактов | % кадра |
|---|---:|---:|
| ввод + `check_skel` + луч видимости + `ctrl_tick` + heal | 65 862 | 15 % |
| `kid_tick` + физика + страж + боёвка | **260 022** | **60 %** |
| `loose_tick` + `process_trobs` | 119 562 | 28 % |
| `redraw_needed` + шов + спрайты + метка | 55 152 | 13 % |
| — из них два блита факелов | 44 136 | 10 % |
1. **60 % на тик персонажей** при том, что оба СТОЯТ — первый кандидат.
Смотреть `pop_phys_tick`/`pop_guard_phys_tick` (банк 3) и `pop_guard_tick`
(банк 1): сколько там работы, которую неподвижный персонаж делать не
обязан, и сколько стоит трамплин на каждом шаге.
2. **28 % на `loose_tick` + `process_trobs`** в комнате БЕЗ единой ловушки —
явно перебор; разобрать, что там сканируется каждый кадр (список trob,
`pop_trob_modif` соседа для шва, `pop_room_link`).
3. Запечь труп в фон (как щебень: тело в фоне + fore-часть поверх Кида) —
тогда бесплатным станет и проход Кида ПО телу, который сейчас снимает
пропуск по правилу «слоты рядом». Предложено пользователем 2026-08-08.
Мерить в ЭТОЙ точке (комната 3, страж убит, Кид стоит) — она воспроизводима
и даёт нижнюю границу; худший случай (бой + бег + падающая плита) считать
отдельно. Метод — memory `z80_profiling_method` (брейкпоинты с
`totalcycles`); полосы бордюра показывают только одну картинку из периода и
годятся лишь для раскладки по фазам.
**Наблюдение пользователя на приёмке DRAW-CHAR 2026-08-08:** циан-полоса
профиля (спрайты + fore) выросла — тогда «в пределах».
**Отчего именно.** Раньше перебор тайлов у Кида шёл строго по футпринту
кадра (`char_x_left/right`, seg006:1021), а он УЖЕ спрайта; расширение
перебора окном спрайта (клинок и брызги уходят за габарит кадра) было
только у соперника. DRAW-CHAR сделала его общим — то есть у Кида теперь на
колонку-другую больше `fore_tile` за кадр. Это не регрессия «лишней
работы», а недостающая ранее корректность: тайл, который спрайт задевает,
обязан вернуть свой передний слой поверх него.
**Если fore-проход снова станет узким местом** (сейчас он в покое не
выполняется вовсе): расширять перебор ТОЛЬКО по накладным спрайтам
(клинок/брызги), а не по всему окну; кэш «в этом тайле fore-слоя нет
вовсе»; считать окно клипа в тайловых координатах один раз.
## P1 — берётся в любой момент
### <a id="perf-sweep"></a>PERF-SWEEP. Поиск узких мест по ВСЕЙ игре, а не в одной сцене
**Постановка (пользователь, 2026-08-18):** «пока мы тестируем на регресс
только одну сцену; надо придумать механизм проверки узких мест по всей
программе — ручной проход уровня, в логи пишутся такты каждой фазы с
информацией, в какой комнате это происходит (возможно, в каком тайле Кид),
ищем максимумы по уровню и оптимизируем уже конкретную комнату».
Сцена 13/23 закрывает ПИК, но она одна: механики, которых в ней нет
(чомперы, бой, мышь, зеркало, толпа стражей), в бюджет никто не проверял.
#### Чем НЕЛЬЗЯ это делать — и почему
Тем, чем меряем сейчас. Брейкпоинты с `printf ...; g` на четырёх границах
фаз роняют эмуляцию в проценты от реального времени — **ручной проход так не
сыграть**, а именно ручной проход и есть суть задачи. Плюс две мелочи,
которые уже стоили времени: адреса зондов надо переснимать после КАЖДОЙ
пересборки (`.lst` + база модуля), а кольцо `clog` переполняется — сегодня
первый прогон принёс один «покой», каскад успел вытесниться за 75 с
ожидания.
#### Куда смотреть
**Вариант А — тап на порт бордюра в Lua-мосте (рекомендую).** `PROF(n)`
это `out (0xFE), a`, то есть границы фаз УЖЕ размечены в железе.
`install_write_tap` на порт даёт колбэк без остановки CPU — накладные
несравнимо меньше брейкпоинта, играть можно. Главный бонус: **тап не
привязан к адресам кода**, переснимать после пересборки нечего. «Где» берём
оттуда же: Lua читает `cur_room` и `Kid.curr_col/curr_row` по адресам из
`roomtest.map` (их выдавать в маленький файл при сборке, чтобы не парсить
map руками). Пишем CSV: кадр, комната, тайл Кида, синяя/зелёная/циан/работа,
период.
**Вариант Б — программа меряет себя сама, в РАСТРАХ.** Тактов Z80 изнутри
не видно, но нам они и не нужны: бюджет сформулирован в растровых кадрах, а
позицию луча видно поллингом бита 5 порта 0xFE (то же, чем живёт
`gfx_wait_vsync`). Разрешение ~1/256 кадра ≈ 1 700 тактов — для поиска
узких мест с запасом. Дороже варианта А по месту в резиденте, но
единственный, который работает **на живом железе**, а не только в MAME —
а такт MAME это 2,4× номинала Z80 (memory `sprinter_wait_states_2x`), и
рано или поздно сверяться придётся с железом.
#### Что считать результатом
Не «дамп кадров», а сводка: максимум каждой фазы **по комнатам**, число
кадров сверх 400 000 и сверх растрового кадра 430 000, и топ комнат по
худшему кадру. Дальше — точечная оптимизация той комнаты, как делали с
13/23.
Смежное: сцена и метод — [`../docs/perf_l13_room23.md`](../docs/perf_l13_room23.md);
фазы — [зелёная](../docs/perf_green_phase.md), [циан](../docs/perf_cyan_phase.md).
### <a id="t-render"></a>T-RENDER. Тесты слоя отрисовки: «в запечку не попадает транзиент»
**Откуда взялось** (пользователь, 2026-08-18, по итогам
[SPIKE-BAKED](BUGS_CLOSED.md#spike-baked)): «такой сценарий (и другие с
пиками) можно внести в наши тесткейсы?»
**Что есть сейчас.** `tests-host/` — это ЛОГИКА: геометрия, физика Кида,
`Char`, спецсобытия. Отрисовки там нет вовсе (`stubs.c` глушит все блиты
пустышками), `pop_tile.c`/`pop_room.c`/`pop_bg.c` не линкуются — таблицы
деления на ширину тайла в стабах даже продублированы копией. **Ловушки не
покрыты ничем**: ни пик, ни чомперов, ни `animate_*`. То есть SPIKE-BAKED
тестами поймать было нельзя в принципе.
**Что покрывать.** Не «картинку» (её проверяет глаз в MAME), а инвариант,
на котором держится вся наша запечка:
> при `pop_t_bake_rest = 1` ни один слой отрисовки тайла не выдаёт
> ТРАНЗИЕНТНЫЙ спрайт — ни своей ячейки, ни соседней.
Он проверяем механически и стоит ровно того: именно его нарушение дало
SPIKE-BAKED, а до того — мерцание плиты, ради которого флаг и заводился.
**Как.** Новый набор `t_render`: линкуются НАСТОЯЩИЕ `pop_tile.c`,
`pop_room.c`, `pop_bg.c`, а `pop_env_b`/`pop_wall_b`/`pop_fore_b` подменяются
**записывающим** стабом — списком `(id, x, yb)` за вызов. Тогда проверка
пишется в одну строку: «`draw_tile` при `bake_rest=1` и modif пик 4 не выдал
ни одного id из `spikes_fram_*`». Тот же стаб сразу закрывает и чомпера, и
плиту, и будущие ловушки — по одному тесту на каждую.
Отдельно и дёшево — характеризация автоматов `animate_spike`/`animate_chomper`
(сверка полного цикла модификатора с SDLPoP seg007), но она НЕ ловит этот
класс: автомат был верен, врал слой отрисовки.
**Чего это стоит.** Не одна строка: набор тянет за собой атласы и
`pop_cd_*`, а `stubs.c` придётся разделить (сейчас он подменяет то, что
здесь нужно настоящим). Прикидка — отдельная сессия. Место в `DATA_LOC`
проверить заранее: набор линкует ещё три крупных модуля (см. грабли
раскладки в `tests-host/README.md`).
### <a id="qsave"></a>QSAVE. QuickSave / QuickLoad — ЗАКРЫТА 2026-08-22
**Закрыта** (F6/F9, POP.SAV + POP.BAK, коммит f4b4852, тэг v0.6-pop-quicksave).
Протокол и устройство — [`TASKS_CLOSED.md`](TASKS_CLOSED.md#qsave);
формат снимка и разбор SDLPoP —
[`../docs/quicksave_plan.md`](../docs/quicksave_plan.md).
### <a id="l1-speed"></a>L1-SPEED. Игра идёт быстрее оригинала (найдено 2026-08-01) — ЗАКРЫТА 2026-08-19
**Закрыта** режимами скорости (`pop_pace.h`, клавиша **P**): FASTEST 3/3,
FAST 3/4, **NORMAL 4/5 = 81,9 / 102,4 мс** против 83,3 / 100 мс у
оригинала. Условие боя взято буквально из SDLPoP (`seg003.c:363`):
`Kid.sword == SWORD_2_DRAWN`. Дефолт — FASTEST (решение пользователя).
Проверено в MAME: период ровно 4 и ровно 5 растров. Разбор и замеры —
`../docs/frame_pacing_plan.md`. Осталось из исходной формулировки только
сверить NORMAL секундомером рядом с живым SDLPoP.
Исходная запись:
Сверка таймингов: оригинал — `BASE_FPS = 60` при `base_speed = 5` тиков на
логический кадр (`SDLPoP/src/types.h:1373`, `data.h:869`) = **83.3 мс**, в бою
`fight_speed = 6` = **100 мс**. У нас `roomtest.c` ждёт **три** `gfx_wait_vsync()`
= 60 мс, и отдельной скорости боя нет — то есть примерно **+39 % к скорости
эталона**. Соответствие: 4 ожидания (80 мс) обычно, 5 (100 мс) в бою.
Условие «делать ПОСЛЕ CLIP-1» снято — CLIP-1 закрыт. Проверка —
секундомером по одинаковому отрезку рядом с живым SDLPoP, не «на глаз».
### <a id="tune-1"></a>TUNE-1. Параметры движка — в конфиг, а не в код
**Что уже есть.** `pop_tune.h` — все настраиваемые числа собраны в одном
заголовке: чекпойнт уровня 3 (`POP_CHKP_*`), отладочное окно решётки
(`POP_DBG_GATE_HOLD`), включатель зацепа в прыжке (`POP_ENABLE_JUMP_GRAB`).
Правило уже действует: новое «особое событие» или тайминг заводится ТАМ, а
не константой по месту.
**Что нужно сделать.** Читать их из ФАЙЛА рядом с exe, чтобы менять без
пересборки — под отладку («растянуть решётку, чтобы успеть пройти») и под
моды. Формат: простой ini/`ключ=значение`, парсер на ~50 строк (числа,
комментарии `;`, неизвестные ключи игнорировать), файл необязателен —
нет файла, значит зашитые дефолты. Секции по смыслу: `[level]`,
`[debug]`, `[enhancements]`.
**Ориентир — SDLPoP.** У него это `custom_options_type` (types.h) +
`SDLPoP.ini` + меню Settings/Mods; наши имена намеренно совпадают с его
(`custom->имя`), чтобы сверка оставалась механической. Осмотр его меню и
опций — часть задачи: у него уже разложены по группам стартовые
HP/минуты, номера «особых» комнат и уровней (шадоу, скелет, зеркало,
чекпойнт), тайминги ворот и пик, скорости, а отдельной группой —
`fixes`/`enhancements` (включая `enable_jump_grab`, который мы уже
портировали). Брать всё подряд не надо: переносим по мере того, как
константа реально понадобилась в игре.
**Оговорка по памяти.** Парсер и таблица параметров — холодный код,
исполняется один раз при старте: кандидат в банк, а не в резидент W1.
### <a id="tune-2"></a>TUNE-2. Параметры СТРАЖЕЙ — в CFG-файл
Частный, но самый востребованный срез [TUNE-1](#tune-1): сейчас весь ИИ
стража зашит константами, и чтобы попробовать «а если он будет блокировать
пореже» — надо пересобирать.
**Что настраивать.** Всё, что индексируется мастерством (12 градаций,
`NUM_GUARD_SKILLS`), — таблицы в `guards.c`:
| таблица | что решает | дефолт для навыка 4 |
|---|---|---:|
| `STRIKEPROB` | вероятность удара | 61 |
| `RESTRIKEPROB` | добить из своего парирования | 5 |
| `BLOCKPROB` | заблокировать замах | 200 |
| `IMPBLOCKPROB` | то же, когда Кид только что пробил блок | 100 |
| `ADVPROB` | наступать | 255 |
| `REFRACTIMER` | «отдышка» после ранения, кадров | 8 |
| `extrastrength` | надбавка HP (сейчас зашита как `skill == 4 ? 1 : 0`, `pop_guard_cold.c`) | 1 |
Плюс не зависящее от навыка: `tbl_guard_hp[16]` (`pop_level_cold.c`) — база
HP по уровню, и навык скелета (`SKEL_SKILL`) / тени (`SHADOW_SKILL`).
**Формат — как у SDLPoP**, чтобы правки переносились в обе стороны без
перевода: секции `[Skill 0]`..`[Skill 11]`, внутри `strikeprob=`,
`blockprob=` и т.д. (`options.c:439`, пример — `SDLPoP/SDLPoP.ini:562`).
Имена ключей брать ЕГО, регистр игнорировать.
**Чего НЕ делать.** Навык конкретного стража живёт в данных уровня
(`level.guards_skill[комната-1]`, читаем в `pop_level_guard`) — его в CFG
не тащим, иначе разъедется с файлами уровней. Переопределение «в комнате
N навык M» — отдельная надобность, если понадобится, то как явная секция
`[Level N]`, а не подмена данных.
**Зачем это стоит сделать.** Прогон 2026-08-20 (11/22, навык 4) дал
ощущение «страж тяжелее, чем в SDLPoP». Сверка кода расхождений не нашла
(протокол — в `../docs/impl_diff.md` и в ответе сессии: дистанция,
диспетчер, три ветки ИИ, шесть таблиц, `justblocked`, `check_hurting`,
`parry`, `swordfight`, порядок тиков, откат seq_74 = −10 — всё совпало
побайтно). Проверить такую гипотезу можно только подбором чисел на живом
прогоне, а для этого их надо крутить без пересборки.
**Оговорка по памяти** — та же, что в TUNE-1: парсер холодный, в банк.
### <a id="snd"></a>SND. Звук — эффекты через CBL, музыка на AY — ЗАКРЫТА ИНАЧЕ 2026-08-26
> **Решение пересмотрено.** Музыка пошла НЕ на AY, а тем же PCM через CBL:
> записи DOS-версии укладываются в формат железа без единого преобразования
> в рантайме (`../docs/sound_plan.md` §4). Озвучены заставка, сцены между
> уровнями и игровые джинглы — 21 трек. Единственный неозвученный кусок —
> финал `won`, см. [MUS-WON](#mus-won).
<details><summary>исходная постановка (путь A на AY)</summary>
Полный разбор с замерами — [`../docs/sound_plan.md`](../docs/sound_plan.md).
Коротко, почему это оказалось дешевле, чем выглядело:
- эффекты уже лежат **8-битным PCM на 11 000 Гц**, а у CBL есть режим
10 937,5 Гц (расхождение 0,6 %) и ровно тот же формат сэмпла —
**играются как есть, без конверсии**; 31 звук, 103 941 Б ≈ 6,3
EMM-страницы;
- музыка есть **нотами PC-спикера, 7 КБ на всю игру**, причём нота задана
прямо в ГЕРЦАХ — пересчёт в период AY это одно деление; MIDI разбирать
не нужно;
- AY и COVOX сведены в один ЦАП, поэтому играют одновременно;
- отдельный таймер не нужен: секвенсор музыки двигает CBL-callback
(11,7 мс), а короче 12 мс во всей музыке всего 2 ноты из 1469.
Фазы З1..З5 и список «проверить артефактом до кодинга» — в доке.
</details>
### <a id="mob-spr-emm"></a>MOB-SPR-EMM. Композит падающего куска — в EMM (1284 Б W2)
`mob_spr` (`pop_room.c`) — самый крупный буфер в W2: 4 + 64*20 байт под
композит падающей плиты. Это половина нынешнего дефицита W2.
**В БАНК ЕГО ПЕРЕНЕСТИ НЕЛЬЗЯ** (разобрано 2026-08-26). Цепочка рисования
`pop_room` (банк 7) -> `pop_mem_b` (банк 5) -> `blit_b_clip` (резидент), и
трамплин на `pop_mem_b` переключает W3. Указатель на данные банка 7 после
этого показывает на страницу банка 5: блит нарисует мусор, причём молча.
`--bank-data` вдобавок действует на весь модуль, а `mobs` из того же файла
читают снаружи.
**ЧТО РАБОТАЕТ — EMM-страница с маппингом в W0.** W0 не переключают ни
трамплин (он трогает только W3), ни звуковой ISR (насос живёт в W3), значит
адрес `0x0000+off` валиден и в банке 7, и в банке 5, и в резиденте — ровно
как у атласных спрайтов, которые тем же блитом и рисуются. `blit_b_clip`
окно не трогает, маппингом занимается вызывающий.
**Цена.** `mob_spr_build` читает три части через `gfx_w0_map(a->page)`, а
приёмник должен быть в том же окне — держать оба нельзя. Сборку придётся
вести построчно: строка части в 64-байтовый буфер на стеке, перемаппить
окно, записать. Вызывается это лишь на смену тайлсета. Плюс
`gfx_w0_map`/`unmap` вокруг каждого куска — около 1350 тактов, при шести
кусках меньше 2 % кадра.
**Чем подтверждать.** Падающие плиты в комнате 1.2 и каскад уровня 13:
ошибка проявится не крашем, а мусором на месте куска.
**Дешёвая половинчатая мера** (если 1284 Б не нужны целиком): буфер объявлен
с запасом 64x20, фактический композит около 58x19 — точные константы дают
~180 байт без единого риска.
### <a id="pal-arc"></a>PAL-ARC. Палитры — в архив своей группы (полишинг)
Файлов палитр в игре шесть: `KID\kid.pal` (1024 Б), `PV\story.pal`,
`PV\pv.pal` (по 1024), `BG\pop_tile.pal`, `BG\pal_tile.pal` (по 128),
`TITLE\title.pal` (64). Всё, что лежит в `poc/res/tiles/` (36 файлов) и
`bg/pop_bg.pal`, на образ не попадает и кодом не читается — осадок ранних
экспериментов, его стоит просто убрать.
**Что предлагается.** Класть палитру в архив ТОЙ ЖЕ группы, где её
графика: `kid.pal``kid.arc`, оба `*_tile.pal``bg.arc`, `title.pal`
`title.arc`, `story`/`pv.pal``pv.arc`. Тогда правило одно на все
ресурсы («файл лежит в архиве своей группы»), и уходят последние шесть
открытий: `pop_bg_load` берёт тайлсет и палитру за одно открытие, заставка
так же. Отдельно ценно, что `kid.pal` перестанет читаться своим `open` на
КАЖДОЙ загрузке уровня (её перезаливает `pop_pal_game_load`).
**Чего это стоит.** Элемент архива читается только в EMM-страницу,
поэтому нужна маленькая функция «палитра из страницы»: `bank_read` по 256
байт в буфер и четыре `gfx_pal_load`. Буфер уже есть — `pop_pal_buf` по
0x4000.
**Что теряется.** Возможность подменить палитру файлом рядом с игрой
(`pal_file_load` сейчас откатывается на `a:\kid.pal`). Решить явно:
либо файл остаётся приоритетным и архив — запасным путём, либо fallback
выбрасывается.
**Почему НЕ отдельная папка `PAL\` и не один общий атлас палитр.** Папка
не уменьшает числа файлов и рвёт связь «палитра ↔ её графика». Общий
атлас экономил бы открытия только при загрузке всех палитр разом, а они
нужны в разное время: `kid.pal` — каждому уровню, заставочные — только
заставке.
### <a id="hof-entry"></a>HOF-ENTRY. Таблица рекордов — ЗАКРЫТА 2026-08-26
> **Сделано.** Оба показа оригинала: между credits и attract-demo и хвостом
> `end_sequence` (с вводом имени, паузой 120 тиков и возвратом на титульную
> картинку). Экран — рамка story с логотипом, время в формате `59:43`,
> строки с тенью, мигающий курсор. Ожидание конца победной темы переехало
> за таблицу, как в оригинале. Протокол и грабли —
> [`TASKS_CLOSED.md`](TASKS_CLOSED.md#hof-entry).
### <a id="mus-won"></a>MUS-WON. Финальная тема — кольцевой стриминг — ЗАКРЫТА 2026-08-26
> **Сделано.** Кольцо из шести страниц (96 КБ = 9 с), насос заворачивается
> по `pop_mus_ring`, подкачка в `pop_music_service` по дистанции
> «проиграно / прочитано». Разбор и грабли — `../docs/sound_plan.md` §8.
> Проверено в MAME на сборке `LEVEL=14`.
<details><summary>исходная постановка</summary>
Трек 56 (`won`, 115 с) — это 1,2 МБ, 78 страниц EMM. Ни в `POP_MUS_PAGES`
(20), ни в разумный бюджет памяти он не влезает, поэтому единственный
оставшийся неозвученным кусок игры — экран победы.
**Что нужно.** Кольцевой буфер: держать в EMM несколько страниц (8 = 128
КБ хватит с запасом — это 12 с звучания), а загрузчик дочитывает в те, что
насос уже прошёл. Курсор насоса (`pop_mus_pg`) при этом обязан
заворачиваться, а загрузчик — держать дистанцию от него и не обгонять на
круг. Чтение страницы 33 мс против 1,5 с её звучания — запаса вдоволь,
вся сложность в аккуратности индексов, а не в скорости.
**Заодно.** Тот же механизм снимает ограничение `POP_MUS_PAGES` со всех
треков: сейчас каждый должен помещаться в память целиком.
</details>
### <a id="arc-atlases"></a>ARC-ATLASES. Атласы — в архивы PBA1 — ЗАКРЫТА 2026-08-26
> **Сделано.** Восемь архивов: kid (58 атласов), pv (78), shadow (32), bg
> (25), title (20), guard (10), vizier (5), skel (4). На образе было 232
> файла, стало 8 архивов плюс шесть палитр, `kid.ani` и таблицы уровней.
> Индексы печатает упаковщик (`ARC_<ГРУППА>_<ФАЙЛ>`), серии код проверяет
> статически. `atlas_load` разделён: `atlas_attach` принимает уже
> прочитанную страницу, поэтому чтение из архива не дублирует проверку
> магии. Грабля, стоившая отладки: имя архива обязано лежать в том же
> банке, что и код, который его читает, — строковый литерал из чужого банка
> после переключения W3 показывает на другие данные; поэтому наружу отдаётся
> НОМЕР архива (`pop_arc_open_id`).
>
> Остаток на будущее — палитры, см. [PAL-ARC](#pal-arc).
<details><summary>исходная постановка</summary>
**Что сделано.** Формат PBA1 и читатель готовы и работают на звуке:
`toolchain/pop_pack_arc.py` собирает архив (заголовок 512 Б, записи по 4
байта — offset в секторах, size в байтах), `pop_arc.c` открывает его ОДИН
раз на группу и читает элементы прямо в EMM-страницы. Набор эффектов
уехал в `SND\snd.arc` целиком.
**Что осталось.** Перевести остальные группы: `BG\` (25 файлов), `KID\`
(60), `GUARD/SKEL/VIZIER/SHADOW` (~50), `TITLE\` (21), `PV\` (75),
`LEVELS\` (15). Каждая группа — свой архив, потому что таблицу держит
вызывающий на стеке (4 байта на запись, у PV это 300 Б).
**Зачем.** Замер 2026-08-25: `open` 51,4 мс против 32,6 мс на чтение 16 КБ.
Стартовая пачка ресурсов — около 142 файлов, то есть **7,3 с чистого
`open`**, который архивы убирают почти целиком. Второй мотив назван
пользователем прямо: «не нравится огромное кол-во файлов» в корне диска.
**Чем подтверждать.** Время от запуска до первого кадра уровня (сейчас
меряется по MAME), число файлов на образе, и что каждая сцена по-прежнему
собирается при неполном диске (пути ошибок в `atlas_load`).
</details>
### <a id="mem-next"></a>MEM. Следующий шаг разгрузки W1/W2
`pop_ctrl.c` уехал в банк 5 ([MEM-BANK5](TASKS_CLOSED.md#mem-bank5), куча
180 Б → 2298 Б; после снижения `--max-allocs` — 2751 Б). Следующий кандидат
по тому же критерию (**не размер, а частота вызова и отсутствие горячих
банк→банк переходов**) — **расщепление `pop_kid.c`**: холодная половина
(загрузка страниц спрайтов, `pop_kid_load`) в банк, движок кадров
(`load_frame`/`play_seq`, 2×/кадр) оставить в резиденте.
Брать по факту нехватки места, не заранее. Таблица резидентного кода по
модулям и разбор, почему `pop_level.c` в банк НЕЛЬЗЯ, — в
[`TASKS_CLOSED.md`](TASKS_CLOSED.md#mem-bank5).
---
## Отложено осознанно (не брать, пока не появится причина)
- **KBD-1, остаток** — «иногда при зажатом Shift стрелка всё-таки
пропускается» (ощущение пользователя, не замер: счётчики на 35 нажатиях
подряд потерь не показывают). Решение пользователя 2026-08-01: отложено до
финальной полировки. Где именно осталась дыра и что делать, если вернёмся,
— в [`TASKS_CLOSED.md`](TASKS_CLOSED.md#kbd-1) (там же весь протокол
замеров и список того, что делать НЕЛЬЗЯ).
- **Quickload (Shift+F9) и остальные читы SDLPoP** — оценка сделана
([DBG-CHEATS](TASKS_CLOSED.md#dbg-cheats)), код не написан. Самое ценное и
самое дорогое: сериализация `Char` + `room_modif` всех комнат + trob'ов +
стражей (`levels_plan.md` §4), зато даёт воспроизводимый регресс «вот кадр,
где баг» вместо ручной подгонки позы.
- **Звук** (CBL-эффекты, Фаза 5 `PORT_PLAN.md`) — геймплей не блокирует.
- **Таймер уровня / HUD времени / меню / сохранения** — Фаза 6.
- **[T-1](BUGS_OPEN.md#t-1)** (пики: перерисовка по причине) — отдаётся почти
бесплатно после [T-2](BUGS_OPEN.md#t-2), отдельно не окупается.
- **Отключение мыши на время игры** и **замена PRNG**
`../docs/ideas_backlog.md` (оба дают доли процента кадра).
- **OPT-1** (хирургический редрой шва) — решено НЕ делать, стоимость
транзиентная; разбор в [`BUGS_CLOSED.md`](BUGS_CLOSED.md).
---
## OPT-BLIT — цена отрисовки: чистка кодогена (СЛЕДУЮЩАЯ СЕССИЯ)
> **2026-08-17: контрольный замер снят, задача разложена по фазам.**
> Работа теперь ведётся в трёх документах, которые живут между сессиями —
> туда же писать результаты каждой правки:
>
> - [`../docs/perf_l13_room23.md`](../docs/perf_l13_room23.md) — сцена
> (каскад 6 плит, ур. 13 комната 23), рецепт воспроизведения, зонды, канал
> `clog`, сводка по кадрам, разбор габаритов спрайтов;
> - [`../docs/perf_green_phase.md`](../docs/perf_green_phase.md) — ЗЕЛЁНАЯ
> фаза: пик **805 000** при бюджете 400 000; позиции G1..G6;
> - [`../docs/perf_cyan_phase.md`](../docs/perf_cyan_phase.md) — ЦИАН фаза:
> пик **632 000** при бюджете 400 000; позиции C1..C7.
>
> Порядок работ: **G1** (окно клипа для запечки, −250..400 тыс.) → **G2**
> (расколоть `draw_tile` + лист для ряда −1) → **C1** (убрать избыточный
> `pop_cd_touch` в `mob_render`, 81 тыс.) → **G3/C3** (`blit_b_clip` на
> байтовые габариты) → **C4** (разобрать `pop_char_fore(KID)`: 1 722 → 150 978).
>
> Синяя секция (**142 830**) в бюджет укладывается — не трогаем.
> Ответ на вопрос про `uint8_t`: горячий путь можно переводить целиком,
> максимум по всем игровым атласам **56×63**; шире 255 только восемь
> полноэкранных подложек титров/сюжета (320×200 и т. п.), и они рисуются раз
> на экран — им отдельный банк / прямой libbgi.
Открыто 2026-08-13 по итогам разбора зелёного блока. Контекст и все замеры
— в memory `blit_cost_model` и `sdcc_z80_stack_locals_hot_loop`, протокол
разбора — в коммитах `f89b7dd` / `b27b313` / этом.
**Что уже сделано и чем это подтверждено.** Цена блита разложена регрессией
по 339 замерам: `такты = 8791 + 198,2*h + 5,96*(w*h)` (такты MAME = системный
клок ~21,5 МГц, НЕ такты Z80 — растровый кадр 430 000). Байт на пределе
железа (через акселератор дважды по 3 такта), строка ~198, а вот 8 791 на
ВЫЗОВ оказались нашим кодогеном: `pop_blit_b` держал `w`/`h` как `uint16_t`,
из-за чего вся функция уезжала в 14-байтовый стековый кадр и `w = img[0] |
(img[1] << 8)` разворачивалось в два десятка IX-относительных пересылок.
После перехода на байтовый габарит: noclip 13 679 -> 11 530 (-16%),
клипованный 18 853 -> 15 340 (-19%), `_CODE` -42 Б.
### а) Где ещё `uint16_t` можно сделать `uint8_t`
Габариты спрайтов и всё, что из них считается. Признак: значение заведомо
<= 255, но объявлено 16-битным «на всякий случай», и живёт в горячем пути.
Начинать с:
- `blit_b_clip` (`pop_tile.c`) — `dw`/`dh`/`sx`/`sy` объявлены `int`, хотя
ядра принимают `uint8_t`; это ВТОРОЙ по частоте путь (33% блитов).
- `pop_cd_touch` / `cd_cols_of` — четыре `int`-аргумента на вызов.
- `_gfx_blit_sprite_noclip` и обёртки libbgi: `stride` уже `uint16_t`, а
`w`/`h``uint8_t`; проверить, не расширяются ли они обратно у вызывающих.
- Кандидаты в `pop_room.c`/`pop_bg.c`: локальные `int x, dby, dmy` в
`draw_tile` — часть из них помещается в байт, но осторожно: координаты
бывают отрицательными.
**Как проверять:** после каждой правки смотреть пролог функции в
`.sprinter-cc-roomtest/*.asm` — исчез ли `ld iy,#-N / add iy,sp / ld sp,iy`
и сколько осталось `-N (ix)` в теле.
### б) Проход по asm за неоптимальным IX-доступом
Механическая проверка, даёт больше всего за единицу усилий:
```
grep -c "(ix)" .sprinter-cc-roomtest/*.asm # где сгущается
grep -n "ld iy, #-" .sprinter-cc-roomtest/*.asm # крупные стековые кадры
grep -n "pop.*\n.*pop.*\n.*push" ... # чтение спилла через стек
```
Признаки беды (все три встречались сегодня): пролог с `iy`-кадром больше
~6 байт; пары `pop bc / pop hl / push hl / push bc` в теле цикла (SDCC
читает спиленный указатель через стек вместо `ld l,-N(ix)`); повторный
пересчёт адреса `arr[i]` под каждое поле структуры.
Приоритет по частоте вызова: `pop_blit_b` (сделан) -> `blit_b_clip` ->
`pop_cd_touch` -> `draw_tile` -> `pop_redraw_needed`.
### Что НЕ делать (проверено сегодня, отрицательный результат)
- **Не откладывать запекание на другой кадр.** Запекание пишет ОЗУ-копию
фона, из которой восстанавливает `heal`; отложенное даёт призрак плиты на
месте дыры. Идея «бюджет одного запекания на кадр» снята.
- **Не ускорять передачу пикселей** — она на пределе железа (3+3 такта на
байт), см. модель выше.
- **Не искать проблему в W3-скобке** (`_bgi_begin`/`_bgi_end` — по пять
инструкций) и не списывать разброс на прерывания (внутри размерной
группы разброс 3 такта, код прямолинейный).
### Хвосты этой сессии
- Пакетная пометка `pop_cd_touch` (`pop_cd_batch_begin/end`, скобка в
`draw_tile`) — сделана, но выигрыш замером НЕ подтверждён: в захваченных
кадрах скобка не срабатывала (блиты шли из холодной отрисовки комнаты).
Переснять на кадрах ЗАПЕКАНИЯ. **2026-08-17: цена НЕпакетного вызова
замерена — 4 502 такта, 28 % всей цены блита; в `mob_render` он к тому же
избыточен (см. C1).**
- Снять временную оснастку: `pop_dbg_m9..m16`, `pop_dbg_b1..b6`,
`pop_dbg_wh`, `pop_dbg_kind`, `pop_dbg_rdmax*`, `mame/v306/run_bridge_log.sh`.
- Цель по кадру не достигнута: зелёный пик был 792 012 при цели 400 000.
После сегодняшних правок не перемерян — начать сессию с контрольного
замера, а не с новых правок.
**ГРАБЛИ (стоили сегодня часа):** проверять, что запущен РОВНО ОДИН MAME
(`pgrep -f mame.arm | wc -l`) — несколько экземпляров пишут в один
`error.log`, мост говорит с одним, замеры собираются с другого, и точки
«не срабатывают». И не обрезать `error.log`, пока MAME его держит: она
пишет по старому смещению, в начале остаётся дыра из нулей.