# 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_OPEN.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`) — задел под вид тени и под любые эффекты «поверх того, что уже нарисовано». Правило проекта в силе: механику сверять с `../SDLPoP/src/` ДО кодинга; диагноз платформы подтверждать артефактом (брейкпоинт/дамп/.asm), а не гипотезой (memory `defer_unexplained_quirks`). --- ## ТЕКУЩАЯ ЦЕЛЬ: уровень 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 — **спецсобытие «тень крадёт зелье»**. | # | Задача | Что | Блокирует | |---|--------|-----|-----------| | 1 | [L5-SHADOW](#l5-shadow) | **СЛЕДУЮЩАЯ**: тень уровня 5 (крадёт зелье в комнате 24) + движок автодвижений | прохождение ур. 5 | | 2 | [GUARD-PHYS](#guard-phys) | физика стража = физика Кида — ядро сделано, остаток: ветка ТЕНИ в `check_guard_fallout` и живая проверка кнопки под стражем | ур. 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 — делаем сейчас ### L5-SHADOW. Тень уровня 5: крадёт зелье — **СЛЕДУЮЩАЯ** Единственная новая механика уровня 5 (тайлов новых нет вовсе, см. цель выше). Тень появляется в комнате 24, дожидается, пока откроется дверь, идёт к зелью, **выпивает его** и уходит за левый край. Боя нет. **Как это в оригинале** (всё сверено по коду, `custom->*` — это дефолты 1.0): | что | где | суть | |---|---|---| | появление | `check_shadow`, seg002:0064 | при СМЕНЕ КОМНАТЫ: если `current_level == 5` и `drawn_room == 24`, и тайл (кол 3, ряд 0) всё ещё `tiles_10_potion` — породить тень | | порождение | `do_init_shad`, seg002:0000 | `memcpy(&Char, init_shad_5, 7)` + `seqtbl_offset_char(2 /*stand*/)`, `charid = charid_1_shadow`, `demo_time = 0`, `guard_skill = 3`, `guardhp_* = 4`, `saveshad()` | | данные | `init_shad_5` | `{0x0F, 0x37, 0x37, 0, 0xFF, 0, 0}` = frame 15, x 55, y 55, direction 0, curr_col −1, curr_row 0, action 0 | | поведение | `autocontrol_shadow_level5`, seg002:1157 | в комнате 24: пока `demo_time == 0` — ждать, пока дверь (кол 1, ряд 0) не откроется (`modif >= 80`), затем `demo_index = 0`; дальше каждый кадр `do_auto_moves(shad_drink_move)`; при `Char.x < 15` — `clear_char()` | | движения | `do_auto_moves`, seg002:1089 | крошечный интерпретатор: `demo_time++`, по таблице `{time, move}` выбирается запись, `move` = 0 nothing / 1 forward / 2 backward / 3 up / 4 down / 5 up+forward / 6 shift / 7 move_7; −1 = ничего, −2 = конец | | таблица | `shad_drink_move` (data.h:866) | `{0x00,0} {0x01,1} {0x0E,0} {0x12,6} {0x1D,7} {0x2D,2} {0x31,1} {0xFF,−2}` — 8 записей по 2 байта | **Что из этого у нас уже есть:** - **механизм «спецсобытие порождает персонажа в слоте соперника»** — `pop_check_skel` (`guards.c`, зовётся из `roomtest.c` в тике); тень уровня 5 садится на тот же шов, только условие другое; - **тень как `charid_1_shadow`** — заведена под уровень 4 ([L4-MIRROR](TASKS_CLOSED.md#l4-mirror)): своя ветка ИИ (`autocontrol_shadow` + `autocontrol_shadow_level4`), выбор таблицы кадров Кида (`pop_frame_tbl_is_guard`), отрисовка спрайтами Кида; - **питьё зелья** — `SEQ_78_DRINK` и `get_item` в `pop_ctrl.c` (Кид уже умеет); тень «нажимает» те же кнопки через автодвижения; - **зелья как trob** — фаза пузырька, тип в старших битах (`pop_trob.c`). **Что писать:** 1. `do_auto_moves` + таблица `shad_drink_move` + `demo_time`/`demo_index` — интерпретатор ~30 строк, кладётся рядом с `autocontrol_shadow` в `guards.c` (банк 1). «Движения» — это те же переменные управления, что заполняет `read_user_control` (`pop_ctrl.c`), так что `move_*` сводятся к присваиваниям. 2. `do_init_shad(init_shad_5, seq stand)` — общий порождатель тени; пригодится и на уровнях 6 и 12 (`init_shad_6`, `init_shad_12` — те же 7 байт). 3. Ветка `check_shadow` для уровня 5 — по образцу `pop_check_skel`, вызов из того же места тика. 4. `autocontrol_shadow_level5`. 5. **Ветка ТЕНИ в `check_guard_fallout`** (seg002:0241): тень падает, только если она в свободном полёте (`action == 4`), и тогда `loadshad(); clear_char(); saveshad()`. Сейчас в `pop_guard_fallout` (`pop_guard.c`) есть ветки стража и скелета, а тени нет — комментарий там обещает её «вместе с L3-SKEL», но она относится именно к тени. **Чем подтверждать:** smoke уровня 5 — дойти до комнаты 24, увидеть, как тень выходит после открытия двери, выпивает зелье (тайл зелья исчезает) и уходит влево. Сверять последовательность движений с живым SDLPoP на том же месте — таблица `shad_drink_move` короткая, расхождение будет видно сразу. **Оговорка по виду:** тень пока рисуется обычной копией спрайтов Кида, то есть выглядит вторым Кидом — это [отложенный](../docs/shadow_render.md) вопрос, к механике уровня 5 отношения не имеет. ### GUARD-PHYS. Страж живёт по тем же правилам, что Кид — **ЯДРО СДЕЛАНО 2026-08-07** > **Что уже работает** (решение пользователя: переносим физику на `Char`, > без предварительных замеров — иначе третий-четвёртый экземпляр той же > логики неизбежен). > > - **физика переведена на `Char`**: `pop_map.c` целиком работает с активным > персонажем, а кто в `Char` — решают окна `loadkid`/`savekid` и > `loadshad`/`saveshad`, как в оригинале (seg006:809). Два входа: > `pop_phys_tick` (порт хвоста play_kid_frame) и `pop_guard_phys_tick` > (порт play_guard_frame) — списки вызовов отличаются ровно тем, чем в > оригинале; > - **`take_hp` стал общим** (`pop_take_hp` в резиденте `pop_guard.c`): урон > идёт тому, кто в `Char`, по `charid`. Раньше у боёвки и у физики были > свои копии, причём у физики неверная — правила `hitp_curr` мимо дельты и > про не-Кидов не знала; > - **порт веток по charid в `land()`** (seg005:173): страж гибнет с двух > рядов, тень падает как с одного, у не-Кида приземление даёт боевую > стойку; `check_guard_bumped` (seg004:0522), `droppedout` + > `guard_follows_kid_down` (seg002:09F8), `check_guard_fallout` > (seg002:0241); > - **`pop_savekid_state` снова полное `Kid = Char`**, а > `pop_load_fram_det_col` пересчитывает колонку ЛЮБОМУ персонажу — обе > заплатки существовали только потому, что физика знала один `Kid`. > > **Проверено:** `tests-host` — трассы Кида не изменились (`[phys] ok: 1723`, > тот же эталон), новый набор `t_char` (32 проверки) покрывает ветки по > персонажам; в MAME проверено, что игра жива (респавн, бег, падение, > приземление). Цена: `_CODE` +80 Б, банк 3 (`pop_map`) 7973 → 8485 > (51.8 %), банк 1 (`guards`) 2042 → 2156. > > **Проверено пользователем 2026-08-07:** страж СПРЫГИВАЕТ ЗА КИДОМ на ряд > ниже — связка «ИИ + физика» работает вживую, не только в тестах. > > **`follow_guard` портирован и проверен в MAME (2026-08-07):** уровень 1, > бой в комнате 3, Кид отступает влево — страж приходит следом (`Guard.room` > 3 → 2, X перенесён через шов), ровно как в SDLPoP. Условия отбора > покрыты тестами `t_char` (7 сценариев: пороги 91/165, «не бой», мёртвый, > вверх/вниз, занятая соседняя комната). **Сцена вскрыла отдельный баг — > [BUG-SWORD-GHOST-1](BUGS_CLOSED.md#bug-sword-ghost-1): при переходе в бою Кид > прячет меч и дальше дерётся пустой рукой.** > > **Осталось (потому и запись открыта) — ревизия 2026-08-11 по коду:** > 1. ~~`check_chomped_guard`~~ — **сделан** вместе с > [L3-CHOMP](TASKS_CLOSED.md#l3-chomp) (`pop_map.c`); > 2. ветки `check_guard_fallout`: **скелет сделан** (возрождается в комнате 3, > `pop_guard_fallout` в `pop_guard.c`), **ветки ТЕНИ нет** — падает только > в свободном полёте, `loadshad/clear_char/saveshad`; идёт в > [L5-SHADOW](#l5-shadow) п. 5 (комментарий в коде обещает её «вместе с > L3-SKEL» — устарел, это про тень); > 3. страж, нажимающий напольную кнопку, вживую не проверялся (код — > общий `check_press`). **Почему это была ОДНА физика, а не «сделаем стражу свою».** В оригинале слот `Guard` — не «стражи», а все НЕ-Киды: тем же `play_guard_frame` ходят **страж** (charid 2), **скелет** (4, ур. 3), **тень** (1, ур. 4/5/6/12), **визирь-Джаффар** (ур. 13), **толстяк** (FAT, ур. 12) и **мышь** (0x18, ур. 8) — `tbl_guard_type` = {0,0,0,2,0,0,1,0,0,0,0,0,4,3,−1,−1}, а `autocontrol_opponent` (seg002:628) разводит их ТОЛЬКО по ИИ. То есть второй экземпляр логики пришлось бы делать не один раз, а пять. **Гард по X: страж не уходит САМ — но его МОГУТ ПЕРЕНЕСТИ.** Поправка к формулировке, которая была здесь раньше («страж не покидает комнату ни в каком виде») — она неверна, контрпример дал пользователь: в SDLPoP страж из комнаты 3 оказывается в комнате 2 вслед за отступающим Кидом. Разделять надо два разных механизма: - **своим ходом — не может.** Физика персонажа слота Guard обёрнута `Char.room == drawn_room` и `Char.x >= 44 && Char.x < 211` (seg000:1252), и никакого `check_leave` в его списке вызовов нет. Провалившегося ниже комнаты убирает `check_guard_fallout` (seg002:0241) — вниз он не уходит. - **следом за Кидом — переносит движок.** `exit_room` (seg002:03C7) вызывается ПОСЛЕ того, как комнату сменил Кид, и решает судьбу стража: ```c if (Guard.alive < 0 && Guard.sword == sword_2_drawn) { // жив и В БОЮ if (guards_tile[kid_room−1] >= 30 || // в новой комнате guards_seq_hi[kid_room−1] != 0) { // своего живого нет if (ушёл ВЛЕВО) { if (Guard.x >= 91) leave = 1; } // страж далеко — остаётся else if (ВПРАВО) { if (Guard.x < 165) leave = 1; } else if (ВВЕРХ) { if (Guard.curr_row >= 0) leave = 1; } // всегда → не идёт else { if (Guard.curr_row < 3) leave = 1; } // вниз → не идёт } else leave = 1; } else leave = 1; leave ? leave_guard() : follow_guard(); ``` `follow_guard` (seg002:039E) стирает `guards_tile` в ОБЕИХ комнатах (0xFF — «стража здесь нет», чтобы он не раздвоился) и гонит стража через `goto_other_room` в окне `loadshad`/`saveshad`. **Что это значит для нас.** У нас в `enter_room` (`roomtest.c:265`) стоит безусловный `pop_guard_leave()` — то есть всегда ветка `leave_guard`, и страж ВСЕГДА остаётся. Портировать надо сам `exit_room`-выбор: условия «жив + меч вынут + в целевой комнате нет своего стража + он у нужного края» и `follow_guard`. Пороги 91/165 — это «страж у того края, в который ушёл Кид»; вверх и вниз оригинал не пускает никогда. **Чем подтверждать:** уровень 1, комната 3 — начать бой, отступить влево в комнату 2: страж обязан прийти следом и продолжить бой (как в SDLPoP на скриншотах пользователя). Обратная проверка: если в соседней комнате СВОЙ живой страж — переход не происходит. ### L3-COLOR. Палитра КЛАДКИ уровня 3 (в оригинале он зелёный) **Наблюдение (пользователь, 2026-08-07, со сравнением карт VGA).** В оригинальной VGA-версии кладка уровня 3 ЗЕЛЁНАЯ, а уровней 1-2 — серо-синяя. В SDLPoP все подземелья одинаковые, поэтому по нему разницу не увидеть. **Почему в SDLPoP её нет — проверено, не гипотеза.** Механизм там ЕСТЬ (seg000:1140, «Level colors (1.3)»): ```c int level_color = custom->tbl_level_color[current_level]; if (level_color != 0) { byte* env_pal = level_var_palettes + 0x30*(level_color-1); byte* wall_pal = env_pal + 0x30 * custom->tbl_level_type[current_level]; set_pal_arr(0x50, 0x10, (rgb_type*)env_pal); /* chtab_6 environment */ set_pal_arr(0x60, 0x10, (rgb_type*)wall_pal); /* chtab_7 wall */ } ``` `tbl_level_color` (data.h:842) = `{0,0,0,1,0,0,0,1,2,2,0,0,3,3,4,0}` — у уровня 3 цвет **1**, у 7 тоже 1, у 8/9 — 2, у 12/13 — 3, у 14 — 4. Но `level_var_palettes` — это ресурс **20** из `PRINCE.DAT` (только версии 1.3/1.4), а в `SDLPoP/data/PRINCE/` его НЕТ: там лежит лишь `res10.bin` (палитры стражей). Значит `level_var_palettes == NULL` и вся ветка молча пропускается — отсюда одинаковые подземелья. **Данные у нас есть.** В `../MSDOS/PRINCE.DAT` ресурс 20 присутствует: offset 22790, **240 байт** = 5 палитр × 16 цветов × 3 байта (6-битные каналы, как res10). **Что делать (когда дойдём до вида уровня 3).** 1. Достать ресурс 20 из `MSDOS/PRINCE.DAT` (упаковщику придётся читать сам `.DAT` — сейчас все скрипты берут распакованные PNG из SDLPoP); 2. сгенерировать таблицу палитр рядом с `pop_guard_pal.h`; 3. при загрузке уровня заливать слоты **0x50..0x5F** (env) и **0x60..0x6F** (wall) — у нас ровно эти базы (`pop_pack_bg.load_indexed`: `pal_base = 0x60` для WALL, `0x50` для env), то есть совпадение со `set_pal_arr` один в один; 4. `wall_pal = env_pal + 0x30 * tbl_level_type[level]` — для подземелья (`level_type == 0`) обе палитры одинаковые. **Грабли, уже пойманные на цвете стражей:** `gfx_pal_load` отдаёт указатель в BIOS (`$A4` через `rst #0x08`), а BIOS читает только `#4000-#BFFF` — таблицу нельзя передавать прямо из банка (0xC000+), надо копировать в W1/W2 (см. [BUG-GUARD-COLOR-1](BUGS_CLOSED.md#bug-guard-color-1)). ### DRAW-COST. Кадр НЕ УКЛАДЫВАЕТСЯ в бюджет — нужна оптимизация > **ШАГ 1 СДЕЛАН 2026-08-08: пропуск неизменившегося персонажа.** Комната > 1.3, труп стража, Кид стоит: было **210 %** кадрового периода, стало > **116 %** (500 772 такта при бюджете 430 000). Отрисовка перестала быть > узким местом: персонажи в покое не рисуются ВООБЩЕ (ноль вызовов > `pop_heal_fast` за кадр), весь фон — ДВА блита факелов (44 136 тактов). > Механизм и почему метка позиционная — в шапке `pop_cdraw.h`. **Исходные замеры пользователя (полосы бордюра, до шага 1).** Уровень 1, Кид СТОИТ — то есть НЕ худший случай, ни боя, ни движения: | комната | синяя (ввод+heal+логика) | зелёная (фон) | циан (спрайты) | итого | |---|---|---|---|---| | 3, страж УБИТ | ~80 % | ~20 % | ~110 % | **~210 %** | | 2, стража НЕТ | ~60 % | ~20 % | ~60 % | ~140 % | Разница ровно в теле убитого стража: +20 % синей и +50 % циана. Труп сохраняет `charid != 0`, поэтому каждый кадр честно проходил весь путь живого персонажа (heal → спрайт+clip_char+брызги+клинок → fore-проход), хотя его кадр постоянен до выхода из комнаты. **Что сделано (шаг 1).** Не спецкейс «мёртвый», а общее правило: у каждой страницы дабл-буфера свой снимок ВХОДОВ отрисовки слота; совпал снимок, спрайт этой страницы цел и фон в его прямоугольнике не трогали — heal, блит и fore-проход пропускаются целиком. Покрывает и труп, и стоящего Кида, и ждущего стража. Детали контракта — `pop_cdraw.h`, реализация — `pop_char_skip_mask` / `cd_quiet` в `pop_cdraw.c`, метка фона — `pop_cd_touch` в резидентном `pop_tile.c`. Грабли, на которые наступили по дороге: сначала метка была ФЛАГОМ «фон трогали хоть где-то» — и выигрыш оказался ровно нулевым, потому что факелы анимируются каждый кадр и гасили пропуск для всех персонажей сразу (замер: 597 684 такта, как без оптимизации). Метка обязана быть ПОЗИЦИОННОЙ. **Остаточный эффект от объединения прямоугольников.** Метка одна на страницу — объединение всех правок фона. В комнате 1 два факела дают прямоугольник x 40…88, а стоящий там Кид занимает x 32…44: пересечение 4 px, и он перерисовывается каждый кадр (140 % против 94 %, если отойти). Если понадобится — держать не один прямоугольник, а 3-4 отдельных и мержить только при переполнении. ### Шаг 2 сделан частично: ЛОГИКА (синяя полоса) 2026-08-09 Пользователь: «на стоящем Киде с двумя факелами на логику уходит 60 % кадрового периода — недопустимо». Разобрано брейкпоинтами в MAME (`z80_profiling_method`), сцена: уровень 1 комната 1, Кид СТОИТ вплотную к левому факелу (то есть пропуск персонажа НЕ срабатывает — худший случай). **Калибровка, которую надо знать заранее.** Один такт `totalcycles` в MAME — НЕ один номинальный T-такт Z80: у Sprinter на обращениях к ОЗУ есть wait-state'ы, и замеренная стоимость выходит **≈ 2,4× номинала** (`get_tile`: 574 номинальных против 1 422 замеренных). Считать бюджет по таблице T-тактов из справочника нельзя — только мерить. Кадр растра = 430 000; главный цикл спейсится тремя `gfx_wait_vsync`, поэтому работа СВЫШЕ 430 000 стоит сразу целый лишний кадр. **Что нашли и починили:** | правка | что было | стало | |---|---|---| | окно перебора коллизии как в оригинале (было: все 14 колонок каждый кадр) | `check_collisions` 60 888 | 50 940 | | `get_tile_div_mod` — таблицей (`tile_div_tbl`/`tile_mod_tbl`), было `/14` и `%14` | 5 400 тактов на вызов, 13 вызовов за кадр ≈ 70 000 = **16 % кадра** | ~250 на вызов | | разрешение ряда вынесено из цикла колонок + грань шагом 14 + `wall_type` таблицей | `check_collisions` 58 026 | 42 750 | | `move_coll_to_prev` — `memcpy` (LDIR) вместо цикла на C | 14 байт за 5 514 тактов (390 на байт!) | ~1 200 | | окно режется на непрерывные пробеги (левый сосед / своя / правый), пролог ряда вынесен на кадр | `check_collisions` 44 022 | **38 334** | Замер одной итерации перебора: **пустая колонка 750 тактов, колонка-стена ~1 700** (две 16-битные знаковые сверки граней — SDCC пишет их через `jp PO / xor 0x80 / jp P`). Ловушка, на которую наступили: «быстрый путь для окна внутри комнаты» не срабатывал ПОЧТИ НИКОГДА — Кид, стоящий в колонке 0, даёт окно с −1, и шёл медленный сбор во временный буфер с тернарником на колонку (1 340 тактов на колонку). Отсюда разбиение на пробеги: вопрос «чья это колонка» решается раз на пробег, а `coll_scan` сам двигает `scan_left`. Самое дорогое было НЕ там, где ожидалось: `/14` и `%14` SDCC разворачивает в `__divsint` + `__modsint`, а `__modsint` внутри зовёт `__divsint` ещё раз — два полноценных 16-битных деления на каждый вопрос «в какой колонке точка». Оригинал делит таблицей (seg006:702) — мы просто не портировали это место. **ГЛАВНОЕ: логический кадр уложился в бюджет.** Главный цикл спейсится тремя `gfx_wait_vsync`, поэтому работа сверх 430 000 тактов стоит СРАЗУ целый лишний растровый кадр. Было 470 964 (период цикла 4 кадра), стало **409 956 + ~15 600 на ввод = 425 600** — период цикла **3 растровых кадра**. Игра стала быстрее на треть (16,7 логических кадров/с против 12,5). Что дало последние тысячи (по убыванию): | правка | экономия | |---|---| | `pop_y_to_row` — цепочка сравнений вместо `/63 % 4` | ~12 000 | | расширение окна fore-прохода арифметикой вместо перебора 10 колонок и 3 рядов | ~8 500 | | `col_from_x` — таблицей (те же `POP_TILE_DIV`, вынесены в резидент) | ~11 000 | | `pop_cd_touch` — развёрнутый цикл по страницам, `x+w`/`y+h` один раз | ~8 600 (зовётся с каждого блита фона) | | `tp / 10`, `tp % 10` у факелов — таблицей | ~4 000 | | пустой слот соперника считается «тихим» | ~8 200 | **Ловушка SDCC, на которой я потерял один прогон:** в `pop_y_to_row` одно и то же выражение `t / 63 % 4 - 1` стояло в двух ветках, и компилятор поднял деление В ВЕРШИНУ функции — быстрые возвраты не спасали, `__divsint` звался всё равно. Лечится выносом медленного хвоста в ОТДЕЛЬНУЮ функцию. Тот же эффект уже был описан в `pop_loose_tick`; теперь ясно, что это правило, а не частный случай: **любое деление, встречающееся дважды, SDCC поднимает выше всех проверок.** Приём, которым это ловится: брейкпоинт на `__divsint`/`__divuint`/ `__divuchar` с печатью адреса возврата (`printf "%04X", w@(sp)`) — сразу видно, кто и сколько раз делит за кадр. **Хвост подобран 2026-08-10.** После переписи `pop_y_to_row` в pop_room.c осталось ТРИ места, считавших `(y+60)/63 % 4 - 1` вручную (927 в `mob_tick_one`, 976/977 в `mob_render`) — сгенерированный asm подтвердил пару `__divsint`+`__modsint` в каждом. Это ~16 200 тактов (3,8 % кадра), но только пока кусок плиты в полёте — то есть ровно в самом тяжёлом кадре. Заменены вызовом `pop_y_to_row`; в банке 7 теперь НОЛЬ `__divsint`. Эквивалентность закреплена тестом `geom_y_to_row_matches_formula` (перебор −400..400 против исходной формулы) — вызовы разбросаны по трём банкам, и соблазн написать деление «по месту» возвращается. ### Полная инвентаризация делений 2026-08-10 Способ (повторяемый одной командой из `.sprinter-cc-roomtest/`): ``` awk '/^;[a-z_0-9]+\.[ch]:[0-9]+:/{s=$0} /^\tcall\t__(div|mod|mul)/{printf "%-22s %-12s %s\n",FILENAME,$2,s}' *.asm ``` Найден **31 вызов в 9 модулях**. Прибрано три места, остальное разобрано и осознанно оставлено. Убрано: | место | что было | почему стоило | |---|---|---| | `pop_cdraw.c` `calc_screen_x_coord` (2 вызова) | `x * 8 / 7` = `__divsint`, **2 400 тактов НА ПЕРСОНАЖА КАЖДЫЙ КАДР** | последнее деление в горячем пути; ~4 800/кадр при живом сопернике | | `pop_guard.c` `guard_col_from_x` | `/14` + `%14` **безусловно**, мимо `POP_TILE_DIV` | единственное 16-битное деление, у которого таблица вообще не была подключена | | `pop_trob.c` `animate_chomper` | `tp / 10` = `__divuchar` на чомпера каждый кадр | таблицы `TP_ROW`/`TP_COL` уже лежали в ЭТОМ ЖЕ файле, но ниже по тексту — чомперы их не видели | Приём для `×8/7`: **таблица** `SCRX7[1152]` (int8_t, хранит `x/7`) в банке 4, индекс `x + 448`, результат `x + SCRX7[i]`. 1 152 байта, резидент не тронут. Два решения по дороге, оба проверены, а не угаданы: 1. **Диапазон — весь, включая отрицательные.** Первая версия крыла 0..255 по тождеству `8x/7 == x + x/7` с байтовой таблицей `x/7`. Ошибка: `obj_x = 2*fwd − 116` уходит в минус, как только `fwd < 58` (левее `x_bump[5]`) — то есть у ЛЕВОЙ КРОМКИ комнаты, а это не экзотика, и там мы продолжали делить. Границы взяты из фактических данных: `kid_data.bin` даёт `dx` кадров Кида −5..+10, стража −2..+10; при `Char.x` типа uint8_t и `render_dx ∈ {−140, 0, +140}` полный диапазон `obj_x` = **−416..695**. Таблица кроет −448..703, деление стало недостижимым (оставлено страховкой на третьего персонажа / другой `render_dx`). 2. **Хранить `x/7` байтом, а не готовое `x*8/7` словом** — по тождеству `8x/7 == x + x/7`. Первая версия хранила готовое, «раз всё равно индексация двухбайтная». Собраны ОБА варианта, такты посчитаны по сгенерированному asm: | вариант | хвост после проверки границ | тактов | байт | |---|---|---|---| | `int16_t` готовое | `add hl,hl / add hl,de / ld e,(hl) / inc hl / ld d,(hl)` | **132** | 2 304 | | `int8_t` `x/7` | `add hl,bc / ld a,(hl) / ld l,a / rlca / sbc a,a / ld h,a / add hl,de` | **131** | 1 152 | Расширение знака и 16-битное сложение стоят ровно столько же, сколько лишний `add hl,hl` при двухбайтном индексе, а обращений к памяти на одно меньше — под wait-state'ами Sprinter (такт ≈ 2,4× номинала именно на обращениях к ОЗУ) байтовый вариант ещё чуть выгоднее номинала. Итог: байтовая таблица не хуже по скорости и на килобайт меньше. **Урок: «двухбайтный индекс съест выигрыш» — гипотеза; она не подтвердилась.** Кодоген проверен глазами (`bank4_pop_cdraw.asm`): `ld hl,#0x01C0; add hl,de`, 16-битное беззнаковое сравнение — индекс полный, старший байт не теряется (грабли `sdcc_z80_const_ptr_index_bug` обойдены отдельной `uint16_t`-переменной). 131 такт номинала против ~1 000 у `__divsint`. Проверка таблицы: все 1 152 записи сверены питоном обратно из `.c`, а ПРАВИЛА генерации (тождество + усечение к нулю) — тестом `geom_mul8div7_table_rules` на целевом компиляторе: округляй SDCC к минус бесконечности, вся отрицательная половина уехала бы на пиксель, и поймалось бы это только глазами на левой кромке. Оставлено сознательно (НЕ трогать, это не забытые места): - **Хвосты за таблицей** — `guards.c:84`, `pop_bg.c:497`, `roomtest.c:163`, `pop_map.c:412`: срабатывают только при x вне 0..255, то есть когда персонаж в соседней комнате. Убирать их — это расширять `POP_TILE_DIV` до −140..395 (+280 Б резидента) ради редкого пути. - **`pop_geom.c:21`** — намеренный медленный хвост `pop_y_to_row` (см. выше про подъём деления SDCC). - **`pop_geom.c:52`** — `v % n` в `pop_rnd_fit` для не-степени двойки: оригинал зовёт `prandom(1)/(255)/(0xFF)`, все три идут веткой с маской, сюда управление не приходит вовсе. - **Холодные, раз на комнату/уровень/событие**: `pop_guard.c:266/268/269` (вход стража), `pop_map.c:310` (пробуждение скелета), `pop_trob.c` дверь уровня, `roomtest.c:473/663/887` (читы и старт), `pop_level.c:131/132` (имя файла уровня), `roomtest.c:976/977` (отладочный HUD номера комнаты). Каждое — единицы вызовов за секунды игры; таблицы под них только раздули бы код. Итог: **в горячем пути делений не осталось**. В банках 4, 6, 7 — ноль `__div*`; всё, что видно в списке выше, либо за быстрым путём, либо холодное. ### Замер в MAME: A/B со сборкой `ec3cca5` (до правок) Обе сборки прогнаны через полный цикл (`make hdd` → рестарт MAME → загрузка уровня 1), сцена — **комната 1, Кид стоит, соперника нет**; скриншоты обеих сборок идентичны. Фазы сняты брейкпоинтами на инструкциях `out (_io_border), a` профилировочного бордюра, медиана по 60 логическим кадрам: | фаза | до | после | Δ | |---|---|---|---| | ввод + heal | 62 361 | 62 364 | +3 | | логика | 79 878 | 79 878 | 0 | | фон | 119 502 | 119 502 | 0 | | **спрайты** | **129 568** | **127 510** | **−2 058** | | РАБОТА за кадр | 391 258 | 389 221 | −2 037 | Счётчик делений (брейкпоинты на `__divsint`/`__modsint`/`__divuchar`/ `__moduchar` с печатью адреса возврата): | сцена | до | после | |---|---|---| | комната 1, только Кид | **1,00 `__divsint`/кадр** (возврат 0xD3AA = `pop_cdraw`) | **0** | | комната 3, бой со стражем | **2,01 `__divsint`/кадр** | **0** (82 кадра боя) | Всё сходится в одну картину: единственное деление горячего пути — `×8/7` на персонажа, по одному вызову на каждого Char. Экономия ровно в фазе спрайтов и ровно на стоимость одного вызова: **2 058 тактов** (документированная оценка была ~2 400). С соперником на сцене — вдвое. **Чего этот замер НЕ показывает, и это важно:** - Период цикла как был 3 растровых кадра, так и остался — 2 000 тактов его не двигают. Выигрыш ушёл в запас: до нижней границы 430 000 стало 40 800 тактов вместо 38 700. - Остальные правки (три `y_to_row` в `pop_room`, `guard_col_from_x`, `tp/10` у чомпера) в этой сцене **не срабатывают вовсе** — им нужны падающая плита, переход стража между комнатами и уровень с чомперами. Их стоимость известна поштучно, но в бою я их не ловил. - Работа в комнате 3 между прогонами **несравнима** (391 204 против 318 145): страж живой, фаза боя и срабатывание `pop_char_skip_mask` от прогона к прогону разные. Оттуда взят только СЧЁТЧИК делений — он от таймингов не зависит. **Профиль работы за логический кадр СЕЙЧАС (409 956 тактов + ~15 600 ввод):** | блок | тактов | % растрового кадра | |---|---|---| | see_kid + ctrl_tick + skip + heal + kid_tick | 52 536 | 12 | | физика Кида + страж + боёвка | 73 452 | 17 | | `pop_loose_tick` | 27 438 | 6,4 | | **`pop_process_trobs`** (два факела) | **75 720** | **17,6** | | `pop_redraw_needed` + шов + skip_mask | 10 932 | 2,5 | | **`pop_char_draw` Кида** | **56 250** | **13** | | **`pop_char_fore` Кида + борта** | **113 628** | **26** | Синяя полоса (ввод + логика) была ~60 % → стала ~29 %. **Оптимизация закрыта по решению пользователя 2026-08-10.** Всё, что осталось неcделанным, вынесено с замерами в [`../docs/perf_backlog.md`](../docs/perf_backlog.md) — там же протокол «как мерить», чтобы не наступать заново на wait-state'ы и на устаревшие адреса символов. Что доделано после таблицы выше: `pop_cd_touch` развёрнут, tp/10 у факелов таблицей, пустой слот соперника считается тихим, ранний выход в fore-проходе (нулевой эффект, оставлен как порт), быстрый путь без клипа в `pop_blit_b` (факел 41 778 -> 31 218 тактов). **Что осталось (запас на будущее, срочности больше нет):** 1. **`pop_char_fore` 113 628.** Внутри: `char_footprint` + расширение окна ~21 000, дальше шесть `fore_tile`, из которых два реально рисуют (по ~32 000). Дальше резать — кэш «в этом тайле переднего слоя нет вовсе». 2. **`pop_process_trobs` 75 720 на два факела** (~31 000 на факел). Внутри одного факела: `gfx_blit_noclip` 8 700, чтение w/h и клип 6 400, `pop_cd_touch` (теперь дешевле), маппинг окна 0 и возвраты. Пламя перерисовывается каждый кадр обязательно (`TORCH_ANIM_DIV = 1`, кадр меняется), так что пропуск тут не поможет — только удешевление блита. 3. `pop_loose_tick` 27 438 при полном отсутствии падающих плит. 4. Одно `__divsint` осталось в `pop_char_draw` (`obj_x * 8 / 7`) — ~2 400. ### Шаг 2 (СЛЕДУЮЩИЙ, назначен пользователем): ДВИЖУЩИЙСЯ Кид Пропуск закрывает только покой. Наблюдение пользователя 2026-08-08: как только Кид побежал, циан-полоса (спрайт + fore-проход) вырастает почти до **100 % кадрового периода** — и это на ОДНОГО персонажа. То есть цена одной перерисовки персонажа сама по себе непозволительно велика, и её надо резать по существу, а не пропусками. Куда смотреть (мерить каждое, метод — `z80_profiling_method`): 1. разделить замером спрайт-блит и fore-проход: у fore уже была история 78 % кадра до кэша кладки (`pop_fore_layer_cost`), он и сейчас главный подозреваемый; 2. fore-проход перебирает 2×2…3 тайла и в каждом разбирает кладку заново — кэш «в этом тайле переднего слоя нет вовсе» снял бы половину; 3. расширять перебор ТОЛЬКО по накладным спрайтам (клинок/брызги), а не по всему окну клипа: габарит кадра уже покрыт футпринтом + правилом меча; 4. heal + блит спрайта: сейчас это два прохода по одной площади; посмотреть, нельзя ли стирать только РАЗНОСТЬ прямоугольников при мелком сдвиге. **Куда идти дальше в ПОКОЕ — там это ЛОГИКА, а не отрисовка.** Разбивка работы кадра брейкпоинтами (комната 3, труп, Кид стоит; всего 500 772 такта): | участок | тактов | % кадра | |---|---:|---:| | ввод + `check_skel` + луч видимости + `ctrl_tick` + heal | 65 862 | 15 % | | `kid_tick` + физика + страж + боёвка | **260 022** | **60 %** | | `loose_tick` + `process_trobs` | 119 562 | 28 % | | `redraw_needed` + шов + спрайты + метка | 55 152 | 13 % | | — из них два блита факелов | 44 136 | 10 % | 1. **60 % на тик персонажей** при том, что оба СТОЯТ — первый кандидат. Смотреть `pop_phys_tick`/`pop_guard_phys_tick` (банк 3) и `pop_guard_tick` (банк 1): сколько там работы, которую неподвижный персонаж делать не обязан, и сколько стоит трамплин на каждом шаге. 2. **28 % на `loose_tick` + `process_trobs`** в комнате БЕЗ единой ловушки — явно перебор; разобрать, что там сканируется каждый кадр (список trob, `pop_trob_modif` соседа для шва, `pop_room_link`). 3. Запечь труп в фон (как щебень: тело в фоне + fore-часть поверх Кида) — тогда бесплатным станет и проход Кида ПО телу, который сейчас снимает пропуск по правилу «слоты рядом». Предложено пользователем 2026-08-08. Мерить в ЭТОЙ точке (комната 3, страж убит, Кид стоит) — она воспроизводима и даёт нижнюю границу; худший случай (бой + бег + падающая плита) считать отдельно. Метод — memory `z80_profiling_method` (брейкпоинты с `totalcycles`); полосы бордюра показывают только одну картинку из периода и годятся лишь для раскладки по фазам. **Наблюдение пользователя на приёмке DRAW-CHAR 2026-08-08:** циан-полоса профиля (спрайты + fore) выросла — тогда «в пределах». **Отчего именно.** Раньше перебор тайлов у Кида шёл строго по футпринту кадра (`char_x_left/right`, seg006:1021), а он УЖЕ спрайта; расширение перебора окном спрайта (клинок и брызги уходят за габарит кадра) было только у соперника. DRAW-CHAR сделала его общим — то есть у Кида теперь на колонку-другую больше `fore_tile` за кадр. Это не регрессия «лишней работы», а недостающая ранее корректность: тайл, который спрайт задевает, обязан вернуть свой передний слой поверх него. **Если fore-проход снова станет узким местом** (сейчас он в покое не выполняется вовсе): расширять перебор ТОЛЬКО по накладным спрайтам (клинок/брызги), а не по всему окну; кэш «в этом тайле fore-слоя нет вовсе»; считать окно клипа в тайловых координатах один раз. ## P1 — берётся в любой момент ### L1-SPEED. Игра идёт быстрее оригинала (найдено 2026-08-01) Сверка таймингов: оригинал — `BASE_FPS = 60` при `base_speed = 5` тиков на логический кадр (`SDLPoP/src/types.h:1373`, `data.h:869`) = **83.3 мс**, в бою `fight_speed = 6` = **100 мс**. У нас `roomtest.c` ждёт **три** `gfx_wait_vsync()` = 60 мс, и отдельной скорости боя нет — то есть примерно **+39 % к скорости эталона**. Соответствие: 4 ожидания (80 мс) обычно, 5 (100 мс) в бою. Условие «делать ПОСЛЕ CLIP-1» снято — CLIP-1 закрыт. Проверка — секундомером по одинаковому отрезку рядом с живым SDLPoP, не «на глаз». ### TUNE-1. Параметры движка — в конфиг, а не в код **Что уже есть.** `pop_tune.h` — все настраиваемые числа собраны в одном заголовке: чекпойнт уровня 3 (`POP_CHKP_*`), отладочное окно решётки (`POP_DBG_GATE_HOLD`), включатель зацепа в прыжке (`POP_ENABLE_JUMP_GRAB`). Правило уже действует: новое «особое событие» или тайминг заводится ТАМ, а не константой по месту. **Что нужно сделать.** Читать их из ФАЙЛА рядом с exe, чтобы менять без пересборки — под отладку («растянуть решётку, чтобы успеть пройти») и под моды. Формат: простой ini/`ключ=значение`, парсер на ~50 строк (числа, комментарии `;`, неизвестные ключи игнорировать), файл необязателен — нет файла, значит зашитые дефолты. Секции по смыслу: `[level]`, `[debug]`, `[enhancements]`. **Ориентир — SDLPoP.** У него это `custom_options_type` (types.h) + `SDLPoP.ini` + меню Settings/Mods; наши имена намеренно совпадают с его (`custom->имя`), чтобы сверка оставалась механической. Осмотр его меню и опций — часть задачи: у него уже разложены по группам стартовые HP/минуты, номера «особых» комнат и уровней (шадоу, скелет, зеркало, чекпойнт), тайминги ворот и пик, скорости, а отдельной группой — `fixes`/`enhancements` (включая `enable_jump_grab`, который мы уже портировали). Брать всё подряд не надо: переносим по мере того, как константа реально понадобилась в игре. **Оговорка по памяти.** Парсер и таблица параметров — холодный код, исполняется один раз при старте: кандидат в банк, а не в резидент W1. ### MEM. Следующий шаг разгрузки W1/W2 `pop_ctrl.c` уехал в банк 5 ([MEM-BANK5](TASKS_CLOSED.md#mem-bank5), куча 180 Б → 2298 Б; после снижения `--max-allocs` — 2751 Б). Следующий кандидат по тому же критерию (**не размер, а частота вызова и отсутствие горячих банк→банк переходов**) — **расщепление `pop_kid.c`**: холодная половина (загрузка страниц спрайтов, `pop_kid_load`) в банк, движок кадров (`load_frame`/`play_seq`, 2×/кадр) оставить в резиденте. Брать по факту нехватки места, не заранее. Таблица резидентного кода по модулям и разбор, почему `pop_level.c` в банк НЕЛЬЗЯ, — в [`TASKS_CLOSED.md`](TASKS_CLOSED.md#mem-bank5). --- ## Отложено осознанно (не брать, пока не появится причина) - **KBD-1, остаток** — «иногда при зажатом Shift стрелка всё-таки пропускается» (ощущение пользователя, не замер: счётчики на 35 нажатиях подряд потерь не показывают). Решение пользователя 2026-08-01: отложено до финальной полировки. Где именно осталась дыра и что делать, если вернёмся, — в [`TASKS_CLOSED.md`](TASKS_CLOSED.md#kbd-1) (там же весь протокол замеров и список того, что делать НЕЛЬЗЯ). - **Quickload (Shift+F9) и остальные читы SDLPoP** — оценка сделана ([DBG-CHEATS](TASKS_CLOSED.md#dbg-cheats)), код не написан. Самое ценное и самое дорогое: сериализация `Char` + `room_modif` всех комнат + trob'ов + стражей (`levels_plan.md` §4), зато даёт воспроизводимый регресс «вот кадр, где баг» вместо ручной подгонки позы. - **Звук** (CBL-эффекты, Фаза 5 `PORT_PLAN.md`) — геймплей не блокирует. - **Таймер уровня / HUD времени / меню / сохранения** — Фаза 6. - **[T-1](BUGS_OPEN.md#t-1)** (пики: перерисовка по причине) — отдаётся почти бесплатно после [T-2](BUGS_OPEN.md#t-2), отдельно не окупается. - **Отключение мыши на время игры** и **замена PRNG** — `../docs/ideas_backlog.md` (оба дают доли процента кадра). - **OPT-1** (хирургический редрой шва) — решено НЕ делать, стоимость транзиентная; разбор в [`BUGS_CLOSED.md`](BUGS_CLOSED.md).