Files
Sprinter-SDCC/applications/SprPoP/docs/music_runtime_index_plan.md
T
snark13 ea8efdb0fd SprPoP: обобщить HDD-сборку и очистить метаданные
Добавить общий каталог назначения для HDD и удалить локальную копию упаковщика.\n\nУбрать устаревшие generated-имена ресурсов, выводить число страниц Kid из kid.arc и ограничить звуковую таблицу горячим модулем.\n\nЗафиксировать планы runtime-индексов музыки и PCM-эффектов.
2026-08-30 16:16:06 +03:00

33 KiB
Raw Blame History

Аудио без перелинковки: runtime-индексы музыки и эффектов

Статус: план будущего улучшения, 2026-08-30. Код пока не менялся.

1. Цель

Один и тот же sprpop.exe должен корректно работать с любым штатным набором музыки (flac, mp3, ogg, mt32) без перекомпиляции и перелинковки. Замена набора должна сводиться к замене файлов в MUS/ вместе с описывающим их индексом.

Сейчас это невозможно: упаковщик печатает зависящие от набора значения в gen/pop_music_tbl.h и gen/pop_music_ticks.h, после чего они становятся частью EXE. Если заменить только MUS/mNN.bin, загрузчик продолжает верить размерам старого набора, а сценарии — его длительностям.

2. Подтверждённое текущее состояние

После преобразования в PCM наборы flac, mp3 и ogg имеют одинаковые длины. Исполнение MT-32 отличается. Значимые примеры:

трек FLAC/MP3/OGG MT-32
m32, конец уровня 4 438 тиков 449 тиков
m41, обычный конец уровня 732 685
m50, принцесса ждёт 831 867
m53, реплика Джафара 985 1044
m56, финальная тема 9865 блоков / 78 страниц 10462 / 82

Все обычные треки каждого из четырёх наборов по-прежнему помещаются в POP_MUS_PAGES == 20. Только m56 требует кольцевого проигрывателя. uint16_t достаточно и для самого длинного MT-32-трека: 10462 блока.

Compile-time длительности используются не повсюду:

  • pop_music.c читает из pop_music_tbl.h наличие, число страниц и блоков;
  • pop_intro.c использует длительности m50 и m53 для шкалы PV-сцены;
  • pop_kid.c использует длительности m32 и m41 для паузы конца уровня, включая режим с выключенной музыкой;
  • остальные ожидания уже опираются на pop_music_busy() и автоматически заработают с правильным runtime-числом блоков.

3. Что должно остаться константами программы

В индекс переносятся только свойства конкретного набора. Инварианты формата остаются в коде, а заголовок IDX лишь подтверждает их:

  • PCM unsigned 8-bit mono;
  • частота CBL_FREQ_10K9;
  • один блок насоса — 128 байт;
  • пространство оригинальных sound id — 0..56;
  • POP_MUS_PAGES == 20 для обычного загрузчика;
  • размер кольца потокового проигрывателя.

Это важно: IDX не должен обещать программе другой формат PCM, который насос физически не умеет воспроизводить.

4. Предлагаемый файл MUS/MUSIC.IDX

Предпочтителен отдельный индекс набора, а не заголовок в каждом треке:

  • все сценарные длительности доступны сразу после старта;
  • mNN.bin остаются простыми сырыми PCM-потоками;
  • загрузчик трека меняется минимально;
  • один индекс легко заменить вместе с набором;
  • формат помещается в один 512-байтовый сектор DSS.

Предлагаемая версия PMI1:

0..3    "PMI1"       magic и версия
4       57           число плотных записей (id 0..56)
5       4            размер записи
6       7            log2 размера блока: 1 << 7 = 128
7       1            формат PCM: u8 mono, CBL_FREQ_10K9
8..15   0            резерв будущих версий

16..    57 записей по 4 байта:
        +0..1 uint16 blocks   длина PCM в блоках по 128 байт
        +2..3 uint16 ticks60  длительность ожидания в тиках оригинала

244..511             нулевой резерв до одного сектора

