Files
Sprinter-SDCC/applications/SprPoP/tools/pop_pack_sound.py
T
snark13 63bfd997a9 SprPoP: звук мигания, равномерные часы, полоса HP после рестарта, надпись ждёт мелодию
Четыре правки прогона 2026-08-31.  Две ПРОВЕРЕНЫ пользователем в MAME
(звук мигания, порядок «мелодия -> надпись»), две ждут проверки — образ
собран.

ЗВУК МИГАНИЯ (проверено).  Упаковщик научился синтезировать ноты PC-
спикера в обычный сэмпл: заголовок с темпом, тройки «частота + длина»,
меандр на нашей частоте вывода.  Берём из нот ТОЛЬКО номера, которых нет
ни в оцифровке, ни среди мелодий, — иначе синтез перекрыл бы музыку,
которую мы играем из MUS/.  На поставке SDLPoP это ровно один номер: 38,
сигнал под мигание «Press Button»; 31, 34 и 42 там пустые заглушки.
Громкость по слуховой проверке снижена вдвое (44 -> 22): на полном
размахе сигнал перекрикивал игру.  EXE не меняется — раскладка читается с
диска, набор занял те же 9 страниц.

НАДПИСЬ ЖДЁТ МЕЛОДИЮ (проверено).  Порядок оригинала: ветка мёртвого
(seg006:1351) на седьмом шаге выходит, пока звук играет, и «Press Button»
появляется только после музыки смерти.  Чтобы ожидание не было
принудительным, три быстрых пути (Ctrl+A, обе быстрые загрузки, пункты
меню) музыку глушат — оригинал при Ctrl+A делает то же (seg000:0617).
Обычная кнопка во время мелодии не действует: она ответ НА надпись.

ЧАСЫ (ждёт проверки).  Тик стоит столько кадров ЛУЧА, сколько их в кадре
режима NORMAL, поэтому FAST/FASTEST больше не ускоряют время.  Считаем
ФАКТИЧЕСКИ прошедшие кадры луча, а не ожидаемый делитель: логический кадр
не всегда укладывается в бюджет, и часы «по делителю» шли рывками (первый
прогон это показал — «несколько секунд быстро, потом притормаживание»).
Вклад одного вызова ограничен, иначе пауза и меню прыгнули бы вперёд.
Четыре новых теста: NORMAL не сдвинулся ни на тик (и вне боя, и в бою),
FAST и FASTEST держат реальное время.

ПОЛОСА HP ПОСЛЕ Ctrl+A (ждёт проверки).  Корень: счётчик считает
СТРАНИЦЫ, а тратился по КАДРАМ — между двумя вызовами переворота может не
быть, и оба прохода уходили в одну страницу, вторая оставалась с
делениями прошлого боя.  Теперь проход тратится только при смене
gfx_get_draw_page().  Плюс полная чистка всей ширины при инвалидации:
старая полоса могла заходить под статус-текст, где щадящая чистка её не
трогала; текст сразу перезапрашивается.

Все 16 наборов host-тестов зелёные, check_bank_calls чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 23:10:35 +03:00

312 lines
18 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 load_bundle(dat_dir, pattern, count):
"""id -> тело ресурса из набора любого вида.
Наборы приезжают в двух видах: контейнером `.DAT` (дистрибутив DOS) и
распакованным каталогом `res1XXXX.bin` (поставка SDLPoP). Тело в обоих
случаях начинается с байта типа, так что дальше разбор общий.
"""
out = {}
for i in range(1, count + 1):
p = dat_dir / (pattern % i)
if p.is_dir():
for f in sorted(p.glob("res1*.bin")):
try:
rid = int(f.stem[3:]) - 10000
except ValueError:
continue
out[rid] = f.read_bytes()
elif p.is_file():
out.update(dat_resources(p))
return out
def speaker(body):
"""(темп, [(частота Гц, длина в долях), ...]) или None — если это не ноты.
Формат — канон Princed, «Internal PC Speaker»: заголовок 3 байта, из них
байт 1 — доли на ДВЕ секунды; дальше тройки «частота (2 байта) + длина
(1 байт)»; нулевая частота = пауза, 1 и 2 — маркеры; в конце маркер
`12 00`, который сам отсекается недобором до целой тройки.
"""
if not body or (body[0] & 7) != 0 or len(body) < 6:
return None
bps = body[1] or 1
notes = []
i = 3
while i + 3 <= len(body):
notes.append((struct.unpack("<H", body[i:i + 2])[0], body[i + 2]))
i += 3
return bps, notes
def speaker_pcm(bps, notes, rate, amp=22):
"""Меандр по нотам -> те же 8 бит без знака, что и оцифровка.
Амплитуда НАМЕРЕННО маленькая: сигналы спикера короткие и резкие, и на
полном размахе они колют ухо рядом с оцифровкой, которая заметно тише.
Значение снижено вдвое по слуховой проверке в MAME (решение пользователя
2026-08-31): на 44 сигнал мигания перекрикивал игру.
"""
out = bytearray()
for freq, beats in notes:
n = int(round(beats * (2.0 / bps) * rate))
if n <= 0:
continue
if freq <= 2: # 0 — пауза, 1 и 2 — маркеры
out += bytes([0x80]) * n
continue
period = rate / freq
for i in range(n):
out.append(0x80 + amp if (i % period) < period / 2 else 0x80 - amp)
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)} сэмплов")
# ДОБОР ИЗ НОТ PC-СПИКЕРА (docs/TASKS_OPEN.md, SND-SPEAKER-38).
# У оригинала три параллельных набора звука, и номера разложены по ним
# не подряд: оцифровка, мелодии и ноты спикера. Берём из нот ТОЛЬКО те
# номера, которых нет ни там, ни там — иначе синтез перекрыл бы собой
# мелодии, которые мы играем отдельно, из MUS/. На поставке SDLPoP это
# ровно один номер: 38, сигнал под мигание надписи «Press Button»
# (31, 34 и 42 в наборе есть, но они пустые заглушки без нот).
midi_cfg = P.snd_extra("midi", args.source)
spk_cfg = P.snd_extra("speaker", args.source)
midi_ids = set(load_bundle(*midi_cfg)) if midi_cfg else set()
added = []
for sid, body in sorted((load_bundle(*spk_cfg) if spk_cfg else {}).items()):
if sid in sounds or sid in midi_ids:
continue
sp = speaker(body)
if sp is None or not sp[1]:
continue
pcm = speaker_pcm(sp[0], sp[1], RATE)
if not pcm:
continue
sounds[sid] = pcm
added.append("%d (%d нот, %d Б)" % (sid, len(sp[1]), len(pcm)))
if added:
print(" из нот спикера: " + ", ".join(added))
elif spk_cfg:
print(" из нот спикера: добирать нечего")
# Раскладка: начало каждого звука кратно 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()