SprPoP: автономное приложение, выделенное из roomtest
Порт PoP переехал в applications/SprPoP — приложение, которое собирается само: код, оригинальные данные, конверторы ресурсов и сборка внутри одной папки. Наружу знает единственный путь — корень тулчейна (SPRINTER_ROOT, по умолчанию ../..). applications/PoP/roomtest ЗАМОРОЖЕНА и остаётся архивом закрытых задач, багов и исполненных планов. Скопировано из applications/PoP/roomtest@4b74478. Перенос проверен побайтово: собранный sprpop.exe совпал с roomtest.exe того же коммита, все 39 дисковых ресурсов и все 16 генерируемых заголовков — тоже, host- тесты зелёные (15/15). Раскладка: src/ рукописный C (roomtest.c -> sprpop.c) gen/ генерируемые заголовки, в репозитории assets/orig/ оригинальные данные игры, вне репозитория (копирайт) assets/packed/ то, что ложится на диск, в раскладке диска tools/ конверторы; все пути — в одном tools/paths.py build/ выход: exe, каталоги ресурсов, hdd/, промежуточные atl/ Сборка ресурсов: assets/packed и gen — версионируемые ВХОДЫ, а не то, что пересчитывается каждым make. Автоматика построена на ОТСУТСТВИИ файла, а не на таймстемпах: git не хранит времена, и в свежем клоне сравнение по времени превращалось бы в лотерею. Недостающий ресурс или заголовок чинится сам, рекурсивным вызовом в ветку генерации. Музыка собирается из любого из четырёх наборов записей (make music-mp3, music-mt32, ...); набор входит в имя stamp'а, поэтому смена набора сама делает музыку устаревшей. Длины реплик больше не захардкожены: упаковщик печатает их в gen/pop_music_ticks.h, и шкала сцены выражена через них — иначе mt32 (реплики на 6% длиннее) молча ломал катсцену. Тулчейн: в app.mk два обратносовместимых крючка (SRC_DIR/BUILD_DIR), HDD_IMG стал ?=; команда сборки roomtest не изменилась. Корневой make host-tests переключён на SprPoP. Подгонка тайминга катсцены с принцессой (PV_MAGIC_LEAD): сцена render-bound и идёт ~49 тиков/с вместо 60, из-за чего кода реплики приходила раньше молнии. Это обход, а не лечение; разбор с замерами — docs/BUGS_OPEN.md, записи SND-PACE-DEAD, PV-RENDER-BOUND, MUS-LEFT-TEAR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
#!/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 КБ.
|
||||
|
||||
Выход: build/atl/sound/s0..sN.bin (по 16 384 Б) + gen/pop_sound_tbl.h
|
||||
"""
|
||||
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)
|
||||
|
||||
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")
|
||||
|
||||
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-бит
|
||||
# Длину округляем ВВЕРХ до блока: хвост последнего блока — это
|
||||
# выравнивающая тишина следующего звука, и проигрыватель за счёт
|
||||
# этого не знает слова «неполный блок».
|
||||
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)
|
||||
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,
|
||||
" * границу страницы. Больше того: с 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",
|
||||
"",
|
||||
"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()
|
||||
Reference in New Issue
Block a user