Files
Sprinter-SDCC/applications/PoP/roomtest/TASKS_OPEN.md
T
snark13 8dc53e6b57 L5-SHADOW: тень уровня 5 крадёт зелье (спецсобытие комнаты 24)
Порт seg002: check_shadow (0064), do_init_shad (0000), do_auto_moves (1089),
autocontrol_shadow_level5 (1157) + таблица shad_drink_move (data.h:866).

Механика.  При входе в комнату 24 уровня 5, если зелье (кол 3, ряд 0) ещё
на месте, слот соперника занимает ТЕНЬ — и занимает его ВМЕСТО стража:
enter_guard в этой комнате оригинал не зовёт НИКОГДА (в данных страж есть,
guards_tile[23] = 8, но check_shadow уходит в return до него — в том числе
когда зелье уже выпито).  Поэтому pop_check_shadow вернула 1 = «событие
обработано, стража не поднимать», и вызов стоит ПЕРЕД pop_guard_enter.

Тень стоит, пока не откроются ворота комнаты (openness >= 80), затем
проигрывает ЗАПИСЬ ходов: подойти, взять, выпить, развернуться, уйти за
левый край.  do_auto_moves — тот же движок, что у демо-режима заставки;
тонкость выбора записи (при достигнутом времени индекс сдвигается, а ход
берётся из ещё не сдвинутой записи) портирована дословно.

Три вещи, найденные по дороге:

1. ГЕЙТ ВЫКЛЮЧЕННОГО ПЕРСОНАЖА.  play_guard_frame (seg000:1248) начинается
   с `if (Guard.direction != dir_56_none)`, и clear_char выключает
   персонажа именно так — он не трогает charid.  У нас гейт был только по
   charid, поэтому ушедшая за край тень продолжала тикать: do_auto_moves за
   концом таблицы отдаёт последнюю запись {0x31,1} = «вперёд», тень
   разворачивалась, вбегала обратно, пробегала верхний ряд и падала в проём
   (наблюдение пользователя в MAME).  Гейт добавлен и в pop_guard_tick, и в
   pop_guard_phys_tick.

2. ЭФФЕКТ ЗЕЛЬЯ — ТОЛЬКО КИДУ.  proc_get_object (seg006:16CB) начинается с
   `if (Char.charid != charid_0_kid || ...) return`: предмет забирает любой
   персонаж, а эффект достаётся только Киду.  На этом стоит всё спецсобытие
   — тень пьёт, зелье исчезает и не достаётся никому.  Без проверки
   hitp_delta уходил бы Киду, то есть тень его ЛЕЧИЛА бы.

3. ВЕТКА ТЕНИ в check_guard_fallout (seg002:0241): исчезает, только если
   реально падает (action == 4).  Была помечена в коде как «появится с
   L3-SKEL» — на самом деле она про тень.

Чит ROOMNAV: телепорт ставил Кида в первый попавшийся пол, то есть почти
всегда (0,0).  Теперь ищет пол С МАКСИМАЛЬНЫМ Y (ряд 2 -> 1 -> 0, по
просьбе пользователя): в комнате 24 старая посадка была вплотную к тени, и
вместо сцены кражи начиналась схватка, которой там быть не должно.

Отладочный тумблер POP_DBG_SHADOW_NOWAIT (pop_tune.h, по умолчанию 0) —
посмотреть сцену, не проходя уровень до кнопки.

Проверено в MAME (уровень 5, комната 24): при закрытых воротах тень стоит
за левым краем и не видна; с тумблером — подошла, выпила (зелье исчезло),
развернулась, ушла за край, выключилась и НЕ вернулась.  tests-host 5/5,
make size-check OK.  Цена: резидент +102 Б (24637 -> 24739), банк 1
+368 Б, банк 3 +11 Б.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:22:10 +03:00

58 KiB
Raw Blame History

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

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

  • закрытые задачи с протоколами и замерами — TASKS_CLOSED.md;
  • открытые баги — BUGS_OPEN.md, закрытые с разбором корней — BUGS_CLOSED.md;
  • планы фаз — ../docs/PORT_PLAN.md, ../docs/layout_plan_v2.md, ../docs/levels_plan.md.