Нулевые blocks и ticks60 означают, что трека с таким id в наборе нет.

pages в IDX не хранится: это производная величина, и её дублирование может разойтись с blocks:

pages = blocks / 128 + ((blocks & 127) != 0);

По умолчанию упаковщик вычисляет тики той же формулой, что сейчас:

ticks60 = round(blocks * 40 / 57)

Хранить ticks60 отдельно всё же полезно: в будущем момент окончания сценарной реплики можно будет уточнить независимо от технического хвоста PCM. Генератор обязан печатать предупреждение, если явно заданные тики заметно отличаются от длительности файла.

5. Размещение индекса в памяти

Загружать таблицу в обычный изменяемый static нельзя. Данные банковых модулей сейчас попадают в общий _DATA/W2, а свободная куча составляет около 238 байт. Даже таблица из 228 байт практически уничтожит этот запас.

Индекс следует держать в одной EMM-странице:

  1. pop_music_init() выделяет блок из одной страницы;
  2. загружает туда первые 512 байт MUS\MUSIC.IDX;
  3. проверяет magic, версию, размеры полей и формат PCM;
  4. готовит страницу для безопасного временного отображения в W0;
  5. сохраняет в W2 только номер блока/страницы и флаг готовности.

Потеря 16 КБ EMM ради маленькой таблицы допустима: EMM у игры с запасом, а W1/W2 — самый дефицитный ресурс. Не следует ради экономии страницы прятать индекс в хвост kid.ani или звукового набора: это создаст ненужную связь между независимыми ресурсами.

Доступ к записи предоставляет банковая функция наподобие:

int8_t pop_music_info(uint8_t id, pop_music_info_t *out) __banked;

Она на короткое время отображает страницу IDX в W0, копирует четыре байта в буфер вызывающего и сразу восстанавливает окно. Наружу указатель на EMM не выдаётся.

Индекс живёт до выхода из программы. pop_music_free(), который вызывается между сценами, освобождать его не должен; для полного завершения нужен отдельный shutdown либо освобождение в общем маршруте выхода.

6. Изменения загрузчика музыки

pop_music_load_begin(id) должен получать из runtime-индекса:

  • наличие трека;
  • число блоков;
  • вычисленное число EMM-страниц.

Далее существующая архитектура почти не меняется:

  • slot_blocks[] уже хранит runtime-длину загруженного трека;
  • pop_mus_left получает её при play/stream;
  • насос сам останавливается на правильном блоке;
  • pop_music_busy() автоматически отражает фактический конец;
  • кольцевой проигрыватель получает правильные 82 страницы MT-32 m56 вместо 78 страниц FLAC.

При каждом открытии mNN.bin надо без дополнительного open проверить фактический размер:

  1. lseek(fd, 0, SEEK_END);
  2. убедиться, что размер положительный и кратен 128;
  3. сравнить его с blocks * 128 из IDX;
  4. вернуть позицию через lseek(fd, 0, SEEK_SET).

lseek(SEEK_END) уже реализован в libc поверх DSS MOVE_FP $15 и используется самой игрой для POP.CFG.

Рекомендуемая политика несовпадения:

  • вывести диагностическое сообщение;
  • считать фактический размер файла главным;
  • пересчитать blocks/pages/ticks60 в загруженной EMM-копии индекса;
  • продолжить работу, если размер проходит ограничения.

Так случайно забытый старый IDX не приведёт к чтению чужой EMM-страницы или обрыву трека. При этом штатная поставка обязана всегда включать согласованные IDX и PCM.

7. Runtime-тики без 32-битного переполнения

Если тики приходится восстанавливать из фактического размера, прямое blocks * 40 может переполнить uint16_t. Та же формула считается только 16-битной арифметикой:

ticks = (blocks / 57) * 40
      + ((blocks % 57) * 40 + 28) / 57;

Даже при blocks == 65535 промежуточные значения остаются в uint16_t. Это холодный путь, поэтому небольшая цена деления допустима.

8. Перевод сценариев с compile-time на runtime

PV-сцена (pop_intro.c)

