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:
@@ -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))
|
||||
Reference in New Issue
Block a user