Уровень 7, комната 14: спуск с ряда 0 на ряд 2 через зацеп (повис на кромке кнопки (0,2), отпустил, взялся за (2,2)) не работал ни при какой фазе и задержке — Кид пролетал мимо и разгонялся до fall_y = 33, после чего окно зацепа закрыто уже по скорости. Корень: do_fall не давал curr_row выйти за 2, а check_grab целится в тайл ряда curr_row-1 — то есть ряд 2 своей комнаты становится целью только при curr_row == 3. В оригинале (seg005:0030) inc_curr_row безусловный, а get_tile для ряда 3 уходит по links.down (find_room_of_tile, seg006:005D). - pop_map.c do_fall: inc_curr_row теперь 2 -> 3; ветка «достиг y_land» гейтится по curr_row <= 2 (при ряде 3 ни in_wall, ни land звать нельзя — тайлов своей комнаты там нет, а до y_land[4] дело не доходит: комнату меняет check_leave_below на y >= 211). - pop_map.c check_action: восстановлена ветка ACT_MIDAIR (кадры 102..105, seg006:0619) — первые четыре кадра падения, где fall_y ещё не разогнан, зацеп не работал вовсе. Отсюда же «иногда цепляется, иногда нет». - tests-host/t_grab.c: регресс grab_below_room_edge_window_exists — сцена комнаты 14 + переход в 15 по pop_fell_out. До фикса ни одного зацепа, после — окно из 8 фаз X при задержках 0..5 кадров; проверяется и играбельность (зацеп при «отпустил и сразу зажал Shift»). - roomtest.c + pop_guard.h: чит-навигация ставит Кида на верхний ряд в комнате 14 уровня 7 (GRAB_DOWN_LEVEL/ROOM) — снизу этот спуск не проверить. Общее правило (снизу вверх) не тронуто: в верхнем ряду пола чаще нет и Кид проваливается сразу после телепорта. Проверено: tests-host 5/5 (55 в [grab]), size-check OK, живьём в MAME — Кид повис на кромке (2,2): frame=91 y=55 row=0 room=15. Доски: GRAB-BELOW-ROOM в BUGS_CLOSED, GRAB-KBD-TIMING в BUGS_OPEN (отложен пользователем до готовности всех уровней), план L7-FEATHER в TASKS_OPEN. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
49 KiB
roomtest — доска ОТКРЫТЫХ задач (обновлено 2026-08-11)
Что берём в работу сейчас и в каком порядке. Каждая запись: что сделать, почему именно сейчас, чем подтверждать результат.
- закрытые задачи с протоколами и замерами —
TASKS_CLOSED.md; - открытые баги —
BUGS_OPEN.md, закрытые с разбором корней —BUGS_CLOSED.md; - планы фаз —
../docs/PORT_PLAN.md,../docs/layout_plan_v2.md,../docs/levels_plan.md.
Состояние на 2026-08-11 (сверено с кодом, не только с доской):
- уровни 1-4 приняты smoke-тестами (пользователь). Полные обходы всех
комнат делаются по готовности ВСЕХ уровней — политика приёмок в
TASKS_CLOSED.md; отдельных задачL3-PASS/L4-PASSбольше нет. Уже сделанные полные обходы уровней 1 и 2 остаются регресс-базой; - L4-MIRROR закрыта: зеркало, отражение,
прыжок сквозь него и рождение тени проверены в MAME. Хвосты — вид тени
(отложен,
../docs/shadow_render.md) и MIRROR-FG-STALE (закрыт как не баг); - L3-CHOMP и L3-SKEL закрыты (2026-08-08 / 2026-08-07);
- тайлсет palace сделан —
pop_bg_load(type),pal_*.atl, дворцовая кладкаwall_pattern, решётчатые тайлы 25-29 и вtile_table, и в коллизии (tile_is_floorсовпадает с seg006:0628). То есть шаг 2levels_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 — спецсобытие «тень крадёт зелье».
| # | Задача | Что | Блокирует |
|---|---|---|---|
| — | DRAW-COST | кадр уложился в бюджет 2026-08-09: 470 964 -> 425 600 тактов, период цикла 4 растровых кадра -> 3. Дальнейшее — запас, не срочность | плавность на ВСЕХ уровнях |
| — | L1-SPEED | игра на ~39 % быстрее оригинала | ощущение от ВСЕХ уровней; берётся в любой момент |
| — | TUNE-1 | параметры движка → cfg-файл (сейчас pop_tune.h) |
отладка таймингов и моды; берётся по мере надобности |
| — | MEM | следующий шаг разгрузки W1/W2 | берётся по факту нехватки места |
Сделанное — в TASKS_CLOSED.md: L4-MIRROR, L3-CHOMP,
L3-SKEL, L3-CHKP, L2 (машинерия уровней), L2-PASS, L1-PASS, DRAW-CHAR,
MEM-BANK2, MEM-BANK5, CLIP-1, KBD-1, DBG-CHEATS.
Ждёт ФИНАЛЬНОЙ приёмки (полные обходы по готовности всех уровней)
Сюда попадает то, что уже работает в проверочном прогоне, но должно быть подтверждено на сквозных прогонах уровней — потому что задевает механику шире, чем собственный сценарий.
- Зацеп ПРЯМО В ПРЫЖКЕ (
POP_ENABLE_JUMP_GRAB,pop_tune.h, сделан 2026-08-06, предварительно проверен пользователем). Почему нужен именно финальный прогон: точки вызова стоят не только вcheck_action, но и в ОБЕИХ веткахcheck_bumped— то есть код вклинивается перед обычным ударом о стену. Регрессия проявится не в самом зацепе, а рядом: удар о стену с зажатым Shift, осторожный шаг у стены, отскок в прыжке. На уровнях 1–3 это надо специально потрогать в паре мест каждого уровня. Напоминание: в ВАНИЛИ этого зацепа нет (у SDLPoP —enable_jump_grab), так что сверять его с оригиналом «как есть» нельзя — только с SDLPoP при включённых enhancements. Прогон 2026-08-07 (уровни 1 и 2) регрессий рядом не показал, но специально на удар о стену с Shift не проверялся.
P0 — делаем сейчас
L7-FEATHER. Зелье МЕДЛЕННОГО ПАДЕНИЯ (уровень 7, комната 1)
Задача (пользователь, 2026-08-12): составить план — здесь он и есть; код следующим заходом.
Зелье на (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) |
Шаги (в порядке выполнения, каждый проверяем отдельно):
- Состояние.
pop_feather(счётчик кадров) вpop_map.cрядом сfall_accel; экспорт вpop_map.hдляplay_seq. Длительность — по таймеру (ванильная привязка к звуку нам не подходит: звука нет), значение пересчитать из 18,75 с в наши кадры и завести вpop_tune.h. Сброс — вpop_start_levelи по смерти Кида. - Физика.
fall_accel(): приpop_feather—FALL_ACCEL_FEATHER 1/FALL_MAX_FEATHER 4. Только для Кида (charid == CHARID_0_KID) — так правильнее по смыслу, но это ОСОЗНАННОЕ расхождение с ванилью (там эффект ловят все, и SDLPoP чинит это опцией) → запись в../docs/impl_diff.md. - Анимация. Опкод
0xF7вplay_seq(pop_kid.c) сейчас БЕЗУСЛОВНО пропускает адрес; сделать как в оригинале: приpop_feather— прыжок по адресу (это и даётstepfloat/bumpfloat, то есть отсутствие урона и «парение»). Правка на 3 строки, но именно она даёт весь визуальный эффект падения. - Вспышка. Сейчас цвет вспышки — булев
pop_flash_red(жёлтая/красная); расширить до кода цвета и добавить ЗЕЛЁНУЮ (flash_time = 3). - Зелёные пузырьки.
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 маленьких спрайтов. - Проверка. Хост-тест: падение с трёх рядов под пером — скорость не выше
4, урона нет, Кид жив (сцена в
t_phys/t_char). MAME: комната 1 уровня 7 — выпить, спрыгнуть в шахту, убедиться в плавном спуске и в том, что эффект кончается по таймеру.
L3-COLOR. Палитра КЛАДКИ уровня 3 (в оригинале он зелёный)
Наблюдение (пользователь, 2026-08-07, со сравнением карт VGA). В оригинальной VGA-версии кладка уровня 3 ЗЕЛЁНАЯ, а уровней 1-2 — серо-синяя. В SDLPoP все подземелья одинаковые, поэтому по нему разницу не увидеть.
Почему в SDLPoP её нет — проверено, не гипотеза. Механизм там ЕСТЬ (seg000:1140, «Level colors (1.3)»):
int level_color = custom->tbl_level_color[current_level];
if (level_color != 0) {
byte* env_pal = level_var_palettes + 0x30*(level_color-1);
byte* wall_pal = env_pal + 0x30 * custom->tbl_level_type[current_level];
set_pal_arr(0x50, 0x10, (rgb_type*)env_pal); /* chtab_6 environment */
set_pal_arr(0x60, 0x10, (rgb_type*)wall_pal); /* chtab_7 wall */
}
tbl_level_color (data.h:842) = {0,0,0,1,0,0,0,1,2,2,0,0,3,3,4,0} — у
уровня 3 цвет 1, у 7 тоже 1, у 8/9 — 2, у 12/13 — 3, у 14 — 4. Но
level_var_palettes — это ресурс 20 из PRINCE.DAT (только версии
1.3/1.4), а в SDLPoP/data/PRINCE/ его НЕТ: там лежит лишь res10.bin
(палитры стражей). Значит level_var_palettes == NULL и вся ветка молча
пропускается — отсюда одинаковые подземелья.
Данные у нас есть. В ../MSDOS/PRINCE.DAT ресурс 20 присутствует:
offset 22790, 240 байт = 5 палитр × 16 цветов × 3 байта (6-битные
каналы, как res10).
Что делать (когда дойдём до вида уровня 3).
- Достать ресурс 20 из
MSDOS/PRINCE.DAT(упаковщику придётся читать сам.DAT— сейчас все скрипты берут распакованные PNG из SDLPoP); - сгенерировать таблицу палитр рядом с
pop_guard_pal.h; - при загрузке уровня заливать слоты 0x50..0x5F (env) и 0x60..0x6F
(wall) — у нас ровно эти базы (
pop_pack_bg.load_indexed:pal_base = 0x60для WALL,0x50для env), то есть совпадение соset_pal_arrодин в один; wall_pal = env_pal + 0x30 * tbl_level_type[level]— для подземелья (level_type == 0) обе палитры одинаковые.
Грабли, уже пойманные на цвете стражей: gfx_pal_load отдаёт указатель
в BIOS ($A4 через rst #0x08), а BIOS читает только #4000-#BFFF —
таблицу нельзя передавать прямо из банка (0xC000+), надо копировать в
W1/W2 (см. BUG-GUARD-COLOR-1).
DRAW-COST. Кадр НЕ УКЛАДЫВАЕТСЯ в бюджет — нужна оптимизация
ШАГ 1 СДЕЛАН 2026-08-08: пропуск неизменившегося персонажа. Комната 1.3, труп стража, Кид стоит: было 210 % кадрового периода, стало 116 % (500 772 такта при бюджете 430 000). Отрисовка перестала быть узким местом: персонажи в покое не рисуются ВООБЩЕ (ноль вызовов
pop_heal_fastза кадр), весь фон — ДВА блита факелов (44 136 тактов). Механизм и почему метка позиционная — в шапкеpop_cdraw.h.
Исходные замеры пользователя (полосы бордюра, до шага 1). Уровень 1, Кид СТОИТ — то есть НЕ худший случай, ни боя, ни движения:
| комната | синяя (ввод+heal+логика) | зелёная (фон) | циан (спрайты) | итого |
|---|---|---|---|---|
| 3, страж УБИТ | ~80 % | ~20 % | ~110 % | ~210 % |
| 2, стража НЕТ | ~60 % | ~20 % | ~60 % | ~140 % |
Разница ровно в теле убитого стража: +20 % синей и +50 % циана. Труп
сохраняет charid != 0, поэтому каждый кадр честно проходил весь путь
живого персонажа (heal → спрайт+clip_char+брызги+клинок → fore-проход), хотя
его кадр постоянен до выхода из комнаты.
Что сделано (шаг 1). Не спецкейс «мёртвый», а общее правило: у каждой
страницы дабл-буфера свой снимок ВХОДОВ отрисовки слота; совпал снимок,
спрайт этой страницы цел и фон в его прямоугольнике не трогали — heal, блит
и fore-проход пропускаются целиком. Покрывает и труп, и стоящего Кида, и
ждущего стража. Детали контракта — pop_cdraw.h, реализация —
pop_char_skip_mask / cd_quiet в pop_cdraw.c, метка фона —
pop_cd_touch в резидентном pop_tile.c.
Грабли, на которые наступили по дороге: сначала метка была ФЛАГОМ «фон трогали хоть где-то» — и выигрыш оказался ровно нулевым, потому что факелы анимируются каждый кадр и гасили пропуск для всех персонажей сразу (замер: 597 684 такта, как без оптимизации). Метка обязана быть ПОЗИЦИОННОЙ.
Остаточный эффект от объединения прямоугольников. Метка одна на страницу — объединение всех правок фона. В комнате 1 два факела дают прямоугольник x 40…88, а стоящий там Кид занимает x 32…44: пересечение 4 px, и он перерисовывается каждый кадр (140 % против 94 %, если отойти). Если понадобится — держать не один прямоугольник, а 3-4 отдельных и мержить только при переполнении.
Шаг 2 сделан частично: ЛОГИКА (синяя полоса) 2026-08-09
Пользователь: «на стоящем Киде с двумя факелами на логику уходит 60 %
кадрового периода — недопустимо». Разобрано брейкпоинтами в MAME
(z80_profiling_method), сцена: уровень 1 комната 1, Кид СТОИТ вплотную к
левому факелу (то есть пропуск персонажа НЕ срабатывает — худший случай).
Калибровка, которую надо знать заранее. Один такт totalcycles в MAME
— НЕ один номинальный T-такт Z80: у Sprinter на обращениях к ОЗУ есть
wait-state'ы, и замеренная стоимость выходит ≈ 2,4× номинала
(get_tile: 574 номинальных против 1 422 замеренных). Считать бюджет по
таблице T-тактов из справочника нельзя — только мерить. Кадр растра =
430 000; главный цикл спейсится тремя gfx_wait_vsync, поэтому работа
СВЫШЕ 430 000 стоит сразу целый лишний кадр.
Что нашли и починили:
| правка | что было | стало |
|---|---|---|
| окно перебора коллизии как в оригинале (было: все 14 колонок каждый кадр) | check_collisions 60 888 |
50 940 |
get_tile_div_mod — таблицей (tile_div_tbl/tile_mod_tbl), было /14 и %14 |
5 400 тактов на вызов, 13 вызовов за кадр ≈ 70 000 = 16 % кадра | ~250 на вызов |
разрешение ряда вынесено из цикла колонок + грань шагом 14 + wall_type таблицей |
check_collisions 58 026 |
42 750 |
move_coll_to_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 байта, резидент не тронут.
Два решения по дороге, оба проверены, а не угаданы:
-
Диапазон — весь, включая отрицательные. Первая версия крыла 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). -
Хранить
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_tx/7add hl,bc / ld a,(hl) / ld l,a / rlca / sbc a,a / ld h,a / add hl,de131 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 — там же протокол «как
мерить», чтобы не наступать заново на wait-state'ы и на устаревшие адреса
символов. Что доделано после таблицы выше: pop_cd_touch развёрнут,
tp/10 у факелов таблицей, пустой слот соперника считается тихим, ранний
выход в fore-проходе (нулевой эффект, оставлен как порт), быстрый путь без
клипа в pop_blit_b (факел 41 778 -> 31 218 тактов).
Что осталось (запас на будущее, срочности больше нет):
pop_char_fore113 628. Внутри:char_footprint+ расширение окна ~21 000, дальше шестьfore_tile, из которых два реально рисуют (по ~32 000). Дальше резать — кэш «в этом тайле переднего слоя нет вовсе».pop_process_trobs75 720 на два факела (~31 000 на факел). Внутри одного факела:gfx_blit_noclip8 700, чтение w/h и клип 6 400,pop_cd_touch(теперь дешевле), маппинг окна 0 и возвраты. Пламя перерисовывается каждый кадр обязательно (TORCH_ANIM_DIV = 1, кадр меняется), так что пропуск тут не поможет — только удешевление блита.pop_loose_tick27 438 при полном отсутствии падающих плит.- Одно
__divsintосталось вpop_char_draw(obj_x * 8 / 7) — ~2 400.
Шаг 2 (СЛЕДУЮЩИЙ, назначен пользователем): ДВИЖУЩИЙСЯ Кид
Пропуск закрывает только покой. Наблюдение пользователя 2026-08-08: как только Кид побежал, циан-полоса (спрайт + fore-проход) вырастает почти до 100 % кадрового периода — и это на ОДНОГО персонажа. То есть цена одной перерисовки персонажа сама по себе непозволительно велика, и её надо резать по существу, а не пропусками.
Куда смотреть (мерить каждое, метод — z80_profiling_method):
- разделить замером спрайт-блит и fore-проход: у fore уже была история
78 % кадра до кэша кладки (
pop_fore_layer_cost), он и сейчас главный подозреваемый; - fore-проход перебирает 2×2…3 тайла и в каждом разбирает кладку заново — кэш «в этом тайле переднего слоя нет вовсе» снял бы половину;
- расширять перебор ТОЛЬКО по накладным спрайтам (клинок/брызги), а не по всему окну клипа: габарит кадра уже покрыт футпринтом + правилом меча;
- 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 % |
- 60 % на тик персонажей при том, что оба СТОЯТ — первый кандидат.
Смотреть
pop_phys_tick/pop_guard_phys_tick(банк 3) иpop_guard_tick(банк 1): сколько там работы, которую неподвижный персонаж делать не обязан, и сколько стоит трамплин на каждом шаге. - 28 % на
loose_tick+process_trobsв комнате БЕЗ единой ловушки — явно перебор; разобрать, что там сканируется каждый кадр (список trob,pop_trob_modifсоседа для шва,pop_room_link). - Запечь труп в фон (как щебень: тело в фоне + 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, куча
180 Б → 2298 Б; после снижения --max-allocs — 2751 Б). Следующий кандидат
по тому же критерию (не размер, а частота вызова и отсутствие горячих
банк→банк переходов) — расщепление pop_kid.c: холодная половина
(загрузка страниц спрайтов, pop_kid_load) в банк, движок кадров
(load_frame/play_seq, 2×/кадр) оставить в резиденте.
Брать по факту нехватки места, не заранее. Таблица резидентного кода по
модулям и разбор, почему pop_level.c в банк НЕЛЬЗЯ, — в
TASKS_CLOSED.md.
Отложено осознанно (не брать, пока не появится причина)
- KBD-1, остаток — «иногда при зажатом Shift стрелка всё-таки
пропускается» (ощущение пользователя, не замер: счётчики на 35 нажатиях
подряд потерь не показывают). Решение пользователя 2026-08-01: отложено до
финальной полировки. Где именно осталась дыра и что делать, если вернёмся,
— в
TASKS_CLOSED.md(там же весь протокол замеров и список того, что делать НЕЛЬЗЯ). - Quickload (Shift+F9) и остальные читы SDLPoP — оценка сделана
(DBG-CHEATS), код не написан. Самое ценное и
самое дорогое: сериализация
Char+room_modifвсех комнат + trob'ов + стражей (levels_plan.md§4), зато даёт воспроизводимый регресс «вот кадр, где баг» вместо ручной подгонки позы. - Звук (CBL-эффекты, Фаза 5
PORT_PLAN.md) — геймплей не блокирует. - Таймер уровня / HUD времени / меню / сохранения — Фаза 6.
- T-1 (пики: перерисовка по причине) — отдаётся почти бесплатно после T-2, отдельно не окупается.
- Отключение мыши на время игры и замена PRNG —
../docs/ideas_backlog.md(оба дают доли процента кадра). - OPT-1 (хирургический редрой шва) — решено НЕ делать, стоимость
транзиентная; разбор в
BUGS_CLOSED.md.