Files
Sprinter-SDCC/applications/SprPoP/docs/ideas_backlog.md
T
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

16 KiB
Raw Blame History

Идеи и вопросы «на подумать» (PoP)

Не план работ, а список того, что осознанно отложено: каждая запись — гипотеза с причиной, по которой её стоит проверить, и с тем, что мешает сделать это прямо сейчас.

Зелье «переворот экрана» (upside-down)

Вопрос пользователя (2026-08-01). Тайлы фона у нас лежат строками, а кадры Кида/стражей — КОЛОНКАМИ (transpose_cols в pop_pack_kid.py, ради бесплатного горизонтального зеркала). Значит вертикальный переворот для персонажей заметно сложнее, чем для фона. Верно; но прежде чем это чинить, надо знать три факта.

Факт 1 — когда оно вообще нужно. Зелье переворота — тип 4 (proc_get_object, seg006.c:1885toggle_upside()). Скан всех уровней по данным (res200N.bin, тайл 10 = зелье, тип в backtable): тип 4 встречается впервые на уровне 9 (две склянки), и больше нигде. Тип 3 (перо, медленное падение) — уровень 7. То есть до уровня 9 механика не нужна вообще, и «на первом этапе просто не реализовывать» — не компромисс, а точное соответствие данным уровней 1..8.

Факт 2 — что именно делает оригинал. НЕ переворачивает спрайты. flip_screen (seg009.c:1042) → flip_not_ega (seg009.c:1023) меняет местами СТРОКИ готового offscreen-буфера (top↔bottom, порядок пикселей внутри строки не трогает — это вертикальное зеркало, не поворот на 180°). Вызывается вокруг отрисовки кадра целиком (seg003.c:296..301): перевернул буфер → дорисовал → перевернул обратно. Так что в оригинале это post-process всего экрана, и вопрос «как перевернуть колоночный спрайт» там просто не возникает.

Факт 3 — почему нам этот приём не подходит как есть. У нас нет шага «готовый offscreen → экран»: рисуем прямо в видеостраницу, а heal берёт фон из ОЗУ-копии этой же страницы. Переворот всей страницы построчно — это 320×192 Б копирования КАЖДЫЙ кадр, что мимо бюджета на порядок.

Варианты, которые надо будет взвесить (не сейчас):

  1. Предпечённые перевёрнутые атласы. Второй набор кадров Кида/стража, перевёрнутый по вертикали ещё в pop_pack_kid.py (там уже есть транспонирование — добавляется одной строкой). Рантайм: выбор набора + зеркальная арифметика Y. Память: ещё ~28 страниц EMM при бюджете ~3.3 МБ — не проблема. Похоже, самый дешёвый по тактам путь.
  2. Фон рисовать с обратным Y — для row-major тайлов строка остаётся непрерывным accel-прогоном, меняется только адрес назначения; цена — вызов на строку вместо вызова на тайл. Померить, прежде чем закладывать.
  3. Аппаратная помощь — до проектирования проверить, есть ли у акселератора направление копирования «вниз» (обратный инкремент адреса); если есть, вариант 1 может и не понадобиться. Смотреть docs/new/06-accel.md и docs/reference/accel_r.txt.

Почему не сейчас. Уровни 1..8 этого не требуют, а к уровню 9 у нас уже будет ответ на вопрос «сколько стоит кадр» (задачи CLIP-1/T-2) — без него выбирать между вариантами выше бессмысленно.

Заменить генератор псевдослучайных чисел

Сейчас стоит LCG оригинала, шаг на ассемблере (~1 020 тактов), бит-в-бит совместимый с SDLPoP. Есть более дешёвые Z80-генераторы (86–148 тактов), но потолок выигрыша — 2 814 тактов за кадр, 0.65 %, и он растворяется в обёртках вызова. Тексты процедур, разбор качества и порядок действий — prng_alternatives.md. Первый шаг там не про генератор: слить приведение к диапазону в ту же asm-процедуру, чтобы на вызов был один call, а не три.

Отключать мышь на время игры

Гипотеза. Мышь на Sprinter — источник прерываний (обёртки RST 30h, см. memory mouse_api). Игре она не нужна вообще: управление — raw-клавиатура (<kbd_raw.h>), которую мы и так забираем у DSS целиком. Значит каждое мышиное прерывание за кадр — украденные такты в бюджете, который у нас и без того занят на 86 %.

Откуда взялось (2026-07-30). При замере бюджета по 100 кадрам три кадра выбились до 552–647 К тактов (1.28–1.51 кадра) при типичных 371 К. Причиной оказалось движение мыши на ХОСТЕ: при неподвижной мыши 225 кадров подряд прошли без единого превышения. То есть эффект реальный и измеримый, просто в тесте он был наведён извне.

Что проверить.

  1. Есть ли у драйвера мыши (RST 30h) функция «выключить/включить» — разобрать список из 14 обёрток; если нет явной, посмотреть, что делает «hide cursor» и снимает ли она обработчик.
  2. Сколько тактов реально стоит одно мышиное прерывание на нашем железе (замер: breakpoint на входе ISR + totalcycles, при движении мыши).
  3. Не ломает ли отключение выход в DSS: состояние обязано восстанавливаться при exit, включая аварийный (atexit).