Текущий большой enum смешивает два класса величин:

  • неизменные интервалы сценария;
  • абсолютные точки, сдвигаемые длительностями m50 и m53.

Надо сохранить относительные сценарные константы, а при входе в PV-сцену один раз собрать локальную структуру pv_timing_t:

m50_end       = MUS_2_START + ticks(m50)
wait_end      = m50_end + 40
...
dialog1_start = предыдущая фиксированная цепочка
exit_start    = dialog1_start + ticks(m53)
anim_end      = exit_start + 469

Структуру лучше держать на стеке intro_pv_animated() и передавать нужным helper-функциям указателем. File-scope изменяемая таблица снова попала бы в W2.

m50 уже загружен до начала PV. m53 начинает подгружаться задолго до своего старта, поэтому метаданные обоих треков к моменту использования доступны.

Конец уровня (pop_kid.c)

Вместо POP_MUS_TICKS_32/41 редкое событие окончания уровня вызывает банковый accessor IDX и вычисляет pop_endmus_left из runtime-тиков.

Индекс используется даже при выключенной музыке: оригинал выдерживает эту паузу молча. Поэтому нельзя заменять её одним pop_music_busy().

9. Генератор и сборка

tools/pop_pack_music.py должен:

  1. как сейчас преобразовать выбранный набор в mNN.bin;
  2. проверить кратность каждого результата 128 байтам;
  3. собрать плотные записи 0..56;
  4. записать MUS/MUSIC.IDX в формате PMI1;
  5. больше не генерировать pop_music_tbl.h и pop_music_ticks.h.

Makefile должен:

  • добавить MUS/MUSIC.IDX в DISK и staging;
  • убрать музыкальные generated-header'ы из GEN_H;
  • убрать их из зависимостей EXE;
  • оставить MUSIC_FMT зависимостью только музыкальных ресурсов;
  • гарантировать, что make music-flac/mp3/ogg/mt32 заменяет и PCM, и IDX, но не перелинковывает EXE.

Критерий архитектуры: SHA/дата sprpop.exe не меняется при переключении между четырьмя музыкальными целями.

10. Ошибки и совместимость

Игра не должна падать из-за необязательной музыки:

  • нет IDX — музыка отключена, игра продолжает работать;
  • неверная magic/версия/формат — музыка отключена с диагностикой;
  • записи нет — конкретный трек считается отсутствующим;
  • файла нет — запрос трека завершается молча/с диагностикой, как сейчас;
  • обычный трек требует больше 20 страниц — не загружать обычным путём;
  • потоковый трек имеет больше 255 страниц — отвергнуть, потому что текущие счётчики страниц восьмибитные;
  • IDX и файл расходятся — применить политику §6;
  • все файловые операции выполнять через POP_PATH_CALL, чтобы сохранить работу на старых DSS с повреждением текущего каталога.

Для отсутствующего/повреждённого IDX остаётся выбрать поведение немой паузы конца уровня: нулевая пауза либо небольшой канонический fallback. Это не мешает основной архитектуре, но решение надо принять до реализации.

11. Этапы реализации

MI0 — формат и host-тест

  • вынести writer/reader PMI1 в тестируемый код упаковщика;
  • проверить magic, размеры, LE-поля, нулевые записи и padding;
  • для всех четырёх наборов сверить blocks/ticks с фактическими PCM;
  • зафиксировать тестом значения MT-32 m50/m53/m56 как отличающиеся от FLAC.

MI1 — runtime-загрузка IDX

  • pop_music_init()/shutdown;
  • одна EMM-страница, загрузка и валидация;
  • accessor одной записи;
  • отказ без порчи W0/W3, EMM и файловых дескрипторов.

MI2 — loader/pump

  • заменить pop_mus_tbl[] runtime-записью;
  • проверять реальный размер файла;
  • обычный, немедленный и кольцевой пути должны использовать одну метаинформацию;
  • удалить pop_music_tbl.h.

