# Аудио без перелинковки: runtime-индексы музыки и эффектов > Статус: **ОБЕ ЧАСТИ СДЕЛАНЫ 2026-08-31** — эффекты (SI0..SI4) и музыка > (MI0..MI5). Разбор и результаты: `sound_plan.md` §10 (эффекты) и §11 > (музыка). Ниже — исходный план; расхождения перечислены следом. > > Что в реализации разошлось с планом ниже: файл назван `SND/snd.idx` > (единый basename с `snd.arc`, оба в нижнем регистре), а при отсутствии > индекса поднимается ПУСТОЙ набор с блоком тишины — иначе встаёт музыка > и игра непроходима (§10.5 sound_plan). Про `ticks60` в `PMI1` решено: > поле в формате оставить даже если сценарии перейдут на ожидание звука — > загрузчик волен не тащить его в память. > > Расхождения части I с планом ниже: > * файл назван `MUS/mus.idx` (единый стиль с `snd.idx`); > * пауза конца уровня переведена НЕ на runtime-тики, а на состояние > «заявка/загрузка/звучание» (`pop_music_active`) — §11.2: это снимает и > зависимость от набора, и враньё делителя `/4` в быстрых режимах, и > расхождение с SDLPoP при выключенном звуке; > * PV-сцена (§8 плана) — не структура времён через пять функций, а четыре > якоря-статики (8 байт), от которых отсчитываются прежние выражения; > * `pages` в PMI1 не хранится — считается из `blocks`. ## 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`: ```text 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`: ```c pages = blocks / 128 + ((blocks & 127) != 0); ``` По умолчанию упаковщик вычисляет тики той же формулой, что сейчас: ```text 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` или звукового набора: это создаст ненужную связь между независимыми ресурсами. Доступ к записи предоставляет банковая функция наподобие: ```c 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-битной арифметикой: ```c 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`: ```text 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/snd.idx` + `SND/snd.arc`, без генерации C-заголовка и перелинковки EXE. Набор состоит из двух согласованных файлов: - `SND/snd.arc` — обычный архив PBA1 с PCM-страницами; - `SND/snd.idx` — описание раскладки эффектов внутри этих страниц. Отдельный IDX предпочтительнее расширения заголовка `snd.arc`: PBA1 остаётся универсальным и не получает специального варианта только для звука, а формат индекса можно независимо версионировать и проверять тем же способом, что будущий `MUS/MUSIC.IDX`. Существующий формат записи менять не требуется: ```c 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/snd.idx` IDX занимает один 512-байтовый сектор и после короткого заголовка является точным дисковым дампом 57 записей `pop_snd_tbl`: ```text 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` становится обычным изменяемым внутренним объектом: ```c 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. Фактическое число страниц Инвариантами программы остаются: ```c #define POP_SND_MAX_PAGES 16 #define POP_SND_COUNT 57 #define POP_SND_BLOCK 128 ``` `POP_SND_PAGES == 10` больше не должен означать размер конкретного набора. Вместо него появляется runtime-состояние: ```c 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/snd.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 и файловый дескриптор освобождаются, таблица не используется, эффекты остаются выключенными. Набор без `snd.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/snd.idx` в формате `PSI1`, включая фактическое число страниц; 5. собрать обычный `SND/snd.arc` из полученных страниц; 6. больше не генерировать `gen/pop_sound_tbl.h`. Чтобы число страниц не было захардкожено списком `s0.bin`..`s9.bin` в Makefile, упаковщик звука предпочтительно должен сразу формировать `assets/packed/SND/snd.arc` либо передавать упаковщику архивов динамический список результатов без сохранения устаревшей десятой страницы. Стабильные тип и константы переносятся в обычный internal-заголовок. EXE не должен зависеть от результата упаковки звука: цель выбора набора меняет согласованную пару `snd.idx` + `snd.arc`. ## 20. Этапы реализации эффектов ### SI0 — формат и host-тест - writer/reader отдельного `snd.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` из десяти имён; - сделать `snd.idx` и `snd.arc` согласованными результатами выбора набора; - проверить неизменность SHA/даты `sprpop.exe` при смене набора. ### SI4 — приёмка - проиграть короткий, обычный и переходящий страницу эффекты; - проверить приоритеты и перебивание звуков; - проверить музыку поверх общего CBL после runtime-загрузки эффектов; - отсутствующий IDX и повреждённые magic/count/pages/page/off/len должны безопасно отключать эффекты; - перед каждым MAME-прогоном завершать предыдущий экземпляр и никогда не запускать две копии одновременно. ## 21. Критерии готовности эффектов - один `sprpop.exe` работает с MSDOS- и SDLPoP-наборами; - замена согласованной пары `SND/snd.idx` + `SND/snd.arc` не требует компиляции C и не меняет EXE; - `pop_snd_tbl` больше не генерируется как C-код; - загружается фактическое число страниц в диапазоне 1..16; - таблица не расходует дополнительную страницу EMM и не увеличивает суммарный резидентный CODE+DATA на свои 285 байт; - горячий путь и ISR используют прежние прямые `page/off/len`; - повреждённый индекс не приводит к чтению за пределами EMM-блока; - файловые операции сохраняют совместимость со старыми DSS через восстановление каталога приложения.