# SprPoP — доска ОТКРЫТЫХ задач (обновлено 2026-08-11) Что берём в работу сейчас и в каком порядке. Каждая запись: что сделать, почему именно сейчас, чем подтверждать результат. - закрытые задачи с протоколами и замерами — [`TASKS_CLOSED.md`](../../PoP/roomtest/TASKS_CLOSED.md); - открытые баги — [`BUGS_OPEN.md`](BUGS_OPEN.md), закрытые с разбором корней — [`BUGS_CLOSED.md`](../../PoP/roomtest/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`](../../PoP/roomtest/TASKS_CLOSED.md#pass-policy); отдельных задач `L3-PASS`/`L4-PASS` больше нет. Уже сделанные полные обходы уровней 1 и 2 остаются регресс-базой; - **[L4-MIRROR](TASKS_CLOSED.md#l4-mirror) закрыта**: зеркало, отражение, прыжок сквозь него и рождение тени проверены в MAME. Хвосты — вид тени (отложен, [`../docs/shadow_render.md`](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`](perf_green_phase.md), [`../docs/perf_cyan_phase.md`](perf_cyan_phase.md), сцена и метод — [`../docs/perf_l13_room23.md`](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. Зелье ПЕРЕВОРОТА — ЗАКРЫТА 2026-08-26 > Проверено пользователем в живой игре. > **Подробный план реализации — [`../docs/l9_invert_plan.md`](../../PoP/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` — самая большая функция `sprpop.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`](../../PoP/roomtest/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`](perf_l11_room15.md) §7), плюс визуальный прогон боя и прохода Кида под факелами: персонаж не должен оставлять хвостов и не должен просвечивать сквозь пламя. **Место в очереди — [`../docs/perf_registry.md`](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](perf_green_phase.md) (пометка соседа узкой полосой).** Это две половины одной темы, и брать их логично вместе: G8 про ширину ЗАПЕЧКИ соседнего тайла (60 вместо 28 нужных, да ещё `draw_tile` соседа дважды на пометку), HEAL-WIDTH — про ширину HEAL'ов (64 вместо 58/57). Числа там уже разобраны: 60 = 32 свой тайл + 28 собственный свес, и для запечки САМОГО тайла 60 минимальны — сужать можно только пометку СОСЕДА. **Место в общей очереди — [`../docs/perf_registry.md`](perf_registry.md) (позиция P8).** Там же собраны ВСЕ отложенные оптимизации из трёх фазовых доков, отсортированные по измеренному эффекту, и разложена цена одного блита (6 126 тактов фиксированной накладной на любой блит, независимо от размера). ### 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`](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, без двери) | `sprpop.c` + `pop_start_level` | seg000:0900 | Осознанное расхождение: мигания Кида спрайтами тени во время вспышки нет — запись в [`../docs/impl_diff.md`](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-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`](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. Зелье МЕДЛЕННОГО ПАДЕНИЯ — ЗАКРЫТА 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 — выпить, спрыгнуть в шахту, убедиться в плавном спуске и в том, что эффект кончается по таймеру. ### 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-SprPoP/`): ``` 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`, `sprpop.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` дверь уровня, `sprpop.c:473/663/887` (читы и старт), `pop_level.c:131/132` (имя файла уровня), `sprpop.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`](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 — берётся в любой момент ### TIME-SPEED. Часы бегут быстрее в FAST/FASTEST — СДЕЛАНО 2026-08-31, остался вариант 3 (RTC) **Наблюдение (пользователь, 2026-08-31):** «при режиме FAST/FASTEST время начинает бежать быстрее — похоже, время мы считаем в наших логических кадрах, и если они отрисовываются чаще, то и время быстрее». Так и есть. `pop_timer_tick` (`src/pop_timer.c`) уменьшает счётчик на КАЖДОМ логическом кадре — ровно как оригинал. Но длина логического кадра у нас зависит от режима скорости (`src/pop_pace.h`): | режим | обычный кадр | бой | |---|---|---| | NORMAL | 4 кадра луча (81,9 мс) | 5 (102,4 мс) | | FAST | 3 (61,4 мс) | 4 (81,9 мс) | | FASTEST | 3 (61,4 мс) | 3 (61,4 мс) | Разная длина кадра в игре и в бою — ПОВЕДЕНИЕ ОРИГИНАЛА (подтверждено пользователем), и часы, идущие в бою медленнее, трогать не нужно. Расхождение только в наших добавочных режимах: NORMAL повторяет оригинал, а FAST/FASTEST ускоряют всё разом, включая ход часов — минута игрового времени проходит примерно на треть быстрее реальной. **Варианты.** 1. Оставить как есть: быстрый режим ускоряет игру целиком, это честно и предсказуемо. Ноль работы и ноль риска. 2. Развязать часы от темпа: тикать не по логическому кадру, а по накопленным кадрам ЛУЧА (4 кадра луча = 1 тик). Тогда минута остаётся минутой в любом режиме, а в бою часы по-прежнему замедляются, как в оригинале. Цена — счётчик-накопитель в `pop_timer_tick`. 3. **Часы от RTC** (идея пользователя, 2026-08-31). Брать время из часов реального времени, а не считать кадры вовсе. **Почему третий вариант интереснее, чем кажется.** Погрешность есть уже СЕЙЧАС и без всяких режимов: логический кадр NORMAL — 4 кадра луча, а это 81,93 мс, то есть 12,2 кадра в секунду вместо ровных 12. Оригинальная минута из 720 тиков проходит у нас за 58,99 с — почти на секунду быстрее. За час игры набегает около минуты. Ни один из первых двух вариантов этого не лечит: они выравнивают режимы между собой, но обе шкалы остаются привязанными к лучу, а луч не кратен игровой секунде. RTC (`ESTEX $21 SYSTIME`, memory `sprinter_systime_dow`) даёт абсолютную шкалу и снимает накопление полностью. Подводные камни, которые надо решить при взятии в работу: - вызов ESTEX стоит дорого и клобберит регистры — читать раз в тик, не в кадре, и не из горячего пути (memory `estex_bios_abi`); - разрешение RTC — секунда, а тик игры — 1/12 секунды: нужен гибрид «кадры внутри секунды, синхронизация по RTC на границе», иначе часы задёргаются; - пауза, меню и загрузка НЕ должны съедать игровое время — при часах от RTC это перестаёт получаться само собой и требует явного вычитания; - быстрая загрузка/сохранение обязаны сохранять смещение, а не абсолютное время. **Решать пользователю.** Порядок по цене: вариант 1 (ничего), вариант 2 (счётчик кадров луча, лечит только разбег режимов), вариант 3 (RTC, лечит и накопление — но требует разобраться с паузами). ### SND-SPEAKER-38. Звук мигания надписи — СДЕЛАНО и ПРОВЕРЕНО 2026-08-31 **Постановка (пользователь, 2026-08-31):** «когда надпись Press Button начинает мигать — в SDLPoP воспроизводится звук на каждое моргание, у нас тишина». Решение выбрано там же: «проще синтезировать как PCM и добавить в наш SND атлас». **Наш код НЕ виноват и правки не требует.** `pop_dead_prompt` (`src/pop_status.c`) уже зовёт звук 38 на каждом появлении строки — ровно как оригинал. Пусто в НАБОРЕ: в `assets/packed/SND/snd.idx` слот 38 имеет длину 0, играть нечего. **Почему его нет.** У оригинала три параллельных набора звука, и номера разложены по ним не подряд: | набор | что | номера | у нас | |---|---|---|---| | `DIGISND1..3.DAT` | оцифровка | 0–23, 44–49, 51 | берём, это и есть наш SND | | `MIDISND1..2.DAT` | мелодии | 24–30, 32, 33, 35–37, 39–41, 43, 50, 52–56 | берём отдельно, как музыку в `MUS/` | | `IBM_SND1..2` | ноты PC-спикера | 0–56 (весь диапазон) | НЕ берём вовсе | Звук 38 есть ТОЛЬКО в наборе спикера — ни оцифровки, ни мелодии для него не существует, поэтому он и провалился между двумя нашими конвейерами. **Полная ревизия недостающего (сделана 2026-08-31).** Номера, которых нет ни в оцифровке, ни среди мелодий: **31, 34, 38, 42**. Из них 31, 34 и 42 — пустые заглушки в один байт, нот внутри нет. **Реально звучит ровно один номер — 38.** То есть задача закрывает единственную дыру в наборе, а не открывает семейство. **Формат ресурса** (канон — `docs/PoP/POP-DAT-FormatSpecifications.pdf`, раздел «Internal PC Speaker»): заголовок 3 байта, из них байт 1 — темп в долях на две секунды; далее тройки «частота в герцах (2 байта) + длина в долях (1 байт)», нулевая частота = пауза; в конце маркер `12 00`. Звук 38 (`IBM_SND1/res10038.bin`, 17 байт) — нисходящий сигнал из четырёх нот: 2500, 2000, 1500, 1000 Гц по одной доле, темп 72 → около 110 мс. Для сверки разбора: звук 17 (мягкое приземление) — одна нота 49 Гц на три доли. **Что делать.** 1. В `tools/pop_pack_sound.py` — генератор PCM из нот: меандр на нашей частоте вывода (`RATE`), амплитуда умеренная (сигнал короткий и резкий, полный размах будет колоть ухо), пауза = уровень тишины `0x80`. 2. Брать из спикера ТОЛЬКО те номера, которых нет ни в оцифровке, ни в мелодиях — сейчас это ровно 38. Правило важнее списка: если брать всё подряд, синтез перекроет собой мелодии, которые мы играем из `MUS/`. 3. Дальше всё уже готово: звук ложится в атлас и индекс общим путём, `snd.idx` пересобирается, EXE не меняется (раскладка читается с диска). 4. Проверка: `make resources` → слот 38 в индексе получил ненулевую длину; в MAME дождаться мигания надписи после смерти — должен звучать сигнал. **Цена.** Один короткий звук в наборе (~2 КБ после выравнивания на блок), кода в игре — ноль. ### 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` по адресам из `sprpop.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`](perf_l13_room23.md); фазы — [зелёная](perf_green_phase.md), [циан](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`](../../PoP/roomtest/TASKS_CLOSED.md#qsave); формат снимка и разбор SDLPoP — [`../docs/quicksave_plan.md`](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 мс**. У нас `sprpop.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 — ЗАКРЫТА ИНАЧЕ 2026-08-26 > **Решение пересмотрено.** Музыка пошла НЕ на AY, а тем же PCM через CBL: > записи DOS-версии укладываются в формат железа без единого преобразования > в рантайме (`../docs/sound_plan.md` §4). Озвучены заставка, сцены между > уровнями и игровые джинглы — 21 трек. Единственный неозвученный кусок — > финал `won`, см. [MUS-WON](#mus-won).
исходная постановка (путь A на AY) Полный разбор с замерами — [`../docs/sound_plan.md`](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 и список «проверить артефактом до кодинга» — в доке.
### 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 байт без единого риска. ### 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` — каждому уровню, заставочные — только заставке. ### HOF-ENTRY. Таблица рекордов — ЗАКРЫТА 2026-08-26 > **Сделано.** Оба показа оригинала: между credits и attract-demo и хвостом > `end_sequence` (с вводом имени, паузой 120 тиков и возвратом на титульную > картинку). Экран — рамка story с логотипом, время в формате `59:43`, > строки с тенью, мигающий курсор. Ожидание конца победной темы переехало > за таблицу, как в оригинале. Протокол и грабли — > [`TASKS_CLOSED.md`](../../PoP/roomtest/TASKS_CLOSED.md#hof-entry). ### MUS-WON. Финальная тема — кольцевой стриминг — ЗАКРЫТА 2026-08-26 > **Сделано.** Кольцо из шести страниц (96 КБ = 9 с), насос заворачивается > по `pop_mus_ring`, подкачка в `pop_music_service` по дистанции > «проиграно / прочитано». Разбор и грабли — `../docs/sound_plan.md` §8. > Проверено в MAME на сборке `LEVEL=14`.
исходная постановка Трек 56 (`won`, 115 с) — это 1,2 МБ, 78 страниц EMM. Ни в `POP_MUS_PAGES` (20), ни в разумный бюджет памяти он не влезает, поэтому единственный оставшийся неозвученным кусок игры — экран победы. **Что нужно.** Кольцевой буфер: держать в EMM несколько страниц (8 = 128 КБ хватит с запасом — это 12 с звучания), а загрузчик дочитывает в те, что насос уже прошёл. Курсор насоса (`pop_mus_pg`) при этом обязан заворачиваться, а загрузчик — держать дистанцию от него и не обгонять на круг. Чтение страницы 33 мс против 1,5 с её звучания — запаса вдоволь, вся сложность в аккуратности индексов, а не в скорости. **Заодно.** Тот же механизм снимает ограничение `POP_MUS_PAGES` со всех треков: сейчас каждый должен помещаться в память целиком.
### 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).
исходная постановка **Что сделано.** Формат 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`).
### 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`](../../PoP/roomtest/TASKS_CLOSED.md#mem-bank5). --- ## Отложено осознанно (не брать, пока не появится причина) - **KBD-1, остаток** — «иногда при зажатом Shift стрелка всё-таки пропускается» (ощущение пользователя, не замер: счётчики на 35 нажатиях подряд потерь не показывают). Решение пользователя 2026-08-01: отложено до финальной полировки. Где именно осталась дыра и что делать, если вернёмся, — в [`TASKS_CLOSED.md`](../../PoP/roomtest/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`](../../PoP/roomtest/BUGS_CLOSED.md). - **KEYS-D — осмотр соседних комнат** (`H`/`J`/`U`/`N` + `Ctrl+B`, фаза D плана [`keys_plan.md`](keys_plan.md)). Отложено 2026-08-27 по решению пользователя: полезно игроку, но дорого. **Почему дорого.** У SDLPoP это три строки, потому что там `drawn_room` влияет ТОЛЬКО на отрисовку, а физика ходит через `get_tile(room, col, row)` с явной комнатой. У нас наоборот: карта коллизий загружается для ОТРИСОВАННОЙ комнаты — `room_fg`, `lcol_fg`, `rcol_fg`, `above_fg`, `below_fg` — это ОДИН комплект на программу, и описывает он `cur_room` (видно в `pop_qsave_restore_room`). Уведи `cur_room` к соседу, не трогая `kid_room`, — и Кид начнёт считать столкновения по чужим тайлам: провалится сквозь пол или упрётся в воздух. Задел уже стоит: `kid_room` заведён отдельно, `update_kid_render_dx()` умеет сдвигать спрайт на ±140 при расхождении. Но `enter_room_side()` пишет обе переменные разом — это и есть незакрытая часть S3 straddle ([`room_model_plan.md`](room_model_plan.md)). **Два пути.** (1) Честный, как в оригинале: игра идёт, пока смотришь. Требует развести владельца карты коллизий — правка ядра, дни работы и риск для того, что уже работает. (2) Смотровой режим С ОСТАНОВКОЙ игры: пока ничто не тикает, рассогласование не проявляется. Нужна третья, облегчённая «нарисовать комнату, не трогая состояние»: `enter_room_side` попутно делает `pop_guard_leave`/`pop_loose_leave_room`, а `pop_qsave_restore_room` перетирает карту и зовёт `pop_loose_reset`. Оценка — 150-250 байт в банк 8 и несколько часов с проверкой. Рекомендация: путь (2) плюс запись в `impl_diff.md` (у нас смотровой режим останавливает игру, у оригинала — нет). Главный риск не в отрисовке, а в ВОЗВРАТЕ: оставить движок с чужой картой или подтёртой историей heal — класс багов из memory `pop_seam_room_model`. Обходной путь, который уже работает: `Ctrl++` / `Ctrl+−` — телепорт по номеру комнаты. Задачу разбора багов он закрывает, задачу игрока «подсмотреть» — нет. - **KEYS-R — воскрешение Кида** (`R`, остаток фазы C). Отложено 2026-08-27. Не ещё один чит, а правка модели смерти: SDLPoP ставит `resurrect_time = 20` и `Kid.alive = -1`, после чего об этом обязаны знать четыре места — убывание счётчика (`seg003:512`), пропуск пик и челюстей (`seg000:1245`), пропуск урона мечом (`seg000:876`), восстановление позы в `play_kid` (`seg006:1352`: `loadkid`, полное HP, стойка, `Char.x += 8`, `set_start_pos`). То есть окно неуязвимости, размазанное по кадровой цепочке, и оно задевает гейт «мёртв», про который предупреждает memory `pop_level_restart_scope`. Наш `Char.alive` использует конвенцию оригинала (`-1` жив), так что портируется, — но точечно и с проверкой каждого гейта. - **KEYS-F12 — скриншот в файл** (фаза E). Помечено пользователем как «возможно, когда-то в следующих версиях»: 80 КБ на кадр и запись на диск посреди игры. --- ## OPT-BLIT — цена отрисовки: чистка кодогена (СЛЕДУЮЩАЯ СЕССИЯ) > **2026-08-17: контрольный замер снят, задача разложена по фазам.** > Работа теперь ведётся в трёх документах, которые живут между сессиями — > туда же писать результаты каждой правки: > > - [`../docs/perf_l13_room23.md`](perf_l13_room23.md) — сцена > (каскад 6 плит, ур. 13 комната 23), рецепт воспроизведения, зонды, канал > `clog`, сводка по кадрам, разбор габаритов спрайтов; > - [`../docs/perf_green_phase.md`](perf_green_phase.md) — ЗЕЛЁНАЯ > фаза: пик **805 000** при бюджете 400 000; позиции G1..G6; > - [`../docs/perf_cyan_phase.md`](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-SprPoP/*.asm` — исчез ли `ld iy,#-N / add iy,sp / ld sp,iy` и сколько осталось `-N (ix)` в теле. ### б) Проход по asm за неоптимальным IX-доступом Механическая проверка, даёт больше всего за единицу усилий: ``` grep -c "(ix)" .sprinter-cc-SprPoP/*.asm # где сгущается grep -n "ld iy, #-" .sprinter-cc-SprPoP/*.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 его держит: она пишет по старому смещению, в начале остаётся дыра из нулей.