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
+67 -55
View File
@@ -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__":