Files
Sprinter-SDCC/applications/PoP/roomtest/TASKS_OPEN.md
T

1090 lines
88 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# roomtest — доска ОТКРЫТЫХ задач (обновлено 2026-08-11)
Что берём в работу сейчас и в каком порядке. Каждая запись: что сделать,
почему именно сейчас, чем подтверждать результат.
- закрытые задачи с протоколами и замерами — [`TASKS_CLOSED.md`](TASKS_CLOSED.md);
- открытые баги — [`BUGS_OPEN.md`](BUGS_OPEN.md), закрытые с разбором корней —
[`BUGS_CLOSED.md`](BUGS_CLOSED.md);
- планы фаз — `../docs/PORT_PLAN.md`, `../docs/layout_plan_v2.md`,
`../docs/levels_plan.md`.
**Состояние на 2026-08-11 (сверено с кодом, не только с доской):**
- **уровни 1-4 приняты smoke-тестами** (пользователь). Полные обходы всех
комнат делаются по готовности ВСЕХ уровней — политика приёмок в
[`TASKS_CLOSED.md`](TASKS_CLOSED.md#pass-policy); отдельных задач
`L3-PASS`/`L4-PASS` больше нет. Уже сделанные полные обходы уровней 1 и 2
остаются регресс-базой;
- **[L4-MIRROR](TASKS_CLOSED.md#l4-mirror) закрыта**: зеркало, отражение,
прыжок сквозь него и рождение тени проверены в MAME. Хвосты — вид тени
(отложен, [`../docs/shadow_render.md`](../docs/shadow_render.md)) и
[MIRROR-FG-STALE](BUGS_CLOSED.md#mirror-fg-stale) (закрыт как не баг);
- **[L3-CHOMP](TASKS_CLOSED.md#l3-chomp) и [L3-SKEL](TASKS_CLOSED.md#l3-skel)
закрыты** (2026-08-08 / 2026-08-07);
- **тайлсет palace сделан** — `pop_bg_load(type)`, `pal_*.atl`, дворцовая
кладка `wall_pattern`, решётчатые тайлы 25-29 и в `tile_table`, и в
коллизии (`tile_is_floor` совпадает с seg006:0628). То есть шаг 2
`levels_plan.md` закрыт;
- **libbgi:** блочные AND/OR/XOR/NOT акселератора (2026-08-11, `tests/accop`)
— задел под вид тени и под любые эффекты «поверх того, что уже нарисовано».
**Обход уровней после оптимизации фаз — идёт сейчас (начат 2026-08-17).**
Оптимизация зелёной/циан фаз тронула порядок пометок и перерисовок, поэтому
уровни проходятся заново подряд:
| уровень | статус | что нашлось и закрыто по дороге |
|---|---|---|
| 1 | **принят** (2026-08-17) | мерцание торцов плит/полов/кнопок (запечка шире копируемого прямоугольника); потолочный fore поверх падающей плиты (не было ряда −1 в проходе foretable); соседний пол под плитой (потерянная половина `draw_mob``set_redraw2`); залипший блеск меча (`trob_drawn` не совпадал с тем, что нарисует полная отрисовка) |
| 2 | **принят** (2026-08-17) | чёрные бары поверх стража — `bar` с `x + w > 320` заворачивался в соседнюю страницу видеопамяти (`dd40c24`); [BUG-CHEAT-IMM-1](BUGS_CLOSED.md#bug-cheat-imm-1) — боевая стойка без меча под читом бессмертия |
| 3 | **принят** (2026-08-17) | критичных багов не найдено — правок не потребовалось |
| 4 | **предварительно пройден** (2026-08-17) | [LEVELDOOR-PALACE-CLIP](BUGS_CLOSED.md#leveldoor-palace-clip) — во дворце проём двери уровня на 8 px шире, и контур Кида в анимации ухода обрезался раньше времени (`89b603a`) |
| 5 | **предварительно пройден** (2026-08-18) | багов не найдено |
| 6 | **предварительно пройден** (2026-08-18) | [SHADOW-STALE-FRAME](BUGS_CLOSED.md#shadow-stale-frame) — тень залипала на страницах дабл-буфера в РАЗНЫХ позах (кадр брался из кэша, снимок писался по `Guard.frame`, `085a198`); [BLUELINE-NOBLUE](BUGS_CLOSED.md#blueline-noblue) — кусок синего узора на стене: модификатор стены не переводился при загрузке (`af4e644`) |
| 7+ | не начат | |
«Предварительно пройден» ≠ «принят»: уровни 4-6 пройдены прогоном по
сценарию, а не обходом ВСЕХ комнат — полные обходы делаются по готовности
всех уровней ([политика приёмок](TASKS_CLOSED.md#pass-policy)).
Регресс-базой остаются полные обходы уровней 1 и 2.
Критичных багов на уровнях 1-6 не осталось. Следом — регресс тактов на
23/13 (документы фаз: [`../docs/perf_green_phase.md`](../docs/perf_green_phase.md),
[`../docs/perf_cyan_phase.md`](../docs/perf_cyan_phase.md), сцена и метод —
[`../docs/perf_l13_room23.md`](../docs/perf_l13_room23.md)).
Правило проекта в силе: механику сверять с `../SDLPoP/src/` ДО кодинга;
диагноз платформы подтверждать артефактом (брейкпоинт/дамп/.asm), а не
гипотезой (memory `defer_unexplained_quirks`).
---
## ТЕКУЩАЯ ЦЕЛЬ: уровень 8
Уровни 1-7 играются (smoke; зелья уровней 2 и 7 приняты пользователем
2026-08-12). Уровень 8 **новых тайлов не приносит вовсе** (инвентарь
`res2008.bin` целиком покрыт уровнями 1-7), тайлсет — подземелье.
Спецсобытие уровня — мышь, [L8-MOUSE](TASKS_CLOSED.md#l8-mouse) закрыта
2026-08-12 (хост-набор `t_mouse` + живая проверка в MAME).
Остаётся smoke-прогон самого уровня: он длинный и с возвратом через два
чомпера комнаты 4, а стражи там сильнее прежних (комната 5 — skill **7**,
это максимум из встречавшихся; 12/22/24 — skill 2/3/3).
<a id="l9-invert"></a>
### L9-INVERT. Зелье ПЕРЕВОРОТА (уровень 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).
<a id="ui-sprites"></a>
### UI-SPRITES. Деления HP — из атласов персонажей в константы резидента
`pop_kid_img_blit` (**334 Б в W1/W2**) существует ради двух картинок 6×5:
полосе HP нужен блит по image id из кидовских атласов, а те column-major, и
обычным `gfx_blit_noclip` их не нарисовать. Деления Кида (images 216/217,
`kid27.atl`) и стража (idx 0 в `g0.atl`) — по 30 байт каждое.
**Решение пользователя 2026-08-12: положить их const-массивами в РЕЗИДЕНТ**
(~68 Б данных), а не подселять в фоновый атлас: резидент всегда ниже
0xC000, значит `gfx_blit*` их видит (буфер обязан быть вне W3), EMM-страница
не тратится, упаковщики не трогаются. Чистая экономия ~266 Б.
Побочный плюс для [L9-INVERT](#l9-invert): страницы `kid27` и `g0` сейчас
смешанные — деления HP (борт, не переворачиваются) лежат рядом с брызгами
крови 28×26 (поле, переворачиваются). После переноса обе страницы
становятся чисто «полевыми», и постраничный кэш перевёрнутых кадров получается
однородным, без спрайтов-исключений.
<a id="mem-cold2"></a>
### MEM-COLD2. Второй шаг разгрузки резидента
> **Пункт 2 сделан 2026-08-12**: `guard_over_kid` с хелперами
> (`objtile_at_char` / `char_x_left_of` / `tile_div_mod`) уехал в банк 8 —
> куча 1005 → **1574 Б**. Зовётся раз в кадр, то есть цена — один трамплин.
> Остались пункты 1 (`enter_room_side`) и 3 (блок редроя шва).
После [MEM-COLD1](TASKS_CLOSED.md#mem-cold1) куча 1707 Б. Когда упрётся
снова, в банк 8 переезжают следующие кандидаты (в порядке выгоды):
1. `enter_room_side` — самая большая функция `roomtest.c`, зовётся раз на
комнату. Требует вынести наружу рабочие массивы комнаты
(`room_bg`/`lcol_*`/`rcol_*`/`below_fg`/`above_*`) — сами данные останутся
в `_DATA`, уедет только код;
2. `guard_over_kid` + `objtile_at_char`/`char_x_left_of`/`tile_div_mod`
зовётся КАЖДЫЙ кадр, но ровно один раз: цена выноса — один трамплин;
3. блок редроя левого шва в `main` (`seam_row_sig` + разбор сигнатуры).
Если и этого не хватит — следующим уходит сам кадровый порядок отрисовки,
но тогда уже стоит мерить, а не выносить наугад.
---
## ПРЕДЫДУЩАЯ ЦЕЛЬ: уровень 5
Уровни 1-4 играются (smoke). Дальше идём по порядку уровней; уровень 5 —
следующий.
**Хорошая новость по ассетам: уровень 5 не приносит НИ ОДНОГО нового тайла.**
Инвентарь, снятый перебором `res2005.bin` (fg & 0x1F):
```
ур. 5: empty, floor, spike, pillar, gate, closer, doortop_with_floor(7),
bigpillar_bottom(8), bigpillar_top(9), potion, loose, doortop(12),
debris, opener, level_door L/R, chomper(18), torch, wall,
lattice_pillar(25)…lattice_right(29)
```
— всё это уже встречалось на уровнях 1-4 и портировано. Единственное новое
на уровне 5 — **спецсобытие «тень крадёт зелье»**.
| # | Задача | Что | Блокирует |
|---|--------|-----|-----------|
| — | [DRAW-COST](#draw-cost) | **кадр уложился в бюджет 2026-08-09**: 470 964 -> 425 600 тактов, период цикла 4 растровых кадра -> **3**. Дальнейшее — запас, не срочность | плавность на ВСЕХ уровнях |
| — | [L1-SPEED](#l1-speed) | игра на ~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 — делаем сейчас
### <a id="l12-shadow"></a>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).
<a id="loose-shake-runs"></a>
### LOOSE-SHAKE-RUNS. Дрожание плит: пометки по сменам кадра, а не по кадрам
**Идея пользователя 2026-08-13, на этап полиша.**
`pop_loose_tick` на КАЖДОМ кадре отсчёта ставит `pop_set_redraw(pos,
POP_RD_LOOSE, 1)` — полную перерисовку тайла, десять раз за отсчёт. А
кадр дрожания меняется не каждую фазу: таблицы
(`POP_LOOSE_FRAM_BOTTOM` = 43,73,43,74,74,43,43,43,74,74,74) дают
фаза 1 2 3 4 5 6 7 8 9 10
кадр 73 43 74 74 43 43 43 74 74 74
^^^^^ ^^^^^^^^ ^^^^^^^^
парами и тройками одно и то же
С фазы 5 кадры идут ТРОЙКАМИ. Значит вместо шести пометок с `pages = 1`
хватит двух — на фазах 5 и 8 — но с `pages = 2` (обе страницы дабл-буфера,
иначе на второй застынет старый спрайт и пойдёт мерцание через кадр).
**Выигрыш ~20 % работы зелёного блока и ТОЛЬКО на дрожащих плитах.**
Наивный вариант «ставить лишь на смене кадра» выигрыша НЕ даёт: пять смен
по две страницы — те же десять перерисовок (проверено арифметикой
2026-08-13, до правки).
Вопрос к этапу полиша: стоит ли неочевидный код такого узкого выигрыша.
<a id="bg-once"></a>
### BG-ONCE. Задний фон рисуется ОДИН раз; вместо чёрных баров — heal
**Предложение пользователя 2026-08-13. Не делать сейчас — обязательно к
проверке и реализации на этапе полиша/оптимизации.**
Суть: элементы САМОГО заднего фона — факелы без пламени, окна, узор кладки,
решётчатая кладка и что ещё найдётся — впечатываются в фон один раз и больше
не перерисовываются никогда. Ни один объект за ними оказаться не может, а
значит и возвращать их поверх чего-либо не нужно. Если такой элемент оказался
затёрт, это САМО ПО СЕБЕ сигнал об ошибке: либо бар/прямоугольник слишком
велик, либо что-то рисуется не в своём слое.
Вторая половина предложения — и она про скорость: там, где мы гасим область
чёрным баром перед выводом спрайта, звать вместо бара **heal по координатам
бара**. Бар всё равно приходится потом закрывать фоном, то есть выходит две
операции; heal делает то же одной и сразу правильным содержимым.
**Оценка: идея верная по направлению, но есть два известных препятствия.**
1. **Узкий heal уже пробовали — и откатили.** В `pop_room.c` (`pop_clip_sprite`)
записано: per-спрайтовый heal не используется, потому что выравнивание
блока акселератора оставляет 1-2 px слайверы правой грани, и на одной
странице дабл-буфера это мерцает. Поэтому борта чистит полоса
`pop_room_clip_borders` с гейтом по флагу. Прежде чем менять бары на heal,
надо решить именно эту задачу — иначе получим мерцание вместо ускорения.
2. **Полный запрет «не перерисовывать фон» противоречит foretable.**
`draw_tile_fore` (seg008:715) кладёт в передний слой ГЛАВНУЮ ГРАНЬ стены
вместе с её узором — она обязана перекрывать персонажа. То есть запрет
должен различать «декор задней стены» и «передняя грань», а это ровно
различие backtable/foretable оригинала. Фактически предложение сводится к
«привести наши слои в соответствие с таблицами оригинала» — тот же вывод,
к которому пришёл разбор `overlay_mid_tile` (см. ниже).
**Известное исключение**, которое надо не сломать: Кид, уходящий по лестнице
в дверь уровня, действительно уезжает за передний план — но он и рисуется
отдельно (`clip_char`, правая грань doortop; см. memory `pop_clip_char_todo`).
**Критерий приёмки этапа:** пройти уровни с падающими плитами (13) и с
факелами/окнами (10-12) и убедиться, что ни один фоновый элемент не
перерисовывается в кадре, где его тайл не менялся; замерить кадр до и после.
<a id="l12-shadow-spr"></a>
### L12-SHADOW-SPR. Тень дерётся спрайтами СТРАЖА, а не своими
**Найдено прогоном 2026-08-13.** `tbl_guard_type[12] = 4` — это SHADOW.DAT,
отдельный набор спрайтов (силуэт Кида), а `pop_guard_load` (`pop_cdraw.c`)
разбирает только тип 2 (SKEL) и 3 (VIZIER); тип 4 сваливается в набор
обычного стража. Ассеты есть: `SDLPoP/data/SHADOW` (32 кадра + `res750.pal`).
Правка по образцу VIZIER (набор в `pop_pack_guard.py`, каталог в Makefile,
ветка в `pop_guard_load`), НО сначала проверить две вещи, иначе спрайты
поедут: (1) какую таблицу кадров получает `CHARID_1_SHADOW` в
`pop_frame_tbl_is_guard` — Кида или стража, и (2) сколько кадров в наборе
(32 против 34 у стража) и не выходит ли индекс за его пределы.
Это НЕ полный «вид тени» (OR/XOR-блиттеры, [`../docs/shadow_render.md`]) —
только правильный набор спрайтов вместо чужого.
<a id="l12-seam-polish"></a>
### L12-SEAM-POLISH. Переход 12 -> 13 выглядит коряво
Механика работает (комната 23, без заставки и без сброса HP), но сама
анимация прохода рваная — замечено пользователем 2026-08-13. Полишинг,
берётся вместе с остальной шлифовкой переходов.
<a id="l13-jaffar"></a>
### L13-JAFFAR. Уровень 13: Джафар, победа, выход, падающая гряда
> **Статус 2026-08-13: код написан, хост-тесты зелёные (44 проверки,
> `tests-host/t_jaffar.c`), ЖИВОЙ ПРОВЕРКИ В MAME ЕЩЁ НЕ БЫЛО.**
Своего ИИ у Джафара нет — это обычный страж (seg002:085A), skill 9 и 6 HP уже
лежат в `pop_level_cold.c`. Сделаны только события и данные:
| кусок | где | порт |
|---|---|---|
| фора после встречи (`guard_notice_timer`) | `guards.c` `pop_meet_jaffar` + `autocontrol_guard_inactive` | seg002:0544 / 0736 |
| победа: белая вспышка + «выход разрешён» | `guards.c` `on_guard_killed` | seg006:1927 |
| выход: кнопка комнаты 24 при уходе ВЛЕВО | `guards.c` `pop_jaffar_exit` | seg002:0517 |
| гряда плит сверху при входе в комнаты 23/16 | `pop_map.c` `pop_check_fall_flo` | seg000:1317 |
| фазу со старшим битом на 13-м НЕ гасить | `pop_map.c` `pop_loose_tick`, `pop_trob.c` `animate_loose` | seg007:823 |
| сотрясение на 13-м плиты НЕ трясёт | `pop_map.c` `do_knock` | seg007:949 |
| плита бьёт и В БЕГЕ | `pop_map.c` `fell_on_your_head` | seg007:1218 |
| СПРАЙТЫ Джафара (VIZIER.DAT, своя палитра) | `pop_pack_guard.py`, `pop_cdraw.c`, `Makefile` | seg000:1092 |
По ходу нашлась и починена смежная ошибка: цвет стража из данных комнаты
применялся к ЛЮБОМУ набору, хотя оригинал берёт его только у обычного стража
(`curr_guard_color = 0` при `tbl_guard_type != 0`, seg002:183) — скелета это
не задевало случайно, Джафара покрасило бы.
Осознанное расхождение (представление фазы отложенного старта) — запись в
[`../docs/impl_diff.md`](../docs/impl_diff.md).
**Не портировано намеренно:** `is_show_time` в `on_guard_killed` — это показ
ОСТАВШЕГОСЯ ВРЕМЕНИ, а часов на 60 минут у нас пока нет вовсе.
**Что проверять в MAME** (`make LEVEL=13`):
1. комната 23 (стартовая): плиты сверху сыплются ВРАЗНОБОЙ с первых кадров,
и они бьют, даже если пробегать под ними;
2. приземление Кида плиты НЕ трясёт (иначе гряда посыплется разом);
3. уход вправо из комнаты 3: Джафар виден СВОИМИ спрайтами и своей палитрой,
~2 секунды стоит, не доставая клинок;
4. смерть Джафара — белая вспышка; уход ВЛЕВО открывает дверь уровня.
**Что проверять в MAME** (по порядку прохождения уровня):
1. комната 15: пока меч лежит — тени нет; подобрал меч, вышел и вернулся —
тень СВАЛИВАЕТСЯ сверху, а не стоит готовая;
2. бой: каждый удар по тени отнимает HP и у Кида; добить её нельзя — на её
смерти умирает и Кид (белая вспышка);
3. слияние: убрать меч (`Shift`+вниз), подойти — белая вспышка, тень исчезла,
потолок HP вырос на единицу;
4. после слияния в комнатах 2 и 13 (верхний ряд) под ногами появляются плиты;
5. уход ВПРАВО из комнаты 18 убирает меч из комнаты 15 (вернуться и взять
второй раз нельзя);
6. комната 23 — уровень молча становится 13-м, HP НЕ сбрасывается.
### <a id="l7-feather"></a>L7-FEATHER. Зелье МЕДЛЕННОГО ПАДЕНИЯ (уровень 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 — выпить, спрыгнуть в шахту, убедиться в плавном спуске и в том, что
эффект кончается по таймеру.
### <a id="l3-color"></a>L3-COLOR. Палитра КЛАДКИ уровня 3 (в оригинале он зелёный)
**Наблюдение (пользователь, 2026-08-07, со сравнением карт VGA).** В
оригинальной VGA-версии кладка уровня 3 ЗЕЛЁНАЯ, а уровней 1-2 — серо-синяя.
В SDLPoP все подземелья одинаковые, поэтому по нему разницу не увидеть.
**Почему в SDLPoP её нет — проверено, не гипотеза.** Механизм там ЕСТЬ
(seg000:1140, «Level colors (1.3)»):
```c
int level_color = custom->tbl_level_color[current_level];
if (level_color != 0) {
byte* env_pal = level_var_palettes + 0x30*(level_color-1);
byte* wall_pal = env_pal + 0x30 * custom->tbl_level_type[current_level];
set_pal_arr(0x50, 0x10, (rgb_type*)env_pal); /* chtab_6 environment */
set_pal_arr(0x60, 0x10, (rgb_type*)wall_pal); /* chtab_7 wall */
}
```
`tbl_level_color` (data.h:842) = `{0,0,0,1,0,0,0,1,2,2,0,0,3,3,4,0}`у
уровня 3 цвет **1**, у 7 тоже 1, у 8/9 — 2, у 12/13 — 3, у 14 — 4. Но
`level_var_palettes` — это ресурс **20** из `PRINCE.DAT` (только версии
1.3/1.4), а в `SDLPoP/data/PRINCE/` его НЕТ: там лежит лишь `res10.bin`
(палитры стражей). Значит `level_var_palettes == NULL` и вся ветка молча
пропускается — отсюда одинаковые подземелья.
**Данные у нас есть.** В `../MSDOS/PRINCE.DAT` ресурс 20 присутствует:
offset 22790, **240 байт** = 5 палитр × 16 цветов × 3 байта (6-битные
каналы, как res10).
**Что делать (когда дойдём до вида уровня 3).**
1. Достать ресурс 20 из `MSDOS/PRINCE.DAT` (упаковщику придётся читать сам
`.DAT` — сейчас все скрипты берут распакованные PNG из SDLPoP);
2. сгенерировать таблицу палитр рядом с `pop_guard_pal.h`;
3. при загрузке уровня заливать слоты **0x50..0x5F** (env) и **0x60..0x6F**
(wall) — у нас ровно эти базы (`pop_pack_bg.load_indexed`: `pal_base =
0x60` для WALL, `0x50` для env), то есть совпадение со `set_pal_arr`
один в один;
4. `wall_pal = env_pal + 0x30 * tbl_level_type[level]` — для подземелья
(`level_type == 0`) обе палитры одинаковые.
**Грабли, уже пойманные на цвете стражей:** `gfx_pal_load` отдаёт указатель
в BIOS (`$A4` через `rst #0x08`), а BIOS читает только `#4000-#BFFF`
таблицу нельзя передавать прямо из банка (0xC000+), надо копировать в
W1/W2 (см. [BUG-GUARD-COLOR-1](BUGS_CLOSED.md#bug-guard-color-1)).
### <a id="draw-cost"></a>DRAW-COST. Кадр НЕ УКЛАДЫВАЕТСЯ в бюджет — нужна оптимизация
> **ШАГ 1 СДЕЛАН 2026-08-08: пропуск неизменившегося персонажа.** Комната
> 1.3, труп стража, Кид стоит: было **210 %** кадрового периода, стало
> **116 %** (500 772 такта при бюджете 430 000). Отрисовка перестала быть
> узким местом: персонажи в покое не рисуются ВООБЩЕ (ноль вызовов
> `pop_heal_fast` за кадр), весь фон — ДВА блита факелов (44 136 тактов).
> Механизм и почему метка позиционная — в шапке `pop_cdraw.h`.
**Исходные замеры пользователя (полосы бордюра, до шага 1).** Уровень 1,
Кид СТОИТ — то есть НЕ худший случай, ни боя, ни движения:
| комната | синяя (ввод+heal+логика) | зелёная (фон) | циан (спрайты) | итого |
|---|---|---|---|---|
| 3, страж УБИТ | ~80 % | ~20 % | ~110 % | **~210 %** |
| 2, стража НЕТ | ~60 % | ~20 % | ~60 % | ~140 % |
Разница ровно в теле убитого стража: +20 % синей и +50 % циана. Труп
сохраняет `charid != 0`, поэтому каждый кадр честно проходил весь путь
живого персонажа (heal → спрайт+clip_char+брызги+клинок → fore-проход), хотя
его кадр постоянен до выхода из комнаты.
**Что сделано (шаг 1).** Не спецкейс «мёртвый», а общее правило: у каждой
страницы дабл-буфера свой снимок ВХОДОВ отрисовки слота; совпал снимок,
спрайт этой страницы цел и фон в его прямоугольнике не трогали — heal, блит
и fore-проход пропускаются целиком. Покрывает и труп, и стоящего Кида, и
ждущего стража. Детали контракта — `pop_cdraw.h`, реализация —
`pop_char_skip_mask` / `cd_quiet` в `pop_cdraw.c`, метка фона —
`pop_cd_touch` в резидентном `pop_tile.c`.
Грабли, на которые наступили по дороге: сначала метка была ФЛАГОМ «фон
трогали хоть где-то» — и выигрыш оказался ровно нулевым, потому что факелы
анимируются каждый кадр и гасили пропуск для всех персонажей сразу (замер:
597 684 такта, как без оптимизации). Метка обязана быть ПОЗИЦИОННОЙ.
**Остаточный эффект от объединения прямоугольников.** Метка одна на
страницу — объединение всех правок фона. В комнате 1 два факела дают
прямоугольник x 40…88, а стоящий там Кид занимает x 32…44: пересечение 4 px,
и он перерисовывается каждый кадр (140 % против 94 %, если отойти). Если
понадобится — держать не один прямоугольник, а 3-4 отдельных и мержить
только при переполнении.
### Шаг 2 сделан частично: ЛОГИКА (синяя полоса) 2026-08-09
Пользователь: «на стоящем Киде с двумя факелами на логику уходит 60 %
кадрового периода — недопустимо». Разобрано брейкпоинтами в MAME
(`z80_profiling_method`), сцена: уровень 1 комната 1, Кид СТОИТ вплотную к
левому факелу (то есть пропуск персонажа НЕ срабатывает — худший случай).
**Калибровка, которую надо знать заранее.** Один такт `totalcycles` в MAME
— НЕ один номинальный T-такт Z80: у Sprinter на обращениях к ОЗУ есть
wait-state'ы, и замеренная стоимость выходит **≈ 2,4× номинала**
(`get_tile`: 574 номинальных против 1 422 замеренных). Считать бюджет по
таблице T-тактов из справочника нельзя — только мерить. Кадр растра =
430 000; главный цикл спейсится тремя `gfx_wait_vsync`, поэтому работа
СВЫШЕ 430 000 стоит сразу целый лишний кадр.
**Что нашли и починили:**
| правка | что было | стало |
|---|---|---|
| окно перебора коллизии как в оригинале (было: все 14 колонок каждый кадр) | `check_collisions` 60 888 | 50 940 |
| `get_tile_div_mod` — таблицей (`tile_div_tbl`/`tile_mod_tbl`), было `/14` и `%14` | 5 400 тактов на вызов, 13 вызовов за кадр ≈ 70 000 = **16 % кадра** | ~250 на вызов |
| разрешение ряда вынесено из цикла колонок + грань шагом 14 + `wall_type` таблицей | `check_collisions` 58 026 | 42 750 |
| `move_coll_to_prev``memcpy` (LDIR) вместо цикла на C | 14 байт за 5 514 тактов (390 на байт!) | ~1 200 |
| окно режется на непрерывные пробеги (левый сосед / своя / правый), пролог ряда вынесен на кадр | `check_collisions` 44 022 | **38 334** |
Замер одной итерации перебора: **пустая колонка 750 тактов, колонка-стена
~1 700** (две 16-битные знаковые сверки граней — SDCC пишет их через
`jp PO / xor 0x80 / jp P`). Ловушка, на которую наступили: «быстрый путь для
окна внутри комнаты» не срабатывал ПОЧТИ НИКОГДА — Кид, стоящий в колонке 0,
даёт окно с −1, и шёл медленный сбор во временный буфер с тернарником на
колонку (1 340 тактов на колонку). Отсюда разбиение на пробеги: вопрос «чья
это колонка» решается раз на пробег, а `coll_scan` сам двигает `scan_left`.
Самое дорогое было НЕ там, где ожидалось: `/14` и `%14` SDCC разворачивает
в `__divsint` + `__modsint`, а `__modsint` внутри зовёт `__divsint` ещё раз —
два полноценных 16-битных деления на каждый вопрос «в какой колонке точка».
Оригинал делит таблицей (seg006:702) — мы просто не портировали это место.
**ГЛАВНОЕ: логический кадр уложился в бюджет.** Главный цикл спейсится
тремя `gfx_wait_vsync`, поэтому работа сверх 430 000 тактов стоит СРАЗУ
целый лишний растровый кадр. Было 470 964 (период цикла 4 кадра), стало
**409 956 + ~15 600 на ввод = 425 600** — период цикла **3 растровых кадра**.
Игра стала быстрее на треть (16,7 логических кадров/с против 12,5).
Что дало последние тысячи (по убыванию):
| правка | экономия |
|---|---|
| `pop_y_to_row` — цепочка сравнений вместо `/63 % 4` | ~12 000 |
| расширение окна fore-прохода арифметикой вместо перебора 10 колонок и 3 рядов | ~8 500 |
| `col_from_x` — таблицей (те же `POP_TILE_DIV`, вынесены в резидент) | ~11 000 |
| `pop_cd_touch` — развёрнутый цикл по страницам, `x+w`/`y+h` один раз | ~8 600 (зовётся с каждого блита фона) |
| `tp / 10`, `tp % 10` у факелов — таблицей | ~4 000 |
| пустой слот соперника считается «тихим» | ~8 200 |
**Ловушка SDCC, на которой я потерял один прогон:** в `pop_y_to_row` одно и
то же выражение `t / 63 % 4 - 1` стояло в двух ветках, и компилятор поднял
деление В ВЕРШИНУ функции — быстрые возвраты не спасали, `__divsint` звался
всё равно. Лечится выносом медленного хвоста в ОТДЕЛЬНУЮ функцию. Тот же
эффект уже был описан в `pop_loose_tick`; теперь ясно, что это правило, а не
частный случай: **любое деление, встречающееся дважды, SDCC поднимает выше
всех проверок.**
Приём, которым это ловится: брейкпоинт на `__divsint`/`__divuint`/
`__divuchar` с печатью адреса возврата (`printf "%04X", w@(sp)`) — сразу
видно, кто и сколько раз делит за кадр.
**Хвост подобран 2026-08-10.** После переписи `pop_y_to_row` в pop_room.c
осталось ТРИ места, считавших `(y+60)/63 % 4 - 1` вручную (927 в
`mob_tick_one`, 976/977 в `mob_render`) — сгенерированный asm подтвердил
пару `__divsint`+`__modsint` в каждом. Это ~16 200 тактов (3,8 % кадра), но
только пока кусок плиты в полёте — то есть ровно в самом тяжёлом кадре.
Заменены вызовом `pop_y_to_row`; в банке 7 теперь НОЛЬ `__divsint`.
Эквивалентность закреплена тестом `geom_y_to_row_matches_formula`
(перебор −400..400 против исходной формулы) — вызовы разбросаны по трём
банкам, и соблазн написать деление «по месту» возвращается.
### Полная инвентаризация делений 2026-08-10
Способ (повторяемый одной командой из `.sprinter-cc-roomtest/`):
```
awk '/^;[a-z_0-9]+\.[ch]:[0-9]+:/{s=$0} /^\tcall\t__(div|mod|mul)/{printf "%-22s %-12s %s\n",FILENAME,$2,s}' *.asm
```
Найден **31 вызов в 9 модулях**. Прибрано три места, остальное разобрано и
осознанно оставлено.
Убрано:
| место | что было | почему стоило |
|---|---|---|
| `pop_cdraw.c` `calc_screen_x_coord` (2 вызова) | `x * 8 / 7` = `__divsint`, **2 400 тактов НА ПЕРСОНАЖА КАЖДЫЙ КАДР** | последнее деление в горячем пути; ~4 800/кадр при живом сопернике |
| `pop_guard.c` `guard_col_from_x` | `/14` + `%14` **безусловно**, мимо `POP_TILE_DIV` | единственное 16-битное деление, у которого таблица вообще не была подключена |
| `pop_trob.c` `animate_chomper` | `tp / 10` = `__divuchar` на чомпера каждый кадр | таблицы `TP_ROW`/`TP_COL` уже лежали в ЭТОМ ЖЕ файле, но ниже по тексту — чомперы их не видели |
Приём для `×8/7`: **таблица** `SCRX7[1152]` (int8_t, хранит `x/7`) в банке 4,
индекс `x + 448`, результат `x + SCRX7[i]`. 1 152 байта, резидент не тронут.
Два решения по дороге, оба проверены, а не угаданы:
1. **Диапазон — весь, включая отрицательные.** Первая версия крыла 0..255 по
тождеству `8x/7 == x + x/7` с байтовой таблицей `x/7`. Ошибка: `obj_x =
2*fwd 116` уходит в минус, как только `fwd < 58` (левее `x_bump[5]`) —
то есть у ЛЕВОЙ КРОМКИ комнаты, а это не экзотика, и там мы продолжали
делить. Границы взяты из фактических данных: `kid_data.bin` даёт `dx`
кадров Кида −5..+10, стража −2..+10; при `Char.x` типа uint8_t и
`render_dx ∈ {140, 0, +140}` полный диапазон `obj_x` = **416..695**.
Таблица кроет −448..703, деление стало недостижимым (оставлено
страховкой на третьего персонажа / другой `render_dx`).
2. **Хранить `x/7` байтом, а не готовое `x*8/7` словом** — по тождеству
`8x/7 == x + x/7`. Первая версия хранила готовое, «раз всё равно
индексация двухбайтная». Собраны ОБА варианта, такты посчитаны по
сгенерированному asm:
| вариант | хвост после проверки границ | тактов | байт |
|---|---|---|---|
| `int16_t` готовое | `add hl,hl / add hl,de / ld e,(hl) / inc hl / ld d,(hl)` | **132** | 2 304 |
| `int8_t` `x/7` | `add hl,bc / ld a,(hl) / ld l,a / rlca / sbc a,a / ld h,a / add hl,de` | **131** | 1 152 |
Расширение знака и 16-битное сложение стоят ровно столько же, сколько
лишний `add hl,hl` при двухбайтном индексе, а обращений к памяти на одно
меньше — под wait-state'ами Sprinter (такт ≈ 2,4× номинала именно на
обращениях к ОЗУ) байтовый вариант ещё чуть выгоднее номинала. Итог:
байтовая таблица не хуже по скорости и на килобайт меньше. **Урок:
«двухбайтный индекс съест выигрыш» — гипотеза; она не подтвердилась.**
Кодоген проверен глазами (`bank4_pop_cdraw.asm`): `ld hl,#0x01C0; add hl,de`,
16-битное беззнаковое сравнение — индекс полный, старший байт не теряется
(грабли `sdcc_z80_const_ptr_index_bug` обойдены отдельной
`uint16_t`-переменной). 131 такт номинала против ~1 000 у `__divsint`.
Проверка таблицы: все 1 152 записи сверены питоном обратно из `.c`, а ПРАВИЛА
генерации (тождество + усечение к нулю) — тестом `geom_mul8div7_table_rules`
на целевом компиляторе: округляй SDCC к минус бесконечности, вся
отрицательная половина уехала бы на пиксель, и поймалось бы это только
глазами на левой кромке.
Оставлено сознательно (НЕ трогать, это не забытые места):
- **Хвосты за таблицей** — `guards.c:84`, `pop_bg.c:497`, `roomtest.c:163`,
`pop_map.c:412`: срабатывают только при x вне 0..255, то есть когда
персонаж в соседней комнате. Убирать их — это расширять `POP_TILE_DIV` до
140..395 (+280 Б резидента) ради редкого пути.
- **`pop_geom.c:21`** — намеренный медленный хвост `pop_y_to_row` (см. выше
про подъём деления SDCC).
- **`pop_geom.c:52`** — `v % n` в `pop_rnd_fit` для не-степени двойки:
оригинал зовёт `prandom(1)/(255)/(0xFF)`, все три идут веткой с маской,
сюда управление не приходит вовсе.
- **Холодные, раз на комнату/уровень/событие**: `pop_guard.c:266/268/269`
(вход стража), `pop_map.c:310` (пробуждение скелета), `pop_trob.c` дверь
уровня, `roomtest.c:473/663/887` (читы и старт), `pop_level.c:131/132`
(имя файла уровня), `roomtest.c:976/977` (отладочный HUD номера комнаты).
Каждое — единицы вызовов за секунды игры; таблицы под них только раздули
бы код.
Итог: **в горячем пути делений не осталось**. В банках 4, 6, 7 — ноль
`__div*`; всё, что видно в списке выше, либо за быстрым путём, либо холодное.
### Замер в MAME: A/B со сборкой `ec3cca5` (до правок)
Обе сборки прогнаны через полный цикл (`make hdd` → рестарт MAME → загрузка
уровня 1), сцена — **комната 1, Кид стоит, соперника нет**; скриншоты обеих
сборок идентичны. Фазы сняты брейкпоинтами на инструкциях `out
(_io_border), a` профилировочного бордюра, медиана по 60 логическим кадрам:
| фаза | до | после | Δ |
|---|---|---|---|
| ввод + heal | 62 361 | 62 364 | +3 |
| логика | 79 878 | 79 878 | 0 |
| фон | 119 502 | 119 502 | 0 |
| **спрайты** | **129 568** | **127 510** | **2 058** |
| РАБОТА за кадр | 391 258 | 389 221 | 2 037 |
Счётчик делений (брейкпоинты на `__divsint`/`__modsint`/`__divuchar`/
`__moduchar` с печатью адреса возврата):
| сцена | до | после |
|---|---|---|
| комната 1, только Кид | **1,00 `__divsint`/кадр** (возврат 0xD3AA = `pop_cdraw`) | **0** |
| комната 3, бой со стражем | **2,01 `__divsint`/кадр** | **0** (82 кадра боя) |
Всё сходится в одну картину: единственное деление горячего пути — `×8/7` на
персонажа, по одному вызову на каждого Char. Экономия ровно в фазе спрайтов
и ровно на стоимость одного вызова: **2 058 тактов** (документированная
оценка была ~2 400). С соперником на сцене — вдвое.
**Чего этот замер НЕ показывает, и это важно:**
- Период цикла как был 3 растровых кадра, так и остался — 2 000 тактов его не
двигают. Выигрыш ушёл в запас: до нижней границы 430 000 стало 40 800
тактов вместо 38 700.
- Остальные правки (три `y_to_row` в `pop_room`, `guard_col_from_x`,
`tp/10` у чомпера) в этой сцене **не срабатывают вовсе** — им нужны
падающая плита, переход стража между комнатами и уровень с чомперами.
Их стоимость известна поштучно, но в бою я их не ловил.
- Работа в комнате 3 между прогонами **несравнима** (391 204 против 318 145):
страж живой, фаза боя и срабатывание `pop_char_skip_mask` от прогона к
прогону разные. Оттуда взят только СЧЁТЧИК делений — он от таймингов не
зависит.
**Профиль работы за логический кадр СЕЙЧАС (409 956 тактов + ~15 600 ввод):**
| блок | тактов | % растрового кадра |
|---|---|---|
| see_kid + ctrl_tick + skip + heal + kid_tick | 52 536 | 12 |
| физика Кида + страж + боёвка | 73 452 | 17 |
| `pop_loose_tick` | 27 438 | 6,4 |
| **`pop_process_trobs`** (два факела) | **75 720** | **17,6** |
| `pop_redraw_needed` + шов + skip_mask | 10 932 | 2,5 |
| **`pop_char_draw` Кида** | **56 250** | **13** |
| **`pop_char_fore` Кида + борта** | **113 628** | **26** |
Синяя полоса (ввод + логика) была ~60 % → стала ~29 %.
**Оптимизация закрыта по решению пользователя 2026-08-10.** Всё, что
осталось неcделанным, вынесено с замерами в
[`../docs/perf_backlog.md`](../docs/perf_backlog.md) — там же протокол «как
мерить», чтобы не наступать заново на wait-state'ы и на устаревшие адреса
символов. Что доделано после таблицы выше: `pop_cd_touch` развёрнут,
tp/10 у факелов таблицей, пустой слот соперника считается тихим, ранний
выход в fore-проходе (нулевой эффект, оставлен как порт), быстрый путь без
клипа в `pop_blit_b` (факел 41 778 -> 31 218 тактов).
**Что осталось (запас на будущее, срочности больше нет):**
1. **`pop_char_fore` 113 628.** Внутри: `char_footprint` + расширение окна
~21 000, дальше шесть `fore_tile`, из которых два реально рисуют (по
~32 000). Дальше резать — кэш «в этом тайле переднего слоя нет вовсе».
2. **`pop_process_trobs` 75 720 на два факела** (~31 000 на факел). Внутри
одного факела: `gfx_blit_noclip` 8 700, чтение w/h и клип 6 400,
`pop_cd_touch` (теперь дешевле), маппинг окна 0 и возвраты. Пламя
перерисовывается каждый кадр обязательно (`TORCH_ANIM_DIV = 1`, кадр
меняется), так что пропуск тут не поможет — только удешевление блита.
3. `pop_loose_tick` 27 438 при полном отсутствии падающих плит.
4. Одно `__divsint` осталось в `pop_char_draw` (`obj_x * 8 / 7`) — ~2 400.
### Шаг 2 (СЛЕДУЮЩИЙ, назначен пользователем): ДВИЖУЩИЙСЯ Кид
Пропуск закрывает только покой. Наблюдение пользователя 2026-08-08: как
только Кид побежал, циан-полоса (спрайт + fore-проход) вырастает почти до
**100 % кадрового периода** — и это на ОДНОГО персонажа. То есть цена
одной перерисовки персонажа сама по себе непозволительно велика, и её надо
резать по существу, а не пропусками.
Куда смотреть (мерить каждое, метод — `z80_profiling_method`):
1. разделить замером спрайт-блит и fore-проход: у fore уже была история
78 % кадра до кэша кладки (`pop_fore_layer_cost`), он и сейчас главный
подозреваемый;
2. fore-проход перебирает 2×2…3 тайла и в каждом разбирает кладку заново —
кэш «в этом тайле переднего слоя нет вовсе» снял бы половину;
3. расширять перебор ТОЛЬКО по накладным спрайтам (клинок/брызги), а не по
всему окну клипа: габарит кадра уже покрыт футпринтом + правилом меча;
4. heal + блит спрайта: сейчас это два прохода по одной площади; посмотреть,
нельзя ли стирать только РАЗНОСТЬ прямоугольников при мелком сдвиге.
**Куда идти дальше в ПОКОЕ — там это ЛОГИКА, а не отрисовка.** Разбивка
работы кадра брейкпоинтами (комната 3, труп, Кид стоит; всего 500 772
такта):
| участок | тактов | % кадра |
|---|---:|---:|
| ввод + `check_skel` + луч видимости + `ctrl_tick` + heal | 65 862 | 15 % |
| `kid_tick` + физика + страж + боёвка | **260 022** | **60 %** |
| `loose_tick` + `process_trobs` | 119 562 | 28 % |
| `redraw_needed` + шов + спрайты + метка | 55 152 | 13 % |
| — из них два блита факелов | 44 136 | 10 % |
1. **60 % на тик персонажей** при том, что оба СТОЯТ — первый кандидат.
Смотреть `pop_phys_tick`/`pop_guard_phys_tick` (банк 3) и `pop_guard_tick`
(банк 1): сколько там работы, которую неподвижный персонаж делать не
обязан, и сколько стоит трамплин на каждом шаге.
2. **28 % на `loose_tick` + `process_trobs`** в комнате БЕЗ единой ловушки —
явно перебор; разобрать, что там сканируется каждый кадр (список trob,
`pop_trob_modif` соседа для шва, `pop_room_link`).
3. Запечь труп в фон (как щебень: тело в фоне + fore-часть поверх Кида) —
тогда бесплатным станет и проход Кида ПО телу, который сейчас снимает
пропуск по правилу «слоты рядом». Предложено пользователем 2026-08-08.
Мерить в ЭТОЙ точке (комната 3, страж убит, Кид стоит) — она воспроизводима
и даёт нижнюю границу; худший случай (бой + бег + падающая плита) считать
отдельно. Метод — memory `z80_profiling_method` (брейкпоинты с
`totalcycles`); полосы бордюра показывают только одну картинку из периода и
годятся лишь для раскладки по фазам.
**Наблюдение пользователя на приёмке DRAW-CHAR 2026-08-08:** циан-полоса
профиля (спрайты + fore) выросла — тогда «в пределах».
**Отчего именно.** Раньше перебор тайлов у Кида шёл строго по футпринту
кадра (`char_x_left/right`, seg006:1021), а он УЖЕ спрайта; расширение
перебора окном спрайта (клинок и брызги уходят за габарит кадра) было
только у соперника. DRAW-CHAR сделала его общим — то есть у Кида теперь на
колонку-другую больше `fore_tile` за кадр. Это не регрессия «лишней
работы», а недостающая ранее корректность: тайл, который спрайт задевает,
обязан вернуть свой передний слой поверх него.
**Если fore-проход снова станет узким местом** (сейчас он в покое не
выполняется вовсе): расширять перебор ТОЛЬКО по накладным спрайтам
(клинок/брызги), а не по всему окну; кэш «в этом тайле fore-слоя нет
вовсе»; считать окно клипа в тайловых координатах один раз.
## P1 — берётся в любой момент
### <a id="qsave"></a>QSAVE. QuickSave / QuickLoad
> **План целиком — [`../docs/quicksave_plan.md`](../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`/`trobs``static` в банковом `pop_trob.c` (нужны
сериализаторы); ГСЧ у нас **три** (`pop_t_seed`, `trob_seed`,
`pop_fight_seed`), и пропуск любого даст «загрузилось, но играется иначе»;
и главное — **дабл-буфер**: перерисовать после загрузки надо ОБЕ страницы,
иначе старая картинка мигнёт через кадр.
Носитель — **файл** (`QUICKSAVE.SAV`, ≈1,9 КБ), EMM-страница отвергнута:
она не переживает рестарт программы, а именно рестарт — тот случай, ради
которого QuickSave и нужен (сцену каскада на 13/23 воспроизводит только
`ESC` → запуск заново). Мгновенный EMM-слот остаётся необязательным QS6.
**Критерий приёмки:** сохранить, выйти по `ESC`, запустить roomtest заново,
загрузить — и оказаться там же.
### <a id="l1-speed"></a>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, не «на глаз».
### <a id="tune-1"></a>TUNE-1. Параметры движка — в конфиг, а не в код
**Что уже есть.** `pop_tune.h` — все настраиваемые числа собраны в одном
заголовке: чекпойнт уровня 3 (`POP_CHKP_*`), отладочное окно решётки
(`POP_DBG_GATE_HOLD`), включатель зацепа в прыжке (`POP_ENABLE_JUMP_GRAB`).
Правило уже действует: новое «особое событие» или тайминг заводится ТАМ, а
не константой по месту.
**Что нужно сделать.** Читать их из ФАЙЛА рядом с exe, чтобы менять без
пересборки — под отладку («растянуть решётку, чтобы успеть пройти») и под
моды. Формат: простой ini/`ключ=значение`, парсер на ~50 строк (числа,
комментарии `;`, неизвестные ключи игнорировать), файл необязателен —
нет файла, значит зашитые дефолты. Секции по смыслу: `[level]`,
`[debug]`, `[enhancements]`.
**Ориентир — SDLPoP.** У него это `custom_options_type` (types.h) +
`SDLPoP.ini` + меню Settings/Mods; наши имена намеренно совпадают с его
(`custom->имя`), чтобы сверка оставалась механической. Осмотр его меню и
опций — часть задачи: у него уже разложены по группам стартовые
HP/минуты, номера «особых» комнат и уровней (шадоу, скелет, зеркало,
чекпойнт), тайминги ворот и пик, скорости, а отдельной группой —
`fixes`/`enhancements` (включая `enable_jump_grab`, который мы уже
портировали). Брать всё подряд не надо: переносим по мере того, как
константа реально понадобилась в игре.
**Оговорка по памяти.** Парсер и таблица параметров — холодный код,
исполняется один раз при старте: кандидат в банк, а не в резидент W1.
### <a id="mem-next"></a>MEM. Следующий шаг разгрузки W1/W2
`pop_ctrl.c` уехал в банк 5 ([MEM-BANK5](TASKS_CLOSED.md#mem-bank5), куча
180 Б → 2298 Б; после снижения `--max-allocs` — 2751 Б). Следующий кандидат
по тому же критерию (**не размер, а частота вызова и отсутствие горячих
банк→банк переходов**) — **расщепление `pop_kid.c`**: холодная половина
(загрузка страниц спрайтов, `pop_kid_load`) в банк, движок кадров
(`load_frame`/`play_seq`, 2×/кадр) оставить в резиденте.
Брать по факту нехватки места, не заранее. Таблица резидентного кода по
модулям и разбор, почему `pop_level.c` в банк НЕЛЬЗЯ, — в
[`TASKS_CLOSED.md`](TASKS_CLOSED.md#mem-bank5).
---
## Отложено осознанно (не брать, пока не появится причина)
- **KBD-1, остаток** — «иногда при зажатом Shift стрелка всё-таки
пропускается» (ощущение пользователя, не замер: счётчики на 35 нажатиях
подряд потерь не показывают). Решение пользователя 2026-08-01: отложено до
финальной полировки. Где именно осталась дыра и что делать, если вернёмся,
— в [`TASKS_CLOSED.md`](TASKS_CLOSED.md#kbd-1) (там же весь протокол
замеров и список того, что делать НЕЛЬЗЯ).
- **Quickload (Shift+F9) и остальные читы SDLPoP** — оценка сделана
([DBG-CHEATS](TASKS_CLOSED.md#dbg-cheats)), код не написан. Самое ценное и
самое дорогое: сериализация `Char` + `room_modif` всех комнат + trob'ов +
стражей (`levels_plan.md` §4), зато даёт воспроизводимый регресс «вот кадр,
где баг» вместо ручной подгонки позы.
- **Звук** (CBL-эффекты, Фаза 5 `PORT_PLAN.md`) — геймплей не блокирует.
- **Таймер уровня / HUD времени / меню / сохранения** — Фаза 6.
- **[T-1](BUGS_OPEN.md#t-1)** (пики: перерисовка по причине) — отдаётся почти
бесплатно после [T-2](BUGS_OPEN.md#t-2), отдельно не окупается.
- **Отключение мыши на время игры** и **замена PRNG**
`../docs/ideas_backlog.md` (оба дают доли процента кадра).
- **OPT-1** (хирургический редрой шва) — решено НЕ делать, стоимость
транзиентная; разбор в [`BUGS_CLOSED.md`](BUGS_CLOSED.md).
---
## OPT-BLIT — цена отрисовки: чистка кодогена (СЛЕДУЮЩАЯ СЕССИЯ)
> **2026-08-17: контрольный замер снят, задача разложена по фазам.**
> Работа теперь ведётся в трёх документах, которые живут между сессиями —
> туда же писать результаты каждой правки:
>
> - [`../docs/perf_l13_room23.md`](../docs/perf_l13_room23.md) — сцена
> (каскад 6 плит, ур. 13 комната 23), рецепт воспроизведения, зонды, канал
> `clog`, сводка по кадрам, разбор габаритов спрайтов;
> - [`../docs/perf_green_phase.md`](../docs/perf_green_phase.md) — ЗЕЛЁНАЯ
> фаза: пик **805 000** при бюджете 400 000; позиции G1..G6;
> - [`../docs/perf_cyan_phase.md`](../docs/perf_cyan_phase.md) — ЦИАН фаза:
> пик **632 000** при бюджете 400 000; позиции C1..C7.
>
> Порядок работ: **G1** (окно клипа для запечки, −250..400 тыс.) → **G2**
> (расколоть `draw_tile` + лист для ряда −1) → **C1** (убрать избыточный
> `pop_cd_touch` в `mob_render`, 81 тыс.) → **G3/C3** (`blit_b_clip` на
> байтовые габариты) → **C4** (разобрать `pop_char_fore(KID)`: 1 722 → 150 978).
>
> Синяя секция (**142 830**) в бюджет укладывается — не трогаем.
> Ответ на вопрос про `uint8_t`: горячий путь можно переводить целиком,
> максимум по всем игровым атласам **56×63**; шире 255 только восемь
> полноэкранных подложек титров/сюжета (320×200 и т. п.), и они рисуются раз
> на экран — им отдельный банк / прямой libbgi.
Открыто 2026-08-13 по итогам разбора зелёного блока. Контекст и все замеры
— в memory `blit_cost_model` и `sdcc_z80_stack_locals_hot_loop`, протокол
разбора — в коммитах `f89b7dd` / `b27b313` / этом.
**Что уже сделано и чем это подтверждено.** Цена блита разложена регрессией
по 339 замерам: `такты = 8791 + 198,2*h + 5,96*(w*h)` (такты MAME = системный
клок ~21,5 МГц, НЕ такты Z80 — растровый кадр 430 000). Байт на пределе
железа (через акселератор дважды по 3 такта), строка ~198, а вот 8 791 на
ВЫЗОВ оказались нашим кодогеном: `pop_blit_b` держал `w`/`h` как `uint16_t`,
из-за чего вся функция уезжала в 14-байтовый стековый кадр и `w = img[0] |
(img[1] << 8)` разворачивалось в два десятка IX-относительных пересылок.
После перехода на байтовый габарит: noclip 13 679 -> 11 530 (-16%),
клипованный 18 853 -> 15 340 (-19%), `_CODE` -42 Б.
### а) Где ещё `uint16_t` можно сделать `uint8_t`
Габариты спрайтов и всё, что из них считается. Признак: значение заведомо
<= 255, но объявлено 16-битным «на всякий случай», и живёт в горячем пути.
Начинать с:
- `blit_b_clip` (`pop_tile.c`) — `dw`/`dh`/`sx`/`sy` объявлены `int`, хотя
ядра принимают `uint8_t`; это ВТОРОЙ по частоте путь (33% блитов).
- `pop_cd_touch` / `cd_cols_of` — четыре `int`-аргумента на вызов.
- `_gfx_blit_sprite_noclip` и обёртки libbgi: `stride` уже `uint16_t`, а
`w`/`h``uint8_t`; проверить, не расширяются ли они обратно у вызывающих.
- Кандидаты в `pop_room.c`/`pop_bg.c`: локальные `int x, dby, dmy` в
`draw_tile` — часть из них помещается в байт, но осторожно: координаты
бывают отрицательными.
**Как проверять:** после каждой правки смотреть пролог функции в
`.sprinter-cc-roomtest/*.asm` — исчез ли `ld iy,#-N / add iy,sp / ld sp,iy`
и сколько осталось `-N (ix)` в теле.
### б) Проход по asm за неоптимальным IX-доступом
Механическая проверка, даёт больше всего за единицу усилий:
```
grep -c "(ix)" .sprinter-cc-roomtest/*.asm # где сгущается
grep -n "ld iy, #-" .sprinter-cc-roomtest/*.asm # крупные стековые кадры
grep -n "pop.*\n.*pop.*\n.*push" ... # чтение спилла через стек
```
Признаки беды (все три встречались сегодня): пролог с `iy`-кадром больше
~6 байт; пары `pop bc / pop hl / push hl / push bc` в теле цикла (SDCC
читает спиленный указатель через стек вместо `ld l,-N(ix)`); повторный
пересчёт адреса `arr[i]` под каждое поле структуры.
Приоритет по частоте вызова: `pop_blit_b` (сделан) -> `blit_b_clip` ->
`pop_cd_touch` -> `draw_tile` -> `pop_redraw_needed`.
### Что НЕ делать (проверено сегодня, отрицательный результат)
- **Не откладывать запекание на другой кадр.** Запекание пишет ОЗУ-копию
фона, из которой восстанавливает `heal`; отложенное даёт призрак плиты на
месте дыры. Идея «бюджет одного запекания на кадр» снята.
- **Не ускорять передачу пикселей** — она на пределе железа (3+3 такта на
байт), см. модель выше.
- **Не искать проблему в W3-скобке** (`_bgi_begin`/`_bgi_end` — по пять
инструкций) и не списывать разброс на прерывания (внутри размерной
группы разброс 3 такта, код прямолинейный).
### Хвосты этой сессии
- Пакетная пометка `pop_cd_touch` (`pop_cd_batch_begin/end`, скобка в
`draw_tile`) — сделана, но выигрыш замером НЕ подтверждён: в захваченных
кадрах скобка не срабатывала (блиты шли из холодной отрисовки комнаты).
Переснять на кадрах ЗАПЕКАНИЯ. **2026-08-17: цена НЕпакетного вызова
замерена — 4 502 такта, 28 % всей цены блита; в `mob_render` он к тому же
избыточен (см. C1).**
- Снять временную оснастку: `pop_dbg_m9..m16`, `pop_dbg_b1..b6`,
`pop_dbg_wh`, `pop_dbg_kind`, `pop_dbg_rdmax*`, `mame/v306/run_bridge_log.sh`.
- Цель по кадру не достигнута: зелёный пик был 792 012 при цели 400 000.
После сегодняшних правок не перемерян — начать сессию с контрольного
замера, а не с новых правок.
**ГРАБЛИ (стоили сегодня часа):** проверять, что запущен РОВНО ОДИН MAME
(`pgrep -f mame.arm | wc -l`) — несколько экземпляров пишут в один
`error.log`, мост говорит с одним, замеры собираются с другого, и точки
«не срабатывают». И не обрезать `error.log`, пока MAME его держит: она
пишет по старому смещению, в начале остаётся дыра из нулей.