Files
Sprinter-SDCC/applications/PoP/roomtest/TASKS.md
T
Александр Петров 4737ec323c MEM-BANK5: pop_ctrl.c → банк 5; чит [/] подгонки Кида по X
Три правки едут вместе намеренно: n_banks и --bank обязаны меняться
атомарно, иначе промежуточный коммит — зависание (см. ниже).

MEM-BANK5.  CODE и DATA делят одно 32-КБ пространство W1+W2, поэтому
килобайт кода, уехавший в банк, — это килобайт, доступный данным.
Кандидат выбран не по размеру, а по частоте вызова: диспетчер управления
дёргается раз в кадр на персонажа и горячих банк→банк переходов не
создаёт (в отличие от pop_level, чей pop_level_tile зовётся из банка 2 на
КАЖДЫЙ тайл).

  _CODE   26 780 -> 24 662 Б   (−2 118)
  куча       180 -> 2 298 Б
  банк 5            2 211 / 16 384 (13.5 %)

Шина control_* (8 глобалов) переехала в pop_state.c.  Сегодня она уцелела
бы и в pop_ctrl.c — банки собираются без --bank-data, их писучие данные
остаются в общем _DATA, — но это флаг сборки, а не свойство кода, а шину
трогают уже три банка: 5 пишет с клавиатуры, 1 подаёт синтетический ввод
ИИ (autocontrol_*, seg002), 3 читает через pop_ctrl_shift_held.
Заодно pop_ctrl.c наконец включает собственный заголовок — раньше
объявления жили прямо в нём.

ГРАБЛИ, на которые наступили (стоили дольше самой задачи): n_banks в
roomtest.c захардкожен, и его надо править вместе с числом --bank.  С
n_banks=4 и пятым банком crt0 выделил четыре страницы, _bank_pages[5]
остался нулём, и первый же вызов pop_ctrl_init() через трамплин
отобразил в W3 страницу 0 и прыгнул на 0xC874 в мусор — исполнение
забрело в дисковый код DSS и осталось крутить чтение секторов.  Симптом:
загрузка ресурсов проходит целиком (open=45 — все атласы), комната и Кид
успевают нарисоваться из enter_room, а HP и номер комнаты уже нет, и
kid_tick не вызывается ни разу.  Ровно предупреждение из шапки
runtime/bank.s.  Сверку n_banks с реальным максимальным индексом банка
записал в docs/TODO.md (Auto-banking) — это должно быть ошибкой сборки.

DBG-CHEATS: [ (0x54) и ] (0x5B) двигают Кида на пиксель (seg000:1828),
по фронту нажатия, под pop_cheats.  Нужны потому, что мост MAME теряет
нажатия при быстрой отправке и подогнать Кида в позу скриптом нельзя —
на это упёрлись BUG-LOOSE-2 и BUG-GATE-PASS-1.

ROOMNAV больше не зовёт pop_trob_reset: reset обнуляет room_seen, то есть
чит ОТМАТЫВАЛ МИР (открытые/закрытые ворота, нажатые кнопки).  Навигация
обязана только телепортировать.

