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

582 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Аудио без перелинковки: runtime-индексы музыки и эффектов
> Статус: **часть II (эффекты, SI0..SI4) СДЕЛАНА 2026-08-31** — разбор и
> результаты в `sound_plan.md` §10. Часть I (музыка, MI0..MI5) — план.
>
> Что в реализации разошлось с планом ниже: файл назван `SND/snd.idx`
> (единый basename с `snd.arc`, оба в нижнем регистре), а при отсутствии
> индекса поднимается ПУСТОЙ набор с блоком тишины — иначе встаёт музыка
> и игра непроходима (§10.5 sound_plan). Про `ticks60` в `PMI1` решено:
> поле в формате оставить даже если сценарии перейдут на ожидание звука —
> загрузчик волен не тащить его в память.
## 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 через
восстановление каталога приложения.