df5071a967
Часть I плана music_runtime_index_plan.md (MI0..MI5). gen/pop_music_tbl.h и gen/pop_music_ticks.h УДАЛЕНЫ: длины треков и длительности реплик читаются из MUS/mus.idx (формат PMI1, tools/pop_idx.py, тесты в make test-tools). Один и тот же sprpop.exe работает с любым из четырёх наборов записей — sha256 бинарника при смене MUSIC_FMT не меняется. ГДЕ ЖИВЁТ ИНДЕКС. 228 байт таблицы в W2 не положить (свободной кучи там порядка двух сотен), поэтому индекс лежит в одной странице EMM, а в резиденте от него два байта. Данные в странице — со смещения 0x100: gfx_w0_page_prepare пишет в неё стабы прерываний (0x38 и 0x66), и с нуля они попали бы прямо в записи id 10 и 21. Со смещением работает штатная защита, а не запрет прерываний (тот же приём, что CFG_BASE в pop_config.c). Число страниц в индексе не хранится — считается из blocks, чтобы не разъехалось. ПАУЗА КОНЦА УРОВНЯ — СОСТОЯНИЕМ, А НЕ СЧЁТЧИКОМ. pop_endmus_left и POP_MUS_TICKS_32/41 удалены; главный цикл ждёт pop_music_active() — «заявка лежит, идёт загрузка или трек звучит». Одного busy мало: между заявкой и первой нотой 190-230 мс (замер в sound_plan §9). Прежний счётчик закрывал эту щель ценой зависимости EXE от набора и жёсткого делителя /4, который врал в режимах FAST/FASTEST (там логический кадр 3 кадра луча, а не 4). Побочно исправилось расхождение с SDLPoP: при выключенном звуке заявка не кладётся, и уровень меняется сразу, как в оригинале (seg006:651 + seg003:387) — раньше игра держала пройденный уровень лишние 12 секунд в тишине. PV-СЦЕНА — на четырёх якорях (8 байт статики), которые считаются из индекса при входе в сцену; прежние выражения шкалы не изменились. План предлагал протащить структуру времён через пять функций — для сцены, которая идёт раз за запуск, это того не стоит. ПАМЯТЬ. За обе фазы резидент не вырос, а освободился: _CODE 23865 -> 23544, куча 239 -> 256 Б. Банк 9 похудел на 118 Б (ушла pop_mus_tbl из rodata), банк 11 — на длительности реплик. ПРОВЕРЕНО В MAME: exe побайтово одинаков для flac и mt32; все 22 трека в индексах различаются, и контрольные значения совпали с предсказанными планом (m41 732->685, m50 831->867, m53 985->1044, m56 9865->10462 блоков, 78->82 страницы); на mt32 PV-сцена проходит целиком по его длительностям; без mus.idx музыки нет, эффекты работают, игра проходима. НЕ ПРОВЕРЕНО: потоковый m56 на 82 страницах — до финала надо дойти в игре. Единственный оставшийся пункт приёмки, отмечен в sound_plan §11.5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
145 lines
7.2 KiB
Python
145 lines
7.2 KiB
Python
#!/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 байт, и остаток «не полного блока» ему пришлось бы
|
||
досчитывать в прерывании.
|
||
|
||
Длина трека уходит в MUS/mus.idx блоками по 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)
|
||
import pop_idx # формат дискового индекса PMI1 (tools/pop_idx.py)
|
||
|
||
# Формат источника выбирается ключом --format (по умолчанию flac — он же
|
||
# лучший по качеству исходник). Каталог и расширение знает tools/paths.py.
|
||
FMT = "flac"
|
||
SRC_DIR, SRC_EXT = P.music_src(FMT)
|
||
OUT_DIR = P.atl("music")
|
||
OUT_IDX = "mus.idx" # дисковый индекс набора, ложится в MUS/ рядом с треками
|
||
|
||
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} с")
|
||
|
||
# ДИСКОВЫЙ ИНДЕКС набора: длины треков и длительности реплик. Пока
|
||
# печатаются и заголовки выше — переход на runtime идёт по шагам, и
|
||
# индекс обязан описывать ровно то же, что они (docs/music_runtime_index_plan.md).
|
||
# Тики считает pop_idx.mus_ticks — общая формула для обеих сторон.
|
||
if len(rows) != len(TRACKS):
|
||
print(f" ВНИМАНИЕ: упаковано {len(rows)} треков из {len(TRACKS)} — "
|
||
f"индекс описывает только их")
|
||
entries = [(0, 0)] * pop_idx.MUS_COUNT
|
||
for tid, _name, _chunks, blocks in rows:
|
||
entries[tid] = (blocks, pop_idx.mus_ticks(blocks))
|
||
(OUT_DIR / OUT_IDX).write_bytes(pop_idx.mus_pack(entries))
|
||
print(f"индекс: {OUT_DIR / OUT_IDX}")
|
||
print(f"итого {total / 1024:.0f} КБ в {OUT_DIR}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|