Files
Sprinter-SDCC/applications/SprPoP/tools/pop_pack_sound.py
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

222 lines
13 KiB
Python
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.
#!/usr/bin/env python3
"""
pop_pack_sound.py — упаковщик ЗВУКОВЫХ ЭФФЕКТОВ (оцифровка, chtab нет).
ИСТОЧНИК. Контейнеры `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). Тело — один из двух вариантов заголовка:
digi_type (1.0/1.1): word rate, word count, word unk, byte size=8
digi_new_type (1.3/1.4): word rate, byte size=8, word count, word, word
Различаем по позиции байта размера: у нового он на смещении 3, у старого
на 7. Сэмплы — 8 бит БЕЗЗНАКОВЫЕ, МОНО (проверено по `convert_digi_sound`,
seg009.c:2358: один байт на кадр, `(b | b<<8) - 32768`; стерео в данных
нет вовсе, каналы размножаются уже на выходе). Это ровно формат нашего
CBL — конвертировать нечего.
ЧАСТОТА. Приводим ВСЁ к 10 937,5 Гц — это `CBL_FREQ_10K9`, то есть
частота железа. Не к 11 000: проигрывание 11 000-данных на 10 937,5 дало
бы сдвиг тона −9,9 цента, а пересчитывать три файла (2 750, 8 200,
14 000 Гц) всё равно надо. Взамен CBL открывается ОДИН раз и частота не
меняется никогда — ни между эффектами, ни при переходе на музыку.
РАСКЛАДКА. Всё склеивается в EMM-страницы по 16 384 Б, и начало каждого
звука ВЫРАВНИВАЕТСЯ НА 128. Это не косметика: блок запроса CBL — ровно
128 байт, а 16 384 кратно 128, поэтому при таком выравнивании ни один
блок не пересекает границу страницы. Проигрыватель за счёт этого не
знает слова «стык»: замапил страницу, отдал 128 байт, сдвинул указатель.
Плата — до 127 байт на звук, всего ~2 КБ.
ВЫХОД: build/atl/sound/s0..sN.bin (страницы по 16 384 Б) + snd.idx —
дисковая раскладка набора (формат PSI1, tools/pop_idx.py). C-заголовка
БОЛЬШЕ НЕТ: раскладка уехала на диск, чтобы замена набора не требовала
перекомпиляции игры.
"""
import argparse
import os
import struct
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__))
OUT_DIR = str(P.atl("sound"))
IDX = "snd.idx" # дисковый индекс набора, рядом с snd.arc
PAGE = 16384
BLOCK = 128 # блок запроса CBL при 8 бит (см. cbl.h)
RATE = 10937.5 # CBL_FREQ_10K9
NSOUND = 57 # id 0..56; у музыки оцифровки нет — длина 0
def dat_resources(path):
"""id -> тело ресурса (без байта контрольной суммы)."""
d = open(path, "rb").read()
off, _ = struct.unpack("<IH", d[0:6])
n = struct.unpack("<H", d[off:off + 2])[0]
out = {}
for i in range(n):
e = off + 2 + i * 8
rid = struct.unpack("<H", d[e:e + 2])[0]
roff = struct.unpack("<I", d[e + 2:e + 6])[0]
rsz = struct.unpack("<H", d[e + 6:e + 8])[0]
out[rid - 10000] = d[roff + 1:roff + 1 + rsz]
return out
def digi(body):
"""(rate, samples) или None, если ресурс не оцифровка."""
if not body or (body[0] & 7) != 1:
return None
if len(body) > 4 and body[3] == 8: # digi_new_type
rate = struct.unpack("<H", body[1:3])[0]
cnt = struct.unpack("<H", body[4:6])[0]
head = 10
elif len(body) > 7 and body[7] == 8: # digi_type
rate, cnt = struct.unpack("<HH", body[1:5])
head = 8
else:
return None
return rate, body[head:head + cnt]
def resample(src, src_rate, dst_rate):
"""Линейная интерполяция. Данные 8 бит беззнаковые, поэтому и работаем
в беззнаковых — никаких знаковых промежуточных представлений."""
if src_rate == dst_rate or not src:
return bytes(src)
n = int(round(len(src) * dst_rate / src_rate))
out = bytearray(n)
step = src_rate / dst_rate
for i in range(n):
x = i * step
i0 = int(x)
if i0 >= len(src) - 1:
out[i] = src[-1]
else:
a = x - i0
out[i] = int(round(src[i0] * (1.0 - a) + src[i0 + 1] * a))
return bytes(out)
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 = 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()):
g = digi(body)
if g is None:
print(f" звук {sid}: не оцифровка, пропуск")
continue
rate, samples = g
pcm = resample(samples, rate, RATE)
sounds[sid] = pcm
if rate != 11000:
print(f" звук {sid}: {rate} Гц -> {RATE:g} Гц, {len(samples)} -> {len(pcm)} сэмплов")
# Раскладка: начало каждого звука кратно BLOCK, страницы по PAGE.
# ПЕРВЫЙ БЛОК — ТИШИНА. Он нужен насосу: когда эффект кончился, в CBL
# всё равно надо что-то отдавать, иначе железо доигрывает по кругу хвост
# своего буфера (жужжание). Держим тишину СВОЮ, а не просим у libc
# (CBL_UNDERRUN_SILENCE делает malloc), потому что куча в резиденте W2
# тесная и её отказ ронял бы весь звук.
blob = bytearray([0x80] * BLOCK)
table = {}
# ПОРЯДОК УКЛАДКИ. Штатно — по возрастанию 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-бит
# Длину округляем ВВЕРХ до блока: хвост последнего блока — это
# выравнивающая тишина следующего звука, и проигрыватель за счёт
# этого не знает слова «неполный блок».
need = (len(sounds[sid]) + BLOCK - 1) // BLOCK * BLOCK
# ЗВУК ЦЕЛИКОМ В ОДНОЙ СТРАНИЦЕ (решение пользователя 2026-08-25).
# Раньше длинные эффекты перетекали в следующую страницу, и насос
# переключал её прямо посреди воспроизведения — лишняя логика в ISR
# и целый класс отказов «играет не тот звук». Теперь страница —
# как атлас: не влезает в остаток — начинаем новую, хвост добиваем
# тишиной. Платим страницами EMM, их у нас с запасом.
if len(blob) % PAGE + need > PAGE:
blob += bytes([0x80]) * ((-len(blob)) % PAGE)
table[sid] = (len(blob), need)
blob += sounds[sid]
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])
# ДИСКОВЫЙ ИНДЕКС. Та же раскладка, что и в заголовке ниже, но на
# диске: с ним пара 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}")
if __name__ == "__main__":
main()