Состояние на 2026-08-11 (сверено с кодом, не только с доской):

  • уровни 1-4 приняты smoke-тестами (пользователь). Полные обходы всех комнат делаются по готовности ВСЕХ уровней — политика приёмок в TASKS_CLOSED.md; отдельных задач L3-PASS/L4-PASS больше нет. Уже сделанные полные обходы уровней 1 и 2 остаются регресс-базой;
  • L4-MIRROR закрыта: зеркало, отражение, прыжок сквозь него и рождение тени проверены в MAME. Хвосты — вид тени (отложен, ../docs/shadow_render.md) и MIRROR-FG-STALE (закрыт как не баг);
  • L3-CHOMP и L3-SKEL закрыты (2026-08-08 / 2026-08-07);
  • тайлсет palace сделанpop_bg_load(type), pal_*.atl, дворцовая кладка wall_pattern, решётчатые тайлы 25-29 и в tile_table, и в коллизии (tile_is_floor совпадает с seg006:0628). То есть шаг 2 levels_plan.md закрыт;
  • libbgi: блочные AND/OR/XOR/NOT акселератора (2026-08-11, tests/accop) — задел под вид тени и под любые эффекты «поверх того, что уже нарисовано».

Правило проекта в силе: механику сверять с ../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 СЛЕДУЮЩАЯ: тень уровня 5 (крадёт зелье в комнате 24) + движок автодвижений прохождение ур. 5
2 GUARD-PHYS физика стража = физика Кида — ядро сделано, остаток: ветка ТЕНИ в check_guard_fallout и живая проверка кнопки под стражем ур. 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 — делаем сейчас

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 < 15clear_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): своя ветка ИИ (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 короткая, расхождение будет видно сразу.

Оговорка по виду: тень пока рисуется обычной копией спрайтов Кида, то есть выглядит вторым Кидом — это отложенный вопрос, к механике уровня 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: при переходе в бою Кид прячет меч и дальше дерётся пустой рукой.

Осталось (потому и запись открыта) — ревизия 2026-08-11 по коду:

  1. check_chomped_guardсделан вместе с L3-CHOMP (pop_map.c);
  2. ветки check_guard_fallout: скелет сделан (возрождается в комнате 3, pop_guard_fallout в pop_guard.c), ветки ТЕНИ нет — падает только в свободном полёте, loadshad/clear_char/saveshad; идёт в 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) вызывается ПОСЛЕ того, как комнату сменил Кид, и решает судьбу стража:

    if (Guard.alive < 0 && Guard.sword == sword_2_drawn) {      // жив и В БОЮ
        if (guards_tile[kid_room1] >= 30 ||                    // в новой комнате
            guards_seq_hi[kid_room1] != 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)»):

int level_color = custom->tbl_level_color[current_level];
if (level_color != 0) {
    byte* env_pal  = level_var_palettes + 0x30*(level_color-1);
    byte* wall_pal = env_pal + 0x30 * custom->tbl_level_type[current_level];
    set_pal_arr(0x50, 0x10, (rgb_type*)env_pal);    /* chtab_6 environment */
    set_pal_arr(0x60, 0x10, (rgb_type*)wall_pal);   /* chtab_7 wall        */
}

tbl_level_color (data.h:842) = {0,0,0,1,0,0,0,1,2,2,0,0,3,3,4,0}у уровня 3 цвет 1, у 7 тоже 1, у 8/9 — 2, у 12/13 — 3, у 14 — 4. Но level_var_palettes — это ресурс 20 из PRINCE.DAT (только версии 1.3/1.4), а в SDLPoP/data/PRINCE/ его НЕТ: там лежит лишь res10.bin (палитры стражей). Значит level_var_palettes == NULL и вся ветка молча пропускается — отсюда одинаковые подземелья.

Данные у нас есть. В ../MSDOS/PRINCE.DAT ресурс 20 присутствует: offset 22790, 240 байт = 5 палитр × 16 цветов × 3 байта (6-битные каналы, как res10).

Что делать (когда дойдём до вида уровня 3).

  1. Достать ресурс 20 из MSDOS/PRINCE.DAT (упаковщику придётся читать сам .DAT — сейчас все скрипты берут распакованные PNG из SDLPoP);
  2. сгенерировать таблицу палитр рядом с pop_guard_pal.h;
  3. при загрузке уровня заливать слоты 0x50..0x5F (env) и 0x60..0x6F (wall) — у нас ровно эти базы (pop_pack_bg.load_indexed: pal_base = 0x60 для WALL, 0x50 для env), то есть совпадение со set_pal_arr один в один;
  4. wall_pal = env_pal + 0x30 * tbl_level_type[level] — для подземелья (level_type == 0) обе палитры одинаковые.