Почему не сейчас. Выигрыш проявляется только когда игрок реально двигает мышью, то есть в норме его нет; а риск оставить систему без мыши после выхода — заметный. Делать после того, как закроем стражей и займёмся бюджетом всерьёз (там же, где батчинг кроссбанковых вызовов и возможный возврат pop_bg в резидент --w3).

Готовить следующий уровень, пока играет мелодия конца текущего

САМАЯ ДАЛЬНЯЯ ВЕРСИЯ. Не полишинг и не порт: это улучшение ПРОТИВ оригинала. Планируем, но не раньше, чем закроем уровни и полишинг.

Идея (пользователь, 2026-08-28). Пока звучит мелодия конца уровня (и трек заставки между уровнями), экран не меняется — значит в это время можно успеть прочитать с диска следующий уровень, чтобы после музыки он появлялся сразу, а не через секунду-другую загрузки.

Что УЖЕ сделано и мерено (2026-08-28). Половина этого уже работает: трек заставки играет ПОВЕРХ загрузки уровня — так же, как в оригинале (seg003:68-108: load_intro возвращает управление, load_level() идёт под музыку, и только потом while (check_sound_playing()) на чёрном экране). Насос это переживает: 500 подряд вызовов pop_sfx_fill через всю загрузку — максимальный зазор 245 832 такта при дедлайне 251 000, ни одной пропущенной порции. Осталась вторая половина: мелодия конца уровня досиживается на ЖИВОМ экране пройденного уровня (как в оригинале, play_level_2), и только после неё начинается загрузка.

Оговорки, найденные при разборе.

  1. Загрузку придётся разрезать надвое. Сейчас pop_level_switch мешает дисковую работу (файл уровня в EMM-страницу, атласы стража и тайлсета) с экранно-палитровой (pop_bg_load переписывает записи палитры и атласы, pop_guard_load физически применяет цветовые слоты, обе страницы заливаются, pop_pal_black). Вторую половину НЕЛЬЗЯ выполнять, пока на экране ещё живёт пройденный уровень — иначе палитра поедет прямо на картинке. То есть в «музыкальное окно» можно вынести только диск, а всё, что трогает палитру и VRAM, остаётся после.
  2. Бюджет кадра. Ожидание мелодии идёт в игровом цикле (гейт pop_endmus_left + pop_music_busy()), кадр там обычный, 80-100 мс. Шаг подкачки должен быть такого же размера, как музыкальный — полстраницы (~16 мс), а не страница целиком; иначе кадры на экране пройденного уровня начнут дёргаться.
  3. Память. Страницы следующего уровня придётся держать одновременно со страницами текущего — проверить EMM-бюджет (memory sprinter_emm_budget), тяжёлые тут не файл уровня, а атласы тайлсета и стража.
  4. Риск невелик, но он в самом хрупком месте. Палитро-атласный обмен — источник уже пойманных багов (PAL-DUNGEON-STALE, PAL-L1-AFTER-INTRO), и трогать его ради экономии секунды стоит только на спокойную голову.

Выигрыш. Одна-две секунды один раз на уровень.

Музыка одним постоянно открытым архивом (замер 2026-08-31, НЕ сейчас)

Идея. Сейчас каждый трек — отдельный файл MUS/mNN.bin, и pop_music_load_begin открывает свой на каждый запуск. Свести треки в один файл и держать его fd открытым: вместо chdir+open+chdir останется lseek к смещению трека.

Почему это стоит внимания — разложение окна старта музыки (полный замер и метод — sound_plan.md §9):

шаг цена уйдёт?
ожидание pop_music_service в кадре 22,6 мс нет
chdir #1 46,1 мс да
open файла трека 34,5 мс да
chdir #2 59,0 мс да
чтение первых 8 КБ 34,0 мс нет
итого 196,7 мс

Уходит 139,6 мс — но это ВЕРХНЯЯ граница: появляется lseek к смещению трека, которого сейчас нет вовсе (файл читается последовательно с нуля), и его цена НЕ ИЗМЕРЕНА. Реальный выигрыш = 139,6 минус lseek; померить можно тем же способом, lseek уже используется в pop_arc.c:89. Остаётся ≈ 57 мс, то есть старт трека ускоряется примерно вчетверо.

Где заметно. Реплики PV-сцены идут встык (три трека подряд), плюс каждый игровой джингл — смерть, зелье, подобранный меч.

Чем осложнено.

  1. PBA1 под это не годится. Размер элемента там uint16 (≤ 64 КБ), а трек — до 1,2 МБ (m56, 78 страниц); резать по страницам нельзя, элементов вышло бы ~240 при POP_ARC_MAX = 126. Нужен свой индекс со смещениями — то есть эта задача СМЫКАЕТСЯ с MUS/MUSIC.IDX из music_runtime_index_plan.md: одно изменение раскладки, а не два.
  2. Постоянно занятый файловый манипулятор — один из восьми (memory dss_fd_limit, девятый open вешает DSS). Рядом свои открывают POP.CFG, quicksave и загрузка уровня.
  3. chdir убрать нельзя — он и есть половина выигрыша, но нужен старым DSS (POP_PATH_CALL); экономия берётся не его удалением, а тем, что открытие вообще перестаёт выполняться на каждый трек.

Оговорка. Задержка старта музыки сама по себе НЕ является дефектом: трек начинается на границе события, на слух это не сбой. У эффектов такой задержки нет вовсе — они целиком в EMM (см. sound_plan.md §9.1). Так что это оптимизация, а не исправление.