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:
@@ -5,9 +5,10 @@ assets/, gen/ и build/.
|
||||
|
||||
Раскладка:
|
||||
|
||||
assets/orig/ ОРИГИНАЛЬНЫЕ данные (SDLPoP, MSDOS, записи музыки).
|
||||
В репозиторий не идут — копирайт; откуда их взять,
|
||||
написано в assets/orig/README.md.
|
||||
assets/orig/ ОРИГИНАЛЬНЫЕ данные (SDLPoP, записи музыки и,
|
||||
необязательно, MSDOS). В репозиторий не идут —
|
||||
копирайт; выкачивает их tools/fetch_orig.py
|
||||
(`make fetch`), см. assets/orig/README.md.
|
||||
build/atl/<set>/ промежуточный выхлоп упаковщиков: отдельные атласы,
|
||||
из которых потом склеиваются архивы. Пересоздаётся,
|
||||
в репозиторий не идёт.
|
||||
@@ -31,6 +32,7 @@ SDLPOP = ORIG / "SDLPoP"
|
||||
SDLPOP_DATA = SDLPOP / "data"
|
||||
SDLPOP_SRC = SDLPOP / "src"
|
||||
MSDOS = ORIG / "MSDOS"
|
||||
|
||||
MUSIC_ROOT = ORIG / "PoP1_DOS_music"
|
||||
# Записи DOS-версии лежат в четырёх вариантах. Имена треков во всех наборах
|
||||
# одинаковые, различаются каталог и расширение; ogg_MT-32 — та же музыка в
|
||||
@@ -52,6 +54,44 @@ def music_src(fmt="flac"):
|
||||
sub, ext = MUSIC_FORMATS[fmt]
|
||||
return MUSIC_ROOT / sub, ext
|
||||
|
||||
# --- источники звуковых эффектов ------------------------------------------
|
||||
# Контейнеры digisnd1..3 есть в ДВУХ поставках, и они не одинаковы: в SDLPoP
|
||||
# лежит версия из репозитория (её и качает fetch_orig.py), в MSDOS/ — из
|
||||
# оригинального дистрибутива 1.0, если пользователь его себе положил.
|
||||
# ПО УМОЛЧАНИЮ БЕРЁМ SDLPoP: сборка обязана работать без MSDOS вообще.
|
||||
# Ключ = значение SND_SRC в Makefile; регистр имён в поставках разный.
|
||||
SND_SOURCES = {
|
||||
"sdlpop": (SDLPOP_DATA, "DIGISND%d.DAT"),
|
||||
"msdos": (MSDOS, "digisnd%d.dat"),
|
||||
}
|
||||
|
||||
|
||||
def snd_src(name="sdlpop"):
|
||||
"""(каталог, шаблон имени) контейнеров оцифровки для источника."""
|
||||
if name not in SND_SOURCES:
|
||||
raise SystemExit(
|
||||
"неизвестный источник звука %r; есть: %s"
|
||||
% (name, ", ".join(sorted(SND_SOURCES))))
|
||||
return SND_SOURCES[name]
|
||||
|
||||
|
||||
def find_ci(directory, name):
|
||||
"""Путь к файлу без оглядки на регистр (FAT-имена приезжают по-разному).
|
||||
|
||||
Возвращает None, если файла нет. Точное совпадение проверяется первым,
|
||||
чтобы на case-insensitive ФС не читать каталог зря.
|
||||
"""
|
||||
p = directory / name
|
||||
if p.exists():
|
||||
return p
|
||||
if not directory.is_dir():
|
||||
return None
|
||||
low = name.lower()
|
||||
for e in directory.iterdir():
|
||||
if e.name.lower() == low:
|
||||
return e
|
||||
return None
|
||||
|
||||
# --- выход ----------------------------------------------------------------
|
||||
ATL = APP / "build" / "atl" # промежуточные атласы, по набору в папке
|
||||
PACKED = APP / "assets" / "packed" # раскладка диска игры
|
||||
|
||||
@@ -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))
|
||||
@@ -2,7 +2,11 @@
|
||||
"""
|
||||
pop_pack_sound.py — упаковщик ЗВУКОВЫХ ЭФФЕКТОВ (оцифровка, chtab нет).
|
||||
|
||||
ИСТОЧНИК. `MSDOS/digisnd1..3.dat` — оригинальные контейнеры. Разбор:
|
||||
ИСТОЧНИК. Контейнеры `digisnd1..3` — по умолчанию из `SDLPoP/data/`
|
||||
(`--source sdlpop`), потому что SDLPoP скачивается автоматически и без него
|
||||
сборки всё равно нет. У кого лежит оригинальный дистрибутив DOS, может
|
||||
взять оцифровку оттуда: `--source msdos` (= `make SND_SRC=msdos`); наборы
|
||||
НЕ одинаковы — версии данных разные. Разбор:
|
||||
индекс по 8 байт (id, offset, size), **первый байт ресурса — контрольная
|
||||
сумма**, тело за ней (спецификация `docs/POP-DAT-FormatSpecifications`,
|
||||
§3.1.2). Тело — один из двух вариантов заголовка:
|
||||
@@ -29,8 +33,12 @@ CBL — конвертировать нечего.
|
||||
знает слова «стык»: замапил страницу, отдал 128 байт, сдвинул указатель.
|
||||
Плата — до 127 байт на звук, всего ~2 КБ.
|
||||
|
||||
Выход: build/atl/sound/s0..sN.bin (по 16 384 Б) + gen/pop_sound_tbl.h
|
||||
ВЫХОД: build/atl/sound/s0..sN.bin (страницы по 16 384 Б) + snd.idx —
|
||||
дисковая раскладка набора (формат PSI1, tools/pop_idx.py). C-заголовка
|
||||
БОЛЬШЕ НЕТ: раскладка уехала на диск, чтобы замена набора не требовала
|
||||
перекомпиляции игры.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
@@ -38,11 +46,11 @@ import sys
|
||||
import sys as _sys, os as _os
|
||||
_sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__)))
|
||||
import paths as P # раскладка каталогов SprPoP (tools/paths.py)
|
||||
import pop_idx # формат дискового индекса PSI1 (tools/pop_idx.py)
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
DAT_DIR = str(P.MSDOS)
|
||||
OUT_DIR = str(P.atl("sound"))
|
||||
HDR = str(P.GEN / "pop_sound_tbl.h")
|
||||
IDX = "snd.idx" # дисковый индекс набора, рядом с snd.arc
|
||||
|
||||
PAGE = 16384
|
||||
BLOCK = 128 # блок запроса CBL при 8 бит (см. cbl.h)
|
||||
@@ -101,12 +109,35 @@ def resample(src, src_rate, dst_rate):
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
|
||||
ap.add_argument("--source", default="sdlpop", choices=sorted(P.SND_SOURCES),
|
||||
help="откуда брать digisnd1..3 (по умолчанию sdlpop)")
|
||||
ap.add_argument("--order", default="id", choices=("id", "reverse"),
|
||||
help="порядок укладки эффектов в страницах: id (штатный) "
|
||||
"или reverse — ОТЛАДОЧНЫЙ, для проверки того, что "
|
||||
"игра берёт раскладку из snd.idx, а не из своей "
|
||||
"памяти: смещения расходятся у ВСЕХ эффектов")
|
||||
args = ap.parse_args()
|
||||
|
||||
# Имена контейнеров в поставках отличаются регистром, и на Linux это не
|
||||
# мелочь: ищем без оглядки на него (P.find_ci).
|
||||
dat_dir, pattern = P.snd_src(args.source)
|
||||
res = {}
|
||||
for i in (1, 2, 3):
|
||||
p = os.path.join(DAT_DIR, f"digisnd{i}.dat")
|
||||
if not os.path.exists(p):
|
||||
sys.exit(f"нет файла {p}")
|
||||
p = P.find_ci(dat_dir, pattern % i)
|
||||
if p is None:
|
||||
sys.exit(f"нет файла {dat_dir / (pattern % i)} "
|
||||
f"(источник {args.source}); см. assets/orig/README.md")
|
||||
res.update(dat_resources(p))
|
||||
print(f" источник оцифровки: {args.source} ({dat_dir})")
|
||||
# У набора SDLPoP оцифровка беднее: `spiked` (48) там длиной 7 сэмплов,
|
||||
# то есть звука пик фактически нет (разбор — docs/sound_plan.md). Если
|
||||
# у пользователя лежит дистрибутив DOS, сказать об этом стоит вслух —
|
||||
# молча отдавать тишину вместо эффекта нельзя.
|
||||
if args.source == "sdlpop" and P.MSDOS.is_dir():
|
||||
print(" ВНИМАНИЕ: у вас есть assets/orig/MSDOS — там оцифровка полнее"
|
||||
" (в наборе SDLPoP звук 48 `spiked` пустой).")
|
||||
print(" собрать с ней: make SND_SRC=msdos")
|
||||
|
||||
sounds = {}
|
||||
for sid, body in sorted(res.items()):
|
||||
@@ -128,7 +159,15 @@ def main():
|
||||
# тесная и её отказ ронял бы весь звук.
|
||||
blob = bytearray([0x80] * BLOCK)
|
||||
table = {}
|
||||
for sid in sorted(sounds):
|
||||
# ПОРЯДОК УКЛАДКИ. Штатно — по возрастанию id; `--order reverse` кладёт
|
||||
# те же эффекты задом наперёд. Данные и таблица при этом остаются
|
||||
# согласованными (индекс печатается из table), но НИ ОДНО смещение не
|
||||
# совпадает со штатным набором — только так и проверяется, что игра
|
||||
# читает раскладку с диска, а не помнит её с компиляции: у двух
|
||||
# реальных наборов совпадают 23 записи из 31, и подмена на них видна
|
||||
# слабо.
|
||||
order = sorted(sounds, reverse=(args.order == "reverse"))
|
||||
for sid in order:
|
||||
pad = (-len(blob)) % BLOCK
|
||||
blob += bytes([0x80]) * pad # 0x80 — тишина в беззнаковом 8-бит
|
||||
# Длину округляем ВВЕРХ до блока: хвост последнего блока — это
|
||||
@@ -148,61 +187,34 @@ def main():
|
||||
blob += bytes([0x80]) * ((-len(blob)) % PAGE)
|
||||
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
# Старые страницы СНОСИМ: у разных источников их разное число (SDLPoP 9,
|
||||
# MSDOS 10), и лишняя s9.bin от прошлого прогона уехала бы в архив
|
||||
# хвостом, которого нет в таблице.
|
||||
for old in os.listdir(OUT_DIR):
|
||||
if old.startswith("s") and old.endswith(".bin"):
|
||||
os.remove(os.path.join(OUT_DIR, old))
|
||||
npages = len(blob) // PAGE
|
||||
for i in range(npages):
|
||||
with open(os.path.join(OUT_DIR, f"s{i}.bin"), "wb") as f:
|
||||
f.write(blob[i * PAGE:(i + 1) * PAGE])
|
||||
|
||||
lines = [
|
||||
"/* pop_sound_tbl.h — раскладка оцифрованных эффектов PoP.",
|
||||
" * СГЕНЕРЕНО tools/pop_pack_sound.py — НЕ править вручную.",
|
||||
" *",
|
||||
" * Данные: 8 бит беззнаковые, МОНО, %g Гц (CBL_FREQ_10K9) — формат" % RATE,
|
||||
" * CBL один в один, конвертировать в рантайме нечего.",
|
||||
" *",
|
||||
" * Начало каждого звука кратно %d — размеру блока запроса CBL, а" % BLOCK,
|
||||
" * страница (%d) кратна ему же. Поэтому НИ ОДИН блок не пересекает" % PAGE,
|
||||
" * границу страницы. Больше того: с 2026-08-25 каждый звук лежит\n * ЦЕЛИКОМ в одной странице — не влезает в остаток, значит начинаем\n * новую (страницы EMM у нас с запасом). Единственное исключение —\n * эффект, который сам ДЛИННЕЕ страницы: для него насос переключает\n * страницу на ходу, и эта ветка в pop_sfx_fill остаётся ради него.",
|
||||
" *",
|
||||
" * Смещение хранится РАЗОБРАННЫМ на страницу и адрес в окне: на Z80",
|
||||
" * 32-битная арифметика дорога, а так всё влезает в 8/16 бит.",
|
||||
" * Длина округлена вверх до блока — неполных блоков не бывает.",
|
||||
" */",
|
||||
"#ifndef POP_SOUND_TBL_H",
|
||||
"#define POP_SOUND_TBL_H",
|
||||
"",
|
||||
"#include <stdint.h>",
|
||||
"",
|
||||
"#define POP_SND_PAGES %d" % npages,
|
||||
"#define POP_SND_COUNT %d" % NSOUND,
|
||||
"#define POP_SND_BLOCK %d" % BLOCK,
|
||||
"/* Первый блок страницы 0 — тишина для насоса (см. упаковщик). */",
|
||||
"#define POP_SND_SILENCE_PAGE 0",
|
||||
"#define POP_SND_SILENCE_OFF 0x0000",
|
||||
"",
|
||||
"/* Полная таблица нужна только горячей половине звука. */",
|
||||
"#ifdef POP_SND_TABLE",
|
||||
"typedef struct { uint8_t page; uint16_t off; uint16_t len; } pop_snd_ent_t;",
|
||||
"",
|
||||
"/* len == 0 — у звука нет оцифровки (это музыка, см. sound_plan.md). */",
|
||||
"static const pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT] = {",
|
||||
]
|
||||
for sid in range(NSOUND):
|
||||
off, ln = table.get(sid, (0, 0))
|
||||
lines.append(" { %d, 0x%04X, %5d }, /* %2d */" % (off >> 14, off & 0x3FFF, ln, sid))
|
||||
lines += [
|
||||
"};",
|
||||
"#endif /* POP_SND_TABLE */",
|
||||
"",
|
||||
"#endif",
|
||||
"",
|
||||
]
|
||||
with open(HDR, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
# ДИСКОВЫЙ ИНДЕКС. Та же раскладка, что и в заголовке ниже, но на
|
||||
# диске: с ним пара snd.idx + snd.arc заменяется целиком, без
|
||||
# перекомпиляции игры (docs/music_runtime_index_plan.md, часть II).
|
||||
# Проверки внутри snd_pack — ровно те, что делает загрузчик на Z80:
|
||||
# набор, который не пройдёт здесь, не пройдёт и там.
|
||||
if npages > pop_idx.SND_MAX_PAGES:
|
||||
sys.exit("набор занял %d страниц, а загрузчик берёт не больше %d"
|
||||
% (npages, pop_idx.SND_MAX_PAGES))
|
||||
entries = [table.get(sid, (0, 0)) for sid in range(NSOUND)]
|
||||
entries = [(off >> 14, off & 0x3FFF, ln) for off, ln in entries]
|
||||
with open(os.path.join(OUT_DIR, IDX), "wb") as f:
|
||||
f.write(pop_idx.snd_pack(entries, npages))
|
||||
print(" индекс -> %s (%d страниц)" % (os.path.join(OUT_DIR, IDX),
|
||||
npages))
|
||||
|
||||
have = sum(1 for s in sounds.values() if s)
|
||||
print(f"ИТОГО: {have} эффектов, {len(blob)} Б = {npages} EMM-страниц -> {OUT_DIR}")
|
||||
print(f" заголовок -> {HDR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""test_pop_idx — формат дисковых индексов аудио (tools/pop_idx.py).
|
||||
|
||||
Индекс читают ДВЕ стороны: упаковщик на хосте и загрузчик на Z80. Тест
|
||||
закрепляет ровно то, на что рассчитывает вторая сторона: точные смещения
|
||||
полей, little-endian, размер сектора и НАБОР ОТКАЗОВ — потому что битый
|
||||
индекс обязан выключить звук, а не увести насос в чужую память.
|
||||
|
||||
Запуск: make test-tools (либо python3 -m unittest discover tools/tests)
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import pop_idx as X
|
||||
|
||||
|
||||
def empty():
|
||||
return [(0, 0, 0)] * X.SND_COUNT
|
||||
|
||||
|
||||
class SndFormat(unittest.TestCase):
|
||||
"""PSI1: раскладка байтов, на которую смотрит Z80."""
|
||||
|
||||
def test_size_is_one_sector(self):
|
||||
self.assertEqual(len(X.snd_pack(empty(), 1)), X.SECTOR)
|
||||
|
||||
def test_header_fields(self):
|
||||
d = X.snd_pack(empty(), 9)
|
||||
self.assertEqual(d[0:4], b"PSI1")
|
||||
self.assertEqual(d[4], 57) # число записей
|
||||
self.assertEqual(d[5], 5) # sizeof(pop_snd_ent_t) в SDCC z80
|
||||
self.assertEqual(d[6], 7) # log2(128)
|
||||
self.assertEqual(d[7], 1) # u8 mono 10937.5
|
||||
self.assertEqual(d[8], 9) # фактических страниц
|
||||
self.assertEqual(d[9:16], b"\0" * 7)
|
||||
|
||||
def test_entry_is_little_endian_at_fixed_offset(self):
|
||||
"""Смещение записи и порядок байтов — контракт с загрузчиком."""
|
||||
e = empty()
|
||||
e[3] = (2, 0x1500, 0x0480)
|
||||
d = X.snd_pack(e, 9)
|
||||
at = X.SND_HDR + 3 * X.SND_REC
|
||||
self.assertEqual(d[at], 2)
|
||||
self.assertEqual(d[at + 1:at + 3], b"\x00\x15") # off LE
|
||||
self.assertEqual(d[at + 3:at + 5], b"\x80\x04") # len LE
|
||||
self.assertEqual(struct.unpack_from("<BHH", d, at), (2, 0x1500, 0x0480))
|
||||
|
||||
def test_tail_is_zero_padded(self):
|
||||
d = X.snd_pack(empty(), 1)
|
||||
self.assertEqual(d[X.SND_HDR + X.SND_COUNT * X.SND_REC:], b"\0" * 211)
|
||||
|
||||
def test_roundtrip(self):
|
||||
e = empty()
|
||||
e[10] = (0, 128, 3584)
|
||||
e[48] = (8, 0x1400, 128)
|
||||
e[56] = (8, 0x2600, 4864)
|
||||
d = X.snd_pack(e, 9)
|
||||
back, pages = X.snd_unpack(d)
|
||||
self.assertEqual(pages, 9)
|
||||
self.assertEqual(back, e)
|
||||
|
||||
|
||||
class SndRejects(unittest.TestCase):
|
||||
"""Битый индекс обязан отвергаться, а не молча разъезжать раскладку."""
|
||||
|
||||
def bad(self, mutate):
|
||||
e = empty()
|
||||
e[1] = (0, 128, 256)
|
||||
d = bytearray(X.snd_pack(e, 4))
|
||||
mutate(d)
|
||||
with self.assertRaises(X.IdxError):
|
||||
X.snd_unpack(bytes(d))
|
||||
|
||||
def test_wrong_magic(self):
|
||||
self.bad(lambda d: d.__setitem__(slice(0, 4), b"PSI2"))
|
||||
|
||||
def test_wrong_count(self):
|
||||
self.bad(lambda d: d.__setitem__(4, 56))
|
||||
|
||||
def test_wrong_record_size(self):
|
||||
self.bad(lambda d: d.__setitem__(5, 4))
|
||||
|
||||
def test_wrong_block_log2(self):
|
||||
self.bad(lambda d: d.__setitem__(6, 8))
|
||||
|
||||
def test_unknown_pcm_format(self):
|
||||
self.bad(lambda d: d.__setitem__(7, 2))
|
||||
|
||||
def test_pages_zero(self):
|
||||
self.bad(lambda d: d.__setitem__(8, 0))
|
||||
|
||||
def test_pages_over_capacity(self):
|
||||
self.bad(lambda d: d.__setitem__(8, X.SND_MAX_PAGES + 1))
|
||||
|
||||
def test_truncated(self):
|
||||
e = empty()
|
||||
d = X.snd_pack(e, 1)
|
||||
with self.assertRaises(X.IdxError):
|
||||
X.snd_unpack(d[:-1])
|
||||
|
||||
def test_page_beyond_set(self):
|
||||
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 9, 0, 128))
|
||||
|
||||
def test_offset_not_block_aligned(self):
|
||||
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 1, 100, 128))
|
||||
|
||||
def test_length_not_block_aligned(self):
|
||||
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 1, 128, 100))
|
||||
|
||||
def test_entry_runs_past_last_page(self):
|
||||
"""Эффект длиннее остатка набора увёл бы насос в чужую память."""
|
||||
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5,
|
||||
3, 0x3F80, 16384))
|
||||
|
||||
def test_entry_in_silence_block(self):
|
||||
"""Первый блок страницы 0 занят тишиной насоса — он неприкосновенен."""
|
||||
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 0, 0, 128))
|
||||
|
||||
def test_empty_entry_must_be_all_zero(self):
|
||||
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 2, 256, 0))
|
||||
|
||||
|
||||
class SndPackGuards(unittest.TestCase):
|
||||
"""Упаковщик не имеет права выпустить набор, который отвергнет Z80."""
|
||||
|
||||
def test_rejects_too_many_pages(self):
|
||||
with self.assertRaises(X.IdxError):
|
||||
X.snd_pack(empty(), X.SND_MAX_PAGES + 1)
|
||||
|
||||
def test_rejects_wrong_entry_count(self):
|
||||
with self.assertRaises(X.IdxError):
|
||||
X.snd_pack([(0, 0, 0)] * 56, 1)
|
||||
|
||||
def test_rejects_entry_in_silence_block(self):
|
||||
e = empty()
|
||||
e[0] = (0, 0, 128)
|
||||
with self.assertRaises(X.IdxError):
|
||||
X.snd_pack(e, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user