63bfd997a9
Четыре правки прогона 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
312 lines
18 KiB
Python
312 lines
18 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 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()
|