MI3 — runtime-шкала сцен

  • перевести m50/m53 в pop_intro.c на локальную runtime-шкалу;
  • перевести m32/m41 в pop_kid.c;
  • удалить pop_music_ticks.h.

MI4 — сборка и образы

  • добавить IDX в каждый музыкальный набор;
  • исключить музыку из зависимостей линковки;
  • собрать четыре HDD-варианта с одним EXE.

MI5 — приёмка в MAME/на железе

  • FLAC: title -> story -> PV, обычный конец уровня, конец уровня 4, ending;
  • MT-32: те же маршруты, особенно m50/m53 и полный m56;
  • музыка выключена: пауза конца уровня берётся из IDX;
  • замена набора без пересборки EXE;
  • повреждённый IDX, отсутствующий трек, несовпадающий размер;
  • контроль, что не запущено более одного MAME и предыдущий экземпляр закрыт перед новым прогоном.

12. Критерии готовности

  • один бинарник запускается со всеми четырьмя наборами;
  • ни один музыкальный generated-header не входит в сборку C;
  • начало следующей сцены/уровня соответствует фактической записи;
  • MT-32 m56 проигрывает все 82 страницы и не обрывается как FLAC-вариант;
  • обычные треки не читают за пределами выделенного EMM-блока;
  • IDX не расходует сотни байт W2;
  • ошибочный набор отключает музыку безопасно;
  • переключение music-* не меняет EXE;
  • старые DSS продолжают работать через восстановление каталога приложения.

Часть II. Звуковые эффекты без перелинковки

13. Цель и отличие от музыки

Один и тот же sprpop.exe должен работать с разными наборами PCM-эффектов, если в них сохранена исходная нумерация sound id 0..56. Замена набора должна сводиться к замене согласованной пары SND/SOUND.IDX + SND/SND.ARC, без генерации C-заголовка и перелинковки EXE.

Набор состоит из двух согласованных файлов:

  • SND/SND.ARC — обычный архив PBA1 с PCM-страницами;
  • SND/SOUND.IDX — описание раскладки эффектов внутри этих страниц.

Отдельный IDX предпочтительнее расширения заголовка snd.arc: PBA1 остаётся универсальным и не получает специального варианта только для звука, а формат индекса можно независимо версионировать и проверять тем же способом, что будущий MUS/MUSIC.IDX.

Существующий формат записи менять не требуется:

typedef struct {
    uint8_t  page;
    uint16_t off;
    uint16_t len;
} pop_snd_ent_t;

Это ровно необходимые проигрывателю номер логической страницы, смещение в ней и длина PCM. В текущем ABI SDCC/z80 запись занимает 5 байт; дисковый формат обязан описывать эти пять байт явно (uint16 little-endian), а код должен проверять sizeof(pop_snd_ent_t) == 5 на этапе сборки.

14. Подтверждённое текущее состояние

Сейчас tools/pop_pack_sound.py печатает раскладку в gen/pop_sound_tbl.h, а pop_sfx.c включает её как static const pop_snd_tbl[57]. Поэтому конкретные page/off/len становятся частью EXE.

В текущей сборке таблица занимает 285 байт в _CODE, по адресам 0x52D8..0x53F4. Это общий резидентный диапазон игры: при huge-модели _CODE, _DATA, heap и stack совместно используют плоские 32 КБ 0x4000..0xBFFF; граница W1/W2 отдельного бюджета здесь не создаёт.

Проверены оба имеющихся исходных набора DIGISND:

набор PCM-эффектов страниц после упаковки отличия раскладки
MSDOS 1.3/1.4 31 из 57 10 эталон текущей сборки
SDLPoP 1.0/1.1 31 из 57 9 отличаются 8 id

Максимальный эффект занимает 17 664 байта, или 138 блоков по 128 байт. У SDLPoP эффект 48 содержит только 7 исходных сэмплов; это свойство самого набора, а не ошибка runtime-индекса.

15. Файл SND/SOUND.IDX

IDX занимает один 512-байтовый сектор и после короткого заголовка является точным дисковым дампом 57 записей pop_snd_tbl:

