Files
Sprinter-SDCC/applications/PoP/roomtest/TASKS_OPEN.md
T
snark13 980d48c168 Пики оставались навсегда: запечка фона консервировала выдвинутый кадр
Уровень 7, комната 19.  Кнопка в (0,5) при нажатии зовёт pop_floor_bake,
а тот перерисовывает и правого соседа — пики (0,6) — в банке, который
пишет в ОЗУ-копию.  Кадр брался живой, и выдвинутая пика оставалась в
фоне: дальше heal возвращал её каждый кадр.  В (0,7) чисто, потому что
окно клипа запечки кончается на x=219, а колонка 7 начинается с 224.

Флаг pop_t_bake_rest («в фон кладём покой») для этого и был, но читал его
только pop_loose_frame.  Кадр пик считался по месту в ЧЕТЫРЁХ слоях.
Добавлен pop_spike_frame — порт get_spike_frame (SDLPoP seg008:08A0),
которого у нас не было, — и все четыре слоя переведены на него; заодно
позу покоя в запечке стал отдавать pop_chomp_pose.

Одна точка лечит все пять путей запечки, включая вход в комнату.
Резидент +28 Б, скорость не затронута.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:53:44 +03:00

92 KiB
Raw Blame History

roomtest — доска ОТКРЫТЫХ задач (обновлено 2026-08-11)

Что берём в работу сейчас и в каком порядке. Каждая запись: что сделать, почему именно сейчас, чем подтверждать результат.

  • закрытые задачи с протоколами и замерами — TASKS_CLOSED.md;
  • открытые баги — BUGS_OPEN.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; отдельных задач L3-PASS/L4-PASS больше нет. Уже сделанные полные обходы уровней 1 и 2 остаются регресс-базой;
  • L4-MIRROR закрыта: зеркало, отражение, прыжок сквозь него и рождение тени проверены в MAME. Хвосты — вид тени (отложен, ../docs/shadow_render.md) и MIRROR-FG-STALE (закрыт как не баг);
  • L3-CHOMP и 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_mobset_redraw2); залипший блеск меча (trob_drawn не совпадал с тем, что нарисует полная отрисовка)
2 принят (2026-08-17) чёрные бары поверх стража — bar с x + w > 320 заворачивался в соседнюю страницу видеопамяти (dd40c24); BUG-CHEAT-IMM-1 — боевая стойка без меча под читом бессмертия
3 принят (2026-08-17) критичных багов не найдено — правок не потребовалось
4 предварительно пройден (2026-08-17) LEVELDOOR-PALACE-CLIP — во дворце проём двери уровня на 8 px шире, и контур Кида в анимации ухода обрезался раньше времени (89b603a)
5 предварительно пройден (2026-08-18) багов не найдено
6 предварительно пройден (2026-08-18) SHADOW-STALE-FRAME — тень залипала на страницах дабл-буфера в РАЗНЫХ позах (кадр брался из кэша, снимок писался по Guard.frame, 085a198); BLUELINE-NOBLUE — кусок синего узора на стене: модификатор стены не переводился при загрузке (af4e644)
7+ не начат

«Предварительно пройден» ≠ «принят»: уровни 4-6 пройдены прогоном по сценарию, а не обходом ВСЕХ комнат — полные обходы делаются по готовности всех уровней (политика приёмок). Регресс-базой остаются полные обходы уровней 1 и 2.

