Files
Sprinter-SDCC/applications/PoP/toolchain/pop_pack_sound.py
T
snark13 8ea4c32e51 Звуковые эффекты PoP: оцифровка оригинала через CBL
Набор digisnd1..3.dat приведён упаковщиком к 10 937,5 Гц (частота
железа), склеен в 8 EMM-страниц с выравниванием каждого звука на 128 —
размер блока запроса CBL, поэтому ни один блок не пересекает границу
страницы и проигрыватель не знает слова «стык».

Насос (pop_sfx.c) резидентный: его зовут из прерывания CBL, из горячих
мест физики и из play_seq.  Тишину льём свою (первый блок набора), а не
через CBL_UNDERRUN_SILENCE с его malloc — куча в резиденте W2 тесная.
Открытие CBL разведено с загрузкой (pop_sfx_start отдельно от
pop_sfx_init): пока ESTEX читает файлы, насос не успевает долить блок и
железо крутит хвост буфера — на слух скрежет.

Разведены все места play_sound() SDLPoP, у которых есть оцифровка
(id 0..23, 44..49): посадки, падение, удары о стену, зацеп, тряска и
провал плит, ворота, дверь уровня, пики, чомпер, кнопки, боёвка, меч,
зеркало, скелет, зелье.  Таблица соответствий — docs/sound_plan.md §8.
Музыкальные id остаются с нулевой длиной до фазы музыки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:16:43 +03:00

200 lines
10 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 нет).
ИСТОЧНИК. `MSDOS/digisnd1..3.dat` — оригинальные контейнеры. Разбор:
индекс по 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 КБ.
Выход: poc/res/sound/s0..sN.bin (по 16 384 Б) + roomtest/pop_sound_tbl.h
"""
import os
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
DAT_DIR = os.path.join(HERE, "..", "MSDOS")
OUT_DIR = os.path.join(HERE, "..", "poc", "res", "sound")
HDR = os.path.join(HERE, "..", "roomtest", "pop_sound_tbl.h")
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():
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}")
res.update(dat_resources(p))
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 = {}
for sid in sorted(sounds):
pad = (-len(blob)) % BLOCK
blob += bytes([0x80]) * pad # 0x80 — тишина в беззнаковом 8-бит
# Длину округляем ВВЕРХ до блока: хвост последнего блока — это
# выравнивающая тишина следующего звука, и проигрыватель за счёт
# этого не знает слова «неполный блок».
table[sid] = (len(blob), (len(sounds[sid]) + BLOCK - 1) // BLOCK * BLOCK)
blob += sounds[sid]
blob += bytes([0x80]) * ((-len(blob)) % PAGE)
os.makedirs(OUT_DIR, exist_ok=True)
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.",
" * СГЕНЕРЕНО toolchain/pop_pack_sound.py — НЕ править вручную.",
" *",
" * Данные: 8 бит беззнаковые, МОНО, %g Гц (CBL_FREQ_10K9) — формат" % RATE,
" * CBL один в один, конвертировать в рантайме нечего.",
" *",
" * Начало каждого звука кратно %d — размеру блока запроса CBL, а" % BLOCK,
" * страница (%d) кратна ему же. Поэтому НИ ОДИН блок не пересекает" % PAGE,
" * границу страницы, и проигрывателю не нужна логика стыка.",
" *",
" * Смещение хранится РАЗОБРАННЫМ на страницу и адрес в окне: на 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",
"",
"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 += [
"};",
"",
"/* Имена файлов видит ТОЛЬКО тот, кто попросил POP_SND_FILES (холодная",
" * половина): иначе копия строк уезжает в каждый включивший заголовок",
" * модуль, а в резиденте W1/W2 каждая сотня байт на счету. Тот же",
" * приём, что у POP_BG_ATLAS_NAMES в pop_bg_atlas.h. */",
"#ifdef POP_SND_FILES",
"static const char *const pop_snd_files[POP_SND_PAGES] = {",
]
lines += [' "SND\\\\s%d.bin",' % i for i in range(npages)]
lines += ["};", "#endif", "", "#endif", ""]
with open(HDR, "w") as f:
f.write("\n".join(lines))
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__":
main()