# 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).
### L9-INVERT. Зелье ПЕРЕВОРОТА (уровень 9, комнаты 7 и 10)
> **Подробный план реализации — [`../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).
### 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 (поле, переворачиваются). После переноса обе страницы
становятся чисто «полевыми», и постраничный кэш перевёрнутых кадров получается
однородным, без спрайтов-исключений.
### 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 — делаем сейчас
### 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-проход — вторая половина той же цены).
### 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 тактов фиксированной накладной на любой блит, независимо от размера).
### L12-SHADOW. Уровень 12: встреча с тенью, бой, слияние
> **Статус 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).
### 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, до правки).
Вопрос к этапу полиша: стоит ли неочевидный код такого узкого выигрыша.
### 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) и убедиться, что ни один фоновый элемент не
перерисовывается в кадре, где его тайл не менялся; замерить кадр до и после.
### 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`]) —
только правильный набор спрайтов вместо чужого.
### L12-SEAM-POLISH. Переход 12 -> 13 выглядит коряво
Механика работает (комната 23, без заставки и без сброса HP), но сама
анимация прохода рваная — замечено пользователем 2026-08-13. Полишинг,
берётся вместе с остальной шлифовкой переходов.
### L13-JAFFAR. Уровень 13: Джафар, победа, выход, падающая гряда
> **Статус 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 НЕ сбрасывается.
### L7-FEATHER. Зелье МЕДЛЕННОГО ПАДЕНИЯ (уровень 7, комната 1)
> **Статус 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 — выпить, спрыгнуть в шахту, убедиться в плавном спуске и в том, что
эффект кончается по таймеру.
### 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)).
### 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 — берётся в любой момент
### 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).
### 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`).
### 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).
### 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, не «на глаз».
### 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.
### 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: парсер холодный, в банк.
### SND. Звук — эффекты через CBL, музыка на AY
Полный разбор с замерами — [`../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 и список «проверить артефактом до кодинга» — в доке.
### 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 его держит: она
пишет по старому смещению, в начале остаётся дыра из нулей.