Проверено в MAME: старт уровня 1 рисуется полностью (комната, Кид, HP,
номер), бег вправо и падение на второй ряд отрабатывают, ] даёт x+1 и
[ даёт x−1 по одному нажатию, Shift+→ — осторожный шаг (x 131 -> 142,
колонка 4 -> 5) и при удержании 90 кадров не срывается в бег (x 142 ->
151), то есть pop_ctrl_shift_held работает через границу банк 3 -> банк 5.
Наборы под ucsim: geom 39, grab 53, phys 1723 — все зелёные.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:45:15 +03:00

59 KiB
Raw Blame History

roomtest — доска текущих задач (обновлено 2026-08-04)

Не список багов (открытые — bug_list.md, закрытые с разбором корней — bug_closed.md) и не план фаз (../docs/PORT_PLAN.md, ../docs/layout_plan_v2.md, ../docs/levels_plan.md), а то, что берём в работу сейчас и в каком порядке. Каждая запись: что сделать, почему именно сейчас, чем подтверждать результат.

Открытых багов нет (bug_list.md): остались незакрытые оптимизации T-1, T-2 и незаконченная таблица обхода 24 комнат.

Правило проекта в силе: механику сверять с ../SDLPoP/src/ ДО кодинга; диагноз платформы подтверждать артефактом (брейкпоинт/дамп/.asm), а не гипотезой (memory defer_unexplained_quirks).


ТЕКУЩАЯ ЦЕЛЬ: уровни 1–3 (подземелье) отлажены целиком

Решение 2026-08-04: palace (уровни 4+) откладываем, доводим до играбельности три dungeon-уровня. Основание — они не требуют ни одного нового ассета фона: инвентарь тайлов, снятый с res200N.bin, показывает, что новое появляется только так —

ур. 1  empty, floor, spike, pillar, gate, closer, potion, loose, debris,
       opener, level_door L/R, torch, wall, skeleton, sword     ← всё есть
ур. 2  bigpillar_bottom(8), bigpillar_top(9), doortop(12)       ← есть (2026-08-04)
ур. 3  chomper(18)                                              ← НЕТ механики
ур. 4  lattice_pillar(25)…lattice_right(29) + тайлсет palace    ← отложено

Порядок (решение пользователя 2026-08-04): СНАЧАЛА приёмка уровня 2, потом уровень 3. Причина техническая, а не вкусовая: чомперы лягут в тот же банк 2, где живёт отрисовка, и чинить баги фона поверх свежей механики дороже, чем до неё.

# Задача Что Блокирует
1 L2-PASS приёмка уровня 2 + баги отрисовки сейчас
2 L3-CHOMP чомперы (5 шт) прохождение ур. 3
3 L3-SKEL скелет (единственный противник ур. 3) прохождение ур. 3
4 L3-CHKP чекпойнт ур. 3 корректный респавн ур. 3
L1-SPEED игра на ~39 % быстрее оригинала ощущение от ВСЕХ уровней; берётся в любой момент
L1-PASS сквозной прогон ур. 1 + таблица 24 комнат приёмка ур. 1
DBG-CHEATS [/] — подгонка Кида по X СДЕЛАНО 2026-08-05
MEM-BANK5 pop_ctrl.c → банк 5 СДЕЛАНО 2026-08-05: куча 180 Б → 2298 Б

P0 — делаем сейчас

L2-PASS. Приёмка уровня 2

Баги отрисовки уровня 2 пользователь подаёт списком отдельно — они идут в bug_list.md, раздел «Уровень 2». Ниже — карта содержимого уровня, снятая прямо с res2002.bin, чтобы приёмка шла по списку, а не «на глаз»: если механика в таблице есть, а в игре не сработала — это баг, а не «так задумано».

Стражи — 5, в комнатах 4, 7, 11, 15, 24 (skill 1/2/1/1/3, цвета 1/3/1/1/6 — цвет мы пока игнорируем, атлас один). Заметить: страж комнаты 24 со skill 3 — первый по-настоящему опасный.

Кнопки и что они открывают (декодировано из LINKLOC/LINKMAP):

Кнопка Тип Цель
к.9 @ряд1,кол1 RAISE дверь уровня к.23 @1,3 — то есть выход
к.11 @1,1 RAISE ворота к.18 @0,9
к.18 @0,7 RAISE ворота к.7 @0,9 и к.18 @0,9 (две сразу)
к.18 @0,2 DROP закрывает ворота к.7 @0,9
к.13 @1,4 DROP закрывает ворота к.13 @1,5

Ловушки и предметы по комнатам:

к. 3  loose@2,2   зелье@2,5 (здоровье)
к. 4  loose@1,7   loose@1,8            + СТРАЖ
к. 5  дверь уровня @1,2-3 — ВХОД (захлопывается за спиной)
к. 6  пики@1,3    loose@1,5   зелье@2,7 (здоровье)
к. 7  ворота@0,9  пики@2,7               + СТРАЖ
к. 8  зелье@2,2 (здоровье)
к. 9  кнопка RAISE@1,1   loose@2,6
к.10  пики@2,2
к.11  кнопка RAISE@1,1                    + СТРАЖ
к.12  зелье@2,6 (здоровье)   loose@2,8
к.13  зелье@1,3 (ВРЕДНОЕ, −1 HP)  кнопка DROP@1,4  ворота@1,5  зелье@1,8
к.15                                      + СТРАЖ
к.18  кнопка DROP@0,2  loose@0,4  кнопка RAISE@0,7  ворота@0,9  зелье@2,6
к.19  пики@1,4
к.20  ЗЕЛЬЕ@1,2 — БОЛЬШАЯ СКЛЯНКА (+1 к потолку HP)   пики@2,6   пики@2,7
к.23  дверь уровня @1,3-4 — ВЫХОД
к.24                                      + СТРАЖ (skill 3)

Отдельно проверить то, что сделано именно сегодня и на уровне 1 не проверялось:

  • большая склянка (к.20) — потолок HP становится 4, индикатор рисует четыре деления, и это HP переносится на уровень 3;
  • меч уже в руках с самого старта (have_sword = level >= 2) — на уровне 1 его надо было подбирать;
  • выход через дверь к.23 вживую (кнопка в к.9) — путь тот же, что закрыт на уровне 1 (L1-EXIT), но на этом уровне не прогонялся;
  • смерть/респавн возвращают на уровень 2, а не на 1.

L3-CHOMP. Чомперы — механика уровня 3

Где: 5 штук, комнаты 5(тайл 4), 16(23,24,25), 22(26). Дальше они почти на каждом уровне, так что это вложение не только в ур. 3.

Что портировать: animate_chomper (seg007) — состояние в room_modif, как у пик/ворот, значит шаблон уже отработан (gates_spikes_plan.md); коллизия и смерть Кида в сомкнутых челюстях (seg004/seg006); отрисовка кадра по модификатору (draw_tile_anim, ветка tiles_18_chomper).

Риск: банк 2 (pop_bg) занят на 86.5 %, свободно 2213 Б — считать место ДО кодинга (levels_plan.md §5.1), иначе повторится «банк 2 упёрся в потолок» (коммит 2f3e854). Свободные номера банков есть (5+).

L3-SKEL. Скелет — единственный противник уровня 3

Важно: в данных уровня 3 стражей нет вообще (guards_tile пуст во всех 24 комнатах). Единственный враг — скелет, и он не «страж из данных», а спецсобытие check_skel (seg002:1044):

в комнате 1, когда Kid.curr_col == 2 или 3 и дверь уровня открыта, тайл tiles_21_skeleton (комната 1, тайлпос 15) стирается в пол, а на его месте поднимается персонаж: charid_4_skeleton, меч сразу вынут, seq_88_skel_wake_up, skill 2, HP 3.

Ещё два тайла скелета (комнаты 17 и 19) — декорация, они не оживают.

Что нужно:

  • charid_4_skeleton в enter_guard/pop_guard_enter (seg002:196): при tbl_guard_type[level] == 2 персонаж поднимается с вынутым мечом и последовательностью seq_63_guard_active_after_fall, а не seq_77_guard_stand_inactive;
  • скелет бессмертен — чит K его уже не берёт (pop_guard_kill проверяет CHARID_4_SKELETON), но и боёвка должна возвращать его к жизни (seg002:252);
  • новый атлас: ../SDLPoP/data/SKEL/ (29 файлов) — pop_pack_guard.py сейчас прибит к GUARD/, нужен параметр набора (tbl_guard_dat = GUARD/FAT/SKEL/VIZIER/SHADOW).

L3-CHKP. Чекпойнт уровня 3

level3_set_chkp (seg002:519): вход в комнату 7 ставит checkpoint = 1 и hitp_beg_lev = hitp_max. do_startpos (seg003:141) при checkpoint подменяет старт: комната 2, тайлпос 6, направление влево, и убирает loose-плиту (комната 7, колонка 4, ряд 0). Механика hitp_beg_lev уже есть (сделана в L2), остаётся сам флаг и подмена старта.

KBD-1. Shift + стрелки: нажатия теряются — ПРИЧИНА НАЙДЕНА, ФИКС ПРОВЕРЕН

ПОПРАВКА К ПОСЫЛКЕ (2026-08-05). Ниже «fake shift» подан как установленный факт («при зажатом Shift PS/2 удваивает трафик»). Прямой замер потока байт это не подтвердил: клавиатура MAME-Sprinter (pc_kbd ms_naturl) обёртку E0 F0 12 / E0 12 не шлёт вовсе — при зажатом Shift поток на стрелку ровно E0 75 E0 75 …. Значит удвоения трафика в связке Shift+стрелка нет, и мотивировка «поэтому FIFO переполняется» отпадает; сам ФИКС (плотный опрос kbd_raw_poll) остаётся верным и нужным — переполнение вызывает не Shift, а короткая жизнь импульса IRQ (пункт 3 гипотезы) плюс DI-окна графики. Разбор и следствия — BUG-KBD-5.

Итог (2026-08-01). Причина — не наш код и не DI-окна графики: при зажатом Shift PS/2 удваивает трафик («fake shift»), а импульс запроса прерывания здесь теряется примерно в 44 % случаев, и трёхбайтовый FIFO SIO переполняется. Лечится ПЛОТНЫМ опросом: kbd_raw_poll повешен idle-хуком на ожидание кадра (gfx_set_idle_hook, новый API libbgi) — процессор всё равно проводит там ~42 мс из 60, крутя опрос луча.

Проверка в roomtest тем же счётным методом: 35 нажатий Shift+Home → 35 make, ноль потерь (до фикса — 9 из 10). Боевой сценарий тоже: четыре Shift+→ подряд дали четыре осторожных шага, Kid.x 114 → 147. Цена: _CODE +170 Б, кадровый бюджет не затронут (опрос стоит в простое). Ниже — полный протокол, как к этому пришли.

ОСТАТОК (ручная проверка пользователем, 2026-08-01): «стало значительно лучше, но иногда при зажатом Shift стрелка всё-таки пропускается». Ощущение, не замер — счётчики на 35 нажатиях подряд потерь не показали, значит остаточная частота заметно ниже прежних ~15 %. Задача осознанно ОТЛОЖЕНА до финальной полировки всей программы (решение пользователя); сейчас клавиатура пригодна для работы.

Где именно осталась дыра — чтобы на полировке не начинать с нуля. Idle-хук покрывает простой, то есть ~2/3 кадра. Оставшаяся треть — это занятая фаза, и там DI-окно одного accel-прохода доходит до ~650 мкс при допуске FIFO ~300 мкс. Пачка байт, целиком попавшая в такое окно, всё ещё может потерять байт — ровно «иногда». Порядок действий, если вернёмся:

  1. Вернуть вызовы kbd_raw_poll() между блитами занятой фазы (они бесплатны; сами по себе не помогали, но вместе с хуком закрывают именно этот зазор) и при необходимости внутрь тайловых циклов pop_bg — тогда слепым остаётся только тело одного блита.
  2. Мерить тем же счётным методом (см. ниже), а не на ощупь: скриптовые нажатия ровнее человеческих, поэтому набирать выборку от 50 нажатий.
  3. Если и это не добьёт — остаются два рычага вне нашего кода: Scan Code Set 3 через BIOS $EA (убирает «fake shift» в корне, но в MAME непроверяемо — обратный путь к клавиатуре не разведён) и общий m_irq_off_timer в драйвере MAME.

Симптом (пользователь, 2026-08-01). Залипаний почти нет, но при УДЕРЖИВАЕМОМ Shift первые один-два нажатия ← дают осторожные шаги, дальше нажатия ← не отрабатываются, пока Shift не отпустишь.

Рабочая гипотеза (механизм, а не догадка «что-то с клавиатурой»). Три известных факта складываются в одну картину:

  1. PS/2 Set 2, «fake shift». При зажатом Shift нажатие РАСШИРЕННОЙ клавиши (стрелки — E0-коды) обрамляется фиктивным отпусканием/нажатием шифта: нажатие ← шлёт E0 F0 12 + E0 6B = 5 байт (без шифта было бы 2), отпускание — E0 F0 6B + E0 12 = 5 байт (было 3). То есть ровно в связке Shift+стрелка трафик удваивается.
  2. Приёмный FIFO SIO — 3 байта. Пачка в 5 байт переживает только то, что мы успеваем вычерпывать её по ходу. Потерянный make стрелки = «нажатие не отработало»; потерянный break = залипание (его лечит kbd_raw_sync, но ценой сброса всех немодификаторных клавиш).
  3. Импульс IRQ клавиатуры в MAME живёт 32 такта CPU. mame/sources/MAME/src/mame/sinclair/sprinter.cpp: on_kbd_data() выставляет m_irqs->in_set<1>() НА КАЖДЫЙ принятый байт (то есть старая запись в docs/TODO.md «MAME не даёт per-byte INT» — неверна), но тут же заводит m_irq_off_timer на 32 такта, а irq_off() снимает линию. Если в эти 32 такта мы под DI — прерывание пропало насовсем, байт остаётся в FIFO до следующего IRQ (следующий байт или кадровый 50 Гц).
  4. Наши DI-окна длинные. Ядра акселератора держат di на ВЕСЬ блит (libbgi/bgi256/_bgi_blit_cols_raw.c:47 — «один DI на весь блит»); порядок цены прохода — 13.6 К тактов (libbgi/include/gfx.h), это сотни микросекунд, на порядки больше 32-тактового импульса.

Отсюда: обе версии пользователя — про одно и то же. Логика ввода (pop_ctrl.c, порт read_user_control/safe_step) сверена с SDLPoP и выглядит корректной: safe_step() ставит control_forward = CONTROL_IGNORE, и это снимается в read_user_control() при ОТПУСКАНИИ стрелки — то есть повторные тапы ← при зажатом Shift обязаны работать. Не работают они потому, что до нас не доезжает либо make, либо break стрелки.

План проверки — по шагам, каждый даёт артефакт:

  1. Счётчики в MAME: брейк на _kbdraw_overrun (запись) и на ветке tr_kbd_drain — сколько overrun'ов за 10 с при «Shift зажат, тапаю ←» против «тапаю ← без Shift». Ожидание по гипотезе: с Shift кратно больше.
  2. Замер максимального DI-окна кадра: брейкпоинты на di/ei в _bgi_blit_cols_raw + {printf totalcycles; g} — получить реальную длину в тактах и в микросекундах.
  3. Спайк «блит без DI». docs/new/06-accel.md §6.6: новая прошивка допускает работу акселератора при EI (по приходу прерывания он отключается, по RETI включается обратно); старая — нет. Собрать libbgi с убранным di в блит/heal-ядрах, прогнать roomtest в MAME: (а) не рушится ли картинка, (б) падает ли счётчик overrun из п.1. Если да — причина подтверждена, и дальше это вопрос «какая прошивка на живом железе» (по умолчанию оставить DI, режим без DI — опцией libbgi).
  4. Независимо от п.3 — kbd_raw_poll(). Вычерпывание FIFO ОПРОСОМ (порт 0x19 бит 0 → читать 0x18, тот же декодер make/break, что в трамплине) из главного цикла 2–4 раза за кадр между фазами PROF(). Снимает зависимость от «поймали ли мы импульс IRQ» вообще, стоит сотни тактов, графику не трогает. Реализация: вынести drain-цикл из libc/irq/_irq_tramp.c в общий кусок либо продублировать в libc/kbd/kbd_raw_poll.c; тело обязано идти под DI (гонка с ISR за деструктивное чтение порта 0x18).
  5. Побочно сюда же играет T-2 (idle-skip): не перерисовывать Кида, пока поза/координаты не менялись, — это минус heal+blit (то есть минус DI-окна) в самых спокойных кадрах, где как раз и тапают Shift+стрелку.
  6. Только если после 3–4 симптом жив — копать логику control_shift2/CONTROL_IGNORE против seg005.c:374..390.

Критерий готовности: при зажатом Shift десять тапов ← дают десять осторожных шагов (проверка в MAME через :kbd:ms_naturl:* напрямую, НЕ через press_key — тот дёргает обе клавиатуры, см. docs/libc-reference.md <kbd_raw.h>).


KBD-1: ЧТО ИЗМЕРЕНО (сессия 2026-08-01) — гипотеза про DI НЕ подтвердилась

Методика. Симптом «нажатие не отработало» переведён в счётчики, чтобы не спорить с глазами. Нажимается Home — тоже расширенная клавиша (тот же E0-префикс и тот же «fake shift», что у стрелок), но игрой игнорируется, поэтому Кид стоит на месте и рельеф комнаты на результат не влияет. Брейкпоинты с действием { b@ADDR = b@ADDR + 1 ; g } (счёт без остановки машины) в трёх точках: вход клавиатурной ветки трамплина, чтение порта 0x18 внутри drain-цикла, запись make-бита для кода 0x6C. Скратч-байты — хвост ovr_tile[] (в этом сценарии не используется).

Симптом воспроизведён скриптом: при зажатом Shift 10 нажатий → до декодера дошло 9 make-байт. Без Shift потерь нет — ровно как сообщил пользователь.

Прогон make дошло / нажато overrun
игра идёт, kbd_raw_poll ВКЛ 9 / 10 3
игра идёт, kbd_raw_poll ВЫКЛ (патч ret в точке входа) 9 / 10 4
игра ЗАМОРОЖЕНА клавишей «1» (блитов нет вообще, значит и длинных DI нет) 8 / 10 6

Вывод 1: наши DI-окна ни при чём. В замороженном кадре, где блитов нет и прерывания разрешены практически всё время, потерь НЕ меньше, а больше.

Вывод 2: kbd_raw_poll() в текущей расстановке бесполезен — 9/10 и с ним, и без. Причина понятна задним числом: шесть вызовов стоят В ТЕХ ЖЕ точках, где прерывания и так разрешены, то есть добавляют ровно то, что трамплин сделал бы сам. Вызовы из roomtest.c убраны; сама функция в libc оставлена — она корректна и нужна как заготовка под «плотный опрос» (см. ниже), но в горячем цикле её держать не за что.

Вывод 3 (главный): байт теряется НИЖЕ нашего кода. Счётчик чтений порта 0x18: 5 нажатий Shift+Home должны дать ровно 50 байт (нажатие E0 F0 12 + E0 6C, отпускание E0 F0 6C + E0 12 = по 10 на цикл). Насчитано 49 — и ровно один make потерян. То есть до процессора байт не доехал вообще, декодер тут ни при чём.

Вывод 4: прерывание на байт теряется примерно в 44 % случаев. На тех же 49 прочитанных байтах — только 28 входов в клавиатурную ветку трамплина (1.75 байта за вход). То есть больше сорока процентов импульсов запроса не были обслужены, и байты копятся в трёхбайтовом FIFO вплотную к его потолку; одна неудачная пауза — и байт потерян.

KBD-1: ПОТОЛОК ПЛОТНОГО ОПРОСА ИЗМЕРЕН — приём лечит полностью

tests/kbdpoll — программа, которая не делает НИЧЕГО, кроме kbd_raw_poll() в бесконечном цикле (ни графики, ни vsync, ни вывода: любая работа разредила бы опрос и испортила замер). Это физический максимум плотности. Тот же счётный метод, те же брейкпоинты-счётчики.

Прогон нажатий make дошло байт прочитано / ожидалось
контроль: Shift зажат 4 с, нажатий нет 0 0 0 (Shift сам ничего не шлёт — автоповтора у модификатора нет)
Shift + Home 25 25 250 / 250

Ни одного потерянного байта. Для сравнения: в игре при шести вызовах за кадр терялся 1 байт из 50. При такой частоте потерь вероятность случайно получить ноль потерь на 250 байтах ≈ 0.6 %, так что результат не совпадение.

Вывод: опрос — рабочее решение, вопрос только в ПЛОТНОСТИ. Нужно опрашивать примерно раз в 0.5 мс (≈10 000 тактов), а шесть вызовов за 60-мс кадр давали один раз в 10 мс — в двадцать раз реже необходимого.

Где взять частоту: логический тик = 60 мс, из них ~18 мс занято работой и ~42 мс процессор простаивает внутри gfx_wait_vsync, опрашивая луч. Опрос там стоит ноль и покрывает две трети периода с запасом по плотности. Остаётся слепым только тело одного accel-блита под DI (до ~650 мкс) — разорвать его нельзя (см. «что НЕ делать»).

Почему нужна именно такая частота (вопрос «PS/2 же не даёт больше 30 нажатий в секунду»). Частота опроса определяется НЕ темпом нажатий, а темпом байт ВНУТРИ одного нажатия и глубиной FIFO. Одно нажатие при зажатом Shift — это 5 байт подряд (E0 F0 12, E0 6C), отпускание — ещё 5, и клавиатура выдаёт их со скоростью провода: 11 бит на байт при ~10–16 кГц = ~0.7–1.1 мс на байт. Воронка — 3 байта. Значит между двумя вычерпываниями имеют право прийти максимум два байта, то есть вычерпывать надо не реже чем раз в ~1.5–2 мс (0.5 мс взято с запасом). Даже ОДНО нажатие в секунду переполнит FIFO, если в эти несколько миллисекунд его никто не разгребает. Замер это подтверждает: 1.75 байта за одно вычерпывание — уже 58 % ёмкости. В норме разгребает прерывание; опрос понадобился только потому, что ~44 % импульсов здесь теряется.

Альтернатива, которая убирает опрос совсем — уменьшить трафик, а не ускорять разгребание. BIOS $EA (FN_KBD_OUT, docs/new/09-input.md §9.2) шлёт байт НА клавиатуру, то есть ей можно скомандовать:

  • Scan Code Set 3 — нет ни «fake shift», ни E0-префиксов: make = 1 байт, break = 2. Нажатие с шифтом перестаёт превышать FIFO в принципе.
  • либо хотя бы отключить typematic (0xF5/0xF7).

Но проверить это в MAME НЕЛЬЗЯ: в sprinter.cpp подключено только направление клавиатура→SIO (m_kbd->out_data_cb() → rxa_w); обратный путь (SIO→клавиатура) не разведён вовсе, так что команда просто уйдёт в никуда. Плюс пришлось бы переписать все наши константы кодов под Set 3. Значит это кандидат на «когда дойдём до реального железа», а не на сейчас.

Что сделано по этому плану (2026-08-01):

  1. Idle-хук в libbgi: gfx_set_idle_hook(fn), вызывается в цикле ожидания луча внутри gfx_wait_vsync. Приложение ставит туда kbd_raw_poll. Полезен не только нам — любой программе даёт «качать» что-то в ожидании кадра. Осторожно с регистрами: цикл ждёт на BC-таймауте, вокруг вызова нужен push/pop, а сам таймаут в итерациях станет длиннее по времени. Важно про цену: это НЕ новая нагрузка. Опрос ставится ровно туда, где процессор и так впустую крутит in a,(#0xFE) — 42 мс из 60. Полезной работы не отнимается нисколько. Ограничитель области, если «постоянный опрос» всё равно не нравится: потери случаются ТОЛЬКО при зажатом модификаторе (замерено; без Shift потерь нет). Значит хук можно взводить лишь пока нажат Shift/Ctrl/Alt — тогда опрос работает исключительно в той ситуации, ради которой заведён.
  2. Перемерено в roomtest тем же счётным методом: 35/35, потерь нет.
  3. Вызовы в занятую треть кадра НЕ возвращены: одного idle-хука хватило. Держать в уме, если на реальном железе или на более тяжёлых сценах (несколько стражей) потери появятся снова — накрыть блиты дешевле, чем изобретать что-то новое.
  4. Ручная проверка пользователем — без неё этап не закрыт: скриптовые нажатия ровнее человеческих, и «залипания до отпускания Shift» они не воспроизводили с самого начала.

Куда смотреть дальше, если плотного опроса не хватит.

  1. Драйвер MAME — НЕ ТРОГАЕМ (решение пользователя: пересборка MAME на его машине занимает часы). Для протокола, подозрение осталось: sinclair/sprinter.cpp держит запрос от клавиатуры ровно 32 такта CPU, и m_irq_off_timerодин на два источника (irq_on() экрана заводит его же, irq_off() гасит разом обе линии). То есть кадровое прерывание способно обрезать клавиатурный импульс — правдоподобное объяснение «44 % пропущенных импульсов».
  2. Реальное железо. Если п.1 — чисто эмуляционный артефакт, на железе проблемы может не быть вовсе. Проверять при первом прогоне на живом Sprinter.

Про совпадение кадрового и клавиатурного прерываний (вопрос пользователя, 2026-08-01). Документация Sprinter: оба приходят с вектором 0FFh, различать по биту приёма байта в порту клавиатуры — «не пришёл, значит экран»; совпадение возможно, но «исключительно редкий случай» (в новой версии обещают развести жёстче через ПЛМ). То есть наш трамплин делает ровно предписанное. Известный побочный эффект: при совпадении мы обслуживаем клавиатуру и reti, пропуская кадровую цепочку и DSS — на потерю байт это не влияет (линия кадрового остаётся взведённой и вызывает повторный вход), но кадровый тик может пропасть. Отдельная мелкая правка, в KBD-1 не входит.

Что НЕ делать (проверено, стоило времени):

  • Снимать di в accel-ядрах libbgi нельзя. Патч dinop в _bgi_blit_cols_raw/_bgi_heal_rows_raw/_bgi_blit_rows_raw прямо в памяти уронил машину в перезагрузку. То есть режим «акселератор работает при EI» из docs/new/06-accel.md §6.6 в этой прошивке/эмуляции недоступен — вопрос закрыт артефактом, а не рассуждением.
  • Дробить DI-окна по колонкам смысла тоже нет: см. вывод 1.

CLIP-1. Аудит блитов: где клип не нужен — СДЕЛАНО 2026-08-01

Итог. Heal Кида и стража переведены на выбор ядра по тому же тесту, что давно стоит у блитов. Замер в MAME (счётчики totalcycles на входах kid_heal и kid_tick, то есть вся группа heal за кадр; комната 1, Кид стоит, стража нет):

путь тактов на кадр кадров в выборке
клипающее ядро (как было) 26 200 149
noclip (стало) 15 848 239

−10 352 такта на кадр, то есть −39.5 % с группы heal (≈0.49 мс при ~21 МГц). A/B честный: оба замера сняты в ОДНОМ прогоне, вторая половина — с пропатченным в памяти условием (jr nzjr в pop_heal_fast), то есть на той же геометрии и в той же сцене.

Размер: −362 Б суммарно (не плюс!): _CODE 25 289 → 25 306 (+17), BANK2 13 792 → 13 676 (−116, свободно стало 2708 Б — это тот самый тесный банк из рисков levels_plan.md §5), BANK3 6512 → 6249 (263), BANK4 без изменений.

Грабли, стоившие двух пересборок (вынесено в память sdcc-static-inline-double-cost): первым заходом хелпер был static inline в pop_bg.h — и SDCC 4.5 И встроил его тело (181 Б) в каждое место вызова, И оставил отдельную копию в КАЖДОМ TU, который видит заголовок. pop_guard_heal раздулся с ~60 до 663 Б, итого +1091 Б в _CODE и +636 Б в банке стража. Лечится обычной функцией в одном резидентном модуле (pop_draw.c, W1 — из банков это прямой call без трамплина, как у pop_sword_draw).

Проверено визуально: обычная ходьба, прыжок, спуск и позиция «за решёткой шва» (straddle — там как раз работает клипающий фолбэк) — артефактов и следов нет.

Ниже — исходная постановка задачи (что и почему смотрели).

Зачем сейчас. Кадр занят на ~86 %; подготовка клипающего варианта стоит ~5.6 К тактов на вызов, а общее ядро против линейного — 13 288 против 4 617 тактов на спрайт 32×3 (libbgi/include/gfx.h). Это самая дешёвая оставшаяся оптимизация: не переписывание логики, а выбор ядра.

Что уже правильно (шаблон, который надо распространить): блиты Кида, стража, клинка и брызг спрашивают pop_onscreen_cols() и уходят в gfx_blit_cols_part_noclip, иначе в клипающий вариант (pop_kid.c:86,591,676, pop_gdraw.c:105).

Что чинили:

  • kid_heal() и pop_guard_heal() звали gfx_healвсегда с клипом, хотя gfx_heal_noclip существует и heal_off в pop_bg.c им уже пользовался. Это heal 2–4 прямоугольников КАЖДЫЙ кадр; замер общего ядра — 11 658 тактов на heal 22×22. Теперь все три идут через общий pop_heal_fast (pop_draw.c + _pop_draw.h).
  • pop_room_clip_borders() (pop_bg.c) — gfx_heal(0,0,320,…): оставлен клипающим осознанно. Полоса шириной 320 не лезет в 8-битный параметр noclip-ядра, а бить её на два куска по 160 нет смысла: гейт border_dirty пускает туда только в кадрах падения, и выигрыш подготовки тонет в цене самих 320×28 пикселей. Причина записана прямо в коде, чтобы не «оптимизировать» повторно.

Что обязано остаться с клипом (зафиксировано комментариями в коде):

  • кромочные тайлы фона (blit_b в pop_bg.c) — тайл у края экрана режется по построению;
  • спрайты при straddle (kid_render_dx = ∓140, комната Кида ≠ отрисованной) и при падении ниже поля — фолбэки в kid_draw/pop_guard_draw;
  • борта поля (pop_room_clip_borders) — см. выше.

Что уже было правильно (шаблон, который и распространили): блиты Кида, стража, клинка и брызг спрашивают pop_onscreen_cols() и уходят в gfx_blit_cols_part_noclip. Отдельный случай — pop_kid_img_blit: noclip БЕЗ проверки, потому что единственный вызывающий (полоса HP) рисует по фиксированным координатам; это тоже помечено в коде.


P1 — сразу после P0 (закрываем уровень 1 как ИГРУ, а не стенд)

L1-START. Старт по данным уровня — СДЕЛАНО 2026-08-01

Старт и оба рестарта (смерть, выпадение из уровня) сведены в один pop_start_level() — порт start_level + do_startpos + set_start_pos (seg003): комната/тайл/направление берутся из pop_level_start_*, направление инвертируется (~start_dir), поза входа — из tbl_entry_pose. У уровня 1 это «падение внутрь» плюс нажатие кнопки room5(0,2) — то самое, что захлопывает решётку за спиной. Проверено: старт даёт room 1, col 0, падение на row 1 — как по данным уровня.

#define ROOMNAV оставлен ВКЛЮЧЁННЫМ осознанно: это наш чит, которого в оригинале не было, — как и S (выдать меч), K, I. Все они со временем съедутся в общий блок читов, разрешаемый в настройках (решение 2026-08-01).

L1-EXIT. Выход с уровня (дверь уровня) — СДЕЛАНО 2026-08-01

Портированы: ветка двери уровня из up_pressed + go_up_leveldoor (seg005:0482/0574) — в pop_leveldoor_enter() (pop_map.c, тайлы и геометрия) и up_pressed() (pop_ctrl.c, только последовательность); опкод 0xF1 END_LEVEL в play_seq теперь инкрементит pop_next_level (порт next_level), а главный цикл по нему перезапускает уровень — ровно та точка, куда levels_plan.md §2.2 подключит загрузку уровня 2.

Открытость двери проверяем по modifier >= 42 (ветка fix_exit_door), а не по ванильному leveldoor_open: с ванильным условием можно войти в ещё ползущую створку.

Грабли, которые стоили отдельного разбора: go_up_leveldoor сначала писал Char.x/Char.direction, и оба присваивания молча терялись — окно Char вокруг диспетчера возвращает назад ТОЛЬКО curr_seq и sword (pop_savekid_state). Направление оставалось «вправо», а все DX последовательности seq_70 отрицательные, поэтому Кид уходил ИЗ проёма влево. Вывод на будущее: геометрию персонажа в этом порте меняет pop_map (пишет в Kid), а не диспетчер.

Косметика тоже закрыта: BUG-DOOR-CLIP (обрезка силуэта правым косяком проёма) — недоставало второй половины clip_char (obj_clip_right) и обрезки СПРАВА у колоночного блита. В libbgi добавлен gfx_blit_cols_part_w(..., maxw); разбор — в bug_closed.md.

L1-TRIAGE. Ревизия багов — ЧАСТЬ 1 СДЕЛАНА 2026-08-01

Три Critical'а прогнаны в MAME и закрыты (протокол с числами — в bug_closed.md, раздел «Проверено в MAME 2026-08-01»): BUG-1 (провал на row 1 при переходе через открытые ворота) и BUG-2 (ping-pong при возврате) не воспроизводятся, BUG-3 (окклюзия climb-up на кнопке) закрыт фиксом tile_code_drawn от 2026-07-28. Заодно снят неверный диагноз BUG-1: репроекция Y при БОКОВОМ переходе — не наш пробел, а точное поведение goto_other_room (SDLPoP/src/seg002.c:390 меняет только x). Список разделён на bug_list.md (открытое) и bug_closed.md (закрытое + разбор корней).

Косметика окклюзии тоже закрыта — BUG-CEIL-1/2/3 и BUG-OCCL-1 были починены кодом ещё в июле, а записи никто не снял: ceil_over_kid_tile (pop_bg.c:1275), pop_ceil_modif + pop_ceil_shake_draw/_bake_empty (:547,815,827), bar с POP_YOFF+3 в pop_room_redraw_seam_left (:793), разделение слоёв по add_backtable vs ptr_add_table в overlay_mid_tile (:1367). Разбор — в bug_closed.md.

Осталось: закрыть таблицу обхода 24 комнат (bug_list.md) — заполнена на 5 строк из 24, инструмент (ROOMNAV) готов. Делать вместе с L1-PASS: это единственный оставшийся источник новых багов уровня 1.

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 закрыт (см. P0). Проверка — секундомером по одинаковому отрезку рядом с живым SDLPoP, не «на глаз».

L1-PASS. Сквозное прохождение уровня 1

От старта до двери уровня одним заходом: подбор меча, страж, кнопки/ворота, пики, loose-полы, зелье, падения. Это приёмка этапа 1 и одновременно регресс-база для уровня 2.

Совмещать с таблицей обхода 24 комнат: известные баги закрыты, значит новые придут только отсюда. Точки, где стоит смотреть внимательно, — уже закрытая косметика окклюзии (потолок при прыжке вверх, шов при анимации решётки, грани дальней колонны) и подъём на тайл-кнопку: см. оговорку к BUG-3 в bug_closed.md.

L2. Переход на уровень 2 и его игра — МАШИНЕРИЯ СДЕЛАНА 2026-08-04

Порт levels_plan.md §2 (шаг 1). Что появилось:

  • Номер уровня стал состоянием. pop_current_level (порт current_level) в pop_level.c; pop_next_level больше не флаг, а НОМЕР — END_LEVEL его инкрементит (как ++next_level, seg006:662), а главный цикл срабатывает по расхождению pop_next_level != pop_current_level (порт play_level_2, seg003:0386).
  • Загрузка по номеруpop_level_load_num(n): LEVELS\res20NN.bin (fallback a:\), старая EMM-страница отпускается ТОЛЬКО после успешной загрузки новой (нет файла — играем дальше на текущем). На диск кладутся все 15 уровней (34 КБ).
  • Потабличные различия (data.h:840..848) — таблицы по 16 в pop_level.c: tbl_entry_pose (поза входа), tbl_guard_hp (HP стража, ушло из хардкода 3), tbl_guard_type (1 = стражей нет, иначе на 14/15 они полезли бы из данных комнат), tbl_level_type (тайлсет — пока только читается).
  • find_start_level_door (seg003:02E6) — на уровне 2 это НЕ косметика: стартовый тайл (комната 5, ряд 1, колонка 3) — правая половина двери уровня, и без modif = 43 + add_trob(...,3) Кид материализуется внутри глухой створки. Тип 3 = «быстро закрыть»: дверь захлопывается за спиной за три кадра, как в оригинале.
  • HP через уровеньhitp_beg_lev (seg003): рестарт уровня откатывает HP к нему, пройденный уровень подтягивает его к hitp_max. Заодно реализована большая склянка (add_life, тип зелья 2: +1 к ПОТОЛКУ HP до 10) — на уровне 2 она есть, комната 20.
  • Мечhave_sword = level >= 2 (play_level, seg003:106), а не жёсткий ноль.
  • Чит Shift+L (seg000:698) — pop_next_level = pop_current_level + 1. NB: L без Shift занят отладочным «осторожным шагом вправо» (pop_ctrl KBD_DBG_STEPR); с Shift шаг тоже пройдёт, но уровень тут же сменится — на практике не мешает.

Проверено в MAME (2026-08-04): старт уровня 1 → Shift+L → уровень 2 рисуется правильно (комната 5, большие колонны — новые для нас тайлы 8/9 — на месте, дверь захлопнута, HP 3) → влево в комнату 4, страж на месте → Shift+L → уровень 3 (комната 9). То есть цепочка загрузок работает повторно, а не только один раз.

Что осталось по уровню 2 (не машинерия, а контент):

  • сквозное прохождение уровня 2 руками — приёмка, как L1-PASS;
  • выход через дверь уровня 2 (комната 23) вживую: путь тот же, что уже закрыт на уровне 1 (L1-EXIT), но не прогнан на этом уровне;
  • цвет стража из данных (guards_color, на уровне 2 их три разных) — у нас один атлас; отдельная задача, к переходам отношения не имеет.

DBG-CHEATS. Отладочные читы SDLPoP — разведка сделана, код не написан

Записано 2026-08-03 (по ходу охоты за BUG-GATE-PASS-1, отложено на потом). Мотив прямой: мост MAME теряет нажатия при быстрой отправке, поэтому подогнать Кида в нужную позу для отладки автоматикой сейчас нельзя — именно на это упёрлись BUG-LOOSE-2 и BUG-GATE-PASS-1.

Берём (дёшево и бьёт в наш класс багов):

  • [ / ] — сдвинуть Кида на пиксель влево/вправо. Оригинал — ../SDLPoP/src/seg000.c:1828:
    if (key_states[SDL_SCANCODE_RIGHTBRACKET] & key_state) ++Char.x;
    else if (key_states[SDL_SCANCODE_LEFTBRACKET] & key_state) --Char.x;
    
    У нас: коды PS/2 set 2 [ = 0x54, ] = 0x5B в pop_cheat.h рядом с KBD_CHEAT_KILL/IMMO/SWORD; обработка — в том же блоке читов roomtest.c (~строка 479), по фронту (*_prev, как у остальных), иначе одно нажатие уедет на десяток пикселей. Работает по Kid.x напрямую: геометрию персонажа в этом порте меняет не диспетчер (см. грабли L1-EXIT). Первый же потребитель — BUG-GATE-PASS-1: там надо снять Kid.x и Kid.curr_col в момент, когда решётка уже закрылась, а Кид ещё стоит на её тайле.
  • Shift+L — следующий уровень. Уже входит в L2-машинерию (../docs/levels_plan.md §2.4): ++pop_next_level и всё.

Остальные — оценка, а не обязательство:

Чит Вердикт
T — таймер не сейчас: таймера уровня у нас нет вообще (Фаза 6), чит пришлось бы делать вместе с механикой
F — остаток feather-fall вместе с Shift+W: ветка JMP_IF_FEATHER (опкод 0xF7) в play_seq есть, но не проверена ничем; индикатор без самого зелья бесполезен, а пара «включить + видеть остаток» закрывает ветку целиком. Перо — уровень 7, так что не срочно
Shift+F9 — quickload с тем же уровнем самое ценное и самое дорогое: это сериализация Char + room_modif всех комнат + trob'ов + стражей (levels_plan.md §4). Даёт воспроизводимый регресс «вот кадр, где баг» вместо ручной подгонки. Кандидат сразу после того, как заработают уровни

MEM-BANK5. Разгрузка W1/W2 новым банком кода

Вопрос 2026-08-04: «надо делать новый банк?». Да, и он лечит именно то, что жмёт. В нашей раскладке (MEMORY=huge, small-вариант) CODE и DATA живут в ОДНОМ 32-КБ пространстве W1+W2 — карта текущей сборки:

_CODE  0x4100..0xAA00   26880 Б
_DATA  0xAAD0..0xB9B0    3808 Б
_BSS   0xB9B8..0xBADA     290 Б
куча   0xBADA..0xBB00      38 Б      ← упёрлись сюда, добавляя отладку
стек   0xBB00..0xC000    1280 Б

Поэтому каждый килобайт кода, уехавший в банк, становится килобайтом, доступным данным. Отдельного «дефицита W2» у нас нет — дефицит один. (38 байт кучи не опасны сами по себе: malloc'ом мы не пользуемся, страницы берутся через mem_alloc_block. Опасно то, что следующая структура данных упрётся в стек молча.)

Резидентный код по модулям (из .sprinter-cc-roomtest/*.rel):

модуль _CODE как часто зовётся в банк?
pop_kid.c 6287 load_frame/play_seq — 2×/кадр (Кид + страж) частично: холодная половина (загрузка страниц спрайтов, pop_kid_load) — да; движок кадров — нет
roomtest.c 3893 main-loop нет (точка входа, зовёт всех)
pop_trob.c 2526 do_trobs 1×/кадр, но pop_trob_modif — горячий аксессор кандидат №1, если вынести аксессор в резидент
pop_level.c 2292 pop_level_tile — из pop_bg (банк 2) на каждый тайл нет: банк→банк на каждый тайл убьёт отрисовку
pop_ctrl.c 2189 user_control 1×/кадр кандидат №2 — дёшево и безопасно
pop_guard.c 923 1×/кадр нет смысла

Порядок действий, когда упрёмся: pop_ctrl.c → банк 5 (2.2 КБ, один banked-вызов за кадр), затем расщепление pop_kid.c на горячее ядро и холодную загрузку. Критерий кандидата — не размер, а частота вызова и отсутствие горячих банк→банк переходов; pop_level показывает, что большой холодный на вид модуль может быть горячим аксессором.

Брать по факту нехватки места, не заранее.


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

  • Звук (CBL-эффекты, Фаза 5 PORT_PLAN.md) — геймплей не блокирует.
  • Таймер уровня / HUD времени / меню / сохранения — Фаза 6.
  • T-1 (пики: перерисовка по причине) — отдаётся почти бесплатно после T-2, отдельно не окупается.
  • Отключение мыши на время игры и замена PRNG../docs/ideas_backlog.md (оба дают доли процента кадра).
  • OPT-1 (хирургический редрой шва) — решено НЕ делать, стоимость транзиентная; разбор в bug_closed.md.