Критичных багов на уровнях 1-6 не осталось. Следом — регресс тактов на 23/13 (документы фаз: ../docs/perf_green_phase.md, ../docs/perf_cyan_phase.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 закрыта 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 (задачи 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: страницы 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 куча 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 кадр уложился в бюджет 2026-08-09: 470 964 -> 425 600 тактов, период цикла 4 растровых кадра -> 3. Дальнейшее — запас, не срочность плавность на ВСЕХ уровнях
L1-SPEED игра на ~39 % быстрее оригинала ощущение от ВСЕХ уровней; берётся в любой момент
TUNE-1 параметры движка → cfg-файл (сейчас pop_tune.h) отладка таймингов и моды; берётся по мере надобности
MEM следующий шаг разгрузки W1/W2 берётся по факту нехватки места

Сделанное — в 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 §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.

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.

Не портировано намеренно: 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_featherFALL_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)»):

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).

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_prevmemcpy (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:52v % 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 — там же протокол «как мерить», чтобы не наступать заново на 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 — берётся в любой момент

T-RENDER. Тесты слоя отрисовки: «в запечку не попадает транзиент»

Откуда взялось (пользователь, 2026-08-18, по итогам 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

План целиком — ../docs/quicksave_plan.md (разбор SDLPoP, инвентаризация нашего состояния, формат снимка, порядок восстановления, разбиение на шаги QS1…QS6, риски). Изучено 2026-08-17, код не начат.

Оговорка, с которой начинается план: в оригинале 1989 года этого нет вовсе, QuickSave — enhancement SDLPoP (seg000.c, USE_QUICKSAVE, F6/F9). Значит повторяем не букву, а устройство.

Что взять у SDLPoP без изменений: снимок — плоский список полей, один обход на запись и на чтение (#define process(x)), совместимость держится строкой версии и ничем больше; клавиша только взводит флаг, работа идёт между кадрами; состояние ОТРИСОВКИ не сохраняется вовсе — комната перерисовывается с нуля.

Чем наш случай тяжелее: уровень лежит в EMM-странице, а не в ОЗУ; room_modif/trobsstatic в банковом pop_trob.c (нужны сериализаторы); ГСЧ у нас три (pop_t_seed, trob_seed, pop_fight_seed), и пропуск любого даст «загрузилось, но играется иначе»; и главное — дабл-буфер: перерисовать после загрузки надо ОБЕ страницы, иначе старая картинка мигнёт через кадр.

Носитель — файл (QUICKSAVE.SAV, ≈1,9 КБ), EMM-страница отвергнута: она не переживает рестарт программы, а именно рестарт — тот случай, ради которого QuickSave и нужен (сцену каскада на 13/23 воспроизводит только ESC → запуск заново). Мгновенный EMM-слот остаётся необязательным QS6.

Критерий приёмки: сохранить, выйти по ESC, запустить roomtest заново, загрузить — и оказаться там же.

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, куча 180 Б → 2298 Б; после снижения --max-allocs — 2751 Б). Следующий кандидат по тому же критерию (не размер, а частота вызова и отсутствие горячих банк→банк переходов) — расщепление pop_kid.c: холодная половина (загрузка страниц спрайтов, pop_kid_load) в банк, движок кадров (load_frame/play_seq, 2×/кадр) оставить в резиденте.

Брать по факту нехватки места, не заранее. Таблица резидентного кода по модулям и разбор, почему pop_level.c в банк НЕЛЬЗЯ, — в TASKS_CLOSED.md.


Отложено осознанно (не брать, пока не появится причина)

  • KBD-1, остаток — «иногда при зажатом Shift стрелка всё-таки пропускается» (ощущение пользователя, не замер: счётчики на 35 нажатиях подряд потерь не показывают). Решение пользователя 2026-08-01: отложено до финальной полировки. Где именно осталась дыра и что делать, если вернёмся, — в TASKS_CLOSED.md (там же весь протокол замеров и список того, что делать НЕЛЬЗЯ).
  • Quickload (Shift+F9) и остальные читы SDLPoP — оценка сделана (DBG-CHEATS), код не написан. Самое ценное и самое дорогое: сериализация Char + room_modif всех комнат + trob'ов + стражей (levels_plan.md §4), зато даёт воспроизводимый регресс «вот кадр, где баг» вместо ручной подгонки позы.
  • Звук (CBL-эффекты, Фаза 5 PORT_PLAN.md) — геймплей не блокирует.
  • Таймер уровня / HUD времени / меню / сохранения — Фаза 6.
  • T-1 (пики: перерисовка по причине) — отдаётся почти бесплатно после T-2, отдельно не окупается.
  • Отключение мыши на время игры и замена PRNG../docs/ideas_backlog.md (оба дают доли процента кадра).
  • OPT-1 (хирургический редрой шва) — решено НЕ делать, стоимость транзиентная; разбор в BUGS_CLOSED.md.

OPT-BLIT — цена отрисовки: чистка кодогена (СЛЕДУЮЩАЯ СЕССИЯ)

2026-08-17: контрольный замер снят, задача разложена по фазам. Работа теперь ведётся в трёх документах, которые живут между сессиями — туда же писать результаты каждой правки:

  • ../docs/perf_l13_room23.md — сцена (каскад 6 плит, ур. 13 комната 23), рецепт воспроизведения, зонды, канал clog, сводка по кадрам, разбор габаритов спрайтов;
  • ../docs/perf_green_phase.md — ЗЕЛЁНАЯ фаза: пик 805 000 при бюджете 400 000; позиции G1..G6;
  • ../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/huint8_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 его держит: она пишет по старому смещению, в начале остаётся дыра из нулей.