Compare commits

..

153 Commits

Author SHA1 Message Date
snark13 aaa480d0f2 SprPoP: в CLAUDE.md — игра на образе лежит в D:\GAMES\SPRPOP
С обобщения HDD-сборки (ea8efdb) один образ рассчитан на несколько
приложений, и EXE переехал в подкаталог.  В инструкции по запуску это не
отразили, поэтому старая последовательность (`d:` + `sprpop`) отвечает
`Bad command or file name`, а `dir D:\` показывает только каталог GAMES —
на это можно потратить время впустую.

Записаны и сам путь, и исправленная цепочка клавиш для моста.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:41:40 +03:00
snark13 8548aa9132 SprPoP: кнопка под Кидом больше не заливается кладкой (палас)
Симптом (пользователь, 2026-09-02, уровень 4 комната 18): Кид
подтягивается на открывающую кнопку (0,4), и кромка, которая его
прячет, нарисована кирпичом вместо кнопки.

РАЗБОР ПО ЖИВОМУ КАДРУ (заморозка): Kid frame 139, x 123, col 4, row 1,
room 18 — то есть персонаж действительно под кромкой кнопки (0,4).
Отсюда путь: кадр подъёма -> climb_overlay_tile; тайл не floor-подобный
(кнопка) -> other_overlay_tile; сосед слева (0,3) пуст -> overlay_mid_tile,
полная перерисовка тайла ПОВЕРХ персонажа.

ПРИЧИНА.  draw_tile_base оригинала подменяет базу кнопки на «левую
половину без пола слева» (id 148) по ТРЁМ условиям (seg008:628): тайл —
opener, слева пусто И tbl_level_type[current_level] == 0, то есть только
в ПОДЗЕМЕЛЬЕ.  Третье условие у нас было потеряно ИМЕННО В ОВЕРЛЕЕ:
статическая отрисовка (pop_room.c, draw_tile_base) его имеет и даже
ссылается на ту же строку оригинала, а pop_bg.c — нет.  Уровень 4
дворцовый, поэтому оверлей брал спрайт 148, а в дворцовом наборе это
другая картинка.

Этим же объясняется, почему баг не виден «просто так»: пока тайл рисует
статика, кнопка правильная — кирпич появляется ровно в тот момент, когда
персонаж встаёт под кромку и включается оверлей.

Цена: +6 байт в банке 2 (8976 -> 8982, свободно 7402).

Проверять стоит на любом ДВОРЦОВОМ уровне, где у открывающей кнопки
слева пусто и персонаж лезет на неё снизу.  Живая проверка в MAME
пользователем: корректно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:41:29 +03:00
snark13 400f5cba63 SprPoP: поправка в CLIMB-VS-GUARD — звук 11 значит ПРОМАХ
В записи стояло, будто оригинал играет звук 11 «сразу на кадре укола, ДО
проверки расстояния», то есть при ЛЮБОМ уколе, попал тот или нет.  Это
было описание НАШЕГО кода, а не SDLPoP: ровно так звук у нас и стоял —
внутри ветки «не парировано» и до присвоения Opp.action = 99_hurt (снято
предыдущим коммитом).  Классическое нарушение правила «источник истины —
SDLPoP»: за оригинал приняли собственную реализацию, и вывод из неё уехал
в доску как факт.

На деле звук 11 в оригинале защищён условием Opp.action != 99_hurt, то
есть звучит ТОЛЬКО на промахе: на попадании играет боль, на парировании
Opp.frame уже 161.

Что это меняет для самого бага:

- УЦЕЛЕЛО наблюдение «в оригинале 11, у нас 8» — это данные.  Арифметика
  приоритетов их подкрепляет: snd_prio[11] = 0x12 против snd_prio[8] =
  0x4B (меньше значит важнее), взмах не мог быть заглушён упором в стену.
- ОТПАЛ вывод «оригинал ведёт стража атакой с промахом, а мы —
  столкновением»: он опирался на неверную посылку.
- УСИЛИЛАСЬ версия про ПОРОГ ДИСТАНЦИИ: раз в оригинале слышен именно 11,
  укол у стража СОСТОЯЛСЯ и ПРОМАЗАЛ.  Прежняя «версия про столкновение»
  вытеснила дистанцию зря — возвращаем её в главные подозреваемые.

Отдельно предупреждение тому, кто вернётся к багу: диагностика по звуку
теперь значит другое, старые заметки прогона будут вводить в заблуждение,
сцену надо переснимать.  Положена таблица соответствий (11 = промах, 13 =
попадание, 8 = столкновение, тишина = ранний выход из check_hurting).

На механику урона правки не влияют — расхождение «удар убивает» остаётся
открытым как было.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:41:07 +03:00
snark13 cb995bf0fc SprPoP: непрерывный взмах клинка у безоружного Кида
Симптом (пользователь, 2026-09-02): на подъёме Кида шёл нескончаемый
свист клинка, хотя меча у него нет.

ОПОЗНАНИЕ.  Звук снят с живой машины, а не угадан: snd_curr == 11
(sound_11_sword_moving).  Заодно проверено, что тракт исправен —
указатели насоса (pg 2, ptr 0x2B80, left 512) сошлись с записью 11 в
SND/snd.idx (страница 2, off 0x2880, длина 1280, конец 0x2D80) байт в
байт.  То есть играл честно заявленный эффект из своих данных, и виновата
была ЗАЯВКА, а не звук.

ПРИЧИНА.  Хвост check_hurting был портирован не до конца.  В оригинале
(seg002:1039..1044) звук 11 стоит В КОНЦЕ функции, после ОБЕИХ веток, и
защищён тремя вещами: ранним выходом по dir_56_none, кадром 154 и
условием Opp.action != actions_99_hurt.  У нас он стоял ВНУТРИ ветки «не
парировано», до присвоения Opp.action = 99_hurt, без проверки на
попадание и — главное — без гарда по dir_56_none, который автор SDLPoP
подписал прямым текстом: «Fix looping sword moving sound».  Направление
dir_56_none означает «персонаж выключен» (clear_char), махать ему нечем.

Наш движок эту константу знает и применяет в pop_guard_tick — в
check_hurting она просто не доехала.

ТОНКОСТЬ ПОРТА: хвост оригинала ПЕРЕЧИТЫВАЕТ Char.frame и Opp.frame у
персонажей, а не берёт снимки начала функции.  Ветка парирования только
что записала Opp.frame, а play_seq в ней — Char.frame; на кэшированных
cf/of условие дало бы неверный ответ.

Живая проверка в MAME пользователем: корректно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:40:45 +03:00
snark13 38fb3c03bb SprPoP: зацеп больше не срывается на кадре после захвата
Регресс от 4e12aa5: glide_through_wall_guard() звался в do_fall сразу
после check_grab и отменял ТОЛЬКО ЧТО СОСТОЯВШИЙСЯ зацеп.

Причина в имени последовательности: seq_15 — это grab_ledge_MIDAIR, и
после удачного захвата Char.action == 3, то есть персонаж формально всё
ещё «в воздухе», уже подтянутый вплотную к кромке.  А под кромкой в
комнатах оригинала стоит кладка.  Guard видел ровно её (замерено на живой
сцене: t == TILE_WALL, d == 10, col 0, row 1), считал это пролётом сквозь
стену, отбрасывал персонажа на 5 пикселей назад и гасил fall_x.  Зацеп
РИСОВАЛСЯ и тут же срывался — ловилось на длинном прыжке уровня 3
(комната 7) и в attract-демо (комната 2, прыжок с места на кромку 0,2,
после срыва Кид падал на пики).

ФИКС.  check_grab() возвращает признак «зацепился», и при нём guard не
зовётся.  Цена — один тест байта на кадр падения; фикс «падение сквозь
стену» цел, t_wall зелёный.

ПОЧЕМУ ПРЕЖНИЕ НАБОРЫ ЭТОГО НЕ ПОЙМАЛИ — и главный урок.  t_grab, t_phys
(1733 проверки) и t_wall судят по «действие стало вис», а вис-то
наступал, он просто не жил.  Первый A/B guard'а по этой же метрике дал
ЛОЖНО-ОТРИЦАТЕЛЬНЫЙ ответ, и подозрение с него было снято зря.  Разница
между «зацепился» и «зацепился и держится» — это и есть разница между
багом и нормой.

Отсюда новый набор t_hang: критерий — вис ДЕРЖИТСЯ три кадра подряд, и в
отчёте различаются «не наступило» и «наступило и сорвалось».  Окно — 34
фазы разбега из 41; при намеренно возвращённой поломке 0 из 41, тест
краснеет (проверено).  Сцена — геометрия уровня 3 комнаты 7, приведённая
внутрь одной комнаты, чтобы шов не примешивался; кромка-пол и
кромка-решётка проверяются отдельно.

Живая проверка в MAME пользователем: корректно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:40:23 +03:00
snark13 879f2bae31 size_baseline: принять atlas — эталон был записан со старым .map
atlas значился 9193 Б против 9627 фактических, и расхождение держалось
через все правки этой сессии: замерял со СПРЯТАННЫМИ правками — те же
9627, то есть к ним оно отношения не имеет.

Похоже на механику самого инструмента: size_check.py читает .map С ДИСКА,
а не пересобирает.  Программа, которую в тот раз не пересобрали, попадает
в эталон со СТАРЫМ числом — и дальше висит расхождением, пока её однажды
не соберут заново.  Сегодня после make clean впервые за долгое время
пересобралось всё, и накопленный рост libc/libbgi стал видимым разом.

Записываю честное число.  Полезный вывод на будущее: `make size-baseline`
имеет смысл только после полной пересборки тестов, иначе он фиксирует
смесь свежих и устаревших размеров.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 15:00:42 +03:00
snark13 b3f9a7430c SprPoP: тихие наборы звука и музыки в dist/
Выхлоп tools/pop_quiet.py на текущих наборах: те же snd.arc и m*.bin, но
вдвое тише (out = in/2 + 0x40, тишина 0x80 остаётся на месте), плюс
неизменённые индексы.  Раскладка совпадает с диском игры.

Это ПРОИЗВОДНЫЙ артефакт: восстанавливается из assets/packed одной
командой `python3 tools/pop_quiet.py` за секунды.  Лежит в репозитории по
той же логике, что и assets/packed — чтобы вариант был под рукой без
пересборки; если решим, что 3,6 МБ того не стоят, снимается одним
git rm --cached плюс строка в .gitignore.

На образ игры dist/ автоматически не попадает: make hdd берёт assets/packed
через build/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 15:00:12 +03:00
snark13 fee3bdb354 SprPoP: ALLOCS по умолчанию 10000; обёртка dist_to_edge — за своим хелпером
Правки пользователя, разобранные по диффу и закоммиченные как есть.

Makefile: умолчание --max-allocs поднято с 6000 до 10000 — плотнее
упаковка регистров.  Напоминание из шапки остаётся в силе: занятость
банков сравнима только при ОДНОМ значении ALLOCS, иначе сравниваются не
правки, а уровни оптимизации.

pop_map.c: банковая обёртка pop_dist_to_edge_weight() переехала НИЖЕ
статического distance_to_edge_weight(), который она зовёт, — раньше стояла
до его определения.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:59:56 +03:00
snark13 6a97124e0d sprinter-cc: --bank-data принимает номер банка
Правка пользователя, разобранная по диффу и закоммиченная как есть.

Было «всё или ничего»: --bank-data уводил писучие данные В СТРАНИЦУ для
ВСЕХ банков сразу.  Теперь флаг принимает необязательный номер и
повторяется: --bank-data 5 --bank-data 6.  Без аргумента поведение прежнее
(все банки), поэтому существующие сборки не меняются.

Зачем поштучно: данные в странице банка НЕ ВИДНЫ снаружи, поэтому модуль с
экспортируемым глобалом обязан остаться на общем _DATA в W1/W2 — а его
сосед в это же время может держать большой приватный буфер вне резидентного
бюджета.  Одним флагом на всю программу это не выражается.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:59:43 +03:00
snark13 8610a8c178 toolchain: предупреждать, когда ISR-стаб W0-страниц остался в W1
Правка пользователя, разобранная по диффу и закоммиченная как есть.

Программа может временно маппить свою EMM-страницу в окно 0 (атласы
спрайтов, gfx_w0_map).  Пока страница там, любое прерывание уходит на
#0038 ЭТОЙ страницы, поэтому gfx_w0_page_prepare прошивает туда переход на
_gfx_w0_isr.  Сам стаб живёт в _CODE, а в режимах small и huge _CODE
начинается с 0x4100 — то есть попадает в W1, окно, которое DSS перемаплет
на время СВОИХ вызовов.  Прерывание в этот момент уходит по адресу,
которого сейчас нет: W1 читается как #FF.

Ловится тяжело: собирается молча, проявляется недетерминированно —
зависанием примерно на каждом третьем холодном старте.  Поймано вживую
2026-09-01.

check_w0_isr.py берёт адрес __gfx_w0_isr из .map: символа нет — W0-путь не
слинкован, молчим; адрес >= 0x8000 — он в W2, всё хорошо; иначе WARNING.
Сборку не валит намеренно: свои страницы в W0 кладут не все.  Вызов
добавлен в app.mk рядом с check_bank_calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:59:26 +03:00
snark13 81e8ed4676 SprPoP: pop_quiet.py — тихая копия готовых наборов звука и музыки
Берёт то, что уже лежит в assets/packed, делает тише и кладёт в dist/ в
раскладке диска, перезаписывая прежнее.  Оригиналы не трогаются, поэтому
скрипт идемпотентен: сколько раз ни запусти, громкость упадёт один раз.

Формула — та, что предложил пользователь: PCM у нас 8 бит без знака с
тишиной 0x80, значит вдвое тише это out = in/2 + 0x40 (беззнаковый сдвиг).
Таблица на 256 значений строится floor-делением отклонения от центра, что
при gain = 1/2 совпадает с этой формулой байт в байт — то есть результат
можно сверять с реализацией на Z80 (srl a / add a,#0x40).  Тишина остаётся
тишиной при любом gain, клиппинга нет по построению.

Пересчитываются: MUS/m*.bin целиком (чистый PCM) и ТОЛЬКО тела записей
PBA1 в SND/snd.arc — заголовок и выравнивающие хвосты остаются как есть,
там нули упаковщика, и пересчёт превратил бы их в 0x40.  Индексы
копируются без изменений (длины те же), но с проверкой магии.

Прогон на текущих наборах: 10 записей архива и 22 трека, пик 128 -> 64,
размеры байт в байт прежние, заголовок PBA1 не тронут.

Тест закрепляет формулу и неприкосновенность служебных байт; make
test-tools — 45 тестов.  Раскладка dist/ добавлена в tools/paths.py, по
правилу «пути знает один файл».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:16:07 +03:00
snark13 8490288d79 libc/crt0: возврат из main завершает программу по-настоящему
Два бага одного пути завершения, оба видны только на железе.

1. ЗВУК ПРОДОЛЖАЛСЯ ПОСЛЕ ВЫХОДА.  cbl_close() закрывает СЕССИЮ, но не
гасит железо: bit7 порта 0x004E держит gfx_wait_vsync ради бита луча,
поэтому порт оставался включённым ("немой" режим), и CBL крутил свои 256
слотов уже под шеллом — тихо ровно до первой чужой записи в порт данных,
а дальше она зацикливалась.  Новый cbl_shutdown() гасит bit7 независимо
от держателей и центрует ЦАП обычного COVOX; pop_shutdown зовёт его
последним действием, а _cbl_open_raw регистрирует в atexit его, а не
cbl_close.

2. ЦЕПОЧКА atexit НЕ ВЫПОЛНЯЛАСЬ ПРИ ВОЗВРАТЕ ИЗ main.  crt0 уходил прямо
в ESTEX EXIT, то есть нарушал контракт C (возврат из main = exit(status)).
Молча терялись не только гашение звука и снятие vsync-ссылки, но и
_fclosall: буферизованная запись в файлы пропадала, если программа не
звала exit() явно.  Теперь crt0 после main дёргает _atexit_hook.

Косвенность обязательна: прямая ссылка crt0 на разматыватель притащила бы
его и стек хендлеров в КАЖДУЮ программу.  Указатель живёт в отдельном
data-модуле (два байта _DATA, ни байта кода), ставит его сам atexit() при
первой регистрации — нет регистраций, нет и кода.  Тот же приём, что у
_irq_cbl_hook.

Цена замерена: +14 Б всем программам (блок в crt0) и +64 Б тем
одиннадцати, что реально регистрируют хендлеры (CBL, файловые через
_fclosall, irqtest, gfx_dbuf, solidt) — у них раньше эти хендлеры были
мёртвым кодом.  Эталоны обновлены (кроме atlas: его +434 Б не отсюда,
замерен тот же и без этих правок).

Проверено в MAME: старт и звук как были, выход по F10 возвращает в шелл
чисто (текстовый режим восстановлен, зависания нет), повторный запуск
работает.  Пункт 1 проверяется только на железе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:15:48 +03:00
snark13 31d0075090 SprPoP: набор звуковых эффектов пересобран из MSDOS-оцифровки
make SND_SRC=msdos MUSIC_FMT=flac resources-rebuild — полная перегенерация
всех ресурсов.  Изменился только SND/: у MSDOS-набора оцифровка полнее,
чем у SDLPoP (там пуст звук 48 spiked и короче 10 sword_vs_sword — разбор
в docs/sound_plan.md).

Музыка и остальные архивы после перегенерации совпали с закоммиченными
байт в байт — набор flac и есть тот, из которого они сделаны.

Умолчание сборки НЕ меняется: SND_SRC ?= sdlpop, чтобы клон без
оригинального дистрибутива DOS собирался целиком (make fetch).  Вернуть
прежний набор — make resources-sound после make clean stamps, или
make SND_SRC=sdlpop -B resources-sound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 11:45:17 +03:00
snark13 6176f6da31 SprPoP: Restart Game возвращал заставку без музыки и вешал её на титуле
pop_boot_sound() был целиком под разовым флагом, а pop_sfx_start() стоял
внутри него.  До перестановки старта CBL открывал сам шаг 0 титров, и
делал это КАЖДЫЙ раз; теперь второй вызов уходил по раннему return, а
pop_title_show начинается с pop_sfx_pause() — значит после Restart Game
насос оставался выключенным и заставка шла молча.

Тем же корнем объясняется зависание: с закрытым CBL курсор трека не
двигается, pop_mus_left не убывает, и ожидания «дослушать тему» в титрах
и интро (while (pop_music_busy())) висели до нажатия клавиши — на экране
Prince of Persia.

- pop_boot_sound: под разовым флагом осталась только ЗАГРУЗКА (индекс
  звука, первая страница набора, индекс музыки, settings_apply);
  pop_sfx_start() зовётся всегда — он идемпотентен и уважает Ctrl+S;
- pop_music_busy(): признак теперь «звучит», а не «есть курсор» —
  спрашивает и про открытый вывод (pop_snd_ok).  Ждать неиграющую музыку
  нельзя в принципе, и эта строка закрывает весь класс подвисаний.

Проверено в MAME: уровень 1 -> ESC -> RESTART GAME -> титул с музыкой
(pop_snd_ok=1, курсор трека прошёл страницы 8..16, pop_mus_id=54), дальше
последовательность сама уходит в сцену с принцессой.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 11:45:02 +03:00
snark13 7377bf6a2c SprPoP: заставка и музыка сразу после запуска, а не через несколько секунд
От запуска до проявления титула проходило ~3 с чёрного экрана в тишине:
pop_boot читал ВСЕ ресурсы игры до первого пикселя, а pop_title_show перед
fade_in вычитывал целиком трек заставки (248 КБ).

Старт разбит на три фазы (sprpop_cold.c):
- pop_boot() — настройки, POP.CFG, графика, чёрный экран, FONT.ATL;
- pop_boot_sound() — snd.idx + ОДНА страница snd.arc, индекс музыки, CBL;
- pop_boot_rest() — остальные страницы звука, kid.ani, Тень, атласы Кида;
  идемпотентно, между кусками доливает трек.

pop_title_show теперь: fade_in -> pop_boot_sound -> pop_music_stream(54) ->
pop_boot_rest под стоящим титулом.  Порядок «сначала fade_in, потом тема»
сохранён как у show_title (seg000.c:1981).  Первый такт заставки укорочен
на фактически потраченное время (часы насоса 85,4 Гц -> кадры луча
сдвигами), иначе сцена уехала бы относительно музыки.  title_wait доливает
трек по полстраницы за кадр.

Музыка играет с ПЕРВОЙ страницы: pop_music_stream уже был (игровые
джинглы), кольцо won не понадобилось — m54 это 16 страниц из 20 доступных.

Звук поднимается в два приёма (pop_sfx_init_begin/finish): насосу для
тишины нужен ровно один блок, и упаковщик обязан класть его первым блоком
страницы 0 (tools/pop_idx.py) — значит для открытия CBL хватает ОДНОЙ
страницы (33 мс вместо 440).  Пока набор неполон, pop_sfx_play пропускает
эффекты; заявки на музыку проходят.

Четыре загрузки из boot УБРАНЫ, а не отложены: атлас фона, страж, уровень 1
и pop_pal_game_load — их и так делает pop_level_switch на входе в любой
уровень (а pop_new_game_load ещё и палитру перед ним), и оба маршрута в
игру, LEVEL_LOAD и DEMO, упираются в него.  Это ~0,77 с на ровном месте.
Так же устроен оригинал: init_game_main до заставки читает только меч,
пламя, звуки и палитры, а chtab_5/6/7 грузит load_lev_spr на старте уровня.
Страховка на случай пропуска заставки в первую секунду — идемпотентный
pop_boot_rest() в начале pop_new_game_load и pop_level_switch.

FONT.ATL остаётся в минимальной фазе, и это не про шрифт: снимок палитры
физически лежит в хвосте его страницы (pop_ui.c), а вся машинерия яркости
начинается с `if (!font_ready) return`.  С отложенным шрифтом заставка
возникала разом на полной яркости — поймано пользователем.

Проверено в MAME: титул виден сразу и проявляется полосами, pop_mus_left
убывает (трек стримится), pop_snd_pages=10 (набор дочитался), цепочка
титул -> интро -> пропуск -> уровень 1 работает.  Банк 8: 95,5 % (732 Б).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 11:30:53 +03:00
snark13 b34997073e libc/cbl: выключение CBL больше не оставляет железо петь одну ноту
cbl_close() не пишет в порт управления ноль: пока gfx_wait_vsync держит
ссылку ради бита луча (в PoP её берёт pop_pace_arm на всю программу),
bit7 остаётся включённым.  А буфер CBL (256 слотов) при включённом bit7
крутится по кругу и не чистится ничем — железо бесконечно поёт хвост
последнего сэмпла, тоном и громкостью по последней мелодии.  Пойман
пользователем на железе: Ctrl+S во время музыки и пропуск заставки
давали ноту до следующего cbl_open.

В MAME не воспроизводится: "немым" кодом частоты был reserved-код 2, у
которого divs[2]==0 и таймер не заводится вовсе.  На железе reserved-коды
не определены, ЦАП тактируется — тишина держалась на свойстве эмулятора,
а не железа.

Теперь тишину даёт СОДЕРЖИМОЕ БУФЕРА:
- _cbl_port_sync() после каждой записи в порт управления зовёт
  _cbl_prime(0x80).  Это закрывает и паузу звука, и включение bit7 ради
  луча на холодном старте (в буфере лежал мусор от прошлой программы), и
  полное выключение — при bit7=0 те же 256 записей уходят прямо в ЦАП
  обычного COVOX и центруют его, снимая щелчок;
- _CBL_VSYNC_FREQ переведён с reserved-кода 2 на документированный 8
  (7,8125 кГц): поведение определено и на железе, и в MAME, прерывания
  по-прежнему выключены (bit4=0), а бит 7 порта 0xFE трамплин смотрит
  только при живом хуке насоса;
- cbl_close() зовёт sync внутри той же DI-скобки, где снимает хук, иначе
  насос долил бы буфер уже после заливки.

Цена: +2 Б программам со звуком, +11 Б графическим (тянется _cbl_prime
следом за gfx_wait_vsync), 256 OUT'ов (~0,3 мс) на редкое событие —
эталоны cblstream/cbltest/cblwav/gfx_dbuf обновлены.  Рост atlas в
size-check к этой правке отношения не имеет (замерен тот же и без неё).

Проверено: кодоген _cbl_port.asm; SprPoP пересобран и прогнан в MAME
(титры → пропуск заставки → уровень 1, пейсинг по лучу жив).  Сам баг
проверяется только на железе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 10:55:15 +03:00
snark13 63bfd997a9 SprPoP: звук мигания, равномерные часы, полоса HP после рестарта, надпись ждёт мелодию
Четыре правки прогона 2026-08-31.  Две ПРОВЕРЕНЫ пользователем в MAME
(звук мигания, порядок «мелодия -> надпись»), две ждут проверки — образ
собран.

ЗВУК МИГАНИЯ (проверено).  Упаковщик научился синтезировать ноты PC-
спикера в обычный сэмпл: заголовок с темпом, тройки «частота + длина»,
меандр на нашей частоте вывода.  Берём из нот ТОЛЬКО номера, которых нет
ни в оцифровке, ни среди мелодий, — иначе синтез перекрыл бы музыку,
которую мы играем из MUS/.  На поставке SDLPoP это ровно один номер: 38,
сигнал под мигание «Press Button»; 31, 34 и 42 там пустые заглушки.
Громкость по слуховой проверке снижена вдвое (44 -> 22): на полном
размахе сигнал перекрикивал игру.  EXE не меняется — раскладка читается с
диска, набор занял те же 9 страниц.

НАДПИСЬ ЖДЁТ МЕЛОДИЮ (проверено).  Порядок оригинала: ветка мёртвого
(seg006:1351) на седьмом шаге выходит, пока звук играет, и «Press Button»
появляется только после музыки смерти.  Чтобы ожидание не было
принудительным, три быстрых пути (Ctrl+A, обе быстрые загрузки, пункты
меню) музыку глушат — оригинал при Ctrl+A делает то же (seg000:0617).
Обычная кнопка во время мелодии не действует: она ответ НА надпись.

ЧАСЫ (ждёт проверки).  Тик стоит столько кадров ЛУЧА, сколько их в кадре
режима NORMAL, поэтому FAST/FASTEST больше не ускоряют время.  Считаем
ФАКТИЧЕСКИ прошедшие кадры луча, а не ожидаемый делитель: логический кадр
не всегда укладывается в бюджет, и часы «по делителю» шли рывками (первый
прогон это показал — «несколько секунд быстро, потом притормаживание»).
Вклад одного вызова ограничен, иначе пауза и меню прыгнули бы вперёд.
Четыре новых теста: NORMAL не сдвинулся ни на тик (и вне боя, и в бою),
FAST и FASTEST держат реальное время.

ПОЛОСА HP ПОСЛЕ Ctrl+A (ждёт проверки).  Корень: счётчик считает
СТРАНИЦЫ, а тратился по КАДРАМ — между двумя вызовами переворота может не
быть, и оба прохода уходили в одну страницу, вторая оставалась с
делениями прошлого боя.  Теперь проход тратится только при смене
gfx_get_draw_page().  Плюс полная чистка всей ширины при инвалидации:
старая полоса могла заходить под статус-текст, где щадящая чистка её не
трогала; текст сразу перезапрашивается.

Все 16 наборов host-тестов зелёные, check_bank_calls чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 23:10:35 +03:00
snark13 decbec79de SprPoP: три находки прогона 2026-08-31 — звук мигания, часы в быстрых режимах, полоса HP после рестарта
Разбор без правок кода; все три отмечены как задачи по решению пользователя.

SND-SPEAKER-38 (TASKS_OPEN, P1).  Надпись «Press Button» мигает молча.
Наш код не виноват: pop_dead_prompt зовёт звук 38 на каждом появлении,
как оригинал, но слот 38 в наборе ПУСТ.  Причина — три параллельных
набора звука у оригинала: оцифровка (0-23, 44-49, 51), мелодии (24-43
частично, 50, 52-56) и ноты PC-спикера (весь диапазон).  Звук 38 есть
ТОЛЬКО среди нот спикера, поэтому провалился между нашими конвейерами.
Полная ревизия: не оцифровка и не мелодия — номера 31, 34, 38, 42, из них
31/34/42 пустые заглушки, реально звучит ровно один — 38.  Решение
выбрано пользователем: синтезировать ноты в PCM и класть в наш атлас;
формат разобран по спецификации Princed и записан в задачу.

TIME-SPEED (TASKS_OPEN, P1).  Часы уменьшаются на каждом логическом кадре
(как оригинал), но длину кадра у нас меняет режим скорости — в FAST
минута проходит на треть быстрее.  Разная длина кадра в игре и в бою есть
и в оригинале (поправка пользователя), поэтому замедление часов в бою не
трогаем; вопрос только в наших добавочных режимах.  Два варианта с
рекомендацией оставить как есть.

HP-BAR-RESTART (BUGS_OPEN).  После гибели и Ctrl+A (у нас это рестарт
уровня) на одной из страниц остаётся полоса по результатам боя.  Механизм
перерисовки на месте — счётчик страниц, все холодные пути его взводят.
Подозреваемый: пока висит статус-текст, стирание чистит только края и не
трогает середину, а полоса стража при большом запасе HP заходит именно
туда.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 22:31:32 +03:00
snark13 b0e7130d0b SprPoP: мёртвое тело больше не приземляется в присед, надпись смерти не залипает
Два независимых фикса, оба проверены в MAME.

1. ПРИЗЕМЛЕНИЕ МЁРТВОГО (симптом «убили Кида, а он сел этажом ниже»).
   Оригинал спрашивает у приземляющегося, жив ли он (seg005:0173): вся
   развязка «мягко / средне / разбиться» отведена ЖИВОМУ, телу — своя
   ветка (добить HP, звук падения насмерть, seq_22).  У нас развилки не
   было, и в глаза это не бросалось только из-за высоты: тело, сброшенное
   ударом с ОДНОГО ряда, набирает fall_y < 22 — урона нет, «последнее HP»
   не тратится, ветка «разбился» не выбирается никогда.  Труп уходил в
   мягкое приземление и садился (кадр 109).

   Цена — один тест байта на вызов land(), то есть на событие касания
   земли, а не на кадр.  Живой путь не изменился ни на операцию.

2. ЗАЛИПАВШАЯ НАДПИСЬ «Press Button to Continue».  Счётчик кадров смерти
   живёт снаружи главного витка и потому переживает возврат на заставку.
   Ответ игрока кнопкой его обнулял, а выход по таймауту (24 с молчания
   -> title) уходил мимо сброса.  Дальше счётчик оставался израсходованным
   на всю сессию, и в следующей игре ПЕРВАЯ же смерть мгновенно уводила в
   title, не показав надписи; лечилось только перезапуском программы.
   Сброс поставлен на входе в игровой маршрут — закрывает и остальные
   боковые дороги (выпадение за нижнюю границу, смена уровня).

Здесь же довезена связка находок 12/13 аудита: смерть безоружного у
обрыва уходит в свою последовательность (seq_81), а прижатие к полу
осталось страховкой для прочих веток — снять его целиком не вышло дважды,
подробности в комментарии guards.c.

Тесты: t_death дополнен обеими сторонами развилки (мёртвый обязан
разбиться, живой с той же высоты — сесть без урона), 18 проверок; все 16
наборов host-тестов зелёные.

В доску записан CLIMB-VS-GUARD: Кид подтягивается к стражу этажом выше —
у нас удар порой смертелен, в оригинале Кид срывается без урона.  Цепочка
засчитывания удара сверена с оригиналом и совпадает дословно, расходятся
входные данные.  Лучшая зацепка — ЗВУК: оригинал играет взмах клинка (11)
при любом уколе, до всякой проверки попадания, а у нас слышен упор в
стену (8) — значит страж не атакует, а сталкивается.  Набор звуков
проверен и не виноват.  Отложено по решению пользователя.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 22:17:38 +03:00
snark13 4e12aa50d1 SprPoP: падение сквозь стену больше не проходит
Портировано опциональное исправление SDLPoP (fix_glide_through_wall,
seg005 в do_fall).  В ванили персонаж, падающий после разворота в беге,
может оказаться внутри кладки и лететь «в стене» — баг оригинала,
воспроизведённый пользователем в игре и затем на host-тесте.

Решением 2026-08-31 фикс взят в ТЕКУЩИЙ билд: играбельность важнее
буквальности.  Реализация вынесена отдельной функцией
glide_through_wall_guard() в pop_map.c намеренно — при разделении
VANILLA/ENHANCED это готовая точка отвязки, достаточно не звать её в
ванильном режиме.

ПРОВЕРКА.  Набор t_wall был заранее написан так, чтобы сторожить ЧИСЛО
заходов в кладку: до фикса их было ровно два из четырнадцати стартовых
позиций, после — ноль.  Остальные 15 наборов (в том числе phys с 1733
проверками и grab) остались зелёными.  Живая проверка в MAME
пользователем: корректно.

Ожидание в тесте обновлено ОСОЗНАННО, прежнее число сохранено рядом
отдельной константой с пометкой «сколько было до фикса»: оно измерено, и
понадобится, когда появится режим VANILLA — там ожидание станет зависеть
от режима.

ЦЕНА: +57 байт в банке 3 (свободно 3043), резидент и куча не изменились.
По скорости попадание только на кадры падения: пересчёт колонки — одно
деление, дистанция до кромки считается лишь если персонаж действительно
внутри кладки.

Документы: в аудите находка 21 переведена в «портировано» с сохранением
исходного разбора; в vanilla_vs_bugfixed статус фикса стал ВЗЯТ, сводка
пересчитана (5 взято, 32 кандидата в ENHANCED).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 20:52:44 +03:00
snark13 c218e8b983 SprPoP: инвентаризация всех 43 фиксов SDLPoP со статусами
Документ vanilla_vs_bugfixed.md дополнен полным перечнем опциональных
исправлений оригинала, какие есть в SDLPoP, с их статусом у нас и
местом в коде, где решение принято.

Расклад: 4 взяты (дверь выхода, звуки ворот, перо только для Кида,
приоритеты звуков), 4 сознательно оставлены ванильными (падение на
стража, прыжок через стража, трюк 35, кровь скелета), 1 в работе
(падение сквозь стену), 33 не реализованы — кандидаты в ENHANCED.

ГЛАВНОЕ СЛЕДСТВИЕ: наш билд — это не VANILLA, а «ваниль плюс четыре
исправления».  При разделении режимов придётся пройтись по уже сделанным
отступлениям и распределить их, иначе текущее поведение нельзя считать
эталоном ни для одного режима.

ИСПРАВЛЕНА МОЯ НЕВЕРНАЯ ОЦЕНКА.  Ранее было записано, что фиксы,
требующие правки байткода seqtbl, у нас недоступны без переделки
конвейера данных.  Это неверно: байткод можно менять и у нас.  Лучший
способ — держать ОБЕ версии в одной странице EMM: kid.ani занимает около
4 КБ при странице в 16 КБ, так что обе помещаются рядом, а переключение
режима сводится к смене базового смещения — без патчей и с мгновенным
откатом.  У SDLPoP, к слову, рабочая таблица и неизменная копия
оригинала тоже существуют раздельно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 20:34:19 +03:00
snark13 a3aaa30e53 SprPoP: тесты смерти от меча (базовое поведение до правок 12/13)
КОД ИГРЫ НЕ МЕНЯЛСЯ.  Новый набор t_death — 9 проверок, всего наборов 16.

Заведён ПЕРЕД правкой находок 12/13, чтобы поймать деградацию: правка 13 в
изоляции уже ломала смерть (мёртвый оставался с ненулевой скоростью
падения, проваливался за нижнюю границу, игра уходила на рестарт, не
показав тела).  Поэтому проверяется ровно то, на что эти правки влияют:

* удар не в боевой стойке смертелен независимо от запаса HP;
* удар с мечом снимает одно HP, на последнем — убивает;
* ПЕРЕЖИВШИЙ удар ставится на пол своего ряда с нулевой скоростью падения
  (в оригинале это единственная ветка, где координата трогается);
* тело после смерти остаётся в своём ряду — и на ровном полу, и у самого
  обрыва (целевая сцена находки 12; после правки ожидание изменится
  осознанно).

По дороге тест дважды показал не баг движка, а мои ошибки в самой сцене:
обвязка выставляет признак «жив» только стражу, а урон применяется не
сразу — удар выставляет дельту, и HP меняет отдельный шаг кадра, как в
оригинале.  Оба раза чинился тест.

VANILLA/BUGFIXED: записано, что переключатель уже существует в настройках
и зафиксирован в положении VANILLA, второй заводить не нужно.  Отмечено
главное следствие — наш «ванильный» билд УЖЕ не чистая ваниль (часть
ванильных багов пофикшена), поэтому при разделении режимов придётся
пройтись по сделанным отступлениям и распределить их; отдельные фиксы
(падение сквозь стену) могут быть сделаны и в нынешнем билде.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 20:23:35 +03:00
snark13 90e304f071 SprPoP: тесты стены + задача VANILLA/BUGFIXED, находка 24 отложена
КОД ИГРЫ НЕ МЕНЯЛСЯ — правка находки 24 сделана и откачена.

ТЕСТЫ.  Набор t_wall вырос с одной проверки до трёх: добавлены проверка
X на каждом кадре падения (персонаж не должен оказываться внутри кладки)
и симметричный прыжок через провал с другой стороны.

Ключевое в них — форма ожидания.  Тест НЕ требует нуля заходов в кладку,
а сторожит их ЧИСЛО: сейчас ровно два случая из четырнадцати стартовых
позиций.  Это ванильное поведение оригинала, для которого SDLPoP держит
отдельное опциональное исправление; больше двух — значит правка сделала
нас хуже ванили, меньше — значит фикс кем-то портирован.  То есть тест
сразу готов обслуживать оба будущих режима.

По дороге тест дважды ловил не баг движка, а мою ошибку в самой сцене
(старт в пустой клетке; перелёт через площадку считался нарушением).
Оба раза чинился тест, а не движок.

НАХОДКА 24 ОТЛОЖЕНА.  Перезагрузка кадра в in_wall верна по букве
оригинала, но эффекта показать не удалось: все 15 наборов host-тестов
дают одинаковый результат до и после.  При этом правка не бесплатна —
маппинг окна и перезагрузка кадра на каждое выталкивание.  Платить за
недоказанное не стали.

НОВАЯ ЗАДАЧА: docs/vanilla_vs_bugfixed.md — поддержка двух поведений,
ванильного и с багфиксами.  Туда переехали находка 24, три опциональных
фикса SDLPoP (скольжение сквозь стену, прыжок над воротами, гобелен) и
готовый детектор из t_wall.  Открытые вопросы записаны: чем переключать
(возможно, объединить с уже существующим VANILLA/ENHANCED), цена
рантайм-проверки в горячем пути, что считать умолчанием, как гонять
тесты в двух режимах.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 20:13:25 +03:00
snark13 b55d4d11e3 SprPoP: аудит — правки 12 и 13 неделимы (проверено на живой машине)
Правка 13 (перенос прижатия к полу в ветки пережитого удара, как в
оригинале) была сделана в изоляции и СЛОМАЛА смерть: страж убивает Кида,
а вместо тела и паузы идут вспышка, стопкадр и мгновенный выход в
заставку.

Причина: у нас прижатие к полу работало КОМПЕНСАЦИЕЙ отсутствующей ветки
«убит и сброшен с уступа» (находка 12).  Без неё мёртвый остаётся с
ненулевой fall_y, физика ведёт его вниз, он пересекает нижнюю границу,
взводится pop_fell_out — и приложение уходит на рестарт РАНЬШЕ отрисовки,
поэтому тела не видно вовсе.

Оценка «чистое перемещение двух строк, риск низкий» была неверной.  В
документе исправлено: риск ВЫСОКИЙ, пока ветка 12 отсутствует; обе
находки — одна правка, и порядок внутри неё обратный: сперва добавить
seq_81 с экспортом тайловых запросов, убедиться, что смерть на краю
отыгрывается ею, и только потом снимать страховку.

Правка откачена, дерево вернулось к проверенному состоянию.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 19:57:42 +03:00
snark13 ca67895667 SprPoP: починена сборка host-тестов
tests/host не линковались: не разрешались pop_chdir_home (его тянет
pop_kboot из банка 10) и kbd_raw_keypad_as_ext (тянет pop_ctrl из libc).
Поломка предсуществующая — воспроизводится и на коммите до всех правок
этой сессии.  Ни файловой системы, ни клавиатуры в хостовых тестах нет,
поэтому обе заглушены пустышками в общей обвязке.

Теперь все пятнадцать наборов проходят: geom 3144 проверки, phys 1733,
char 72, grab 55, jaffar 44, shadow 45, app 58, cfg 51, demo 30, flow 27,
timer 28, cutscene 13, gate 10, mouse 17, wall 1.

Это условие для дальнейшей работы: логику движка снова можно проверять за
секунды, без сборки образа и ручного прохождения в MAME.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 19:57:42 +03:00
snark13 a5252b1c60 SprPoP: глубокое ревью находок А/Б — исправимость и цена по скорости
КОД НЕ МЕНЯЛСЯ.  Разбор одиннадцати находок рангов А и Б: что менять, во
что это обойдётся по скорости и памяти, каков риск.

СНЯТО ГЛАВНОЕ ПРЕПЯТСТВИЕ.  Обоснование двух упрощений (guards.c:1011 —
«нужны тайловые запросы от Char, а pop_map умеет только от Kid»)
УСТАРЕЛО: get_tile_at_char, get_tile_infrontof_char, get_tile_behind_char
и distance_to_edge_weight в pop_map.c уже работают от Char, они лишь не
выведены в заголовок.  Данные тоже на месте — pop_char_set_seq ставит
любую из 115 последовательностей, то есть seq_81 и seq_64 доступны без
единого нового байта.  Три находки упираются не в архитектуру, а в четыре
строки объявлений.

СКОРОСТЬ.  Места классифицированы по частоте вызова: play_seq и ИИ стража
— горячие, land/in_wall/bumped/hurt_by_sword — событийные.  Из
одиннадцати правок две УСКОРЯЮТ код (уходит условие из горячего цикла;
звук перестаёт играть в двух случаях из трёх), большинство бесплатны
(перестановка строк), и ни одна не требует переделки архитектуры.

Единственный конфликт со скоростью — отложенная побудка чомперов:
play_seq маппит страницу байткода в W0 один раз перед циклом, и звать
start_chompers внутри цикла значило бы снимать и возвращать окно на
каждый переход ряда.  Дешёвая замена: копить не один флаг, а битовую
маску рядов и разбудить их после цикла — теряться ряды перестанут, цена
в цикле нулевая.  Для стражей аналогично: не межбанковый вызов wall_type,
а копия таблицы в 32 байта в своём банке.

Порядок работ — от «одна-две строки, низкий риск» (13, 24) к тем, где
правка может компенсировать наши отличия в другом месте (1, 7).

Политика: для критичных фиксов скорость не вето — такие выносятся в
отдельный разбор с поиском дешёвого способа.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 18:31:34 +03:00
snark13 3a0e847353 SprPoP: аудит расхождений с SDLPoP — Кид, стражи, seqtbl, отрисовка
КОД НЕ МЕНЯЛСЯ.  Построчный разбор наших реализаций против оригинала:
26 позиций за пять проходов, каждая с рангом вероятности (А..Д), с
описанием «чем грозит» и сценарием проверки.

Расхождения группируются в три узла, и это главный вывод аудита:

1. СМЕРТЬ ПРИ АКТИВНОЙ ФИЗИКЕ — здесь все находки ранга А.  У нас смерть
   это флаг, а физика продолжает вести персонажа как живого: нет ветки
   «убит и сброшен с уступа» (оригинал выбирает её по тайлу позади),
   прижатие к полу в hurt_by_sword стало безусловным (в оригинале только
   для выжившего удара), в land лишний пересчёт колонки.  Этим
   объясняется наблюдение пользователя: заколотый на краю Кид доезжает
   этажом ниже и садится в присед.

2. ГРАНИЦЫ МОДУЛЕЙ — pop_map не отдаёт наружу тайловые запросы от
   произвольного Char, wall_type и загрузку кадра.  Три ветки упрощены НЕ
   по логике, а по доступности функций: отсутствующая ветка уступа,
   «стена впереди» сужена у стражей до одного тайла (оригинал считает
   преградой ещё ворота, верх двери, зеркало и чомпер), in_wall не
   перезагружает кадр.  Чинить это заплатками неправильно — сначала
   расширять интерфейс pop_map.

3. МОМЕНТ ПОБОЧНЫХ ДЕЙСТВИЙ — делаем то же самое, но раньше или позже:
   сброс fall_x, побудка чомперов (у нас отложена до конца play_seq),
   звук удара, перезагрузка кадра.  По отдельности мелочь, вместе — сдвиг
   состояния на кадр.

Восемь позиций СВЕРЕНЫ И СОВПАДАЮТ (диспетчер control, все 15 опкодов
seqtbl, control_with_sword, parry, swordfight, sword_strike,
check_sword_hurt, check_hurting, bumped_fall, таблицы кадров) — их не
нужно перепроверять.  Дважды по ходу работы едва не записана ложная
находка из-за чтения отфильтрованного вывода; отсюда правило: фиксировать
расхождение только после чтения обеих реализаций целиком.

Отдельно: второе наблюдение пользователя (падение частично в стене) —
у SDLPoP есть ТРИ опциональных фикса ровно про это, то есть в ванили баг
присутствует, и мы его намеренно повторяем.  Но найдены два места, где мы
можем быть хуже ванили (гард curr_row<=2 в do_fall и in_wall выше).

Незакрытое перечислено в файле: тела autocontrol_*, check_grab,
check_bumped_look_left, старшие биты байта клинка.  Также отмечено, что
ни одно найденное осознанное отличие не занесено в docs/impl_diff.md,
хотя правило проекта этого требует.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 18:25:02 +03:00
snark13 df5071a967 SprPoP: музыка без перелинковки — длины и длительности уехали на диск
Часть I плана music_runtime_index_plan.md (MI0..MI5).  gen/pop_music_tbl.h
и gen/pop_music_ticks.h УДАЛЕНЫ: длины треков и длительности реплик
читаются из MUS/mus.idx (формат PMI1, tools/pop_idx.py, тесты в
make test-tools).  Один и тот же sprpop.exe работает с любым из четырёх
наборов записей — sha256 бинарника при смене MUSIC_FMT не меняется.

ГДЕ ЖИВЁТ ИНДЕКС.  228 байт таблицы в W2 не положить (свободной кучи там
порядка двух сотен), поэтому индекс лежит в одной странице EMM, а в
резиденте от него два байта.  Данные в странице — со смещения 0x100:
gfx_w0_page_prepare пишет в неё стабы прерываний (0x38 и 0x66), и с нуля
они попали бы прямо в записи id 10 и 21.  Со смещением работает штатная
защита, а не запрет прерываний (тот же приём, что CFG_BASE в
pop_config.c).  Число страниц в индексе не хранится — считается из blocks,
чтобы не разъехалось.

ПАУЗА КОНЦА УРОВНЯ — СОСТОЯНИЕМ, А НЕ СЧЁТЧИКОМ.  pop_endmus_left и
POP_MUS_TICKS_32/41 удалены; главный цикл ждёт pop_music_active() —
«заявка лежит, идёт загрузка или трек звучит».  Одного busy мало: между
заявкой и первой нотой 190-230 мс (замер в sound_plan §9).  Прежний
счётчик закрывал эту щель ценой зависимости EXE от набора и жёсткого
делителя /4, который врал в режимах FAST/FASTEST (там логический кадр 3
кадра луча, а не 4).  Побочно исправилось расхождение с SDLPoP: при
выключенном звуке заявка не кладётся, и уровень меняется сразу, как в
оригинале (seg006:651 + seg003:387) — раньше игра держала пройденный
уровень лишние 12 секунд в тишине.

PV-СЦЕНА — на четырёх якорях (8 байт статики), которые считаются из
индекса при входе в сцену; прежние выражения шкалы не изменились.  План
предлагал протащить структуру времён через пять функций — для сцены,
которая идёт раз за запуск, это того не стоит.

ПАМЯТЬ.  За обе фазы резидент не вырос, а освободился: _CODE 23865 ->
23544, куча 239 -> 256 Б.  Банк 9 похудел на 118 Б (ушла pop_mus_tbl из
rodata), банк 11 — на длительности реплик.

ПРОВЕРЕНО В MAME: exe побайтово одинаков для flac и mt32; все 22 трека в
индексах различаются, и контрольные значения совпали с предсказанными
планом (m41 732->685, m50 831->867, m53 985->1044, m56 9865->10462
блоков, 78->82 страницы); на mt32 PV-сцена проходит целиком по его
длительностям; без mus.idx музыки нет, эффекты работают, игра проходима.

НЕ ПРОВЕРЕНО: потоковый m56 на 82 страницах — до финала надо дойти в
игре.  Единственный оставшийся пункт приёмки, отмечен в sound_plan §11.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 17:17:59 +03:00
snark13 2349481b86 SprPoP: звуковые эффекты без перелинковки — раскладка уехала на диск
Часть II плана music_runtime_index_plan.md (SI0..SI4).  gen/pop_sound_tbl.h
БОЛЬШЕ НЕ ГЕНЕРИРУЕТСЯ: раскладка набора читается из SND/snd.idx (формат
PSI1, писатель и разборщик — tools/pop_idx.py, 22 теста в make test-tools).
Один и тот же sprpop.exe работает с набором SDLPoP (9 страниц) и MSDOS
(10) — sha256 бинарника при смене набора не меняется.

Заодно умолчание источника эффектов переведено на SDLPoP (SND_SRC=sdlpop):
сборка обязана работать без оригинального дистрибутива DOS.  У кого он
есть, включает лучший набор явно — make SND_SRC=msdos (там полнее
оцифровка: в SDLPoP звук 48 spiked пустой).

Устройство: pop_snd_tbl/pop_snd_page/pop_snd_pages — резидентные данные
(pop_snd_data.c), тип и инварианты — рукописный pop_snd_tbl.h.  Записи
читаются ОДНИМ read прямо в таблицу, поэтому sizeof(pop_snd_ent_t) == 5
стало частью дискового контракта: проверяется статически и полем размера
записи в заголовке.  POP_SND_PAGES как compile-time размер набора исчез —
вместо него POP_SND_MAX_PAGES (вместимость, 16) и runtime pop_snd_pages.

Цена: таблица переехала из _CODE в _DATA, суммарный резидент почти не
изменился (куча 239 -> 229 Б); банк 8 +601 Б на чтение и валидацию.

Валидация не доверяет файлу: заголовок целиком плюс каждая запись
(страница, смещение, кратность блоку, непересечение с блоком тишины,
выход за последнюю страницу).  Последнее считается В БЛОКАХ — байтовый
адрес конца не влезает в uint16, а 32-битная арифметика на Z80 дорога.

НЕТ ИНДЕКСА — ЭФФЕКТОВ НЕТ, НО МУЗЫКА ИГРАЕТ.  Первая версия просто
возвращала ошибку, и игра становилась непроходимой: тишину льёт первый
блок набора, без набора CBL не открывался, а с ним вставала музыка (её
блоки считает тот же насос) — заставка ждала конца трека вечно.  Теперь
поднимается пустой набор с блоком тишины.  Заливается ровно 128 байт и
под DI: gfx_w0_page_prepare ставит в страницу IRQ-стабы, и заливка всей
страницы затирала их — первое же прерывание давало чёрный экран.

Грабли сборки: смена SND_SRC тихо давала неверный результат
(sdlpop -> msdos -> sdlpop оставлял чужой набор в assets/packed).  Причина
не в логике, а в секундной гранулярности mtime.  Лечение убирает время из
решения: смена варианта сносит stamp'ы своего семейства, а упаковка,
сборка архива и копия индекса делаются одним рецептом.  То же получила и
музыка (MUSIC_FMT).

Проверено в MAME: таблица в памяти совпадает с файлом из образа побайтово;
один EXE поднимает оба набора; отладочный --order reverse (30 из 31
записей отличаются от штатных) звучит правильно; битый индекс выключает
эффекты, не роняя игру; без индекса PV-сцена проходит с музыкой; Ctrl+S
работает в обоих режимах.  Разбор — docs/sound_plan.md §10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 16:14:53 +03:00
snark13 589894c50d SprPoP: автономность — внешние данные качаются, а не хранятся
В репозитории нет ни байта чужих данных, но есть знание, откуда их взять:
tools/fetch_orig.py качает SDLPoP (ресурсы + исходники-эталон) и записи
саундтрека, причём адрес архива музыки читает из самого SDLPoP
(ReadMe.amigaos4, секция «AUDIO IS SLOW/AWFULL»); запасной адрес вшит
константой.  Цели: make fetch / fetch-sdlpop / fetch-music / fetch-check /
fetch-list.

Версия SDLPoP пишется в .fetch.json вместе с манифестом sha256 всего
дерева.  По нему следующий fetch отличает НАШИ отладочные врезки
(POP_TRACE — покадровая трасса Кида, дампы палитры и спрайтов) от
нетронутых файлов и не сносит их молча: без --force каталог не трогается
вовсе, с --force старая копия уезжает в бэкап .cache/.

MSDOS/ не качается и НЕ НУЖЕН: уровни и оцифровка берутся из SDLPoP.

У SprPoP теперь свой .gitignore, написанный так, чтобы стать корневым при
выделении в отдельный репозиторий (пути от корня приложения, ничего про
applications/).  Из корневого .gitignore тулчейна SprPoP-секция убрана,
чтобы две копии не разъезжались.  Заодно закрылась дыра: шаблон
applications/*/*/*.exe не покрывал артефакты в корне SprPoP.

assets/orig/README.md выведен из-под игнора — без него в чистом клоне не
написано, откуда брать данные, а это и есть смысл затеи.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 16:14:12 +03:00
snark13 ea8efdb0fd SprPoP: обобщить HDD-сборку и очистить метаданные
Добавить общий каталог назначения для HDD и удалить локальную копию упаковщика.\n\nУбрать устаревшие generated-имена ресурсов, выводить число страниц Kid из kid.arc и ограничить звуковую таблицу горячим модулем.\n\nЗафиксировать планы runtime-индексов музыки и PCM-эффектов.
2026-08-30 16:16:06 +03:00
snark13 623199337e SprPoP: HDD-раскладка и пути от каталога EXE 2026-08-30 11:21:11 +03:00
snark13 602c3a20fa SprPoP: сняты последние глушения насоса — палитра на смене уровня и загрузки треков
Замер тем же способом (брейк на pop_sfx_fill + печать totalcycles, три окна
по 800 вызовов = 9 секунд каждое, с уже снятыми глушениями):

  период насоса   245 760 тактов (медиана во всех окнах)
  максимум        245 832 / 270 096 / 311 346 (1,00 / 1,10 / 1,27 периода)
  пропущено порций 0

Порция считается пропущенной, когда зазор доходит до ДВУХ периодов: сама
порция отдаётся железу за период до того, как она понадобится, поэтому
опоздание обработчика на 1,1 мс — джиттер, а не потеря.  Запас
десятикратный.

Поэтому сняты и оставшиеся места:
* палитра через BIOS на переходе БЕЗ катсцены (sprpop_cold.c) — то самое,
  где ловили скрежет 2026-08-25; в комментарии помечено, что при возврате
  скрежета возвращать надо именно сюда;
* массовые чтения треков и ресурсов под чёрным экраном (pop_intro.c):
  первая реплика PV, трек заставки между уровнями, «время вышло», ресурсы
  финала.

Осталось только то, что глушит звук ПО СМЫСЛУ, а не ради защиты: выходы из
сцен (pop_music_free + pause), уход в титры и в игру, выключение по Ctrl+S.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:01:27 +03:00
snark13 4ac3584bf9 SprPoP: идея «готовить следующий уровень под мелодию» — в бэклог, на дальнюю версию
Записана с оговорками, найденными при сегодняшнем разборе: загрузку придётся
разрезать на дисковую и палитро-экранную половины, шаг подкачки держать
полустраничным, проверить EMM-бюджет на два уровня разом.  Половина идеи уже
работает — трек заставки играет поверх загрузки (порядок оригинала, замер
насоса приложен в записи).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 18:55:31 +03:00
snark13 3bf28da8ee SprPoP: трек заставки переживает загрузку уровня — порядок как в оригинале
ЗАМЕР (MAME, 2026-08-28).  Брейк на pop_sfx_fill с печатью totalcycles, 500
подряд вызовов насоса через всю загрузку уровня 1->2 под звучащий трек 27:
медиана интервала 245 760 тактов, максимум 245 832 при дедлайне 251 000
(11,7 мс) — НИ ОДНОЙ пропущенной порции.  Загрузка уровня насос не морит.

Поэтому снята двойная заплатка:
* pop_level_switch больше не глушит насос перед pop_level_load_num;
* pre_cut_finish больше не досиживает трек на чёрном экране (это делалось
  только чтобы глушение не обрубило его на полуслове; ценой были ~8 секунд
  пустого экрана — трек 27 длиннее сцены: 10,7 с против 2,6 с).

Взамен восстановлен порядок оригинала (seg003:68-108, play_level):
катсцена возвращает управление сразу -> уровень грузится ПОД музыку ->
ожидание конца трека на чёрном экране (порт `while (check_sound_playing())`
+ stop_sounds) -> показ уровня.  Общая чернота теперь max(трек, загрузка), а
не их сумма, и трек не обрывается.  Ожидание со страховкой на ~20 с, чтобы
потоковый трек не подвесил переход.

Пропуск сцены по-прежнему обрывает музыку — как и было.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 18:53:44 +03:00
snark13 770b946a36 SprPoP: доски — SND-PACE-DEAD снят, PV-RENDER-BOUND исправлен
Обе записи закрыты сегодняшними правками: вторая шкала по насосу удалена
вместе с гонкой, которая её выбирала, а «сцена дороже бюджета» оказалась не
ценой кадра, а местом отсчёта интервала.  Исходные разборы оставлены под
заголовками — они объясняют, как искали.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:41:08 +03:00
snark13 89e9663753 SprPoP: сняты остальные глушения насоса — появление/закрытие меню, настройки, quicksave
Продолжение предыдущего коммита: заплатка стояла не в одном месте.

* открытие меню (snapshot палитры + затемнение фона + первая полная
  перерисовка) и закрытие (menu_erase — те же две полностраничные копии) —
  DI у копира бандами по 16 строк (~1,6 мс против дедлайна 11,7 мс), а
  палитровое затемнение идёт и в титрах с катсценами, где музыку не рвёт;
* сохранение POP.CFG и проба quickload — это десятки байт и open/close;
  прежняя осторожность «ESTEX уходит в диск надолго» относилась к загрузке
  НАБОРА страниц.  Подтверждение с поля: HOF пишется под звучащий «won»;
* quicksave/quickload (pop_qsave.c) — единственное снятое место, где по
  диску реально едут 16 КБ (~три периода насоса).  Помечено в комментарии:
  если на F6/F9 появится скрежет, вернуть pop_sfx_pause/start точечно сюда.

Дисковых глушений в живом звуке больше не осталось; те, что стоят на
загрузке уровня и наборов страниц, не трогали — там они по делу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:36:20 +03:00
snark13 ea07a8d7b0 SprPoP: меню больше не глушит звук на время перерисовки
ui_begin() звал pop_sfx_pause() перед полностраничной accel-копией, и это
слышно: pause закрывает CBL целиком (cbl_close), курсор трека стоит, и
короткая мелодия начала уровня замирала ровно на время перерисовки меню —
а на перемещении по пунктам это повторялось на каждом кадре меню.

Обоснование заплатки устарело.  Лист, который зовёт gfx_copy_page
(_bgi_scroll_rows_raw), режет DI бандами по 16 строк — ~1,6 мс против
дедлайна насоса 11,7 мс (85,4 порции в секунду), между бандами есть окно
прерываний.  Тот же полностраничный копир каждый кадр делают игровой цикл
и катсцены, и звук там не рвётся.  Скрежет, под который заплатка ставилась,
шёл от чтения насосом мусора и вылечен отдельно.

Парный pop_sfx_start() в ui_end оставлен: он идемпотентен и чинит вход в
меню при закрытом выводе (например сразу после загрузки уровня).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:29:04 +03:00
snark13 d23126983e SprPoP: музыка не замолкает в меню и в долгих фейдах
Насос CBL идёт из прерывания (и его запрос защёлкивается до подтверждения —
проверено по MAME irqack_cb), поэтому сам звук главному циклу не нужен.  А
вот ПОТОКОВЫЙ трек — титульная тема играется кольцом — дочитывается с диска
только в pop_music_service: страница ложится в слот, который насос уже
прошёл.  Этот вызов был лишь в игровом цикле, HOF и сценах, но не в меню и
не в фейдах, а там главный цикл стоит секундами — кольцо опустошалось, и
музыка замолкала до закрытия меню (жалоба пользователя).

Добавлено: ui_wait_frame() в pop_menu.c (обслужить музыку + ждать фронт,
заменил все ожидания кадра в меню) и pop_music_service() в fade_run и
transition_ltr (pop_ui.c) — темп тот же, 50 раз в секунду, как в игровом
цикле.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:18:44 +03:00
snark13 50570881f5 SprPoP: подгонка молнии под музыку снята — причина устранена
PV_MAGIC_LEAD двигал жест заклинания (замах, шаг назад, вспышка) на 100
тиков (1,67 с) раньше сценария: сцена была render-bound, шла ~49 тиков/с
вместо 60, а реплика играла по реальному времени — кода приходила раньше
молнии.  После перевода сцены на единые часы и блочную отрисовку подгонка
стала вредной: молния била больше чем на секунду РАНЬШЕ коды (проверка
пользователем).  Ставим 0; константу оставляем на месте — если запись
другого набора (mt32/ogg) разъедется, крутить надо её.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:28:17 +03:00
snark13 f25ed37d85 SprPoP: единые часы в сцене с Джафаром + конец уровня ждёт свою мелодию
1. СЦЕНА С ДЖАФАРОМ — ОДНИ ЧАСЫ, КАДРЫ ЛУЧА.  Было две шкалы, и выбор между
   ними делался гонкой на старте сцены: одна выборка pop_snd_tick через кадр,
   «успел ли диск раскрутить звук».  От прогона к прогону сцена шла то по
   тикам насоса CBL, то по кадрам луча, и кода реплики приходилась каждый раз
   на другое место картинки (наблюдение пользователя).  Насос был нужен
   потому, что кадр рисовался дольше своего интервала; теперь отрисовка
   разложена по интервалам (pv_restore_bg), и счёт кадров честен — ветка
   насоса убрана целиком.

2. ПОДКАЧКА ТРЕКА — ПОЛСТРАНИЦЫ ЗА ШАГ (pop_music_load_step).  8 КБ ≈ 16 мс
   влезают в кадровый интервал, целая страница (33 мс) не влезала и
   растягивала кадр сцены.  В сцене шаг остаётся безусловным (иначе реплики
   не успевали грузиться, memory pv_music_stall_regression) и оплачивается
   ровно одним интервалом.

3. КОНЕЦ УРОВНЯ ЖДЁТ МЕЛОДИЮ.  Оригинал (seg003:387, play_level_2) не
   сменяет уровень, пока `check_sound_playing()`: экран пройденного уровня
   живёт с анимацией факелов, пока звучит трек.  Мы уходили на смену сразу и
   обрывали мелодию на первых нотах.  Теперь ждём большего из двух:
   pop_music_busy() и счётчика pop_endmus_left по длине записи
   (gen/pop_music_ticks.h) — второе нужно потому, что при ВЫКЛЮЧЕННОЙ музыке
   busy ложен, а оригинал выдерживает паузу и молча.

Стартовый уровень возвращён на 1 (отладочный LEVEL=14 был только для замера).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:19:43 +03:00
snark13 3449f6f8c9 SprPoP: кадр катсцены — блоками по кадровому интервалу, а не «отрисовка плюс пять»
Катсцены шли ~8,2 fps вместо десяти.  Делитель тут ни при чём: у оригинала
cutscene_frame_time = 6 тиков по 1/60 с (reset_cutscene, seg001:527; его
зовёт load_intro прямо перед сценой) = 100 мс, у нас 5 кадров луча по 50 Гц
= те же 100 мс.  Причина в том, ГДЕ отсчитывался интервал: сначала рисовали
кадр целиком, и только потом ждали vsync и ещё четыре — то есть отрисовка
ПРИБАВЛЯЛАСЬ к делителю.  Полноэкранная gfx_copy_page стоит ~547 000 тактов
= 1,27 кадра, отсюда 6+ кадров вместо 5 (замер PV-RENDER-BOUND: 49 тиков/с
вместо 60).

Теперь отрисовка разложена на блоки, каждый из которых заведомо влезает в
кадровый интервал, и после каждого честно ждём vsync:
  фон верхняя половина -> vsync | фон нижняя половина -> vsync |
  актёры и декорации -> vsync (+ флип) | служебный блок (звук, подкачка
  трека) и добор до CUT_FRAME_VSYNC.
Фон восстанавливаем только по картинке (200 строк с POP_YOFF), а не по всем
256: сверху и снизу чёрная рамка.  Общий хелпер pv_restore_bg на все три
цикла — cut_run (сцены 8/9/12 и финал), pre_room_animated (2_6/4/12 и
time_expired) и intro_pv_draw_frame (сцена с Джафаром); последний теперь
возвращает 3 кадра вместо 1 (13 вместо 11 со вспышкой), вызывающий их и так
учитывал.

Сверка делителей с SDLPoP: у всех сцен 6 тиков = 5 наших кадров; плавает
только pv_scene (6 -> 8 -> 7, seg001:434/455) — это уже сделано кумулятивно
через pv_seq_period + POP_T60, и минимальный бюджет (5 кадров) больше трёх
съедаемых блоками.

Замер в MAME пока НЕ сделан: до финальной сцены на отладочном старте
LEVEL=14 не добраться (решётка перед комнатой 5 закрывается по таймеру, а
Ctrl-комбинации через MCP-мост до игры не доходят).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:55:27 +03:00
snark13 6dabe9b4b1 libc: пока raw-клавиатура открыта, не звать обработчик DSS — он крал наши скан-коды
Корень двух багов SprPoP (KBD-STUCK-WAIT, частично GRAB-KBD-TIMING) нашёлся
в исходниках DSS (docs/sources/Estex-DSS): обработчик прерывания DSS живёт в
IM1 по 0x0038 и ПЕРВЫМ ДЕЛОМ делает `CALL KEYSCAN`, а тот вычерпывает FIFO
SIO досуха.  Наш трамплин проверял «есть ли клавиатурный байт» один раз, на
входе в прерывание, а хвост кадрового пути уходил в DSS — значит скан-код,
прилетевший позже, доставался DSS и уезжал в его буфер.  Rx-overrun при этом
НЕ взводится (байт не потерян железом, а прочитан не тем владельцем) — отсюда
и загадка исходного диагноза: бит залип при `_kbdraw_overrun == 0`.

Измерено в MAME (брейки + totalcycles): окно 738 тактов (~34 мкс) на каждом
кадровом прерывании, из них 481 такт — пролог самого DSS.  Поэтому проверка
FIFO перед chain'ом снимает лишь треть и не годится (пробовали, кражи
продолжались); кадровый путь при открытом raw теперь заканчивается приватным
RETI, окно = 0.  Цена: на это время у DSS замирает опрос мыши и мигание
текстового курсора — зафиксировано в <kbd_raw.h>.

Пойманный случай (старая сборка): DSS прочитал 0x74 (make стрелки «вправо»)
при _kbdraw_pending = EXT, то есть посылку E0 74 разорвало пополам между
двумя владельцами канала.

Проверка: брейк на входе KEYSCAN с условием «страница точно DSS + raw открыт»
до правки срабатывал мгновенно (50/с), после — молчит; положительный контроль
на нашем RETI срабатывает сразу.

Трамплин 300 -> 310 Б (буфер W2-копии поднят 336 -> 384, запас 74 Б);
размерный эталон обновлён: +10 Б у программ, линкующих IRQ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:40:39 +03:00
snark13 a05970cd36 SprPoP: убрана устаревшая копия релизного дерева dist/SprPoP
dist/SprPoP/** — снимок готового к раздаче каталога (exe + упакованные
ассеты) от старой раскладки.  Ничто его больше не собирает: `make`
кладёт результат в build/, образ — в build/hdd/.  В dist/ остаются
только ИСХОДНИКИ README (README.txt / README.ru.txt), из которых
Makefile печёт README на образе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:00:22 +03:00
snark13 2176c12cc5 PoP: чистка архива roomtest + справка по клавишам оригинала
Замороженная roomtest: убраны рабочие заметки прошлых сессий
(NEXT_SESSION.md, «new bugs») и скриншоты уже закрытых багов;
в Makefile — PROF=0 по умолчанию и путь к HDD-образу.

applications/PoP/docs/keys.txt — раскладка управления оригинального
PoP (справочник для порта).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:00:15 +03:00
snark13 4e43890fce SprPoP: финал больше не убивает программу — прямой вызов в чужой банк
Пройденная игра доходила до таблицы рекордов и умирала: программа
исчезала, машина следом вставала намертво (di;halt на 0x0000) либо уходила
в reset.  Одинаково из Flex Navigator и из голого DSS.

КОРЕНЬ.  pop_ui.h объявлял группу pop_text_*_mapped БЕЗ __banked.  Пока
pop_hof.c лежал в банке 9 рядом с pop_ui.c, прямой call был верен; после
переноса pop_hof/pop_config/pop_pal в банк 10 тот же call стал уходить в
пустой хвост чужого банка.  Процессор полз по 0xFF до 0x0000, где ловушка
DSS ставит B=0x27 и сворачивает процесс — подмена страниц W1/W2/W3,
которую было видно на трупе, оказалась уборкой, а не причиной.

Точную инструкцию (call $E503 = _pop_text_map банка 9) дала трассировка
MAME на узком участке: trace включалась брейкпоинтом на входе в
pop_hof_show и выключалась на процедуре завершения процесса DSS (0x1E56).

ЧТО СДЕЛАНО

* pop_ui.h/.c — группа text_*_mapped помечена __banked.
* toolchain/check_bank_calls.py — две проверки банкового кода:
  1) прямой call в чужой банк (доказательна, ВАЛИТ сборку — проверено
     намеренной поломкой);
  2) указатель на данные своего банка, отданный в чужой (эвристика по
     форме кода, только предупреждает).
  Встроена в app.mk, запускается сразу после линковки.
* pop_hof.c — курсор ввода строится на стеке: литерал "_" лежал в _BANK10
  и после пометки __banked уезжал из-под ног чужому банку, заливая экран
  знаками вопроса.
* libc: kbd_raw_keypad_as_ext() — kbd_raw_sync переносит голые коды
  нумпада в EXT-половину карты.  Лечит залипание стрелок (потерянный
  префикс E0 сажал make в PLAIN как код нумпада, и снять его было нечем),
  заодно нумпад стал управлением: 7/8/9, 4/6, 2 и 5 = вниз.
* pop_pace.c — цикл ожидания луча зовёт тот же idle-хук, что и
  gfx_wait_vsync: без этого F10 в геймплее не работал вовсе.
* pop_hof.c — Esc в таблице рекордов отменяет запись (расхождение с
  оригиналом записано в docs/impl_diff.md).
* Экран версии показывается только через Menu/Settings/About: стартовый
  показ и Ctrl+V убраны, мёртвый код снят.
* sprpop_cold.c — pop_start_level зовёт pop_hp_invalidate: после Ctrl+A с
  выросшим за уровень максимумом полоса HP моргала между страницами.

Разбор всех четырёх багов — в applications/PoP/roomtest/BUGS_CLOSED.md
(FINAL-BANKCALL, FINAL-HOF-GARBAGE, KBD-ARROW-PHANTOM, F10-GAMEPLAY),
правило про банки — в applications/SprPoP/CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 22:11:01 +03:00
snark13 25b2db8b0b SprPoP: README на образе — в корень, а не в каталоги-однофамильцы
На готовом HDD-образе вместо двух файлов оказывались два КАТАЛОГА с их
именами, и README лежал внутри каждого.  Виноват генератор аргументов
упаковщика:

    $(foreach f,$(DISK),$(word 1,$(subst /, ,$(f))):$(BUILD_DIR)/$(f))

Он безусловно брал первое слово до слэша как имя каталога.  Для BG/bg.arc
это верно, но у записи БЕЗ слэша первое слово — всё имя, и README.TXT
превращался в README.TXT:build/README.TXT, то есть «каталог README.TXT,
файл внутри».  Теперь префикс подставляется только при наличии слэша;
причина записана в комментарий, чтобы следующий файл в корне не наступил
на то же самое.

Заодно имена: README_E.TXT и README_R.TXT.  Язык суффиксом, а не
расширением — прежний README.RUS в 8.3 укладывался, но терял .TXT, и
просмотрщик не открыл бы его как текст.

Проверено чтением готового .chd через chdman + mtools, а не по логу
сборки: баг был именно в том, что попадает на диск.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:12:12 +03:00
snark13 f76914849f SprPoP: колонки Controls сдвинуты вправо
Слева оставалось 6 свободных точек, справа 36 — блок выглядел прижатым к
краю.  Содержимое шириной 278 точек, сдвиг на +15 делает поля 21 и ~20.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:58:16 +03:00
snark13 6cc8611d03 SprPoP: README для игрока и экран Controls в две колонки
README, который едет НА ДИСК рядом с программой, в двух версиях: README.TXT
(английский) и README.RUS (русский).  Краткое описание, запуск, полная
раскладка управления, читы, файлы игры, чего не хватает против оригинала,
благодарности.

Русский обязан быть в CP866 — DSS и местные просмотрщики читают именно её,
UTF-8 показался бы кракозябрами.  Исходники лежат в dist/ как UTF-8 (чтобы
читались в репозитории), перекодировка и CRLF делаются правилом Makefile.
ПОРЯДОК В КОНВЕЙЕРЕ ВАЖЕН: sed ставит CRLF ДО iconv — BSD sed в UTF-8
локали отказывается работать с байтами CP866 («RE error: illegal byte
sequence»), а с валидным UTF-8 работает.  iconv БЕЗ -c намеренно: потеря
символа должна ломать сборку, а не молча портить текст.

ЭКРАН CONTROLS переписан в две колонки.  В одну раскладка больше не
помещалась: после фаз A-C клавиш стало вдвое больше, а по высоте есть
только 200 точек вместе с заголовком.  Слева игра (бег, лазание, бой),
справа служебное и читы; высота блока считается по ДЛИННОЙ колонке.
Строка «IN A FIGHT» — заголовок, а не клавиша: ниже неё те же стрелки и
Shift означают другое, и без разделителя список читался бы противоречиво.

Заодно экран перестал врать: там до сих пор висели P, I, U и F7/F8 —
клавиши, переназначенные ещё в фазе A.

Проверено в MAME: Backspace открывает меню, Settings -> Show key bindings
показывает обе колонки целиком.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:50:59 +03:00
snark13 659071838d SprPoP: KEYS-D, KEYS-R и KEYS-F12 — на доску отложенного
Три остатка плана keys_plan.md записаны в TASKS_OPEN.md с полным разбором,
чтобы не выводить его заново.

KEYS-D (осмотр соседних комнат) — главное, что стоит помнить: у SDLPoP это
три строки, потому что там drawn_room влияет ТОЛЬКО на отрисовку, а физика
ходит через get_tile(room, col, row) с явной комнатой.  У нас наоборот —
карта коллизий грузится для ОТРИСОВАННОЙ комнаты: room_fg, lcol_fg,
rcol_fg, above_fg, below_fg это ОДИН комплект на программу.  Уведи cur_room
к соседу, не трогая kid_room, и Кид считает столкновения по чужим тайлам.
Задел под расхождение уже стоит (kid_room отдельной переменной,
update_kid_render_dx со сдвигом ±140), но enter_room_side пишет обе разом —
это незакрытая часть S3 straddle.

Записаны оба пути с ценой: честный (правка ядра, дни) и смотровой режим с
остановкой игры (150-250 байт, часы) — плюс что главный риск не в
отрисовке, а в возврате.

KEYS-R (воскрешение) — четыре места, которые обязаны знать про окно
неуязвимости, со ссылками на seg-код.

KEYS-F12 (скриншот) — «возможно, когда-то», по пометке пользователя.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:38:22 +03:00
snark13 f8e96c0495 SprPoP: читы здоровья и пера — фаза C плана keys_plan.md
Shift+S — одна единица HP (малое красное зелье), Shift+T — поднять потолок
HP (большая склянка), Shift+W — медленное падение (зелье пера).  Механика
у всех трёх в движке уже была: читы просто ставят те же переменные, что и
подобранное зелье.

СВЕРКА С SDLPoP дала деталь, которую легко проглядеть: ветка ЧИТА и ветка
ЗЕЛЬЯ различаются.  Зелье в seg006:1871 зовёт stop_sounds, а чит в
seg000:838 — нет.  Повторяем чит, а не зелье.  Исключение — перо: там
stop_sounds сидит внутри самой feather_fall(), поэтому остаётся.

Вторая деталь: Shift+T НЕ проверяет, полное ли HP, — потолок растёт всегда
(упираясь в POP_MAX_HITP = 10, как max_hitp_allowed оригинала).  У Shift+S
условие hitp_curr != hitp_max есть и сохранено.

Модификаторы разведены: Shift+S делит скан-код с Ctrl+S (звук), поэтому
требует отпущенного Ctrl; Shift+T делит с голым T (таймер), но тот сам
требует отпущенного Shift.

Проверено в MAME по памяти, а не на глаз: Shift+T 3/3 -> 4/4, Shift+W
взводит pop_feather в 148, Shift+S на подпорченном отладчиком HP 2/4 -> 3/4.

R (воскрешение) НЕ входит: это не ещё один чит, а правка модели смерти —
окно неуязвимости, размазанное по кадровой цепочке (счётчик в seg003:512,
пропуск пик и челюстей в seg000:1245, пропуск урона мечом в seg000:876,
восстановление позы в seg006:1352).  Задевает гейт «мёртв», про который
memory pop_level_restart_scope прямо предупреждает.  Отложено по решению
пользователя.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:34:19 +03:00
snark13 76f02e76db SprPoP: раскладка управления — фазы A и B плана keys_plan.md
Приводим клавиши к keys.txt (раскладка SDLPoP).  Работа делится надвое, и
здесь только то, что не трогает игровую логику: переназначения (A) и три
мелкие функции поверх готовых механизмов (B).  Читы, требующие правки
логики, и осмотр соседних комнат — фазы C и D, отложены.

ПЕРЕНАЗНАЧЕНИЯ (A)

  U            -> Shift+I   переворот экрана; U отдан «комнате сверху»
  Esc          -> Backspace меню; Esc остаётся дублёром, как у SDLPoP
  F7 / F8      -> - / +     ±минута, основной ряд и цифровой блок
  - / +        -> Ctrl+- / Ctrl++  обход комнат (наш отладочный телепорт)
  I            -> Ctrl+I    бессмертие
  P            -> Ctrl+P    режим скорости
  1 и 2        -> Ctrl+F    стоп-кадр, теперь одной клавишей
  F10 в игре   -> Ctrl+Q    вторая клавиша выхода; F10 ловится глобально

Плюс новое на готовых путях: Ctrl+A — рестарт уровня, Ctrl+V — версия
сборки (функция была, её показывал только старт), Ctrl+D — отладочная
строка (тот же тумблер, что в Settings; POP.CFG не пишем), Home / Page Up —
дублёры диагональных прыжков (у SDLPoP это не отдельное действие, а те же
Up+Left / Up+Right, поэтому просто добавляются к стрелкам).

Все наши сверхштатные клавиши ушли под Ctrl, чтобы не занимать голые буквы
из раскладки, и вписаны в keys.txt отдельным разделом.  Ctrl+B намеренно
не занята: keys.txt держит её под «вернуться в комнату Кида» (фаза D).

ФУНКЦИИ (B)

  Space  «сколько осталось» (seg000:612).  Не печатает сама: поднимает тот
         же pop_show_time, которым пользуется автоматическое объявление
         минут, и строку собирает time_msg() — «59 MINUTES LEFT» и «11
         SECONDS LEFT» остаются в одном месте.
  T      постоянный показ таймера.  Переиспользует поле DBG_F_TIME
         отладочной строки, своего рендера нет.  У верхней полосы теперь
         три состояния, и отслеживается РЕЖИМ (0 нет / 1 таймер / 2 всё),
         а не флаг: переход «таймер -> полоса» тоже перерисовывает всё.
  Ctrl+R возврат в заставку — тот же переход, что «Restart Game» в меню.

ДВЕ ЛОВУШКИ, найденные по дороге

  Модификатор обязан входить в САМО значение, а не в условие блока: с
  `if (ctrl) { nav = ...; nav_prev = nav; }` при отпускании Ctrl кромка
  застревала ненулевой и следующее нажатие глохло.

  Один скан-код на два чита: Shift+I и Ctrl+I — это 0x43 в обоих случаях.
  По той же причине ±минута требует ОТПУЩЕННОГО Ctrl (иначе сработает и
  время, и обход комнат), а T — отпущенных Shift и Ctrl (Shift+T отдан
  «добавить HP» в фазе C).

Обработчики положены в pop_frame_ui (банк 8), а не в резидент: там куча
всего 308 байт.  Проверено в MAME: Ctrl+D поднимает строку
«Level 1, Room 1, Speed: NORMAL...», T — один таймер 59:30 без подписей,
Space — watchpoint на pop_show_time ловит запись значения 2 (именно
обработчик клавиши, автообъявление пишет 1).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:22:21 +03:00
snark13 c71981fdf9 SprPoP: pop_config и pop_hof — из банка 9 в банк 10
Банк 9 дошёл до 98,5% (247 свободных байт), и следующая заметная правка
в menu/status/ui упёрлась бы в потолок.  Критерий переселения тот же, что
у остальной раскладки, — ЧАСТОТА ВЫЗОВА, а не размер: POP.CFG и POP.HOF
работают раз за партию и упираются в диск, где один `open` стоит 51 мс,
так что трамплин банк→банк на их фоне не существует.

Отдельный банк заводить не пришлось: в десятом лежал один pop_pal.c на
485 байт, то есть 3% страницы.  Новой страницы в образе не появилось.

  банк 9:  16137 (98,5%) -> 11948 (72,9%), свободно 247 -> 4436
  банк 10:   485 ( 3,0%) ->  4674 (28,5%), свободно 11710

Переезд безопасен, потому что все публичные функции обоих модулей
помечены __banked: трамплин выбирается по пометке в объявлении, а не по
банку, и непомеченная функция пережила бы переезд только внутри своего
банка.  pop_settings — обычный глобал в W2, виден отовсюду.

Следующий рычаг, если понадобится: pop_menu (4745 байт, 29% банка 9,
работает только при открытом меню).  Трогать нельзя pop_ui, pop_status,
pop_music и pop_timer — они в кадровом пути.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:49:01 +03:00
snark13 808c2a5349 SprPoP: заставку можно прервать в любой её точке, F10 — выход
Три места, где нажатие раньше не работало или работало наполовину.

FADE.  fade_run() был замкнутым циклом на 2,13 с без опроса клавиш, а на
стыке экранов их два: пропуск работал внутри сценария, но не между его
шагами, и заставка ощущалась невыключаемой.  Добавлены прерываемые
варианты (pop_ui_fade_*_skip, обёртки в pop_pal) — отдельными функциями,
а не флагом в прежних: в меню паузы и на переходе уровня прерывать
нечего, и менять там поведение молча не следует.  Прерванный fade всё
равно доводит палитру до конца, экран не остаётся на промежуточной
ступени.  Подключено в интерпретаторе сценария, сцене с принцессой,
четырёх катсценах cut_*, титрах и таблице рекордов.

ПРОЯВЛЕНИЕ ПОЛОСАМИ.  pop_screen_present_ltr() была void и нажатие
ГЛОТАЛА: полосы схлопывались, картинка появлялась целиком — и всё,
вызывающий о нажатии не узнавал.  На заставке это выглядело как
«клавиша срабатывает наполовину».  Теперь возвращает признак прерывания,
и он проброшен по маршруту: первый экран истории, титры финала, логотип
между «свадьбой» и титрами.

Везде считается КРОМКА нажатия от входа, как в сценах: клавиша, которой
закончили предыдущий экран, ещё зажата, и принимать её за новое нажатие
нельзя — иначе весь маршрут заставки схлопывался бы сам собой.

F10 — НЕМЕДЛЕННЫЙ ВЫХОД, откуда угодно.  Проверка стоит в kbd_idle(), а
этот хук висит на gfx_wait_vsync, то есть вызывается везде, где программа
ждёт кадр: заставка, титры, fade, проявление полосами, меню, игра.  Одна
точка вместо десятка по циклам ожидания.  Флаг pop_quit_req резидентный —
взводится и читается без трамплина из любого банка.  Проверка в НАЧАЛЕ
витка автомата обязательна: заставку прерывает любая клавиша, и без неё
F10 успевал уронить программу в загрузку уровня перед закрытием.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:49:01 +03:00
snark13 31b82661eb SprPoP: автономное приложение, выделенное из roomtest
Порт PoP переехал в applications/SprPoP — приложение, которое собирается
само: код, оригинальные данные, конверторы ресурсов и сборка внутри одной
папки.  Наружу знает единственный путь — корень тулчейна (SPRINTER_ROOT,
по умолчанию ../..).  applications/PoP/roomtest ЗАМОРОЖЕНА и остаётся
архивом закрытых задач, багов и исполненных планов.

Скопировано из applications/PoP/roomtest@4b74478.  Перенос проверен
побайтово: собранный sprpop.exe совпал с roomtest.exe того же коммита,
все 39 дисковых ресурсов и все 16 генерируемых заголовков — тоже, host-
тесты зелёные (15/15).

Раскладка:
  src/           рукописный C (roomtest.c -> sprpop.c)
  gen/           генерируемые заголовки, в репозитории
  assets/orig/   оригинальные данные игры, вне репозитория (копирайт)
  assets/packed/ то, что ложится на диск, в раскладке диска
  tools/         конверторы; все пути — в одном tools/paths.py
  build/         выход: exe, каталоги ресурсов, hdd/, промежуточные atl/

Сборка ресурсов: assets/packed и gen — версионируемые ВХОДЫ, а не то, что
пересчитывается каждым make.  Автоматика построена на ОТСУТСТВИИ файла, а
не на таймстемпах: git не хранит времена, и в свежем клоне сравнение по
времени превращалось бы в лотерею.  Недостающий ресурс или заголовок
чинится сам, рекурсивным вызовом в ветку генерации.

Музыка собирается из любого из четырёх наборов записей (make music-mp3,
music-mt32, ...); набор входит в имя stamp'а, поэтому смена набора сама
делает музыку устаревшей.  Длины реплик больше не захардкожены: упаковщик
печатает их в gen/pop_music_ticks.h, и шкала сцены выражена через них —
иначе mt32 (реплики на 6% длиннее) молча ломал катсцену.

Тулчейн: в app.mk два обратносовместимых крючка (SRC_DIR/BUILD_DIR),
HDD_IMG стал ?=; команда сборки roomtest не изменилась.  Корневой
make host-tests переключён на SprPoP.

Подгонка тайминга катсцены с принцессой (PV_MAGIC_LEAD): сцена
render-bound и идёт ~49 тиков/с вместо 60, из-за чего кода реплики
приходила раньше молнии.  Это обход, а не лечение; разбор с замерами —
docs/BUGS_OPEN.md, записи SND-PACE-DEAD, PV-RENDER-BOUND, MUS-LEFT-TEAR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:12:28 +03:00
snark13 4b74478d19 Таблица рекордов на титрах проявляется собранной
Строки дорисовывались уже после полосового перехода: он копирует страницу
акселератором, а тот читает ОЗУ-копию, куда GFX_BANK_SPRITE (NOSHADOW +
TRANSPARENT) не пишет. Банк GFX_BANK_TRANSPARENT даёт ту же прозрачность
0xFF, но обновляет и копию, поэтому страницу можно собрать целиком до
показа — как offscreen оригинала (draw_full_image + show_hof, и только
потом transition_ltr).

Проверено в MAME: на промежуточных кадрах перехода строка рекорда видна в
уже проявившейся части экрана вместе с логотипом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:18:58 +03:00
snark13 747783c422 Реплики сцены с принцессой: вернуть подкачку в кадр
Регрессия 4086dde: тот коммит перенёс шаг постраничной подкачки «в паузу
кадра» — внутрь цикла ожидания тика насоса — и одновременно ввёл защиту от
промотки (snd_want подтягивается к snd_done). Вместе это убило подкачку:
паузы в этой сцене почти нет, цикл ожидания выходит сразу, шаг не
вызывается. Реплики Джафара не успевали загрузиться к своему тику,
pop_music_play() молча ничего не делал, и после первой реплики сцена шла
в тишине.

Замер в MAME (адреса статиков pop_music.c — от public _pop_mus_page по
смещениям из .sym): до фикса ld_next стоял на нуле 20 секунд при активной
загрузке; после — 11 страниц из 11, mus_ready=1, и звучат все три реплики
m50 → m53 → m52.

Фикс: один безусловный load_step в кадре сцены; шаг в паузе оставлен, он
бесплатный, когда время есть.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:00:16 +03:00
snark13 a3c5c600da Таблица рекордов: оба показа оригинала, ввод имени, переходы полосами
pop_hof.c был написан, но никуда не вызывался: после финала автомат уходил
мимо него в title, а на титрах экран получался чёрным. Теперь это хвост
end_sequence и шаг show_title между credits и attract-demo, как в оригинале.

Два корня пустого экрана:

- story.pal — 256 записей, и запись 0x3F (цвет глифов шрифта) в ней чёрная:
  текст рисовался, но был не виден. Генератор кладёт туда золотой 0xB7;
- полосовой переход копирует страницу акселератором, а тот читает ОЗУ-копию,
  куда прозрачный блит текста не пишет. Строки рисуются после перехода,
  прямо в видимую страницу.

Попутно:

- s5 в архиве PV — фон таблицы (рамка story + логотип на y=24, HOF_POP);
- отдельный индекс палитры под фон текстовой рамки: финал красит его в
  #800000, титры оставляют #100060 (load_title_images(bgcolor)). Прежний
  ремап в индекс 9 подменить было нельзя — им нарисована сама титульная
  картинка;
- второй набор глифов в font.atl цветом 0x3E: у SDLPoP шрифт маска и
  show_hof_text рисует текст дважды разным цветом, у нас цвет запечён в
  пиксели. Каталог SPA1 держит счётчик в байте, поэтому набор обрезан по '_';
- общий pop_screen_present_ltr(): им теперь пользуются story-переход интро,
  титры, таблица и титульная картинка финала. Заодно вернулся пропущенный
  шаг show_title — экран с логотипом и Jordan Mechner между «свадьбой» и
  титрами (BUGS_CLOSED#cutscene-ltr).

Формат POP.HOF — 6 записей, имя до 15 символов, версия 2. Проверено в MAME
на сборке LEVEL=14: HAIL → титульная картинка → таблица с полосой ввода и
временем справа → ввод имени → титульная картинка; на следующем запуске
таблица показывается между credits и demo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:41:41 +03:00
snark13 faec9a7d3a Финальная тема won: кольцевой стриминг с диска
Последний неозвученный кусок игры. 115 секунд и 1,2 МБ в память не влезают
никак, поэтому тема играется кольцом из шести страниц (96 КБ = 9 с): насос
идёт по кругу, а pop_music_service дочитывает файл в те страницы, которые
насос уже прошёл. Страница звучит 1,5 с и читается 33 мс — запас
сорокакратный; дистанция считается без отдельных счётчиков, потому что
страница это ровно 128 блоков насоса.

Три вещи, всплывшие при живой проверке:

- кольцо обязано сниматься при любом обычном запуске трека, иначе
  следующая тема играет по кругу первых шести страниц (поймано на титрах
  сразу после победы);
- живые сцены комнаты принцессы теперь открывают CBL сами (cut_begin):
  pop_ending_show глушит звук первым действием, и «arrived to princess»
  не звучал вовсе;
- тема дослушивается до конца с возможностью прервать клавишей — как
  seg001:637. У оригинала там стоит ввод имени в таблицу рекордов; когда
  он появится, ожидание переедет за него (задача HOF-ENTRY).

Проверено в MAME на сборке LEVEL=14: после встречи с принцессой звучит
тема победы (id 56, кольцо 6), курсор уходит далеко за размер кольца —
подкачка успевает. Host-тесты: 15 наборов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:32:40 +03:00
snark13 5d912f0f3f Доска: ARC-ATLASES закрыта, SND закрыта иначе (PCM вместо AY)
Обе задачи выполнены, но SND — не так, как планировалась: музыка пошла не
на AY, а тем же PCM через CBL. Исходные постановки свёрнуты в details,
сверху — что фактически сделано и какие грабли попались.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:04:37 +03:00
snark13 4086dde1f1 Сцена с принцессой: убрана промотка кадров; GAME PAUSED чистит полосу
ПРОМОТКА. Подкачка следующей реплики (страница — 33 мс) и любой тяжёлый
кадр оставляли часы насоса впереди расписания, и цикл гнал кадры без
единого ожидания, пока не наверстает: пламя факелов мелькало, а события
соседних тиков слипались в один кадр — заявка на звук перезаписывала
заявку, и створка ворот или дверь покоев пропадала. Отсюда же ощущение,
что музыка до появления Джафара тянется дольше нужного.

Долг больше не наверстываем — тот же принцип, что у pop_pace_end: якорь
ставится по факту. Плюс сама подкачка перенесена ВНУТРЬ паузы кадра: 33 мс
диска укладываются в ожидание (кадр сцены — 133 мс) и к длительности кадра
не добавляются.

GAME PAUSED писалось поверх полосы, не очищая её: если в строке HP висело
сообщение игры («60 MINUTES LEFT», «QUICKSAVE»), две надписи ложились друг
на друга. Полосу теперь чистит pop_status_wipe.

Host-тесты: 15 наборов. Проверено в MAME: сцена с принцессой идёт ровно,
принцесса проявляется вместе с комнатой, GAME PAUSED чистое.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:01:48 +03:00
snark13 0b15ed7b8f Разгрузка W1: смена уровня и служебный слой — в банк 8
_main занимал 3658 байт резидента, и добрая половина этого — события «раз
в партию». Вынесены целиком:

- pop_level_change — смена уровня (катсцена, гашение, загрузка, палитра,
  звук): 61 строка, наружу осталось одно условие и вызов;
- pop_frame_ui — меню, F10 и QuickSave/Load: 58 строк, плюс туда же уехали
  edge-состояния клавиш и сами коды клавиш (снаружи они больше не нужны).

_CODE 24102 -> 23626, запас W2 вырос с 86 до 559 байт.

Заодно три правки по замечаниям:

- затемнение фона под меню считалось числом «2», а шкала ступеней выросла
  с 4 до 32 — фон почти не гас. Теперь доля от шкалы (POP_PAL_DIM_BLACK/2);
  тем же прошлись по остаткам pop_pal_apply(4);
- в катсценах персонажи появлялись только после fade in: первый кадр
  рисуется ПОД чёрной палитрой, и картинка проявляется целиком, вместе с
  актёрами (cut_scene_8/9/12_short/ending, pre_room_animated,
  intro_pv_animated);
- отладочная полоса по умолчанию выключена.

Host-тесты: 15 наборов. Проверено в MAME: меню открывается и закрывается,
фон под ним затемнён, полосы нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:42:56 +03:00
snark13 f66fd0e1b6 Музыка на прыжке и на плите: SUB затирал A в звуке тряски
Watchpoint на pop_mus_req поймал виновника: заявку ставил pop_sfx_play с
id 27, а звал его loose_shake — звук дрожащей плиты.

Классический sdcc_z80_cmp_store_a_bug. Было:
    do { id = prandom(...) + 20; } while (id == last_loose_snd);
    last_loose_snd = id;  pop_sfx_play(id);
собиралось как
    ld a, e / add a, #0x14      ; A = 20..22 — номер сэмпла
    sub a, (hl)                 ; A = РАЗНОСТЬ, номер потерян
    ld (_last_loose_snd), a     ; сохраняем разность
    jp _pop_sfx_play            ; играем разность
То есть в звук уходил не сэмпл тряски, а id минус предыдущий id. Пока
такие «номера» попадали в пустые слоты набора, это молчало; с приходом
музыки мусор вида 22-251 = 27 стал запускать ТРЕК — отсюда музыка на
прыжке с уступа и на падающей плите.

Обход тот же, что в pop_status и pop_cdraw: записать ДО сравнения и играть
перечитанное из памяти. Проверено по сгенерированному asm.

Вторая линия обороны: pop_sfx_play принимает музыкальную заявку только в
диапазоне оригинала (24..43). Случайный мусорный id теперь молчит, а не
играет минуту музыки.

Проверено в MAME: тем же бегом, что раньше ловил заявку 27, watchpoint
больше не срабатывает. Host-тесты: 15 наборов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:04:55 +03:00
snark13 e5179af9d8 Музыка звучала там, где оригинал играет эффекты
Три места, все — наши заявки на треки, поставленные без условий оригинала.

Вступление первого уровня (25) оказалось не «звуком при приседе», а
автоматом (seg005:02EB): пока need_level1_music не ноль, control_crouched
НЕ читает управление — Кид сидит, играет тема, и только когда она смолкла,
он встаёт. Мы играли трек при любом первом приседе, и тема догоняла игрока
посреди уровня: пробежал, спрыгнул, присел — заиграла. Теперь автомат
портирован целиком, а «ещё звучит» спрашивается у курсора насоса
pop_mus_left (если музыка выключена, курсор нулевой и Кид просто встаёт).

Демо-уровень: оригинал молчит музыкой и там, где играет её в игре. Смерть
Кида — прямое условие `current_level != 0 && != 15` (seg006:1366), убитый
страж — отдельная ветка «беги из комнаты» без звука (seg006:1929). Плюс
общий гейт в pop_music_service: заставочная демка озвучена одними
эффектами, и любой новый музыкальный повод (меч, зелье) звучал бы там, где
оригинал их не играет.

Гейт живёт в банке, а не в главном цикле: в резиденте W1 оставалось
27 байт.

Host-тесты: 15 наборов. Проверено в MAME: на старте первого уровня тема 25
отыгрывает целиком, курсор доходит до нуля.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:41:07 +03:00
snark13 06a1772011 Fade: 32 ступени яркости — на глаз неотличимо от оригинальных 64
Шестнадцать ступеней на 2,13 с давали различимую лесенку (0,13 с на шаг).
Тридцать две меняются каждые 66 мс — это уже слитное затухание. Запас на
них есть: ступень стоит 487 тысяч тактов, тридцать три съедают около сорока
кадров из ста шести, остальное цикл ждёт луча.

Длительность каждого fade остаётся оригинальной (128 тиков), поэтому сумма
«fade in + сцена + fade out» совпадает с SDLPoP без правки самих сцен.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:21:55 +03:00
snark13 5ede8b9045 Fade: пересчёт палитры в пять раз дешевле, шестнадцать ступеней
Затемнение длилось около восьми секунд вместо заказанных двух. Причин
две, и обе измерены в MAME.

Первая: пересчёт палитры звался на КАЖДОМ кадре — сто с лишним раз за
fade, хотя ступеней всего восемь. Теперь пересчёт идёт только при смене
ступени, остальное время цикл просто ждёт луча.

Вторая: сам пересчёт стоил 2,44 млн тактов — почти шесть кадров.
Разложение показало, что заливка палитры через BIOS тут ни при чём
(136 тысяч на восемь вызовов). Съедали два цикла на C: 768 умножений
uint16 на канал и побайтовое копирование снимка из страницы шрифта.
Умножения заменены таблицей яркости на стеке (256 сложений, без единого
умножения), копирование — memcpy, то есть LDIR. Итог: 487 тысяч тактов,
в пять раз меньше.

Освободившийся запас потрачен на плавность: ступеней теперь шестнадцать
вместо восьми. Длительность fade считает POP_FADE_FRAMES — из 106 кадров
(2,13 с оригинала) вычитается то, что съедает сам пересчёт.

Host-тесты: 15 наборов. Проверено в MAME: заставка проходит цепочку
кадров штатно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:14:55 +03:00
snark13 0db4f94707 Катсцены: fade длиной как в оригинале, музыка дослушивается до уровня
ДЛИТЕЛЬНОСТЬ. fade_in_1/fade_out_1 у оригинала — 64 шага палитры по два
тика, то есть 128 тиков = 2,13 с каждый; сцена перед уровнем 2 идёт с ними
около семи секунд. Наши четыре кадра укладывались в восемь сотых секунды,
и сцена выходила втрое короче. INTRO_FADE и TITLE_FADE теперь POP_T60(128).

Ступеней яркости стало восемь вместо четырёх (7/8, 3/4, 5/8, 1/2, 3/8,
1/4, 1/8, 0 — каждая парой сдвигов, умножения на Z80 не нужно): растянуть
четыре ступени на две секунды значило бы получить четыре скачка яркости.

МУЗЫКА. Прошлый коммит отдавал трек доигрывать уже в игре — так делает
оригинал. На слух вышло хуже: музыка спотыкается, потому что загрузка
уровня не даёт насосу долить блок вовремя (у DOS-версии такой проблемы
нет). Дослушиваем под чёрным экраном, до отрисовки уровня — тем более что
следующий load_intro у оригинала всё равно начинается с ожидания тишины
(seg001:681). Пропуск сцены обрывает и музыку.

Заодно: WAIT перед PV пересчитан (584 вместо 707 — наш fade больше не
короче), а тема титров пускается ПОСЛЕ fade_in, как в show_title.

Host-тесты: 15 наборов. Проверено в MAME: плавное появление титров.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:47:16 +03:00
snark13 4a939e42f8 Катсцены: тайминги, часы и «время вышло» — по оригиналу; тумблер музыки
ТАЙМИНГИ. Треки заставок между уровнями длиннее самих сцен (27 — 10,7 с
против 2,6 с картинки), и оригинал их не обрывает: load_intro гасит экран
и возвращает управление, а звук продолжает играть поверх загрузки уровня
и первых секунд игры (seg001:690). Мы глушили CBL на выходе — музыка
обрывалась на полуслове. Больше не глушим.

«ВРЕМЯ ВЫШЛО». Оригинал (seg001:04D3) не зовёт ни одного init_*: на
экране пустая комната и ОСТАНОВИВШИЕСЯ часы (state 7, струйки песка
нет). Мы показывали готовую композицию с принцессой и полными часами —
противоположное по смыслу. Теперь это живая сцена без персонажей,
102 кадра, и трек 36 дослушивается под чёрным экраном, как в оригинале.

ПЕСОЧНЫЕ ЧАСЫ. Сцена «времени мало» (cutscene_12) шла статической
композицией, куда упаковщик запёк res953 — ПОЛНЫЕ часы. Отсюда и
наблюдение «чем меньше времени, тем полнее часы»: картинка была одна и та
же независимо от таймера. Сделана живой (cut_scene_12_short: принцесса
стоит правее, через два кадра оборачивается), часы берутся от
pre_hourglass_state. Заодно: при state 7 струйка песка не рисуется.

МУЗЫКА — СВОЙ ТУМБЛЕР. Settings -> MUSIC, Ctrl+M и статус в debug bar.
Флаг pop_mus_want отдельный от звукового: музыка у нас поток с диска, и
выключают её по другим причинам, чем эффекты; выключение обрывает текущий
трек, эффекты продолжают звучать. Строка-заглушка GAMEPLAY PROFILE
(«VANILLA ONLY») уступила место MUSIC — на десятую строку экрана не
хватает. Подписи тумблеров в debug bar сокращены до S:/M:/I:.

БАГИ. После меню персонаж оставался невидимым до первого движения:
снимок слота продолжал утверждать, что кадр на странице уже нарисован.
Общая pop_cd_forget() теперь зовётся после меню, QuickLoad и переворота
экрана. «QUICKLOAD» мигал через кадр — pop_status_show_now снимал бит
видимой страницы, а её успевало переписать восстановление комнаты.

Host-тесты: 15 наборов. Проверено в MAME: debug bar, выход из меню.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:28:36 +03:00
snark13 0086ac80c2 Таблицы анимации Кида: kid_data.bin -> KID\kid.ani
Все ресурсы Кида теперь зовутся kid.*: kid.arc (атласы), kid.pal
(палитра), kid.ani (кадры + seqtbl). Расширение .ani говорит о
содержимом: это не «какие-то данные», а таблица кадров и байткод
последовательностей движения.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:02:14 +03:00
snark13 7e6b38cca6 Музыка по ходу игры: смерть, зелья, меч, конец уровня, катсцены
Звуки 24..43 в наборе оцифровки пустые — в оригинале это Adlib-музыка, и
в digisnd её нет. На этом и построено подключение: pop_sfx_play для звука
с нулевой длиной кладёт номер в pop_mus_req, а разбирает заявку
pop_music_service() — один вызов на кадр из любого цикла (игра, интро,
катсцена). Всё чтение с диска живёт там.

Ждать полной загрузки джингла нельзя — это фриз посреди игры. Поэтому
pop_music_stream читает первую страницу (33 мс) и сразу пускает трек: она
звучит 1,5 с, а следующая читается те же 33 мс. Остальные доливаются по
одной за кадр.

Расставлено по местам оригинала: смерть (24/28/32), вступление первого
уровня и тень шестого (25), сцены перед уровнями (27/35/40), встреча с
Джафаром (29), зелья (30/33/39), время вышло (36), меч и смерть стража
(37), смерть Джафара (43), конец уровня (41/32), встреча с принцессой
(26). Темы «раз за уровень» сбрасывает pop_music_level_start.

Упакован 21 трек (2,4 МБ). Не озвучен только финал: won — 115 с и 78
страниц EMM, ему нужен кольцевой стриминг (задача MUS-WON на доске).

Проверено в MAME: вступительная тема первого уровня отыгрывается на
старте (курсор дошёл до конца, id 25). Host-тесты: 15 наборов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 21:52:37 +03:00
snark13 65797af44c Ресурсы — в архивы PBA1; тайминги сцены PV — по часам насоса CBL
ТАЙМИНГИ.  Пользователь заметил, что заклинание Джафара не совпадает с
музыкой. Замер в MAME (breakpoint на вспышке + курсор трека): реплика m53
успевала ДОИГРАТЬ до молнии, хотя по шкале оригинала между входом Джафара
и заклинанием ровно 822 тика = 13,7 с.

Причина: пейсинг считал НАШИ ожидания vsync, а не прошедшее время.
Полноэкранная копия страницы стоит больше кадра луча, и разница копилась.
Кадровые прерывания для счёта не годятся (теряются в di-окнах
акселератора), поэтому часами стал насос CBL: он идёт от расхода буфера
железом, 85,4 Гц, и ему безразлично, чем занят главный цикл. Один тик
оригинала = 57/40 тика насоса (0,07 % ошибки, без деления в кадре).
Когда звук выключен, работает прежний путь по кадрам луча.

После фикса замер даёт 229 блоков остатка m53 против расчётных 233 —
расхождение 47 мс.

АРХИВЫ.  Группы ресурсов сложены в PBA1: kid (58 атласов), pv (78),
shadow (32), bg (25), title (20), guard (10), vizier (5), skel (4). Было
232 файла на образе, стало 8 архивов плюс шесть палитр и таблицы уровней.
При цене `open` 51,4 мс против 32,6 мс за чтение 16 КБ это возвращает
секунды на каждой загрузке.

- pop_pack_arc.py печатает индексы элементов константами (ARC_<GRP>_<FILE>),
  порядок задаёт Makefile, а серии код проверяет статически;
- atlas_load разделён: atlas_attach принимает уже прочитанную страницу,
  поэтому чтение из архива не дублирует проверку магии и подготовку W0;
- имена архивов живут в pop_arc.c и адресуются номером. Строковый литерал
  лежит в rodata своего банка, и указатель на него из другого банка после
  переключения W3 показывает на чужие данные — ровно так падала загрузка
  фона (поймано брейкпоинтом на puts).

Проверено в MAME: заставка, интро и уровень 1 собираются из архивов,
Тень грузит все 32 страницы. Host-тесты: 15 наборов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 21:29:14 +03:00
snark13 d86adc54c4 Музыка: озвучена вся заставка, тайминги сведены со шкалой оригинала
Добавлены треки 54 (тема титров), 50/53/52 (три реплики сцены с
принцессой и Джафаром) — заставка звучит целиком, как в оригинале.

pop_music: два слота EMM. Реплики идут встык, паузы под загрузку нет,
поэтому следующая читается в НЕ играющий слот, а play подменяет
резидентную таблицу страниц. Своей копии таблицы слот не держит — её
хранит блок EMM. Плюс постраничная загрузка (load_begin/load_step):
страница стоит 33 мс, четверть логического кадра сцены, и подкачка
посреди анимации не видна.

Тайминги: все длительности сцен — в тиках оригинала (60 Гц), перевод в
кадры луча делает POP_T60. Пока сцены были немыми, разбег в 20 % был
незаметен; с музыкой реплика кончалась раньше картинки. Переведены
титры, история, хвост после PV и пейсинг самой PV-сцены.

Паузы, которые оригинал отмеряет концом сэмпла, теперь равны реальной
длине наших записей: конец m50 на тике 846, вход Джафара 1046, уход
2073, конец сцены 2500 (было 1959). Fade перед PV сдвинут так, чтобы
полная темнота наступала в тот же момент трека, что у SDLPoP: их fade
длиннее нашего на 123 тика, наш полосовой переход длиннее на 16.

Финальная сцена: убраны песочные часы — end_sequence_anim не трогает
hourglass_state, а reset_cutscene его обнулил. Уровень пройден, время
больше не идёт.

Проверено в MAME: цепочка 54 -> 55 -> 50 -> 53 -> 52 отыгрывается
целиком, интро доходит до демо-режима. Host-тесты: 15 наборов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:17:38 +03:00
snark13 16d3262340 Звук: насос CBL качает через W3 — вход в BIOS ломал W0
СИМПТОМ: затемнение (fade) хрипело — одинаково с играющей музыкой и в
тишине.  Ключевое наблюдение пользователя: повтор тишины обязан звучать
тишиной, значит дело не в недоливе буфера.

ПОИСК: отладочные клавиши, каждая делала ровно один кусок fade.  Ожидание
кадров — чисто; чтение палитры, запись палитры и 512 вызовов
bios_get_place() (видео вообще не трогает) — скрежет во всех трёх.
Последнее и решило: виновата не палитра, а ЛЮБОЙ вызов BIOS.

ПРИЧИНА: `rst 8` раскрывается в `out ($7C),a`, который включает системное
ПЗУ и перестраивает окно 0 (MAME sprinter.cpp, update_memory: m_pages[0] +
m_bank_view0.select).  ПЗУ ложится ПОВЕРХ страничного регистра, поэтому
запись в порт 0x82 из прерывания бесполезна — OTIR вычитывает ПЗУ и
отдаёт его в звук.  Отсюда же старое правило «глушить CBL на время
загрузки файлов»: причина была не в том, что ESTEX долго занимает CPU.

РЕШЕНИЕ (идея пользователя): качать через W3.  Он управляется только
портом 0xE2, подмену из прерывания никто не перекрывает, а BIOS во время
нашего ISR не исполняется — окно возвращается до выхода, и для него
подмена невидима.  После этого BIOS безопасен везде.

* pop_sfx.c — насос берёт взаймы W3 вместо W0, чтение по 0xC000 + смещение.
* pop_ui.c — буфер палитры по фиксированному 0x4000 (эти 256 байт DSS
  занимает только при загрузке программы): 256 байт со стека долой.
* libbgi/common/gfx_pal_write.c — запись палитры прямо в видеопамять,
  минуя BIOS.  Писалась как обход скрежета, после переноса насоса не
  нужна; оставлена как более быстрый примитив (2,5 тыс. тактов на 64
  цвета против 10,8 тыс. у BIOS) с честной шапкой.  Адресация разобрана
  по исходникам BIOS (FUNC_SCREEN.ASM): Port_Y = индекс цвета, адрес
  0xC3E0 + pal*4, порядок R/G/B/Y.
* libc/video/pal_get.c, pal_load.c — в шапках зафиксировано, что BIOS
  выбирает окно ПО АДРЕСУ БУФЕРА (`BIT 7,H`).
* pop_intro.c — при пропуске интро клавишей не глушился CBL, и следующая
  загрузка уровня шла с открытым буфером; добавлен pop_sfx_pause.
* pop_ctrl.c — убраны отладочные «осторожные шаги» на J/L (эмуляция
  Shift+стрелка для MAME), у них и стоял TODO.

Разбор целиком — docs/sound_plan.md §5.  Бюджет: _CODE 23966, куча 245 Б.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:36:47 +03:00
snark13 4327ac88b9 R1 (рабочая копия roomtest) — в .gitignore
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:13:35 +03:00
snark13 ba3aca07bf Архивы ресурсов PBA1: звук одним файлом, музыка потоком; ALLOCS 6000
ЗАМЕР, из которого всё выросло (MAME, 2026-08-25): `open` стоит 51,4 мс,
а чтение 16 КБ из уже открытого файла — 32,6 мс.  То есть 61 % времени
загрузки уходит на ОТКРЫТИЕ, а не на данные, и чистая скорость чтения —
491 КБ/с, а не 260.  На диске игры 343 файла; одна только стартовая пачка
(kid+shadow+bg+sound+guard, ~142 файла) — это 7,3 секунды чистого open.

* toolchain/pop_pack_arc.py + pop_arc.c/h (банк 8) — формат PBA1:
  заголовок 512 Б, магия, count, записи по 4 байта (offset в СЕКТОРАХ —
  архив бывает больше 64 КБ; size в байтах — ресурс всегда <= 16 КБ,
  он обязан лезть в EMM-страницу).  Таблицу держит вызывающий на своём
  стеке: в W2 её класть нельзя, а стека в точке загрузки израсходовано
  75 байт из 1279 (замер там же).
* libc: bank_read_page — чтение из УЖЕ ОТКРЫТОГО файла в EMM-страницу.
  bank_load_file переписана через неё, поэтому дублирования работы с W3
  не осталось и резидент почти не вырос.
* Набор эффектов: 10 файлов -> SND\snd.arc, один open вместо десяти.
* Музыка: трек лежит ОДНИМ файлом и читается порциями по странице —
  нарезка на куски больше не нужна.  Этот же путь понадобится финалу
  `won` (1,2 МБ), который в память целиком не влезает.
* Менеджер звука приведён к оригиналу: играющая музыка участвует в
  таблицах приоритета наравне с эффектами (seg000:1672 + data.h:433).
  Почти вся музыка НЕперебиваема, поэтому поверх неё эффект не стартует —
  ровно как в SDLPoP; раньше эффект её перебивал.
* ALLOCS 3000 -> 6000 (решение пользователя): сборка +20 с, резидент
  -161 байт.  Куча 87 -> 248 Б.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:13:25 +03:00
snark13 34ba5f71d5 Музыка: PCM-трек через CBL, первый экран истории озвучен
Путь C из sound_plan.md вместо ранее выбранного A (ноты спикера на AY).
Причина смены: появились готовые записи DOS-версии, и строка «нужен
синтезатор на хосте», из-за которой C считался дорогим, отпала —
осталось `ffmpeg -ac 1 -ar 10937 -f u8`.  Оцифровки музыки в самой игре
нет вовсе: digisnd*.dat содержит только эффекты, музыка лежит MIDI под
Adlib, так что конвертировать ресурс игры всё равно было не из чего.

* toolchain/pop_pack_music.py — запись flac -> сырой u8 на 10 937,5 Гц
  (частота CBL, чтобы её не переключать никогда), нарезка на куски
  MUS\m<id>_<nn>.bin по 16 КБ: bank_load_file читает файл только целиком
  и только в одну страницу.  Каталог -> pop_music_tbl.h; длина трека
  хранится ПОРЦИЯМИ по 128 байт — 169 КБ в uint16 не влезает, 1350
  блоков влезает (32-бит арифметики на Z80 избегаем).
* pop_music.c (банк 9) — загрузка трека в EMM и курсор; pop_sfx_fill
  (резидент) получил третий источник: эффект важнее музыки, музыка
  важнее тишины.  Эффект музыку не сбрасывает — её курсор стоит, пока
  тот доигрывает, и она продолжается с места.
* story_1_absence звучит на первом экране истории, где была тишина.
  Грузим ПОСЛЕ сборки картинки: чтение идёт через ESTEX, и при открытом
  CBL насос не успел бы долить блок.

Проверено записью звука из MAME: корреляция огибающих с эталоном 0,836
при сдвиге 1,8 с, RMS 22,6 против 18,3 (разница — 8-битное квантование).
Эффекты двери в PV-сцене после трека звучат по-прежнему.

Бюджет: резидент +83 Б кода и +25 Б данных, запас W2 151 Б (кучи в
приложении нет, malloc не слинкован); банк 9 занят на 88 %.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:21:22 +03:00
snark13 ffb59b3470 Статус-строка: время, отсчёт ##:## в отладочной полосе, смерть как в оригинале
* Порт show_time() целиком: «N MINUTES LEFT», «N SECONDS LEFT» (у
  единственной секунды свой total=12, как у оригинала), «TIME HAS
  EXPIRED!».  Флаг pop_show_time — это is_show_time: взводит и само ядро
  таймера (круглые пятёрки, каждая секунда последней минуты), и старт
  уровня, и читы времени.  Значение 2 = «перебить текущую строку», как
  оригинал делает в последнюю минуту и после читов.
* Отладочная полоса показывает оставшееся время ##:## у правого края.
  На табло уходит minutes-1: rem_min у оригинала — НОМЕР идущей минуты, а
  не остаток целых (старт 60 при rem_tick 719 = «почти 60:00»).  Секунды
  считаются делением раз в 12 кадров, а не каждый кадр — это единственное
  деление на кадровом пути.
* Смерть Кида по образцу оригинала: строка «Press Button to Continue» с
  7-го кадра (Kid.alive > 6), 288 тиков = 24 секунды, последние 72 тика
  мигание с периодом 12 и звуком 38 на появлении.  Промолчал — start_game
  (title), нажал Enter/Shift — рестарт уровня.  Авто-респавна по таймеру
  больше нет.  Отпускания клавиш оригинал не ждёт, а мы ждём: Shift у нас
  клавиша действия — см. impl_diff.md.
* POP_APP_PLAYING принимает EV_RESTART_INTRO: start_game зовётся прямо из
  геймплея, раньше это событие принимал только pause menu.
* expired() загейчен уровнем 13 (seg000:910).  Время на уровне Джафара
  идёт до открытия двери, но КОНЧИТЬСЯ игра там уже не может — у нас
  этого гейта не было, и партия завершалась.

Бюджет: _CODE 23979, куча 263 Б.  Host-тесты: 15 наборов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:06:17 +03:00
snark13 5756101d29 Статус-строка: LEVEL N и лейбл QS/QL до дисковой операции
* pop_status_level() — порт show_level (seg008:25A8): «LEVEL N» на старте
  уровня.  Демо-уровень 0 и номера от 14 молчат, тринадцатый показывается
  двенадцатым, бесшовный переход 12->13 пропускается и гасит флаг за собой.
  Первое сообщение с ПАРАМЕТРОМ: строка собирается вручную (dec2, без
  printf и без деления), номер хранится снимком st_arg — иначе вторая
  страница дабл-буфера нарисовала бы другое число.
* pop_status_show_now() — печать немедленно, в ВИДИМУЮ страницу.
  QuickSave/QuickLoad заявляют лейбл ПЕРВЫМ действием, до mem_alloc/ESTEX:
  запись снимка занимает доли секунды, и раньше игрок видел сначала
  необъяснённый фриз, а надпись — уже после него.  Отказ переписывает
  строку на NO QUICKSAVE/NO QUICKLOAD обычной заявкой.
  Расхождение с оригиналом (он печатает по результату) — impl_diff.md.

Проверено в MAME: watchpoint ловит заявку внутри pop_status_show_now,
step_out возвращает в pop_qsave_process — и QUICKSAVE уже на экране, диск
ещё не тронут.  Бюджет прежний: _CODE 23981, куча 266 Б.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:13:48 +03:00
snark13 47a4b084c4 Меню, статус-строка и оболочка игры: title/intro/cutscene/HoF, POP.CFG, палитры
Эта сессия (меню + текст в служебных полосах):

* Меню: рамка выделения считается от силуэта текста (SEL_PAD сверху и
  снизу), а не «на глаз»; все экраны центрируются в игровых 200 строках
  (UI_CENTER_TOP/UI_CENTER_FIELD) и в них помещаются; Enter и Space —
  равноправные клавиши действия (ui_action_down).
* Settings: убраны SHOW SPRINTER SCREEN и BACK, добавлены DEBUG BAR и
  ABOUT.  About показывает тот же текст, что стартовый Sprinter screen,
  минус строка про клавиши — общий about_text(), чтобы экраны не
  разъехались.  CONTROLS собирается таблицей и центрируется по
  фактическому числу строк.
* GAME PAUSED переехала в нижнюю статус-строку, как в оригинале
  (SDLPoP rect_bottom_text = {193,70,202,250}): это состояние программы,
  а не пункт меню.  POP_HP_Y вынесен в pop_cdraw.h — полосу делят два
  модуля.
* pop_status.c (банк 9) — текст в обеих служебных полосах.  Нижняя:
  порт display_text_bottom + таймера (QUICKSAVE/QUICKLOAD/SOUND ON/OFF,
  24 тика).  Верхняя отладочная переведена с палочек на текст
  «Level ##, Room ##, Speed: …, Sound: …, Immortal #» малым шрифтом, с
  своим форматированием чисел (без printf и без деления).
  Заявка сообщения — запись одного байта pop_status_msg: резидент W1/W2
  не растёт, весь рендер в банке.  Бюджет после правок не изменился
  (_CODE 23981, куча 267 Б).
* Цена вывода: блит глифа ~4,6 тыс. тактов независимо от размера, поэтому
  всё change-driven, отладочная строка перерисовывается ПО ПОЛЯМ, пробелы
  не блитятся вовсе, а вход в комнату заливает только игровое поле
  (pop_screen_fill_field) — борта от комнаты к комнате не меняются.
* Интро: в PV-сцене зазвучали пропавшие эффекты оригинала — закрытие
  ворот (4) и открытие двери покоев (51), из которой входит Джафар.
* docs/status_line_text.md — полная инвентаризация ВСЕХ текстов SDLPoP в
  статус-строке: геометрия, семантика text_time_total как идентификатора
  сообщения, мигание, рестарт по истечении 36/288.

Вместе с этим выкладывается накопленная работа по оболочке полной игры:
автомат состояний (pop_app), title, intro/PV и cutscene, attract-demo,
Hall of Fame, глобальный таймер, настройки и POP.CFG, модуль палитр и
fade, звуковой набор, host-тесты на новые швы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:58:36 +03:00
snark13 7fd7f28ffc Доки: план меню (рендер, restart без подтверждения), QSAVE закрыт
menu_settings_plan.md:
- §10 переписан: выбран Вариант A — текстовые строки + собственный
  растровый рендерер в новом банке; референс SDLPoP (hc_small_font /
  hc_font — один рендерер, два шрифта); шрифт как ассет из паковщика,
  прототип MS4 — системный CP866 ZG; двуязычность eng/rus через пару
  (таблица строк CP866, файл шрифта)
- Restart Level / Restart Game выполняются сразу, без подтверждения
  (§3, §8, из §11 убраны диалоги RESTART *?)
- §13 MS4: текстовый рендерер + два шрифта; §14: host-тест рендерера

quicksave_plan.md: статус «РЕАЛИЗОВАНО и проверено в MAME»
(v0.6-pop-quicksave), документ оставлен справочником по формату 'POPQ'

TASKS_OPEN/TASKS_CLOSED: запись QSAVE переехала в закрытые с полным
протоколом; docs/README.md аннотации обновлены
2026-08-22 12:45:01 +03:00
snark13 f4b4852d51 QuickSave F6/F9 в roomtest; bank_load_file/bank_save_file/gfx_w0_page_prepare; sprinter-cc: авто n_banks
roomtest:
- QuickSave/QuickLoad (F6/F9): снапшот 'POPQ' v3 в POP.SAV/POP.BAK на HDD,
  транзакционная запись (POP.NEW -> rename, откат при ошибке), XOR-контрольная
  сумма payload'а; сериализация всех игровых переменных через W0-примитивы
  pop_qs_*; pop_qsave_process() на границе кадра вне Char-окон
- pop_qsave_restore_room(): полная перезагрузка комнаты после загрузки
  (карта/края/швы, сброс bake-кэша, перерисовка обеих страниц, инвалидация
  кэшей спрайтов и HP)
- сериализаторы в pop_map/pop_loose_mob/pop_trob/pop_guard_ai
  (+ восстановление инвариантов: mobs_live, trob_drawn, redraw)
- immortal-чит 2 уровня: уровень 2 поглощает только малый урон Kid'а

libc/libbgi:
- bank_load_file()/bank_save_file() — резидентное файловое I/O в банк,
  без правила W3 (путь читается до переключения страницы)
- gfx_w0_page_prepare(page) — подготовка W0-окна (IRQ/NMI-стабы) одной
  функцией; atlas_load.c и roomtest переведены на новые примитивы;
  ручные ISR-стабы удалены

sprinter-cc / сборка:
- --bank N=FILE.c: автогенерация n_banks (_n_banks_auto.c), ручные
  const n_banks удалены из тестов
- roomtest/app.mk: ресурсы через stamp-файлы (.resource-stamps/) — один
  запуск упаковщика на группу вместо N под -B; HDD_PACK_ARGS
2026-08-22 11:53:10 +03:00
snark13 b6699b3aef Разрез pop_tile: холодная половина в банк 5 (−1788 Б резидента)
pop_tile.c был крупнейшим жильцом резидента (5972 Б кода).  Целиком он не
уедет — его const-таблицы читают банки 2, 3, 7 и 8, а таблица в чужом банке
не видна.  Поэтому разрез, а не перенос.

Отбор ЗАМЕРОМ, а не по смыслу: каждая функция посчитана брейкпоинтом-
счётчиком в MAME — сколько вызовов в кадре покоя и сколько в кадре полной
перерисовки комнаты (форсируется читом +/-).  Порог — пик не больше 3.
Проверено на ДВУХ тайлсетах, подземелье и дворец: pop_mem_b рисует
композитный кусок и мог оказаться дворцовым, но и там 0 вызовов.

Уехало: pop_mem_b, pop_cd_hit (+hit_rect), pop_cd_hit_slot, pop_cd_init,
pop_cd_clear, pop_t_win_set/clear, pop_bar_black, pop_heal_off,
pop_potion_flask, pop_room_set_above/below.
Осталось: pop_blit_b со статиками (184 вызова на редрав), pop_cd_touch
(198), pop_tile_code (296), pop_wall_modifier (101), pop_env_b (73),
pop_tile_mod (70), все таблицы.  pop_fore_set_clip оставлен намеренно —
88 Б не стоят отказа от прямого вызова из банка 4.

Цена трамплина замерена: 252 такта пролог + 84 эпилог + ~50 у вызывающего
= ~410.  Это ~1000 тактов на кадр покоя (0,2 % работы) и ~3700 на редрав.

blit_b_clip перестал быть static и объявлен в _pop_tile.h: вызов
банк -> резидент прямой, трамплин не нужен, поэтому статик горячей половины
переносить следом не пришлось.

Итог: _CODE 23716 -> 21928, свободно 747 -> 2535 Б (с 129 Б до всех работ).
Проверено в MAME на уровнях 1 и 4, с переходами комнат.

Метод замера, таблица частот и ловушка с данными банка — docs/resident_budget.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:26:05 +03:00
snark13 536c60d14c AGENTS.md + .codex/config.toml — конфигурация агента
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:59:28 +03:00
snark13 e2730f78e0 gfx_scroll_v: колоночный accel-проход без буфера-посредника + тест scroll
Вертикальный скролл перестал ходить через строку-буфер на стеке: колонку
читаем с Port_Y=ys, пишем с Port_Y=yd, а STOP между read- и write-триггером
делает промежуточный OUT Port_Y безопасным.  Один проход вместо
grab→blit-через-буфер.

Справочник libc приведён в соответствие (там же строка про новую точку
входа cbl_open_silence).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:59:28 +03:00
snark13 3ea4546656 cbltest: пропущенный вызывающий cbl_open + обновление размерного эталона
tests/cbltest не попал в правку API (мой греп обрезался на артефактах
сборки, полный make его и поймал).

Эталон размеров: cblstream −614, cbltest −617, cblwav −592 — это ушедший
malloc.  bgi_img +229 к моей правке отношения не имеет (CBL он не линкует
вовсе): рост пришёл с b3e754a, реверта «каталог атласа читается из W0».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:59:28 +03:00
snark13 8458ec65f4 libc/cbl: две точки входа вместо underrun_mode — malloc больше не в резиденте
Линкер тянет .rel целиком, поэтому malloc/free, стоявшие в мёртвой ветке
CBL_UNDERRUN_SILENCE внутри cbl_open, приезжали в резидент КАЖДОМУ
приложению — включая те, что льют тишину сами и кучей не пользуются.

Разведено:
  cbl_open(freq, fmt, pump, fill)          — ничего не аллоцирует;
  cbl_open_silence(freq, fmt, pump, fill)  — аллоцирует буфер тишины;
  _cbl_open_raw(...)                       — общее тело.
Параметр underrun_mode из публичного API убран: режим задаёт выбор функции.

cbl_close больше не зовёт free — иначе malloc возвращался бы тем же путём.
Буфер тишины живёт до выхода из программы и переиспользуется; его размер
запоминается, иначе открытие 16-бит после 8-бит писало бы memset'ом мимо
выделенного куска.  _cbl_open_raw указатель на буфер не трогает вовсе —
иначе cbl_open после cbl_open_silence терял бы уже выделенную память.

В дереве режим SILENCE не использовал никто: все три вызова (cblwav,
cblstream, PoP) передавали CBL_UNDERRUN_APP.

Итог для PoP: _CODE 24329 -> 23716 (−613 Б), свободно в резиденте 129 ->
747 Б.  make size-check чистый (67 программ), звук в MAME проверен —
насос отработал 696 запросов, pop_snd_ok/want = 1/1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:53:08 +03:00
snark13 63e426cc0d Откат оптимизации резидента: перенос данных банка в его страницу ломает картинку
Возврат к состоянию после звуковых правок (c6828c0).  Отменяются 3545826 и
9025573 целиком: --bank-data=SRC в sprinter-cc, его включение в PoP и
docs/resident_budget.md.

Что выяснено и почему откат, а не доводка.  Перенос писучих данных
банкового модуля в его 16-КБ страницу даёт цветной мусор блоками и уводит
DSS.  У pop_trob причина найдена: pop_trob_modif() возвращает указатель на
room_modif[24][30], и его разыменовывают банки 2/3/7 и резидент — то есть
пишут по 0xC000+ в СВОЮ страницу, поверх чужого кода.  Def/Ref-анализ такое
не ловит: снаружи ссылки на символ нет, есть ссылка на функцию, отдающую
его адрес.

Но и один pop_room, у которого явной утечки указателя найти не удалось,
ломается так же — значит механизм понят не до конца.  Пока не понят,
включать нельзя.  Нулевая инициализация при этом ни при чём: mkexe -p 0
проверен по образу (прогон нулей 14304 Б, самый длинный прогон 0xFF — 14).

Место в резиденте искать другими путями: malloc (287 Б, требует раздельных
cbl_open для APP и SILENCE) и код pop_tile (5972 Б).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:26:23 +03:00
snark13 90255737c2 Откат bank-data для pop_trob: указатель на его статику уходил наружу
Симптом: через несколько комнат живого прохода перезагружался DSS.

pop_trob_modif() возвращает указатель на room_modif[24][30], а зовут её из
банков 2, 3, 7 и резидента.  После переноса массив лежит по 0xC000+ в
странице банка 6, но разыменовывает указатель ЧУЖОЙ код — когда замаплена
его собственная страница.  Значит чтение и запись идут поверх кода соседнего
банка.

Анализ Def/Ref такое не ловит: снаружи нет ссылки на символ, есть ссылка на
функцию, которая отдаёт его адрес.  Условий для кандидата два, и второе
проверяется только чтением кода — ни один указатель на статику не должен
уходить наружу.

pop_room оба условия проходит (_mobs не читает никто; bake_copy статическая;
atlas_load(&pop_env[i]) берёт адрес глобала из резидентного pop_tile.c).
Остаётся −1620 Б: данные в W2 6774 -> 5157, свободно 129 -> 1746 Б.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:21:44 +03:00
snark13 354582662c Резидент W1/W2: данные pop_room и pop_trob — в свои банки (−2546 Б)
sprinter-cc: новый повторяемый --bank-data=SRC — писучие данные ОДНОГО
банкового модуля в его же страницу.  Прежний --bank-data был всё-или-ничего
и потому неприменим: у большинства банковых модулей часть глобалов читают
соседние банки и резидент (hitp_*, pop_upside, pop_loose_modif, pop_cd), и
такие данные обязаны остаться замапленными всегда.

Кандидаты отобраны по объектным файлам, а не на глаз: символ должен быть
Def только в своём .rel и нигде не Ref.  Прошли ровно двое — pop_room
(_mobs не читает никто) и pop_trob (экспортируемых данных нет вовсе).

При любом --bank-data sprinter-cc сам добавляет mkexe -p 0: crt0 зануляет
только резидентный _DATA, а mkexe по умолчанию бьёт пустоты 0xFF — иначе
вся банковая статика поднялась бы мусором.

Итог: данные в W2 6774 -> 4228, свободно до стека 129 -> 2675 Б.
BANK7 87 %, BANK6 31 %.  Проверено в MAME: уровень 1, комнаты 1-2, фон,
факелы, решётки, проваливающиеся полы, переход между комнатами.

Метод замера и оставшиеся кандидаты — docs/resident_budget.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:14:02 +03:00
snark13 c6828c0ad1 Ворота PoP: правильный гейт слышимости, звук «решётка встала», playsound кнопки
Проверка на сцене 9/9 (кнопка 1,8; челюсти 1,2; ворота — в комнате 4,
тайл 1,9) вскрыла три расхождения.

1. Гейт слышимости стоял как «ворота в текущей комнате», а у оригинала
   (play_door_sound_if_visible, seg007:1239) слышны ещё и ворота в комнате
   СЛЕВА, если они в колонке 9; и НЕ слышны в колонке 9 своей комнаты; плюс
   особый случай «уровень 3, комната 2».  Сцена 9/9 — ровно первый пункт,
   поэтому спуск решётки молчал.  Подъём совпадал, потому что звук открытия
   идёт без гейта — эта асимметрия и была подсказкой.

2. Потерян звук 7 «решётка встала»: gate_stop (seg007:05E3) зовётся из трёх
   мест animate_door и каждый раз играет его через гейт.  У нас во всех трёх
   стояло только type = -1.

3. У trigger_button оригинала есть параметр playsound, нулевой в трёх
   местах (вход на уровень, выход Джаффара, зелье «открыть»).  Добавлен.

Щелчок кнопки слышен через раз — это НЕ баг: prio 0x66 против 0x10 у
челюстей, а укус занимает 465 мс из цикла 1229 мс (замерено).  Разбор с
цифрами — sound_plan.md §12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:03:16 +03:00
snark13 a630568a8b Звук PoP: приоритеты и перебиваемость вместо «всегда перебивать»
Пользователь услышал расхождение с SDLPoP: у нас решётка обрывалась
приземлением Кида, в оригинале доигрывает до конца, а приземления не
слышно.  Оказалось, упущен целый механизм.

play_sound (seg000:12C5) НЕ играет, а только номинирует кандидата на
кадр — из нескольких выживает важнейший (меньше prio = важнее, при
равенстве последний).  play_next_sound (seg000:1304) раз в кадр решает,
запускать ли: можно, только если ничего не играет ЛИБО текущий помечен
перебиваемым и новый не менее важен.  Иначе номинант выбрасывается —
очереди в оригинале нет.

Отсюда всё, что слышно: gate_closing_fast неперебиваем и доигрывает
целиком; челюсти (prio 0x10) всегда важнее решётки (0x32), поэтому
решётка звучит только в паузах между укусами.

Таблицы из SDLPoP с учётом fix_sound_priorities (в его config.h он
определён безусловно).  Створка двери уровня — единственная запись,
правимая на ходу, вынесена в отдельный байт.  Добавлен пропущенный
stop_sounds на завершении открытия двери (seg007:455).

Проверено записью MAME: старт уровня 1 был 135+210 мс (решётка, обрезанная
на 80 мс), стал один всплеск 455 мс с корреляцией огибающей +0,889 со
звуком 6.

Заведён BUG-SND-FIRSTRUN: искажение первого эффекта при первом запуске
после загрузки системы — вероятно, лечится _cbl_prime, но проверить можно
только на железе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:46:04 +03:00
snark13 f159aa47e1 CBL: заливка буфера тишиной при открытии + щелчок на выходе из PoP
Две разные болячки, обе разобраны записью звука MAME в WAV.

libc: буфер CBL железо не чистит, а запись в порт управления сразу пускает
воспроизведение с нулевого слота — первые 256 сэмплов (23,4 мс) уходит то,
что лежало раньше.  Своими данными звук идёт лишь с третьей половины:
прерывание приходит на 128-м слоте и ставит указатель на противоположную
половину.  _cbl_prime заливает буфер тишиной сразу после включения (раньше
нельзя — запись проходит только при поднятом bit7).  В MAME это немо
(эмулируемый буфер стартует нулями при двухдополнительном ЦАП), на железе
это ровно тот мусор, что ловился на тестовых примерах CBL.

PoP: на выходе по ESC звучало ровно 11 мс шума на полной громкости — один
пропущенный блок (128 сэмплов).  Причина: pop_shutdown освобождал атласы и
графику через ESTEX при открытом звуке, насос не успевал долить.  Звук
гасим первым действием.  Проверено записью — всплеска больше нет.

Заодно записан разбор стартового всплеска (sound_plan.md §10): это не
мусор, а gate_closing_fast из левой комнаты, обрываемый soft_land.  Обрыв
одноголосьем — поведение оригинала (play_digi_sound начинается с
stop_digi, seg009.c:2402).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:23:06 +03:00
snark13 e0c86a96ef Ctrl+S — вкл/выкл звук; чит «выдать меч» убран
Порт Ctrl+S из SDLPoP (seg000:657).  Выключение реально закрывает CBL, а
не глушит сэмпл: иначе насос продолжал бы отдавать блоки тишины и платить
те же 3,26 % процессорного времени.

Флаг намерения pop_snd_want отдельно от pop_snd_ok: последний гасит
служебная пауза на время загрузки уровня, и правь Ctrl+S только его —
первая же смена уровня вернула бы выключенный звук.

Индикация: пурпурная палочка в борте, когда звука НЕТ (включённый слышно
и так, а молчание неотличимо от «нечему звучать»).

Чит S «выдать меч» был отладочным и больше не нужен — снят, клавиша ушла
под звук.

Замер цены звука — sound_plan.md §9: 8 001 такт на прерывание при периоде
245 759 (3,26 % времени), +2,2 % к работе кадра в сцене 11/15, период
кадра не сдвинулся ни разу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:49:33 +03:00
snark13 7e2e7fbb37 Тишина на первом уровне: звук включался только после смены уровня
При разделении загрузки набора (pop_sfx_init) и открытия CBL
(pop_sfx_start) парный вызов start попал только в pop_level_switch.
На стартовом пути его не было — игра шла молча до первого перехода.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:25:09 +03:00
snark13 8ea4c32e51 Звуковые эффекты PoP: оцифровка оригинала через CBL
Набор digisnd1..3.dat приведён упаковщиком к 10 937,5 Гц (частота
железа), склеен в 8 EMM-страниц с выравниванием каждого звука на 128 —
размер блока запроса CBL, поэтому ни один блок не пересекает границу
страницы и проигрыватель не знает слова «стык».

Насос (pop_sfx.c) резидентный: его зовут из прерывания CBL, из горячих
мест физики и из play_seq.  Тишину льём свою (первый блок набора), а не
через CBL_UNDERRUN_SILENCE с его malloc — куча в резиденте W2 тесная.
Открытие CBL разведено с загрузкой (pop_sfx_start отдельно от
pop_sfx_init): пока ESTEX читает файлы, насос не успевает долить блок и
железо крутит хвост буфера — на слух скрежет.

Разведены все места play_sound() SDLPoP, у которых есть оцифровка
(id 0..23, 44..49): посадки, падение, удары о стену, зацеп, тряска и
провал плит, ворота, дверь уровня, пики, чомпер, кнопки, боёвка, меч,
зеркало, скелет, зелье.  Таблица соответствий — docs/sound_plan.md §8.
Музыкальные id остаются с нулевой длиной до фазы музыки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:16:43 +03:00
snark13 24ced6058f Упаковщик звуковых эффектов + сверка наборов MSDOS и SDLPoP
Эффекты берём из MSDOS/digisnd*.dat: 28 из 31 звука совпадают побайтно с
набором SDLPoP, но три различаются не в пользу последнего — у него
sword_vs_sword короче, sword_moving другой, а spiked вообще пустой
(7 сэмплов против 5 069).

MIDI наоборот: у MSDOS формат 0 (всё слито в одну дорожку), у SDLPoP
формат 1 с 8-9 дорожками — готовое разделение голосов, если дойдём до
пути B (MIDI -> три канала AY).

Упаковщик: 31 эффект -> 10 937,5 Гц, 131 072 Б = 8 EMM-страниц.  Начало
каждого звука выровнено на 128 (блок запроса CBL), а страница кратна 128,
поэтому ни один блок не пересекает границу страницы — проигрывателю не
нужна логика стыка.
2026-08-20 12:42:19 +03:00
snark13 a3d37bcbfe Тень закрыта: прогон на уровнях 4/5/6/12, расхождение по кайме в impl_diff 2026-08-20 12:33:46 +03:00
snark13 22cdc67c3a Звук: подтверждён формат (8 бит моно) и замерен бюджет EMM
Формат оригинала проверен по convert_digi_sound: один байт на кадр (моно),
байт беззнаковый с центром 0x80 — ровно формат нашего CBL.  Стерео в
данных нет, каналы размножаются на выходе.

Живой замер памяти из работающей программы: занято 124 страницы из 256
(система с exe 43, наши ассеты 81), свободно 132 = 2,06 МБ.  Самый
крупный ассет теперь набор Тени — 32 страницы.  Эффекты WAV займут 8.
2026-08-20 12:25:31 +03:00
snark13 4688364091 Звук: решения пользователя и единая частота 10 937,5 Гц
Эффекты — WAV через CBL; музыка первым заходом путь A (ноты на AY);
заставки потом WAV; музыка по ходу игры — открыто (WAV с гашением
эффектов либо путь B).

Единую частоту берём не 11 000, а ровно частоту CBL 10 937,5: тогда тон
точен (иначе −9,9 цента), а пересчитывать три файла всё равно надо.
112 922 -> 124 531 Б, 6,9 -> 7,6 EMM-страниц; рост целиком от
leveldoor_sliding (источник 2 750 Гц).  Взамен CBL открывается один раз и
частота не меняется никогда.
2026-08-20 12:10:04 +03:00
snark13 9f9a8f26ae Звук: разобраны все наборы MS-DOS версии, включая mt32snd
Эффекты все 31 есть в WAV (digisnd, 8 бит PCM) — просьба «не спикер, а
wav» для них уже выполнена исходным планом.  mt32snd оказался НЕ музыкой,
а теми же эффектами в MIDI для Roland MT-32.

У музыки WAV нет ни в одном наборе: только ноты спикера (7 КБ) или MIDI
(27 КБ).  Посчитал третий путь — рендер MIDI в WAV на хосте: игровые
треки 74,7 с = 803 КБ = 50 EMM-страниц (влезает), заставки 248 с = 167
страниц (только стрим с диска).

Только спикером во всей игре остаётся один звук — blink (4 ноты).
2026-08-20 11:57:19 +03:00
snark13 7fcd93a35b Разбор звука: эффекты через CBL как есть, музыка на AY из нот PC-спикера
Замеры по ассетам: 31 эффект уже 8-битным PCM на 11 000 Гц (у CBL есть
10 937,5 — расхождение 0,6 %, формат сэмпла совпадает байт в байт, то
есть конверсии нет вовсе), 103 941 Б = 6,3 EMM-страницы.

Вся музыка есть нотами PC-спикера — 7 КБ на 57 звуков, и нота там задана
в ГЕРЦАХ напрямую (проверено по speaker_callback), а не делителем PIT,
как кажется по числам.  MIDI разбирать не нужно.

Отдельный таймер не нужен: секвенсор музыки двигает CBL-callback раз в
11,7 мс, а короче 12 мс во всей музыке 2 ноты из 1469.
2026-08-20 11:53:02 +03:00
snark13 d1183f7315 Отладочный старт перехватывал смену уровня
DBG_START_ROOM/POS подменялись безусловно, а pop_start_level зовётся и на
границе уровня.  Из-за этого на 7-м стартовой становилась отладочная
комната вместо комнаты 17 из данных, и спецсобытие «вход падением»
(set_start_pos, seg003:0196) не срабатывало — переход 6->7 выглядел
сломанным.

Подмена теперь действует только на своём уровне (FIRST_LEVEL); рестарт
того же уровня отладочную позицию сохраняет, как и задумано.
2026-08-20 11:35:09 +03:00
snark13 30bcc3459b Атлас Тени: запечённый набор вместо спрайтов стража
Оригинал кладёт спрайт дважды — прозрачным блитом в x и XOR-блиттером в
x+1; пакетный блит так не умеет, поэтому результат запечён упаковщиком.
Две половины, как и у оригинала: sk* — кадры вне боя (спрайты Кида),
sf* — кадры 150..189 (SHADOW.DAT, тоже графика Кида).  251 спрайт,
32 EMM-страницы, палитра 16 цветов в 0xA0..0xAF.

Закрывает BUG-SHADOW-SET: раньше тип 4 уходил в guard_names, и Тень в
бою дралась серым стражем.

Грабля: kid.pal заливает все 256 записей и затирает слоты Тени —
палитра вынесена в pop_shadow_pal_apply рядом с pop_bg_pal_apply.

Проверено в MAME на 6-м уровне: силуэт с контуром, как в оригинале.
2026-08-20 11:22:53 +03:00
snark13 e60a04e900 Тень: набор спрайтов выбирает поле кадра, а не charid; SHADOW.DAT у нас нет
Поправка к вчерашнему выводу «в бою Тень рисуется спрайтами стража».
Набор берётся из cur_frame.sword>>6 (seg008.c:1752), chtab_base жёстко
равен Киду.  Тень идёт через chtab_5, но chtab_5 — это «соперник уровня»,
и на 12-м это SHADOW.DAT: графика КИДА в боевых позах, палитра побайтно
равна палитре Кида.  Пользователь прав — Тень всегда выглядит Кидом.

Наш pop_guard_load уводит тип 4 в guard_names, SHADOW.DAT в ассетах нет
вообще — заведён BUG-SHADOW-SET.

Пересчитал палитру на правильных наборах: 251 кадр, 45 цветов; 16 цветов
гибридом дают 76 грубых промахов на все кадры (было 129 на ошибочном
наборе).
2026-08-20 10:50:36 +03:00
snark13 b6660d7694 Атлас Тени: остановились на 16 цветах (гибридный подбор), блок 0xA0..0xAF 2026-08-20 10:39:40 +03:00
snark13 acb483897d Разбор атласа Тени: алгоритм оригинала, замеры палитры, план
XOR у оригинала идёт по 24-битному RGB, а blitters_2_or — обычный блит с
colour key 0.  От фона зависит только кайма в один пиксель по левым
кромкам силуэта; на чёрном фоне запечка точна.

Замеры: 253 кадра (Кид 219 + страж 34, Тень в боевых кадрах рисуется
спрайтами СТРАЖА), 59 разных цветов.  32 цвета оставляют перцептивно
значимыми 232 пикселя из 96 746.  Палитра: занято 112 слотов, свободно
144; берём 0xA0..0xBF.
2026-08-20 10:38:27 +03:00
snark13 47c26d1899 TUNE-2: параметры стражей в CFG-файл (формат секций как у SDLPoP) 2026-08-20 10:23:20 +03:00
snark13 0ef8c4b60e Бессмертие: два уровня вместо тумблера
1 — только бой: удары мечом не отнимают HP (ветка в hurt_by_sword).
2 — плюс мелкий урон: не проходят «минус деление» от падения с двух
    этажей и от падающей плиты (pop_take_hp гасит count < 100).
Мгновенная смерть остаётся на обоих: пики, чомпер, падение с трёх этажей
и удар вне боевой стойки приходят с count = 100.  В коде ровно два
значения урона, 1 и 100, поэтому граница точная, а не эвристическая.

Заодно ушёл костыль «снять бессмертие на время вызова take_hp(100)» в
hurt_by_sword — он был нужен только потому, что прежний чит глушил и
смертельный урон.

Клавиша I идёт по кругу 0 -> 1 -> 2 -> 0; в отладочной метке число
красных палочек = уровень.
2026-08-20 10:08:52 +03:00
snark13 10b920f156 impl_diff: ГСЧ разведён по доменам (у оригинала один сид) 2026-08-20 09:43:07 +03:00
snark13 6bdac70508 Отладочная метка: уровень, комната, режим скорости, бессмертие; дефолт NORMAL
Четыре блока палочками в верхнем борте, каждый своим цветом.  Цвета взяты
из 0x3A..0x3F — единственного диапазона, который не перезаписывают ни
зелья (0x40), ни env/wall тайлсета (0x50/0x60), ни страж (0x90).  Прежняя
метка комнаты рисовалась цветом 0x57, то есть из env-диапазона: белой она
была только в подземелье, во дворце брала цвет тайлсета.

Записи палитры — BGR (BIOS $A4), не RGB; читать kid.pal «как привычно»
нельзя, цвета выйдут переставленными.

Режимы перенумерованы: NORMAL=0, FAST=1, FASTEST=2.  Тогда дефолт (crt0
зануляет _DATA) — NORMAL, обход инкрементом даёт NORMAL->FAST->FASTEST, а
номер режима + 1 = число палочек.
2026-08-20 09:40:57 +03:00
snark13 5d61224229 Реестр оптимизации: бюджет кадра вырос втрое, срочность позиций падает 2026-08-19 23:23:22 +03:00
snark13 35d7bd38d4 L1-SPEED закрыта режимами скорости 2026-08-19 23:21:52 +03:00
snark13 5e9c6a2e9e Пейсинг: результаты замеров и грабли методики 2026-08-19 23:21:33 +03:00
snark13 a6e39070af Фиксированный логический кадр по лучу + режимы FASTEST/FAST/NORMAL
Период стал max(n, ceil(W)) вместо ceil(W)+2: три gfx_wait_vsync после
работы отсчитывались от её КОНЦА, поэтому бюджет кадра был один растр.
Теперь ждём от якоря начала кадра, и при n=3 бюджет 1 290 240 тактов.

Счёт кадров — программный, по биту 5 порта 0xFE (положение луча), а не по
кадровым прерываниям: те теряются в DI-окнах акселератора фазозависимо
(замер: 0..2,8 %, на полной перерисовке три подряд).  Условие точности
одно — зазор между выборками меньше 64 512 тактов; точки выборки
расставлены по замеру, а не на глаз.

Режимы (pop_pace.h), клавиша P по кругу, дефолт FASTEST.  Условие боя
взято у оригинала буквально (SDLPoP seg003.c:363): Kid.sword ==
SWORD_2_DRAWN, а не «идёт бой».

Проверено в MAME на 11/15: счётчик без недосчёта на 270 кадров, период
ровно 3 растра на 302 логических кадрах (ни длиннее 3,1, ни короче 2,9),
NORMAL даёт ровно 4, с вынутым мечом — ровно 5.
2026-08-19 23:06:27 +03:00
snark13 61b8d80275 Пейсинг: подробный разбор счётчика кадров по лучу (условие точности, точки выборки, приёмка) 2026-08-19 21:55:05 +03:00
snark13 7f778bba2f Разбор перехода на фиксированный логический кадр (кода не трогали)
Замеры в MAME: кадровые прерывания теряются фазозависимо (0..3%), на
полной перерисовке комнаты — три подряд.  Причина: импульс запроса 32
такта (9,14 мкс) против DI-окон блита ~0,29 мс.  Счёт попаданий
брейкпоинтом на этом драйвере недостоверен (WAIT-линия), достоверен
только детектор разрыва.

Блокер включения gfx_set_fps_div как есть: счётчиковый путь ждёт через
halt и не зовёт idle-хук, то есть возвращает KBD-1.
2026-08-19 21:41:13 +03:00
snark13 39c3247532 Док 13/23: регресс после дня оптимизации 11/15
Максимумы по секциям против эталона mob-order-B-done: работа 911 862
(-1 986), синяя 149 106 (-10 704), зелёная 436 494 (-3 924), циан 382 770
(-10 230).  Период 3 растра в 2341 кадре, 4 в 23, 5 в 2 — как в эталоне.

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

Отмечено, что зелёная по-прежнему выше растрового кадра и главный
оставшийся кандидат для этой сцены — P9 (G8): сосед падающей плиты
перезапекается целиком и повторно, при шести плитах это умножается.

И записан урок процесса: прогон 13/23 обязателен после каждой правки
loose-механики — именно он вскрыл пропущенный взвод гейта в check_fall_flo,
которого не поймали ни хост-тесты, ни сцена 11/15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:56:07 +03:00
snark13 d0ac4b1c2a Каскад уровня 13 не падал: в гейте P5 пропущено шестое место взвода
Нашёл пользователь на прогоне 13/23: плиты потолка трясутся, но не
падают.

Причина — моя ошибка в P5.  Гейт loose_any снимается циклом по факту
прохода без живых фаз, а взводиться обязан у КАЖДОЙ записи фазы.  Я
пометил пять мест и пропустил шестое: check_fall_flo, который на уровне 13
раздаёт плитам-потолкам отложенный старт (0xF0..0xFF).  В результате фаза
записывалась, а цикл её не досчитывал — ровно тот отказ, который я сам
описал в комментарии к loose_any: «ложный ноль стоит застывшей навсегда
плиты».

Исправлено, и в шапку loose_any добавлено предупреждение с этим случаем:
добавляя новое место записи фазы, добавляй и взвод.

Замер 13/23 после исправления (максимумы по секциям, 2367 кадров):

                эталон    сейчас
  работа       913 848   911 862
  синяя        159 810   149 106
  зелёная      440 418   436 494
  циан         393 000   382 770

Период: 3 растра в 2341 кадре, 4 в 23, 5 в 2 — как в эталоне.  Зелёная
по-прежнему выше растрового кадра (436 494 против 430 000).

Урок для процесса: сцену 13/23 надо прогонять после КАЖДОЙ правки
loose-механики, а не только когда меняешь её сознательно.  Хост-тесты
этот отказ не поймали: phys_loose_gate_survives_room_change проверяет
возврат в комнату, а не отложенный старт уровня 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:50:32 +03:00
snark13 d0de6dedf4 HEAL-WIDTH: heal чомпера ровно 32x60 — его точный след
Замечание пользователя: весь чомпер помещается в свой тайл, значит его
heal максимум 32x60.  Проверено по каталогу атласа и подтвердилось:

  нижняя челюсть 101/102 = 32x60 низом на dmy = 63*row+62, занимает
    63*row+3 .. +62;
  верхние челюсти дают ТОТ ЖЕ верх — подъём 0x25 при высоте 23, 0x2F при
    13 и 0x32 при 10 все три упираются в 63*row+3;
  кровь 114..118 шириной 6 рисуется на x+8, то есть внутри 32.

Было 64 «на всю высоту тайла» (плюс лишняя строка запаса от прошлой
правки) — стало ровно 60 от +3.

Заодно зафиксирован разбор структуры перерисовки чомпера: ОДИН heal на
тайл и ДВА блита (низ и верх).  Объединить блиты нельзя — при раскрытых
позах нижняя часть маленькая (32x30, 32x21, 32x17) и между ней и верхней
челюстью разрыв: например, при позе 2 низ занимает +33..+62, верх
+3..+25, а строки +26..+32 пустые.

Проверено в MAME: чомпер рисуется чисто, хвостов от прежнего кадра нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:36:04 +03:00
snark13 b6b214e225 Реестр: HEAL-WIDTH закрыт, P10 разобран без реализации
HEAL-WIDTH: плита 64->58, чомпер 64->61, габариты из каталогов атласов.
На 11/15 медиана не сдвинулась (плит нет), максимум -960.  Основной
эффект ждёт прогона 13/23, где плит шесть одновременно.

P10 разобран: «просто передать готовое из физики» не выйдет, величины
РАЗНЫЕ.  char_footprint берёт габарит кадра и расширяет диапазон на
колонку под меч; set_char_collision тот же fpw корректирует на FRAME_THIN
и меч не учитывает, а ряды у него — опорный curr_row, а не верх/низ
спрайта.  У оригинала обе задачи пользуются одними величинами, потому что
он считает их один раз; у нас они исторически разошлись.

Значит P10 — это сведение двух геометрий к одной, с риском для физики,
которая сейчас работает правильно.  Приоритет понижен до низкого, и
записано, чего не хватает: отдельного замера самого char_footprint
(сейчас известно лишь «вход + set_clip + footprint = 10 872»).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:27:18 +03:00
snark13 f62989e358 HEAL-WIDTH: ширина heal'ов по фактическому следу из атласа
Задача была помечена обязательной.  Габариты сняты из каталогов .atl, а
не «по клеткам на глаз»:

  плита   41/69/70 = 32x13-14   43/73/74 = 32x3   42/71/72 = 26x15-16
  чомпер  101/102 = 32x60       111 = 27x23       113 = 23x10

Отсюда два сужения:

  pop_loose_shake_draw  ширина 64 -> 58  (свой тайл 32 + правая грань 26,
                                          во дворце 25)
  pop_chomp_redraw      высота 64 -> 61  (след 63*row+3..62: верх самого
                                          высокого bot-кадра и низ на dmy;
                                          верхняя челюсть при подъёме 0x32
                                          и высоте 10 даёт ровно +3)

Замер 11/15: медиана не сдвинулась (437 484 — плит в комнате нет),
максимум 550 776 -> 549 816, то есть эффект только в кадрах перерисовки
чомпера и он мал, как и предсказал пользователь.  Основной выигрыш от
сужения плиты (9,4 % площади) ждёт сцены 13/23 и требует отдельного
прогона на сборке LEVEL=13.

Пики не трогал: их таблицы кадров (POP_SPIKES_FRAM_LEFT/RIGHT) я по
атласу не разбирал, а сужать heal по догадке — прямой путь к
недочищенному хвосту.

Проверено в MAME: чомпер и факелы рисуются чисто, хвостов нет; хост-тесты
зелёные.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:25:35 +03:00
snark13 a29fb8da34 Реестр: P6a закрыт (-840), P6b оставлен неделанным
Оценка пары P6a/P6b была -20 000, факт по P6a — -840.  Записана причина:
оценку я перенёс по аналогии с лучом видимости, где трамплин звался девять
раз за кадр, а тут trob'ов в комнате всего несколько.  Урок в реестре:
«тот же паттерн» не означает «тот же порядок величины».

P6b (кэш префетча, 11 058) не делался: инвалидацию пришлось бы ловить из
трёх источников (pop_level_set_tile, вход в комнату, добавление trob), а
пропуск любого даёт застывшую анимацию.

Бюджет лёгкой позиции: 437 484, до цели 7 484.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:18:13 +03:00
snark13 5a42b2d245 P6a: указатель модификаторов комнаты кэшируется между trob'ами
pop_trob_modif объявлен __banked, а звался он на КАЖДЫЙ trob внутри
цикла pop_process_trobs — при том что комната у них в подавляющем
большинстве кадров одна (чужие появляются только у брошенных плит
соседней комнаты).  Тот же паттерн «трамплин в цикле», что дал -23 784 на
луче видимости (P2b) и -14 118 на guard_over_kid (P16).

Замер 11/15: цикл trobs 78 726 -> 74 964, работа кадра 438 324 -> 437 484.

ОЖИДАНИЕ НЕ ОПРАВДАЛОСЬ: в реестре стояло -20 000 на пару P6a/P6b, а
вышло -840.  Причина простая — trob'ов в комнате всего несколько, и кэш
экономит два-три вызова, а не двадцать.  Оценка была построена на
аналогии с лучом видимости, где вызовов было девять на КАЖДЫЙ кадр.

Проверено в MAME на чистом запуске: факелы, чомпер и страж рисуются
правильно, хост-тесты зелёные.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:17:38 +03:00
snark13 52a36caa75 Реестр: P18 — метка огрублена по X (отложено)
Пользователь заметил: Кид перерисовывается, хотя с пламенем не
пересекается; на пиксель левее — перестаёт.

Разбор: спрайт Кида занимает x 213..224, колонка считается как x >> 5,
и 224 — ровно первый пиксель колонки 7, где лежит метка от пламени
(y 33..50).  По вертикали пересечение настоящее, по горизонтали его нет:
пламя в той же колонке занимает x 232..247, зазор восемь пикселей.

То есть P15 исправил огрубление по Y и оставил его по X.

Отложено по решению пользователя с его же аргументами: x не влезает в
байт (0..319), значит нужны 16-битные сравнения в горячем пути, а они у
SDCC z80 дороги настолько, что могут съесть выигрыш; огрубление вдвое —
лишний сдвиг при записи и проверке плюс потеря точности.

Записана непроверенная идея: хранить границы как смещение ВНУТРИ колонки
(0..31, пять бит) — байта хватит и сравнение 8-битное, но запись
усложняется для прямоугольников через несколько колонок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:07:36 +03:00
snark13 9e03739bb0 Реестр: P4 откачен, добавлен P17 (разрядность)
P4 (каталог из W0) отменён по критерию пользователя: 408 тактов не стоят
второй публичной функции в libbgi с неявным контрактом «страница уже
подключена».  Знание сохранено: gfx_w0_map стоит 324, поэтому потолок
непробованной части P4 — около 2 600, а не 10 000.

P17 — по замечанию пользователя про 16 бит там, где хватает 8: границы
экрана беззнаковыми сравнениями (-378) и габариты спрайтов в uint8_t
(-276 в статике, -1 134 в циане динамики, плюс 24 байта _DATA).

Бюджет: лёгкая 438 324, тяжёлая 603 684.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:02:09 +03:00
snark13 79bcabde94 Габариты спрайтов в байтах: uint16_t -> uint8_t в слоте отрисовки
Замечание пользователя: спрайты наших атласов не крупнее 64x64, а w/h
почти везде были uint16_t.  Это уже записано в памяти проекта
(pop_sprite_size_limits: весь игровой кадр PoP <= 56x63; больше 255 только
восемь полноэкранных подложек титров, а они через слот персонажа не
проходят).

Переведены в uint8_t: w/h, ow/oh, fpw/fph, cw/ch в pop_cdraw_t, параметры
cd_overlay_add и cd_clip_add, локали w/h/vis_w в pop_char_draw и
cd_splash, и чтение габарита из шапки ленты (было двухбайтным сложением
со сдвигом).

Эффект: лёгкая позиция 438 600 -> 438 324 (там персонажи не рисуются,
поэтому почти ничего), тяжёлая — циан 181 404 -> 180 270.  Плюс 24 байта
_DATA на двух слотах.

Скромно, но код от этого не запутаннее, а честнее: тип теперь отражает
реальный диапазон.  Проверено в MAME — бой идёт, хвостов и обрезков нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:00:19 +03:00
snark13 892f005ca5 pop_blit_b: границы экрана двумя беззнаковыми сравнениями вместо четырёх знаковых
Проверка «спрайт целиком на экране» стояла как
  pb_x >= 0 && pb_top >= 0 && pb_x + pb_w <= 320 && pb_top + pb_h <= 256
— четыре знаковых 16-битных сравнения, а знаковое у SDCC z80 разворачивается
в пару sbc плюс jp PO / xor 0x80 / jp P (видно в листинге).

Беззнаковая форма делает то же двумя: отрицательная координата в
беззнаковом виде становится очень большой и проваливает условие так же,
как проверка >= 0, а верхняя граница переносится в правую часть вместе со
сложением.  Границы неотрицательны по построению: pb_w и pb_h не больше
255, значит 320-pb_w >= 65 и 256-pb_h >= 1.

Работа кадра 438 978 -> 438 600.  Немного, но идиома стандартная и код
не усложняется.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:52:43 +03:00
snark13 b3e754a66b Revert "P4: каталог атласа читается из W0, а не через мап W3 — минус 408"
This reverts commit 06fb4235f0.
2026-08-19 17:42:51 +03:00
snark13 a993cb3b62 Реестр: P4 частично, эффект много меньше ожидаемого
Каталог атласа теперь читается из W0 вместо переключения W3 — минус 408
на кадре при ожидании минус 5 400.  Цена блита 16 107 -> 16 005.

Причина записана: 672 такта atlas_image — это почти целиком вызов
функции и арифметика idx*8, а не переключение окна; после правки работа
переехала в статью «каталог + шапка + клип» (2 694), а сам gfx_w0_map
стоит всего 324.

Отсюда понижена оценка непробованной части P4 (один map на группу
блитов): потолок ~2 600 за кадр, а не 10 000.

Бюджет: лёгкая 438 570, тяжёлая 602 574.  До цели 8 570 и 172 574.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:41:14 +03:00
snark13 06fb4235f0 P4: каталог атласа читается из W0, а не через мап W3 — минус 408
atlas_image ради двух байт записи каталога переключает W3 туда и обратно,
хотя вызывающий сразу после этого мапит ту же страницу в W0 — и каталог
там доступен по тому же смещению.  Новый atlas_image_w0 (libbgi) читает
его из W0; в pop_blit_b порядок стал «сначала gfx_w0_map, потом каталог».

ОЖИДАНИЕ НЕ ОПРАВДАЛОСЬ.  По раскладке блита atlas_image стоил 672 такта,
и я рассчитывал снять их целиком: 8 блитов зелёной фазы это 5 400 за кадр.
Фактически цена блита 16 107 -> 16 005 (-102), на кадре -408.

Причина: 672 — это почти целиком вызов функции и арифметика idx*8, а не
переключение окна.  Замер после правки: gfx_w0_map 324, «каталог + шапка +
клип» 2 694 — работа просто переехала из одной статьи в другую.

Правку оставляю: она не вредит, убирает лишнее переключение W3 и делает
контракт честнее (страница мапится один раз).  Но как способ снять
накладные блита она не работает — фиксированная часть 6 765 -> 6 663.

Замеры: лёгкая позиция 438 978 -> 438 570; тяжёлая 602 574 (прошлый замер
617 487 снят до P16, поэтому напрямую не сравним).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:40:24 +03:00
snark13 6f077b0d6d Разбор P14: он сводится к P4 (цене блита)
Fore-проход Кида в тяжёлой позиции (89 268) разложен зондами:

  вход + set_clip + char_footprint   10 872
  арифметика границ окна              3 786
  шов ворот + overlay-цикл            3 294
  ЦИКЛ fore_tile ПО ТАЙЛАМ           67 854   76 %
  gate_over_char + хвост              3 462

Счётчик показал, что цикл обходит ВСЕГО 4 тайла, и 3 из них реально
рисуют.  То есть 67 854 — не перебор лишних тайлов и не проверки, а цена
самих блитов переднего слоя: около четырёх блитов по ~16 000, где 6 765
на каждом — фиксированная накладная.

Значит отдельной оптимизации fore-прохода почти нет: срезать можно цену
блита (P4, ~27 000 из 67 854), char_footprint из физики (P10) и слияние
двух трамплинов в банк 2 (~4 000).

P4 поднят в очереди: он бьёт и по fore-проходу (4 блита), и по зелёной
фазе (8 блитов) — то есть работает и в динамике, и в статике.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:29:19 +03:00
snark13 952879e7fa Реестр: три отрицательных результата по оптимизации проверок
Записаны, чтобы не повторять, и с разбором причины.

1. cd_sig_same блоком (сравнение 10 байт циклом вместо 13 сравнений
   полей): по листингу короче (1939 -> 1290), на машине хуже
   438 978 -> 450 426.  Сумма тактов по листингу считает инструкцию один
   раз, а тело цикла исполняется десять раз.

2. cd_touch_pb (пометка «для блита» из file-scope вместо четырёх
   аргументов): 438 978 -> 442 242.  В зелёной фазе блиты идут пакетным
   путём, где нужны все четыре значения, а в регистрах они дешевле, чем
   чтение из статиков.

3. Обёртка pop_cd_hit_slot поверх pop_cd_hit — 1799 против 1318 тактов;
   помогло только когда сравнение переехало внутрь.

Общий урок записан там же: короткий листинг не равно быстрый код, и
снятие аргументов со стека помогает не всегда.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:17:27 +03:00
snark13 68e7d17b14 Реестр: P16 закрыт, P14 уточнён замером трупа
P16 (цианные проверки) — минус 25 818 тремя правками: снимок без
построения структуры, guard_over_kid только когда кого-то рисуем,
проверка слота без пяти аргументов.  Записан и отрицательный результат
внутри третьей: обёртка, которая внутри всё равно звала pop_cd_hit с
пятью аргументами, сделала хуже.

P14 уточнён: fore-проход не «62 778…89 000», а от 4 122 (персонаж
пропущен) до 117 570 (труп Кида в челюстях — широкий кадр в тайле с
передним слоем).  Значит в бою он будет ближе к сотне тысяч.

Бюджет лёгкой позиции: 801 768 -> 438 978.  До цели 430 000 осталось
8 978 — одна правка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:57:47 +03:00
snark13 e501982457 Проверка «задет ли слот» без пяти аргументов: минус 3 684
pop_cd_hit принимает (p, x0, y0, x1, y1) — три последних идут стеком, и
функция целиком уезжает в IX-фрейм: 45 % её тактов на `-n(ix)` (asm).
А зовут её из cd_quiet до восьми раз за кадр.

Новый pop_cd_hit_slot(who, p) берёт координаты прямо из pop_cd, а само
сравнение вынесено в hit_rect с file-scope аргументами.  Первая попытка —
обёртка, которая внутри всё равно звала pop_cd_hit — не дала ничего
(1799 Z80 вместо 1318, то есть стало хуже), и это записано здесь, чтобы
не повторять: снимать аргументы со стека нужно у ТОГО, кто их читает.

asm на путь «спрайт + накладной»: было 1799 + 2x1318 = 4435 тактов Z80,
стало 1221 + 2x1009 = 3239 (-27 %).

Замер 11/15, лёгкая позиция: синяя 219 894 -> 218 052, циан 41 280 ->
39 438, работа кадра 442 662 -> 438 978.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:56:50 +03:00
snark13 f47d79ded8 guard_over_kid — только когда кого-то рисуем: минус 14 118
Разложил остаток цианной фазы (44 007 на трёх вызовах):

  pop_loose_mob_draw        978   гейт mobs_live работает
  guard_over_kid         14 424   трамплин в банк 8 + два objtile_at_char
  pop_char_skip_mask     28 605   трамплин в банк 4 + два cd_quiet

guard_over_kid отвечает на вопрос «кто рисуется поверх кого», а он не имеет
смысла, когда не рисуется никто.  Перенёс вызов ПОСЛЕ pop_char_skip_mask и
сделал условным: при skip == 3 оба слота тихие, и порядок не нужен.

Перестановка безопасна: обе функции только читают, и читают разное —
skip_mask снимок cd_sig, guard_over_kid габариты pop_cd прошлого кадра.

Замер 11/15, лёгкая позиция: циан 55 257 -> 41 280, работа кадра
456 780 -> 442 662.

Проверено в MAME: статика чистая, в бою (Кид сближается и бьёт стража)
персонажи перекрываются правильно, порядок не сломался.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:42:54 +03:00
snark13 379c513087 cd_quiet: сравнение снимка без построения структуры — минус 8 016
cd_sig_make СТРОИТ структуру из тринадцати полей в стековом кадре (то есть
через -n(ix)), и только потом шёл побайтовый цикл сравнения.  А зовётся
проверка четыре раза за кадр: pop_char_skip_mask дважды, и в ней по два
слота.

Новый cd_sig_same сравнивает поля прямо с источником, с ранним выходом на
первом расхождении — у двигающегося персонажа это обычно первое же поле.
cd_sig_make остался: он нужен pop_char_draw, чтобы снимок записать.

Замер 11/15, лёгкая позиция: участок «mob_draw + guard_over_kid +
skip_mask» 47 883 -> 43 875, циан 59 265 -> 55 257, синяя 223 902 ->
219 753, работа кадра 464 796 -> 456 780.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:33:31 +03:00
snark13 52bcc65a62 Особенность: убитый за правым краем страж не виден ни в одной комнате
Вопрос пользователя после боя в комнате 15: Кид вытеснил стража вправо
(из-за края торчал только меч), убил — труп не появился ни в 15, ни в
соседней справа.

Это не наш баг, а сложение трёх механизмов оригинала: физика стража
работает только в полосе x 44..211, поэтому комнату он не менял;
мёртвый за Кидом не идёт (follow_guard требует alive < 0), и leave_guard
сохраняет его в прежнюю комнату; а из чужой комнаты страж не рисуется
вовсе — при Guard.room != drawn_room оригинал гасит слот (seg000:422).

Труп остаётся приписан комнате 15 с guards_x за правым краем: при
возврате восстанавливается там же, то есть вне видимого поля.

Живьём в SDLPoP сценарий не воспроизводился — вывод из чтения кода, о чём
в записи сказано прямо.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:23:41 +03:00
snark13 41deb69013 Реестр: P15 закрыт замерами обеих позиций
Лёгкая: 628 542 -> 464 796 (-163 746), персонажи не рисуются вовсе.
Тяжёлая: 758 358 -> 617 487 (-140 871), рисуется только Кид — он
действительно стоит под пламенем, а страж нет.  Пятирастровые кадры в
тяжёлой позиции исчезли (было 27 %).

Итог восьми позиций: 801 768 -> 464 796 в лёгкой (-42 %).  До цели
430 000 осталось 35 000 в лёгкой и 187 000 в тяжёлой.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:09:47 +03:00
snark13 767d6f78a4 P15: метка «фон трогали» стала точной — минус 167 880 тактов на кадре
Две правки, обе про ложные срабатывания пропуска отрисовки персонажа.

1. МЕТКА: вместо «маска колонок по 32 px на ТРИ ряда по 63 px» теперь на
   каждую колонку хранится диапазон затронутых y (ymin/ymax, 40 байт на обе
   страницы).  Прежняя гранулярность склеивала касания внутри ряда: пламя
   факела занимает y 33..50, клинок стоящего стража — y 59..65, между ними
   девять пикселей зазора, а метка считала слот задетым.

2. ПРОВЕРКА: cd_quiet сверяет с меткой спрайт и накладной (клинок, брызги)
   ДВУМЯ ОТДЕЛЬНЫМИ прямоугольниками, а не объединённым bbox.  Объединение
   включает пустой угол между ними, и он ловил касания, которых нет: спрайт
   стража лежит в колонке 8, клинок уходит в колонку 7 на y 59..65, пламя
   метит колонку 7 на y 33..50 — прямоугольник «спрайт + клинок»
   (x 241..284, y 46..84) цеплял метку углом.

Без второй правки первая почти ничего не дала (632 676 против 628 542 до
неё): объединённый bbox продолжал ловить ложное пересечение.

Замер 11/15:

  фаза      до P15    после
  синяя    259 050   223 902   (heal тоже перестал платить)
  зелёная  181 494   181 494
  циан     194 262    59 406
  работа   632 676   464 796

Проверено в MAME: в статике картинка чистая, в динамике (пробежка, бой,
переход в соседнюю комнату) хвостов и просвечивания нет.  Хост-тесты
зелёные.

Заодно найден и исправлен собственный баг первой редакции: обе страницы
обновлялись по условию, проверяющему только страницу 0, и после
pop_cd_clear(0) метка страницы 1 переставала расти.  Плюс pop_cd_init:
пустая колонка обозначается ymin = 255, а нули от crt0 читались бы как
«затронута строка 0».

У ОРИГИНАЛА такой метки нет вовсе: и Apple II (FRAMEADV.S RedBlockFast,
шесть буферов по блокам), и SDLPoP (set_redraw_fore) метят целыми тайлами,
но им это не мешает — персонаж у них рисуется каждый кадр безусловно.
Пропуск неизменившегося персонажа — наша добавка, поэтому и точность метки
нужна выше оригинальной.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:04:46 +03:00
snark13 17c41b32de P15 переписан: перекрытия нет, виновата грубость метки
Пользователь поймал ошибку в моём расчёте, глядя на экран: страж целиком
правее пламени, пересекаться может только меч.

Проверка по памяти машины подтвердила и уточнила:

  страж, спрайт   x 257..284  y 18..56
  страж, клинок   x 241..261  y 31..37
  пламя факела    x 232..247  y  5..22

Ошибок было две.  Первая: координаты пламени я взял по предположению
«факел в колонке 7», а он в колонке 6 (пламя рисуется в ячейке правого
соседа).  Вторая, содержательная: ФИЗИЧЕСКОГО ПЕРЕКРЫТИЯ НЕТ ВООБЩЕ — по x
клинок и пламя пересекаются, но по y между ними девять пикселей зазора.

Настоящая причина: pop_cd_touch хранит метку как маску КОЛОНОК по 32 px на
ТРИ ряда по 63 px (cd_row_of).  Пламя (y 5..22) и клинок (y 31..37)
попадают в один ряд 0 и одну колонку 7 — cd_quiet считает слот задетым.
148 302 такта, 23 % кадра, за ложную тревогу.

Решение стало проще и точнее: хранить на колонку диапазон y вместо номера
ряда (10 x 2 байта x 2 страницы = 40 байт).  Расчётом проверено, что это
спасает стража и НЕ спасает Кида в тяжёлой позиции — там перекрытие
настоящее, и он честно перерисовывается.  Вариант с 8-пиксельными полосами
тоже работает, 16-пиксельные уже нет.

Прежние предложения (частичная перерисовка по пересечению, обрезка фона под
персонажем) записаны как НЕ НУЖНЫЕ: они решали задачу, которой нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:40:30 +03:00
snark13 a65da96960 CHAR-PARTIAL-REDRAW: обязательная задача о неподвижном персонаже
Постановка пользователя: проверять, нужна ли отрисовка стража, когда он
не двигается.  Если движется — лишние ~150 000 тактов приемлемы: в
оригинале во время боя число физических кадров на логический тоже растёт
на единицу.

Замер чтением pop_cd из памяти машины показал, насколько цена
несоразмерна поводу:

  страж       x 257..284, y 18..56   28 x 39
  пламя (0,7) x 264..279, y  5..22   16 x 18
  пересечение x 264..279, y 18..22   16 x 5

То есть пламя задевает страже только макушку — 80 пикселей, — а
перерисовывается он целиком за 148 302 такта (85 524 спрайт с клинком и
снимком + 62 778 fore-проход), это 23 % работы кадра.  Пересечение при
этом настоящее: дело не в грубости маски меток, проверено числами.

В задаче записаны два варианта: A — частичная перерисовка только
пересечения (безопаснее, укладывается в контракт pop_cd), B — не рисовать
фон там, где он всё равно перекрыт неподвижным персонажем (дешевле, но
обрезанное пламя попадёт в ОЗУ-копию и heal вернёт дыру, когда персонаж
сдвинется).

Заодно уточнено, чем НЕ является P13 (вопрос пользователя): это не
перерисовка комнаты заново каждый кадр — такой вариант стоил бы порядка
3 000 000 тактов, семь растровых кадров, и оригинал так тоже не делает.
Разница в цене ПОСЕЩЕНИЯ тайла: у нас fore_tile сразу блитит, у оригинала
add_*table только кладёт запись, а рисует один draw_table в конце.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:35:54 +03:00
snark13 d3049693b8 Реестр: сводка и статусы обновлены; замер тяжёлой позиции
Пользователь передвинул Кида на один осторожный шаг вправо (x = 106
вместо 99, колонка та же) — его спрайт начал пересекаться с тайлом (0,3),
где одновременно чомпер и пламя факела.

  фаза      лёгкая    тяжёлая
  синяя    259 500    257 520
  зелёная  181 068    180 870
  циан     187 761    319 842   (+132 081)
  работа   628 542    758 358

Период кадра: 4 растра в 335 кадрах, 5 растров в 123 (27 %).  Это уже не
«стабильно медленно», а рывки.

Куда ушли 132 тысячи: pop_char_draw(KID) 204 -> 54 738 и fore-проход
Кида 4 356 -> 93 486.  То есть Кид из «пропущен» превращается в
полноценного персонажа за ~144 000 — столько же, сколько страж.

Отсюда новая позиция P14: fore-проход персонажа, 62 778 у стража и
~89 000 у Кида, вместе около 152 000 = 20 % работы кадра.  Это самая
дорогая единичная статья.  У Кида он дороже потому, что в его футпринте
лежит чомпер со своим передним слоем.

P3 переведён в «частично сбылось»: выигрыш держится только пока персонаж
не подошёл к анимированному тайлу, а в игре он подходит постоянно.

Итог семи закрытых позиций: 801 768 -> 628 542, то есть -22 %.  До цели
430 000 остаётся снять 199 000 в лёгкой позиции и 328 000 в тяжёлой, а
всё оставшееся в реестре даёт порядка 100 000.  Арифметика не сходится —
в реестр записаны три возможных решения (P13, осознанное расхождение с
оригиналом, принять 4 растра), выбор за пользователем.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:30:04 +03:00
snark13 6faf81016a Замер цианной фазы: крупного лишнего в отрисовке персонажа нет
Циан 188 004 не двигался ни от P1, ни от P5, ни от P2b — разложил его
зондами.

Хорошая новость: Кид УЖЕ пропускается (204 такта на pop_char_draw), то
есть надежда P3 сбылась после P1 — метка от чомпера до него больше не
дотягивается.  Страж же перерисовывается каждый кадр честно: пламя
правого факела (0,7) рисуется в ячейке (0,8), где он стоит, и реально
накрывает ему голову (пламя занимает y 5..22, страж 12..62).

Отрисовка стража — 148 302:

  pop_char_fore (2 трамплина в банк 2 + обход тайлов)  62 778   42 %
  клинок (sword_draw + overlay_add + clip_add)         27 522   19 %
  блит спрайта + clip_char_right                       20 982   14 %
  загрузка кадра и геометрия                           12 696    9 %
  pop_clip_char_top (трамплин банк 4 -> банк 3)         8 658    6 %
  снимок прямоугольника + cd_clip_add                   7 890    5 %
  gfx_w0_unmap + cd_sig_make                            4 968    3 %
  вход + cd_heal                                        2 946    2 %

Единственная явно лишняя статья — трамплин clip_char_top, и снять его
непросто: функции нужны get_tile и таблицы деления из банка 3, перенос в
резидент вернёт тот же трамплин внутрь.  Остальное — работа, которую
персонаж действительно делает.

Зонды переставлены с уже закрытых замеров (loose_tick, физика) внутрь
pop_cdraw; оснастка снимается позицией P12, когда оптимизация закончится.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:53:49 +03:00
snark13 068e21b56a Реестр: P2b закрыт; иерархия референсов и трамплины в цикле
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:37:34 +03:00
snark13 f8493a4c04 P2b: луч видимости стража 36 786 -> 13 002 (-65 %)
Замер отделил луч от pop_frame_timers: таймеры со всеми тремя
спецсобытиями уровней стоят 1 962, луч — 36 786, то есть 5,6 % работы
кадра на девять чтений байта.

Причина оказалась НЕ в алгоритме.  Сверка трёх референсов:

  SDLPoP (seg003:688) — идёт по x с шагом 14 и на каждом шаге переводит x
    в колонку делением.  Причём сам SDLPoP признаёт в комментарии, что
    «DOS PoP does this: tile_div_tbl[xpos]» — то есть оригинал брал
    таблицу, а порт заменил её на / и %, потому что на 32 битах так проще.
  Apple II (MISC.S CHECKALERT) — тот же алгоритм байт в байт, но перевод
    x -> блок через таблицу BlockTable[x].  Ровно то, что у нас уже было
    сделано (POP_TILE_DIV, 2026-08-10).
  mininim — другая архитектура (тайловые позиции, своя механика), для
    сравнения реализации не годится.

То есть алгоритмически мы уже были на уровне Apple II, а платили за
другое: pop_tile_at объявлен __banked, луч живёт в guards.c (банк 1), и
на КАЖДУЮ колонку шёл трамплин банк 1 -> банк 3.  На сцене 11/15 (Кид в
колонке 2, страж в 8) это девять трамплинов за кадр.

Сделано:

  1. луч переведён на КОЛОНКИ вместо x-координат.  Это эквивалентно:
     начальные x — ровно центры тайлов персонажей, а обратный перевод даёт
     ту же колонку (floor((58 + col*14 - 58)/14) == col).  Ушли 16-битный
     шаг, 16-битное сравнение и индексация таблицы на каждой итерации;
  2. тайлы отрезка забираются ОДНИМ банковым вызовом (pop_row_tiles)
     вместо девяти;
  3. внутри pop_row_tiles — быстрый путь для отрезка целиком внутри
     комнаты: get_tile при ряде 0..2 и колонке 0..9 сводится ровно к
     g_fg[row*10+col] & 0x1F, идём указателем;
  4. буфер тайлов — file-scope, а не локальный массив (иначе каждое
     чтение это -n(ix)).

Замер по шагам: 36 786 -> 24 048 (колонки + один вызов) -> 13 002
(быстрый путь + буфер).  Синяя фаза 283 215 -> 259 500, работа кадра
654 990 -> 628 542, то есть -26 448 при ожидании -30 000.

Кэш-гейт «пересчитывать только при смене позиции» НЕ понадобился:
расхождения с оригиналом нет, луч считается каждый кадр, как и должен.

Поведение проверено в MAME: страж в боевой стойке, но не идёт — между ним
и Кидом чомпер, то есть can_guard_see_kid = 1 («видит, но не пойдёт»).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:37:06 +03:00
snark13 da17a48576 Реестр: P2a закрыт, следующий P2b
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:09:30 +03:00
snark13 2bdaf0f4cd P2a: coll_scan переведён на 8 бит и снят с IX — минус 3 486 в коллизиях
Разбор pop_phys_tick (61 266 тактов на НЕПОДВИЖНОМ Киде) зондами по
звеньям kid_phys:

  check_collisions      33 846   55 %
  хвост (spike/spiked/chomped/knock/leave/save)  16 188   26 %
  check_press            4 140
  check_action           2 622
  loadkid_and_opp        2 148
  determine_col          1 182
  fall_accel+fall_speed    582
  bump_into_opponent       198

Внутри check_collisions: три coll_row (сканирование рядов) — 23 256,
подготовка окна 3 240, set_char_collision 1 788, обход пересечения 5 562.

Сгенерированный asm coll_scan показал 322 такта Z80 на ПУСТУЮ колонку
(с wait-state'ами 773 — ровно замеренные 750), из них 137 (43 %) —
обращения через IX-фрейм, и четыре 16-битные операции на колонку там,
где от колонки зависит один операнд.

Сделано:

  1. вся арифметика цикла в 8 битах.  scan_left = x_bump[col+5] + TILE_MIDX
     при колонках окна -2..11 лежит в [37, 233], wall_dl в [-1, 10],
     wall_dr в [0, 13] — суммы в [36, 246], переполниться не могут.
     Границы персонажа приводятся к 8 битам с клипом, и клип точен: порог
     ниже 37 означает «условие не выполнится никогда», выше 233 — «всегда».
  2. dst снят с IX-фрейма в file-scope (scan_dst).

ОТРИЦАТЕЛЬНЫЙ РЕЗУЛЬТАТ, не повторять: предпосчёт таблиц порогов по типу
стены (thr_l[6]/thr_r[6] на кадр) сделал ХУЖЕ — check_collisions
33 846 -> 36 570, синяя фаза +10 269.  Колонок в окне четыре-пять, а типов
стен пять: кэша получилось больше, чем потребления.

Проверено на кодогенерации: register на параметре-указателе SDCC 4.5 z80
проигнорировал (asm байт в байт), а file-scope дал 607 -> 454 такта.

Итог: check_collisions 33 846 -> 30 360 (-10 %), работа кадра
657 882 -> 654 990.  Крупной статьи в физике нет: остаток размазан по
десятку честных проверок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:06:43 +03:00
snark13 de68eb5cec P1: чомпер перерисовывался неизменной позой — минус 110 802 такта
Позиция заводилась с НЕПОЛНЫМ диагнозом.  Я приписал 190 260 тактов
пометке от факела (пламя лежит в ячейке правого соседа, то есть поверх
чомпера, и запекается каждый кадр).  Правка по этому диагнозу не дала
ничего: 769 002 против 768 684.

Зонд pop_dbg_kind показал факт: все 312 перерисовок прогона — вид
POP_RD_CHOMP, полная, и ни одной от факела.  Собственная пометка чомпера
просто перебивала пометку соседа.

Настоящая причина нашлась сверкой с animate_chomper (seg007:0448).
Оригинал заканчивает её так:

    if ((curr_modifier & 0x7F) < 6) redraw_at_trob();

то есть перерисовывает чомпер только пока фаза меньше 6 — пять кадров из
пятнадцати.  Это не оптимизация оригинала, а следствие таблицы поз:
chomper_fram1 = {3,2,0,1,4,3,3}, и с фазы 5 до конца круга поза одна и та
же.  Мы метили тайл каждый кадр, пока trob жив, а живёт он всё время, пока
Кид в том же ряду — то есть платили полный draw_tile плюс heal 32x64 за
неизменную картинку в двух третях кадров.

Сделано:

  1. пометка только при фазе < 6; на фазе 5 — обе страницы дабл-буфера
     (она последняя рисуемая, её поза обязана лечь на обе; вторую страницу
     пометка догоняет в кадре фазы 6, где поза та же — CHOMP_FRAM1[6] == 3);
  2. новый вид POP_RD_CHOMP_ANIM -> pop_chomp_anim_draw: три блита графики
     чомпера поверх свежего пламени, без heal и без остальных слоёв — порт
     ветки redraw_frames_anim (seg008:0211), где оригинал делает ровно
     draw_tile_anim_topright / draw_tile_anim_right / draw_tile_anim и
     никакого wipe;
  3. приоритет полной перерисовки над anim в pop_set_redraw: у оригинала
     это два независимых счётчика и full побеждает, а у нас вид один на
     тайл, и без проверки исход решал бы порядок trob'ов в списке.

Обе половины работают — замер даёт 40 % полных перерисовок и 60 % лёгких.
Работа 768 684 -> 657 882 (медиана), зелёная 294 510 -> 183 420.  В 40 %
кадров цена прежняя: там поза реально меняется, это честная работа.

Циан не сдвинулся ни на такт, то есть надежда P3 (Кид перестанет будиться
каждый кадр) пока не оправдалась — метки продолжают его будить.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:30:01 +03:00
snark13 f81b30eb68 Замер P2 и P6: главные статьи — физика двух Char и луч видимости
Синяя фаза (286 518) разложена зондами на 12 участков, process_trobs
(89 784) — на три.

Гипотеза, с которой я входил в замер, ОТВЕРГНУТА.  Я ждал, что дорого
обходятся банковые трамплины на спецсобытиях уровней — по аналогии с
pop_clip_char_top, где трамплин ради одной проверки стоит 8 892.  На деле
три спецсобытия (skel, mouse, killed_shadow) вместе стоят 1 650: они
гейтятся внутри и на уровне 11 выходят сразу.

Настоящие статьи синей:

  физика двух Char        105 246  (61 266 Кид + 43 980 страж)
  heal двух Char           67 734
  луч видимости стража  до 37 032  (вместе с frame_timers)
  pop_ctrl_tick            18 648
  логика стража            19 932

Физика съедает 13,7 % работы кадра при том, что ОБА персонажа стоят и кадр
позы не меняется.  Цена измерена, причина нет — это отдельная позиция P2a.
Луч видимости считается каждый кадр, хотя никто не двигался: гейт по смене
позиции/комнаты — позиция P2b, ждём −30 000.  heal отдельной правки не
требует, он уйдёт вместе с P1/P3.

process_trobs: префетч кодов тайлов с маппингом окна 0 — 11 058, обход
самих trob'ов ~43 000 (pop_trob_modif зовётся банковым вызовом на КАЖДЫЙ
trob, хотя комната одна), два факела ~36 000.  Цена одного pop_pot_b
измерена отдельно: 17 346, и это единственная группа в распределении —
значит в кадре его зовут только факелы.  Пиксели пламени 16x18 — 1 716,
то есть 10 % цены.

Заодно посчитано, достижим ли период 3 растра: снять надо 338 000, а сумма
ВСЕХ известных позиций даёт 357 000, из которых 160 000 держатся на одной
(P1).  Цель достижима, но без запаса.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:05:58 +03:00
snark13 6d7c1c8b6b P5: гейты холостого хода в pop_loose_tick — минус 33 840 тактов на кадре
Замер 11/15 показал, что loose-механика берёт 28 872 такта в комнате, где
не анимируется ни одна плита и не летит ни один кусок.  Раскладка зондами
m9..m12: два цикла по тайлам 9 852, обход 14 слотов mob 12 090, поиск
куска над головой Кида 5 868 (там ещё и банковый трамплин).

Два гейта:

  loose_any (статик pop_map.c) — «идёт ли анимация плит».  Ставят пять мест
  записи ненулевой фазы: make_loose_fall, ветка потолка в check_press,
  do_knock для обоих рядов и восстановление фазы из room_modif при входе в
  комнату.  Снимает его сам цикл, по факту прохода, в котором не осталось
  ни одной живой фазы.

  pop_mob_busy (резидент pop_state.c) — «занят ли слот падающего куска»
  (active или дочистка clean).  Ставит mob_alloc, снимает обход по факту
  пустой таблицы.  В резиденте, а не в pop_room.c, потому что читает его
  pop_map из банка 3, а писучие статики банкового модуля наружу не видны.

Гейт отвечает не на «есть ли в комнате плиты», а на «идёт ли анимация»: у
лежащей плиты-потолка фаза нулевая, и крутить нечего (вопрос пользователя).
Асимметрия намеренная — ложная единица стоит одного холостого прохода,
ложный ноль стоит застывшей навсегда плиты, поэтому взвод стоит рядом с
КАЖДОЙ записью, а снятие только по факту пустого прохода.

Стало: 132 / 996 / 546, вся функция 28 872 -> 2 760.  На кадре работа
801 768 -> 767 928.  Ожидание по реестру было -28 000.

Покрытие: новый phys_loose_gate_survives_room_change на пятое место взвода
(фаза восстановлена входом в комнату) — единственное, которое не прогонял
ни один тест, и дающее самый тихий отказ.  Мутационная проверка: со снятым
взводом тест падает (фаза 3 вместо 4).

Заодно отладочный старт сразу в целевую комнату: make ROOM=15 POS=2
(дефолт), roomtest стартует в 11/15 с Кидом в (0,2).  kid_init ставит
x = x_bump[col] + TILE_SIZEX, а это левая граница СЛЕДУЮЩЕЙ колонки — с неё
физика относила Кида в тайл чомпера, и он погибал на старте (найдено
пользователем).  Сдвиг внутрь на 2: колонку определяет весовая точка кадра,
поэтому число снято замером, а не выведено геометрией.

План работ между сессиями — docs/perf_registry.md §4: очередь позиций со
статусами, текущий бюджет сцены, рецепт её воспроизведения и метод замера.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 10:49:19 +03:00
snark13 fd570c7eb8 Замер сцены 11/15 и единый реестр оптимизаций
Новая целевая сцена: уровень 11 комната 15 — два факела, чомпер, страж.
В отличие от 13/23 (разовый пик на каскаде плит) здесь дорога САМА
статика: Кид и страж стоят, а кадр стоит 801 768 тактов = 1,86
растрового кадра, период 4 растра во всех 866 интервалах прогона.

Фазы: синяя 293 238 (heal 141 048 + логика 152 190), зелёная 320 916
(loose_tick 28 872 + process_trobs 92 448 + redraw_needed 190 260),
циан 187 758.  Впечатление «циан ~150 % кадра» не подтвердилось: за 867
кадров разброс циана 174 такта, это 0,44 растра.

Главная находка — 190 260 тактов на ОДИН тайл (pop_dbg_rdmax_tot = 1).
Пламя факела запекается в ячейке правого соседа, то есть поверх чомпера,
и process_trobs метит соседа (порт set_redraw_anim_right).  Оригинал на
такую пометку рисует ТОЛЬКО слой anim, мы же отвечаем heal 32x64 плюс
полный draw_tile — со всеми слоями, которых пламя не касалось.

Заодно разложена цена одного блита фона (брейкпоинты на резидентных
адресах внутри pop_blit_b, temp0 на входе, 1603 блита): фиксированная
накладная 6 126 тактов на ЛЮБОЙ блит — пролог с IX-фреймом 810,
atlas_image 672, w0_map с чтением шапки 2 400, cd_touch 2 069, unmap 175.
У самого дешёвого блита это 59 % цены, у пламени 16x18 пиксели тянут
лишь 12 %.  Причины ровно те, на которые указал пользователь:
16-битные аргументы там, где хватает 8 бит, и адресация через IX.

perf_registry.md сводит в один отсортированный список всё отложенное из
perf_green_phase (G1-G9), perf_cyan_phase (C1-C7), perf_backlog (1-7),
HEAL-WIDTH и сегодняшние находки — с пометкой замер/модель/гипотеза.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 10:21:02 +03:00
snark13 09f32ce834 Уровни 10-12 прошли предварительный тест; HEAL-WIDTH связан с G8
Уровень 11 комната 14 проверена пользователем визуально после варианта B —
порядок падающего куска, соседней плиты и Кида корректен.  На 10 и 12
багов не найдено.

HEAL-WIDTH и G8 сведены как две половины одной темы: G8 про ширину ЗАПЕЧКИ
соседнего тайла (60 вместо 28 нужных, плюс draw_tile соседа дважды на
пометку), HEAL-WIDTH про ширину HEAL'ов (64 вместо фактических 58/57).
Оговорка из G8 перенесена: 60 = 32 свой тайл + 28 собственный свес, для
запечки самого тайла это минимум, сужать можно только пометку СОСЕДА.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:00:23 +03:00
snark13 4db60c750f Вариант B: кусок с завёрнутым рядом рисуется под всем (корзина 30)
Порт правила оригинала, разобранного в 272cf8f.  y_to_row_mod4 даёт −1 и
для куска выше потолка, и для ушедшего ниже комнаты; get_tilepos_nominus
сводит оба в тайл 30, а объекты тайла 30 рисуются в redraw_needed_tiles
ПЕРВЫМИ, до всего обхода тайлов.

Что сделано:
 - defer = 0 для таких кусков: они под всем, включая Кида.  Раньше
   сравнение рядов читало −1 как «обходится последним» = «поверх всего»;
 - оверлею отдаётся ориентир 3 («раньше любого ряда 2,1,0») вместо сырого
   −1 — гейт other_overlay_tile перестал отбрасывать возврат соседа, из-за
   чего тело плиты не возвращалось и оставался только её торец из
   переднего слоя;
 - в набор перекрываемых тайлов добавлена СВОЯ клетка (только для этого
   случая: у куска в обычном ряду объект вливается в midtable после частей
   своего тайла, и перерисовывать её нельзя).

Отладочная обвязка разбора (журнал решений оверлея, маска перекрывающих
тайлов) снята; счётчик перерисовок за кадр в pop_redraw_needed оставлен —
он дешёвый и пригодится для HEAL-WIDTH.

ЗАМЕР 13/23, 3032 кадра, против тега mob-order-B-start:
  работа  888 984 -> 913 848  (+24 864)
  синяя   159 804 -> 159 810
  зелёная 427 242 -> 440 418  (+13 176)
  циан    378 864 -> 393 000  (+14 136)
Период кадра не изменился: 4 растра в 23 кадрах, 5 в двух.

Зелёная вышла за растровый кадр (440 418 против 430 000).  Детализация:
pop_loose_tick 185 826, из них pop_loose_mob_tick 168 180; тробы +
redraw_needed 337 800.  Разбор и план возврата тактов — HEAL-WIDTH.

Визуальная проверка комнаты 14 за пользователем: поймать кадр с куском
снимками мне не удалось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:10:41 +03:00
681 changed files with 79711 additions and 1638 deletions
+29
View File
@@ -0,0 +1,29 @@
[mcp_servers.mame-z80]
command = "/Users/alex/.local/bin/uv"
args = [
"run",
"--python",
"3.12",
"--no-project",
"--with",
"mcp<2",
"/Volumes/SAM8/Projects/DIY/Z80/Sprinter/C-Compiler/mame/sources/MAME/src/mame_mcp.py",
]
[mcp_servers.mame-z80.tools.clear_breakpoint]
approval_mode = "approve"
[mcp_servers.mame-z80.tools.list_breakpoints]
approval_mode = "approve"
[mcp_servers.mame-z80.tools.press_key]
approval_mode = "approve"
[mcp_servers.mame-z80.tools.step_out]
approval_mode = "approve"
[mcp_servers.mame-z80.tools.debugger_command]
approval_mode = "approve"
[mcp_servers.mame-z80.tools.pause]
approval_mode = "approve"
+18
View File
@@ -8,6 +8,7 @@ build/
# sprinter-cc per-example intermediate directory
.sprinter-cc-*/
.resource-stamps/
# Per-program final/intermediate outputs landing alongside the source
# (real apps under examples/ and libc feature tests under tests/).
@@ -120,3 +121,20 @@ mame/
# .git внутри (в коммите стали бы битыми gitlink-ссылками).
docs/extra/
docs/sources/
# Записи музыки DOS-версии PoP (43 МБ в четырёх форматах) — ИСТОЧНИК для
# toolchain/pop_pack_music.py, а не ресурс сборки: на диск игры уходят уже
# упакованные poc/res/music/*.bin, и они в репозитории есть. Если понадобится
# перегенерировать музыку — положить сюда PoP1_DOS_music (flac).
applications/PoP/PoP1_DOS_music/
# R1 — рабочая копия roomtest для экспериментов пользователя, в репозиторий
# не идёт (сама roomtest и есть версируемая ветка разработки).
applications/PoP/R1/
# SprPoP — автономное приложение, и правила игнора у него СВОИ:
# applications/SprPoP/.gitignore. Он написан так, чтобы стать корневым
# .gitignore, когда SprPoP выделят в отдельный репозиторий, — поэтому
# здесь его содержимое НЕ дублируется (иначе разъедется). Коротко: в
# репозиторий не идут assets/orig/ (чужие данные — их выкачивает
# `make fetch`) и assets/packed/LEVELS/ (уровни из оригинала как есть).
+83
View File
@@ -0,0 +1,83 @@
# Sprinter C-Compiler — правила проекта
Target-слой SDCC 4.5 (z80) для компьютера Sprinter Sp2000: crt0,
линковка, libc, mkexe. Общение и комментарии — на русском.
## Сборка и проверка
```
make # tools + lib + libbgi + все тесты (45) + examples
make -C libc # только libc → lib/sprinter.lib (fast) + sprinter_safe.lib
make -C libbgi # только BGI → lib/bgi256.lib (fast) + bgi256_safe.lib
make floppy # упаковать все .exe в mame/v306/IMG/mc.img
make size-check # размерный регресс: _CODE vs docs/size_baseline.tsv
make size-baseline # принять текущие размеры эталоном
```
Обе библиотеки собираются в двух вариантах: fast (дефолт; `-D*_NOCHECK`
параметр-валидации вырезаны) и safe (линкуется по `sprinter-cc --safe`).
Критичные гарды (напр. _fd_guard — 9-й OPEN вешает DSS) — в ОБОИХ.
Графика (BGI) — отдельная библиотека libbgi/ (см. ниже). Программа,
использующая graphics.h, собирается с `--gfx 256` (или `--gfx 16` в
Фазе 2): sprinter-cc подлинкует lib/bgi256.lib и добавит -I libbgi/include.
Одиночный тест: `cd tests/<имя> && make run` (пакует exe + EXTRA_DATA на
дискету и запускает MAME автоматически через `toolchain/mame_interactive.py`,
снимает скриншоты, выводит пути). Для сложных сценариев (диалог, несколько
шагов ввода) — прямой вызов:
`python3 toolchain/mame_interactive.py tests/<имя>/<имя>.exe --snap T1,T2 --timeout T`.
Скриншоты лежат в `mame/v306/snap_auto/sprinter/` (читаются инструментом Read).
Подробности: `docs/mame-autotest.md`.
После правок libc/libbgi: пересборка от чистого листа (`make -C libc clean`
/ `make -C libbgi clean`) не обязательна — stale .rel чистятся
автоматически; `make size-check` обязателен (рост _CODE без причины —
регрессия).
## Правила libc
- **1 публичная функция = 1 .c-модуль** (линкер тянет .rel целиком —
гранулярность файлов = гранулярность DCE). Никакой группировки
«используются вместе». Internal-хелперы — тоже по одному на модуль
(`_`-префикс); общие статики — в отдельные data-модули
(`_xxx_state.c`); internal-заголовки (`_file.h`, `_gfx.h`, …) —
рядом с исходниками, НЕ в libc/include.
- Имя файла = имя функции. libc/Makefile собирает wildcard'ом —
ничего регистрировать не надо.
- Комментарии — на русском; шапка модуля объясняет что/зачем + ABI.
- File-scope переменные НЕ инициализировать `= 0` (crt0 зануляет
_DATA; см. memory/sdcc_static_storage_gotcha).
- asm-связки между модулями: `call/jp _global` — ок; `jr/djnz` через
границу и fall-through — НЕЛЬЗЯ (docs/libc-split-asm-cases.md).
- Заголовки: сначала пробовать include_next-паттерн; полная замена
SDCC-заголовка обязана дублировать его контракт
(docs/libc-headers.md).
- Справочник API — docs/libc-reference.md (обновлять при добавлении
функций).
## ABI и платформа (кратко; детали в memory/)
- SDCC `__sdcccall(1)`: arg1 → HL (8-бит → A), arg2 → DE, остальные
на стеке (callee-pops в __naked); **возврат int/ptr в DE**, uint8 в A.
IX callee-saved (в __naked с IX — push/pop обязательны).
- ESTEX (rst #0x10): CF=1 — ошибка, код в A → `call __errno_set`;
все регистры клобберятся (IX сохранять); стек обязан быть в W2.
- BIOS (rst #0x08): строки/буферы в #4000-#BFFF.
- Квирки: ESTEX WRITE возвращает DE=0 на успехе (судить по CF/A);
лимит 8 файловых манипуляторов, 9-й OPEN ВЕШАЕТ DSS (_fd_guard);
ENV $46: A=0 = NOT FOUND.
- Перед обвинением компилятора/железа — подтвердить артефактом
(сгенерированный .asm в libc/build/ или libbgi/build/, дамп, репро) — см.
memory/defer_unexplained_quirks.
## Структура
- `libc/<area>/*.c` — модули libc (ядро, БЕЗ графики); `libc/include/` — публичные заголовки libc
- `libbgi/` — графика BGI (отдельная библиотека): `common/` — mode-agnostic (один исходник, .rel в обеих driver-библиотеках), `bgi256/` + `bgi16/` — mode-specific leaf'ы (реальные реализации, без обёрток); `include/` — graphics.h + gfx.h; `_bgi.h` — внутренний заголовок. Собирает `lib/bgi256.lib``bgi16.lib` в Фазе 2). Выбор режима линковкой: `--gfx 256` / `--gfx 16`.
- `runtime/` — crt0-семейство, heap, bank (bank.s собирается per-build)
- `bin/sprinter-cc` — обёртка компилятора; `toolchain/mkexe` — упаковщик
- `tests/` — по одному API/фиче; `examples/` — реальные приложения
- `docs/` — дизайн-доки; `docs/TODO.md` — roadmap
- `third_party/solid-c/` — нативный Sprinter C (референс, CP866;
их ABI несовместим — только как образец)
+3 -1
View File
@@ -80,7 +80,9 @@ floppy: tests examples tests/seek/big.txt
# Модульные тесты под ucsim_z80. Обвязка — testkit/, сами наборы лежат
# рядом с кодом, который проверяют. MAME не нужна, идут за секунды;
# ucsim идёт в комплекте нашего SDCC.
HOST_TEST_DIRS := testkit applications/PoP/roomtest/tests-host
# applications/PoP/roomtest заморожена (её ветка развития — SprPoP), поэтому
# её набор здесь больше не гоняется.
HOST_TEST_DIRS := testkit applications/SprPoP/tests/host
host-tests:
@for d in $(HOST_TEST_DIRS); do $(MAKE) -C $$d || exit 1; done
+2 -1
View File
@@ -144,7 +144,8 @@ sprinter-cc -o foo.exe foo.c [more.c ...] [options]
--memory-manual SPEC explicit placement (CODE=W1|W2,DATA=W1|W2|SAME,BANKED=W1|W3)
--stack-size N bytes reserved for the stack (default ~1278)
--crt0=TYPE default | minimal | banked | small
--bank N=FILE.c compile FILE.c into bank N (repeatable, max 15)
--bank N=FILE.c compile FILE.c into bank N (repeatable, consecutive 1..15;
crt0 bank count is generated automatically)
--debug enable runtime diagnostics (defines DEBUG_RT)
-I PATH extra include path
-L 0xADDR / -E / -S override load / entry / stack addresses
+55 -10
View File
@@ -13,6 +13,7 @@
# # EXTRA_SRCS := helper.c util.c # additional .c files in this dir
# # EXTRA_FLAGS := --crt0=minimal # passed through to sprinter-cc
# # EXTRA_DATA := test.txt # extra files to add to `make floppy`
# # HDD_DEST_DIR := games/myapp # общий каталог файлов в `make hdd`
#
# include $(PROJ_ROOT)/app.mk
#
@@ -36,14 +37,40 @@ LIB := $(PROJ_ROOT)/lib/sprinter.lib
MAME_DIR := $(PROJ_ROOT)/mame/v306
FLOPPY_IMG := $(MAME_DIR)/IMG/mc.img
HDD_IMG := $(MAME_DIR)/IMG/test_hdd.chd
# ?= — приложение со своим каталогом выхода (applications/SprPoP) держит
# образ у себя и связывает его с MAME символьной ссылкой.
HDD_IMG ?= $(MAME_DIR)/IMG/test_hdd.chd
MAKE_DISK := $(MAME_DIR)/make_disk.py
MAKE_HDD := $(PROJ_ROOT)/toolchain/make_hdd.sh
MAKE_HDD ?= $(PROJ_ROOT)/toolchain/make_hdd.sh
RUN_MAME := $(MAME_DIR)/run_mame.sh
# Optional knobs — see top of file.
MEMORY ?= tiny
SOURCES := $(EXAMPLE).c $(EXTRA_SRCS)
# SRC_DIR / BUILD_DIR — раскладка приложения, которое НЕ держит исходники и
# выхлоп в одной папке с Makefile (applications/SprPoP: src/ и build/). По
# умолчанию обе пусты, то есть всё как было: ./$(EXAMPLE).c → ./$(EXAMPLE).exe.
# Пустое значение обрабатывается отдельной веткой намеренно: "./prog.exe" и
# "prog.exe" — разные имена целей, и склеивать префикс безусловно нельзя.
SRC_DIR ?=
BUILD_DIR ?=
ifeq ($(strip $(SRC_DIR)),)
MAIN_SRC := $(EXAMPLE).c
else
MAIN_SRC := $(SRC_DIR)/$(EXAMPLE).c
endif
ifeq ($(strip $(BUILD_DIR)),)
EXE := $(EXAMPLE).exe
else
EXE := $(BUILD_DIR)/$(EXAMPLE).exe
endif
SOURCES := $(MAIN_SRC) $(EXTRA_SRCS)
# Аргументы упаковщика HDD. Обычно это exe и EXTRA_DATA; приложение со
# своей раскладкой каталогов может переопределить переменную до include.
HDD_PACK_ARGS ?= $(EXE) $(EXTRA_DATA)
# Общий каталог назначения внутри HDD. Пустое значение сохраняет прежнюю
# укладку в корень; вложенные КАТАЛОГ:файл считаются относительно него.
HDD_DEST_DIR ?=
CC_FLAGS := --memory $(MEMORY)
ifneq ($(STACK_SIZE),)
@@ -51,15 +78,33 @@ CC_FLAGS += --stack-size $(STACK_SIZE)
endif
CC_FLAGS += $(EXTRA_FLAGS)
all: $(EXAMPLE).exe
all: $(EXE)
# runtime/*.s (crt0-семейство, bank.s, heap.s) собираются per-build
# внутри sprinter-cc — без этой зависимости их правка не перелинкует
# уже собранный exe (кусало: фикс bank.s не подхватился).
RUNTIME_DEPS := $(wildcard $(PROJ_ROOT)/runtime/*.s)
$(EXAMPLE).exe: $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS)
# ПРОВЕРКА БАНКОВЫХ ВЫЗОВОВ — сразу после линковки, пока артефакты свежие.
# Ловит прямой `call` в чужой банк: он собирается МОЛЧА и стреляет диким
# переходом в пустой хвост банка (разбор — в шапке скрипта). Запускается
# только если банки вообще есть, то есть по наличию каталога сборки с
# bankN_*.asm; обычным небанковым программам ничего не стоит.
BANK_CHECK := $(PROJ_ROOT)/toolchain/check_bank_calls.py
# ПРОВЕРКА ISR-СТАБА W0-СТРАНИЦ — там же, по свежей карте. Ловит стаб
# _gfx_w0_isr, оставшийся в W1: программа, кладущая свои страницы в W0
# (атласы спрайтов, gfx_w0_map), получает недетерминированные зависания,
# когда прерывание приходит во время вызова DSS и W1 перемаплен. Только
# предупреждение: страницы в W0 кладут не все. Разбор — в шапке скрипта.
W0ISR_CHECK := $(PROJ_ROOT)/toolchain/check_w0_isr.py
$(EXE): $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS)
$(if $(strip $(BUILD_DIR)),@mkdir -p $(dir $@))
$(SPRINTER_CC) $(CC_FLAGS) -o $@ $(SOURCES)
@d=$(dir $@).sprinter-cc-$(EXAMPLE); \
if ls $$d/bank*_*.asm >/dev/null 2>&1; then python3 $(BANK_CHECK) $$d; fi; \
if ls $$d/*.map >/dev/null 2>&1; then python3 $(W0ISR_CHECK) $$d; fi
$(MKEXE):
$(MAKE) -C $(PROJ_ROOT)/toolchain/mkexe
@@ -73,13 +118,13 @@ $(LIB):
$(MAKE) -C $(PROJ_ROOT)/libbgi
clean:
rm -rf .sprinter-cc-* $(EXAMPLE).exe
rm -rf $(if $(strip $(BUILD_DIR)),$(BUILD_DIR),.sprinter-cc-* $(EXE))
# `make floppy` packs ONLY this program (+ optional EXTRA_DATA files) into
# the MAME floppy image, replacing whatever was there. Handy for trying a
# single program without rebuilding everything.
floppy: $(EXAMPLE).exe
python3 $(MAKE_DISK) $(FLOPPY_IMG) $(EXAMPLE).exe $(EXTRA_DATA)
floppy: $(EXE)
python3 $(MAKE_DISK) $(FLOPPY_IMG) $(EXE) $(EXTRA_DATA)
@echo
@echo "Floppy ready: $(FLOPPY_IMG) (with $(EXAMPLE).exe$(if $(EXTRA_DATA), + $(EXTRA_DATA))) "
@echo "Run: cd $(MAME_DIR) && ./run_mame.sh"
@@ -91,8 +136,8 @@ run: floppy
# HDD image mounted as disk D: (-hard2 test_hdd.chd). Гораздо быстрее FDD —
# используется MCP-мостом к MAME (run_bridge.sh). После пересборки образа
# MAME ОБЯЗАН полный рестарт (chdman -f = новый inode; см. memory).
hdd: $(EXAMPLE).exe
$(MAKE_HDD) $(HDD_IMG) $(EXAMPLE).exe $(EXTRA_DATA)
hdd: $(EXE)
$(MAKE_HDD) $(if $(strip $(HDD_DEST_DIR)),--dest "$(HDD_DEST_DIR)") $(HDD_IMG) $(HDD_PACK_ARGS)
@echo
@echo "HDD (D:) ready: $(HDD_IMG) (with $(EXAMPLE).exe$(if $(EXTRA_DATA), + $(EXTRA_DATA)))"
@echo "ВНИМАНИЕ: перезапусти MAME (run_bridge.sh) — образ пересобран."
+6 -2
View File
@@ -1,6 +1,6 @@
# `applications/PoP/docs` — индекс + сводка по форматам ресурсов
## Индекс документов (актуальность на 2026-08-01)
## Индекс документов (актуальность на 2026-08-22)
**Живые планы — читать перед работой:**
@@ -13,7 +13,11 @@
| [`perf_green_phase.md`](perf_green_phase.md) | **ЗЕЛЁНАЯ фаза (слой фона)**: раскладка тактов, способы ускорения (G1..G6), журнал правок — рабочий документ между сессиями. 2026-08-17 |
| [`perf_cyan_phase.md`](perf_cyan_phase.md) | **ЦИАН фаза (персонажи + передний слой)**: раскладка тактов, способы ускорения (C1..C7), журнал правок — рабочий документ между сессиями. 2026-08-17 |
| [`perf_backlog.md`](perf_backlog.md) | Отложенная оптимизация отрисовки с замерами 2026-08-10 + **как мерить** (wait-state'ы, границы кадра). Позиции 1–7 переехали в фазовые документы выше |
| [`quicksave_plan.md`](quicksave_plan.md) | **QuickSave/QuickLoad**: разбор (это enhancement SDLPoP, в оригинале 1989 его НЕТ), инвентаризация нашего состояния, формат снимка, шаги QS1..QS6. План, код не начат. 2026-08-17 |
| [`quicksave_plan.md`](quicksave_plan.md) | **QuickSave/QuickLoad** (✅ реализовано, F6/F9, POP.SAV+BAK): разбор (это enhancement SDLPoP, в оригинале 1989 его НЕТ), инвентаризация нашего состояния, формат снимка 'POPQ' v3, шаги QS1..QS6. Справочник. 2026-08-22 |
| [`full_game_plan.md`](full_game_plan.md) | **Полноценная игра**: app state machine, title/intro, demo level 0, таймер, cutscenes, уровни 1..14, ending и Hall of Fame. Уровень 15 исключён. 2026-08-21 |
| [`menu_settings_plan.md`](menu_settings_plan.md) | **Pause menu и Settings**: QuickSave/QuickLoad в основном menu, `POP.CFG`, один `POP.SAV` + `POP.BAK`, текущий VANILLA и задел под ENHANCED; §10 — выбор UI-рендера (текстовые строки + свой растровый рендерер, референс SDLPoP: два шрифта), restart без подтверждения. 2026-08-22 |
| [`palette_plan.md`](palette_plan.md) | **Палитры и fade**: карта всех 256 слотов (kid.pal/title/story, тайлсеты dungeon/palace, стражи), механика fade (4 ступени vs ~64 у SDLPoP), план модуля `pop_pal.c` (API load/apply/black) + переход уровня через fade. 2026-08-23 |
| [`status_line_text.md`](status_line_text.md) | **Строка HP как статус-строка**: полная инвентаризация ВСЕХ текстов SDLPoP в `rect_bottom_text` (геометрия, семантика `text_time_total`, мигание, рестарт по истечении) + что из этого уже есть у нас. 2026-08-25 |
| [`levels_plan.md`](levels_plan.md) | Следующий этап: уровни 2+, второй тайлсет, читы SDLPoP |
| [`levels_12_15_plan.md`](levels_12_15_plan.md) | **Уровни 12/13** (тень, Джафар, падающие плиты) + что такое 14/15 и 0. 2026-08-13 |
| [`midtable_analysis.md`](midtable_analysis.md) | **Слои отрисовки**: как устроены back/mid/fore и objtable в оригинале, чего стоит порт, развилки. 2026-08-13 |
+603
View File
@@ -0,0 +1,603 @@
# Фиксированный логический кадр — разбор перед реализацией
Дата разбора: 2026-08-19. Отправная точка — тег `0.0.1-prealpha`.
Статус: **АНАЛИЗ, кода не трогали.**
Задача пользователя: перевести логический кадр на фиксированный размер,
не зависящий от длительности синей/зелёной/циан фаз. Инструмент —
`gfx_set_fps_div`, режимы FASTEST / FAST / NORMAL.
## 1. Что на самом деле меняется
Сейчас главный цикл (`roomtest.c`) после отрисовки ждёт **три**
`gfx_wait_vsync()` подряд. Каждый ждёт ближайший фронт луча, поэтому
период логического кадра равен
период = ceil(W) + 2 растровых кадра, W = работа в растрах
Первое ожидание доедает хвост текущего растра, два следующих — целые
растры. Отсюда наблюдаемое: `W <= 1` → период 3; `W = 1.1` → период
уже 4. То есть **реальный бюджет логического кадра сегодня — один
растр (430 080 тактов)**, всё сверх него стоит целого лишнего растра.
Нужное поведение:
период = max(n, ceil(W))
При `n = 3` бюджет становится **1 290 240 тактов** — втрое больше.
Замеренный максимум работы на 13/23 (911 862) укладывается туда с
запасом, а 11/15 (437 484 лёгкая / ~603 000 тяжёлая позиция) — вдвойне.
Это главный выигрыш, и он не про скорость игры, а про **исчезновение
скачков**: сегодня превышение растра на один такт стоит +33 % к периоду.
## 2. Три режима и их привязка к оригиналу
Растровый кадр Sprinter: 320 строк × 896 пикселей при 14 МГц =
**20,48 мс** (48,83 Гц). В тактах CPU (21 МГц) — **430 080**;
замерено на холостом DSS: 430 131 на прерывание.
Оригинал (`SDLPoP/src/seg003.c:363`): `BASE_FPS = 60`,
`base_speed = 5` тика = **83,3 мс**, `fight_speed = 6` = **100 мс**.
| режим | обычно | в бою | мс обычно | мс в бою | к оригиналу |
|---|---:|---:|---:|---:|---|
| FASTEST | 3 | 3 | 61,4 | 61,4 | +36 % скорости |
| FAST | 3 | 4 | 61,4 | 81,9 | +36 % / точно |
| NORMAL | 4 | 5 | 81,9 | 102,4 | точно (83,3 / 100) |
NORMAL воспроизводит оригинал с точностью 1,7 % и 2,4 % — расхождение
только из-за 48,83 Гц против 60 Гц, целыми делителями точнее не выйдет.
**Условие «бой» берём у оригинала буквально** — оно проще, чем кажется:
```c
if (Kid.sword == sword_2_drawn) set_timer_length(timer_1, fight_speed);
else set_timer_length(timer_1, base_speed);
```
Это не «идёт бой» и не «есть страж рядом», а **только «у Кида вынут
меч»**, и проверяется в самом верху главного цикла, до `play_frame()`.
У нас поле есть (`Kid.sword`, `SWORD_2_DRAWN``guards.c`), так что
переключение — одна строка в том же месте цикла.
Это закрывает и давнюю задачу **L1-SPEED** (`TASKS_OPEN.md`): сейчас мы
идём на 60 мс вместо 83,3 — примерно на 39 % быстрее эталона.
## 3. Как устроен темп сейчас и что мешает
`gfx_wait_vsync()` имеет две ветки (`libbgi/common/gfx_wait_vsync.c`):
- **`_gfx_fps_div <= 1`** — лучевой поллинг бита 5 порта `0xFE` в тесном
цикле, и в этом цикле зовётся **idle-хук** (`gfx_set_idle_hook`).
roomtest вешает туда `kbd_raw_poll` — это единственное, что делает
клавиатуру работоспособной (задача KBD-1: приёмный FIFO SIO 3 байта,
импульс запроса прерывания живёт 32 такта и теряется в DI-окнах
акселератора; лечится только плотным опросом раз в ~0,5 мс).
- **`_gfx_fps_div >= 2`** — счётчиковый путь: ждёт, пока фоновый ISR
(`_gfx_frame_isr`, слот кадровой цепочки) насчитает `n` фронтов, а
ожидание реализовано через **`ei; halt`**.
**И вот здесь блокер.** На счётчиковом пути idle-хук не зовётся вообще.
Включив `gfx_set_fps_div(3)` как есть, мы немедленно возвращаем KBD-1:
теряются нажатия при зажатом Shift, залипают клавиши. Это не мелочь и
не «потом поправим» — это единственная причина, по которой клавиатура
сейчас вообще работает.
## 4. Что измерено (MAME, 2026-08-19)
### Метод и его границы
Прерывания считались брейкпоинтами на резидентных адресах трамплина
(`_irq_tramp` = 0x88B4, ветка кадрового пути `tr_frame` = 0x8995).
**Счёт попаданий брейкпоинтом на этом драйвере недостоверен**: sprinter
дёргает `Z80_INPUT_LINE_WAIT` (`do_mem_wait`), инструкция пересчитывается,
и один и тот же PC срабатывает по нескольку раз. Наблюдалось
«попаданий больше, чем растровых кадров» и «попаданий в `tr_frame`
больше, чем входов в трамплин» — логически невозможные результаты.
Достоверен только **детектор разрыва**: брейкпоинт на следующей
инструкции пишет `temp3 = totalcycles`, брейкпоинт с условием
`(totalcycles - temp3) > 0x9D800` (1,5 растра) останавливает машину.
Дубли попаданий его не портят. Все числа ниже — этим методом.
Отдельная грабля: **литералы в отладчике MAME шестнадцатеричные**.
Первый прогон с порогом «700000» на деле проверял 0x700000 = 17 растров
и не срабатывал никогда.
### Факты
1. **Холостой DSS** — 799 прерываний на 343 674 880 тактов = 430 131 на
прерывание. Подтверждает константу растра и что потерь нет, когда
нечего рисовать.
2. **11/15, покой (чомпер + два факела + страж)** — потери ЕСТЬ:
разрыв ровно 860 129 тактов = два растра = одно потерянное
прерывание. Частота в «плохой» фазе: 12 потерь на 427 растровых
кадров = **2,8 %**; повтор — 12 на 495 (2,4 %) при бегущем Киде.
3. **Та же сцена после сдвига Кида** (`]`/`[`, попиксельно) —
**0 потерь на 3919 кадров**, и после возврата обратно **0 на 4010**.
4. **Полная перерисовка комнаты** (переход/`+`) — разрыв 1 720 289 =
ровно четыре растра = **три потерянных прерывания подряд**.
5. **13/23** — ни в покое, ни при беге потерь не поймано; на смене
комнаты — поймано.
6. **Клавиатура ни при чём**: с удержанной клавишей 3,1 %, без неё
2,8 % — в пределах разброса.
### Как это читать
Пункты 2 и 3 вместе — самое важное. Одна и та же сцена даёт то 2,8 %,
то ноль. Значит потеря определяется **не нагрузкой, а фазой**: попадает
ли момент кадрового прерывания внутрь DI-окна блита.
Механизм подтверждён исходником MAME (`sprinter.cpp`):
- `irq_on` поднимает линию и заводит `irq_off_timer` на **32 такта
неразогнанного клока (3,5 МГц) = 9,14 мкс**; `irq_off` гасит. Ядро
z80 в MAME уровневое (`m_irq_state` без защёлки) — импульс, пришедший
под `di`, теряется НАСОВСЕМ. Это же поведение у настоящего Spectrum
(INT 32 такта), так что это не эмуляторный артефакт.
- DI-окно у нас — один вызов `_bgi_blit_rows_raw`, а он по контракту
режется вызывающим на **чанки ≤16 строк** (≈6 200 тактов ≈ 0,29 мс).
То есть DI-окна короткие и с промежутками, отсюда и «то теряем, то
нет»: всё решает, куда попал 9-микросекундный импульс.
- `irqack_cb` гасит **все три** входа мержера (экран/клавиатура/CBL)
одним подтверждением. Плюс трамплин обслуживает за вход ровно один
источник и делает приватный RETI. Значит кадровое прерывание может
быть съедено клавиатурной веткой или (в будущем) CBL-веткой.
**Вывод по фазе.** Наш период сейчас 3 растра, но иногда 4 — и каждый
такой случай сдвигает фазу рендера относительно луча. Отсюда «полосы»:
десятки секунд без потерь, потом полоса с потерями. При жёстком
пейсинге период станет РОВНО n, фаза перестанет плавать — и сцена может
**залипнуть в плохой фазе надолго**. Это хуже случайных 3 %: систематическая
потеря по прерыванию на кадр превратит логический кадр из 3 растров в 4,
то есть даст ровные −25 % скорости, которые никак не проявятся в
профиле тактов.
## 5. Проблемы по убыванию риска
| # | проблема | риск |
|---|---|---|
| P1 | счётчиковый путь ждёт через `halt` → idle-хук не зовётся → возврат KBD-1 (потеря нажатий, залипание клавиш) | блокер |
| P2 | счётчик кадров теряет тики (фазозависимо, 0…3 %), при жёстком пейсинге может залипнуть в плохой фазе → ровный минус скорости | высокий |
| P3 | полноэкранная перерисовка (вход в комнату, старт уровня) теряет 3+ тика подряд → счётчик недосчитает, ожидание растянется сильнее самой работы | средний |
| P4 | звук через CBL (`_irq_cbl_hook`, реальный ISR в трамплине): (а) ещё один источник, крадущий кадровые тики приватным RETI; (б) обратно — любое подтверждение гасит ожидающий запрос CBL → underrun (счётчик `cbl_underruns()` уже есть); (в) ISR длинный (полный сейв обоих наборов + вызов в приложение) | средний, растёт |
| P5 | второй call-site `gfx_wait_vsync()` — ветка `frozen` (`roomtest.c:320`) ждёт один фронт; под делителем её смысл меняется | низкий |
| P6 | `IRQ_CHAIN_MAX = 4`; делитель занимает слот, звук — свой; запас есть, но конечный | низкий |
| P7 | бит 5 порта `0xFE` доступен только при включённом `cbl_mode`; сейчас его лениво занимает сам `gfx_wait_vsync` через `_cbl_port_ref`, при открытии реального звука владение переходит к CBL — переход уже спроектирован, но его надо проверить в связке | низкий |
Отдельно, не проблема а рычаг: по сообщению разработчиков
(IvanMak.txt:846848) **новая прошивка позволяет акселератору работать с
EI** — по приходу прерывания он отключается, по `RETI` включается.
Если это подтвердится на железе и моделируется в MAME, P2 исчезает
полностью. Проверять отдельно; строить на этом нельзя (неизвестно, какая
прошивка у пользователя).
## 6. Варианты реализации
### A. Включить `gfx_set_fps_div(3)` как есть
Отвергается: P1 (убивает клавиатуру) — сразу, без вариантов.
### B. Свой `wait` в приложении: счётчик только на рендер, ожидание — лучом
Счётчик кадровых прерываний отвечает на один вопрос — «сколько фронтов
съел рендер», а само ожидание идёт **существующим лучевым поллингом с
idle-хуком**, то есть клавиатура работает ровно как сегодня.
```
k = tick - tick_at_frame_start; /* фронтов съел рендер */
if (k >= n) k = n - 1; /* опоздали — ждём хотя бы один */
повторить (n - k) раз: ждать фронт луча (поллинг + idle-хук)
tick_at_frame_start = tick; /* якорь на фактическом фронте */
```
Плюсы: минимальная правка, клавиатура нетронута, фаза переякоривается
каждый кадр (ошибка не копится). Минус: остаётся P2 — при залипании в
плохой фазе `k` систематически занижен на 1, и кадр ровно на растр
длиннее. Профилем это не видно.
### C. Программный счётчик кадров по лучу (без прерываний вообще)
Считать фронты **выборкой бита 5 в точках, которые мы и так проходим**.
Окно бланка (строки 272…319) длится **3,07 мс**, максимальное DI-окно —
0,29 мс, значит достаточно опрашивать чаще, чем раз в 3 мс.
Точки выборки: `pop_blit_b` (наша обёртка, зовётся на каждый блит —
в фазах рисования это плотнее 0,3 мс) плюс несколько точек в синей фазе
(она 149 106 тактов ≈ 6,9 мс без единого блита, нужно 3-4 точки).
Цена: `in a,(0xFE)` + проверка бита + дедуп фронта ≈ 40-50 тактов; при
~50 выборках это 2 500 тактов = 0,6 % растра.
Плюсы: **точно, и точность не зависит ни от DI, ни от звука, ни от
клавиатуры** — снимает P2, P3, P4(а) разом. Минус: заводит инвариант
«между выборками не больше 3 мс», который легко нарушить будущей правкой.
Инвариант проверяем в MAME тем же детектором разрыва.
### D. CTC как источник кадра
`irq_ctc_install` уже есть (каналы 2+3, вектор 0x06, отдельный от 0xFF).
Запрос CTC **защёлкивается** (daisy chain — в `_irq.h` прямо записано,
что без `RETI` следующего прерывания не будет), поэтому под `di` он не
теряется, а откладывается. Пресет 112 × 160 даёт ровно период кадра
без дрейфа (обе частоты — от одного X_SP).
Блокер: `irq_ctc_install` вектрится напрямую и требует кода в W2 →
сейчас только tiny/big, а roomtest — **huge**. Нужна W2-копия
CTC-трамплина (в дизайн-доке помечена как follow-up). Плюс защёлка
хранит только ОДИН отложенный запрос — на полноэкранной перерисовке
(P3) всё равно недосчитает.
### Рекомендация
**B как первый шаг, C — как способ закрыть P2**, и оба под одним
интерфейсом: приложение зовёт свой `pop_wait_logical_frame(n)`, а чем
внутри считаются кадры — деталь реализации. Тогда B→C не трогает ни
главный цикл, ни режимы.
D не нужен, пока C справляется, и требует работы в libc (W2-копия
CTC-трамплина) ради того же результата.
## 7. Порядок работ с критериями приёмки
**Ш0. Инструмент.** Скрипт замера потерь кадровых прерываний (детектор
разрыва) — зафиксировать как повторяемую процедуру, он понадобится на
каждом шаге. Критерий: воспроизводит числа §4 на 11/15 и на смене комнаты.
**Ш1. Свой `wait` (вариант B), делитель ещё не включён.** Вынести
хвост главного цикла в `pop_wait_logical_frame(n)`, поведение при n=3
должно быть **бит-в-бит прежним** (три фронта после работы). Критерий:
такты по фазам и распределение периода не изменились, клавиатура
работает.
**Ш2. Счётчик кадров.** Слот кадровой цепочки + `k = tick - anchor`.
Критерий: в 11/15 период стал ровно 3 растра ВСЕГДА (сейчас 3/4/5), а
на 13/23 — 3 вместо нынешних 3/4/5; клавиатура не деградировала
(проверка Shift+стрелки по методике KBD-1, не «на глаз»).
**Ш3. Режимы.** `POP_SPEED_FASTEST/FAST/NORMAL` + условие боя
`Kid.sword == SWORD_2_DRAWN` в верху цикла, как у оригинала. Критерий:
NORMAL секундомером совпадает с живым SDLPoP на одинаковом отрезке
(методика из L1-SPEED — секундомер, не глазомер).
**Ш4. Закрыть P2 (вариант C).** Выборка луча в `pop_blit_b` и в синей
фазе. Критерий: детектор разрыва не ловит ни одного расхождения между
программным счётчиком и лучом за 10 000 кадров, включая смену комнаты.
**Ш5. Звук.** Только после Ш4: открыть CBL и перемерить P4 —
`cbl_underruns()` и потери кадровых тиков.
## 8. Что проверить артефактом до начала
1. Сколько именно фронтов съедает вход в комнату — от этого зависит,
нужен ли отдельный «resync» на тяжёлых переходах или хватит того,
что якорь переставляется каждый кадр.
2. Ветка `frozen` (`roomtest.c:320`) — какой темп ей нужен под делителем.
3. Проверить, что при n=4/5 бит 5 всё ещё единственный источник фронта
(то есть `_cbl_port_ref` держится всё это время).
---
# 9. Предлагаемый вариант подробно: программный счётчик кадров по лучу
Дополнение от 2026-08-19 по запросу: как именно получается **точное**
число пройденных кадровых интервалов. Акселератор рассматриваем только
в нынешнем виде — с DI/EI (режим «акселератор с EI» из новой прошивки
из рассмотрения снят).
## 9.1. Сигнал и его геометрия
Единственный источник — **бит 5 порта `0xFE`**. Точная семантика по
исходнику MAME (`sprinter.cpp`, `kbd_fe_r`):
```c
data |= 0xe0;
data ^= 0x40;
if (cbl_mode()) {
data &= ~0xa0; /* гасит биты 5 и 7 */
data |= (vpos >= BORDER_TOP + SCREEN_YSIZE) << 5;
data |= ... & 0x80; /* бит 7 — CBL */
}
```
То есть **бит 5 = 1 ровно тогда, когда луч ниже картинки**
(`vpos >= 16 + 256 = 272`), и это ЧТЕНИЕ ПОЛОЖЕНИЯ ЛУЧА, а не событие:
ни прерывания, ни защёлки, ни очереди — его невозможно «потерять»,
можно только не посмотреть.
Важное следствие из той же строки: вне `cbl_mode` бит читается как 1
всегда (его выставляет `data |= 0xe0` и уже ничто не гасит). Поэтому
счётчик обязан работать только при взведённом `_cbl_port_ref()` — том
самом, который сейчас лениво взводит `gfx_wait_vsync`.
Геометрия кадра (320 строк × 896 пикселей при 14 МГц):
| | строк | мс | тактов CPU (21 МГц) |
|---|---:|---:|---:|
| бит 5 = 1 (нижний бланк) | 48 | 3,07 | **64 512** |
| бит 5 = 0 (картинка + верхний бордер) | 272 | 17,41 | 365 568 |
| кадр целиком | 320 | 20,48 | 430 080 |
Границей кадра берём **фронт 1→0** — это `vpos = 0`, ровно то же
событие, которого ждёт сегодняшний `gfx_wait_vsync`. Значит момент
свопа страниц не меняется: до начала картинки остаётся верхний бордер,
16 строк ≈ 1 мс запаса, как и сейчас.
## 9.2. Счётчик
```c
static uint8_t beam_prev; /* бит 5 на прошлой выборке */
static uint8_t frame_tick; /* счётчик кадров, разностная арифметика */
/* ~20 T-состояний тела + вызов; в тактах MAME ≈ 120 на выборку */
void pop_beam_sample(void)
{
uint8_t b = in_fe() & 0x20;
if (beam_prev && !b) frame_tick++; /* фронт 1→0 = начало кадра */
beam_prev = b;
}
```
Вся арифметика ожидания — разностная по модулю 256, wrap безопасен
(тот же приём, что в существующем `_gfx_fps_state`).
## 9.3. Почему счёт ТОЧНЫЙ (условие и запас)
Утверждение: **если между соседними выборками проходит меньше 64 512
тактов, то каждый фронт 1→0 будет засчитан ровно один раз.**
Доказательство прямое. Пусть максимальный зазор между выборками
Δ < 64 512. Окно «бит 5 = 1» длится 64 512 тактов, то есть длиннее Δ,
значит в него попадает хотя бы одна выборка → `beam_prev` обязательно
станет 1 внутри каждого бланка. Окно «бит 5 = 0» длится 365 568 — тем
более содержит выборку → сразу после бланка `beam_prev` перейдёт в 0 и
даст ровно один инкремент. Двойной счёт невозможен: инкремент
происходит только на переходе 1→0, а `beam_prev` тут же обновляется.
Условие ОДНО и оно про зазор, а не про нагрузку, не про DI, не про
прерывания. Отсюда все свойства варианта.
**Какой запас по факту.** Самый длинный неделимый кусок кода без
возможности выборки — одно DI-окно акселератора, то есть один вызов
`_bgi_blit_rows_raw`. Он по контракту режется вызывающим на чанки
**≤16 строк**; при ширине 32 это ≈ 6 200 тактов, при полной высоте
спрайта 63 строки самый дорогой замеренный блит целиком — 32 073.
Даже если мерить самым грубым образом (одна выборка на целый блит,
а не на чанк), зазор вдвое меньше окна бланка.
## 9.4. Где ставить выборки
Правило простое: **выборка обязана стоять так, чтобы ни один путь
исполнения не давал зазора длиннее 64 512 тактов.** По фазам:
- **Зелёная и циан** (436 494 и 382 770 тактов на 13/23) состоят из
блитов и хилов. Достаточно одной выборки на вызов наших обёрток
`pop_blit_b`, `pop_heal_off`, `pop_heal_fast` — но НЕ только их:
прямые вызовы `gfx_blit_*` / `gfx_heal*` разбросаны по шести файлам
(`pop_cdraw.c`, `pop_draw.c`, `pop_kdraw.c`, `pop_room.c`,
`pop_state.c`, `pop_tile.c`). Точный набор точек определяем НЕ
рассуждением, а замером (см. 9.7): ставим в обёртки, меряем худший
зазор, добавляем точки только там, где замер их требует.
- **Синяя** (149 106 тактов) — блитов нет вообще, это 2,3 окна бланка.
Нужны явные точки: после ввода, после физики, после `pop_process_trobs`,
после mob-тика. Ставятся на границах, которые и так размечены
зондами `pop_dbg_m*`.
Чего заведомо НЕ хватит: выборок только в главном цикле. Один
`pop_floor_bake` — 179 914 тактов, почти три окна бланка.
## 9.5. Ожидание — тот же примитив
Ожидание фронта и есть плотная выборка, поэтому оно сливается со
счётчиком, а опрос клавиатуры остаётся ровно таким же плотным, как
сегодня:
```c
static void wait_edge(void)
{
uint8_t t = frame_tick;
do {
pop_beam_sample();
kbd_raw_poll(); /* то, что сейчас висит idle-хуком */
} while (frame_tick == t);
}
```
`gfx_set_idle_hook` при этом больше не нужен — опрос зовётся прямо.
Обязателен аварийный выход по счётчику попыток (как в нынешнем
`gfx_wait_vsync`): на железе, где бит ведёт себя иначе, цикл не должен
виснуть насмерть.
## 9.6. Пейсинг целиком
```c
uint8_t k = (uint8_t)(frame_tick - anchor); /* фронтов съел рендер */
if (k >= n) {
wait_edge(); /* опоздали — выравниваемся на ближайший */
} else {
do { wait_edge(); } while ((uint8_t)(frame_tick - anchor) < n);
}
anchor = frame_tick; /* якорь по ФАКТУ, фаза не копится */
flip_page();
```
`anchor = frame_tick`, а не `anchor += n` — сознательно: догонять
пропущенное время нельзя, иначе после тяжёлого кадра игра рванёт
вперёд. Это же правило заложено в исходном дизайне делителя
(«выравнивание на ближайший фронт, без накопления фазовой ошибки»).
Поведение по случаям:
| работа W (растров) | период | комментарий |
|---|---|---|
| W ≤ n | ровно n | цель задачи |
| n < W ≤ n+1 | ceil(W) | подтормаживает ровно настолько, насколько не успели |
| вход в комнату, W ≫ n | ceil(W) + 1 | ветка «опоздали»: один фронт, без растягивания |
| загрузка уровня, файловые операции | ceil(W) + 1…n | выборок нет вовсе → k занижен; худшее — n лишних растров ОДИН раз |
Последняя строка — единственный случай, где счёт неточен, и он
безобиден: во время `ESTEX`-вызова выбирать нечего, а ошибка живёт один
кадр, потому что якорь переставляется по факту.
## 9.7. Как это доказывается, а не декларируется
Инструмент уже построен и проверен на нынешнем коде (§4): брейкпоинт на
следующей инструкции пишет `temp3 = totalcycles`, второй с условием
`(totalcycles - temp3) > 0x9D800` останавливает машину.
Для приёмки он ставится **на инструкцию инкремента `frame_tick`**.
Если хоть один фронт пропущен, зазор между инкрементами станет два
растра и детектор остановит машину. Критерий: **10 000 кадров без
единого срабатывания**, включая смену комнаты и смерть Кида.
Дополнительно, в отладочной сборке — перекрёстная проверка со счётчиком
кадровых прерываний (слот цепочки, один INC): прерывания теряются, луч
не должен, значит `frame_tick` обязан идти НЕ МЕДЛЕННЕЕ `irq_tick`.
Расхождение в другую сторону = пропущенная выборка.
Напоминание о методике: **счёт попаданий брейкпоинтом на этом драйвере
недостоверен** (WAIT-линия, инструкция пересчитывается) — только
детектор разрыва. И литералы в отладчике MAME шестнадцатеричные.
## 9.8. Цена
| статья | тактов |
|---|---:|
| одна выборка (с вызовом) | ≈ 120 |
| ~60 выборок за логический кадр | ≈ 7 200 |
| доля от бюджета при n=3 (1 290 240) | **0,6 %** |
В горячих местах (`pop_blit_b`) выборку можно заинлайнить и снять цену
вызова.
## 9.9. Чем это лучше счётчика прерываний
| | счётчик кадровых IRQ | счётчик по лучу |
|---|---|---|
| теряет тик под `di` акселератора | да, фазозависимо 0…3 % | нет — читается положение луча |
| теряет тик, если IRQ съела клавиатурная/CBL-ветка трамплина | да (приватный RETI) | нет |
| ломается от добавления звука | да (ещё один источник, `irqack` гасит все входы мержера) | нет |
| поведение на полной перерисовке | 3 тика подряд мимо | считает все |
| условие корректности | никакого — не в нашей власти | зазор выборок < 64 512 тактов, проверяется артефактом |
| риск залипнуть в плохой фазе и ровно потерять 25 % скорости | есть | нет |
## 9.10. Что осталось проверить зондами до кодирования
1. **Худший зазор между выборками** при размещении «только в обёртках»
— сколько точек реально нужно добавить. Это же число решает, нужна
ли выборка в синей фазе в четырёх местах или в двух.
2. **Владение `cbl_mode`**: сейчас бит 5 доступен потому, что
`gfx_wait_vsync` взвёл `_cbl_port_ref()` при первом вызове. Свой
ожидатель обязан взвести его сам — значит примитив логичнее держать
в libbgi (там доступен `_cbl_port_ref`), а не в приложении.
3. **Ветка `frozen`** (`roomtest.c:320`) — какой темп ей нужен.
4. Совпадает ли момент возврата `wait_edge()` с нынешним возвратом
`gfx_wait_vsync()` с точностью до микросекунд (иначе поедет момент
свопа и появятся разрывы картинки).
---
# 10. РЕЗУЛЬТАТ (2026-08-19, реализовано и проверено в MAME)
Реализовано в приложении (`roomtest/pop_pace.c/.h`), в libbgi пока НИЧЕГО не
переносили — по решению пользователя: сначала обкатать у себя.
## 10.1. Что сделано
- `pop_beam_sample()` — выборка бита 5 порта `0xFE`, 10 инструкций,
быстрый путь 46 T + вызов. Модуль НЕ банковый, поэтому из банков
зовётся прямым `call` (проверено: банки так зовут `_pop_cd_hit_slot`).
- `pop_wait_edge()` — ожидание одного фронта; внутри тот же
`kbd_raw_poll()`, что раньше висел idle-хуком.
- `pop_pace_end(n)` — добрать до n фронтов от якоря; якорь ставится ПО
ФАКТУ. Главный цикл: `pop_wait_edge()` → строб вспышки →
`pop_pace_end(n)` → своп страниц.
- `pop_pace_arm()` — взводит `cbl_mode` через `gfx_wait_vsync()` и
ПРОВЕРЯЕТ, что фронты идут; если нет — `pace_ok = 0` и всё молча
откатывается на прежние `gfx_wait_vsync`.
- Режимы FASTEST/FAST/NORMAL, клавиша **P** по кругу, дефолт FASTEST.
Условие боя — `Kid.sword == SWORD_2_DRAWN`, буквально как у оригинала.
## 10.2. Где стоят выборки и как они выбраны
Точки ставились **не на глаз, а по замеру**: детектор зазора между
выборками (порог 64 512) останавливает машину, адрес возврата со стека
называет виновника. Пять итераций «замерил → закрыл дыру → перемерил»:
| итерация | найденная дыра | тактов |
|---|---|---:|
| 1 | `kid_tick` + `pop_phys_tick` без выборок | 68 340 |
| 1 | весь циан, когда оба персонажа «тихие» | 100 044 |
| 2 | отрисовка персонажей идёт мимо `pop_blit_b` | 82 242 |
| 2 | полоса HP: `pop_kid_img_blit` в цикле | 86 586 |
| 3 | между двумя `pop_blit_b` — работа `pop_bg` | 75 000 |
| 4 | сам блит и сам heal (выборка была только НА ВХОДЕ) | 71 900 |
| 5 | `pop_loose_tick` | 73 990 |
Итог: выборки в `pop_blit_b` (вход и перед каждым `gfx_w0_unmap`),
`pop_heal_fast` (вход и выход), после каждого `gfx_set_bank(SPRITE)` в
`pop_cdraw/pop_kdraw/pop_room`, в 16 потайловых функциях `pop_bg`, после
зондов `pop_dbg_p1..p8` в физике и в 21 точке главного цикла.
## 10.3. Замеры
Метод точности счётчика — **атомарный снимок одной командой отладчика**:
`printf "%d %d", totalcycles, b@<адрес pop_frame_tick>`. Раздельные
`lmem` и `print totalcycles` НЕ ГОДЯТСЯ: между двумя обращениями к мосту
проходят десятки кадров, и «недосчёт» получается на ровном месте (на этом
я сначала и обжёгся).
| проверка | результат |
|---|---|
| счётчик, 11/15 покой, 5 окон | недосчёт **0** (270 растровых кадров) |
| счётчик, 11/15 тяжёлая позиция Кида | недосчёт **0** |
| счётчик, 13/23 | недосчёт **0** |
| период кадра, 11/15 | **ровно 3 растра**: ни длиннее 3,1, ни короче 2,9 на 302 логических кадрах |
| период кадра, 13/23 | **ровно 3 растра** на 308 логических (эталон был 3/4/5) |
| режим NORMAL | ровно 4 растра |
| NORMAL + `Kid.sword = 2` | ровно 5 растров |
| клавиша P | 0 → 1 → 2 → 0 |
| клавиатура | Кид отвечает на удержание и отпускание |
**Цена выборок** — A/B прямо в памяти (заглушить `pop_beam_sample`
байтом `C9` и снять `pace_ok`, чтобы игра не зависла в ожидании фронта):
работа за кадр 543 860 с выборками против 539 832 без — **≈4 000 тактов,
0,9 %**. На фоне бюджета, который вырос втрое, это ничто.
## 10.4. Грабли, стоившие времени
1. **Литералы в отладчике MAME шестнадцатеричные.** Порог «700000» на
деле проверял 0x700000 = 17 растров и не срабатывал никогда.
2. **Счёт попаданий брейкпоинтом на этом драйвере недостоверен** (WAIT-
линия, инструкция пересчитывается): наблюдались «попаданий больше, чем
растровых кадров». Достоверны только сравнения ВРЕМЁН.
3. **Раздельные чтения через мост не атомарны** (см. 10.3).
4. **Мёртвый Кид перезапускает уровень** раз в `RESPAWN_DELAY` тиков, а
рестарт уровня — это 3-5 растров без единой выборки. Полдня я гонялся
за «дырой в статике», которой не было: Кид успел убежать в соседнюю
комнату и погибнуть, пока я мерил. **Проверяй, что на экране, прежде
чем объяснять числа.**
5. **`make LEVEL=13` без `make clean` не пересобирает** — флаги в
зависимостях не участвуют, на диск уезжает старый уровень.
## 10.5. Что осталось
- Перенос примитива в libbgi — по решению пользователя ПОСЛЕ обкатки.
Там же уместнее взводить `_cbl_port_ref` напрямую, без обходного
`gfx_wait_vsync()` в `pop_pace_arm`.
- Загрузка уровня/комнаты остаётся без выборок (ESTEX-вызовы) — счётчик
там недосчитывает. Это безобидно: якорь переставляется по факту, и
ошибка живёт один кадр. Отдельного «resync» не потребовалось.
- Проверить на реальном железе, что `pace_ok` взводится (в MAME — да).
+367
View File
@@ -0,0 +1,367 @@
# От roomtest к полноценной игре — сценарий и оболочка
Статус: **частично реализовано; аудит обновлён 2026-08-24**. Ранее пометка
«завершены FG0–FG12» была неверной: для многих этапов уже есть код и
host-тесты, но их критерии приёмки на Sprinter ещё не выполнены. Фактический
статус каждого FG приведён в [§14](#14-этапы-реализации).
Этот документ описывает превращение текущего игрового цикла
`roomtest` в законченную игру: заставка, интро, демонстрационный уровень,
сцены между уровнями, таймер, финал и Hall of Fame. План меню и постоянных
настроек вынесен в [`menu_settings_plan.md`](menu_settings_plan.md),
детальный план QuickSave — в [`quicksave_plan.md`](quicksave_plan.md).
## 1. Зафиксированный scope
- Целевая последовательность — оригинальная SDLPoP/DOS PoP с уровнями
**1..14**. Уровень 14 — скрытая финальная часть после Джаффара: его номер
игроку не показывается, победа наступает в комнате 5.
- Уровень **15 удаляется полностью**: не пакуется на HDD, не загружается,
отсутствует в переходах, читах и UI; специальная логика potions/copy
protection level удаляется.
- Уровень **0** остаётся только демонстрационным (attract mode), а не частью
новой игры.
- Title sequence повторяет SDLPoP. Перед ней допускается отдельный
пропускаемый экран с информацией о Sprinter-сборке.
- Первая версия использует текущий профиль поведения `VANILLA`. Сейчас это
означает **существующую реализацию roomtest**, включая уже встроенные
исправления. Аудит и разведение `VANILLA/ENHANCED` — будущая задача.
- Программа работает **только с HDD**. Варианты без сохранения для floppy не
проектируются.
- Моды и выбор levelset в этот план не входят.
## 2. Что делает SDLPoP
Источники истины в локальном SDLPoP:
- `src/seg000.c`: `start_game()`, `show_title()`, demo mode, общий кадр,
проверка финала;
- `src/seg003.c`: `init_game()`, `play_level()`, `play_level_2()`;
- `src/seg001.c`: cutscene engine, `pv_scene()`, сцены 2/4/6/8/9/12,
`time_expired()`, `end_sequence()` и Hall of Fame;
- `src/data.h`: `tbl_cutscenes`, параметры уровня 0, win level/room;
- `data/TITLE`, `data/PV`, `data/LEVELS/res2000.bin`: ресурсы оболочки.
Штатный маршрут:
```text
boot
-> title / story screens
-> Princess + Jaffar intro
-> credits / Hall of Fame
-> demo level 0
-> title или новая игра
-> levels 1..14
before 2 -> princess cutscene
before 4 -> princess cutscene
before 6 -> princess cutscene
before 8 -> princess + mouse
before 9 -> princess + mouse
before 12 -> scene selected by remaining time
-> level 14, room 5
-> embrace + mouse
-> ending text/music
-> Hall of Fame
-> title
```
Кроме уровней, здесь есть глобальный 60-минутный таймер, сцена истечения
времени, пропуск сцен клавишей, fade/flash, ожидание музыки и возврат в
attract loop после демо или финала.
## 3. Текущее состояние roomtest
Уже реализованы игровой кадр, комнаты, уровни, тайлсеты, Kid/Guard/Shadow,
Джаффар, специальные события, checkpoint, переходы уровней, бесшовный
выход 12-го уровня, перенос максимального HP и звуковые эффекты.
Поверх игрового цикла уже добавлены автомат оболочки, title/story, demo
уровень 0, global timer, сценарный интерпретатор, level-flow, ending и Hall
of Fame. Полный маршрут также собирается в HDD-образ.
Однако это **не означает готовность оболочки**. На момент аудита остаются
существенные незакрытые места:
- lifecycle палитр: gameplay-переходы используют чёрный барьер без fade;
cold start и полный набор dungeon/palace переходов ещё не прошли приёмку;
- PV intro Princess/Jaffar уже покадровый (актёры, факелы, звёзды, часы,
молния и foreground-колонна); сцены перед 2/4/6 и длинной веткой 12
анимируют факелы, звёзды и песок, а сцены 8/9 и короткая ветка 12 пока
используют статические позы с исходной длительностью;
- demo отображается с игровой палитрой, проходит второй разворот/зацеп и
доходит до боя; после смерти Кида корректно завершает цикл;
- time-expired, ending и Hall of Fame имеют маршрут и реализацию UI, но не
прошли сквозную MAME-проверку вместе с ресурсами и возвратом к title;
- нет полного регресса EMM/FD для каждого перехода состояния.
## 4. Архитектура: автомат состояний приложения
Нельзя наращивать все режимы условиями внутри кадрового цикла. Текущий
цикл должен стать реализацией одного состояния `PLAYING`:
```text
BOOT -> BUILD_INFO -> TITLE -> INTRO -> DEMO
| |
+---- NEW_GAME <-+
NEW_GAME -> LEVEL_LOAD -> PLAYING <-> PAUSE_MENU
|
+-> CUTSCENE -> LEVEL_LOAD
+-> TIME_EXPIRED -> TITLE
+-> ENDING -> HALL_OF_FAME -> TITLE
```
Минимальный контекст оболочки:
```c
typedef enum {
POP_APP_BOOT,
POP_APP_BUILD_INFO,
POP_APP_TITLE,
POP_APP_INTRO,
POP_APP_DEMO,
POP_APP_LEVEL_LOAD,
POP_APP_PLAYING,
POP_APP_PAUSE_MENU,
POP_APP_CUTSCENE,
POP_APP_TIME_EXPIRED,
POP_APP_ENDING,
POP_APP_HALL_OF_FAME,
POP_APP_QUIT
} pop_app_state_t;
```
Переходы задаются результатом состояния, а не прямыми рекурсивными
вызовами наподобие SDLPoP `start_game()`/`longjmp()`. На Z80 это проще для
стека и позволяет освобождать ресурсы каждого режима в одном месте.
## 5. Ресурсная модель
Title и cutscene-ресурсы нельзя постоянно держать рядом с игровыми
атласами. Для каждого состояния нужен явный lifecycle:
```text
enter: pause sound -> unload incompatible set -> load set -> apply palette
run: process input/timer/animation
leave: stop sound -> release EMM pages -> clear transient state
```
Новые группы HDD:
```text
TITLE\ title/story images, palette, optional build-screen assets
PV\ princess room, Princess/Jaffar/mouse frames, palettes
MUSIC\ intro, cutscene and ending tracks/samples
LEVELS\ res2000..res2014.bin
```
Конкретный формат атласов выбирает упаковщик. Runtime не должен разбирать
PNG/DAT: как и игровые спрайты, он получает подготовленные `.atl`/`.bin`.
## 6. Экран Sprinter build
Отдельное состояние перед оригинальной заставкой:
```text
PRINCE OF PERSIA
SPRINTER SP2000 BUILD
version / date / build id
```
Требования:
- пропускается любой клавишей;
- выключается в Settings;
- не запускает музыку оригинального title и не меняет её тайминги;
- данные версии генерируются сборкой, а не правятся вручную в C;
- отсутствие экрана приводит прямо к `TITLE`.
## 7. Title и текстовая подсистема
Порядок переносится из `show_title()`:
1. основной титульный экран;
2. Presents;
3. название игры и Jordan Mechner;
4. story frame / “In the absence…”;
5. intro Princess + Jaffar;
6. story “Marry Jaffar…”;
7. credits;
8. Hall of Fame, если таблица непуста;
9. demo level 0.
Нужны общие примитивы: загрузить full-screen image, вывести строку,
показать экран заданное время, transition left-to-right, fade in/out,
прервать ожидание клавишей. Текст и меню должны использовать один renderer.
Критерий: последовательность и музыкальные точки совпадают с SDLPoP;
Sprinter build screen не сдвигает оригинальный soundtrack.
## 8. Demo level 0
- Добавить на HDD `res2000.bin`.
- Загружать уровень обычным loader, но выставлять demo HP и demo mode.
- Воспроизводить `demo_moves` как синтетический источник `control_*`.
- Пользовательский ввод прерывает демо и начинает новую игру.
- Достижение demo end room (у SDLPoP — 24), смерть или конец скрипта
возвращают в `TITLE`.
- Pause menu, QuickSave и cheats в demo недоступны.
- RNG демо и начальное состояние должны быть детерминированы.
Критерий: без ввода attract loop не требует перезапуска процесса;
title -> demo -> title повторяется неограниченно.
## 9. Глобальный таймер
Состояние: минуты, тики и флаг показа. Таймер создаётся при New Game,
переносится между уровнями и входит в QuickSave.
Правила `VANILLA`:
- на pause menu, загрузке HDD, QuickSave/QuickLoad время не идёт;
- игровые тики следуют темпу логического кадра, а не частоте render loop;
- поведение во время level-end sound и cutscenes сверяется буквально с
SDLPoP;
- после Джаффара/на финальном уровне время не должно вызвать поражение;
- ноль времени переводит приложение в `TIME_EXPIRED`.
Критерий: одинаковый игровой отрезок в NORMAL даёт то же уменьшение времени,
что SDLPoP; сохранение/загрузка не добавляет и не отнимает тики.
Реализация FG4 живёт одним модулем `roomtest/pop_timer.c` в bank 9:
`60:719`, 720 тиков на минуту, счёт только в живом игровом кадре. Settings
хранит `TIME LIMIT: 60 MIN / UNLIMITED` в `POP.CFG`; старый семибайтный v1
payload по-прежнему читается как `60 MIN`. Читы таймера повторяют SDLPoP,
но из-за занятого `+/-` используют F7 (−1 минута, не ниже одной) и F8
(+1 минута). Состояние входит в QuickSave v4.
## 10. Cutscene engine
Сцены SDLPoP состоят из небольшого набора повторяемых команд. Вместо набора
крупных C-функций нужен компактный интерпретатор:
```text
SET_ACTOR actor
SET_POS x,y,dir
START_SEQ seq
WAIT_FRAMES n
PLAY_SOUND id
WAIT_SOUND
SET_HOURGLASS frame
SET_SAND state
FLASH color,frames
FADE_IN / FADE_OUT
CLEAR_ACTOR actor
END
```
Скрипты — `const` в холодном банке или подготовленный бинарный ресурс.
Interpreter обязан:
- исполнять один шаг/кадр без блокирующих длинных циклов;
- поддерживать пропуск сцены;
- при пропуске выполнять cleanup и выходить в заранее заданное состояние;
- освобождать PV-ресурсы перед загрузкой игрового тайлсета;
- не разрешать pause menu/QuickSave внутри сцены.
Порядок переноса: intro, 2/6, 4, 8, 9, 12, time expired, ending. Сцена 12
выбирает короткий или обычный вариант по остатку времени.
## 11. Переходы между уровнями
Таблица сценария должна быть отдельна от таблиц механики уровня:
```c
typedef struct {
uint8_t level;
uint8_t pre_cutscene;
uint8_t show_level_number;
uint8_t ending_rule;
} pop_level_flow_t;
```
Особые правила:
- New Game начинает уровень 1;
- перед 2/4/6/8/9/12 запускается сцена;
- 12 -> 13 остаётся бесшовным;
- после победы над Джаффаром переход идёт в 14;
- номер 14 не показывается;
- вход в комнату 5 уровня 14 переводит в `ENDING`;
- значения больше 14 недопустимы и дают диагностическую ошибку, а не
попытку открыть файл.
## 12. Ending и Hall of Fame
Ending:
1. загрузить PV-набор;
2. встреча Kid и Princess;
3. объятие;
4. появление мыши;
5. ending music;
6. финальные story/title экраны;
7. переход в Hall of Fame.
Hall of Fame хранится на HDD в отдельном версионированном `POP.HOF`.
Сохраняются имя и результат; ввод имени использует тот же текстовый/UI слой.
Повреждённый или неизвестный формат означает пустую таблицу, но не мешает
запуску игры. После показа — возврат в `TITLE`.
## 13. Удаление уровня 15
Отдельный ранний этап, чтобы новый flow не наследовал лишний маршрут:
- убрать `res2015.bin` из `LVL_NUMS` и HDD image;
- заменить последний игровой уровень на 14;
- остановить Shift+L и прочую навигацию на 14;
- удалить `POP_POTIONS_LEVEL` и специальный половинный урон синих зелий;
- исключить copy protection из конфигурации и меню;
- добавить тест: после уровня 14 приложение входит в ending и никогда не
запрашивает `res2015.bin`.
## 14. Этапы реализации
Легенда аудита: **✓** — критерий этапа закрыт; **~** — код существует, но
критерий приёмки ещё не закрыт; **○** — не начат. Статус отражает состояние
исходников и последней MAME-проверки на 2026-08-24, а не только наличие
модуля в bank 9.
| этап | статус | результат и фактическое состояние | критерий приёмки |
|---|---|---|---|
| **FG0** | ✓ | `POP_LEVEL_LAST=14`, HDD содержит `res2000..res2014`; `t_flow` отвергает 15 | HDD не содержит res2015; переход выше 14 невозможен |
| **FG1** | ~ | автомат `pop_app` и `t_app` реализованы; сквозной ресурсный lifecycle и контроль EMM/FD ещё не измерены | старт/рестарт/выход проходят без рекурсии и утечки EMM |
| **FG2** | ✓ | QuickSave/QuickLoad с `POP.SAV` и `POP.BAK`; отдельно проверен в MAME 2026-08-22 | критерии `quicksave_plan.md`, включая POP.BAK |
| **FG3** | ✓ | pause menu, Settings, подтверждения и двойной буфер реализованы; меню проверялось в MAME; добавлены SDLPoP-звуки навигации и защита CBL вокруг полного redraw/файловых операций | Resume/Save/Load/Restart/Settings/Quit работают |
| **FG4** | ~ | `pop_timer`, настройка unlimited, F7/F8 и состояние QuickSave реализованы; есть host-тест, но нет буквального сравнения темпа со SDLPoP на всех переходах | совпадение с SDLPoP и корректный save/load |
| **FG5** | ~ | text/full-screen/fade примитивы есть; для входа в первый уровень и границ уровней выбран мгновенный чёрный барьер без fade: CBL и яркая новая палитра включаются только после подготовки обеих страниц; Level 1 проверен в MAME | тестовые экраны и переходы на Sprinter |
| **FG6** | ~ | title-ресурсы и порядок кадров реализованы; Enter на title и Esc на первом story в MAME переводят прямо в `FIRST_LEVEL`, минуя demo; полная cold-boot приёмка fade остаётся в FG5 | основной титул/Presents/название/Mechner идут в точном порядке `show_title()`; Enter/Space/Esc/стрелки прерывают ожидание; story/intro продолжит FG8 |
| **FG7** | ✓ | level 0, исходная таблица `demo_moves`, demo HP=4 и блокировка игрового UI реализованы; исправлены зеркалирование auto-control, боевой AI Кида и завершение после смерти; в MAME demo проходит разворот/зацеп, доходит до боя и возвращается в attract-цикл без повторного убийства | `res2000.bin`, исходная `demo_moves`, demo HP=4; бесконечный attract loop, любой ввод начинает чистую новую игру; Pause/QuickSave/читы/таймер отключены |
| **FG8** | ~ | data-driven interpreter и покадровый PV intro работают; в MAME проверены актёры, факелы, звёзды 1x1, часы/песок, palette-0 lightning и foreground-колонна; Enter/Esc переводят прямо в `FIRST_LEVEL`; временный темп 12,5 FPS и TODO точного pacing записаны в `impl_diff.md` | story/PV intro проходит, любой raw-ввод пропускает его без удержания EMM-страниц |
| **FG9** | ~ | `pop_flow` корректно маршрутизирует 2/4/6/8/9/12 и ветку <=5 минут (`t_flow`); 2/4/6 и длинная 12 уже обновляют часы, песок, факелы и звёзды каждые 5 кадров Sprinter; длительности всех веток сверены с SDLPoP: 2/4/6/12 — 2,6 с, 8 — 6,0 с, 9 — 7,2 с; входная клавиша gameplay/Shift+L поглощается до сцены, а новое нажатие делает skip; анимации мыши/Princess в 8/9 и разворот Princess в короткой 12 ещё статичны | таблица flow переводит в CUTSCENE ровно перед 2/4/6/8/9/12; scene 12 выбирает короткий вариант при <=5 минутах |
| **FG10** | ~ | переход TIME_EXPIRED и экран существуют, но это ещё статическая PV-стадия; сквозной MAME-маршрут не принят | PV-сцена истечения с пропуском, затем возврат на title/attract; новая игра сбрасывает таймер |
| **FG11** | ~ | room 5 уровня 14 переводит в ENDING (`t_flow`); объятие/мышь заменены статической стадией, полный маршрут не принят | room 5 уровня 14 переводит в ENDING; PV-финал и Hail-экран возвращают управление оболочке |
| **FG12** | ~ | версионированный `POP.HOF`, ввод имени и восстановление после повреждённого файла реализованы; нужна сквозная MAME-проверка ending → HOF → title | версионированный `POP.HOF`, ввод имени raw-клавиатурой, повреждённый файл = пустая таблица, затем title/attract |
## 15. Проверки
- Host-тест автомата: все допустимые переходы и отсутствие уровня 15.
- Host-тест cutscene interpreter на синтетическом скрипте и skip в каждой
ожидающей команде.
- Host-тест demo input: одинаковый seed даёт одинаковый поток управления.
- MAME: cold boot -> build info -> title -> demo -> title.
- MAME: новая игра -> принудительный переход по всем pre-level scenes.
- MAME: time expired и пропуск сцены.
- MAME: 13 -> 14 -> room 5 -> ending -> HOF -> title.
- Проверка EMM/FD до и после каждого состояния: число страниц и открытых
файлов возвращается к базовому.
- `make size-check`; крупный cold-код размещать в банках и отдельно следить
за лимитом 16 КБ каждого банка.
## 16. Не входит в план
- уровень 15 и copy protection;
- моды и выбор levelset;
- replay/recording;
- точная эмуляция SDL video/controller options;
- профиль ENHANCED и индивидуальные switches fixes.
+208
View File
@@ -439,3 +439,211 @@ BUG-CHEAT-FIGHT-1 (выход из боя), и лечится там же.
**Что проверять при регрессе.** Уровень 13: `K` на Джафаре → белая вспышка,
уход ВЛЕВО открывает дверь уровня. Честная победа в бою даёт то же самое.
---
## Страж, вытесненный за правый край комнаты и там убитый, не виден нигде
**Не расхождение, а особенность оригинала.** Записано, чтобы вопрос не
возникал повторно (спросил пользователь 2026-08-19: бой шёл в комнате 15,
Кид вытеснил стража вправо — из-за края торчал только меч, — убил его, и
труп не появился ни в комнате 15, ни в соседней справа).
**Почему так.** Три механизма складываются:
1. **комнату страж не менял.** Его физика работает только в полосе
`x ∈ [44, 211)` (`seg000:1254`, у нас то же условие в
`pop_guard_phys_tick`), поэтому своим ходом за край он не уходит —
Кид вытолкнул его туда толчком, а `Guard.room` остался прежним;
2. **мёртвый за Кидом не идёт.** Единственный способ сменить комнату —
`follow_guard` при переходе Кида, и первое же условие там
(`seg002:0346`) — `Guard.alive < 0 && Guard.sword == sword_2_drawn`,
то есть ЖИВОЙ и с вынутым мечом. Мёртвый уходит веткой `leave_guard`,
которая сохраняет его в **`Guard.room`** — в старую комнату. У нас
ровно это же условие, `pop_guard_cold.c` (`pop_guard_follow`);
3. **из соседней комнаты страж не рисуется.** Оригинал при
`Guard.room != drawn_room` просто ГАСИТ слот (`seg000:422`:
`Guard.direction = dir_56_none`). Механизм «видно из-за шва»
(`xpos_in_drawn_room`) работает для коллизий и для Кида, но стража из
чужой комнаты на экран не выводит.
Итог: труп приписан комнате, где страж стоял, а его `guards_x` — за
правым краем. При возврате в ту комнату он честно восстанавливается там
же, то есть за пределами видимого поля; в соседней комнате его нет,
потому что в её данных стража и не было.
**Живой страж в этой ситуации ведёт себя иначе** — при уходе Кида вправо
он идёт следом, если стоит достаточно близко к краю (`Guard.x >= 165`).
Это портировано и работает.
**Чего я НЕ проверял:** живьём в SDLPoP этот сценарий не воспроизводил —
вывод сделан чтением трёх мест кода. Если понадобится подтверждение,
сценарий короткий: любой бой у правого края комнаты, вытеснить стража за
край и добить.
## ГСЧ разведён по доменам (у оригинала он ОДИН)
**Оригинал.** `random_seed` один на всё: кладка стены, анимация тайлов,
броски боя, модификаторы падающих плит — всё тянет из одной
последовательности (`seg009` PRNG, 32-битный LCG). Поэтому в оригинале
бой воспроизводим вместе со всем остальным: тот же сид — тот же бой.
**У нас.** Сидов несколько: `pop_t_seed` (кладка, `pop_tile.h`),
`pop_fight_seed` (броски боя, `pop_guard.h`), отдельные у trob и loose.
Сам генератор тот же (`pop_prandom`), таблицы вероятностей —
побайтно те же, что в `data.h`.
**Чем платим.** Конкретный бой у нас и в SDLPoP разойдётся: порядок
бросков другой, значит блоки/удары лягут иначе. Статистически поведение
то же (те же вероятности, тот же генератор), но «сверить бой кадр в кадр
с SDLPoP» нельзя, и QuickSave обязан сохранять ВСЕ сиды, а не один.
**Что проверять при регрессе.** Если страж кажется сильнее/слабее
оригинала — сначала проверить не таблицы (они сверены), а **режим
скорости**: `fight_speed` у оригинала 100 мс, а в нашем FASTEST бой идёт
61,4 мс, то есть в реальном времени на 63 % быстрее, и на глаз это ровно
«страж давит сильнее». Режим NORMAL (дефолт) даёт 102,4 мс — см.
`frame_pacing_plan.md`.
## PV intro: единые 12,5 FPS вместо переменных 10/7,5/8,57 FPS
**Оригинал.** `proc_cutscene_frame()` двигает последовательности через
`cutscene_frame_time`: 6 тиков 60 Гц в начале, 8 после первой речи и 7 во
время заклинания. Это соответственно 10, 7,5 и примерно 8,57 FPS.
**У нас (осознанное временное отличие).** Один логический кадр PV держится
четыре физических кадра Sprinter: номинально 50/4 = 12,5 FPS. Молния живёт
на отдельной физической шкале и не растягивается этим делителем. Если полная
отрисовка пересечёт дополнительный фронт, реальная частота может упасть до
10 FPS — это допустимо на текущем этапе, но должно быть измерено.
**TODO.** Перевести PV-сцену на тот же anchor-based механизм точного темпа,
который gameplay использует через `pop_beam_sample/pop_pace_end`: измерять
число реально прошедших фронтов во время сборки кадра, держать период ровно
четыре фронта при укладывании в бюджет и явно учитывать overrun. После замера
можно вернуть точные переменные интервалы SDLPoP без накопления фазы.
## Межуровневые PV-сцены: сохранён реальный период 100 мс
Это правило не относится к временному темпу основного Princess/Jaffar intro
выше. `reset_cutscene()` SDLPoP задаёт для сцен перед уровнями период
6 кадров при 60 Гц, то есть 100 мс. На Sprinter тот же период получается
ровно как 5 кадров при 50 Гц.
Суммы вызовов `proc_cutscene_frame()` перенесены без изменения реального
времени: сцены 2/4/6 и обе ветки 12 содержат 26 логических кадров (130
физических, 2,6 с), сцена 8 — 60 (300, 6,0 с), сцена 9 — 72 (360, 7,2 с).
Fade in/out в эти числа не входят, как и в оригинале.
## Gameplay: загрузка уровней через чёрный cut, без fade
**Оригинал.** На границах игровых уровней использует fade out/in.
**У нас (решение пользователя 2026-08-24).** Вход в первый уровень и
переход между уровнями выполняются как `старый кадр -> чёрная палитра ->
подготовка -> новый кадр с новой палитрой`. Fade на этих двух маршрутах
отсутствует. Сюжетные title/story/PV переходы сохраняют собственные fade и
left-to-right эффекты.
Чёрная палитра устанавливается до любого HDD I/O. Загрузчики guard и
tileset сами физически правят отдельные цветовые слоты, поэтому после них
чёрный экран подтверждается повторно. Зеркальные атласы уровня 9 готовятся
до финального источника палитры. CBL открывается последним: старый порядок
`level_switch -> CBL open -> BIOS fade` давал скрежет повторяющейся половины
аппаратного буфера на входе в Level 1; после перестановки баг исчез в MAME.
## Тень: кайма силуэта не подкрашивается фоном
**Оригинал.** Спрайт Тени не хранится — он кладётся ДВАЖДЫ: обычным
прозрачным блитом в x и «блиттером XOR» в x+1 (`draw_objtable_item`,
seg008.c:1600). XOR идёт по 24-битному RGB того, что УЖЕ на экране
(`blit_xor`, seg009.c:3190), поэтому там, где спрайт прозрачен в x, но
непрозрачен в x−1, цвет получается как `фон XOR цвет спрайта`.
**У нас.** Пакетный блит наложения на себя не умеет, поэтому результат
запечён в отдельный атлас (`toolchain/pop_pack_shadow.py`,
`docs/shadow_atlas_plan.md`). Запекать пришлось для КОНКРЕТНОГО фона, и
выбран чёрный: на нём `фон XOR цвет == цвет`, то есть запечка точна.
**Чем платим.** Ровно одним: **кайма в один пиксель по ЛЕВЫМ кромкам
силуэта** на НЕчёрном фоне. У оригинала она принимает оттенок фона, у нас
всегда «свой» цвет. Внутренность силуэта и правые кромки совпадают точно —
там первый проход уже закрасил пиксель, и от фона результат не зависит.
**Почему это приемлемо.** Тень бывает на четырёх уровнях, и почти всегда
на чёрном: у зеркала (ур. 4), в проёме (5), над пропастью (6), в бою (12).
**Что проверять при регрессе.** Если Тень окажется на светлом фоне и
кайма станет резать глаз — вариантов два: запечь второй набор под светлый
фон (ещё 32 страницы EMM) или считать эту кайму прозрачной (силуэт станет
на пиксель уже). Оба хуже нынешнего; трогать только по факту жалобы.
## QuickSave/QuickLoad: лейбл печатается ДО дисковой операции, а не после
**Как в оригинале.** SDLPoP печатает `QUICKSAVE` / `NO QUICKSAVE` (и пару
для загрузки) уже ПО РЕЗУЛЬТАТУ операции — `process_quicksave` (seg000:497)
сначала делает save/load, потом зовёт `display_text_bottom` и ставит
`text_time_total = 24`. На PC это незаметно: файл пишется мгновенно.
**У нас.** `pop_qsave_process` заявляет строку ПЕРВЫМ действием, ещё до
`mem_alloc_pages`/ESTEX, через `pop_status_show_now()` — та печатает её
немедленно в ВИДИМУЮ страницу, не дожидаясь конца кадра. Отказ уже потом
переписывает строку на `NO QUICKSAVE`/`NO QUICKLOAD` обычной заявкой.
**Зачем.** Запись снимка на диск занимает доли секунды, и всё это время
игра стоит. При порядке оригинала игрок видел сначала необъяснённый фриз,
и только по его окончании — надпись, объясняющую то, что уже прошло.
Решение пользователя, 2026-08-25.
**Чем платим.** Строка успевает мигнуть даже там, где операция потом не
удалась: сначала `QUICKSAVE`, следом `NO QUICKSAVE`. На практике отказ —
редкость (нет места/диска), и «заявка → отказ» читается не хуже.
**Что проверять при регрессе.** Что после неудачной операции на экране
остаётся именно `NO QUICKSAVE`/`NO QUICKLOAD`, а не первая строка: отказ
идёт обычной заявкой и печатается кадровым проходом, то есть на кадр позже.
## Смерть Кида: ждём кнопку и перезапускаем УРОВЕНЬ, а не игру
**Как в оригинале.** `play_kid` (seg006:1383) печатает «Press Button to
Continue» с `text_time_total = 288`. Тик — это логический игровой кадр,
720 тиков = минута, то есть 12 тиков в секунду: 288 тиков = **24 секунды**.
Последние 72 тика (6 секунд) строка мигает с периодом 12 тиков, и на каждом
появлении играет звук 38. Дальше развилок ровно две:
* игрок молчит все 24 секунды — `draw_game_frame` (seg000:958) зовёт
`start_game()`, и игра начинается ЗАНОВО, с title, а не с уровня;
* игрок нажимает **Enter или Shift** (не любую клавишу!) — seg000:584
подменяет их на Ctrl+A: `if (rem_min != 0 && Kid.alive > 6 && (control_shift
|| key == SDL_SCANCODE_RETURN)) key = SDL_SCANCODE_A | WITH_CTRL;` — и
уровень перезапускается. Условия важны: время не должно быть исчерпано
(иначе отработал `expired()`), а `Kid.alive > 6` даёт трупу улечься.
**У нас.** Обе развилки сведены к одной: 24-секундного выхода в начало
игры нет вовсе, строка висит бессрочно (`MSG_HOLD`), а перезапускает уровень
ЛЮБАЯ кнопка, а не только Enter/Shift (решение пользователя).
`pop_start_level()` возвращает игрока на уровень. Место возрождения выбирает сам `pop_start_level` — на части
уровней это не старт, а пройденный чекпойнт. Esc за кнопку продолжения не
считается: он открывает pause menu. Логика ожидания живёт в банке
(`pop_dead_prompt`, pop_status.c) — резидент W1 переполнен.
**Зачем.** Решение пользователя, 2026-08-25: возврат к title после каждой
смерти в отладочной сборке съедает всё время прохода, а прежний вариант
(авто-респавн через 400 кадров либо стрелка вверх) не объяснял игроку, чего
от него ждут.
**Чем платим.** Двумя вещами. Первое: смерть больше не заканчивает
партию — счёт попыток фактически бесконечен, тогда как оригинал через 24
секунды бездействия отправляет в title. Второе: любая клавиша вместо
Enter/Shift означает, что случайное нажатие (например, ещё не отпущенная
после боя клавиша) перезапустит уровень — отсюда требование сперва отпустить
всё. Когда дойдёт до «настоящей» игры, обе развилки придётся выбирать
заново: вернуть таймер на 288 тиков со start_game и сузить клавиши до
Enter/Shift — либо оставить как есть уже осознанно.
**Что проверять при регрессе.** Нажатие принимается только после того, как
отпущено ВСЁ, что игрок держал в момент смерти (иначе зажатая при падении
стрелка перезапускает уровень мгновенно), и не раньше `RESPAWN_SETTLE`
кадров — труп должен успеть лечь.
+58
View File
@@ -0,0 +1,58 @@
* Left: turn or run left
* Right: turn or run right
* Up: jump or climb up
* Down: crouch or climb down
* Down+Left/Right: hop
* Shift: pick up things
* Shift+Left/Right: careful step
* Home or Up+Left: jump left
* Page Up or Up+Right: jump right
* Up while running: running jump
* Shift while falling: grab onto ledge
* Left/Right: walk (advance or retreat)
* Shift: strike (attack)
* Up: block (defend)
* Down: put sword away; press Shift to draw your sword again.
===
* Esc: Pause game.
* Space: Show how much time is left.
* Ctrl+A: Restart level.
* Ctrl+R: Return to intro.
* Ctrl+S: Sound on/off.
* Ctrl+M: Music on/off.
* Ctrl+V: Show version of SprPoP.
* Ctrl+Q: Quit game.
* F6: Quicksave: Save the exact state of the game.
* F9: Quickload: Load what the last quicksave saved.
* F12: Save a screenshot to the screenshots folder.
* Backspace: Display the in-game menu. (Esc will also display the menu by default, but you can turn that off.)
* Shift+L: Go to next level.
* -: Decrease remaining time by one minute.
* +: Increase remaining time by one minute.
* R: Resurrect kid.
* K: Kill guard.
* Shift+I: Flip the screen upside down.
* Shift+W: Slow falling.
* Shift+S: Restore a lost hit-point. (Like a small red potion.)
* Shift+T: Give more hit-points. (Like a big red potion.)
===
* H: Look at the room to the left.
* J: Look at the room to the right.
* U: Look at the room above.
* N: Look at the room below.
* Ctrl+B: Go back to the room where the prince is. (Undo H,J,U,N.)
===
* [: Shift kid 1 pixel to the left.
* ]: Shift kid 1 pixel to the right.
* T: Toggle display of timer (remaining minutes:seconds:ticks). Also shows the total elapsed ticks during playback.
+529
View File
@@ -0,0 +1,529 @@
# Pause menu и Settings для Sprinter PoP
Статус: **MS0, MS2 и MS4MS8 выполнены** (2026-08-23). Pause menu, CFG,
Settings, диалоги, Controls и build screen находятся в bank 9. Решение о
рендеринге и затемнении — §10.
Связанные документы:
- [`full_game_plan.md`](full_game_plan.md) — автомат состояний, title,
demo, cutscenes и ending;
- [`quicksave_plan.md`](quicksave_plan.md) — состав и восстановление снимка.
## 1. Решения
- Программа работает только с HDD; настройки, QuickSave и Hall of Fame
всегда могут быть постоянными файлами.
- Основной pause menu обязательно содержит QuickSave и QuickLoad.
- QuickSave имеет один слот `POP.SAV`; предыдущая корректная запись хранится
как `POP.BAK`.
- Первая версия имеет один профиль `VANILLA`. Под этим именем пока понимается
**текущее поведение roomtest**, включая уже встроенные исправления.
- Дизайн файла и API предусматривает будущий `ENHANCED`, но аудит и
переключение fixes сейчас не выполняются.
- Уровень 15/copy protection отсутствует.
- Моды, levelsets и меню Mods отложены.
## 2. Что есть в SDLPoP
`src/menu.c` содержит:
- Resume, QuickSave, QuickLoad, Restart Level, Settings, Restart Game, Quit;
- General, Gameplay, Visuals, Mods, Controls;
- toggle/number/key controls, пояснения, scroll и confirmation dialogs;
- большой список fixes/enhancements и custom level options.
На Sprinter не переносятся SDL-специфичные параметры: fullscreen, hardware
acceleration, scaling, aspect ratio, rumble. UI берёт структуру SDLPoP, но
набор настроек соответствует платформе.
## 3. Pause menu первой версии
```text
RESUME
QUICKSAVE (F6)
QUICKLOAD (F9)
RESTART LEVEL
SETTINGS
RESTART GAME
QUIT GAME
```
Поведение:
- `Esc` в `PLAYING` открывает меню; повторный Esc или Resume возвращает игру;
- игра, логический таймер и звуковой насос ставятся на паузу согласованно;
- QuickSave/QuickLoad только взводят запрос, фактическая операция идёт на
безопасной границе кадра;
- QuickLoad disabled/показывает `NO QUICKLOAD`, если нет валидных SAV/BAK;
- перед QuickLoad из меню лёгкий probe проверяет заголовок и checksum обоих
файлов: валидный `POP.BAK` при отсутствующем/битом `POP.SAV` требует
отдельного `LOAD BACKUP?`, а не загружается молча;
- Restart Level и Restart Game выполняются сразу, БЕЗ подтверждения
(2026-08-22): Restart Level перечитывает уровень, Restart Game завершает
gameplay и возвращает к первому экрану title/intro; новая игра создаётся
общим LEVEL_LOAD только после skip/attract;
- Quit требует подтверждения и закрывает файлы/каналы штатным путём;
- меню недоступно в demo, cutscene, time-expired и ending;
- отдельная debug-комбинация немедленного выхода может остаться только в
отладочной сборке.
## 4. Settings первой версии
```text
GENERAL
Sound ON / OFF
Show Sprinter screen ON / OFF
Restore defaults...
GAMEPLAY
Speed NORMAL / FAST / FASTEST
Gameplay profile VANILLA
Cheats ON / OFF
CONTROLS
Show key bindings
BACK
```
`Gameplay profile: VANILLA` показывается read-only: место в модели уже есть,
но пользователь не может выбрать ещё не реализованный ENHANCED.
Изменения применяются немедленно к скорости, читам и звуку, но `POP.CFG`
записывается один раз при Back/Esc. На экране есть итог `SETTINGS SAVED` или
`SAVE ERROR`; во втором случае runtime-значения остаются рабочими.
Отладочные параметры `ROOMNAV`, border profiling, stop-frame и переключение
double buffering не являются пользовательскими Settings. Они остаются
compile-time/debug функциями и скрываются из release UI.
## 5. Модель настроек
Игровой код не должен читать UI-структуры. Единственный runtime-контракт:
```c
typedef enum {
POP_PROFILE_VANILLA = 0,
POP_PROFILE_ENHANCED = 1
} pop_gameplay_profile_t;
typedef struct {
uint8_t sound_enabled;
uint8_t speed_mode;
uint8_t gameplay_profile;
uint8_t cheats_enabled;
uint8_t show_build_info;
uint16_t enhancement_flags;
} pop_settings_t;
```
В первой версии загрузчик принимает только `POP_PROFILE_VANILLA`. Значение
ENHANCED из более нового/ручного файла заменяется на VANILLA с диагностикой,
а не включает частично реализованный режим.
Будущий профиль задаёт маску возможностей централизованно:
```text
VANILLA -> текущий согласованный набор
ENHANCED -> будущий рекомендуемый набор fixes
CUSTOM -> только если позже действительно понадобится
```
До отдельного аудита существующие `fix_exit_door`, feather guard behavior,
jump grab и sound priorities не переключаются и считаются частью текущего
VANILLA.
## 6. Файл POP.CFG
Бинарный, компактный, версионированный формат:
```text
+0 "PCFG" magic, 4 Б
+4 format_version 1 Б
+5 payload_size 2 Б
+7 payload фиксированные поля little-endian
.. checksum 2 Б
```
Требования:
- путь рядом с exe/в выделенном каталоге игры на HDD;
- неизвестная версия, неверная длина или checksum -> defaults;
- неизвестные будущие хвостовые поля можно пропустить по `payload_size`;
- запись только после Apply/выхода из Settings, не на каждый шаг курсора;
- ошибка записи не завершает игру: показать сообщение и оставить runtime
значения;
- Restore defaults меняет RAM только после подтверждения и затем сохраняет.
CFG не содержит состояние уровня, QuickSave или Hall of Fame.
## 7. QuickSave / QuickLoad в меню
Детальный состав снимка и порядок восстановления — в
[`quicksave_plan.md`](quicksave_plan.md). Здесь фиксируется UI и файловая
транзакция.
### Один слот и backup
Файлы:
```text
POP.SAV текущий слот
POP.BAK предыдущий валидный слот
POP.NEW временный файл во время записи
```
Безопасная запись:
1. записать полный снимок в `POP.NEW`;
2. закрыть файл;
3. повторно открыть/прочитать заголовок и checksum;
4. старый валидный `POP.SAV` перенести/скопировать в `POP.BAK`;
5. `POP.NEW` сделать новым `POP.SAV`;
6. при любой ошибке сохранить прежний `POP.SAV`.
Точную последовательность rename/copy выбрать после характеризации DSS.
Если атомарный rename не гарантирован, использовать copy + fsync/close и
никогда не удалять единственную валидную копию до проверки новой.
### Загрузка
1. проверить `POP.SAV`;
2. если он отсутствует/повреждён/несовместим — проверить `POP.BAK`;
3. при валидном BAK показать `LOAD BACKUP?`;
4. несовместимая версия — `INCOMPATIBLE SAVE`, без частичной загрузки;
5. после успеха закрыть menu, перерисовать обе страницы, перезапустить звук.
### Сообщения
Минимальный набор:
```text
QUICKSAVED
QUICKLOADED
NO QUICKLOAD
SAVE ERROR
INCOMPATIBLE SAVE
LOAD BACKUP?
```
Сообщение показывается UI-слоем, но операция завершается до возврата в
игровой кадр.
## 8. Restart Level / Restart Game
Restart Level:
- использует существующий штатный reset текущего уровня;
- не перечитывает CFG;
- не меняет `POP.SAV`;
- сбрасывает состояние, которое сбрасывает текущая реализация roomtest.
Restart Game:
- выполняется сразу, без подтверждения;
- завершить текущий gameplay session и вернуть автомат в TITLE;
- начать title/intro с самого первого экрана;
- создать новую игру с `FIRST_LEVEL` и новым глобальным таймером только
после пользовательского skip либо ввода в attract-demo;
- настройки оставить;
- QuickSave не удалять.
## 9. Controls
Первая версия только показывает активную раскладку. Переназначение клавиш
откладывается: raw PS/2 канал имеет особенности Shift и расширенных кодов,
поэтому generic key-binding UI требует отдельного проекта.
Экран должен перечислить минимум:
- движение и Shift/action;
- Esc/menu;
- F6/F9 QuickSave/QuickLoad;
- Ctrl+S sound;
- P speed;
- доступные cheats, только если они включены: K/Kill Guard, I/Immortal,
Shift+L/Next Level, U/Flip Screen и F7/F8/Time /+ на отдельных понятных
строках. Нижней подсказки `Esc or Enter: Back` нет.
## 10. UI renderer и ввод
### 10.1. Выбор способа отрисовки: текст против спрайт-атласов
Ограничение платформы: стандартный текстовый вывод libbgi (`outtextxy`)
не годится — он тянет системный знакогенератор в `_gfx_font_buf` (2 КБ
статики в W2) плюс жирный резидентный код, а W1/W2 забиты игрой
(тот же вывод зафиксирован комментарием в `roomtest_cold.c`, где отладочный
борд рисуется палочками именно поэтому). Значит, любой вариант требует
СВОЕЙ реализации вывода меню, живущей в отдельном банке (память на банк
есть; скорость не критична — меню работает на паузе).
Рассматривались два подхода.
**Вариант A — текстовые строки + собственный растровый рендерер.**
Плюсы:
- минимальные данные: шрифт 2–4 КБ + таблицы строк по сотни байт на язык;
- весь динамический текст бесплатно: значения опций (ON/OFF,
NORMAL/FAST/FASTEST), сообщения (`QUICKSAVED`, `INCOMPATIBLE SAVE`),
диалоги (`LOAD BACKUP?`), экран Controls, будущий ввод инициалов
Hall of Fame — без текстового движка HoF вообще не сделать;
- правка формулировки = правка C-строки, мгновенные итерации;
- локализация = вторая таблица строк (+ вторая половина глифов);
- **решающий аргумент: так сделано в самом SDLPoP** — см. §10.2.
Минусы:
- надо написать рендерер (блиттер глифа + строка + центрирование +
подсветка) — небольшой, но свой;
- вид определяется качеством шрифта-ассета.
**Вариант B — готовые спрайт-атласы** (атлас главного меню с активными/
неактивными пунктами, атлас вложенного меню, атлас каждой опции
On/Off и т.д.).
Плюсы:
- аутентичный вид: любая типографика/декор запекаются при упаковке;
- вывод = существующий блит атласов, текстовый движок не нужен;
- язык = другой файл атласа с диска, ноль логики.
Минусы:
- комбинаторика ассетов: 7 пунктов × состояния + вложенные меню + значения
всех опций + все сообщения + все диалоги ≈ десятки КБ raw на язык до RLE;
второй язык удваивает;
- любая правка текста = перегенерация ассетов + перекладка ресурсов;
- динамический текст (HoF initials) всё равно потребует шрифтового движка —
получили бы ОБЕ системы сразу.
**Решение (2026-08-22): Вариант A**, шрифт — ассет. Спрайты остаются только
для нетекстового декора (рамка/фон меню, маркер выделения — как arrowheads
в SDLPoP). Титульный экран — полноэкранная картинка, тема `full_game_plan.md`.
### 10.2. Референс: как устроено меню в SDLPoP
`SDLPoP/src/menu.c` + текстовый движок `seg009` — источник структуры:
- **Текстовые строки + встроенный пропорциональный bitmap-шрифт**
`hc_small_font_data[]` (menu.c:2488): символы 32..126, каждый глиф —
монохромное изображение переменной ширины; `font_type`
{first_char, last_char, space_between_chars, height_above_baseline, chtab}.
Никаких per-item атласов, хотя SDL_ttf доступен.
- Вывод — портированный движок оригинального DOS PoP (seg009):
`draw_text_character``method_3_blit_mono(image, x, y, textblit,
textcolor)`; `get_line_width` для центрирования; перенос по словам.
Тем же движком рисуются in-game тексты и copy protection.
- Пункты меню — data-driven C-структуры `{id, previous, next, required,
char text[32]}` + таблицы `pause_menu_items[]` / `settings_menu_items[]`;
`required` — указатель на флаг disabled, такие пункты пропускаются при
навигации (prev/next пересчитываются).
- Выделенный пункт = смена цвета текста (bright-white против обычного) +
рамка-контур `draw_rect_contours(selection_box, lightgray)`; НЕ отдельный
спрайт «активного пункта».
- Фон меню — затемнение замороженного игрового кадра:
`draw_rect_with_alpha(black, alpha=120)`, внизу просвечивает «GAME PAUSED».
- Settings — декларативная таблица `setting_type` со стилями TOGGLE / NUMBER /
TEXT_ONLY / KEY, геттером/сеттером/increase/decrease значения, строкой-
explanation внизу экрана, скроллом длинных списков и фокусом «левая половина
(список) / правая половина (значения)».
- Диалоги — один общий `draw_confirmation_dialog(text)` + обработчик
результата; диалог возвращает решение автомату меню.
- Мини-спрайты только для декора значений (arrowheads up/down/left/right).
- Навигация озвучена (menu tick), ввод клавиатура+мышь, hover по прямоугольникам.
### 10.3. Наша реализация
- Банк 9: код рендерера,
шрифт, таблицы строк, автомат меню. Резидентно — только request-flag и
вызов процесса на границе кадра (паттерн pop_qsave_io).
- Рендерер повторяет минимальный контракт seg009: пропорциональные глифы,
baseline, `draw_string` и центрирование по сумме advance. Блит идёт через
W0-атлас, в `GFX_BANK_SPRITE`: `0xFF` в атласе пропускается, а UI временный
и не портит теневую копию игрового фона. Перед каждым кадром UI `gfx_copy_page` переносит чистый
shadow видимой страницы в скрытую, затем готовый кадр показывается только
на следующем фронте. При выходе чистый фон тем же способом возвращается на
обе страницы и восстанавливается исходная visible-страница. Поэтому
перемещение выделения не показывает поэтапную перерисовку и не оставляет
следов на back buffer.
- Шрифт — АССЕТ из **оригинальных** `hc_small_font_data[]` и
`hc_font_data[]` SDLPoP, не системный ZG и не TTF. Паковщик
`toolchain/pop_extract_font.py` делает `FONT\\font.atl`: 95 ASCII-глифов
малого и 95 крупного шрифта (7667 Б). Номер ленты вычисляется из ASCII,
поэтому это один текстовый движок, а не атлас готовых надписей.
- Двуязычность (eng/rus): строки храним в CP866 — латиница и кириллица одним
байтовым порядком, одна кодировка на оба алфавита. Локаль = пара
(указатель на таблицу строк, файл шрифта); переключатель — одна настройка.
Русские строки длиннее английских ~10–15% — раскладку экранов и ширину
колонок закладывать по русской. Второй язык можно добавить позже без
переделки: сначала eng.
- Визуальная композиция MS4 следует SDLPoP: замороженная сцена остаётся
открытой, поверх неё компактный центрированный список без чёрной карточки,
выбранная строка обведена тонким светло-серым контуром, а крупное
`GAME PAUSED` лежит в нижнем борту. Цвета текста и контура берутся из
стабильного диапазона палитры 0x37..0x3F.
- Фон открытого меню: снимок текущей палитры, затемнение всех слотов кроме
UI 0x37..0x3F и точное восстановление при выходе. Снимок хранится в
свободном хвосте EMM-страницы шрифта, не в W2.
- Навигация MS4: вверх/вниз, Enter/Esc, edge-triggered поверх `kbd_raw`.
Left/right и menu tick добавляются вместе с настройками на MS5.
Первый UI может быть визуально простым. Критично отсутствие потери клавиш,
предсказуемая пауза и отсутствие повреждения игрового back buffer.
### 10.4. Затенение экрана под меню — решение MS4
Режим меню виден сразу: bank 9 делает динамический снимок palette 0,
затемняет RGB-каналы вдвое и пишет одинаковый результат в обе экранные
палитры. Девять стабильных UI-слотов 0x37..0x3F не гасятся. При Resume/Enter
палитра восстанавливается из EMM-снимка. Это выбранный вариант Б ниже;
ступенчатый fade для роликов пока не нужен и остаётся отдельной будущей
задачей, а не причиной раздувать MS4.
**Как сделано в SDLPoP** (`seg009.c`):
- Меню: `draw_rect_with_alpha(&screen_rect, color_0_black, pause_menu_alpha)`
(menu.c:1364) — альфа-заливка чёрным поверх замороженного кадра средствами
SDL; нижняя полоса рисуется с alpha=0, чтобы сквозь неё просвечивало
«GAME PAUSED». Прямого аналога на Sprinter НЕТ (альфа-блендинг в железе
отсутствует) — это SDL-специфика, переносить нечего.
- Ролики/переходы: `fade_in_2/fade_out_2(rows)` (seg009.c:3947+, вызовы из
seg000.c) — ПОШАГОВОЕ затухание ПАЛИТРЫ к чёрному и обратно: палитра
копируется, каждая строка по 16 цветов гасится за несколько кадров
(`which_rows` маской выбирает, какие строки участвуют: 0x800/0x1000/...).
Вот этот механизм на Sprinter воспроизводим один в один.
Отсюда рабочая гипотеза: наш примитив = «снимок текущей палитры → ступенчатое
приближение к затемнённой копии (кроме резервного блока для UI)», статично для
меню и анимированно для роликов/переходов. Варианты:
**Вариант А — единая основная палитра (глобальный рефакторинг палитры).**
1. Собрать ВСЕ палитры игры (уровневые наборы `pal_env*`, kid.pal, палитра
Тени и пр.) в одну общую 256-цветную; использовать её целиком всегда.
Сейчас переиспользования цветов НЕТ — каждая загрузка ассетов перезаписывает
слоты (см. pop_boot: kid.pal затирает тайловые цвета, приходится
восстанавливать `pop_bg_pal_apply`/`pop_shadow_pal_apply`).
2. Для затенения — затемнённая копия основной палитры, КРОМЕ зарезервированного
блока из 16 цветов для самого меню (кандидат — стандартные 16 цветов VGA).
3. Выход из меню — возврат к полной основной палитре.
Плюс: решает попутно существующую боль с перезаписью палитр при загрузках.
Минус: большой разовый рефакторинг упаковщиков и всех загрузчиков атласов;
нужен аудит, что все цвета всех уровней влезают в 256. **Против говорит
план перевода камней подземелья на цвета VGA-версии PoP: там ряд уровней
несёт ДРУГУЮ палитру, отличную от SDLPoP (VDUNGEON/VPALACE каскад,
levels_plan.md), — единая палитра этому прямо противоречит.**
**Вариант Б — динамический снимок текущей палитры (сейчас выглядит
предпочтительным).**
1. При открытии меню прочитать всю текущую палитру, сохранить.
2. Записать затемнённую копию (кроме зарезервированного блока для меню).
3. При выходе — восстановить сохранённую.
Плюс: локальная правка внутри меню, ничего в пайплайне ассетов не меняется;
работает при любой текущей палитре автоматически — включая будущие
уровне-специфичные палитры VGA-камней; тот же примитив ступенями даёт
fade-out/fade-in для роликов и переходов между уровнями (как fade_*_2 в
SDLPoP). Минус: чтение/запись 256 записей палитры при входе/выходе (раз на
открытие — дёшево); затемнение «на глаз» может по-разному выглядеть на разных
уровнях.
Резервный блок 16 цветов нужен в ОБОИХ вариантах; текущий диапазон 0x37..0x3F
(стабильный, проверен) даёт 9 цветов — этого может не хватить на
текст+подсветку+рамку, тогда резервировать отдельный блок.
**Следствие для архитектуры:** работа с цветом/палитрой должна собраться в
ОДИН модуль (сейчас она разбросана: gfx_pal_* вызовы в boot, pop_bg_pal_apply,
pop_shadow_pal_apply, вспышки урона в roomtest.c и т.д.). Модуль палитры —
единственный владелец записи в палитру и предоставляет примитивы, которые
понадобятся и меню, и роликам:
```text
pal_snapshot()/pal_restore() — снимок/восстановление всей палитры
pal_dim(step) / pal_undim(step) — ступени затемнения (кроме резервного блока)
pal_fade_out(rows)/pal_fade_in(rows) — анимированное затухание по строкам
(порт fade_out_2/fade_in_2, seg009)
```
Меню уже использует snapshot+dim локально в bank 9. Когда появятся ролики,
выделить из него общий palette/fade-модуль; вспышка урона сможет переехать
туда же после отдельного аудита.
## 11. Диалоги
Общий диалог подтверждения:
```text
QUIT GAME?
RESTORE DEFAULTS?
LOAD BACKUP?
YES / NO
```
Диалог не выполняет действие напрямую: он возвращает решение автомату меню,
который формирует команду приложению. Так UI не зависит от gameplay-модулей.
По умолчанию выбран `NO`; Up/Down/Left/Right меняют ответ, Enter подтверждает,
Esc отменяет. Реализованы все три вопроса: Quit, Restore defaults и backup
QuickLoad. В Quit-dialog вопрос и `YES / [NO]` заключены в общую рамку;
отдельная строка `Enter: Select Esc: Cancel` не выводится.
## 12. Будущий ENHANCED
Не реализуется сейчас, но дизайн обязан позволять:
- добавить второй профиль без смены всего UI;
- хранить `enhancement_flags` в CFG;
- отличать технические исправления порта (всегда включены) от изменений
оригинальной механики;
- провести аудит уже встроенных исправлений;
- покрыть каждый переключаемый fix host/MAME тестом;
- при необходимости добавить Advanced screen, не раздувая основной menu.
До этого момента нельзя рассыпать проверки `if (enhanced)` по горячему коду.
Сначала составляется реестр и выбирается минимальная битовая модель.
## 13. Этапы реализации
| этап | результат | критерий приёмки |
|---|---|---|
| **MS0** ✓ | определить команды app/menu и структуру settings | UI возвращает команду главному циклу; прямых gameplay-вызовов нет |
| **MS1** | проверить запись/rename/copy на HDD DSS | crash/power-loss сценарий не теряет обе копии save |
| **MS2** ✓ | `POP.CFG`: defaults, load, validate, save | v1 codec, будущий хвост, checksum; повреждённый CFG даёт defaults |
| **MS3** | QuickSave hotkeys + POP.SAV/BAK | полный критерий `quicksave_plan.md` |
| **MS4** ✓ | текстовый рендерер + два шрифта (малый для пунктов, крупный для сообщений) + минимальный pause menu | SDLPoP fonts в одном W0-atlas, центрирование, dim/restore palette и tear-free page flip; все семь пунктов видимы, навигация и Resume/QuickSave работают в MAME |
| **MS5** ✓ | General/Gameplay Settings | значения применяются сразу и после Back/Esc записываются в POP.CFG |
| **MS6** ✓ | dialogs + backup recovery | подтверждения default-NO; QuickLoad спрашивает перед валидным POP.BAK |
| **MS7** ✓ | Controls help | показаны движение, action, menu, save/load, звук, speed и conditional cheats; MAME проверил отдельные K/I и Shift+L/U и возврат Esc ровно на один уровень |
| **MS8** ✓ | build info | CFG читается до первого показа; включаемый build screen получает ID и дату из Make/git |
QuickSave (`MS1/MS3`) можно реализовать раньше визуального menu: сначала
F6/F9 и сообщения, затем подключить те же команды к пунктам UI.
## 14. Тесты
- Host: CFG round-trip, defaults, bad magic/version/size/checksum.
- Host: меню navigation, disabled items, confirmations, команды приложению.
- Host: рендерер строк — вывод глифов обеих локалей, центрирование,
ширина строки для малого и крупного шрифта.
- Host: SAV invalid -> BAK valid; оба invalid -> NO QUICKLOAD.
- MAME: F6, изменение сцены, F9; затем рестарт программы и повторный F9.
- MAME: прервать запись/испортить SAV — BAK остаётся загружаемым.
- MAME: pause на бое/падении, Resume не меняет состояние и таймер; смена
выбранного пункта не показывает промежуточный кадр и после закрытия не
оставляет меню на второй странице.
- MAME: Settings сохраняются после полного выхода и запуска с HDD.
- MAME: включить Show Sprinter screen, перезапустить `roomtest`, увидеть
build ID/date до первого игрового кадра и пропустить экран Esc/Enter/Space.
- Проверка лимита 8 DSS handles на каждом error path.
- `make size-check`; menu/text строки не должны съесть резидентный бюджет.
## 15. Не входит в план
- Mods и выбор levelset;
- уровень 15/copy protection;
- несколько save slots;
- replay/recording;
- key rebinding;
- SDL visual/controller options;
- фактическая реализация ENHANCED и individual fix switches.
+297
View File
@@ -0,0 +1,297 @@
# План: консолидация работы с палитрами + переход уровня через fade
Статус: **этапы A и B реализованы; визуальная приёмка полного маршрута ещё
идёт** (2026-08-24). Палитры выделены в bank 10, а renderer cutscene/intro —
в bank 11, чтобы не переполнять bank 9 оболочки.
Обсуждение велось вокруг `roomtest/` (банк 9 — оболочка, fade из `pop_ui.c`).
---
## 1. Текущее состояние: карта палитры
Палитра Sprinter — 256 записей по 4 байта (B, G, R, 0) = 1 КБ на страницу.
У страниц дабл-буфера ДВЕ раздельные палитры (`gfx_pal_load(0,…)` и
`gfx_pal_load(1,…)` — почти всегда парой). BIOS читает буферы только из
#4000#BFFF: банковую rodata напрямую отдавать нельзя (копия в стек/W2),
см. грабли `pop_guard_set_palette` и `bg_load_tile_pal`.
### 1.1 Игровая палитра `KID\kid.pal` — раскладка слотов
Собирается `toolchain/pop_pack_kid.py build_palette()`, грузится одним
`gfx_pal_fload` (перезаписывает все 256 записей). Атласы запекались под эти
индексы — менять раскладку нельзя без перепаковки ассетов.
| Слоты | Назначение | Источник | Динамика |
|---|---|---|---|
| 0x00 | Цвет фона + **вспышка молнии** (подмена записи 0, `flash_bg` ← do_flash/set_bg_attr SDLPoP) | — | меняется в игре |
| 0x01–0x2F | Не закреплены (нули) | — | свободно |
| 0x300x3F | VGA16 — базовые 16 цветов для mono-блитов: пламя факелов, пузырьки зелья (+12 красный «лечение», +10 зелёный, +9 синий), кровь чомпера (12), дворцовая кладка mono (+6) | `VGA16[]` | статично |
| ↳ 0x37–0x3F | Поддиапазон **UI**: текст/рамка меню; единственное, что `keep_ui` не затемняет (`MENU_BORDER`=0x37) | — | — |
| 0x400x4F | chtab_1 пламя/зелья (`POT_PAL_BASE`) | VDUNGEON res150.pal | статично |
| 0x500x5F | **ENV фон тайлсета** (`POP_PAL_ENV`) | res200.pal набора | **меняется при смене тайлсета** |
| 0x600x6F | **WALL тайлсета** (`POP_PAL_WALL`) | res360.pal набора | **меняется при смене тайлсета** |
| 0x700x7F | Kid (`PAL_BASE`) | KID res400.pal | статично |
| 0x800x8F | Меч chtab_0 (`SWORD_PAL_BASE`) | POT res700.pal | статично |
| 0x900x9F | Страж chtab_5 (`GUARD_PAL_BASE`) | res10.bin guard_palettes | **меняется по КОМНАТАМ** |
| 0xA00xAF | Тень (`POP_SHADOW_PAL_BASE`) | RGB-сетка pop_pack_shadow.py | статично |
| 0xB00xFF | Свободны (5 слотов) | — | — |
Итого динамических зон три: запись 0 (молния), env+wall (тип здания),
стражи (per-room). Всё остальное одинаково всю игру.
### 1.2 Полноэкранные палитры заставок
Каждая перезаписывает ВСЕ 256 записей:
| Файл | Где используется |
|---|---|
| `KID\kid.pal` (+ fallback `a:\kid.pal`) | BOOT и возврат в игру после заставок |
| `TITLE\title.pal` | экран TITLE |
| `PV\story.pal` | INTRO и HALL_OF_FAME (одна палитра на обе фазы) |
### 1.3 Тайлсеты: подземелье ↔ дворец
Оба набора используют ОДНИ И ТЕ ЖЕ слоты 0x500x5F/0x600x6F, заполняя их
разными цветами (атласы обоих наборов запекались под эти индексы).
Переключение = загрузка 64 байт (32 записи env+wall) в обе страницы
(`bg_load_tile_pal`); остальные 224 записи не трогаются.
Какие уровни дворец — `tbl_level_type` (`pop_level_cold.c:44`):
**4, 5, 6, 10, 11, 14**; остальные подземелье.
Палитра дворца `pal_tile.pal` (расшифровка, формат записи B,G,R):
ENV 0x500x5F (пол, ковры, факелы, ворота, пики, арки):
| Слот | RGB | | Слот | RGB |
|---|---|---|---|---|
| 50 | 0,0,0 чёрный | | 58 | 202,190,178 серо-бежевый |
| 51 | 121,89,60 коричневый | | 59 | 153,133,129 серо-лиловый |
| 52 | 161,121,76 светло-коричневый | | 5A | 76,64,56 тёмный серо-бурый |
| 53 | 194,149,89 песочный | | 5B | 153,97,89 кирпично-красный |
| 54 | 230,178,113 яркий песок | | 5C | 137,80,72 тёмный кирпич |
| 55 | 246,202,125 кремовый | | 5D | 48,125,125 бирюзовый |
| 56 | 255,234,170 бледно-кремовый | | 5E | 12,56,89 тёмно-синий |
| 57 | 255,255,255 белый | | 5F | 202,56,28 красно-оранжевый |
WALL 0x600x6F (вся палитра песочная): 61=(218,170,89), 62=(226,165,93),
63=(226,170,97), 64=(218,161,85), 65=белый, 66=(226,165,93), 67=(218,165,89),
68=(226,170,89), 69=(218,170,97), 6A=(255,210,137), 6B=(255,218,149),
6C=(255,210,137), 6D=(255,218,145), 6E=(194,153,80 тёмный песок),
6F=(238,186,117).
Чем рисуется во дворце:
- **Тело стены — НЕ спрайты**, а сплошные заливки; цвет разыгрывается на
комнату prandom'ом (`gen_palace_wall_colors`, `pop_bg.c:140`, порт
seg000:1942): подряды 1 и 3 берут случайный из 0x61–0x64, подряды 0 и 2 —
из 0x66–0x69; соседи по горизонтали не повторяются.
- Декор стен id 3–17 — mono-силуэт цветом VGA16+6 (0x36).
- Верх дверных проёмов дворца — спец-id 78–84 + полоса 145 («полоса под
окнами», pop_room.c:478).
- Остальное (пол, ковры, порталы-факелы, ворота, пики) — env-куски
pal_env*.atl с ENV-таблицей выше.
### 1.4 Стражи (0x900x9F)
Цвет задаётся на КОМНАТУ (`level.guards_color[room-1]`), при входе в
комнату зовётся `pop_guard_set_palette(color)` ДО отрисовки (слоты общие
на экран — смена посреди кадра дала бы стража в новой палитре с полосой HP
в старой). Только для обычных стражей (`tbl_guard_type == 0`): скелет и
Джафар имеют собственную палитру, зашитую в kid.pal; им зовётся с color=0
(не трогать — иначе Джафар на ур.13 покрасился бы в цвет стража своей
комнаты). Внутри одного уровня слоты могут перезаписываться многократно.
## 2. Текущее состояние: механика fade
### 2.1 Наша реализация (`pop_ui.c`, банк 9)
- `pop_ui_palette_snapshot()` — снимок всех 256 записей через
`gfx_pal_get` по 4 чанкам × 64; хранится в хвосте страницы шрифта
FONT.ATL ([0x3C00,0x4000)), map/unmap W0. Требует `font_ready`.
- `pop_ui_palette_dim(step, keep_ui)` — готовит ОБЕ экранные палитры из
снимка. Шкала без умножений (только сдвиги):
| Шаг | Формула на канал | Яркость |
|---|---|---|
| 0 | x | оригинал |
| 1 | `(x>>1)+(x>>2)` | ≈3/4 |
| 2 | `x>>1` | 1/2 |
| 3 | `x>>2` | 1/4 |
| 4 | 0 | чёрный |
`keep_ui` пропускает 0x37–0x3F (меню остаётся ярким).
- `pop_ui_fade_out/in(steps)` — проигрывание ступеней за `steps` кадров
vsync (`step = i*4/steps`, целочисленно): steps=4 — канонический (по кадру
на ступень), steps<4 — перескакивает ступени, steps>4 — повторяет (плавнее),
steps=0 у fade_in — мгновенный restore.
- Контракт map/unmap: обращения к EMM/W0 и BIOS-палитре строго после unmap.
Стоимость одного dim ≈ 15–25 тыс. тактов (~4–7 мс при 3.5 МГц) —
укладывается в кадр vsync, на практике лагов нет.
### 2.2 Как сделано в SDLPoP (seg009.c, USE_FADE/gmMcgaVga)
- fade_out: каждый кадр КАЖДЫЙ ненулевой канал каждой записи −1; до нуля.
- fade_in: `fade_pos` от 0x40 вниз; канал +1, пока меньше оригинала.
- Уровней затемнения до 63–64 (VGA-канал 6 бит), полный фейд ~63 кадра ×
wait_time=2 тика — медленно и кинематографично.
- `which_rows` — битовая маска групп по 16 записей: можно фейдить часть
палитры (в оригинале используется).
- По завершении принудительно восстанавливается оригинал; после out экран
заливается чёрным.
Это осознанное расхождение (скорость/такты vs плавность) — ЗАПИСАТЬ в
`docs/impl_diff.md` (сейчас записи нет).
## 3. Зафиксированные решения
1. **Ступени затемнения: остаются 4.** Вариант 8 ступеней той же сдвиговой
техникой — рассмотреть отдельно, сейчас не внедрять.
2. **Предрасчёт fade-вариантов палитры отклонён.** Аргументы: чтение файла
с диска на порядок дороже вычисления; 3–7 КБ постоянной RAM при
MEMORY=small непозволительны; предрасчёт привязан к конкретным палитрам,
а снимок работает с любой текущей автоматически; keep_ui удвоил бы набор.
3. **Считать на лету**, хранить один снимок (уже есть, бесплатно в хвосте
страницы шрифта).
4. **Буферы на стеке**, не статика (W1/W2 мало) и не 1 КБ: обнулить 64/256
байт дешевле, чем держать килобайт резидентно.
5. **Контракт `gfx_pal_load(pal, start, count, data)`**: count — число
СЛОТОВ, буфер обязан быть `count*4` байт; count=0 означает «все 256».
6. **Leaf-applеры остаются на месте** (`pop_bg_pal_apply` — банк 7 со своими
таблицами, `pop_shadow_pal_apply`, `pop_guard_set_palette`): банковая
rodata чужого банка не видна, перенос сломал бы доступ к данным.
7. **Молния (`flash_bg` в roomtest.c) не переносится** — игровой эффект
записи 0; после вспышки восстановление записи 0 из снимка ложится на API.
8. Модель состояния: разделены «какая палитра логически загружена» (load_*)
и «с какой яркостью показана» (apply/fade). Любой load_* обновляет снимок;
apply/fade показывает его с нужной глубиной. Это позволяет грузить новую
палитру «в темноте» (экран остаётся чёрным, пока не позвали apply/fade_in).
## 4. Целевой API `pop_pal.c/.h` (банк 9)
```c
/* сброс */
void pop_pal_black(void) __banked;
/* все 256 записей ОБЕИХ страниц = 0. Стековый buf[256], обнуление циклом,
* 8 вызовов gfx_pal_load (4 чанка × 2 страницы, паттерн как в dim).
* Зовётся СРАЗУ ПОСЛЕ initgraph в pop_boot (раньше нельзя — нет гарантий
* состояния графического режима): закрывает кейс «мусор/палитра предыдущей
* программы при включении графики». СНИМОК НЕ ТРОГАЕТ (контракт:
* чёрный экран без изменения логической палитры). */
/* загрузка (пишет полную палитру в обе страницы + refresh снимка;
* видимую яркость НЕ трогают — экран меняется только по apply/fade) */
void pop_pal_file_load(const char *name) __banked;
/* gfx_pal_fload + fallback "a:\" + gfx_pal_sync (fallback сегодня
* скопирован в каждом из ~6 мест вызова) */
void pop_pal_game_load(void) __banked;
/* file_load("KID\kid.pal") + pop_bg_pal_apply + pop_shadow_pal_apply.
* Сегодня тройка скопирована 3 раза (roomtest_cold ~958, pop_title ~88,
* pop_intro ~183). Единое место инварианта «kid.pal затирает слоты
* тайлсета 0x50..0x6F и тени 0xA0..0xAF». */
void pop_pal_level_load(uint8_t full) __banked;
/* палитра уровня: kid.pal/shadow + tileset 0x50..0x6F если набор сменился
* (сравнение через pop_level_type()). full=1 — ПРИНУДИТЕЛЬНО перечитать
* kid.pal/shadow (один экспорт с флагом, не две функции — меньше банковых
* точек входа). СТРАЖЕЙ (0x90..0x9F) НЕ включает: это компетенция входа
* в комнату (pop_guard_set_palette до первого draw). */
void pop_pal_story_load(void) __banked; /* PV\story.pal (INTRO и HOF — файл один, функция одна) */
void pop_pal_title_load(void) __banked; /* TITLE\title.pal */
/* отображение */
void pop_pal_snapshot(void) __banked; /* переезд из pop_ui, тело то же */
void pop_pal_apply(uint8_t fade) __banked; /* = dim(fade, 0), 0..4 */
void pop_pal_fade_in(uint8_t steps) __banked; /* переезд из pop_ui */
void pop_pal_fade_out(uint8_t steps) __banked;
/* меню продолжает звать низкоуровневый dim(step, keep_ui=1) — отдельный
* тонкий экспорт, чтобы не тащить флаг в горячий apply. Старые имена
* pop_ui_palette_* / pop_ui_fade_* УДАЛЯЮТСЯ (без алиасов — меньше
* экспорта банка). */
```
Соответствие старое→новое: snapshot→snapshot, restore→apply(0),
fade_out/in→fade_out/in, тройка kid.pal×3→game_load, fload+fallback+sync×6→file_load.
## 5. Этап A: рефакторинг — выполнен (2026-08-24)
1. Создан `roomtest/pop_pal.c/.h` в **bank 10**, добавлен в Makefile.
Он владеет политикой `load logical palette → snapshot → apply brightness`.
Низкоуровневые snapshot/dim/fade остаются физически в `pop_ui.c`: там
владелец страницы FONT.ATL, где лежит снимок; наружу они доступны только
через `pop_pal`.
2. Заменены call-sites:
- `roomtest_cold.c` ~958: black → game_load вместо тройки;
- `pop_title.c` title_restore_game_palette → game_load; загрузка title.pal → title_load;
- `pop_intro.c` intro_load/intro_restore → story_load/game_load;
- `pop_hof.c` (2 × story.pal) → story_load;
- `pop_menu.c`: fade/dim → новые имена (dim с keep_ui — низкоуровневый экспорт);
- `roomtest.c` demo-start (snapshot+dim(4,0)+fade_in(4)) → новый API.
3. Старые вызовы не остаются в коде приложения; внутренние функции `pop_ui`
сохранены как реализации одного владельца памяти снимка.
4. Сборка и host-тесты пройдены. `make size-check` неприменим: меняется
приложение, а не libc/libbgi.
5. MAME smoke-тест полного цикла смен палитр: boot → title (title.pal +
fade) → intro (story/kid) → demo fade-in → игра → HOF (story.pal).
Проверить: отсутствие мусора при включении графики (эффект black),
меню с keep_ui остаётся ярким при затемнении, молния (запись 0)
восстанавливается.
## 6. Этап B: переход уровня через fade — реализован, ждёт визуальной приёмки
Сценарий (обсуждён, детали уточнить по SDLPoP перед реализацией — как
оригинал делает смену уровня, есть ли там fade в DOS-версии):
```
fade_out // последний кадр уровня N темнеет
рисуем комнату 1 уровня N+1 // во ВТОРУЮ страницу, в темноте
pop_pal_level_load(full=0) // новая палитра: железо+снимок обновлены,
// экран всё ещё чёрный
флип + копия второй страницы обратно в первую
fade_in // = анимированный apply 3→2→1→0
```
Экономия: реально переезжают только 32 записи (env/wall) при смене набора
dungeon↔palace; guards_color обновит вход в комнату. Kid/shadow не меняются
— потому full=0.
Реализация находится в `roomtest.c` / `roomtest_cold.c`: последний кадр
уровня N темнеет, `pop_level_switch()` подготавливает первый кадр N+1 и
обновляет логический источник через `pop_pal_level_load(1)`, затем главный
цикл показывает кадр только через fade-in. Восемь ступеней и отдельная
анимация смерти не входят в этот этап.
**Этап B закрывает два открытых бага** (разборы — `roomtest/BUGS_OPEN.md`):
- [PAL-L1-AFTER-INTRO] — вход в игру на уровень 1 после интро с чёрным
экраном (маршрут demo_new_game; корень не установлен, воспроизведение
нестабильно);
- [PAL-DUNGEON-STALE] — переход 3→4 оставляет подземную палитру (корень
ясен: fade_in восстанавливает из снимка, снятого ДО загрузки тайлсета
дворца; быстрый фикс `fade_in_pending` 2026-08-23 сам же и проявляет этот
дефект модели).
Быстрый фикс 2026-08-23 (маршрут CUTSCENE → LEVEL_LOAD → PLAYING,
`fade_in_pending` + `pop_ui_fade_in(4)` после `pop_level_switch`) закрыл
чёрный экран на переходах с pre-cutscene внутри подземелья (1→2), но модель
«кто и когда меняет яркость» остаётся разношёрстной — её и приводит в
порядок этап B.
## 7. Этап C: документирование
- Запись в `docs/impl_diff.md`: наши 4 ступени vs SDLPoP ~64 (что делает
оригинал, что делаем мы — сдвиговая шкала ради тактов, чем платим —
грубее градации, что проверять при регрессе).
- После этапа B — дополнить запись про сам переход.
## 8. Не трогаем
- Молнию (`flash_bg`, roomtest.c) — включая обход SDCC-бага
`gfx_pal_set(0,0,0,0,0)` → ручные `gfx_pal_set(0/1, 0, r,g,b)`;
- leaf-applеры: `pop_bg_pal_apply` (банк 7), `pop_shadow_pal_apply`,
`pop_guard_set_palette` (данные своих модулей);
- хранилище снимка в хвосте страницы шрифта FONT.ATL (бесплатное место,
guard `font_ready`);
- раскладку слотов 0x00–0xAF (зафиксирована атласами).
+238
View File
@@ -0,0 +1,238 @@
# Сцена и замер: факел + чомпер + страж, уровень 11 комната 15
Вторая целевая сцена для оптимизации (первая — [`perf_l13_room23.md`](perf_l13_room23.md),
каскад плит). Здесь узкое место другое: не разовый пик на каскаде, а
**постоянная** цена статичной комнаты, в которой одновременно живут два
факела, чомпер и страж.
Такты — `totalcycles` MAME (не такты Z80, ≈2,4× номинала, memory
`sprinter_wait_states_2x`). **Растровый кадр = 430 000.** Хвост кадра — три
`gfx_wait_vsync`, поэтому логический кадр занимает 3 растра, пока работа
укладывается в один; при работе 1..2 растра период становится 4.
## 1. Сцена
Уровень 11, комната 15. Проверено чтением состояния машины:
`pop_current_level` = 0x0B, `cur_room` = 15.
| кто | где | кадр |
|---|---|---|
| Кид | (0,2), x=98 | 15 (стойка с мечом) |
| чомпер | (0,3) | застывший (trob мёртв) |
| факел | (0,2) — пламя рисуется в ячейке (0,3) | анимируется каждый кадр |
| факел | (0,7) — пламя в ячейке (0,8) | анимируется каждый кадр |
| страж | (0,8), x=170 | 171 (боевая стойка) |
Ни Кид, ни страж не двигаются: сцена статична, разброс замера — сотни тактов
на 800 000.
## 2. Замер (`09f32ce`, база модуля `roomtest.c` = 0x42AD)
867 кадров, зонды A/C/D/E; детализация — тремя отдельными прогонами
(m1, m5..m7, M/F). **Период кадра: 4 растра во всех 866 интервалах.**
| участок | зонды | медиана | доля работы |
|---|---|---:|---:|
| **синяя: ввод + heal** | A→m1 | 141 048 | 17,6 % |
| **синяя: логика** | m1→C | 152 190 | 19,0 % |
| синяя, всего | A→C | **293 238** | 36,6 % |
| зелёная: `pop_loose_tick` | C→m5 | 28 872 | 3,6 % |
| **зелёная: `pop_process_trobs`** | m5→m6 | 92 448 | 11,5 % |
| **зелёная: `pop_redraw_needed`** | m6→m7 | 190 260 | 23,7 % |
| зелёная: шов/ворота соседа | m7→D | 9 336 | 1,2 % |
| зелёная, всего | C→D | **320 916** | 40,0 % |
| циан: `check_mirror` + `loose_mob_draw` | D→M | 37 458 | 4,7 % |
| **циан: Кид + страж + fore + HP** | M→F | 148 566 | 18,5 % |
| циан: `char_fore(KID)` + борта | F→E | 1 740 | 0,2 % |
| циан, всего | D→E | **187 758** | 23,4 % |
| **работа** | A→E | **801 768** | 1,86 растра |
Циан здесь **не** выделяется: 187 758 — это 0,44 растрового кадра, а разброс
за 867 кадров всего 174 такта (187 674..187 848). Впечатление «циан ~150 %
кадра» на глаз не подтвердилось — при периоде 4 растра полосы бордюра
размазаны по кадрам и на глаз не читаются.
## 3. Главная находка: чомпер справа от факела — 190 260 тактов/кадр
`pop_dbg_rdmax_tot` = **1**: за кадр перерисовывается РОВНО ОДИН тайл, и
стоит он все 190 260 тактов зелёной фазы.
> **ПОПРАВКА 2026-08-19 (после реализации P1).** Механизм ниже описан
> верно, но ГЛАВНЫМ источником 190 260 тактов он НЕ был. Зонд
> `pop_dbg_kind` показал, что все 312 перерисовок в прогоне — вид
> `POP_RD_CHOMP` (полная), и ни одной от факела: собственная пометка
> чомпера просто перебивала пометку соседа. Настоящая причина — в §6.
> Урок ровно тот, что уже записан в `defer_unexplained_quirks`: механизм,
> который правдоподобно объясняет цифру, ещё не доказан цифрой.
Цепочка:
1. `TORCH_ANIM_DIV = 1` — факел меняет кадр пламени КАЖДЫЙ логический кадр;
2. пламя запекается в фон (`pop_torch_draw`, `GFX_BANK_NORMAL`), а канвас
пламени 16×18 лежит **в ячейке правого соседа** (seg008:560) — то есть
поверх чомпера;
3. поэтому `pop_process_trobs` метит соседа: `if (trob_rcode[i] == TILE_CHOMP)
pop_set_redraw(tp + 1, POP_RD_CHOMP, 1)` (порт `set_redraw_anim_right`);
4. `pop_chomp_redraw` отвечает на пометку **heal 32×64 + полный `draw_tile`**.
Расхождение с оригиналом именно в шаге 4. `set_redraw_anim_right` метит
слой **anim**, и оригинал возвращает только его (`draw_tile_anim_topright` →
`draw_tile_anim_right` → `draw_tile_anim`) — одну графику чомпера поверх
огня. Мы вместо этого стираем и пересобираем тайл целиком, со всеми слоями
(`draw_tile_right`, `base`, `bottom`, `loose`), которые пламя вообще не
трогало.
Цена по модели блита (`blit_cost_model`, 8791 + 198·h + 5,96·w·h):
heal 32×64 ≈ 33 700, значит на один `draw_tile` уходит ≈ 156 000 — сходится
с известным замером «полная запечка щебня 179 914».
Чомпер при этом **застывший**: своей анимации у него нет, поза не меняется,
возвращать нужно ровно ту же графику поверх свежего пламени.
## 4. Что это даёт и куда смотреть дальше
Ранжирование по цене (доля от 801 768):
| # | участок | такты | что делать |
|---|---|---:|---|
| 1 | `redraw_needed`: чомпер под факелом | 190 260 | вернуть только слой anim, как в оригинале — без heal и без остальных слоёв |
| 2 | синяя: логика двух Char | 152 190 | графики нет вообще; разобрать `pop_check_can_guard_see_kid` и два `play_seq` |
| 3 | циан: Кид + страж | 148 566 | оба будятся каждый кадр — метки фона от чомпера/факела накрывают обоих |
| 4 | синяя: heal двух Char | 141 048 | следствие того же: skip не срабатывает ни разу |
| 5 | `process_trobs`: два факела | 92 448 | ≈46 000 на факел при блите пламени 16×18 ≈ 14 000 — разобрать накладные |
| 6 | `loose_tick` | 28 872 | в комнате нет ни одной loose-плиты |
Пункты 3 и 4 — одна тема: пока фон трогают каждый кадр, `pop_char_skip_mask`
не может пропустить ни Кида, ни стража. Пламя метит узко (16×18), а вот
`pop_chomp_redraw` метит весь тайл со свесом — то есть пункт 1 чинит и часть
пунктов 3/4.
Связанные задачи: `HEAL-WIDTH` и G8 в [`perf_green_phase.md`](perf_green_phase.md)
— та же болезнь (полный тайл там, где хватает полосы), но на другом
материале.
## 6. Настоящая причина 190 260 тактов: перерисовка неизменной позы
Найдено при реализации P1, сверкой с `animate_chomper` (seg007:0448).
Функция оригинала заканчивается так:
```c
if ((curr_modifier & 0x7F) < 6) {
redraw_at_trob();
}
```
То есть чомпер перерисовывается **только пока фаза меньше 6** — пять кадров
из пятнадцати (`POP_CHOMPER_SPEED = 15`). Это не оптимизация оригинала, а
следствие таблицы поз: `chomper_fram1 = {3,2,0,1,4,3,3}`, и начиная с фазы 5
и до конца круга поза одна и та же — 3. Рисовать её десять кадров подряд
значит рисовать ровно ту же картинку.
Мы же метили тайл БЕЗУСЛОВНО, каждый кадр, пока trob жив — то есть платили
полный `draw_tile` плюс heal 32×64 за неизменную картинку в двух третях
кадров. А trob у чомпера живёт, пока Кид в том же РЯДУ (`animate_chomper`
снимает его только при фазе ≥ 6 и ушедшем Киде) — в 11/15 Кид стоит в (0,2),
чомпер в (0,3), ряд один.
**Что сделано:**
1. пометка только при фазе < 6, и на фазе 5 — на ОБЕ страницы дабл-буфера
(она последняя рисуемая, её поза обязана лечь на обе; вторую страницу
пометка догоняет в кадре фазы 6, где поза та же самая);
2. пометка от факела (`set_redraw_anim_right`) переведена на новый вид
`POP_RD_CHOMP_ANIM` → `pop_chomp_anim_draw`: три блита графики чомпера
поверх свежего пламени, без heal и без остальных слоёв — порт ветки
`redraw_frames_anim` (seg008:0211);
3. приоритет полной перерисовки над anim в `pop_set_redraw` — у оригинала
это два независимых счётчика, и `full` побеждает.
**Результат** (замер, 552 кадра): полная перерисовка теперь в **40 %**
кадров, лёгкий возврат челюстей — в 60 %. Зелёная фаза: 291 888 в дорогом
кадре против 183 414 в дешёвом.
| | работа | зелёная |
|---|---:|---:|
| до P1 | 768 684 | 294 510 |
| после P1, медиана | **657 882** | **183 420** |
| после P1, дорогой кадр (40 %) | 765 936 | 291 888 |
**−110 802 на медиане** при ожидании −160 000. Разница в том, что 40 %
кадров по-прежнему платят полную цену: там поза реально меняется, и это уже
не лишняя работа, а честная. Дальше её можно резать только раскладом
`draw_tile` на части (P7) или сужением heal (P8).
## 5. Журнал правок по этой сцене
| дата | правка | работа | синяя | зелёная | циан |
|---|---|---:|---:|---:|---:|
| 2026-08-19 | базовый замер (`09f32ce`) | 801 768 | 293 238 | 320 916 | 187 758 |
| 2026-08-19 | **P5**: гейты холостого хода в `pop_loose_tick` | **767 928** | 285 864 | 294 384 | 187 764 |
| | | 33 840 | 7 374 | 26 532 | +6 |
| 2026-08-19 | зонды для замера P2/P6 (временные) | 768 684 | 286 518 | 294 510 | 187 761 |
| 2026-08-19 | **P1**: чомпер — перерисовка только при фазе < 6 (медиана) | **657 882** | 286 503 | 183 420 | 187 761 |
| | | 110 802 | 15 | 111 090 | 0 |
Оснастка P2/P6 стоит 756 тактов на кадр — замеры до и после сопоставимы.
Циан не изменился (+6 тактов — шум), и это ожидаемо: `loose_tick` целиком
лежит в зелёной. Синяя просела на 7 374 без прямой причины в правке —
скорее всего перераскладка кода банка 3 компилятором; проверять отдельно
не стали, знак верный.
## 7. ТЯЖЁЛАЯ позиция: Кид на шаг правее [замер 2026-08-19]
Поставлена пользователем: один осторожный шаг вправо (x = 106 вместо 99,
колонка та же). Спрайт Кида начинает пересекаться с тайлом (0,3), где
одновременно чомпер и пламя факела — и `skip_mask` перестаёт его
пропускать.
| фаза | лёгкая | **тяжёлая** | разница |
|---|---:|---:|---:|
| синяя | 259 500 | 257 520 | 1 980 |
| зелёная (медиана) | 181 068 | 180 870 | 198 |
| **циан** | 187 761 | **319 842** | **+132 081** |
| **работа (медиана)** | 628 542 | **758 358** | **+129 816** |
| работа (максимум) | 744 384 | **876 612** | |
**Период кадра: 4 растра в 335 кадрах, 5 растров в 123 (27 %).** Это уже
не «стабильно медленно», а рывки: каждый четвёртый кадр длиннее соседних.
Разбор циана показывает, куда ушли 132 тысячи:
| участок | лёгкая | тяжёлая |
|---|---:|---:|
| `check_mirror` | 3 198 | 3 198 |
| `mob_draw` + `guard_over_kid` + `skip_mask` | 34 374 | 18 672 |
| **`pop_char_draw(KID)`** | **204** | **54 738** |
| соперник: `char_draw` + `char_fore` | 145 896 | 149 748 |
| **`fore_needed` + `char_fore(KID)` + борта** | **4 356** | **93 486** |
То есть Кид из «пропущен за 204 такта» превращается в полноценного
персонажа за ~144 000 — ровно столько же, сколько стоит страж.
**Главный вывод замера: самая дорогая единичная статья кадра — это
fore-проход персонажа.** 62 778 у стража и ~89 000 у Кида, вместе около
**152 000, то есть 20 % работы кадра**. У Кида он дороже потому, что в его
футпринте лежит чомпер, а у чомпера есть собственный передний слой
(`POP_CHOMP_FRAM_FOR`), который перерисовывается поверх персонажа каждый
кадр.
## 8. После P15 (точная метка «фон трогали»)
| фаза | лёгкая до | лёгкая после | тяжёлая до | тяжёлая после |
|---|---:|---:|---:|---:|
| синяя | 259 050 | **223 902** | 257 520 | **245 808** |
| зелёная | 181 494 | 181 494 | 180 870 | 181 761 |
| циан | 194 262 | **59 406** | 319 842 | **192 090** |
| **работа** | 628 542 | **464 796** | 758 358 | **617 487** |
В лёгкой позиции не рисуется НИ ОДИН персонаж (циан 59 406 — это уже только
`check_mirror`, проверки и передний слой по пометкам). В тяжёлой рисуется
один Кид: он действительно стоит под пламенем, а страж — нет.
**Пятирастровые кадры в тяжёлой позиции исчезли** (было 27 %), период стал
ровно 4.
**Полная очередь оптимизаций с оценками — [`perf_registry.md`](perf_registry.md).**
Там же разложена цена одного блита фона по этапам (замер 2026-08-19, 1603
блита) и модель зелёной фазы этой сцены.
+32
View File
@@ -237,3 +237,35 @@ memory `blit_cost_model`), а 16-битная арифметика в стеко
Существующая страховка уже есть и остаётся: `pop_blit_b` уводит кадр с
`img[1] | img[3] != 0` на общий путь `blit_b_oversize`.
**Регресс после дня оптимизации 11/15 (`d0ac4b1`, 2026-08-19), 2367 кадров:**
| максимум по секции | эталон `mob-order-B-done` | сейчас | разница |
|---|---:|---:|---:|
| работа | 913 848 | **911 862** | 1 986 |
| синяя | 159 810 | **149 106** | 10 704 |
| зелёная | 440 418 | **436 494** | 3 924 |
| циан | 393 000 | **382 770** | 10 230 |
Период: **3 растра в 2341 кадре, 4 в 23, 5 в 2** — как в эталоне.
Почему сумма минусов по фазам не равна минусу по работе: максимумы разных
фаз достигаются В РАЗНЫХ КАДРАХ (пик синей — не тот кадр, где пик зелёной),
а «работа» здесь — максимум СУММЫ, а не сумма максимумов.
Что из правок 11/15 сюда дошло: P16 и P2b дали синюю и циан (они про
проверки и луч видимости, а те работают в любой сцене), HEAL-WIDTH дал
зелёную (плита 64 → 58 на шести heal'ах кадра).
**Зелёная по-прежнему выше растрового кадра** (436 494 против 430 000).
Главный оставшийся кандидат именно для этой сцены — **P9 (G8)**: при
падении плиты помечаются ДВА тайла, и соседний перезапекается целиком и
повторно (`draw_tile` соседа дважды на одну пометку), хотя потревожены у
него только левые 28 пикселей. При шести падающих плитах это умножается
на шесть.
**ВАЖНО ДЛЯ ПРОЦЕССА.** Этот прогон вскрыл регрессию, которую не поймали
ни хост-тесты, ни сцена 11/15: гейт `loose_any` (позиция P5) не взводился
в `check_fall_flo`, и плиты уровня 13 дрожали, не падая. Сцену 13/23 надо
прогонять после КАЖДОЙ правки loose-механики, а не только когда меняешь её
сознательно.
+871
View File
@@ -0,0 +1,871 @@
# Реестр оптимизаций: всё отложенное, в одном списке
Собрано 2026-08-19 из [`perf_green_phase.md`](perf_green_phase.md) (G1-G9),
[`perf_cyan_phase.md`](perf_cyan_phase.md) (C1-C7),
[`perf_backlog.md`](perf_backlog.md) (позиции 1-7),
[`../roomtest/TASKS_OPEN.md`](../roomtest/TASKS_OPEN.md) (HEAL-WIDTH) и из
свежего разбора сцены [`perf_l11_room15.md`](perf_l11_room15.md).
**База для процентов — работа кадра в 11/15: 801 768 тактов** (замер
`09f32ce`). Где эффект относится к другой сцене, это сказано явно.
Оценки помечены: **[замер]** — измерено; **[модель]** — посчитано по
измеренным составляющим; **[гипотеза]** — не мерено, нужен прогон.
---
## 1. Цена одного блита фона — разложена [замер 2026-08-19]
Метод: брейкпоинты на резидентных адресах внутри `pop_blit_b` (0x5C50),
`temp0` на входе, разница `totalcycles` на каждом вызове. 1603 блита.
| этап | такты | постоянство |
|---|---:|---|
| пролог + аргументы + грубый отсев | **810** | ровно, всегда |
| `atlas_image` | **672** | ровно, всегда |
| `gfx_w0_map` + чтение шапки ленты + арифметика клипа | **2 400** | ровно, всегда |
| ядро блита (пиксели) | 4 380 … 26 592 | по размеру кадра |
| `pop_cd_touch` | **2 069** (пакетный путь) / 4 115 (настоящая пометка) | почти ровно |
| `gfx_w0_unmap` + эпилог | **175** | ровно, всегда |
| **весь блит** | 10 458 … 32 718, медиана **16 674** | |
**Фиксированная накладная = 6 126 тактов на любой блит, хоть 8×8.**
У самого дешёвого блита (10 458) это **59 % цены**; у пламени факела 16×18
пиксели тянут ~1 700 из ~14 000, то есть **12 %**.
Это и есть ответ на вопрос «почему маленький блит стоит 14 000». Причины
ровно те, о которых спрашивал пользователь:
- **810 на пролог** — `call ___sdcc_enter_ix`, IX-фрейм и шесть чтений
`N(ix)`: третий и четвёртый аргументы (`int x`, `int ybottom`) идут
СТЕКОМ, каждое обращение 19 тактов Z80;
- **2 069 на `pop_cd_touch`** даже по пакетному пути, где вся работа — четыре
сравнения. Сигнатура `(int x, int y, int w, int h)` = 8 байт аргументов,
два из них через стек; `w`/`h` никогда не больше 64, `y` не больше 255,
то есть три из четырёх могли быть `uint8_t`;
- **2 400 на map + шапку** — `gfx_w0_map` (1 086) + `unmap` (264, платится в
конце) + ~1 000 на чтение четырёх байт заголовка и арифметику;
- **672 на `atlas_image`** — маппинг W3, чтение записи каталога, возврат W3;
размеры при этом читаются и выбрасываются (см. §3, C6).
**Блитов за кадр в 11/15: 8** [замер] — все в зелёной фазе (6 на `draw_tile`
чомпера, 2 на пламя факелов). Синяя и циан через `pop_blit_b` не ходят
вовсе: heal и персонажи идут своими путями. Значит фиксированные накладные
блита стоят сцене **8 × 6 126 = 49 000 тактов/кадр (6,1 % работы)**.
---
## 2. Модель зелёной фазы 11/15 [модель, сходится с 320 916 замера]
| статья | такты | доля фазы |
|---|---:|---:|
| 8 блитов фона (из них 6 126×8 = 49 000 накладных) | 133 400 | 41 % |
| диспетчер `draw_tile` (один тайл чомпера) | 56 200 | 18 % |
| цикл `pop_process_trobs` без блитов пламени | 59 100 | 18 % |
| `pop_loose_tick` (плит в комнате НЕТ) | 28 872 | 9 % |
| heal 32×64 в `pop_chomp_redraw` | 34 000 | 11 % |
| шов / ворота соседа | 9 336 | 3 % |
Больше половины фазы — не пиксели, а обвязка вокруг них.
---
## 3. Список приёмов, отсортированный по эффекту
### P1. Чомпер: перерисовка неизменной позы ✅ СДЕЛАНО 2026-08-19 — 110 802
**Диагноз, с которым позиция заводилась, оказался неполным.** Я приписал
190 260 тактов пометке от факела; зонд `pop_dbg_kind` показал, что все 312
перерисовок прогона — вид `POP_RD_CHOMP` (полная), а пометку соседа она
просто перебивала. Настоящая причина нашлась сверкой с `animate_chomper`
(seg007:0448): оригинал перерисовывает чомпер **только при фазе < 6**, пять
кадров из пятнадцати, потому что с фазы 5 поза не меняется
(`chomper_fram1 = {3,2,0,1,4,3,3}`). Мы метили тайл каждый кадр.
Сделано три вещи: условие фазы (с пометкой обеих страниц на фазе 5), новый
вид `POP_RD_CHOMP_ANIM``pop_chomp_anim_draw` (три блита поверх огня, порт
ветки `redraw_frames_anim`) и приоритет полной перерисовки над anim в
`pop_set_redraw`. Обе половины работают: замер даёт 40 % полных
перерисовок и 60 % лёгких.
Работа 768 684 → **657 882** (медиана), зелёная 294 510 → **183 420**.
В 40 % кадров цена осталась прежней — там поза действительно меняется, и это
уже честная работа; резать её дальше только через P7 (раскол `draw_tile`)
или P8 (ширина heal).
Полный разбор — [`perf_l11_room15.md`](perf_l11_room15.md) §6.
<details><summary>Исходная (неполная) постановка</summary>
Разбор в [`perf_l11_room15.md`](perf_l11_room15.md) §3. Сейчас пометка от
факела обрабатывается как `heal 32×64 + полный draw_tile` (190 260 тактов на
единственный перерисованный тайл кадра); оригинал в этом случае рисует
ТОЛЬКО `draw_tile_anim` — графику чомпера поверх свежего пламени.
Останется 1-2 блита челюстей ≈ 20 000-33 000. **Риск низкий**: это
сближение с оригиналом, а не отход от него. Побочно снимает широкую пометку
«фон трогали» вокруг тайла чомпера — см. P3.
</details>
### P2. Синяя фаза разложена [ЗАМЕР 2026-08-19] — гипотеза не подтвердилась
Замер зондами внутрь обеих половин синей (286 518 тактов):
| участок | такты | доля работы |
|---|---:|---:|
| ввод + читы | 14 154 | 1,8 % |
| три спецсобытия уровней (skel / mouse / killed_shadow) | **1 650** | 0,2 % |
| `pop_frame_timers` + **луч видимости стража** | **37 032** | 4,8 % |
| `pop_ctrl_tick` | 18 648 | 2,4 % |
| `skip_mask` + **heal двух Char** | **67 734** | 8,8 % |
| `mirror_heal` + `fore_heal` | 2 040 | 0,3 % |
| `kid_tick` (play_seq) | 10 098 | 1,3 % |
| **`pop_phys_tick`** (физика Кида) | **61 266** | 8,0 % |
| `pop_guard_tick` (логика стража) | 19 932 | 2,6 % |
| **`pop_guard_phys_tick`** (физика стража) | **43 980** | 5,7 % |
| боёвка (`sword_hurting` / `sword_hurt` / `delta_hp`) | 9 558 | 1,2 % |
| `guard_fallout` + уход из комнаты | 366 | — |
**Что оказалось не так, как ждали.** Я предполагал, что дорогие тут
банковые трамплины на спецсобытиях (по аналогии с `pop_clip_char_top`,
8 892 такта за трамплин ради одной проверки). Замер это отверг: три
спецсобытия уровней вместе стоят **1 650** — они гейтятся внутри и на
уровне 11 честно выходят сразу.
**Настоящие статьи — три:**
1. **Физика двух Char — 105 246** (61 266 + 43 980), при том что оба
персонажа СТОЯТ и кадр позы не меняется. Это 13,7 % работы кадра и
самая крупная статья синей. Нужен ещё один уровень разбора — внутрь
`pop_phys_tick` (позиция **P2a**, отдельным заходом).
2. **Луч видимости стража — до 37 032** (вместе с `pop_frame_timers`, но тот
заведомо копеечный: три счётчика). Считается КАЖДЫЙ кадр, хотя ни Кид,
ни страж не сдвинулись. Кандидат на гейт «пересчитывать только при
смене позиции или комнаты любого из двоих» — позиция **P2b**,
ожидание −30 000, риск низкий.
3. **heal двух Char — 67 734.** Отдельной правки не требует: он платится
ровно потому, что `skip_mask` никого не пропускает, и уйдёт вместе с
P1/P3.
Новое, найдено 2026-08-19.
### P15. Точность метки «фон трогали» ✅ СДЕЛАНО 2026-08-19 — 163 746 / 140 871
Постановка пользователя: не перерисовывать стража, пока он не двигается.
**Две правки, и вторая оказалась решающей:**
1. **метка**: вместо «маска колонок × три ряда по 63 px» — диапазон y на
каждую колонку (`ymin`/`ymax`, 40 байт на обе страницы). Прежняя
гранулярность склеивала пламя факела (y 33..50) с клинком стоящего
стража (y 59..65), между которыми девять пикселей зазора;
2. **проверка**: `cd_quiet` сверяет спрайт и накладной (клинок, брызги)
ДВУМЯ отдельными прямоугольниками вместо объединённого bbox.
Объединение включает пустой угол: спрайт стража в колонке 8, клинок
уходит в колонку 7 на y 59..65, пламя метит колонку 7 на y 33..50 —
и прямоугольник «спрайт + клинок» цеплял метку углом.
**Без второй правки первая дала почти ноль** (632 676 против 628 542 до
неё) — это стоит помнить: точность структуры бесполезна, пока запрос к ней
остаётся грубым.
| | лёгкая позиция | тяжёлая позиция |
|---|---:|---:|
| синяя | 259 050 → **223 902** | 257 520 → **245 808** |
| зелёная | 181 494 → 181 494 | 180 870 → 181 761 |
| циан | 194 262 → **59 406** | 319 842 → **192 090** |
| **работа** | 628 542 → **464 796** | 758 358 → **617 487** |
В тяжёлой позиции вдобавок исчезли пятирастровые кадры (было 27 %).
Проверено в MAME: статика чистая, динамика (пробежка, бой, переход в
соседнюю комнату) без хвостов и просвечивания; хост-тесты зелёные.
Побочно исправлены два собственных дефекта первой редакции: обе страницы
обновлялись по условию, проверяющему только страницу 0 (после
`pop_cd_clear(0)` метка второй переставала расти), и отсутствовала явная
инициализация — пустая колонка обозначается `ymin = 255`, а нули от crt0
читались бы как «затронута строка 0».
### P16. Цианные проверки ✅ СДЕЛАНО 2026-08-19 — 25 818
Раскладка остатка цианной фазы (59 406) показала, что 47 883 из них — три
вызова, а не отрисовка:
| вызов | было | стало |
|---|---:|---:|
| `pop_loose_mob_draw` | 978 | 978 (гейт `mobs_live` работает) |
| `guard_over_kid` | 14 424 | **0** |
| `pop_char_skip_mask` | 28 605 | ~21 000 |
Три правки:
1. **`cd_sig_same`** — сравнение снимка БЕЗ построения структуры.
`cd_sig_make` записывал тринадцать полей в стековый кадр (через
`-n(ix)`), и лишь потом шёл побайтовый цикл; теперь сравнение идёт прямо
с источником и выходит на первом расхождении. **8 016**;
2. **`guard_over_kid` по условию** — вопрос «кто поверх кого» не имеет
смысла, когда не рисуется никто. Вызов перенесён ПОСЛЕ `skip_mask` и
идёт только при `skip != 3`. **14 118**;
3. **`pop_cd_hit_slot`** — проверка «задет ли слот» брала пять аргументов,
три из них стеком (45 % тактов на IX). Теперь координаты берутся из
`pop_cd`, а сравнение вынесено в `hit_rect` с file-scope аргументами.
**3 684**.
**Отрицательный результат внутри третьей правки** (не повторять): первая
версия была обёрткой, которая внутри всё равно звала `pop_cd_hit` с пятью
аргументами — стало ХУЖЕ (1799 тактов Z80 вместо 1318). Снимать аргументы
со стека надо у того, кто их читает, а не этажом выше.
### Отрицательные результаты 2026-08-19 — НЕ ПОВТОРЯТЬ
Три попытки подряд сделали ХУЖЕ. Общая ошибка в двух из них — я оценивал
правку по СУММЕ ТАКТОВ ИНСТРУКЦИЙ в листинге, а не по реально исполняемому
пути.
**1. `cd_sig_same` блоком вместо тринадцати сравнений.** Снимок был
переложен так, чтобы сравнивать непрерывные 10 байт начала `pop_char_t`
циклом `do { if (*a++ != *b++) return 0; } while (--i)`. По листингу
функция стала короче (1939 → 1290 тактов), а на машине **стало хуже:
438 978 → 450 426 (+11 448)**.
Причина: сумма по листингу считает каждую инструкцию ОДИН раз, а тело
цикла исполняется ДЕСЯТЬ раз. Тринадцать линейных сравнений выполняются по
разу каждое и выходят раньше на первом же расхождении. **Урок: короткий
листинг ≠ быстрый код; цикл надо разворачивать в уме.**
**2. `cd_touch_pb` — пометка «для блита» из file-scope.** `pop_cd_touch`
зовётся из `pop_blit_b` с четырьмя аргументами, хотя тот держит те же
значения в `pb_x`/`pb_top`/`pb_w`/`pb_h`. Специализированный вход без
аргументов дал **438 978 → 442 242 (+3 264)**.
Причина: в зелёной фазе блиты идут ПАКЕТНЫМ путём (`draw_tile` открывает
`pop_cd_batch`), а там нужны все четыре значения сразу — и в регистрах
(`x`, `y` приходят в HL/DE) они дешевле, чем чтение из статиков.
**Снятие аргументов со стека помогает не всегда: если значение и так живёт
в регистре, статик его туда ещё и загружать заставит.**
**3. Обёртка `pop_cd_hit_slot` поверх `pop_cd_hit`** (описана в P16):
внутри всё равно звала функцию с пятью аргументами и добавила свои — стало
1799 тактов вместо 1318. Помогло только когда сравнение переехало внутрь.
### P14. Fore-проход персонажа — от 4 122 до 117 570 [замеры 2026-08-19]
**Самая НЕСТАБИЛЬНАЯ статья кадра.** Замеры на одной и той же сцене:
| ситуация | fore-проход |
|---|---:|
| персонаж пропущен (`skip`) | 4 122 |
| стоящий страж | 62 778 |
| живой Кид у чомпера | ~89 000 |
| **труп Кида в челюстях** | **117 570** |
Растёт от двух вещей: ширины футпринта (широкий кадр смерти, вынутый меч
добавляет колонку) и числа тайлов с передним слоем внутри футпринта (здесь
чомпер со своими зубьями). Отсюда практический вывод: **в бою проход будет
ближе к сотне тысяч, чем к шестидесяти** — кадры выпадов и ударов широкие.
Замер трупа сделан по просьбе пользователя. Сама по себе эта ситуация не
игровая («когда Кид — труп, игры нет»), но именно она показала верхнюю
границу цены.
**РАЗБОР 2026-08-19: P14 сводится к P4.** Fore-проход Кида в тяжёлой
позиции (89 268) разложен зондами:
| участок | такты |
|---|---:|
| вход + `pop_fore_set_clip` + `char_footprint` | 10 872 |
| арифметика границ окна | 3 786 |
| шов ворот + overlay-цикл | 3 294 |
| **цикл `fore_tile` по тайлам** | **67 854** (76 %) |
| `pop_gate_over_char` + хвост | 3 462 |
А счётчик показал, что цикл обходит **всего 4 тайла**, и 3 из них реально
рисуют (`FORE_ANY != 0`). То есть 67 854 — это НЕ перебор лишних тайлов
(их четыре) и не проверки, а **цена самих блитов переднего слоя**: около
четырёх блитов по ~16 000, из которых 6 765 на каждом — фиксированная
накладная (см. §1).
**Отсюда вывод для плана:** отдельной «оптимизации fore-прохода» почти нет.
Срезать там можно ровно три вещи, и только первая крупная:
1. **цену блита (P4)** — 4 блита × 6 765 накладных = ~27 000 из 67 854;
2. `char_footprint` из физики (**P10**) — часть от 10 872;
3. слияние двух трамплинов в банк 2 — ~4 000.
Иначе говоря, **P4 ускоряет и зелёную фазу (8 блитов), и fore-проход
(4 блита), то есть работает и в статике, и в динамике** — в отличие от
P14, который я считал самостоятельной позицией.
`pop_char_fore` = два трамплина в банк 2 (`pop_fore_set_clip` +
`pop_fore_over_char`) плюс обход тайлов футпринта, в каждом `fore_tile`.
У Кида дороже, чем у стража, потому что в его футпринте лежит чомпер, а у
чомпера есть собственный передний слой (`POP_CHOMP_FRAM_FOR`), который
перерисовывается поверх персонажа каждый кадр.
Что можно пробовать, по возрастанию радикальности:
1. слить два трамплина в один вызов (мелочь, ~4 000);
2. **P10** — брать футпринт из физики, а не считать заново (−11 574 на
проход, то есть до −23 000 на двоих);
3. гейт по сигнатуре: пропускать проход, если не изменились ни кадр
персонажа, ни тайлы его футпринта. **Это расхождение с оригиналом**
он рисует foretable безусловно;
4. **P13** — objtable и отложенные таблицы: у оригинала «посетить тайл»
стоит копейки именно потому, что таблицы только копят записи.
### P3. Персонажи будятся каждый кадр ✅ ЧАСТИЧНО СБЫЛОСЬ
**В лёгкой позиции — да:** после P1 `pop_char_draw(KID)` стоит 204 такта,
метка от чомпера до Кида больше не дотягивается.
**В тяжёлой позиции — нет:** стоит Киду шагнуть на 7 пикселей вправо, и его
спрайт пересекается с тайлом чомпера и пламени, `skip_mask` перестаёт
пропускать, и он снова стоит ~144 000 (54 738 draw + ~89 000 fore). То
есть выигрыш P3 держится только пока персонаж не подошёл к анимированному
тайлу — а в игре он к нему подходит постоянно.
Исходная оценка (−70 000) была:
heal 141 048 + отрисовка 148 566 = 290 000 тактов (36 % работы) уходят на то,
что `pop_char_skip_mask` не может пропустить ни Кида, ни стража: фон трогают
каждый кадр.
- **Кида** спасает P1: широкая пометка вокруг чомпера исчезнет;
- **стража спасти нельзя** — пламя правого факела (0,7) рисуется в ячейке
(0,8), где он и стоит. Там фон честно меняется, и оригинал персонажа тоже
перерисовывает.
### P17. 16 бит там, где хватает 8 ✅ 2026-08-19 (замечание пользователя)
**1. Границы экрана — беззнаковыми сравнениями.** Проверка «спрайт целиком
на экране» стояла как четыре ЗНАКОВЫХ 16-битных сравнения, а знаковое у
SDCC z80 разворачивается в `sbc` плюс `jp PO / xor 0x80 / jp P`.
Беззнаковая форма делает то же двумя: отрицательная координата становится
очень большой и проваливает условие так же, как `>= 0`. **378.**
**2. Габариты спрайтов в байтах.** `w`/`h`, `ow`/`oh`, `fpw`/`fph`,
`cw`/`ch` в `pop_cdraw_t`, параметры `cd_overlay_add`/`cd_clip_add`, локали
в `pop_char_draw`/`cd_splash` и чтение габарита из шапки ленты были
`uint16_t`, хотя спрайты атласов не крупнее 64×64 (memory
`pop_sprite_size_limits`). **−276 в статике, −1 134 в циане динамики**,
плюс 24 байта `_DATA`.
### P6a. Кэш указателя модификаторов ✅ 2026-08-19 — 840 (ждали 20 000)
`pop_trob_modif` объявлен `__banked`, а звался на КАЖДЫЙ trob внутри цикла
`pop_process_trobs`, хотя комната у них в подавляющем большинстве кадров
одна. Указатель теперь кэшируется между итерациями.
Цикл trobs 78 726 → **74 964**, работа кадра 438 324 → **437 484**.
**Оценка в реестре была завышена в двадцать раз**, и стоит понять почему:
я перенёс её по аналогии с лучом видимости (P2b), где трамплин звался
ДЕВЯТЬ раз за кадр. Здесь trob'ов в комнате всего несколько, и кэш
экономит два-три вызова. **Урок: «тот же паттерн» не означает «тот же
порядок величины» — считать надо число вызовов, а не узнавать шаблон.**
### P6b. Кэш префетча кодов тайлов — НЕ ДЕЛАЛОСЬ
Префетч (`pop_level_access_begin/end` плюс чтение кода на каждый trob)
стоит **11 058** за кадр. Кэшировать мешает инвалидация: код тайла меняет
`pop_level_set_tile` (кнопка → пол, loose → empty), вход в комнату и
добавление trob'а — пропустить хоть один источник значит получить
застывшую анимацию. С учётом того, что P6a дал 840 вместо 20 000,
ожидаемый выигрыш тут тоже стоит считать скромным, а риск он несёт
несоразмерный.
### P10. Футпринт из физики — РАЗБОР 2026-08-19 (без реализации)
Идея из `perf_backlog.md` §1: `redraw_at_char` (seg003:0430) берёт ГОТОВЫЕ
`char_col_left/right`, `char_top_row`, `char_bottom_row`, посчитанные в том
же кадре физикой (`set_char_collision`, seg006:0723), а наш
`char_footprint` (pop_bg.c) считает их заново внутри fore-прохода.
**Разбор показал, что «просто передать» не получится: величины разные.**
| | `char_footprint` (банк 2, fore) | `set_char_collision` (банк 3, физика) |
|---|---|---|
| ширина | габарит КАДРА `w` из атласа, `wh = (w+1)/2` | то же `fpw`, но затем **`FRAME_THIN` сдвигает края на ±4** |
| меч | расширяет диапазон на колонку (`sword >= DRAWN`) | не расширяет |
| колонки | `cLraw` до клампа (нужен для шва), затем кламп 0..9 | `coll_xl`/`coll_xr` в пикселях, колонки считает уже `calc_coll_window` |
| ряды | `rT`/`rB` от ВЕРХА и НИЗА спрайта, с форсом `rT = rB-1` | `Char.curr_row` — опорный ряд, это другое |
То есть у оригинала обе задачи пользуются ОДНИМИ величинами, потому что он
считает их один раз в `set_char_collision`. У нас они исторически
разошлись: коллизии считают своё окно (с поправкой `FRAME_THIN`), fore —
своё (габарит кадра плюс колонка под меч).
**Значит P10 — это не «передать готовое», а сперва СВЕСТИ обе величины к
одной, как в оригинале.** Работа не механическая: `FRAME_THIN` влияет на
коллизии осознанно (узкие кадры не должны цеплять стену), а fore-проходу
нужен полный габарит, иначе передние грани в крайней колонке не
перерисуются.
**Чего не хватает для решения:** отдельного замера самого
`char_footprint`. Сейчас известно только «вход + `pop_fore_set_clip` +
`char_footprint` = 10 872», а оценка 11 574 в backlog взята из старого
замера другой сборки. Первым шагом нужен зонд между `set_clip` и
`char_footprint`.
**Оценка приоритета:** низкая. Даже если `char_footprint` окажется всеми
10 872, он платится только когда персонаж рисуется (в статике fore-прохода
нет), а сведение двух геометрий к одной — это риск для коллизий, то есть
для физики, которая сейчас работает правильно.
### P18. Метка «фон трогали» огрублена по X — ОТЛОЖЕНО (решение пользователя)
**Найдено 2026-08-19 пользователем:** Кид перерисовывается, хотя с пламенем
не пересекается; на пиксель левее — перестаёт.
Разбор по памяти машины. Кид `x = 156`, спрайт занимает **x 213..224**,
экранные y 43..83. Метка колонки 7 — y 33..50 (пламя правого факела).
Колонка считается как `x >> 5`, то есть по 32 пикселя, и спрайт достаёт до
224 — ровно первый пиксель колонки 7. По вертикали пересечение с меткой
настоящее (43..50), поэтому слот считается задетым.
А по горизонтали пересечения НЕТ: пламя лежит в колонке 7 на x 232..247,
между ним и Кидом восемь пикселей зазора. На пиксель левее спрайт
кончается на 223, `223 >> 5 = 6`, колонка 7 не задета — и перерисовка
пропадает.
То есть P15 исправил огрубление по Y и оставил его по X.
**Почему отложено (аргументы пользователя):**
- x лежит в 0..319 и в байт не влезает — нужен `uint16_t` на границу, то
есть 4 байта на колонку (80 байт на две страницы), и **16-битные
сравнения в горячем пути**. А они у SDCC z80 дороги ровно настолько,
что могут съесть весь выигрыш (см. отрицательные результаты выше);
- огрубить x вдвое (`x >> 1`, диапазон 0..159 влезает в байт) — это лишний
сдвиг и при записи, и при проверке, плюс точность падает до 2 пикселей.
**Непроверенная идея на будущее:** хранить границы НЕ в экранных x, а как
смещение ВНУТРИ колонки (0..31, пять бит). Тогда байта хватает и сравнение
8-битное, но запись усложняется: прямоугольник, пересекающий несколько
колонок, даёт частичные диапазоны у крайних и полные у средних.
**Когда браться:** если после других позиций бюджет всё ещё не сойдётся.
Выигрыш будет именно в пограничных положениях, а их в игре много —
персонаж почти всегда стоит рядом с чем-то анимированным.
### P4. Накладные блита — ОТКАЧЕНО
**Правка сделана и отменена по решению пользователя.** Критерий: если
выигрыш получен ценой сильно усложнённого кода — откатывать.
Что было: `atlas_image_w0` в libbgi читал каталог из уже подключённой в W0
страницы. **−408 на кадре** при ожидании −5 400.
Почему откачено: цена — вторая публичная функция в API libbgi с НЕЯВНЫМ
контрактом («страница обязана быть подключена до вызова»), которую легко
вызвать неправильно и молча получить мусор, плюс дублирование чтения
каталога. 408 тактов — 0,09 % кадра, меньше разброса между прогонами.
**Что осталось знанием:** сам `gfx_w0_map` стоит всего **324** такта, а 672
у `atlas_image` — это почти целиком вызов функции и арифметика `idx * 8`.
Значит непробованная часть P4 («один map на группу блитов») имеет потолок
~2 600 за кадр, а не 10 000, как считалось.
Ожидание было −5 400 (672 такта × 8 блитов зелёной фазы), и оно НЕ
оправдалось: цена блита 16 107 → 16 005, то есть −102. Причина в том, что
эти 672 — почти целиком вызов функции и арифметика `idx * 8`, а не само
переключение окна. Замер после правки показывает, что работа просто
переехала между статьями:
| этап | до | после |
|---|---:|---:|
| пролог + отсев | 810 | 762 |
| `gfx_w0_map` | (в составе 2 400) | **324** |
| каталог + шапка ленты + клип | | **2 694** |
| ядро | 8 508 | 8 508 |
| `cd_touch` + `unmap` + эпилог | 2 883 | 2 883 |
| **фиксированная накладная** | **6 765** | **6 663** |
Правка оставлена: не вредит, убирает лишнее переключение W3 и делает
контракт честнее (страница мапится один раз). Но как способ снять
накладные она не работает.
**Что осталось непробованным** (и во что я теперь верю меньше): один
`gfx_w0_map` на ГРУППУ блитов — судя по замеру, сам map стоит 324, так что
потолок этой правки ~2 600 за кадр, а не 10 000, как считалось.
<details><summary>Исходная постановка (модель 28 000)</summary>
| правка | на блит | источник |
|---|---:|---|
| `pop_cd_touch`: `uint8_t` вместо `int` для `y`/`w`/`h`, ранний выход пакетного пути | ~−1 300 | новое |
| один `gfx_w0_map`/`unmap` на ГРУППУ блитов | ~1 350 | C5 / backlog §3 |
| размеры ленты из каталога, без `atlas_image` и чтения шапки | ~670 | C6 / backlog §2 |
| `pop_blit_b`: аргументы в 8 бит, где хватает | ~−400 | новое |
Все четыре — низкий риск, механическая работа. Вместе снимают ~3 700 из
6 126 фиксированных.
</details>
### P5. `pop_loose_tick` при пустой комнате — 28 872 → 2 760 ✅ СДЕЛАНО 2026-08-19
**Получено −26 112 внутри функции, −33 840 на кадре** (замер до/после в
11/15). Оценка была 28 000.
Раскладка холостого хода (замер зондами m9..m12) и что с ней стало:
| участок | было | стало |
|---|---:|---:|
| два цикла по тайлам (30 + 10 позиций) | 9 852 | **132** |
| `pop_loose_mob_tick` (обход 14 слотов) | 12 090 | **996** |
| `check_loose_fall_on_kid` (трамплин + обход) | 5 868 | **546** |
| вход + хвост | 1 062 | 1 086 |
| **итого** | **28 872** | **2 760** |
Сделано двумя гейтами:
- `loose_any` (статик `pop_map.c`) — «идёт ли анимация плит». Ставится в
пяти местах записи ненулевой фазы, снимается САМИМ циклом по факту
прохода, где не осталось ни одной живой фазы;
- `pop_mob_busy` (резидент `pop_state.c`) — «занят ли хоть один слот
падающего куска» (`active` или дочистка `clean`). Ставит `mob_alloc`,
снимает обход по факту пустой таблицы. В резиденте, а не в `pop_room.c`,
потому что читает его `pop_map` из банка 3.
**Важно про границу:** гейт отвечает не на «есть ли в комнате плиты», а на
«идёт ли анимация». У лежащей плиты-потолка фаза 0, и крутить нечего —
вопрос пользователя 2026-08-19. Асимметрия намеренная: ложная единица
стоит одного холостого прохода, ложный ноль — застывшей навсегда плиты,
поэтому взвод стоит рядом с КАЖДОЙ записью, а снятие только по факту.
Покрытие: `phys_loose_floor_breaks` (взвод от шага и сотрясения) и новый
`phys_loose_gate_survives_room_change` — на пятое место взвода
(фаза восстановлена входом в комнату), которое не покрывал никто.
Мутационная проверка: со снятым взводом тест падает.
### P6. `pop_process_trobs` разложен [ЗАМЕР 2026-08-19] — 89 784
| участок | такты |
|---|---:|
| вход + префетч кодов тайлов (маппинг окна 0) | **11 058** |
| цикл: два `pop_torch_draw` | ~36 000 |
| цикл: обход самих trob'ов | ~43 000 |
Цена одного `pop_pot_b` (пламя факела) измерена отдельно, брейкпоинтами на
резидентных адресах: **17 346 тактов**, и это ЕДИНСТВЕННАЯ группа в
распределении — то есть `pop_pot_b` в кадре зовут только два факела. При
канвасе пламени 16×18 сами пиксели там 1 716, то есть **10 % цены**; всё
остальное — накладные (см. §1) плюс ~6 900 сверх `pop_blit_b` на самом
`pop_pot_b`.
Направления:
- **P6a**: `pop_trob_modif(room)` зовётся банковым вызовом на КАЖДЫЙ trob
внутри цикла, хотя комната у них одна и та же — вынести наружу;
- **P6b**: префетч 11 058 маппит окно 0 каждый кадр, а коды тайлов trob'ов
меняются редко — кэшировать с инвалидацией по смене тайла/комнаты;
- **P6c**: цена факела — это цена блита, то есть позиция P4.
Новое, найдено 2026-08-19.
### P7. G5. Раскол `draw_tile` на узкие части [оценка дока: −50 000 … 60 000]
Диспетчер + контекст оплачиваются целиком всегда; у оригинала это девять
независимых функций. В 11/15 это те самые 56 200 на один тайл — но если
сделан P1, `draw_tile` чомпера вообще не вызывается, и здесь эффект пропадёт.
Ценность приёма — в ДРУГИХ сценах (13/23, любая комната с плитами).
**Риск средний**: в `draw_tile` собрано много инвариантов (BUG-LOOSE-3,
BUG-LATTICE-DOORTOP, BUG-SEAM-WEDGE-1) — только отдельным заходом с прогоном
всех уровней.
### P8. HEAL-WIDTH — ширина heal'ов по фактическому следу [оценка: 5-6 % цены heal'ов]
ОБЯЗАТЕЛЬНАЯ по решению пользователя (2026-08-18). След плиты 58 px в
подземелье / 57 во дворце против используемых 60 и 64.
Постановка — `TASKS_OPEN.md`, якорь `heal-width`.
### P9. G8 — пометку СОСЕДА ставить узкой полосой (28 px), а не тайлом [гипотеза]
Парная к HEAL-WIDTH. Относится к сценам с падающими плитами (13/23), в 11/15
не играет. Разбор — `perf_green_phase.md` §G8, там же три условия, из-за
которых это не «просто уменьшить число».
### P10. Футпринт персонажа — из физики, а не считать заново [замер: −11 574 на fore-проход]
`backlog` §1. С двумя персонажами — ~23 000 за кадр. Мешает то, что физика
(банк 3) держит `char_col_left/right` в статиках, а слой фона — банк 2.
**Риск средний**: окно fore-клипа заводилось под клинок и брызги.
### P11. Мелочи с известной ценой [замер, `backlog` §7]
| что | цена | где |
|---|---:|---|
| `pop_clip_char_top` — трамплин банк 4 → банк 3 ради одной проверки | 8 892 | `pop_cdraw.c` |
| `cd_sig_make` + возврат из `pop_char_draw` | 7 944 | `pop_cdraw.c` |
| `pop_loadkid` + расчёт координат кадра | 7 410 | `pop_cdraw.c` |
| `obj_x * 8 / 7` — последнее `__divsint` в горячем пути | ~2 400 | `pop_char_draw` |
### P12. G9 — снять временную оснастку [замер: −6 000]
`pop_dbg_b1..b6` в `pop_blit_b` (~400 на блит), `pop_dbg_kind`/`m16`,
`pop_dbg_m5..m15`, счётчик `rd_cnt` в `pop_redraw_needed`.
**Только ПОСЛЕ окончания оптимизации** — без них не мерить.
### P13. Крупные рефакторинги — брать, только если понадобится ещё запас
**Чем P13 НЕ является (вопрос пользователя 2026-08-19).** Это не «рисовать
комнату заново каждый кадр в скрытый буфер». Такой вариант исключён
арифметикой: 30 тайлов по 5-6 спрайтов при цене блита 16 674 (и 179 914 за
полную запечку одного тайла) дают порядка **3 000 000 тактов — семь
растровых кадров**. Оригинал так тоже не делает: у него та же
инкрементальная схема с пометками (`redraw_frames_full` / `_anim` /
`_fore`), перерисовываются только помеченные тайлы.
Разница не в объёме отрисовки, а в цене ПОСЕЩЕНИЯ тайла: у нас
`fore_tile(r, c)` сразу блитит (со всеми 6 126 фиксированных накладных), а
у оригинала `add_backtable`/`add_midtable`/`add_foretable` только кладут
запись в массив, и рисует один `draw_table()` в конце. Плюс у него ОДИН
обход тайлов за кадр против наших трёх.
- **C7 / backlog §5-6: objtable + отложенные таблицы back/mid/fore.** У
оригинала «посетить тайл» стоит копейки, потому что таблицы только копят
записи, а рисует один `draw_table()` в конце. У нас блит идёт сразу из
обхода, и fore-проход отдельный НА КАЖДОГО персонажа.
- **backlog §4: единый проход по тайлам вместо трёх** (`pop_redraw_needed`,
`pop_process_trobs`, `pop_fore_over_char`) и семь счётчиков причин
перерисовки вместо одного `kind`.
- **G6: меньше блитов в `RD_FLOOR`**, **G7: `mob_tick_one` в file-scope**.
---
## 4. ПЛАН РАБОТ — состояние между сессиями
Рабочий чеклист. Правило: одна позиция = один заход = один коммит с замером
до/после на сцене 11/15. Замер обязателен даже когда «очевидно» — из семи
закрытых позиций ТРИ дали не то, что ожидалось (P1 — вдвое меньше, P2a —
почти ничего, таблицы порогов — регресс).
### Закрыто
| # | что | факт |
|---|---|---|
| P15 | точность метки «фон трогали» + раздельная проверка клинка | **163 746** лёгкая / **140 871** тяжёлая |
| P16 | цианные проверки: снимок без структуры, `guard_over_kid` по условию, `hit_slot` без пяти аргументов | **25 818** |
| P5 | `loose_tick`: гейты холостого хода | **33 840** (ждали 28 000) |
| P1 | чомпер: перерисовка только при фазе < 6 | **110 802** (ждали 160 000) |
| P2b | луч видимости: колонки + один банковый вызов | **26 448** (ждали 30 000) |
| P2a | `coll_scan` в 8 бит + снят с IX | **−2 892** (крупной статьи в физике нет) |
| P3 | Кид перестал будиться каждый кадр | сбылось само после P1 — но только в ЛЁГКОЙ позиции |
| P2/P6 | замеры синей фазы и `process_trobs` | гипотеза «трамплины на спецсобытиях» отвергнута |
| — | замер цианной фазы | крупного лишнего в отрисовке персонажа нет |
### Осталось, по убыванию ожидаемого эффекта
| # | что | ожидание | риск | комментарий |
|---|---|---:|---|---|
| P4 | накладные блита — 6 663 на КАЖДЫЙ блит | частично сделано: **−408** | низкий | из четырёх правок сработала слабо; разбор ниже |
| P14 | fore-проход персонажа | сводится к P4 + P10 | — | разбор ниже: цикл обходит всего 4 тайла |
| P11 | мелочи с известной ценой | −26 000 | низкий | `clip_char_top` 8 658 подтверждён замером |
| P10 | футпринт персонажа из физики | ? (нужен замер) | **высокий** | разбор ниже: величины физики и fore РАЗНЫЕ |
| P6a/P6b | `trob_modif` из цикла, кэш префетча | −20 000 | низкий | тот же паттерн трамплина в цикле |
| P7 | раскол `draw_tile` (G5) | 50 000 в 13/23 | средний | в 11/15 не играет |
| ~~P8~~ | HEAL-WIDTH | ✅ сделано: плита 64→58, чомпер 64→61 | — | эффект ждёт прогона 13/23 |
| P9 | G8 — пометка соседа полосой | не оценено | средний | для сцен с плитами |
| P13 | objtable + отложенные таблицы, единый проход по тайлам | не оценено | очень высокий | большой рефакторинг слоя фона |
| P12 | снять оснастку | −6 000 | нулевой | **последней**: без неё не мерить |
### Текущее состояние бюджета
| | работа | синяя | зелёная | циан | период |
|---|---:|---:|---:|---:|---:|
| до оптимизации | 801 768 | 293 238 | 320 916 | 187 758 | 4 растра |
| после P5 | 767 928 | 285 864 | 294 384 | 187 764 | 4 |
| после P1 (медиана) | 657 882 | 286 503 | 183 420 | 187 761 | 4 |
| после P2a | 654 990 | 283 215 | 183 798 | 187 812 | 4 |
| **после P2b (лёгкая позиция)** | **628 542** | 259 500 | 181 068 | 187 761 | 4 |
| ТЯЖЁЛАЯ позиция (Кид на шаг правее) | 758 358 | 257 520 | 180 870 | 319 842 | **4 и 5** |
| **после P15, лёгкая** | **464 796** | 223 902 | 181 494 | **59 406** | 4 |
| после P15, тяжёлая | 617 487 | 245 808 | 181 761 | 192 090 | **4 везде** |
| после P16, лёгкая | 438 978 | 218 052 | 181 494 | 39 438 | 4 |
| ~~после P4~~ | ~~438 570~~ | | | | правка **ОТКАЧЕНА** |
| после P17, лёгкая | 438 324 | 217 590 | 181 098 | 39 192 | 4 |
| **после P6a, лёгкая** | **437 484** | 217 704 | 180 252 | 39 306 | 4 |
| **после P17, тяжёлая** | **603 684** | 241 956 | 181 464 | 180 270 | 4 |
Итог восьми позиций: **801 768 → 464 796 в лёгкой позиции (−42 %)** и
**758 358 → 617 487 в тяжёлой (19 %)**. Отдельно важно: в тяжёлой позиции
исчезли пятирастровые кадры (было 27 %), период стал ровно 4 — рывки ушли.
### Достижима ли цель — арифметика на 2026-08-19
Цель: работа ≤ 430 000, тогда период станет 3 растра (хвост кадра — три
`gfx_wait_vsync`).
- в ЛЁГКОЙ позиции снять надо **7 484**;
- в ТЯЖЁЛОЙ — **173 684**.
**Лёгких путей больше не осталось.** За 2026-08-19 отвергнуто ЧЕТЫРЕ
правки подряд (три с регрессом, одна почти без эффекта), и все они целили
в накладные проверок и блита. Фиксированная часть блита 6 663 держится
ядром `gfx_w0_map`/`cd_touch`/чтения шапки, а не «лишними» вызовами.
Всё оставшееся в списке, кроме P13, даёт по оценкам **порядка 100 000** — и
это оптимистично. **Арифметика не сходится:** сцена с двумя персонажами,
чомпером и двумя факелами в три растра не укладывается без одного из трёх
решений:
1. **P13** — переход на objtable и отложенные таблицы, как в оригинале
(единственный резерв нужного размера, но это переписывание слоя фона);
2. **осознанное расхождение с оригиналом** — например, не перерисовывать
передний слой персонажа, пока не изменились ни персонаж, ни тайлы под
ним (гейт по сигнатуре футпринта);
3. **принять 4 растра** как рабочий режим для сцен такой плотности и
выравнивать период, чтобы не было рывков 4/5.
Решение за пользователем — это выбор между точностью порта и скоростью.
### Как воспроизвести сцену (важно для следующей сессии)
Сборка стартует прямо в ней: `make` (дефолты `LEVEL=11 ROOM=15 POS=2`) →
`make hdd`**полный рестарт MAME** (`chdman -f` даёт новый inode, memory
`mame_hdd_rebuild_restart`) → в DSS набрать `d:` и `roomtest`. Кид встаёт в
(0,2) лицом к чомперу, справа факел и страж — та самая сцена замеров.
Штатный старт уровня возвращается через `make ROOM=`.
Проверка, что программа ЖИВА, обязательна перед любым чтением памяти:
`cur_room` (0x97AA) должен лежать в 1..24 — на этом уже был сорван один
замер (прочитаны два случайных байта остановленной машины).
### Метод замера
Зонды — `out (_io_border)` в `roomtest.c` (база модуля 0x42AD) плюс
резидентные пустышки `pop_dbg_m*` из `pop_state.c`. Адреса брать ЗАНОВО из
`.sprinter-cc-roomtest/roomtest.map` после каждой пересборки. Скрипты
сессии: `perfrun.py <out> <сек> tag=addr ...` и `parseseq.py <файл> ПОСЛЕД`.
Цену отдельной РЕЗИДЕНТНОЙ функции можно снять вообще без пересборки:
`bpset <вход>,1,{temp0=totalcycles; g}` плюс `bpset <точка>,1,{printf "…
%d",totalcycles-temp0; g}`. Так разложен блит в §1.
---
## 5. Сводка: что сколько даёт в 11/15
| # | приём | эффект | тип оценки | риск |
|---|---|---:|---|---|
| P1 | чомпер: только anim-слой | −160 000 | модель | низкий |
| P3 | Кид перестанет будиться | −70 000 | модель | следствие P1 |
| P2 | логика двух Char | 40 000 … 70 000 | гипотеза | ? |
| P4 | накладные блита (4 правки) | −28 000 | модель | низкий |
| P5 | `loose_tick` без плит | ✅ −33 840 | ФАКТ | сделано |
| P6 | цикл `process_trobs` | 20 000 … 40 000 | гипотеза | ? |
| P10 | футпринт из физики | −23 000 | замер | средний |
| P11 | мелочи (4 штуки) | −26 000 | замер | низкий |
| P12 | снять оснастку | −6 000 | замер | нулевой |
| P7 | раскол `draw_tile` | 0 здесь (50 000 в 13/23) | оценка | средний |
| P8/P9 | HEAL-WIDTH / G8 | 0 здесь (сцены с плитами) | оценка | низкий/средний |
Верхняя часть списка (P1 + P3 + P4 + P5) — **около 286 000 из 801 768, то
есть 36 % работы кадра**, и вся она низкого риска. Этого хватит, чтобы
сцена ушла с 1,86 растрового кадра до ~1,2 — но НЕ хватит, чтобы период
кадра упал с 4 растров до 3: для этого работа должна уложиться в 430 000,
то есть нужны ещё ~90 000 сверху (P2 или P6).
---
## 5б. Цианная фаза разложена [ЗАМЕР 2026-08-19]
Фаза 188 004 тактов, и она НЕ менялась ни от P1, ни от P5, ни от P2b.
| участок | такты |
|---|---:|
| `check_mirror` | 3 198 |
| `loose_mob_draw` + `guard_over_kid` + `skip_mask` | **34 374** |
| **`pop_char_draw(KID)`** | **204** |
| **соперник: `char_draw` + `char_fore`** | **145 896** |
| `fore_needed` + `hp_draw` | 2 598 |
| `char_fore(KID)` + борта | 1 758 |
**Кид уже пропускается** — 204 такта, то есть надежда P3 всё-таки сбылась
после P1: метка от чомпера до него больше не дотягивается. А страж
перерисовывается каждый кадр, и это ЧЕСТНО: пламя правого факела (0,7)
рисуется в ячейке (0,8), где он стоит, и реально накрывает ему голову
(пламя занимает y 5..22, страж 12..62).
Отрисовка стража (148 302) по частям:
| участок | такты | доля |
|---|---:|---:|
| **`pop_char_fore`** (два трамплина в банк 2 + обход тайлов) | **62 778** | 42 % |
| клинок: `pop_sword_draw` + `cd_overlay_add` + `cd_clip_add` | 27 522 | 19 % |
| блит спрайта + `clip_char_right` | 20 982 | 14 % |
| загрузка кадра и геометрия | 12 696 | 9 % |
| **`pop_clip_char_top`** (трамплин банк 4 → банк 3) | 8 658 | 6 % |
| снимок прямоугольника + `cd_clip_add` | 7 890 | 5 % |
| `gfx_w0_unmap` + `cd_sig_make` | 4 968 | 3 % |
| вход + `cd_heal` | 2 946 | 2 % |
**Вывод: крупного лишнего здесь нет.** Единственная явно лишняя статья —
трамплин `clip_char_top` (8 658), и убрать его непросто: функции нужны
`get_tile` и таблицы деления из банка 3, а перенос в резидент вернёт тот же
трамплин внутрь. Всё остальное — работа, которую персонаж действительно
делает: рисует себя, клинок и передний слой поверх себя.
## 6. Иерархия референсов (уточнена 2026-08-19)
Сравнение трёх реализаций луча видимости показало, что источники не
равноценны, и это важно для ЛЮБОЙ будущей оптимизации:
| источник | что берём | чего НЕ берём |
|---|---|---|
| **Apple II** (`Prince-of-Persia-Apple-II`) | как это делается на 8 битах: таблицы вместо делений, борьба за такты | ничего — но код на 6502, читать сложнее |
| **SDLPoP** | эталон ПОВЕДЕНИЯ (декомпиляция DOS-версии) | реализацию: она нарочно «расслаблена» под 32 бита |
| **mininim** | разбор краевых случаев, второе мнение о замысле | алгоритмы — переписан с нуля, механика местами своя |
Доказательство на конкретном месте: `get_tile_div_mod` в SDLPoP содержит
комментарий
```c
// DOS PoP does this:
// obj_xl = tile_mod_tbl[xpos];
// return tile_div_tbl[xpos];
```
а вместо этого делает `x % TILE_SIZEX` и `x / TILE_SIZEX`. Таблицы в файле
лежат, но нужны только для эмуляции чтения DOS-версии ЗА ГРАНИЦЕЙ массива.
Apple II (`CTRLSUBS.S`, `GETBLOCKX`) читает ровно `BlockTable[x]`.
**Правило:** сверять поведение по SDLPoP, а реализацию под 8 бит — по
Apple II и по комментариям вида «DOS PoP does this» в самом SDLPoP.
## 7. Повторяющийся источник цены: банковый трамплин в цикле
Уже трижды крупнейшей статьёй оказывался не алгоритм, а вызов `__banked`-
функции ИЗ ЦИКЛА, идущего в другом банке:
| место | цена | лечение |
|---|---:|---|
| луч видимости: `pop_tile_at` по колонке (P2b) | 36 786 → 13 002 | один вызов на весь отрезок |
| `pop_clip_char_top` — банк 4 → банк 3 ради одной проверки | 8 892 | не сделано (P11) |
| `pop_trob_modif(room)` на каждый trob в цикле | не мерено | не сделано (P6a) |
**Что проверять в первую очередь при новом «дорогом» месте:** не сколько
там арифметики, а сколько раз за кадр пересекается граница банка.
## Фиксированный логический кадр (2026-08-19) — МЕНЯЕТ ВСЕ ЦЕЛЕВЫЕ ЧИСЛА
Период логического кадра больше не `ceil(W) + 2`, а `max(n, ceil(W))`
(`roomtest/pop_pace.c`, разбор — `frame_pacing_plan.md`). Поэтому:
- **Бюджет кадра вырос с 430 080 до 1 290 240 тактов** (n = 3, режим
FASTEST по умолчанию). Все записи этого реестра, где «работа сверх
430 000 стоит сразу целого растра», СЧИТАТЬ УСТАРЕВШИМИ.
- 13/23 (максимум работы 911 862) теперь укладывается в период 3 растра —
проверено, ни одного кадра длиннее. Прежний профиль был 3/4/5.
- Оптимизация из спешной стала плановой: смысл резать такты остался
(режим NORMAL при n=4 и бой при n=5 дают ещё больше запаса, а FASTEST —
верхнюю планку скорости), но «свалиться за растр» больше не обрыв.
- Цена самого пейсинга — ≈4 000 тактов на кадр (0,9 %), замерено A/B.
Приоритет P9 (G8) и остальных позиций от этого не меняется, но их
СРОЧНОСТЬ падает: они больше не спасают от скачка периода.
+18 -14
View File
@@ -1,6 +1,8 @@
# QuickSave / QuickLoad — разбор оригинала и план реализации
Статус: **план, код не начат** (2026-08-17). Задача на доске —
Статус: **РЕАЛИЗОВАНО и проверено в MAME** (2026-08-22; F6/F9, POP.SAV +
POP.BAK — см. коммит `v0.6-pop-quicksave`). Документ оставлен как
справочник по формату снимка и разбору. Задача на доске —
[`../roomtest/TASKS_OPEN.md#qsave`](../roomtest/TASKS_OPEN.md#qsave).
---
@@ -180,12 +182,14 @@ SDLPoP, сохраняются.
---
## 4. Куда писать снимок: файл, а не EMM-страница
## 4. Куда писать снимок: HDD-файл, а не EMM-страница
Рекомендация: **основной путь — файл `QUICKSAVE.SAV`; EMM-страница —
необязательный второй слот.**
Решение: **один основной слот `POP.SAV` на HDD; предыдущая
валидная запись хранится в `POP.BAK`.** EMM-слота нет: программа
работает только с HDD, а главный сценарий QuickSave обязан переживать
перезапуск игры.
> Пересмотрено 2026-08-17 по вопросу пользователя «почему EMM, а не файл».
> Пересмотрено 2026-08-21 по вопросу пользователя «почему EMM, а не файл».
> Первая редакция плана рекомендовала EMM — это была ошибка: она взвешивала
> скорость и недооценивала главный сценарий использования. Разбор оставлен
> целиком, потому что довод переносится и на другие «положить в память
@@ -214,10 +218,8 @@ MAME, обязательный после каждой пересборки об
`_fd_guard` в libc и так стоит), а «не нужен путь и права» — экономия одной
строки.
Что остаётся за EMM: мгновенный слот для «переиграть» без обращения к диску.
Делается тем же сериализатором и добавляется, если понадобится. Поэтому
обход состояния писать сразу так, чтобы «куда» было параметром — как у
SDLPoP через `process_func`.
Обход состояния всё равно писать с абстракцией чтения/записи, как у SDLPoP
через `process_func`, но второй EMM-слот в scope не входит.
**Проверить ДО кодинга:** пишется ли `test_hdd.chd` из-под MAME. Если образ
только на чтение, файловый путь упрётся в это на первом же шаге и порядок
@@ -234,6 +236,7 @@ roomtest.
+5 pop_current_level 1 Б
+6 длина полезной части 2 Б (контроль, что обход совпал)
+8 ... поля встык, ОДИН порядок на запись и на чтение ...
.. checksum 2 Б (заголовок + payload)
```
Версия проверяется первой; несовпадение — отказ, как в SDLPoP. Никаких
@@ -282,13 +285,12 @@ static void qs_walk(qs_io_t io) /* io = запись или чтение */
| шаг | что | критерий готовности |
|---|---|---|
| **QS1** | Аксессоры/сериализаторы для `static`-состояния банковых модулей: `pop_trob.c` (`room_modif`, `room_seen`, `trobs`, `trob_seed`), `pop_room.c` (`mobs_live`), страница уровня (чтение `fg`) | хост-тест `tests-host/t_qsave.c`: обход туда-обратно на синтетическом состоянии даёт байт-в-байт исходное |
| **QS0** | Проверить, что `D:` пишется из-под MAME (пробный файл из roomtest) | файл создался и читается обратно после рестарта программы |
| **QS2** | Ядро: `qs_walk` + запись/чтение файла `QUICKSAVE.SAV`, магия и версия, отказ при несовпадении | сохранение и загрузка **в той же комнате, без движения** — картинка и состояние не изменились |
| **QS1** | Аксессоры/сериализаторы для `static`-состояния банковых модулей: `pop_trob.c` (`room_modif`, `room_seen`, `trobs`, `trob_seed`), `pop_room.c` (`mobs_live`), страница уровня (чтение `fg`) | хост-тест `tests-host/t_qsave.c`: обход туда-обратно на синтетическом состоянии даёт байт-в-байт исходное |
| **QS2** | Ядро: `qs_walk` + `POP.SAV`, магия/версия/checksum, безопасная замена с предыдущей валидной копией в `POP.BAK` | сохранение и загрузка **в той же комнате, без движения**; порча SAV не портит BAK |
| **QS3** | Восстановление отрисовки (§6), включая обе страницы дабл-буфера | загрузка после перехода в другую комнату; нет мерцания через кадр |
| **QS4** | Клавиши **F6/F9** (или свободные из `pop_cheat.h`) через `<kbd_raw.h>`, флаги `need_quick_save/load`, обработка **между кадрами** | загрузка посреди боя/падения не ломает `play_seq` |
| **QS5** | Загрузка с **другого уровня** (перезагрузка уровня и атласов) | сохранить на ур. 2, уйти на ур. 12, загрузить — тайлсет и стражи верные |
| **QS6** | Опционально: второй слот в EMM-странице тем же сериализатором | мгновенное «переиграть» без обращения к диску |
Порядок не переставлять: QS0 первым (он может изменить весь план), QS3 без
QS2 нечего проверять, а QS5 обязан идти после QS3 — иначе смена тайлсета
@@ -320,5 +322,7 @@ QS2 нечего проверять, а QS5 обязан идти после QS3
5. **Дабл-буфер.** Самый вероятный источник «почти работает»: забыть вторую
страницу. Симптом — мерцание через кадр
(см. `roomtest/CLAUDE.md`, раздел про дабл-буфер).
6. **Открытый вопрос:** нужен ли снимок в файле вообще, или EMM-страницы
достаточно. Решать после QS3, по факту использования.
6. **Транзакция SAV/BAK.** До кодинга проверить на DSS семантику
rename/replace. Если атомарная замена не гарантирована, писать через
`POP.NEW`, проверять его после close и не удалять единственную валидную копию
до завершения новой.
+111
View File
@@ -0,0 +1,111 @@
# Бюджет резидента W1/W2: как мерить и как освобождать
Резидент huge-режима — окно `0x4100..0xBB00` (стек с 0xBB00): код в W1,
данные в W2, между концом данных и стеком остаётся куча. Всё, что туда не
влезло, живёт в банках.
## Как СМОТРЕТЬ, а не гадать
**Карта линкера врёт.** File-static SDCC в неё не попадает, и «дырка» между
двумя именованными символами приписывается предыдущему целиком. По карте
выходило, что у `pop_bg` 1529 Б данных (на деле 289), а у `pop_t_win_clear`
1282 Б кода — при том, что это однострочник, а 1282 Б это два статических
помощника соседнего `pop_blit_b`.
Точный источник — объектные файлы: строки `A <area> size <n> flags <f>` в
`.rel` дают ровный размер каждой области модуля, а `S <sym> Def/Ref` — кто
символ определяет и кто на него ссылается.
**Частоту вызовов мерить в MAME счётчиком**, а не оценивать по смыслу:
```
bpset <frame_probe>,1,{printf "F %d ...",temp0,...; temp0=0;...; g}
bpset <func_addr>,1,{temp0=temp0+1; g}
```
Обязательна **канарейка** — счётчик заведомо горячей функции в том же
прогоне. Дважды спасала: один раз показала, что перехода комнаты в окне
замера не было (все нули), другой — что зонды вообще не встали (в zsh
`set -- $pair` НЕ разбивает строку на слова, и адрес уезжал в мусор).
Полную перерисовку комнаты форсировать читом `+`/`-`, ходьбой ненадёжно.
## Сделано
### 1. malloc вон из резидента (−613 Б)
`cbl_open` держал `malloc`/`free` в мёртвой ветке `CBL_UNDERRUN_SILENCE`, а
линкер тянет `.rel` целиком — и куча приезжала каждому приложению. Разведены
две публичные точки входа (`cbl_open` / `cbl_open_silence`) поверх общего
`_cbl_open_raw`; `cbl_close` больше не зовёт `free`.
### 2. Разрез pop_tile: холодная половина в банк 5 (−1788 Б)
`pop_tile.c` был крупнейшим жильцом резидента (5 972 Б кода). Целиком он не
уедет: его const-таблицы (`POP_TILE_DIV/MOD`, `pop_tile_table`, таблицы
кадров) читают банки 2, 3, 7 и 8, а таблица в чужом банке не видна.
Отбирали ЗАМЕРОМ, на двух тайлсетах (подземелье ур. 1 и дворец ур. 4 —
`pop_mem_b` рисует композитный кусок и мог оказаться дворцовым). Порог —
пик не больше 3 вызовов на кадр.
| уехало в банк 5 | пик/кадр | | осталось в резиденте | пик/кадр |
|---|---:|---|---|---:|
| `pop_mem_b` | 0 | | `pop_tile_code` | 296 |
| `pop_cd_hit` (+`hit_rect`) | 0 | | `pop_cd_touch` | 198 |
| `pop_t_win_set/clear` | 0..1 | | `pop_blit_b` (+2 статика) | 184 |
| `pop_heal_off` | 0..1 | | `pop_wall_modifier` | 101 |
| `pop_potion_flask` | 0..1 | | `pop_env_b` | 73 |
| `pop_room_set_above/below` | 1 | | `pop_tile_mod` | 70 |
| `pop_cd_init/clear` | 1 | | `pop_cd_batch_end` | 40 |
| `pop_bar_black` | 3 | | `pop_fore_set_clip` | 2 |
| `pop_cd_hit_slot` | 2..3 | | все const-таблицы | — |
`pop_fore_set_clip` (88 Б) оставлен намеренно: не стоит отказа от прямого
вызова из банка 4, ради которого он и заводился.
**Цена трамплина замерена**: 252 такта пролог + 84 эпилог + ~50 на стороне
вызывающего = **~410 тактов** на вызов. Итого ~1 000 тактов на кадр покоя
(0,2 % работы) и ~3 700 на кадр редрава (0,009 растра).
**Ключ, почему это безопасно:** вызов банк → резидент ПРЯМОЙ, трамплин не
нужен (W1/W2 замаплены всегда). Поэтому `blit_b_clip` просто перестал быть
`static` и объявлен в `_pop_tile.h`, а не переехал следом за `pop_mem_b`.
### Итог
| | было | стало |
|---|---:|---:|
| `_CODE` резидента | 24 329 | **21 928** |
| свободно до стека | **129 Б** | **2 535 Б** |
| BANK5 | 2 080 (13 %) | 3 954 (24 %) |
Проверено в MAME: уровень 1 (подземелье) и уровень 4 (дворец), переходы
комнат читом `+`, ходьба — фон, факелы, решётки, гобелены, колонны без
искажений.
## ЛОВУШКА: данные банка в его страницу — НЕ ДЕЛАТЬ без разбора
Отдельная попытка (`--bank-data=SRC`, коммиты 3545826/9025573) **откачена**:
перенос писучих данных банкового модуля в его 16-КБ страницу давал цветной
мусор блоками и ронял DSS.
У `pop_trob` причина найдена: `pop_trob_modif()` ВОЗВРАЩАЕТ УКАЗАТЕЛЬ на
`room_modif[24][30]`, а зовут её из банков 2, 3, 7 и резидента — после
переноса они пишут по 0xC000+ в СВОЮ страницу, поверх чужого кода.
Def/Ref-анализ такого не видит: снаружи ссылки на символ нет, есть ссылка на
функцию, отдающую его адрес. Но и `pop_room`, у которого утечки указателя
найти не удалось, ломался так же — механизм понят не до конца.
Нулевая инициализация при этом ни при чём: `mkexe -p 0` был проверен по
образу (прогон нулей 14 304 Б, самый длинный прогон 0xFF — 14).
**Перенос КОДА в банк — штатный путь, на нём стоят все наши банки. Ломался
именно перенос ДАННЫХ.**
## Что осталось
- `roomtest.c` 2 508 Б и `pop_kid.c` 2 418 Б — следующие по величине, но оба
горячие (главный цикл и `play_seq`).
- `pop_level.c` 956 Б кода + 660 Б данных (из них `pop_dl1`/`pop_dl2` по
256 Б — таблицы дверных связей).
- BANK7 на 77 %: если понадобится место в нём — выносить `pop_redraw.c`.
+266
View File
@@ -0,0 +1,266 @@
# Атлас Тени — разбор и план
Дата: 2026-08-20. Статус: **ЗАКРЫТО.** Ш0-Ш4 сделаны, прогон
пользователем на уровнях **4, 5, 6 и 12** — всё корректно.
Задача: сейчас Тень рисуется спрайтами Кида (вне боя) и спрайтами стража
(в бою) как есть, поэтому от Кида она не отличается. В оригинале её вид
даёт наложение спрайта на себя со сдвигом; наш пакетный блит такого не
умеет, поэтому запекаем результат в отдельный атлас заранее.
## 1. Что делает оригинал (проверено по SDLPoP, не по памяти)
`draw_objtable_item`, `seg008.c:1600`:
```c
case 1: // shadow
add_midtable(obj_chtab, obj_id + 1, obj_xh, obj_xl, obj_y, blitters_2_or, 1);
add_midtable(obj_chtab, obj_id + 1, obj_xh, obj_xl + 1, obj_y, blitters_3_xor, 1);
```
Тот же самый спрайт кладётся ДВАЖДЫ: первый проход в x, второй в x+1.
Два уточнения, которые меняют алгоритм запекания:
1. **`blitters_2_or` — это НЕ побитовое ИЛИ.** В SDLPoP он реализован
обычным блитом с colour key = индекс 0 (`method_6_blit_img_to_scr`,
`seg009.c:3306`: `SDL_SetColorKey(image, SDL_TRUE, 0)`). То есть
первый проход — наш обычный прозрачный блит, один в один.
2. **`blitters_3_xor` работает по 24-битному RGB, а не по индексам
палитры** (`blit_xor`, `seg009.c:3190`: конвертация в 24 бита, затем
`*p_dest ^= *p_src` побайтно). Прозрачности у него нет вообще —
XOR'ится весь прямоугольник, но прозрачные пиксели спрайта это
чёрный 0x000000, а XOR с нулём ничего не меняет.
Отсюда и берётся необходимость СВОЕЙ палитры: XOR двух цветов игровой
палитры даёт цвет, которого в ней нет.
### Каким набором спрайтов рисуется Тень (проверено 2026-08-20)
Сначала я решил, что в боевых кадрах Тень рисуется спрайтами СТРАЖА, и
записал это в план. **Это было неверно, поправка ниже.**
Набор выбирает НЕ charid. `load_frame_to_obj` (`seg008.c:1752`):
```c
word chtab_base = id_chtab_2_kid; // жёстко Кид
obj_chtab = chtab_base + (cur_frame.sword >> 6); // старшие 2 бита кадра
```
то есть набор берётся из САМИХ ДАННЫХ КАДРА, поле `sword`: младшие 6 бит —
картинка меча, старшие два — смещение chtab относительно Кида
(`types.h:361`). Проверил обе таблицы:
- `frame_table_kid`**все 241 кадра** имеют `sword & 0xC0 == 0` → chtab_2.
Значит **Кид всегда рисуется своими спрайтами**, боевые кадры 150..189 не
исключение;
- `frame_tbl_guard` — все кадры имеют `0xC0` → chtab_5.
Тень берёт `frame_tbl_guard` для кадров 150..189 (`seg006.c:533`), значит в
бою она идёт через chtab_5. **Но chtab_5 — это не «страж», это «соперник
уровня»**: он грузится из `tbl_guard_dat[tbl_guard_type[уровень]]`
(`seg000.c:1117`), а `tbl_guard_type[12] == 4`**SHADOW.DAT**.
Открыл SHADOW.DAT: его палитра **побайтно равна палитре Кида** (у GUARD.DAT
там серая рампа под перекраску), а спрайты — Кид в боевых позах, не страж.
Отрендерил тройками «Кид / SHADOW.DAT / GUARD.DAT» для кадров
151/153/158/161/167: первые две колонки — один и тот же персонаж, третья —
серый страж в тюрбане.
**Вывод: Тень ВСЕГДА выглядит Кидом, и ощущение пользователя верно.**
Спрайты при этом лежат в двух файлах: не-боевые кадры в chtab_2 (KID),
боевые — в chtab_5, заполненном SHADOW.DAT.
### Можно ли взять для боя собственные кадры Кида
Механически да: `frame_table_kid` покрывает 150..189 со своей геометрией,
и хватило бы снять спецветку для `charid_1_shadow` в `load_frame`. Но
кадры НЕ совпадают: из 34 боевых у 31 отличается габарит (на 1-6 px), у 3
отличаются пиксели. Это разная графика, а не одна и та же в двух файлах.
Поэтому берём SHADOW.DAT — он и есть «кадры Кида для Тени», подготовленные
авторами.
## 2. Единственное место, где запечка отличается от оригинала
XOR идёт по тому, что УЖЕ на экране, то есть результат зависит от фона.
Разбор по пикселям показывает, что зависимость узкая:
| пиксель | первый проход | второй проход | зависит от фона? |
|---|---|---|---|
| спрайт непрозрачен в x | закрашен цветом спрайта | XOR с цветом из x−1 | **нет** |
| прозрачен в x, непрозрачен в x−1 | фон | фон XOR цвет | **да** |
| прозрачен в обоих | фон | фон | нет (не рисуем) |
То есть от фона зависит только **кайма в один пиксель по левым кромкам
силуэта**. На чёрном фоне (а Тень почти всегда на нём — уровень 4 у
зеркала, уровень 6, бой на 12-м) `фон XOR цвет == цвет`, и запечка точна.
На светлом фоне оригинал подкрасит эту кайму, мы — нет.
**Это осознанное расхождение, в `impl_diff.md` при реализации.**
## 3. Замеры на реальных ассетах
Прогон алгоритма по правильным наборам (`SDLPoP/data/KID` +
`SDLPoP/data/SHADOW`):
| набор | кадров | макс. габарит | разных цветов |
|---|---:|---|---:|
| Кид (chtab_2) | 219 | 56×57 | 44 |
| SHADOW.DAT (chtab_5 на ур. 12) | 32 | 49×38 | 22 |
| **вместе** | **251** | 57×57 (с учётом +1 px сдвига) | **45** |
Цветов вместе почти столько же, сколько у одного Кида: SHADOW.DAT сидит на
той же палитре, новых сочетаний XOR почти не даёт. Всего пикселей во всех
кадрах Тени — 91 045.
### Насколько заметна подмена
«Затронуто» само по себе ничего не говорит — важно, НА СКОЛЬКО сместился
цвет. Порог различимости на плоской заливке ~30-40 единиц евклида в RGB
(максимум возможного — 441). Пробовал три стратегии подбора:
- **A** — оставить N самых частых цветов ТОЧНО, остальные в ближайший;
- **B** — взвешенный k-means по всем цветам (двигает вообще все);
- **C** — гибрид: часть слотов под точные частые, остаток — кластеры хвоста.
| палитра | стратегия | изменено px | заметно (40..90) | сильно (>90) | худшая |
|---|---|---:|---:|---:|---:|
| 16 | A самые частые | 959 (1,05 %) | 569 | 381 | 128 |
| **16** | **C гибрид 5+11** | 6 984 (7,67 %) | 1 078 | **76** | 114 |
| 32 | A самые частые | 50 (0,05 %) | 40 | 4 | 113 |
| 32 | C гибрид 28+4 | 73 (0,08 %) | 58 | **0** | 90 |
Читается так. При 32 цветах всё практически идеально: 73 пикселя на 251
кадр, грубых промахов нет вовсе. При 16 цветах выбор стратегии виден:
«самые частые» трогает меньше пикселей (959), но 381 из них уезжает СИЛЬНО;
гибрид размазывает ошибку — грубых остаётся 76, то есть примерно **0,3
пикселя на кадр**.
### Решение: **16 цветов, стратегия C (гибрид 5 + 11)**
Числа выше — про пиксели, а решает глаз. Отрендерил одни и те же кадры в
трёх видах (точный цвет / 32 / 16) и сравнил в увеличении ×3: **отличий
не видно**. Объяснение в самих числах: перцептивно значимых пикселей при
16 цветах гибридом — 1 154 на 251 кадр, это ~4,6 пикселя на кадр при
~1 000 видимых, и они РАССЫПАНЫ по контуру, а не собраны в пятно.
Поэтому берём 16, а не 32: экономим блок палитры (пригодится под будущие
наборы — принцесса, визирь, мышь), а разница неразличима.
**Но стратегия обязана быть гибридной.** При 16 цветах «взять самые
частые» впятеро хуже по грубым промахам (381 пиксель против 76), и это
единственное место, где выбор стратегии виден. Гибрид: 5 самых частых
берём ТОЧНО, оставшиеся 11 слотов отдаём под взвешенные кластеры хвоста.
Если в реальной игре кайма всё же будет резать глаз — переход на 32 цвета
это одна константа в упаковщике и один блок палитры, данные не меняются.
## 4. Палитра: что занято и куда класть
| блок | кто | примечание |
|---|---|---|
| 0x30..0x3F | VGA-16 | общая; из неё цвет вспышки, пузырьки зелий, отладочная метка |
| 0x40..0x4F | chtab_1 | зелья, пламя |
| 0x50..0x5F | env тайлсета | **меняется** подземелье/дворец |
| 0x60..0x6F | wall тайлсета | **меняется** подземелье/дворец |
| 0x70..0x7F | chtab_2 | Кид |
| 0x80..0x8F | chtab_0 | меч в руке |
| 0x90..0x9F | chtab_5 | страж, перезаливается цветом стража |
Занято 112 слотов из 256, **свободно 144** — девять выровненных блоков по
16. Оговорки: 0xFF в наших атласах это маркер прозрачности, а запись 0
правит `flash_bg`, так что блоки 0x00 и 0xF0 лучше не трогать.
**Берём 0xA0..0xAF** (16 слотов) — сразу за стражем, персонажи остаются
сгруппированы, а блок 0xB0 остаётся свободным (под 32 цвета Тени, если
понадобится, или под будущие наборы).
Важно: в наших атласах прозрачность кодируется байтом 0xFF, а исходный
индекс 0 в них означает «прозрачно». У Тени **чёрный — настоящий цвет**
(это XOR-погашенная середина силуэта, 51 % всех её пикселей), поэтому у
неё маппинг свой: «нет пикселя» → 0xFF, цвет k → 0xA0 + k, и слот 0xA0 =
чёрный НЕПРОЗРАЧНЫЙ.
## 5. Объём
251 кадр против 219 у Кида — по страницам EMM примерно как нынешний
набор Кида (28 страниц), плюс пара на кадры из SHADOW.DAT. При 215 свободных
страницах на старте (memory `sprinter_emm_budget`) это не проблема.
Кадры смерти («убитый Кид») пока НЕ вырезаем — экономия несколько
страниц, а риск промахнуться мимо нужного кадра реальный: Тень на 12-м
уровне умирает.
## 6. План работ
**Ш0. Довезти SHADOW.DAT.** Его у нас нет вовсе (см. BUG-SHADOW-SET) —
добавить каталог ассетов и правило в Makefile рядом с GUARD/SKEL/VIZIER.
**Ш1. Упаковщик** `toolchain/pop_pack_shadow.py`: прогнать оба набора
через алгоритм §1, собрать 16-цветную палитру гибридом (10 точных + 6
кластеров хвоста), выдать
`poc/res/shadow/shadow0..N.atl` + `shadow.pal` + `pop_shadow_atlas.h`.
Критерий: предпросмотр PNG совпадает с видом Тени в SDLPoP.
**Ш2. Загрузка**: `pop_shadow_load()` рядом с `pop_kid_load`, палитра в
0xA0..0xAF, и обе страницы дабл-буфера (как `bg_load_tile_pal`).
Грузить ЛЕНИВО — только когда на уровне есть Тень (4, 5, 6, 12), иначе
28 страниц EMM висят зря.
**Ш3. Отрисовка**: одно место — `pop_cdraw.c:551..568`, где выбирается
`pages`. Сейчас там для соперника берётся `gp`, а для не-боевых кадров
Тени подменяется на `kidp`; станет «charid == CHARID_1_SHADOW → shadowp»
БЕЗ подмены: обе половины (кадры Кида и кадры SHADOW.DAT) лежат в ОДНОМ
атласе Тени, так что ветка становится проще нынешней.
**Ш4. Проверка в MAME**: уровень 4 (Тень у зеркала), уровень 6 (Тень
крадёт зелье), уровень 12 (бой с Тенью — там она в боевых кадрах, то
есть проверяется вторая половина набора).
## 7. Что проверить артефактом до Ш3
1. Индекс кадра для Тени в боевых кадрах: `frame_tbl_guard` адресуется
как `frame + add_frame - 149` (`seg006.c:535`), то есть у нашего
атласа Тени нумерация двух половин должна совпадать с тем, что уже
делает `pop_frame_tbl_is_guard`.
2. Перекраска Тени НЕ нужна: SHADOW.DAT приходит уже в палитре Кида, а
`curr_guard_color` у не-стражей равен 0 (`seg002:183`). Ветку
`pop_guard_set_palette` для типа 4 звать нельзя — она затрёт палитру
Тени палитрой стража.
---
# 8. Как сделали (2026-08-20)
- `toolchain/pop_pack_shadow.py` — запекает обе половины, собирает
16-цветную палитру гибридом (получилось 5 частых точно + 11 кластеров
хвоста), пишет `poc/res/shadow/sk0..27.atl`, `sf0..3.atl` и
`roomtest/pop_shadow_atlas.h`. Итог: **251 спрайт, 32 EMM-страницы,
225 178 Б**.
- `roomtest/pop_shadow.c/.h` (банк 8) — загрузка. **Грузим один раз при
старте**, а не по уровням: страниц EMM с запасом, а забыть перезагрузку
на границе легко — ровно так и появился BUG-SHADOW-SET.
- `pop_cdraw.c` — одна ветка выбора атласа; заодно брызги урона теперь
берут атлас отдельным указателем (`spl`), потому что у оригинала они
всегда из «своего» chtab, а у Тени кадр может прийти из другой
половины набора.
## Грабля, стоившая одного прогона
Набор загрузился, силуэт нарисовался правильной формы — и **целиком
чёрный**. Причина: `gfx_pal_fload("KID\\kid.pal")` заливает ВСЕ 256
записей палитры и затирает любые слоты, выставленные до него. Ровно та
же беда уже была с тайлсетом — сразу за этим вызовом стоит
`pop_bg_pal_apply`. Поэтому палитра Тени вынесена в отдельный
`pop_shadow_pal_apply()` и зовётся там же, а не внутри загрузки атласов.
**Правило на будущее: любой новый набор палитровых слотов красится ПОСЛЕ
kid.pal, рядом с pop_bg_pal_apply.**
## Проверено
Уровни 4 (рождение из зеркала), 5 (кража зелья), 6 (прыжок через
пропасть) и 12 (бой — там работает вторая половина набора, `sf*`) —
прогон пользователя 2026-08-20, расхождений не найдено.
Расхождение из §2 (кайма в один пиксель по левым кромкам на НЕчёрном
фоне) записано в `impl_diff.md`.
+963
View File
@@ -0,0 +1,963 @@
# Звук в порте PoP — разбор и план
Дата: 2026-08-20, музыка дописана 2026-08-25. Статус: **PCM-эффекты
реализованы; музыка — путь C (PCM через CBL), первый трек играет.**
Задача пользователя: добавить звук. Приоритет — эффекты; музыку, если
найдётся способ. Эффекты — **обязательно WAV, а не PC-спикер**
(уточнение 2026-08-20; см. §1а — оказалось, что они и так все в WAV). Ниже — что реально лежит в ассетах, что умеет железо, и
почему получившийся план вышел проще, чем ожидалось.
## 1. Главный вывод
**Ни MIDI разбирать, ни ноты сочинять не придётся, и ресэмплировать тоже.**
- Эффекты уже лежат **8-битным беззнаковым PCM на 11 000 Гц**, а у CBL есть
режим **10 937,5 Гц** — расхождение 0,6 %, на слух неразличимо. Формат
сэмпла совпадает с нашим CBL байт в байт (`cbl.h`: 8 бит, беззнаковый,
центр 0x80). То есть данные играются **как есть**, без конверсии.
- Музыка есть в виде **списков нот PC-спикера — 7 КБ на всю игру**, а нота
там задана прямо в ГЕРЦАХ. Пересчёт в делитель AY — одно деление.
- AY и COVOX на Sp2000 сведены в **один ЦАП TDA1543** (док Ивана Мака,
§5), значит музыка на AY и эффекты через CBL звучат ОДНОВРЕМЕННО, и
смешивать их программно не надо.
## 1а. Уточнение после разбора ВСЕХ наборов MS-DOS версии (2026-08-20)
Пользователь попросил, чтобы эффекты были не PC-спикером, а WAV, и заодно
посмотреть `mt32snd[1-2].dat`. Разобрал все восемь `.dat` из `MSDOS/`.
Ответ короткий: **эффекты И ТАК все до одного есть в WAV, а вот у музыки
WAV нет ни в одном наборе.**
| набор | формат | какие звуки | сколько |
|---|---|---|---|
| `digisnd1..3` | **WAV**, 8 бит PCM | эффекты 0..23, 44..49, 51 | **31** |
| `mt32snd1..2` | MIDI для Roland MT-32 | ТЕ ЖЕ эффекты 0..23, 44..51 | 31 |
| `midisnd1..2` | MIDI (AdLib/GM) | **музыка** 24..43, 50, 52..56 | 22 |
| `ibm_snd1..2` | ноты PC-спикера | **всё подряд, 0..56** | 57 |
Здесь пряталась ловушка: `mt32snd` по имени похож на «музыку получше», а
на деле это набор ЭФФЕКТОВ для владельцев MT-32 — те же id, что у
`digisnd`. Музыки в нём нет вовсе.
Частоты WAV: 28 звуков на 11 000 Гц, по одному на 8 200, 14 000 и 2 750.
Итого 112 922 сэмпла = **11,4 с, ~110 КБ ≈ 6,7 EMM-страниц**.
Только PC-спикером, без альтернатив, остаются четыре id: 31, 34, 42
(пустые) и **38 `blink`** — четыре ноты. То есть на весь звук игры
спикер нужен ровно для одного писка.
### Музыка: WAV нет, есть три пути
| путь | данные | что получится | цена |
|---|---|---|---|
| **A. Ноты PC-спикера на AY** | 7 КБ | один квадратный голос — ровно то, что слышали на IBM PC 1989 | секвенсор на полсотни строк |
| **B. MIDI -> AY, три голоса** | 27 КБ исходника | богаче: бас + мелодия + арпеджио | разбор MIDI + раскладка по каналам |
| **C. MIDI -> WAV на хосте, стрим через CBL** | см. ниже | настоящее звучание, любое | нужен синтезатор на хосте + место |
Про объём для пути C (замерено по длительностям треков):
| группа | треков | длительность | WAV 11 кГц |
|---|---:|---:|---|
| звучат ПО ХОДУ игры (гимн уровня, смерть, зелья, перо, победа) | 12 | 74,7 с | **803 КБ = 50 EMM-страниц** |
| заставки и титры | 10 | 248,1 с | 2 665 КБ = 167 страниц |
Игровая половина в EMM **влезает** (при ~215 свободных страницах), а
заставочная — нет, её пришлось бы стримить с диска. Но заставки идут
тогда, когда игра ничего не рисует, так что стрим там как раз уместен.
**Предложение:** начинать с A (7 КБ, работает сразу, ноль рисков), а C
держать как отдельную фазу — она ортогональна: проигрыватель WAV для
музыки это тот же `cbl_push`, что и для эффектов, только длиннее буфер.
B имеет смысл только если C окажется неподъёмным по месту.
## 1б. РЕШЕНИЯ (пользователь, 2026-08-20)
1. **Эффекты — WAV через CBL, 8 бит, МОНО, единая частота.** Проверено по
`convert_digi_sound` (`seg009.c:2358`): один байт на кадр, то есть
моно, и байт беззнаковый (`(b | b<<8) - 32768`), центр 0x80 — ровно
формат нашего CBL. Стерео в данных нет вовсе: каналы у оригинала
размножаются уже на выходе (`digi_audiospec->channels`).
2. **Музыка, первый заход — путь A** (ноты спикера на AY).
3. **Заставки и титры — потом WAV.** Конфликта с эффектами там нет:
одновременно они не звучат.
4. **Музыка ПО ХОДУ игры** (она может совпасть с эффектом) — открыто, два
варианта: либо тоже WAV с ГАШЕНИЕМ эффектов на время музыки (музыка
важнее — **проверить на слух**), либо путь B (MIDI -> три голоса AY).
5. **Все эффекты привести к одной частоте.**
6. Синтезатор для MIDI -> WAV — решать ближе к делу; годятся и онлайн-
конвертеры, хоть вручную, если fluidsynth/timidity не поставится.
### Про единую частоту (замер)
Приводим не к 11 000, а ровно к **10 937,5 Гц — частоте CBL**
(`CBL_FREQ_10K9`). Тогда тон точен, а не «на 0,6 % ниже»: проигрывание
11 000 Гц данных на 10 937,5 даёт сдвиг **−9,9 цента**, что на коротком
эффекте не слышно, но бесплатно избавиться от него всё равно приятно —
пересчитывать три файла всё равно придётся.
| id | звук | было | станет | дельта |
|---|---|---|---|---:|
| 15 | `leveldoor_sliding` | 2 750 Гц, 4 436 сэмплов | 17 643 | **+13 207 Б** |
| 23 | `footstep` | 8 200 Гц, 996 | 1 329 | +333 Б |
| 51 | `princess_door_opening` | 14 000 Гц, 6 188 | 4 834 | 1 354 Б |
| — | остальные 28 (11 000 Гц) | — | ×0,9943 | 588 Б |
Итог: **112 922 -> 124 531 Б, 6,9 -> 7,6 EMM-страниц.** Рост целиком от
`leveldoor_sliding`: источник у него 2 750 Гц, вчетверо реже целевой, и
апсэмплинг не улучшит звучание — только уравняет формат. Платим 0,8
страницы за то, что **CBL открывается ОДИН раз и частоту менять не надо
никогда** — ни между эффектами, ни при переходе на музыку-WAV.
(Альтернатива для него — хранить как есть и повторять каждый сэмпл
четырежды в рантайме: 2 750 × 4 = 11 000 ровно. Это код в `fill()` ради
13 КБ; не стоит того, но если место когда-нибудь прижмёт — вариант есть.)
## 1в. Бюджет памяти EMM (живой замер 2026-08-20)
Замерено `mem_info` из работающей программы (уровень 1), а не посчитано на
бумаге: инструментовка ставилась временно и откатана.
| | страниц | КБ |
|---|---:|---:|
| всего в машине | 256 | 4 096 |
| система (DSS) + сам exe: база + 8 банков кода | **43** | 688 |
| наши ассеты | **81** | 1 296 |
| **занято** | **124** | 1 984 |
| **свободно** | **132** | **2 112 (2,06 МБ)** |
Разбивка 81 страницы ассетов (сходится точно):
| набор | страниц |
|---|---:|
| **Тень** (`sk*` 28 + `sf*` 4) | **32** |
| Кид (`kid0..27`) | 28 |
| фон тайлсета (env 10 + wall 1 + fore 1) | 12 |
| страж | 5 |
| зелья (chtab_1), меч, `kid_data.bin`, страница уровня | по 1 |
Самый крупный потребитель теперь — **набор Тени, 32 страницы**, больше
самого Кида. Если место когда-нибудь прижмёт, там есть очевидный резерв
(кадры смерти и позы, в которых Тень не бывает), но при 132 свободных
страницах трогать незачем.
### Что из этого следует для звука
| статья | страниц | останется свободно |
|---|---:|---:|
| эффекты WAV, все 31, 10 937,5 Гц | **8** | 124 |
| музыка ПО ХОДУ игры в WAV (путь C, 12 треков) | 50 | 74 |
| заставки и титры в WAV (10 треков, 248 с) | 167 | **не влезает** |
То есть эффекты — капля, игровая музыка в WAV тоже поместится, а
заставочную придётся стримить с диска в любом случае (что и планировалось:
во время заставок игра ничего не рисует).
Оговорка: 132 свободных страницы — это на уровне 1. На уровне 9 добавятся
зеркальные наборы (`pop_vflip_load_all`: 28 Кид + 5 страж + меч = 34
страницы), останется ~98. Проверять запас надо ИМЕННО ТАМ.
## 1г. MSDOS против SDLPoP: чем отличаются наборы (сверено 2026-08-20)
У нас лежат ДВЕ копии звука — оригинальные `.dat` в `MSDOS/` (версия
1.3/1.4) и распакованные ассеты `SDLPoP/data/` (версия 1.0/1.1). Разница
есть, и она влияет на выбор источника.
### Оцифровка: берём MSDOS
Заголовок разный (`digi_new_type` против `digi_type`), но **28 звуков из
31 совпадают побайтно**. Различаются три, и все не в пользу SDLPoP:
| id | звук | MSDOS | SDLPoP |
|---|---|---:|---:|
| 10 | `sword_vs_sword` | 5 020 сэмплов | 3 504 |
| 11 | `sword_moving` | 1 172 | 1 172, но **другие байты** |
| 48 | `spiked` | 5 069 | **7** — то есть звука нет |
`spiked` в наборе SDLPoP фактически пустой. Поэтому упаковщик читает
`MSDOS/digisnd*.dat`, а не распакованные ассеты — в отличие от графики,
где источник наоборот SDLPoP.
### MIDI: если дойдём до музыки — брать SDLPoP
Здесь всё наоборот. Содержимое музыкально то же (деление 480, те же
каналы 0..7 плюс ударные), но:
| | MSDOS | SDLPoP |
|---|---|---|
| формат MIDI | **0** — всё слито в ОДНУ дорожку | **1** — 8-9 дорожек |
| размер (звук 24) | 327 Б | 448 Б |
| размер (звук 56) | 13 587 Б | 12 773 Б |
Формат 1 с отдельной дорожкой на инструмент — это готовое разделение
голосов. Для пути B (MIDI -> три канала AY) оно решает половину задачи:
дорожки можно выбирать напрямую (бас / мелодия / гармония), а не
разбирать слитый поток и догадываться, что чем было.
**Итог: эффекты из MSDOS, музыка (когда дойдёт) из SDLPoP.**
## 2. Что лежит в ассетах (замерено, а не по памяти)
Звук в PoP адресуется как ресурс `10000 + N`, N = 0..56 — 57 звуков
(`load_sound`, `seg009.c:2289`). Наборов три, и они ПАРАЛЛЕЛЬНЫЕ: один и
тот же звук есть в нескольких видах.
| набор | что это | объём | покрытие |
|---|---|---:|---|
| `DIGISND1..3.DAT` | оцифровка, 8 бит PCM | 103 941 Б | **31 звук** (эффекты) |
| `MIDISND1..2.DAT` | MIDI-музыка | 27 776 Б | музыка |
| `IBM_SND1..2` (распакованы) | ноты PC-спикера | **7 КБ** | **все 57** |
### 2.1 Оцифровка (эффекты)
Разбор контейнера: индекс по 8 байт на запись (id, offset, size), **первый
байт ресурса — контрольная сумма**, тело за ней (спецификация
`POP-DAT-FormatSpecifications`, §3.1.2 — на этом я сначала споткнулся и
читал мусор). Тело — `digi_type`: `word rate, word count, word unk,
byte size`, дальше сэмплы.
- 31 звук, все 8-битные;
- частоты: **28 звуков на 11 000 Гц**, по одному на 8 200 и 14 000;
- 103 701 сэмпл = **9,4 секунды**, **103 941 Б ≈ 6,3 EMM-страницы**.
### 2.2 Ноты PC-спикера (и эффекты, и музыка)
Формат: `byte type(=0), word tempo`, дальше тройки `word frequency,
byte length`; `frequency <= 1` — пауза, `0x12` — конец. Ключевое, что
пришлось смотреть в `play_speaker_sound`/`speaker_callback` (`seg009.c`):
- **`frequency` — это ГЕРЦЫ напрямую** (`generate_square_wave(stream,
(float)note->frequency, ...)`), а не делитель PIT, как кажется по
маленьким числам;
- длительность ноты = `length / tempo` СЕКУНД.
Замеры по всем 57 звукам: **2212 нот**, частоты 16..65507 Гц,
длительности 1,74..2571 мс.
Из них 23 звука — те, у которых оцифровки НЕТ, то есть вся музыка:
заставки, гимны уровней, смерть, победа, титры (id 24..43, 50..56).
**1469 нот**, и вот их длительности:
| длительность ноты | нот | доля |
|---|---:|---:|
| < 3 мс | 0 | 0 % |
| 3..12 мс | 2 | 0,1 % |
| 12..25 мс | 140 | 9,5 % |
| > 25 мс | 1327 | 90,3 % |
Это число решает вопрос про таймер — см. §4.
## 3. Что умеет железо (док Ивана Мака §5 + MAME)
- **AY-3-8910/8912** в ПЛМ, «программируется по стандартным описаниям» —
то есть ZX-порты; в MAME он заведён как `AY8910(config, "ay8912",
X_SP/24)`, то есть **тактовая 1,75 МГц**. Период канала = 109375 /
частота(Гц), 12 бит (макс 4095) → снизу берутся частоты от ~27 Гц.
Точные Z80-адреса портов идут через таблицу DCP, а не напрямую —
**проверить артефактом до кодинга** (ожидаем ZX-стандарт 0xFFFD/0xBFFD).
- **CBL** — COVOX с буфером 256 Б, две половины по 128; бит 7 порта 0xFE
показывает играющую половину, порт управления 0x4E. Прерывание —
когда половина сменилась.
- **Бипер** (бит 5 порта 0xFE) — туда же в ЦАП. Нам не нужен.
- Всё это **сведено в один ЦАП**, поэтому AY и CBL звучат вместе.
У нас уже есть готовая обвязка CBL (`libc/include/cbl.h`): callback
`fill(n)`, выдача блока через `cbl_push_otir()` (порт 0x4F) или через
акселератор, коды частот, счётчик недоливов. Своего кольца библиотека не
держит — данные пропихиваются прямо из наших EMM-страниц.
## 4. Предлагаемая архитектура
```
эффекты (31 шт, 8 бит 11 кГц) музыка (23 шт, ноты)
│ │
EMM-страницы (6,3) 7 КБ нот в банке
│ │
cbl_push_otir из fill() запись 3 регистров AY
│ │
CBL (10,9 кГц) ──────┐ ┌────────── AY (1,75 МГц)
▼ ▼
TDA1543 (аппаратное смешивание)
```
**Один источник прерываний — CBL.** Его callback приходит каждые 128
сэмплов = **11,7 мс** при 10,9 кГц, и он же двигает секвенсор музыки.
Отдельный таймер (CTC) НЕ нужен: по таблице из §2.2 короче 12 мс всего
2 ноты из 1469 — они растянутся на один тик, чего не слышно.
Почему это важно: `irq_ctc_install` сейчас требует кода в W2 (tiny/big),
а roomtest — huge, и попадёт ли туда CTC-трамплин, зависит от раскладки.
Обойтись без него — значит не открывать этот фронт вовсе.
**Пейсинг кадра при этом не страдает.** Наш темп считается ПО ЛУЧУ
(`pop_pace.h`), а не по кадровым прерываниям, поэтому то, что CBL-ветка
трамплина делает приватный RETI и съедает кадровые прерывания, нам
безразлично. Если бы пейсинг остался на прерываниях — звук бы его сломал.
## 5. Объём работ
| фаза | что | оценка |
|---|---|---|
| **З1** | распаковщик `pop_pack_sound.py`: DAT → `.snd`-страницы EMM (эффекты) + `.not` (ноты музыки) | формат уже разобран |
| **З2** | `pop_sfx.c`: `cbl_open` + `fill()`, таблица «звук → страница/смещение/длина», `pop_sfx_play(id)` | ядро |
| **З3** | развесить вызовы: 66 мест `play_sound` в оригинале; у нас часть уже помечена TODO (`pop_ctrl.c:408`, `guards.c:992`, `pop_map.c:3035/3107`, …). **Плюс бесплатный кусок**: опкод `SEQ_SOUND` в seqtbl уже разбирается нашим `play_seq` (`pop_kid.c:326`) — шаги, приземления и прочее поедут сами | механическая |
| **З4** | `pop_music.c`: секвенсор нот на AY (путь A), тик из CBL-callback | небольшая |
| **З6** | заставки и титры — WAV-музыка потоком (эффекты в это время не звучат) | после З1-З4 |
| **З7** | музыка по ходу игры: WAV с гашением эффектов ЛИБО путь B — решать по итогам З6 | открыто |
| **З5** | приоритеты и вытеснение: у оригинала `play_sound` глушит предыдущий (`stop_sounds`), музыка и эффект — разные каналы | правила из seg009 |
## 6. Что проверить артефактом ДО кодинга
1. **Порты AY на Sprinter** — записать в 0xFFFD/0xBFFD и убедиться, что
MAME отдаёт звук (порты идут через DCP-таблицу, «стандартные ZX» —
это ожидание, а не факт).
2. **CBL в режиме huge.** Шапка `cbl.h` говорит «код/данные в W2
(tiny/big)», но это скорее всего устаревшая оговорка: IM2-трамплин
давно переделан на all-modes, и наш кадровый путь в huge работает.
Проверить `cbl_open` из roomtest.
3. **Совместное владение портом 0xFE.** Бит 5 (луч) у нас держится через
`_cbl_port_ref` «немым» кодом частоты; когда откроется НАСТОЯЩИЙ CBL,
владение переходит к нему. Убедиться, что пейсинг переживает
`cbl_open`/`cbl_close`.
4. **Цена fill() в кадре.** 128 байт через `cbl_push_otir` раз в 11,7 мс
— замерить тем же способом, что и остальное (брейкпоинт + totalcycles).
## 7. Про MIDI — почему не он
Музыка в MIDISND — настоящие MThd/MTrk чанки, 27 КБ. Чтобы играть их на
AY, нужен разбор MIDI, раскладка каналов на три голоса и таблица
инструментов — это отдельный проект, и звучать он будет НЕ так, как
оригинал на PC. А набор PC-спикера — это ровно то, что слышал игрок на
IBM PC 1989 года: один квадратный голос. Он у нас есть целиком, весит
7 КБ и ложится на AY напрямую.
Если позже захочется богаче — материал уже будет разобран, и можно
разложить те же мелодии на три канала AY (бас/мелодия/арпеджио), не трогая
ни данные, ни секвенсор.
---
## 8. Разводка вызовов по коду (сделано 2026-08-20)
Портированы ВСЕ места `play_sound()` SDLPoP, у которых есть оцифровка
(id 0..23, 44..49, 51 — остальные id это музыка, у них в
`pop_sound_tbl.h` длина 0, и вызов просто глушит текущий эффект).
| id | что | где у нас | оригинал |
|----|-----|-----------|----------|
| 0 | разбился насмерть | `pop_map.c` land | seg005 |
| 1 | крик падения | `pop_map.c` do_fall | seg005:39 |
| 2 | плита рухнула | `pop_room.c` (обе ветки посадки) | seg007 |
| 3 | кнопка нажата | `pop_trob.c` | seg007 |
| 4/5/6/7 | ворота: закрываются / открываются / рухнули / стоп | `pop_trob.c` | seg007 |
| 8 | удар о стену | `pop_map.c` bumped_fall/bumped_floor + seqtbl | seg004/seg006 |
| 9 | зацеп за карниз | `pop_map.c` check_grab | seg006 |
| 10 | клинок о клинок | `roomtest.c` после `check_sword_hurt`, если один из бойцов в кадре 167 | seg000:1353 |
| 11 | свист клинка мимо | `guards.c` check_hurting | seg002:0DAE |
| 12/13 | ранен соперник / Кид | `guards.c` hurt_by_sword | seg002:0C1F |
| 13 | Кид ранен зельем | `pop_map.c` ветка «злого» зелья | seg006:1894 |
| 14/15 | дверь уровня: закрывается / едет | `pop_trob.c` | seg007 |
| 16 | средняя посадка; толчок о стража | `pop_map.c` land / bump_into_opponent | seg005/seg003:0654 |
| 17 | мягкая посадка | `pop_map.c` land | seg005 |
| 18 | пьёт | seqtbl (SEQ_SOUND) | seg006 |
| 19 | вынул меч | `pop_ctrl.c` | seg005:945 |
| 20/21/22 | дрожит плита | `pop_map.c` loose_shake | seg007:0E55 |
| 23 | шаг | seqtbl (SEQ_SOUND) | seg006 |
| 44 | скелет оживает | `guards.c` pop_check_skel | seg002:106D |
| 45 | прыжок в зеркало | `pop_map.c` jump_through_mirror | seg003:0617 |
| 46 | сожрал чомпер | `pop_map.c` | seg004 |
| 47 | чомпер щёлкнул | `pop_trob.c` (кадр 2) | seg007 |
| 48 | напоролся на пики | `pop_map.c` | seg005 |
| 49 | пики пошли | `pop_map.c` start_anim_spike | seg007:08F6 |
Что осталось не разведено — только МУЗЫКА (24/28 смерть, 25 презентация,
26 объятия,
27/35/40 заставки, 29 встреча Джафара, 30/33 зелья, 32/41 конец уровня,
36 время вышло, 37 победа, 43 смерть Джафара, 50/52/53 сюжетные вставки)
и 51 (дверь принцессы, тоже из заставки). Их черёд — фаза «музыка».
**Квирк, за которым следить.** Звук ворот у оригинала звучит не всегда, а
по условию видимости (`play_door_sound_if_visible`, seg007:1250): либо
ворота в комнате слева и стоят в 9-й колонке, либо ворота в НАРИСОВАННОЙ
комнате и колонка не 9-я. У нас это параметр `audible` у `animate_door`.
**Тряска плиты — свой домен prandom.** Оригинал берёт номер сэмпла (20/21/22)
из общего генератора и вдобавок «сжигает» один бросок ради совместимости с
DOS-версией; у нас последовательности разведены по доменам
(`impl_diff.md`), поэтому у тряски свой сид, а холостой бросок не делаем —
на розыгрыши физики и кладки это не влияет.
## 9. Цена звука в тактах (замер 2026-08-20, MAME)
Вопрос был поставлен так: звук идёт по прерываниям, значит размазан по всем
фазам кадра — и если фазы укладываются, всё хорошо? Да, но проверять это
надо не по фазам, а по двум числам, потому что **нагрузка от звука
постоянная и от сцены не зависит вовсе**.
### 9.1 Одно прерывание CBL
Зонды: `bpset` на входе трамплина (`_irq_tramp`) и на `reti` ветки CBL,
разница `totalcycles`. 398 замеров в сцене 11/15.
| величина | значение |
|---|---:|
| цена одного прерывания | **7 825 тактов ровно**, с разбросом до 8 299 (среднее 8 001) |
| период между прерываниями | 245 759 тактов (= 128 сэмплов на 10 937,5 Гц) |
| **доля процессорного времени** | **8 001 / 245 759 = 3,26 %** |
Цена постоянная, потому что работа фиксированная: OTIR ровно 128 байт плюс
скобка сохранения контекста. Ветвлений по данным в насосе нет.
### 9.2 Дрожание обслуживания — риск для ЗВУКА, не для кадра
Период плавает 228 804 … 262 734, то есть прерывание опаздывает максимум на
**~17 000 тактов = 0,8 мс**. Это самая длинная DI-скобка в коде
(акселератор режется по 16 строк, memory `sprinter_wait_states_2x`).
Буфер CBL — 128 сэмплов = **11,7 мс**, запас **14×**. Недолива быть не
может; счётчик `cbl_underruns()` это подтверждает косвенно (наш `fill`
всегда возвращает 1, поэтому он ловит только отсутствие данных, не
опоздание).
### 9.3 A/B в одном прогоне (Ctrl+S), сцена 11/15
Один и тот же кадр, звук выключается на ходу — сравнение чистое.
| | работа min | работа max | работа avg | прерываний CBL на кадр |
|---|---:|---:|---:|---:|
| звук ВКЛ | 453 132 | 572 532 | **498 064** | 1,40 |
| звук ВЫКЛ | 444 348 | 559 434 | **487 372** | 0,00 |
| разница | +8 784 | +13 098 | **+10 692 (+2,2 %)** | |
Разница на лёгком кадре (+8 784) — ровно одно прерывание, сходится с §9.1.
`CBL/кадр = 0` при выключенном звуке подтверждает, что Ctrl+S реально
ЗАКРЫВАЕТ CBL, а не глушит сэмпл: иначе насос продолжал бы отдавать блоки
тишины и платить те же 3,26 %.
### 9.4 Укладываемся ли
Логический кадр (`pop_pace.h`): NORMAL = 4 растра вне боя = **1 720 000
тактов**, FASTEST = 3 растра = **1 290 000**.
| | работа | доля NORMAL | доля FASTEST |
|---|---:|---:|---:|
| 11/15, обычная позиция | 498 064 | 29 % | 39 % |
| 11/15, тяжёлая позиция (Кид на 7 px правее) | 750 066 макс | 44 % | 58 % |
Период кадра за все прогоны: 1 719 936 … 1 720 752 — ровно 4 растра, ни
одного проскока. **Звук занимает 1,1 % бюджета NORMAL и 1,5 % FASTEST.**
### 9.5 Где 3,26 % МОГЛИ БЫ стоить дорого
Ответ «всё размазано, если фазы влезли — ок» верен с одной оговоркой.
Пейсинг квантован растром: работа 1,00 растра и 1,02 растра дают РАЗНЫЙ
период кадра (3 против 4 интервалов), то есть скачок сразу на 20 мс.
Значит звук опасен ровно в одной ситуации — когда сцена стоит в пределах
~8 000 тактов НИЖЕ кратного растру порога. Сейчас ближайший запас — 540 000
тактов до порога FASTEST, то есть в 60 раз больше цены звука. Проверять
эту оговорку заново стоит только если работа кадра подберётся к 430 000 или
860 000 вплотную.
## 10. Мусор при включении и щелчок на выходе (разбор 2026-08-20)
Жалоба: «при старте, когда разрешается звук, проходит кусок мусора».
Разобрано записью выхода MAME в WAV (`-wavwrite`) — по огибающей и
автокорреляции, а не на слух.
### 10.1 На старте мусора НЕТ; это настоящие звуки
От `cbl_open` до первого эффекта в записи **точная цифровая тишина**
(размах 1 при разрешении 16 бит). Дальше — два штатных звука:
| что | когда | длительность |
|---|---|---|
| `gate_closing_fast` (6) — решётка в комнате СЛЕВА | +0,30 с после `cbl_open` | обрывается на 80 мс |
| `soft_land` (17) — Кид приземляется | +0,38 с | 383 мс |
Опознаны корреляцией огибающих с оригинальными сэмплами `digisnd`:
звук 6 даёт +0,72 с начала записи, звук 17 — +0,32 со сдвигом 80 мс.
Приземление на старте КОРРЕКТНО: `start_pos` уровня 1 — тайл (0,0), а он
`space`, то есть Кид падает на ряд ниже, на площадку с факелами.
Обрыв первого звука вторым — тоже поведение оригинала, а не наш дефект:
`play_digi_sound` (seg009.c:2402) начинается с `stop_digi()`, голос ОДИН.
Ощущение «мусора» даёт именно 80-мс огрызок скрежещущей решётки.
Звук кнопки (3) при этом не слышен: `pop_sfx_play(3)` случается ДО
`cbl_open` и глохнет. С SDLPoP совпадает (там на старте тоже только
решётка), но держится это на порядке вызовов — если поднимать звук раньше
`pop_start_level`, щелчок кнопки станет слышен.
### 10.2 Незалитый буфер CBL — дефект есть, но в MAME он немой
Буфер CBL (256 слотов) железо не чистит ни сбросом, ни записью в порт
управления, а эта запись сразу пускает воспроизведение с нулевого слота.
Значит первые 256 сэмплов (23,4 мс) — то, что лежало раньше. Разбор по
`sprinter.cpp`: `case 0x89` делает `m_cbl_cnt = 0; m_cbl_wa = 0`, а
прерывание «долей половину» приходит только на 128-м слоте и ставит
указатель на ПРОТИВОПОЛОЖНУЮ половину — своими данными звук идёт лишь с
третьей половины.
В MAME это не слышно: эмулируемый буфер стартует нулями, а ЦАП
двухдополнительный, то есть 0 = середина шкалы. На ЖЕЛЕЗЕ там
неинициализированное ОЗУ — ровно тот мусор, который ловился ещё на
тестовых примерах CBL. Лечение — `_cbl_prime` в `cbl_open`: сразу после
включения 256 записей байта тишины в порт данных (заранее нельзя, запись
проходит только при поднятом bit7). Стоит 6 400 тактов один раз за
открытие; за это время таймер уходит на два-три слота.
### 10.3 Щелчок на выходе — ГОЛОДАНИЕ насоса, вылечено
На выходе по ESC в записи было **ровно 11 мс шума на полной громкости**
(размах 33 671 — громче всего в прогоне), потом мгновенная тишина. 11 мс
= один блок CBL (128 сэмплов = 11,7 мс), то есть один пропущенный долив:
`pop_shutdown` звал `closegraph`/`pop_bg_free`/`pop_kid_free` (а это
ESTEX на каждый атлас) ПРИ ОТКРЫТОМ звуке, насос не успевал, и железо
доигрывало несвежую половину.
Лечение: `pop_sfx_close()` первым действием `pop_shutdown`. Проверено
второй записью — всплеска на выходе больше нет. Механизм тот же, из-за
которого звук глушится на время загрузки уровня.
## 11. Приоритеты и перебиваемость: звук у оригинала НЕ «всегда перебивать»
Пользователь услышал расхождение: у нас решётка обрывалась приземлением
Кида, в SDLPoP — доигрывала до звонкого конца, а приземления не было
слышно вовсе. Разбор исходника показал, что мы упустили ЦЕЛЫЙ МЕХАНИЗМ.
### 11.1 Модель оригинала
```
play_sound(id) seg000:12C5 — только НОМИНИРУЕТ кандидата на кадр:
if next < 0 || prio[id] <= prio[next]: next = id
play_next_sound() seg000:1304 — раз в кадр решает, запускать ли:
if next >= 0:
if !играет_что_то ||
(перебиваем[текущий] && prio[next] <= prio[текущий]):
текущий = next; запустить
next = -1 // НЕ запустили -> номинант ВЫБРОШЕН, очереди нет
```
Три следствия, каждое слышно:
- **Неперебиваемый звук доигрывает целиком.** У `gate_closing_fast` (6)
`interruptible = 0`, поэтому приземление Кида (17) в этот момент
пропадает совсем — не откладывается, а именно теряется.
- **Внутри кадра выживает важнейший.** Меньше `prio` — важнее; при
равенстве побеждает ПОСЛЕДНИЙ (сравнение `<=`).
- **Два источника не «чередуются как получится».** Челюсти (47, prio
0x10) всегда важнее решётки (4, prio 0x32): решётка не может перебить
укус, а укус решётку — может. Отсюда и картина на ур. 9 к. 9, где
решётка звучит только в паузах между укусами.
### 11.2 Что сделано у нас
`pop_sfx_play` теперь только номинирует; запуск — в `pop_sfx_tick`,
который зовётся раз в кадр в конце отрисовки (там же, где оригинал зовёт
`play_next_sound`, seg000:954). Таблицы `snd_prio` (57 байт) и битовая
карта `snd_intr` (8 байт) — в резиденте, значения из SDLPoP С УЧЁТОМ
`fix_sound_priorities()`: в `config.h` SDLPoP `FIX_SOUND_PRIORITIES`
определён безусловно, значит сравниваемся мы с исправленным вариантом
(звук 10 → 0x0D, 48 → 0x15, 49 перебиваем).
Створка двери уровня (15) — единственная запись, которую оригинал правит
на ходу: перебиваема при закрытии, нет при открытии (seg007:442/464).
Держим отдельным байтом `pop_sfx_slide_intr`, чтобы таблица осталась в
ПЗУ. Там же добавлен пропущенный `stop_sounds()` на завершении открытия
двери (seg007:455) — без него неперебиваемый съезд (1,6 с) блокировал бы
очередь.
Звуки без оцифровки (музыка, длина 0) не номинируются вовсе — порт
проверки `if (NULL == sound_pointers[id]) return;`. Раньше такой id
глушил живой эффект.
### 11.3 Проверка
Записью MAME, старт уровня 1:
| | всплески | что это |
|---|---|---|
| до | 135 мс + 210 мс | решётка, обрезанная приземлением на 80 мс |
| после | **один, 455 мс** | решётка целиком, корреляция огибающей со звуком 6 **+0,889** |
Цена: резидент +~250 Б (таблицы + логика), куча ужалась с 347 до 134 Б —
довод в пользу давно назревшей реорганизации базовой памяти.
## 12. Ворота: гейт слышимости и «решётка встала» (2026-08-20)
Проверка на сцене, которую предложил пользователь — уровень 9, комната 9:
кнопка (1,8), челюсти (1,2), а ворота **в комнате 4, тайл (1,9)**, то есть
в комнате СЛЕВА. Нашлись три расхождения сразу.
### 12.1 Гейт слышимости был неверный
У нас стояло `audible = (room == cur_room)`. У оригинала
(`play_door_sound_if_visible`, seg007:1239) правило другое:
- ворота в комнате СЛЕВА и в колонке 9 — СЛЫШНЫ (створка видна в шве);
- ворота в отрисованной комнате и НЕ в колонке 9 — слышны;
- особый случай: уровень 3, комната 2 — слышны всегда.
Сцена 9/9 попадает ровно в первый пункт, поэтому спуск решётки у нас
молчал. Подъём при этом совпадал с оригиналом — потому что звук открытия
(5) идёт БЕЗ гейта (seg007:386, прямой `play_sound`). Эта асимметрия и была
подсказкой.
Взят вариант под `FIX_GATE_SOUNDS` (условия через ИЛИ): в config.h SDLPoP
он определён безусловно.
### 12.2 Потерян звук «решётка встала» (7)
`gate_stop()` (seg007:05E3) зовётся из ТРЁХ мест `animate_door` и каждый раз
играет звук 7 через гейт слышимости: конец закрытия, открытие насовсем и
ветка «уже 0xFF». У нас во всех трёх стояло только `*type = -1` без звука.
Добавлено. Лязг после ОБЫЧНОГО открытия (seg007:395) остаётся без гейта —
там оригинал зовёт `play_sound` напрямую.
### 12.3 Кнопка: у оригинала есть параметр playsound
`trigger_button(playsound, ...)` — в трёх местах он нулевой: вход на уровень
(seg003:170), выход Джаффара (seg002:520) и зелье «открыть» (seg006:1890, у
нас не портировано). Мы играли щелчок всегда. Добавлен параметр `snd`.
### 12.4 Почему щелчок кнопки слышно через раз — это НЕ баг
Бюджет сцены 9/9 (длительности после пересчёта на 10 937,5 Гц):
| звук | длительность | prio |
|---|---:|---:|
| челюсти (47) | 465 мс | 0x10 |
| решётка вниз (4) | 97 мс | 0x32 |
| решётка вверх (5) | 123 мс | 0x37 |
| решётка встала (7) | 75 мс | 0x30 |
| кнопка (3) | 106 мс | 0x66 |
Цикл челюстей — 15 кадров = 1229 мс (замерено брейкпоинтом на номинации:
25 805 000 тактов между укусами). Значит укус занимает 465 мс, пауза 764 мс.
Кнопка (prio 0x66) перебить челюсти не может (0x66 > 0x10), поэтому слышна
только если нажатие попало в паузу — примерно в 6 случаях из 10.
Подтверждено пользователем на живой сцене.
### 12.5 Грабли сцены
Если игра стартует ПРЯМО в комнате с челюстями, они не заводятся сами:
нужно сходить Кидом на левую кнопку и вернуться. Это поведение оригинала
(trob челюстей создаётся событием), а не наш дефект — учитывать при
постановке автотестов.
## 13. Повторный аудит игрового звука (2026-08-24)
Проверены три независимых слоя: содержимое атласов, места вызова и живой
тракт `play -> tick -> CBL`.
- В восьми `SND*.ATL` есть все 31 оцифрованных ресурса: `0..23`, `44..49`
и `51`; ненулевая страница/длина есть у каждой записи таблицы.
- Для всех 30 PCM-эффектов, которые могут возникать непосредственно в игре,
есть место вызова. Последним пропуском был звук 10 при столкновении
клинков; условие перенесено буквально из `check_sword_vs_sword` SDLPoP.
Звук 51 относится к сцене с принцессой, а не к игровому циклу.
- Звук 19 «Кид достал меч» проверен в MAME брейкпоинтами. В момент вызова
предыдущий PCM уже закончился (`sfx_left=0`), номинация дошла до
`pop_sfx_tick`, после чего курсор получил id 19, страницу 5, смещение
`0x1500` и длину 2816 байт. В этом прогоне его не подавляли решётка,
плиты, шаги или приоритеты. Если он всё ещё субъективно не слышен, искать
надо после выбора эффекта — в непрерывности CBL/громкости самого сэмпла.
- После продолжительного прогона title/intro/demo/menu CBL обслужил 3303
блока и сообщил 0 программных недоливов (`cbl_requests=0x0CE7`,
`cbl_underruns=0`). Это исключает возврат `fill=0`, но само по себе не
измеряет запоздание прерывания внутри слишком длинной секции `DI`.
- Регрессия full-game на входе в Level 1 оказалась именно запозданием CBL:
`pop_level_switch` открывал его ДО блокирующего BIOS fade-in. Demo был
чистым, потому что включал палитру без fade. Теперь загрузчик оставляет
CBL закрытым, а caller открывает его после окончательной палитры/QuickLoad;
скрежет на Level 1 исчез в MAME. Однократный щелчок самого первого
`cbl_open` за всю MAME-сессию остаётся отдельной низкоприоритетной задачей.
- Открытие pause menu у SDLPoP беззвучно; движение играет 21, вход/выход
из подменю — 22, изменение настройки — 10. Эти вызовы перенесены. На
время полного копирования страницы и файловых операций CBL закрывается,
после flip открывается снова: аппаратная половина не должна повторять
старые данные и давать «скрежет».
Отдельно остаётся игровая музыка и сигнальные мелодии без PCM: смерть
(`24/28`), начало/появление Shadow (`25`), встреча Jaffar (`29`), большое
и малое зелья (`30/33`), Shadow (`32`), победа/меч (`37`), перо (`39`),
конец уровня (`41`) и победа над Jaffar (`43`). Вызовы и AY-проигрыватель
для них ещё не реализованы; наличие всех PCM-эффектов эту задачу не закрывает.
## 4. МУЗЫКА: решение пересмотрено (2026-08-25) — путь C вместо A
В §1б первым заходом был выбран **путь A** (ноты PC-спикера на AY), а путь
C (запись -> WAV -> CBL) стоил дорого из-за строки «нужен синтезатор на
хосте». Это обстоятельство отпало: у пользователя есть **готовые записи
DOS-версии** — `applications/PoP/PoP1_DOS_music` (flac/mp3/ogg/ogg_MT-32,
22 трека). Синтезировать нечего, остаётся `ffmpeg -ac 1 -ar 10937 -f u8`.
### Что померено по этим записям
| группа | длительность | PCM 10 937,5 Гц |
|---|---:|---:|
| всё вместе (22 трека) | 339 с | **3 625 КБ = 227 EMM-страниц** |
| игровые джинглы (10) | 57 с | 608 КБ |
| заставки и титры | 282 с | 3 017 КБ |
| финальный `won` один | 115 с | 1 233 КБ |
Свободной EMM на старте ~3 440 КБ, так что вся музыка разом в память не
влезает и не должна: трек грузится под сцену и освобождается после.
### Что сделано
`toolchain/pop_pack_music.py` -> один файл `MUS\m<id>.bin` на трек
(читается порциями по 16 КБ из одного открытого fd) + каталог
`pop_music_tbl.h`. Длина трека хранится **порциями по 128 байт**,
а не байтами: 169 КБ в uint16 не влезает, 1350 блоков — легко.
`pop_music.c` (банк 9) грузит трек в EMM и ставит курсор; насос
`pop_sfx_fill` (резидент) получил **третий источник**: эффект важнее
музыки, музыка важнее тишины. Эффект музыку не сбрасывает — её курсор
стоит, пока эффект доигрывает, и она продолжается с места.
Проверено в MAME записью звука: трек `story_1_absence` на первом экране
истории, корреляция огибающих с эталоном **0,836**, RMS 22,6 против 18,3
(разница — 8-битное квантование). Эффекты двери в PV-сцене после него
звучат как прежде, то есть освобождение страниц и возврат к набору
эффектов работают.
### Цена и что осталось
* CBL один: пока играет музыка, эффектов нет. Для заставок это не важно
(их там не бывает), для игровых джинглов — открытый вопрос §1б.4.
* Резидент вырос на 83 байта (третий источник в насосе) плюс 25 байт
данных под курсор и таблицу страниц; запас W2 — 151 байт. Кучи в
приложении нет (`malloc` не слинкован), так что это чистый запас роста.
* Банк 9 занят на 88 %. Следующий модуль туда уже не влезет — либо
переносить, либо заводить банк 12.
* `won` (77 страниц) в POP_MUS_PAGES=20 не помещается: финал придётся либо
резать, либо стримить кусками по ходу.
## 5. СКРЕЖЕТ ПРИ BIOS-ВЫЗОВАХ: причина и решение (2026-08-25)
Симптом: во время затемнения (fade) звук хрипел — одинаково с играющей
музыкой и в тишине. Пользователь заметил ключевое: **повтор тишины обязан
звучать тишиной**, значит дело не в недоливе буфера.
### Как искали
Отладочные клавиши, каждая делает ровно один кусок fade:
| клавиша | что делала | результат |
|---|---|---|
| H | только ожидание 8 кадров | чисто |
| V | только чтение палитры | скрежет |
| G | затемнение целиком | скрежет |
| J | только запись палитры | скрежет |
| L | 512 раз `bios_get_place()` — видео не трогает | **скрежет** |
`L` и решил вопрос: виновата не палитра, а **любой вызов BIOS**.
### Причина
Вход в BIOS — это `rst 8`, то есть `out ($7C),a`. В драйвере MAME он
правит `m_rom_sys` и вызывает `update_memory()`, которая перестраивает
**окно 0**: `m_pages[0]` + `m_bank_view0.select(1)`. ПЗУ ложится ПОВЕРХ
страничного регистра.
Насос CBL брал окно взаймы именно у W0 (`_io_page_w0 = phys` + `OTIR` по
адресу < 0x4000). Пока BIOS работает, запись в порт `0x82` ничего не
меняет, и `OTIR` вычитывает ПЗУ, отдавая его в звук.
Побочно выяснилось, почему `DI` вокруг BIOS помогал лишь иногда: он не
даёт войти в ISR (тогда блок просто пропускается, что неслышно), но в
обработчиках BIOS есть `EI`, так что защита негарантированная.
### Решение
Насос переведён на **W3** (идея пользователя): это окно управляется только
портом `0xE2`, подмену из прерывания никто не перекрывает, а BIOS во время
нашего ISR не исполняется — окно возвращается до выхода.
```c
saved = _io_page_w3;
_io_page_w3 = phys;
cbl_push_otir((const void *)(0xC000u + ptr), n);
_io_page_w3 = saved;
```
После этого BIOS безопасен везде: и палитра, и любые другие функции.
Временный обход палитры мимо BIOS (`gfx_pal_write`) стал не нужен — он
остался в libbgi как более быстрый примитив (2,5 тыс. тактов на 64 цвета
против 10,8 тыс. у BIOS), но игра его не зовёт.
Бонус: в W3 нет стаба восстановления окна, который в W0 занимал начало
страницы, — звуковые страницы можно использовать целиком.
## 6. ВСЯ ЗАСТАВКА ОЗВУЧЕНА (2026-08-25)
К `story_1_absence` добавлены остальные четыре трека заставки:
**54** intro_theme (титры), **50** story_2_princess, **53**
story_3_Jaffar_enters, **52** story_4_Jaffar_leaves (сцена с принцессой).
`MUS_IDS` в Makefile — 50 52 53 54 55, всего 1056 КБ на образе.
### Два слота вместо одного
Реплики оригинала идут ВСТЫК: следующая начинается там, где кончилась
предыдущая, паузы под загрузку нет. Поэтому `pop_music` держит два слота
EMM: `pop_music_load*` всегда пишет в НЕ играющий, `pop_music_play`
подменяет резидентную таблицу страниц и отпускает прошлый слот. Своей
копии таблицы слот не хранит — её и так держит блок EMM, `mem_get_page`
отдаёт номер по индексу (иначе −40 байт W2 у игры, а там их нет).
Плюс **постраничная загрузка**: `pop_music_load_begin` / `_load_step`
читают по одной странице за вызов. Страница стоит 33 мс — четверть
логического кадра заставки (133 мс), поэтому подкачка следующей реплики
прямо посреди анимации не видна. Кто может позволить себе паузу (титры,
чёрный экран между сценами) — зовёт прежний `pop_music_load`.
### Тайминги приведены к шкале оригинала
Все длительности сцен взяты из SDLPoP в его тиках (60 Гц), а ждём мы
кадрами луча (~50 Гц). Пока сцены были немыми, разбег в 20 % не был
виден; с музыкой он слышен сразу — реплика кончается раньше картинки.
Введён `POP_T60(t)` (pop_cutscene.h), и на него переведены титры,
`intro_before_pv`, хвост после PV и пейсинг самой PV-сцены (счётчик
потраченных кадров луча против `POP_T60(tick)`, вместо прежних жёстких
четырёх кадров на логический).
**Паузы-реплики.** Там, где оригинал ждёт конца сэмпла, у нас теперь
стоит реальная длина нашей записи: m50 — 831 тик, m53 — 985. Отсюда
новая шкала PV: конец m50 на 846, вход Джафара (m53) на 1046, уход
(m52) на 2073, конец сцены 2500 тиков (было 1959).
**Fade перед PV** (вопрос пользователя: наш fade короче, 4 ступени против
64). Совпасть должен момент полной темноты, считая от пуска m55:
у SDLPoP это 80 (transition) + 600 (wait) + 128 (fade_out_2: 0x40 шагов
по 2 тика); у нас переход занимает 80 кадров луча = 96 тиков, а fade —
5 тиков. Отсюда `WAIT = 80 + 600 + 128 96 5 = 707` тиков. Дальше и
там и там экран уже чёрный, а трек доигрывает: этой паузой заставка и
стыкуется с PV.
**Музыка переживает смену сцен.** m54 звучит с титров и до первого
экрана истории (`pop_intro_show` больше не глушит CBL на входе), m52
начинается в PV и доигрывает уже на экране «свадьбы» — как seg000:2051.
Проверено в MAME: цепочка 54 → 55 → 50 → 53 → 52 отыгрывается целиком,
курсор `pop_mus_id` меняется ровно на своих кадрах, интро доходит до
демо-режима. Слуховая проверка (нет ли хрипа от диска при играющей
музыке) — за пользователем.
## 7. МУЗЫКА ПО ХОДУ ИГРЫ (2026-08-26)
Звуки 24..43 в наборе оцифровки ПУСТЫЕ — в оригинале это Adlib-музыка, и
в digisnd её нет вовсе. На этом и построено подключение: `pop_sfx_play`
для звука с нулевой длиной не пытается его играть, а кладёт НОМЕР в
`pop_mus_req` (один байт). Заявку разбирает `pop_music_service()` — один
вызов на кадр из любого цикла (игрового, интро, катсцены); всё чтение с
диска живёт там.
**Стриминг вместо загрузки.** Ждать полной загрузки джингла нельзя — это
фриз на треть секунды посреди игры. `pop_music_stream` читает ПЕРВУЮ
страницу (33 мс) и сразу пускает трек: она звучит 1,5 с, а следующая
читается те же 33 мс — запас сорокакратный. Остальные доливаются по
одной за кадр, пока `pop_music_loading()`. Номера страниц известны сразу
после `mem_alloc_pages`, поэтому таблица для насоса заполняется целиком —
данные появятся раньше, чем насос до них дойдёт.
**Что и где играет** (номера и места — из SDLPoP):
| трек | событие | место у нас |
|------|---------|-------------|
| 24 / 28 / 32 | смерть: обычная / в бою / от руки тени | `ctrl_kid_death` |
| 25 | вступление 1-го уровня (Кид сидит), тень 6-го | `control_crouched`, `guards.c` |
| — | НА ДЕМО-УРОВНЕ музыки нет вовсе: там одни эффекты | гейт в `pop_music_service` |
| 27 / 35 / 40 | сцены перед 2/4/6/12, 8/9, «времени мало» | `pop_pre_cutscene_show` |
| 29 | встреча с Джафаром | `pop_meet_jaffar` |
| 30 / 33 | большая склянка / малая | `pop_proc_get_object` |
| 36 | время вышло | `pop_time_expired_show` |
| 37 / 43 | меч найден, страж убит / смерть Джафара | `pop_proc_get_object`, `on_guard_killed` |
| 39 | перо (медленное падение) | `pop_proc_get_object` |
| 41 / 32 | конец уровня / конец 4-го (тень) | опкод SND_LEVEL в `play_seq` |
| 26 | встреча с принцессой | `cut_ending` |
**Вступление первого уровня — автомат, а не «звук при приседе»** (seg005:02EB).
Пока `need_level1_music` не ноль, `control_crouched` НЕ ЧИТАЕТ управление:
Кид сидит, тема играет, и лишь когда она смолкла, он может встать. Наша
первая версия просто играла трек при первом приседе — и тема догоняла
игрока посреди уровня (пробежал, спрыгнул, присел — заиграла). Признак
«ещё звучит» берём у курсора насоса `pop_mus_left`: он резидентный, и если
музыка выключена, курсор остаётся нулём — Кид просто встаёт.
Темы, которые звучат один раз за заход на уровень (вступление 1-го, тень
6-го), сбрасывает `pop_music_level_start()` из `pop_start_level`. Оригинал
для этого портит переменную двери (`leveldoor_open = 0x4D`) — у нас на это
есть свои два байта.
**ГДЕ КОНЧАЕТСЯ МУЗЫКА СЦЕНЫ** (уточнено 2026-08-26). Сначала мы отдали
трек «доигрывать в игре»: у оригинала load_intro после сцены просто гасит
экран и возвращает управление. На слух оказалось хуже, чем в оригинале —
музыка спотыкается: загрузка уровня (ESTEX плюс сборка комнаты) не даёт
насосу долить блок вовремя. У DOS-версии этой проблемы нет, там звук
живёт своей жизнью на аппаратуре.
Поэтому дослушиваем ПОД ЧЁРНЫМ ЭКРАНОМ, до отрисовки уровня: следующий
load_intro у оригинала и так начинается с ожидания тишины (seg001:681), то
есть к новому уровню трек в любом случае смолкает. Пропуск сцены обрывает
и музыку — игрок нажал клавишу, чтобы идти дальше.
**ДЛИТЕЛЬНОСТЬ FADE.** fade_in_1/fade_out_1 — это 64 шага палитры по два
тика, 128 тиков = 2,13 с; сцена перед уровнем 2 занимает с ними около семи
секунд. Наши четыре ступени укладывались в восемь сотых секунды, и сцена
выходила втрое короче. Теперь `INTRO_FADE = POP_T60(128)`, а ступеней в
`pop_ui_palette_dim` тридцать две вместо четырёх: на четырёх растянутых
ступенях затемнение выглядело бы скачками. Половина от оригинальных 64 —
на глаз от них не отличается (ступень каждые 66 мс), а вот шестнадцать уже
видно. Сумма «fade in + сцена + fade out» при этом совпадает с оригиналом
сама собой: длительность каждого fade та же, что у fade_*_1.
**Цена ступени** (замеры в MAME 2026-08-26, такты 21 МГц; кадр 430 000):
| версия | такты | что изменилось |
|--------|-------|----------------|
| исходная | 2 440 000 | снимок копировался побайтовым циклом на C |
| + таблица яркости на стеке | 1 250 000 | 768 умножений uint16 заменены 256 сложениями |
| + memcpy для снимка | 487 000 | LDIR вместо цикла — главный выигрыш |
Из оставшихся 487 тысяч 136 тысяч — заливка палитры через BIOS (8 вызовов
`gfx_pal_load` по 17 000). Дальше можно было бы хранить готовые таблицы
яркости файлом, но при 1,2 мс на построение это уже незаметно.
ВАЖНО: ступень пересчитывается только когда она СМЕНИЛАСЬ. Наивный цикл
«ступень на каждый кадр» звал пересчёт сто раз и растягивал fade до
десяти с лишним секунд.
## 8. ПОТОКОВЫЙ ТРЕК: ФИНАЛЬНАЯ ТЕМА (2026-08-26)
`won` (56) — 115 с, 1,2 МБ, 78 страниц EMM. В память он не влезает ни при
каком бюджете, поэтому играется КОЛЬЦОМ из шести страниц (96 КБ = 9 с): насос
идёт по кругу, а `pop_music_service` дочитывает файл в те страницы, которые
насос уже прошёл.
**Кто кого догоняет.** Страница звучит 1,5 с, а читается 33 мс — запас
сорокакратный. Дистанция считается без отдельных счётчиков: страница ровно
128 блоков насоса, поэтому проигранных страниц = (всего блоков − осталось)
/ 128. Пока прочитано меньше, чем проиграно плюс размер кольца, в кольце
есть свободный слот. Файл читается ПОСЛЕДОВАТЕЛЬНО, без `lseek`.
**Что пришлось учесть.**
* Насос заворачивает страницу только при `pop_mus_ring != 0`; конец трека
по-прежнему определяет `left`. Обычный трек этой ветки не касается.
* Кольцо обязано сниматься при любом обычном запуске (`pop_music_play`,
`_stream`, `_load_begin`): иначе следующий трек играет по кругу первых
шести страниц — поймано на титрах сразу после победы.
* Живые сцены комнаты принцессы открывают CBL сами (`cut_begin`):
`pop_ending_show` глушит звук первым действием, и «arrived to princess»
(26) не звучал вовсе.
* Тема дослушивается до конца (прерывается клавишей), как `while
(check_sound_playing() && !key_test_quit())` в seg001:637. У оригинала
между титрами и этим ожиданием стоит ввод имени в таблицу рекордов —
когда он появится у нас, ожидание переедет за него (задача HOF-ENTRY).
Проверено в MAME на сборке `LEVEL=14`: после встречи с принцессой звучит
тема победы (`pop_mus_id` = 56, `pop_mus_ring` = 6), курсор уходит далеко
за размер кольца — то есть подкачка успевает.
+218
View File
@@ -0,0 +1,218 @@
# Текст в нижней статус-строке (строке HP) — полная инвентаризация SDLPoP
Разбор `SDLPoP/src/` на 2026-08-25. Цель — знать ВЕСЬ набор сообщений,
которые оригинал печатает в ту же полосу, где нарисованы деления HP,
и правила их появления/исчезновения. Это входные данные для порта:
у нас пока туда пишется только `GAME PAUSED`.
---
## 1. Геометрия: одна полоса на HP и на текст
```
rect_bottom_text = { top 193, left 70, bottom 202, right 250 } // data.h:217
display_text_bottom: draw_rect(чёрным) + show_text(halign_center, valign_bottom)
```
* Деления HP **Кида** — от `x = 0` вправо, шаг 7, максимум 10 → занимают `x 0..69`.
* Деления HP **стража** — от `x = 314` влево, шаг 7, максимум 10 → занимают `x 245..320`.
* Текст живёт РОВНО в промежутке `x 70..250` и по X с делениями не пересекается.
* По Y деления на `y = 194..200`, текст (`valign_bottom` к 202) — на `y = 195..201`,
то есть на строку ниже. Именно поэтому в оригинале текст выглядит «сидящим»
чуть ниже стрелок HP.
**У нас**: `POP_HP_Y = 194` (`pop_cdraw.h`), экран сдвинут на `POP_YOFF = 28`,
базовая линия крупного шрифта `POP_YOFF + POP_HP_Y + 8 = 230` — силуэт
ложится на `223..229`, то есть та же картинка.
## 2. Два примитива и два таймера
| Имя | Что делает |
|-----|------------|
| `display_text_bottom(text)` (seg008:2644) | стереть прямоугольник цветом 0 и напечатать текст по центру |
| `erase_bottom_text(arg)` (seg008:266D) | стереть прямоугольник; при `arg != 0` ещё и обнулить оба таймера |
| `text_time_remaining` | сколько игровых тиков сообщение ещё висит; 0 — ничего не висит |
| `text_time_total` | **идентификатор сообщения**, а не только его длительность |
Обработка тика — в `draw_game_frame`/`idle` (seg000:956). Комментарий в
оригинале прямой: *«Note: texts are identified by their total time!»* Значения
`text_time_total`, у которых есть особое поведение:
| `total` | Смысл | Что происходит по истечении |
|---------|-------|------------------------------|
| 12 | «1 SECOND LEFT» | обычное стирание |
| 24 | обычное короткое сообщение | обычное стирание |
| 36 | смерть на демо-уровне (0) или на уровне зелий (15) — **текста нет** | `start_game()` — рестарт игры |
| 288 | «Press Button to Continue» | `start_game()` — рестарт игры |
| 1188 | защита от копирования (уровень 15) | **не убывает и не исчезает** |
Мигание: при `total == 288` и `remaining < 72` сообщение мигает с периодом 12
тиков — 4 тика видно (`blink_frame <= 3`), 8 нет; в кадре `blink_frame == 3`
заново печатается текст и играет звук 38 (`sound_38_blink`).
Сброс: `init_game()` (seg003:32) обнуляет оба таймера и `is_show_time` — то есть
любое сообщение умирает на старте уровня.
---
## 3. Полный список сообщений
### 3.1. Состояние программы
| Текст | Где | Таймер |
|-------|-----|--------|
| `GAME PAUSED` | seg000:1769, пока `is_paused` | **без таймера**: печатается на входе в паузу, `erase_bottom_text(1)` на выходе (seg000:1784) |
### 3.2. Уровень и оставшееся время (`show_level` / `show_time`, seg008)
| Текст | Условие | `total` |
|-------|---------|---------|
| `LEVEL %d` | `show_level()` при старте уровня; только `1..12` (`hide_level_number_from_level = 14`), не при `seamless`; уровень 13 показывается как **12** (`level_13_level_number`) | 24, дальше сразу `is_show_time = 1` |
| `%d MINUTES LEFT` | каждая минута, кратная 5, и каждая из последних 5 | 24 |
| `%d SECONDS LEFT` | последняя минута, раз в 12 тиков | 24 |
| `1 SECOND LEFT` | остался 1 с | **12** |
| `TIME HAS EXPIRED!` | `rem_min == 0` | 24 |
| `%d MINUTES PASSED` / `1 MINUTE PASSED` | только SDLPoP (`ALLOW_INFINITE_TIME`), при отрицательном таймере | 24 |
Что взводит `is_show_time` (все → следующий кадр печатает время):
* **Space** — seg000:612, штатная клавиша оригинала «сколько осталось»;
* читы **`-`/`+` numpad** (изменение времени) — seg000:762 / 777, при этом
таймеры сообщения обнуляются, чтобы новое напечаталось немедленно;
* **смерть Джафара** — `on_guard_killed()` seg006:1936, уровень 13
(`jaffar_victory_level`): вспышка + показать время;
* истечение очередной минуты — seg008:1796;
* сразу после `show_level()`.
Обнуляет `is_show_time`: `play_kid()` при смерти (seg006:1365) и
`show_copyprot(1)` (seg000:2385).
### 3.3. Смерть Кида
| Текст | Где | `total` |
|-------|-----|---------|
| `Press Button to Continue` | `play_kid()` seg006:1383 — умер на обычном уровне | **288** (мигает, затем рестарт игры) |
| *(без текста)* | тот же код, но уровень 0 (демо) или 15 (зелья) | **36** (тихая пауза, затем рестарт игры) |
Стирается: `fell_out()` (seg006:1342, упал из комнаты 0) и чит **R**
(воскрешение, seg000:783) — оба зовут `erase_bottom_text(1)`.
### 3.4. Сохранение и загрузка
| Текст | Клавиша | `total` |
|-------|---------|---------|
| `GAME SAVED` / `UNABLE TO SAVE GAME` | Ctrl+G (`save_game`, seg000:2211) | `total` не ставится, `remaining = 24` |
| `QUICKSAVE` / `NO QUICKSAVE` | F6 (расширение SDLPoP, seg000:497) | 24 |
| `QUICKLOAD` / `NO QUICKLOAD` | F9 (расширение SDLPoP, seg000:514) | 24 |
### 3.5. Ответы на клавиши (`answer_text` → `need_show_text`, все `total = 24`)
| Текст | Клавиша |
|-------|---------|
| `SOUND ON` / `SOUND OFF` | Ctrl+S |
| `KEYBOARD MODE` | Ctrl+K |
| `JOYSTICK MODE` / `JOYSTICK NOT FOUND` / `JOYSTICK UNAVAILABLE` | Ctrl+J |
| `PRINCE OF PERSIA V1.0` (в SDLPoP заменено на `SDLPoP v%s`) | Ctrl+V |
| `SDL COMP v… LINK v…` | Ctrl+C — только SDLPoP |
### 3.6. Отладочные читы (`cheats_enabled`, `total = 24`)
| Текст | Клавиша | Смысл |
|-------|---------|-------|
| `S%d L%d R%d A%d B%d` | `C` | номер отрисованной комнаты и её соседей L/R/A/B |
| `AL%d AR%d BL%d BR%d` | Shift+`C` | диагональные соседи |
### 3.7. Защита от копирования (только уровень 15)
| Текст | Где | `total` |
|-------|-----|---------|
| `WORD %d LINE %d PAGE %d` | `show_copyprot(1)` seg000:2389 | **1188** — висит, пока не сменится уровень |
### 3.8. Только SDLPoP, в оригинале 1989 отсутствует
| Текст | Где |
|-------|-----|
| `RECORDING`, `REPLAY SAVED`, `REPLAY CANCELED` | replay.c:599/626/628 |
| имя файла скриншота | screenshot.c:62 |
---
## 4. Что из этого касается нашего порта
Реализовано (`pop_status.c`, таймер 24 тика как у оригинала):
* `GAME PAUSED` — без таймера, рисует само меню (`pop_menu.c`);
* `QUICKSAVE` / `NO QUICKSAVE`, `QUICKLOAD` / `NO QUICKLOAD` — заявка стоит
в `pop_qsave_process`, то есть в единственном месте, где известно, что
именно делали. Лейбл печатается ДО дисковой операции — осознанное
расхождение, см. `impl_diff.md`;
* `SOUND ON` / `SOUND OFF` — Ctrl+S;
* `LEVEL %d` — порт `show_level()` целиком: демо-уровень 0 и номера от 14
молчат, тринадцатый показывается двенадцатым, бесшовный переход 12→13
пропускается и гасит флаг за собой.
* вся группа времени — `N MINUTES LEFT`, `N SECONDS LEFT`, `1 SECOND LEFT`,
`TIME HAS EXPIRED!`. Флаг `pop_show_time` (порт `is_show_time`) взводит
само ядро таймера на круглых пятёрках и каждую секунду последней минуты,
а также старт уровня и читы времени; значение 2 означает «перебить
текущую строку», как оригинал делает в последнюю минуту;
* `Press Button to Continue` — висит бессрочно (`MSG_HOLD`), уровень
перезапускает кнопка. Расхождение с оригиналом, см. `impl_diff.md`.
Пока НЕ печатается:
* номера комнат (`C`/Shift+`C`) — у нас отдельная отладочная строка;
* copy protection и SDLPoP-расширения (replay, скриншоты) — не нужны.
Отладочная строка вдобавок показывает оставшееся время `##:##` у правого
края. На табло уходит `minutes-1`: у оригинала `rem_min` — это НОМЕР идущей
минуты, а не остаток целых (старт 60 при `rem_tick` 719 = «почти 60:00»).
Секунды считаются делением раз в 12 кадров, а не каждый кадр.
Нам не нужно: copy protection (уровень 15 исключён из порта — см.
`full_game_plan.md`), joystick-режимы, replay, скриншоты.
Механика, которую придётся портировать целиком, если брать группу времени:
пара таймеров `text_time_total`/`text_time_remaining` с семантикой
«идентификатор сообщения» — иначе не воспроизвести ни мигание, ни рестарт по
истечении 36/288.
---
## 5. Цена вывода и что делать, если упрёмся
Блит одного глифа стоит ~4,6 тыс. тактов почти независимо от размера — это
цена вызова, а не пикселей (memory `blit_cost_model`). Полсотни символов =
полкадра. Что уже сделано в `pop_status.c` / `pop_ui.c`:
* **change-driven**: пока показанное не изменилось, не рисуем вовсе;
* **по полям**: смена комнаты — две цифры (~9 тыс. тактов, 2% кадра), а не
вся строка; подписи рисуются только при полной инвалидации;
* **пробелы не блитятся**: их глиф целиком прозрачен, а стоит как буква —
на полной отладочной строке это девять сэкономленных блитов;
* **заливка только поля** при входе в комнату (`pop_screen_fill_field`):
борта от комнаты к комнате не меняются, это и четверть заливки, и то, что
обе полосы вход переживают.
Запас, если бюджета всё же не хватит (идеи пользователя, 2026-08-25):
1. **Растянуть вывод на несколько кадров, не показывая полуготовую строку.**
Печатать по нескольку букв за кадр, держа цвет шрифта чёрным (отдельный
индекс палитры), а по готовности подменить этот индекс на белый — строка
появится целиком и мгновенно. Стоит ноль байт памяти и укладывается в
нашу же технику «два разных чёрных» (`POP_COL_OUTSIDE`).
2. **Собирать строку в один спрайт** в свободном хвосте страницы шрифта и
блитить одним вызовом. Дороже по подготовке (~35 тыс. тактов на
копирование), но выгодно там, где строка ЦЕЛИКОМ меняется каждый раз.
Для меню этот путь уже рассматривался и был отвергнут; для статус-строк
он имеет смысл только вместе с п.1.
Про QuickSave/QuickLoad оптимизация не нужна вовсе: там игра и так стоит на
время дисковой операции.
## 6. Ловушка: свисающие глифы
Зона стирания текста обязана захватывать строку НИЖЕ базовой линии. В малом
шрифте `'p'` имеет высоту 7 при ascent 5, `','` — 6: они свисают под базовую
линию. Стирание ровно до неё оставляло от хвоста «p» в «Speed:» одинокую
точку (поймано в MAME 2026-08-25).
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More