0..3       "PSI1"       magic и версия индекса эффектов
4          57           число плотных записей, id 0..56
5          5            размер записи
6          7            log2 размера блока: 1 << 7 = 128
7          1            формат: unsigned 8-bit mono, CBL_FREQ_10K9
8          pages        фактическое число PCM-страниц в snd.arc
9..15      0            резерв

16..300    57 записей по 5 байт:
            +0    uint8  page
            +1..2 uint16 off, little-endian
            +3..4 uint16 len, little-endian

301..511   0            резерв до одного сектора

pages — фактический размер конкретного набора, например 10 для MSDOS или 9 для SDLPoP. Его не следует называть или трактовать как POP_SND_MAX_PAGES: максимум 16 является compile-time-вместимостью загрузчика, а IDX сообщает число реально нужных страниц и обязан укладываться в этот предел.

Загрузчик сверяет pages с числом элементов внешнего PBA1 и не доверяет расходящимся файлам. off и len обязаны быть кратны 128; нулевая длина означает отсутствие PCM для данного id.

IDX загружается прямо в резидентный pop_snd_tbl, поэтому отдельной страницы EMM для него не требуется. Цена решения — одно дополнительное открытие и чтение 512 байт при старте звука; на фоне загрузки 9–10 страниц PCM это приемлемый холодный расход.

16. Размещение runtime-таблицы в памяти

pop_snd_tbl становится обычным изменяемым внутренним объектом:

pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT];

Его следует определить в отдельном internal data-модуле и объявить через extern в _pop_sfx.h, потому что таблицу заполняет холодный загрузчик из банка 8, а читает резидентный pop_sfx.c.

После снятия const SDCC перенесёт эти 285 байт из _CODE в _DATA. Это не добавляет 285 байт к общему резидентному расходу: _CODE одновременно уменьшается на тот же размер, а _CODE и _DATA последовательно лежат в одном диапазоне 0x4000..0xBFFF. Возможна лишь небольшая разница из-за выравнивания, которую надо проверить итоговой map-картой и size-check.

Банк 8 может читать индекс напрямую в этот буфер: весь диапазон 0x4000..0xBFFF остаётся доступен, пока банковый код исполняется в W3. Горячие обращения pop_snd_tbl[id].page/off/len и ISR не требуют новых маппингов или accessor-функций.

17. Фактическое число страниц

Инвариантами программы остаются:

#define POP_SND_MAX_PAGES 16
#define POP_SND_COUNT     57
#define POP_SND_BLOCK     128

POP_SND_PAGES == 10 больше не должен означать размер конкретного набора. Вместо него появляется runtime-состояние:

uint8_t pop_snd_pages;
uint8_t pop_snd_page[POP_SND_MAX_PAGES];

Загрузчик выделяет mem_alloc_pages(pop_snd_pages) и заполняет только фактическое число элементов массива. MSDOS-набор займёт 10 страниц, SDLPoP-набор — 9. Предел 16 оставляет запас будущим наборам без перекомпиляции и увеличивает постоянный массив лишь на 6 байт относительно текущего; ещё один байт занимает pop_snd_pages.

Насос при переходе длинного эффекта через границу страницы сравнивает sfx_pg + 1 с pop_snd_pages, а не с compile-time-константой.

18. Загрузка и валидация

pop_sfx_init() должен выполнять следующую последовательность:

  1. открыть SND/SOUND.IDX через существующий путь POP_PATH_CALL;
  2. прочитать 16-байтовый заголовок, затем 285 байт записей прямо в pop_snd_tbl, и проверить полный размер IDX;
  3. проверить magic, версию, число/размер записей, формат и размер блока;
  4. получить из IDX фактическое число страниц и проверить диапазон 1..16;
  5. открыть SND/SND.ARC через POP_PATH_CALL;
  6. прочитать обычную таблицу PBA1 с ёмкостью POP_SND_MAX_PAGES;
  7. сверить PSI1.pages с числом элементов PBA1;
  8. проверить каждую ненулевую запись;
  9. только после этого выделить фактическое число EMM-страниц и загрузить их.

