2349481b86
Часть 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
222 lines
13 KiB
Python
222 lines
13 KiB
Python
#!/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()
|