31b82661eb
Порт PoP переехал в applications/SprPoP — приложение, которое собирается само: код, оригинальные данные, конверторы ресурсов и сборка внутри одной папки. Наружу знает единственный путь — корень тулчейна (SPRINTER_ROOT, по умолчанию ../..). applications/PoP/roomtest ЗАМОРОЖЕНА и остаётся архивом закрытых задач, багов и исполненных планов. Скопировано из applications/PoP/roomtest@4b74478. Перенос проверен побайтово: собранный sprpop.exe совпал с roomtest.exe того же коммита, все 39 дисковых ресурсов и все 16 генерируемых заголовков — тоже, host- тесты зелёные (15/15). Раскладка: src/ рукописный C (roomtest.c -> sprpop.c) gen/ генерируемые заголовки, в репозитории assets/orig/ оригинальные данные игры, вне репозитория (копирайт) assets/packed/ то, что ложится на диск, в раскладке диска tools/ конверторы; все пути — в одном tools/paths.py build/ выход: exe, каталоги ресурсов, hdd/, промежуточные atl/ Сборка ресурсов: assets/packed и gen — версионируемые ВХОДЫ, а не то, что пересчитывается каждым make. Автоматика построена на ОТСУТСТВИИ файла, а не на таймстемпах: git не хранит времена, и в свежем клоне сравнение по времени превращалось бы в лотерею. Недостающий ресурс или заголовок чинится сам, рекурсивным вызовом в ветку генерации. Музыка собирается из любого из четырёх наборов записей (make music-mp3, music-mt32, ...); набор входит в имя stamp'а, поэтому смена набора сама делает музыку устаревшей. Длины реплик больше не захардкожены: упаковщик печатает их в gen/pop_music_ticks.h, и шкала сцены выражена через них — иначе mt32 (реплики на 6% длиннее) молча ломал катсцену. Тулчейн: в app.mk два обратносовместимых крючка (SRC_DIR/BUILD_DIR), HDD_IMG стал ?=; команда сборки roomtest не изменилась. Корневой make host-tests переключён на SprPoP. Подгонка тайминга катсцены с принцессой (PV_MAGIC_LEAD): сцена render-bound и идёт ~49 тиков/с вместо 60, из-за чего кода реплики приходила раньше молнии. Это обход, а не лечение; разбор с замерами — docs/BUGS_OPEN.md, записи SND-PACE-DEAD, PV-RENDER-BOUND, MUS-LEFT-TEAR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
224 lines
12 KiB
Python
224 lines
12 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 байт, и остаток «не полного блока» ему пришлось бы
|
||
досчитывать в прерывании.
|
||
|
||
Длина трека уходит в 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<id>_<nn>.bin лежит на диске, blocks — длина трека",
|
||
" * в порциях насоса по 128 байт (в байтах не влезло бы в uint16).",
|
||
" *",
|
||
" * Длительности реплик в тиках вынесены в pop_music_ticks.h: таблица",
|
||
" * ниже — 171 байт ДАННЫХ, и модулю, которому нужна одна константа,",
|
||
" * платить за неё копией таблицы незачем (поймано на pop_intro.c:",
|
||
" * +172 байта в банк 11).",
|
||
" *",
|
||
" * POP_MUS_TICKS_<id> — та же длина в ТИКАХ СЦЕНЫ (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 <stdint.h>",
|
||
"",
|
||
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()
|