Для каждой записи проверяются:

  • page < pop_snd_pages;
  • off < 16384 и off % POP_SND_BLOCK == 0;
  • len % POP_SND_BLOCK == 0;
  • диапазон page/off/len не выходит за загруженный набор;
  • переход длинного эффекта на следующие страницы не превышает pop_snd_pages.

При любой ошибке блок EMM и файловый дескриптор освобождаются, таблица не используется, эффекты остаются выключенными. Набор без SOUND.IDX считается неполным; хранить в EXE старую таблицу как fallback не следует, иначе зависимость бинарника от конкретного набора останется.

19. Генератор и сборка

tools/pop_pack_sound.py должен:

  1. как сейчас разобрать DIGISND, привести PCM к CBL_FREQ_10K9 и выровнять начала/длины на 128 байт;
  2. вычислить фактическое число страниц и отвергнуть набор больше 16;
  3. сформировать плотные 57 записей page/off/len;
  4. записать SND/SOUND.IDX в формате PSI1, включая фактическое число страниц;
  5. собрать обычный SND/SND.ARC из полученных страниц;
  6. больше не генерировать gen/pop_sound_tbl.h.

Чтобы число страниц не было захардкожено списком s0.bin..s9.bin в Makefile, упаковщик звука предпочтительно должен сразу формировать assets/packed/SND/snd.arc либо передавать упаковщику архивов динамический список результатов без сохранения устаревшей десятой страницы.

Стабильные тип и константы переносятся в обычный internal-заголовок. EXE не должен зависеть от результата упаковки звука: цель выбора набора меняет согласованную пару SOUND.IDX + SND.ARC.

20. Этапы реализации эффектов

SI0 — формат и host-тест

  • writer/reader отдельного SOUND.IDX в формате PSI1;
  • проверка точного пятибайтового LE-формата записи;
  • сборка и разбор обоих имеющихся DIGISND-наборов;
  • проверки 10 страниц MSDOS, 9 страниц SDLPoP и отличающихся записей.

SI1 — runtime-таблица

  • изменяемый pop_snd_tbl в общем резидентном CODE/DATA-диапазоне;
  • загрузка и полная валидация PSI1;
  • удаление generated pop_sound_tbl.h;
  • проверка map-карты: перенос CODE -> DATA не должен съесть heap.

SI2 — гибкое выделение страниц

  • POP_SND_MAX_PAGES == 16 и runtime pop_snd_pages;
  • фактический размер стека таблицы PBA1;
  • выделение/освобождение 9, 10 и тестовых 16 страниц;
  • runtime-гард перехода длинного эффекта между страницами.

SI3 — сборка без перелинковки

  • убрать фиксированный SND_ATL из десяти имён;
  • сделать SOUND.IDX и SND.ARC согласованными результатами выбора набора;
  • проверить неизменность SHA/даты sprpop.exe при смене набора.

SI4 — приёмка

  • проиграть короткий, обычный и переходящий страницу эффекты;
  • проверить приоритеты и перебивание звуков;
  • проверить музыку поверх общего CBL после runtime-загрузки эффектов;
  • отсутствующий IDX и повреждённые magic/count/pages/page/off/len должны безопасно отключать эффекты;
  • перед каждым MAME-прогоном завершать предыдущий экземпляр и никогда не запускать две копии одновременно.

21. Критерии готовности эффектов

  • один sprpop.exe работает с MSDOS- и SDLPoP-наборами;
  • замена согласованной пары SND/SOUND.IDX + SND/SND.ARC не требует компиляции C и не меняет EXE;
  • pop_snd_tbl больше не генерируется как C-код;
  • загружается фактическое число страниц в диапазоне 1..16;
  • таблица не расходует дополнительную страницу EMM и не увеличивает суммарный резидентный CODE+DATA на свои 285 байт;
  • горячий путь и ISR используют прежние прямые page/off/len;
  • повреждённый индекс не приводит к чтению за пределами EMM-блока;
  • файловые операции сохраняют совместимость со старыми DSS через восстановление каталога приложения.