# 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+ | не начат | | Критичных багов на уровнях 1 и 2 не осталось. Следом — регресс тактов на 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) | игра на ~39 % быстрее оригинала | ощущение от ВСЕХ уровней; берётся в любой момент | | — | [TUNE-1](#tune-1) | параметры движка → cfg-файл (сейчас `pop_tune.h`) | отладка таймингов и моды; берётся по мере надобности | | — | [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 — делаем сейчас ### 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 — берётся в любой момент ### L1-SPEED. Игра идёт быстрее оригинала (найдено 2026-08-01) Сверка таймингов: оригинал — `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. ### 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 его держит: она пишет по старому смещению, в начале остаётся дыра из нулей.