#!/usr/bin/env python3 """pop_pack_music.py — музыка PoP в сырой поток для CBL. Источник — готовые записи DOS-версии (assets/orig/PoP1_DOS_music). Набор выбирается ключом --format: flac (по умолчанию), mp3, ogg, mt32. Оцифровки музыки в самой игре НЕТ: в digisnd*.dat лежат только эффекты, а музыка хранится как MIDI под Adlib. Поэтому здесь мы не «конвертируем ресурс игры», а укладываем готовую запись в формат, который наше железо играет без единого преобразования в рантайме. ФОРМАТ — тот же, что у эффектов (см. pop_pack_sound.py и sound_plan.md): 8 бит БЕЗЗНАКОВЫЙ моно на 10 937,5 Гц — ровно CBL_FREQ_10K9. Частоту берём не 11 000, а частоту железа: тогда CBL открывается ОДИН раз за запуск и не переключается ни между эффектами, ни при переходе на музыку. ОДИН ФАЙЛ НА ТРЕК. Резать его на куски не нужно: читаем порциями по 16 КБ из ОДНОГО открытого файла (`bank_read_page` + `lseek`), и «дорогой» open платится раз на трек, а не на каждую страницу — замер 2026-08-25: open 51,4 мс против 32,6 мс на чтение 16 КБ. Этот же путь работает для финального `won` (1,2 МБ), который в память целиком не помещается и обязан подгружаться по ходу. Хвост дополняется тишиной (0x80) до кратности 128: насос отдаёт CBL блоками по 128 байт, и остаток «не полного блока» ему пришлось бы досчитывать в прерывании. Длина трека уходит в pop_music_tbl.h блоками по 128 байт, а не байтами: самый длинный трек (won, 115 с) — это 1,2 МБ, что не влезает в uint16, а в блоках даёт 9855. 32-битной арифметики на Z80 избегаем (см. memory avoid_32bit_arith_z80). """ import subprocess import sys from pathlib import Path 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) # Формат источника выбирается ключом --format (по умолчанию flac — он же # лучший по качеству исходник). Каталог и расширение знает tools/paths.py. FMT = "flac" SRC_DIR, SRC_EXT = P.music_src(FMT) OUT_DIR = P.atl("music") OUT_H = P.GEN / "pop_music_tbl.h" OUT_TICKS = P.GEN / "pop_music_ticks.h" CBL_RATE = 10937.5 # CBL_FREQ_10K9 — частота железа, не 11 000 PAGE = 16384 # EMM-страница BLOCK = 128 # порция насоса CBL SILENCE = 0x80 # центр беззнакового 8-битного сэмпла # id — нумерация звуков ОРИГИНАЛА (SDLPoP data/music/names.txt). Держим её, # чтобы игровой код просил трек тем же номером, каким оригинал зовёт # play_sound, и таблицу можно было сверить глазами. TRACKS = { 24: "regular_death", 25: "find_mirror_and_find_shadow", 26: "embrace", 27: "cutscene_before_2_4_6_12", 28: "fight_death", 29: "meet_Jaffar", 30: "life_potion", 32: "end_level_4_and_killed_by_shadow", 33: "potion", 35: "cutscene_before_8_9", 36: "out_of_time", 37: "guard_death_and_obtaining_the_sword", 39: "floating", 40: "cutscene_not_much_time_left", 41: "story_5_level_end_and_merge_with_shadow", 43: "killed_Jaffar", 50: "story_2_princess", 52: "story_4_Jaffar_leaves", 53: "story_3_Jaffar_enters", 54: "intro_theme", 55: "story_1_absence", 56: "won", } def decode(path: Path) -> bytes: """запись любого формата -> сырой u8 моно на частоте CBL.""" out = subprocess.run( ["ffmpeg", "-v", "error", "-i", str(path), "-ac", "1", "-ar", str(int(CBL_RATE)), "-f", "u8", "-"], capture_output=True, check=True) return out.stdout def pack(track_id: int, name: str) -> tuple[int, int]: src = SRC_DIR / f"{name}{SRC_EXT}" if not src.exists(): raise SystemExit(f"нет источника: {src}") data = bytearray(decode(src)) # Добить до кратности блока насоса — хвост тишиной, а не обрезкой: # обрезать значило бы щёлкнуть на последнем сэмпле. tail = len(data) % BLOCK if tail: data += bytes([SILENCE]) * (BLOCK - tail) chunks = (len(data) + PAGE - 1) // PAGE OUT_DIR.mkdir(parents=True, exist_ok=True) (OUT_DIR / f"m{track_id:02d}.bin").write_bytes(bytes(data)) return chunks, len(data) // BLOCK def main() -> None: global FMT, SRC_DIR, SRC_EXT args = sys.argv[1:] if args and args[0] == "--format": FMT = args[1] SRC_DIR, SRC_EXT = P.music_src(FMT) args = args[2:] wanted = [int(a) for a in args] or sorted(TRACKS) print(f"музыка из набора {FMT}: {SRC_DIR}") rows = [] total = 0 for tid in wanted: if tid not in TRACKS: raise SystemExit(f"неизвестный трек {tid}") chunks, blocks = pack(tid, TRACKS[tid]) rows.append((tid, TRACKS[tid], chunks, blocks)) total += blocks * BLOCK print(f" {tid:2d} {TRACKS[tid]:<40} {chunks:2d} стр, " f"{blocks * BLOCK / 1024:7.1f} КБ, {blocks * BLOCK / CBL_RATE:5.1f} с") max_id = max(t for t, *_ in rows) lines = [ "/*", " * pop_music_tbl.h — каталог музыкальных треков, СГЕНЕРИРОВАН.", f" * Источник: assets/orig/PoP1_DOS_music/{P.MUSIC_FORMATS[FMT][0]}" " (записи DOS-версии).", " * Генератор: tools/pop_pack_music.py. Руками не править.", " *", " * Нумерация id — оригинала (SDLPoP data/music/names.txt). pages —", " * сколько кусков m_.bin лежит на диске, blocks — длина трека", " * в порциях насоса по 128 байт (в байтах не влезло бы в uint16).", " *", " * Длительности реплик в тиках вынесены в pop_music_ticks.h: таблица", " * ниже — 171 байт ДАННЫХ, и модулю, которому нужна одна константа,", " * платить за неё копией таблицы незачем (поймано на pop_intro.c:", " * +172 байта в банк 11).", " *", " * POP_MUS_TICKS_ — та же длина в ТИКАХ СЦЕНЫ (60 Гц оригинала).", " * Нужна там, где сценарий ждёт окончания реплики: у оригинала это", " * `do { frame(1) } while (check_sound_playing())`, у нас — пауза", " * длиной в нашу запись. Считается ЗДЕСЬ, а не в C, по двум", " * причинам: blocks*40 переполняет uint16 на won (9855 порций), и", " * число обязано соответствовать НАБОРУ, которым собрали (flac и", " * mt32 — разные исполнения разной длины).", " *", " * Отношение 40/57 — обратное к тому, которым шкала сцены переводит", " * тики в порции насоса (57/40, см. pop_intro.c). Берём именно его,", " * а не точное 10937,5/128/60, чтобы длина реплики и ход шкалы", " * считались одной и той же арифметикой. Округление — к ближайшему", " * (+28 перед делением): отбрасывание вниз теряло по тику на реплику.", " */", "#ifndef POP_MUSIC_TBL_H", "#define POP_MUSIC_TBL_H", "", "#include ", "", f"#define POP_MUS_COUNT {max_id + 1}", f"#define POP_MUS_BLOCK {BLOCK}", f"#define POP_MUS_MAX_PAGES {max(c for _, _, c, _ in rows)}", "", ] lines += [ "", "typedef struct {", " uint8_t pages; /* 0 — трека нет */", " uint16_t blocks; /* длина в порциях по 128 байт */", "} pop_mus_ent_t;", "", "static const pop_mus_ent_t pop_mus_tbl[POP_MUS_COUNT] = {", ] by_id = {t: (c, b, n) for t, n, c, b in rows} for i in range(max_id + 1): if i in by_id: c, b, n = by_id[i] lines.append(f" {{ {c:2d}, {b:5d} }}, /* {i:2d} {n} */") else: lines.append(f" {{ 0, 0 }}, /* {i:2d} */") lines += ["};", "", "#endif /* POP_MUSIC_TBL_H */", ""] OUT_H.write_text("\n".join(lines)) # Отдельный заголовок ТОЛЬКО с длительностями реплик. Данных в нём нет, # поэтому его может включать кто угодно, ничего не утаскивая в свой банк. tk = [ "/*", " * pop_music_ticks.h — длительности треков в ТИКАХ СЦЕНЫ, СГЕНЕРИРОВАН.", f" * Набор: {P.MUSIC_FORMATS[FMT][0]}. Генератор: tools/pop_pack_music.py.", " * Руками не править.", " *", " * Нужны там, где сценарий ждёт окончания реплики: у оригинала это", " * `do { frame(1) } while (check_sound_playing())`, у нас — пауза длиной", " * в НАШУ запись. Число обязано соответствовать набору, которым собрана", " * сборка: одна и та же реплика на flac и mt32 разной длины (m53 — 985", " * тиков против 1044), и захардкоженная константа молча ломает сцену.", " *", " * Считается здесь, а не в C: blocks*40 переполняет uint16 на won", " * (9855 порций). Отношение 40/57 — обратное к тому, которым шкала", " * сцены переводит тики в порции насоса (57/40, pop_intro.c); берём", " * именно его, чтобы длина реплики и ход шкалы считались одной", " * арифметикой. Округление к ближайшему: вниз теряло по тику.", " */", "#ifndef POP_MUSIC_TICKS_H", "#define POP_MUSIC_TICKS_H", "", ] for tid, name, _c, blocks in rows: tk.append(f"#define POP_MUS_TICKS_{tid:<3d} {(blocks * 40 + 28) // 57:5d}" f" /* {name} */") tk += ["", "#endif /* POP_MUSIC_TICKS_H */", ""] OUT_TICKS.write_text("\n".join(tk)) print(f"итого {total / 1024:.0f} КБ в {OUT_DIR}") print(f"каталог: {OUT_H}") print(f"тики: {OUT_TICKS}") if __name__ == "__main__": main()