#!/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(" 4 and body[3] == 8: # digi_new_type rate = struct.unpack(" 7 and body[7] == 8: # digi_type rate, cnt = struct.unpack("= 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(" те же 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()