Files
Sprinter-SDCC/applications/PoP/roomtest/TASKS.md
T
Александр Петров 774b1cc7c4 docs(PoP): документация к актуальному статусу + план следующих уровней
Документы отстали от кода: PORT_PLAN писал «PoC не начат», хотя играется
весь уровень 1, а четыре плана были исполнены целиком.

- PORT_PLAN: таблица статусов по разделам; фазы 0-3 сделаны, 4-6 нет;
  риски §8 п.1/п.3 закрыты, п.2 переформулирован под реальный движок
  (спрайтовый движок для персонажей не используется, лимит «21 спрайт»
  неприменим), п.4 — найдено расхождение таймингов: оригинал считает
  логический кадр за 5 тиков при BASE_FPS=60 (83.3 мс, в бою 100 мс), а мы
  ждём три vsync (60 мс) — игра идёт примерно на 39 % быстрее эталона.
- levels_plan.md — новый: машинерия перехода между уровнями, второй
  тайлсет (palace), потабличные различия и читы SDLPoP, которые окупаются
  сразу.  Инвентарь тайлов снят прямо с res200N.bin: уровень 2 не требует
  ни одного нового ассета и ни одной новой механики.
- roomtest/TASKS.md — новый: доска текущих задач с критериями готовности.
- Удалены как исполненные и перекрытые кодом: clip_char_plan,
  double_buffer_plan, loose_floors_plan, size_optimization_plan.  Его §8
  (замеры скорости отрисовки) не был перекрыт — перенесён в
  layout_plan_v2 §9, чтобы не потерять цифры.
- KID_PLAN / gates_spikes_plan — шапки «реализовано, оставлено
  справочником»; room_model_plan — «S1 сделан, остальное не срочно».
- docs/README.md стал индексом с отметками актуальности.
- ideas_backlog: зелье переворота экрана — оригинал переворачивает готовый
  буфер построчно, спрайты не трогает; по данным уровней тип 4 встречается
  только на уровне 9, до него механика не нужна.
- examples/scroll: ссылка на удалённый план вела к неверному факту
  «теневая копия одна — общая»; заменено на подтверждённое «у каждой
  страницы своя».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:32:48 +03:00

28 KiB
Raw Blame History

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

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

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


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

KBD-1. Shift + стрелки: нажатия теряются — определить причину

Симптом (пользователь, 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) из bug_list.md: не перерисовывать Кида, пока поза/координаты не менялись, — это минус 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. Значит это кандидат на «когда дойдём до реального железа», а не на сейчас.

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

  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. Вернуть вызовы в занятую треть кадра (они бесплатны, просто сами по себе ничего не решали).
  3. Перемерить тем же счётным методом уже в roomtest: цель — 25/25.

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

  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. Аудит блитов: где клип не нужен

Зачем сейчас. Кадр занят на ~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_kid.c:613,615) и pop_guard_heal() (pop_gdraw.c:65,66) зовут gfx_healвсегда с клипом, хотя gfx_heal_noclip существует и pop_bg.c:147 им уже пользуется по тому же тесту. Это heal 2–4 прямоугольников КАЖДЫЙ кадр; замер общего ядра — 11 658 тактов на heal 22×22. Тест onscreen у нас уже посчитан рядом.
  • pop_room_clip_borders() (pop_bg.c:1193,1194) — gfx_heal(0,0,320,…): noclip требует w,h ≤ 255, значит либо два куска по 160, либо оставить как есть (зовётся по гейту, только в кадрах падения — проверить, что гейт действительно редкий, прежде чем трогать).

Что обязано остаться с клипом (зафиксировать в комментарии, чтобы потом не «оптимизировать» повторно):

  • кромочные тайлы фона pop_bg.c:132 — тайл у края экрана режется по построению;
  • спрайты при straddle (kid_render_dx = ∓140, комната Кида ≠ отрисованной) и при падении ниже поля — фолбэки pop_kid.c:594,681, pop_gdraw.c:107.

Порядок работы: (1) выписать полный список вызовов gfx_blit*/gfx_heal* в roomtest с ответом «кто гарантирует on-screen»; (2) перевести то, что можно, на noclip по существующему тесту; (3) замерить кадр полосами бордюра ДО/ПОСЛЕ (PROF() уже в roomtest.c) и брейкпоинтом на конкретной функции — числом, а не «стало плавнее»; (4) make size-check.


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

L1-START. Старт по данным уровня

roomtest.c жёстко стартует START_ROOM 1 / COL 3 / ROW 0, хотя pop_level_start_room() / pop_level_start_pos() / pop_level_start_dir() в pop_level.h уже реализованы и НИКЕМ не вызываются. Перевести старт и респавн на них; #define ROOMNAV оставить, но выключенным по умолчанию.

L1-EXIT. Выход с уровня (дверь уровня)

Сейчас: дверь открывается (animate_leveldoor), но войти в неё нельзя — up_pressed() (pop_ctrl.c:188) не проверяет tiles_16_level_door_left, а опкод 0xF1 END_LEVEL в play_seq (pop_kid.c:418) пустой. Портировать up_pressed-ветку + go_up_leveldoor() (seg005.c:410..500) и завести pop_next_level, который взводит END_LEVEL (seg006.c:662). Это же первый шаг плана следующих уровней — см. ../docs/levels_plan.md.

L1-TRIAGE. Ревизия bug_list.md

Список отстал от кода: BUG-1/BUG-2 (боковой переход, ping-pong) закрываются pop_leave_timer + char_x_forward_edge (pop_map.c:1402..1432,1553), BUG-3 (окклюзия climb-up на кнопке) — фиксом tile_code_drawn от 2026-07-28, но все три по-прежнему стоят как Critical. Пройти их в MAME, закрыть подтверждённые, оставшиеся (BUG-CEIL-1/2/3, BUG-OCCL-1 — косметика окклюзии) переклассифицировать. Заодно закрыть таблицу обхода 24 комнат — она заполнена на 5 строк из 24, а инструмент (ROOMNAV) готов.

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: замедление кадра спрячет проблемы бюджета вместо того, чтобы их показать. Проверка — секундомером по одинаковому отрезку рядом с живым SDLPoP, не «на глаз».

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

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


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

  • Звук (CBL-эффекты, Фаза 5 PORT_PLAN.md) — геймплей не блокирует.
  • Таймер уровня / HUD времени / меню / сохранения — Фаза 6.
  • T-1 (пики: перерисовка по причине) — bug_list.md; отдаётся почти бесплатно после T-2, отдельно не окупается.
  • BUG-CEIL-2 (loose-плита в потолке из комнаты сверху) — требует персистентного per-room modifier соседей, это Фаза P0 из ../docs/gates_spikes_plan.md.
  • Отключение мыши на время игры и замена PRNG../docs/ideas_backlog.md (оба дают доли процента кадра).
  • OPT-1 (хирургический редрой шва) — стоимость транзиентная, решение от 2026-07-22 «оставляем».