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
This commit is contained in:
2026-08-31 16:14:53 +03:00
parent 589894c50d
commit 2349481b86
19 changed files with 1077 additions and 207 deletions
+126
View File
@@ -0,0 +1,126 @@
"""pop_idx.py — ДИСКОВЫЕ ИНДЕКСЫ аудио-наборов SprPoP.
Зачем. Раскладка звукового набора (какой эффект в какой странице, по
какому смещению и какой длины) сегодня печатается упаковщиком в
gen/pop_sound_tbl.h и становится частью EXE. Из-за этого замена набора
(SDLPoP <-> MSDOS) требует перекомпиляции игры. Индекс переносит эту
раскладку НА ДИСК: пара `SND/snd.idx` + `SND/snd.arc` меняется целиком,
а бинарник остаётся прежним.
Формат читают ДВЕ стороны — этот упаковщик и загрузчик на Z80, — поэтому
писатель и разборщик живут в одном месте и покрыты тестом
(tools/tests/test_pop_idx.py). Числа little-endian: у Z80 это родной
порядок, и разбор на нём — просто чтение байтов подряд.
PSI1 — индекс ЭФФЕКТОВ, ровно один сектор DSS (512 Б):
0..3 'PSI1' магия и версия
4 57 число плотных записей, id 0..56
5 5 размер записи
6 7 log2 размера блока: 1 << 7 = 128
7 1 формат PCM: 8 бит без знака, моно, CBL_FREQ_10K9
8 pages сколько PCM-страниц в снд-архиве (1..16)
9..15 0 резерв
16..300 57 записей по 5 байт: page (u8), off (u16), len (u16)
301..511 0 добивка до сектора
Размер записи 5 — это НЕ произвольное число, а sizeof(pop_snd_ent_t) в
ABI SDCC z80 (uint8 + uint16 + uint16 без выравнивания). Загрузчик
читает записи ПРЯМО в резидентную таблицу, поэтому расхождение здесь
молча разъедет всю раскладку; на стороне C это проверяется статически.
Нулевая `len` означает, что у этого id нет оцифровки (это музыка).
"""
import struct
SECTOR = 512
# --- PSI1: эффекты ---------------------------------------------------------
SND_MAGIC = b"PSI1"
SND_COUNT = 57 # пространство id оригинала: 0..56
SND_REC = 5 # sizeof(pop_snd_ent_t) в ABI SDCC z80
SND_HDR = 16
SND_MAX_PAGES = 16 # compile-time вместимость загрузчика
BLOCK = 128 # блок запроса CBL при 8 бит
BLOCK_LOG2 = 7
PCM_U8_MONO_10K9 = 1 # единственный формат, который умеет наш насос
PAGE = 16384
class IdxError(Exception):
"""Индекс не разобрался: битый, чужой версии или противоречивый."""
def snd_pack(entries, pages):
"""Собрать PSI1 из 57 записей (page, off, len) и числа страниц."""
if len(entries) != SND_COUNT:
raise IdxError("записей должно быть %d, а не %d"
% (SND_COUNT, len(entries)))
if not 1 <= pages <= SND_MAX_PAGES:
raise IdxError("страниц %d — вне 1..%d" % (pages, SND_MAX_PAGES))
out = bytearray(SECTOR)
out[0:4] = SND_MAGIC
out[4] = SND_COUNT
out[5] = SND_REC
out[6] = BLOCK_LOG2
out[7] = PCM_U8_MONO_10K9
out[8] = pages
for i, (page, off, ln) in enumerate(entries):
_check_entry(i, page, off, ln, pages)
struct.pack_into("<BHH", out, SND_HDR + i * SND_REC, page, off, ln)
return bytes(out)
def snd_unpack(data):
"""Разобрать PSI1 -> (записи, pages). Проверки те же, что у Z80."""
if len(data) != SECTOR:
raise IdxError("размер %d, а должен быть сектор %d" % (len(data), SECTOR))
if data[0:4] != SND_MAGIC:
raise IdxError("не PSI1: %r" % (bytes(data[0:4]),))
if data[4] != SND_COUNT:
raise IdxError("записей %d, ожидалось %d" % (data[4], SND_COUNT))
if data[5] != SND_REC:
raise IdxError("размер записи %d, ожидался %d" % (data[5], SND_REC))
if data[6] != BLOCK_LOG2:
raise IdxError("log2 блока %d, ожидался %d" % (data[6], BLOCK_LOG2))
if data[7] != PCM_U8_MONO_10K9:
raise IdxError("формат PCM %d — насос умеет только %d"
% (data[7], PCM_U8_MONO_10K9))
pages = data[8]
if not 1 <= pages <= SND_MAX_PAGES:
raise IdxError("страниц %d — вне 1..%d" % (pages, SND_MAX_PAGES))
entries = []
for i in range(SND_COUNT):
page, off, ln = struct.unpack_from("<BHH", data, SND_HDR + i * SND_REC)
_check_entry(i, page, off, ln, pages)
entries.append((page, off, ln))
return entries, pages
def _check_entry(i, page, off, ln, pages):
"""Одна запись: то же, что обязан проверить загрузчик перед EMM.
ПЕРВЫЙ БЛОК СТРАНИЦЫ 0 — ТИШИНА. Насос льёт её, когда играть нечего
(POP_SND_SILENCE_PAGE/OFF), поэтому набор не имеет права положить туда
эффект: иначе вместо паузы будет обрывок звука по кругу.
"""
if ln == 0:
if (page, off) != (0, 0):
raise IdxError("запись %d пуста, но page/off не нулевые" % i)
return
if page >= pages:
raise IdxError("запись %d: page %d >= pages %d" % (i, page, pages))
if off >= PAGE:
raise IdxError("запись %d: off %d вне страницы" % (i, off))
if off % BLOCK or ln % BLOCK:
raise IdxError("запись %d: off/len не кратны блоку %d" % (i, BLOCK))
if page == 0 and off < BLOCK:
raise IdxError("запись %d залезает в блок тишины (стр.0, off 0)" % i)
# Длинный эффект переливается на следующие страницы — они обязаны быть.
last = (page * PAGE + off + ln - 1) // PAGE
if last >= pages:
raise IdxError("запись %d выходит за набор: кончается в стр.%d из %d"
% (i, last, pages))