Грабли, уже пойманные на цвете стражей: gfx_pal_load отдаёт указатель в BIOS ($A4 через rst #0x08), а BIOS читает только #4000-#BFFF — таблицу нельзя передавать прямо из банка (0xC000+), надо копировать в W1/W2 (см. BUG-GUARD-COLOR-1).

DRAW-COST. Кадр НЕ УКЛАДЫВАЕТСЯ в бюджет — нужна оптимизация

ШАГ 1 СДЕЛАН 2026-08-08: пропуск неизменившегося персонажа. Комната 1.3, труп стража, Кид стоит: было 210 % кадрового периода, стало 116 % (500 772 такта при бюджете 430 000). Отрисовка перестала быть узким местом: персонажи в покое не рисуются ВООБЩЕ (ноль вызовов pop_heal_fast за кадр), весь фон — ДВА блита факелов (44 136 тактов). Механизм и почему метка позиционная — в шапке pop_cdraw.h.

Исходные замеры пользователя (полосы бордюра, до шага 1). Уровень 1, Кид СТОИТ — то есть НЕ худший случай, ни боя, ни движения:

комната синяя (ввод+heal+логика) зелёная (фон) циан (спрайты) итого
3, страж УБИТ ~80 % ~20 % ~110 % ~210 %
2, стража НЕТ ~60 % ~20 % ~60 % ~140 %

Разница ровно в теле убитого стража: +20 % синей и +50 % циана. Труп сохраняет charid != 0, поэтому каждый кадр честно проходил весь путь живого персонажа (heal → спрайт+clip_char+брызги+клинок → fore-проход), хотя его кадр постоянен до выхода из комнаты.

Что сделано (шаг 1). Не спецкейс «мёртвый», а общее правило: у каждой страницы дабл-буфера свой снимок ВХОДОВ отрисовки слота; совпал снимок, спрайт этой страницы цел и фон в его прямоугольнике не трогали — heal, блит и fore-проход пропускаются целиком. Покрывает и труп, и стоящего Кида, и ждущего стража. Детали контракта — pop_cdraw.h, реализация — pop_char_skip_mask / cd_quiet в pop_cdraw.c, метка фона — pop_cd_touch в резидентном pop_tile.c.

Грабли, на которые наступили по дороге: сначала метка была ФЛАГОМ «фон трогали хоть где-то» — и выигрыш оказался ровно нулевым, потому что факелы анимируются каждый кадр и гасили пропуск для всех персонажей сразу (замер: 597 684 такта, как без оптимизации). Метка обязана быть ПОЗИЦИОННОЙ.

Остаточный эффект от объединения прямоугольников. Метка одна на страницу — объединение всех правок фона. В комнате 1 два факела дают прямоугольник x 40…88, а стоящий там Кид занимает x 32…44: пересечение 4 px, и он перерисовывается каждый кадр (140 % против 94 %, если отойти). Если понадобится — держать не один прямоугольник, а 3-4 отдельных и мержить только при переполнении.

Шаг 2 сделан частично: ЛОГИКА (синяя полоса) 2026-08-09

Пользователь: «на стоящем Киде с двумя факелами на логику уходит 60 % кадрового периода — недопустимо». Разобрано брейкпоинтами в MAME (z80_profiling_method), сцена: уровень 1 комната 1, Кид СТОИТ вплотную к левому факелу (то есть пропуск персонажа НЕ срабатывает — худший случай).

Калибровка, которую надо знать заранее. Один такт totalcycles в MAME — НЕ один номинальный T-такт Z80: у Sprinter на обращениях к ОЗУ есть wait-state'ы, и замеренная стоимость выходит ≈ 2,4× номинала (get_tile: 574 номинальных против 1 422 замеренных). Считать бюджет по таблице T-тактов из справочника нельзя — только мерить. Кадр растра = 430 000; главный цикл спейсится тремя gfx_wait_vsync, поэтому работа СВЫШЕ 430 000 стоит сразу целый лишний кадр.

Что нашли и починили:

правка что было стало
окно перебора коллизии как в оригинале (было: все 14 колонок каждый кадр) check_collisions 60 888 50 940
get_tile_div_mod — таблицей (tile_div_tbl/tile_mod_tbl), было /14 и %14 5 400 тактов на вызов, 13 вызовов за кадр ≈ 70 000 = 16 % кадра ~250 на вызов
разрешение ряда вынесено из цикла колонок + грань шагом 14 + wall_type таблицей check_collisions 58 026 42 750
move_coll_to_prevmemcpy (LDIR) вместо цикла на C 14 байт за 5 514 тактов (390 на байт!) ~1 200
окно режется на непрерывные пробеги (левый сосед / своя / правый), пролог ряда вынесен на кадр check_collisions 44 022 38 334

Замер одной итерации перебора: пустая колонка 750 тактов, колонка-стена ~1 700 (две 16-битные знаковые сверки граней — SDCC пишет их через jp PO / xor 0x80 / jp P). Ловушка, на которую наступили: «быстрый путь для окна внутри комнаты» не срабатывал ПОЧТИ НИКОГДА — Кид, стоящий в колонке 0, даёт окно с −1, и шёл медленный сбор во временный буфер с тернарником на колонку (1 340 тактов на колонку). Отсюда разбиение на пробеги: вопрос «чья это колонка» решается раз на пробег, а coll_scan сам двигает scan_left.

Самое дорогое было НЕ там, где ожидалось: /14 и %14 SDCC разворачивает в __divsint + __modsint, а __modsint внутри зовёт __divsint ещё раз — два полноценных 16-битных деления на каждый вопрос «в какой колонке точка». Оригинал делит таблицей (seg006:702) — мы просто не портировали это место.

ГЛАВНОЕ: логический кадр уложился в бюджет. Главный цикл спейсится тремя gfx_wait_vsync, поэтому работа сверх 430 000 тактов стоит СРАЗУ целый лишний растровый кадр. Было 470 964 (период цикла 4 кадра), стало 409 956 + ~15 600 на ввод = 425 600 — период цикла 3 растровых кадра. Игра стала быстрее на треть (16,7 логических кадров/с против 12,5).

Что дало последние тысячи (по убыванию):

правка экономия
pop_y_to_row — цепочка сравнений вместо /63 % 4 ~12 000
расширение окна fore-прохода арифметикой вместо перебора 10 колонок и 3 рядов ~8 500
col_from_x — таблицей (те же POP_TILE_DIV, вынесены в резидент) ~11 000
pop_cd_touch — развёрнутый цикл по страницам, x+w/y+h один раз ~8 600 (зовётся с каждого блита фона)
tp / 10, tp % 10 у факелов — таблицей ~4 000
пустой слот соперника считается «тихим» ~8 200

Ловушка SDCC, на которой я потерял один прогон: в pop_y_to_row одно и то же выражение t / 63 % 4 - 1 стояло в двух ветках, и компилятор поднял деление В ВЕРШИНУ функции — быстрые возвраты не спасали, __divsint звался всё равно. Лечится выносом медленного хвоста в ОТДЕЛЬНУЮ функцию. Тот же эффект уже был описан в pop_loose_tick; теперь ясно, что это правило, а не частный случай: любое деление, встречающееся дважды, SDCC поднимает выше всех проверок.

Приём, которым это ловится: брейкпоинт на __divsint/__divuint/ __divuchar с печатью адреса возврата (printf "%04X", w@(sp)) — сразу видно, кто и сколько раз делит за кадр.

Хвост подобран 2026-08-10. После переписи pop_y_to_row в pop_room.c осталось ТРИ места, считавших (y+60)/63 % 4 - 1 вручную (927 в mob_tick_one, 976/977 в mob_render) — сгенерированный asm подтвердил пару __divsint+__modsint в каждом. Это ~16 200 тактов (3,8 % кадра), но только пока кусок плиты в полёте — то есть ровно в самом тяжёлом кадре. Заменены вызовом pop_y_to_row; в банке 7 теперь НОЛЬ __divsint. Эквивалентность закреплена тестом geom_y_to_row_matches_formula (перебор −400..400 против исходной формулы) — вызовы разбросаны по трём банкам, и соблазн написать деление «по месту» возвращается.

Полная инвентаризация делений 2026-08-10

Способ (повторяемый одной командой из .sprinter-cc-roomtest/):

awk '/^;[a-z_0-9]+\.[ch]:[0-9]+:/{s=$0} /^\tcall\t__(div|mod|mul)/{printf "%-22s %-12s %s\n",FILENAME,$2,s}' *.asm

Найден 31 вызов в 9 модулях. Прибрано три места, остальное разобрано и осознанно оставлено.

Убрано:

место что было почему стоило
pop_cdraw.c calc_screen_x_coord (2 вызова) x * 8 / 7 = __divsint, 2 400 тактов НА ПЕРСОНАЖА КАЖДЫЙ КАДР последнее деление в горячем пути; ~4 800/кадр при живом сопернике
pop_guard.c guard_col_from_x /14 + %14 безусловно, мимо POP_TILE_DIV единственное 16-битное деление, у которого таблица вообще не была подключена
pop_trob.c animate_chomper tp / 10 = __divuchar на чомпера каждый кадр таблицы TP_ROW/TP_COL уже лежали в ЭТОМ ЖЕ файле, но ниже по тексту — чомперы их не видели

Приём для ×8/7: таблица SCRX7[1152] (int8_t, хранит x/7) в банке 4, индекс x + 448, результат x + SCRX7[i]. 1 152 байта, резидент не тронут.

Два решения по дороге, оба проверены, а не угаданы:

  1. Диапазон — весь, включая отрицательные. Первая версия крыла 0..255 по тождеству 8x/7 == x + x/7 с байтовой таблицей x/7. Ошибка: obj_x = 2*fwd 116 уходит в минус, как только fwd < 58 (левее x_bump[5]) — то есть у ЛЕВОЙ КРОМКИ комнаты, а это не экзотика, и там мы продолжали делить. Границы взяты из фактических данных: kid_data.bin даёт dx кадров Кида −5..+10, стража −2..+10; при Char.x типа uint8_t и render_dx ∈ {140, 0, +140} полный диапазон obj_x = 416..695. Таблица кроет −448..703, деление стало недостижимым (оставлено страховкой на третьего персонажа / другой render_dx).

  2. Хранить x/7 байтом, а не готовое x*8/7 словом — по тождеству 8x/7 == x + x/7. Первая версия хранила готовое, «раз всё равно индексация двухбайтная». Собраны ОБА варианта, такты посчитаны по сгенерированному asm:

    вариант хвост после проверки границ тактов байт
    int16_t готовое add hl,hl / add hl,de / ld e,(hl) / inc hl / ld d,(hl) 132 2 304
    int8_t x/7 add hl,bc / ld a,(hl) / ld l,a / rlca / sbc a,a / ld h,a / add hl,de 131 1 152

    Расширение знака и 16-битное сложение стоят ровно столько же, сколько лишний add hl,hl при двухбайтном индексе, а обращений к памяти на одно меньше — под wait-state'ами Sprinter (такт ≈ 2,4× номинала именно на обращениях к ОЗУ) байтовый вариант ещё чуть выгоднее номинала. Итог: байтовая таблица не хуже по скорости и на килобайт меньше. Урок: «двухбайтный индекс съест выигрыш» — гипотеза; она не подтвердилась.

Кодоген проверен глазами (bank4_pop_cdraw.asm): ld hl,#0x01C0; add hl,de, 16-битное беззнаковое сравнение — индекс полный, старший байт не теряется (грабли sdcc_z80_const_ptr_index_bug обойдены отдельной uint16_t-переменной). 131 такт номинала против ~1 000 у __divsint.

Проверка таблицы: все 1 152 записи сверены питоном обратно из .c, а ПРАВИЛА генерации (тождество + усечение к нулю) — тестом geom_mul8div7_table_rules на целевом компиляторе: округляй SDCC к минус бесконечности, вся отрицательная половина уехала бы на пиксель, и поймалось бы это только глазами на левой кромке.

Оставлено сознательно (НЕ трогать, это не забытые места):

  • Хвосты за таблицейguards.c:84, pop_bg.c:497, roomtest.c:163, pop_map.c:412: срабатывают только при x вне 0..255, то есть когда персонаж в соседней комнате. Убирать их — это расширять POP_TILE_DIV до 140..395 (+280 Б резидента) ради редкого пути.
  • pop_geom.c:21 — намеренный медленный хвост pop_y_to_row (см. выше про подъём деления SDCC).
  • pop_geom.c:52v % n в pop_rnd_fit для не-степени двойки: оригинал зовёт prandom(1)/(255)/(0xFF), все три идут веткой с маской, сюда управление не приходит вовсе.
  • Холодные, раз на комнату/уровень/событие: pop_guard.c:266/268/269 (вход стража), pop_map.c:310 (пробуждение скелета), pop_trob.c дверь уровня, roomtest.c:473/663/887 (читы и старт), pop_level.c:131/132 (имя файла уровня), roomtest.c:976/977 (отладочный HUD номера комнаты). Каждое — единицы вызовов за секунды игры; таблицы под них только раздули бы код.

Итог: в горячем пути делений не осталось. В банках 4, 6, 7 — ноль __div*; всё, что видно в списке выше, либо за быстрым путём, либо холодное.

Замер в MAME: A/B со сборкой ec3cca5 (до правок)

Обе сборки прогнаны через полный цикл (make hdd → рестарт MAME → загрузка уровня 1), сцена — комната 1, Кид стоит, соперника нет; скриншоты обеих сборок идентичны. Фазы сняты брейкпоинтами на инструкциях out (_io_border), a профилировочного бордюра, медиана по 60 логическим кадрам:

фаза до после Δ
ввод + heal 62 361 62 364 +3
логика 79 878 79 878 0
фон 119 502 119 502 0
спрайты 129 568 127 510 2 058
РАБОТА за кадр 391 258 389 221 2 037

Счётчик делений (брейкпоинты на __divsint/__modsint/__divuchar/ __moduchar с печатью адреса возврата):

сцена до после
комната 1, только Кид 1,00 __divsint/кадр (возврат 0xD3AA = pop_cdraw) 0
комната 3, бой со стражем 2,01 __divsint/кадр 0 (82 кадра боя)

Всё сходится в одну картину: единственное деление горячего пути — ×8/7 на персонажа, по одному вызову на каждого Char. Экономия ровно в фазе спрайтов и ровно на стоимость одного вызова: 2 058 тактов (документированная оценка была ~2 400). С соперником на сцене — вдвое.

Чего этот замер НЕ показывает, и это важно:

  • Период цикла как был 3 растровых кадра, так и остался — 2 000 тактов его не двигают. Выигрыш ушёл в запас: до нижней границы 430 000 стало 40 800 тактов вместо 38 700.
  • Остальные правки (три y_to_row в pop_room, guard_col_from_x, tp/10 у чомпера) в этой сцене не срабатывают вовсе — им нужны падающая плита, переход стража между комнатами и уровень с чомперами. Их стоимость известна поштучно, но в бою я их не ловил.
  • Работа в комнате 3 между прогонами несравнима (391 204 против 318 145): страж живой, фаза боя и срабатывание pop_char_skip_mask от прогона к прогону разные. Оттуда взят только СЧЁТЧИК делений — он от таймингов не зависит.

Профиль работы за логический кадр СЕЙЧАС (409 956 тактов + ~15 600 ввод):

блок тактов % растрового кадра
see_kid + ctrl_tick + skip + heal + kid_tick 52 536 12
физика Кида + страж + боёвка 73 452 17
pop_loose_tick 27 438 6,4
pop_process_trobs (два факела) 75 720 17,6
pop_redraw_needed + шов + skip_mask 10 932 2,5
pop_char_draw Кида 56 250 13
pop_char_fore Кида + борта 113 628 26

Синяя полоса (ввод + логика) была ~60 % → стала ~29 %.

Оптимизация закрыта по решению пользователя 2026-08-10. Всё, что осталось неcделанным, вынесено с замерами в ../docs/perf_backlog.md — там же протокол «как мерить», чтобы не наступать заново на wait-state'ы и на устаревшие адреса символов. Что доделано после таблицы выше: pop_cd_touch развёрнут, tp/10 у факелов таблицей, пустой слот соперника считается тихим, ранний выход в fore-проходе (нулевой эффект, оставлен как порт), быстрый путь без клипа в pop_blit_b (факел 41 778 -> 31 218 тактов).

Что осталось (запас на будущее, срочности больше нет):

  1. pop_char_fore 113 628. Внутри: char_footprint + расширение окна ~21 000, дальше шесть fore_tile, из которых два реально рисуют (по ~32 000). Дальше резать — кэш «в этом тайле переднего слоя нет вовсе».
  2. pop_process_trobs 75 720 на два факела (~31 000 на факел). Внутри одного факела: gfx_blit_noclip 8 700, чтение w/h и клип 6 400, pop_cd_touch (теперь дешевле), маппинг окна 0 и возвраты. Пламя перерисовывается каждый кадр обязательно (TORCH_ANIM_DIV = 1, кадр меняется), так что пропуск тут не поможет — только удешевление блита.
  3. pop_loose_tick 27 438 при полном отсутствии падающих плит.
  4. Одно __divsint осталось в pop_char_draw (obj_x * 8 / 7) — ~2 400.

Шаг 2 (СЛЕДУЮЩИЙ, назначен пользователем): ДВИЖУЩИЙСЯ Кид

Пропуск закрывает только покой. Наблюдение пользователя 2026-08-08: как только Кид побежал, циан-полоса (спрайт + fore-проход) вырастает почти до 100 % кадрового периода — и это на ОДНОГО персонажа. То есть цена одной перерисовки персонажа сама по себе непозволительно велика, и её надо резать по существу, а не пропусками.

Куда смотреть (мерить каждое, метод — z80_profiling_method):

  1. разделить замером спрайт-блит и fore-проход: у fore уже была история 78 % кадра до кэша кладки (pop_fore_layer_cost), он и сейчас главный подозреваемый;
  2. fore-проход перебирает 2×2…3 тайла и в каждом разбирает кладку заново — кэш «в этом тайле переднего слоя нет вовсе» снял бы половину;
  3. расширять перебор ТОЛЬКО по накладным спрайтам (клинок/брызги), а не по всему окну клипа: габарит кадра уже покрыт футпринтом + правилом меча;
  4. heal + блит спрайта: сейчас это два прохода по одной площади; посмотреть, нельзя ли стирать только РАЗНОСТЬ прямоугольников при мелком сдвиге.

Куда идти дальше в ПОКОЕ — там это ЛОГИКА, а не отрисовка. Разбивка работы кадра брейкпоинтами (комната 3, труп, Кид стоит; всего 500 772 такта):

участок тактов % кадра
ввод + check_skel + луч видимости + ctrl_tick + heal 65 862 15 %
kid_tick + физика + страж + боёвка 260 022 60 %
loose_tick + process_trobs 119 562 28 %
redraw_needed + шов + спрайты + метка 55 152 13 %
— из них два блита факелов 44 136 10 %
  1. 60 % на тик персонажей при том, что оба СТОЯТ — первый кандидат. Смотреть pop_phys_tick/pop_guard_phys_tick (банк 3) и pop_guard_tick (банк 1): сколько там работы, которую неподвижный персонаж делать не обязан, и сколько стоит трамплин на каждом шаге.
  2. 28 % на loose_tick + process_trobs в комнате БЕЗ единой ловушки — явно перебор; разобрать, что там сканируется каждый кадр (список trob, pop_trob_modif соседа для шва, pop_room_link).
  3. Запечь труп в фон (как щебень: тело в фоне + fore-часть поверх Кида) — тогда бесплатным станет и проход Кида ПО телу, который сейчас снимает пропуск по правилу «слоты рядом». Предложено пользователем 2026-08-08.

Мерить в ЭТОЙ точке (комната 3, страж убит, Кид стоит) — она воспроизводима и даёт нижнюю границу; худший случай (бой + бег + падающая плита) считать отдельно. Метод — memory z80_profiling_method (брейкпоинты с totalcycles); полосы бордюра показывают только одну картинку из периода и годятся лишь для раскладки по фазам.

Наблюдение пользователя на приёмке DRAW-CHAR 2026-08-08: циан-полоса профиля (спрайты + fore) выросла — тогда «в пределах».

Отчего именно. Раньше перебор тайлов у Кида шёл строго по футпринту кадра (char_x_left/right, seg006:1021), а он УЖЕ спрайта; расширение перебора окном спрайта (клинок и брызги уходят за габарит кадра) было только у соперника. DRAW-CHAR сделала его общим — то есть у Кида теперь на колонку-другую больше fore_tile за кадр. Это не регрессия «лишней работы», а недостающая ранее корректность: тайл, который спрайт задевает, обязан вернуть свой передний слой поверх него.

Если fore-проход снова станет узким местом (сейчас он в покое не выполняется вовсе): расширять перебор ТОЛЬКО по накладным спрайтам (клинок/брызги), а не по всему окну; кэш «в этом тайле fore-слоя нет вовсе»; считать окно клипа в тайловых координатах один раз.

P1 — берётся в любой момент

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.