SprPoP: музыка без перелинковки — длины и длительности уехали на диск
Часть 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
This commit is contained in:
@@ -30,6 +30,30 @@ ABI SDCC z80 (uint8 + uint16 + uint16 без выравнивания). Заг
|
||||
молча разъедет всю раскладку; на стороне C это проверяется статически.
|
||||
|
||||
Нулевая `len` означает, что у этого id нет оцифровки (это музыка).
|
||||
|
||||
PMI1 — индекс МУЗЫКИ, тоже один сектор:
|
||||
|
||||
0..3 'PMI1' магия и версия
|
||||
4 57 число плотных записей, id 0..56
|
||||
5 4 размер записи
|
||||
6 7 log2 размера блока
|
||||
7 1 формат PCM
|
||||
8..15 0 резерв
|
||||
16..243 57 записей по 4 байта: blocks (u16), ticks60 (u16)
|
||||
244..511 0 добивка до сектора
|
||||
|
||||
Числа страниц здесь НЕТ намеренно: это производная от `blocks`, и
|
||||
дублирование однажды разъедется с ним. Загрузчик считает сам:
|
||||
|
||||
pages = blocks / 128 + ((blocks & 127) != 0)
|
||||
|
||||
`ticks60` — длительность в тиках сцены (60 Гц оригинала), нужная там, где
|
||||
сценарий ждёт конца реплики. Считается из blocks отношением 40/57 —
|
||||
обратным к тому, которым шкала сцены переводит тики в порции насоса.
|
||||
Хранится ОТДЕЛЬНО, а не выводится в рантайме: так момент окончания
|
||||
реплики можно будет уточнить независимо от технического хвоста PCM.
|
||||
|
||||
Нулевой `blocks` означает, что трека с таким id в наборе нет.
|
||||
"""
|
||||
|
||||
import struct
|
||||
@@ -124,3 +148,83 @@ def _check_entry(i, page, off, ln, pages):
|
||||
if last >= pages:
|
||||
raise IdxError("запись %d выходит за набор: кончается в стр.%d из %d"
|
||||
% (i, last, pages))
|
||||
|
||||
|
||||
# --- PMI1: музыка ----------------------------------------------------------
|
||||
MUS_MAGIC = b"PMI1"
|
||||
MUS_COUNT = 57 # то же пространство id оригинала
|
||||
MUS_REC = 4
|
||||
MUS_HDR = 16
|
||||
# Потоковый трек (финальная тема) заведомо длиннее обычных, и предел ему
|
||||
# ставят не страницы загрузчика, а восьмибитные счётчики страниц в коде.
|
||||
MUS_MAX_PAGES = 255
|
||||
|
||||
|
||||
def mus_ticks(blocks):
|
||||
"""Длительность в тиках сцены (60 Гц) по длине в блоках.
|
||||
|
||||
Отношение 40/57 — обратное к тому, которым шкала сцены переводит тики
|
||||
в порции насоса (57/40, pop_intro.c). Берём именно его, а не точное
|
||||
10937,5/128/60: длина реплики и ход шкалы обязаны считаться ОДНОЙ
|
||||
арифметикой, иначе они разъедутся на длинных треках. Округление к
|
||||
ближайшему: отбрасывание вниз теряло по тику на реплику.
|
||||
"""
|
||||
return (blocks * 40 + 28) // 57
|
||||
|
||||
|
||||
def mus_pack(entries):
|
||||
"""Собрать PMI1 из 57 записей (blocks, ticks60)."""
|
||||
if len(entries) != MUS_COUNT:
|
||||
raise IdxError("записей должно быть %d, а не %d"
|
||||
% (MUS_COUNT, len(entries)))
|
||||
out = bytearray(SECTOR)
|
||||
out[0:4] = MUS_MAGIC
|
||||
out[4] = MUS_COUNT
|
||||
out[5] = MUS_REC
|
||||
out[6] = BLOCK_LOG2
|
||||
out[7] = PCM_U8_MONO_10K9
|
||||
for i, (blocks, ticks) in enumerate(entries):
|
||||
_check_mus(i, blocks, ticks)
|
||||
struct.pack_into("<HH", out, MUS_HDR + i * MUS_REC, blocks, ticks)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def mus_unpack(data):
|
||||
"""Разобрать PMI1 -> записи. Проверки те же, что у Z80."""
|
||||
if len(data) != SECTOR:
|
||||
raise IdxError("размер %d, а должен быть сектор %d" % (len(data), SECTOR))
|
||||
if data[0:4] != MUS_MAGIC:
|
||||
raise IdxError("не PMI1: %r" % (bytes(data[0:4]),))
|
||||
if data[4] != MUS_COUNT:
|
||||
raise IdxError("записей %d, ожидалось %d" % (data[4], MUS_COUNT))
|
||||
if data[5] != MUS_REC:
|
||||
raise IdxError("размер записи %d, ожидался %d" % (data[5], MUS_REC))
|
||||
if data[6] != BLOCK_LOG2:
|
||||
raise IdxError("log2 блока %d, ожидался %d" % (data[6], BLOCK_LOG2))
|
||||
if data[7] != PCM_U8_MONO_10K9:
|
||||
raise IdxError("формат PCM %d — насос умеет только %d"
|
||||
% (data[7], PCM_U8_MONO_10K9))
|
||||
entries = []
|
||||
for i in range(MUS_COUNT):
|
||||
blocks, ticks = struct.unpack_from("<HH", data, MUS_HDR + i * MUS_REC)
|
||||
_check_mus(i, blocks, ticks)
|
||||
entries.append((blocks, ticks))
|
||||
return entries
|
||||
|
||||
|
||||
def mus_pages(blocks):
|
||||
"""Сколько EMM-страниц занимает трек такой длины."""
|
||||
return (blocks + 127) // 128
|
||||
|
||||
|
||||
def _check_mus(i, blocks, ticks):
|
||||
if blocks == 0:
|
||||
if ticks:
|
||||
raise IdxError("запись %d: трека нет, но тики ненулевые" % i)
|
||||
return
|
||||
if mus_pages(blocks) > MUS_MAX_PAGES:
|
||||
raise IdxError("запись %d: %d блоков — больше %d страниц, счётчики "
|
||||
"страниц в коде восьмибитные"
|
||||
% (i, blocks, MUS_MAX_PAGES))
|
||||
if ticks == 0:
|
||||
raise IdxError("запись %d: трек есть, а длительность нулевая" % i)
|
||||
|
||||
@@ -23,7 +23,7 @@ open 51,4 мс против 32,6 мс на чтение 16 КБ. Этот же
|
||||
блоками по 128 байт, и остаток «не полного блока» ему пришлось бы
|
||||
досчитывать в прерывании.
|
||||
|
||||
Длина трека уходит в pop_music_tbl.h блоками по 128 байт, а не байтами:
|
||||
Длина трека уходит в MUS/mus.idx блоками по 128 байт, а не байтами:
|
||||
самый длинный трек (won, 115 с) — это 1,2 МБ, что не влезает в uint16, а
|
||||
в блоках даёт 9855. 32-битной арифметики на Z80 избегаем (см. memory
|
||||
avoid_32bit_arith_z80).
|
||||
@@ -35,14 +35,14 @@ 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_H = P.GEN / "pop_music_tbl.h"
|
||||
OUT_TICKS = P.GEN / "pop_music_ticks.h"
|
||||
OUT_IDX = "mus.idx" # дисковый индекс набора, ложится в MUS/ рядом с треками
|
||||
|
||||
CBL_RATE = 10937.5 # CBL_FREQ_10K9 — частота железа, не 11 000
|
||||
PAGE = 16384 # EMM-страница
|
||||
@@ -125,98 +125,19 @@ def main() -> None:
|
||||
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))
|
||||
# ДИСКОВЫЙ ИНДЕКС набора: длины треков и длительности реплик. Пока
|
||||
# печатаются и заголовки выше — переход на 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}")
|
||||
print(f"каталог: {OUT_H}")
|
||||
print(f"тики: {OUT_TICKS}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -140,5 +140,105 @@ class SndPackGuards(unittest.TestCase):
|
||||
X.snd_pack(e, 1)
|
||||
|
||||
|
||||
def mus_empty():
|
||||
return [(0, 0)] * X.MUS_COUNT
|
||||
|
||||
|
||||
class MusFormat(unittest.TestCase):
|
||||
"""PMI1: раскладка байтов индекса музыки."""
|
||||
|
||||
def test_size_is_one_sector(self):
|
||||
self.assertEqual(len(X.mus_pack(mus_empty())), X.SECTOR)
|
||||
|
||||
def test_header_fields(self):
|
||||
d = X.mus_pack(mus_empty())
|
||||
self.assertEqual(d[0:4], b"PMI1")
|
||||
self.assertEqual(d[4], 57)
|
||||
self.assertEqual(d[5], 4)
|
||||
self.assertEqual(d[6], 7)
|
||||
self.assertEqual(d[7], 1)
|
||||
self.assertEqual(d[8:16], b"\0" * 8)
|
||||
|
||||
def test_entry_is_little_endian_at_fixed_offset(self):
|
||||
e = mus_empty()
|
||||
e[41] = (1043, 732)
|
||||
d = X.mus_pack(e)
|
||||
at = X.MUS_HDR + 41 * X.MUS_REC
|
||||
self.assertEqual(d[at:at + 2], b"\x13\x04") # blocks LE
|
||||
self.assertEqual(d[at + 2:at + 4], b"\xdc\x02") # ticks60 LE
|
||||
self.assertEqual(struct.unpack_from("<HH", d, at), (1043, 732))
|
||||
|
||||
def test_roundtrip(self):
|
||||
e = mus_empty()
|
||||
e[24] = (305, X.mus_ticks(305))
|
||||
e[56] = (9865, X.mus_ticks(9865))
|
||||
self.assertEqual(X.mus_unpack(X.mus_pack(e)), e)
|
||||
|
||||
|
||||
class MusTicks(unittest.TestCase):
|
||||
"""Формула тиков закреплена ЗНАЧЕНИЯМИ нынешней сборки (набор flac).
|
||||
|
||||
Она переехала из упаковщика в общий модуль, и разойтись ей нельзя:
|
||||
по этим числам сцены отмеряют паузы, пока они не перешли на ожидание
|
||||
звука. Слева — blocks из gen/pop_music_tbl.h, справа — тики из
|
||||
gen/pop_music_ticks.h.
|
||||
"""
|
||||
|
||||
CASES = ((624, 438), # m32 end_level_4
|
||||
(1043, 732), # m41 end_level
|
||||
(1184, 831), # m50 princess
|
||||
(1403, 985), # m53 Jaffar
|
||||
(9865, 6923), # m56 won — самый длинный
|
||||
(305, 214), # m24 regular_death — самый короткий из игровых
|
||||
(196, 138)) # m25
|
||||
|
||||
def test_matches_current_build(self):
|
||||
for blocks, ticks in self.CASES:
|
||||
self.assertEqual(X.mus_ticks(blocks), ticks, "blocks=%d" % blocks)
|
||||
|
||||
def test_no_uint16_overflow_on_longest(self):
|
||||
"""blocks*40 переполнил бы uint16 — формула обязана это переживать."""
|
||||
self.assertLess(X.mus_ticks(65535), 65536)
|
||||
|
||||
def test_pages_rounds_up(self):
|
||||
self.assertEqual(X.mus_pages(128), 1)
|
||||
self.assertEqual(X.mus_pages(129), 2)
|
||||
self.assertEqual(X.mus_pages(9865), 78) # m56 flac
|
||||
self.assertEqual(X.mus_pages(10462), 82) # m56 mt32 — длиннее
|
||||
|
||||
|
||||
class MusRejects(unittest.TestCase):
|
||||
def bad(self, mutate):
|
||||
e = mus_empty()
|
||||
e[41] = (1043, 732)
|
||||
d = bytearray(X.mus_pack(e))
|
||||
mutate(d)
|
||||
with self.assertRaises(X.IdxError):
|
||||
X.mus_unpack(bytes(d))
|
||||
|
||||
def test_wrong_magic(self):
|
||||
self.bad(lambda d: d.__setitem__(slice(0, 4), b"PSI1"))
|
||||
|
||||
def test_wrong_count(self):
|
||||
self.bad(lambda d: d.__setitem__(4, 56))
|
||||
|
||||
def test_wrong_record_size(self):
|
||||
self.bad(lambda d: d.__setitem__(5, 5))
|
||||
|
||||
def test_unknown_pcm_format(self):
|
||||
self.bad(lambda d: d.__setitem__(7, 3))
|
||||
|
||||
def test_ticks_without_track(self):
|
||||
self.bad(lambda d: struct.pack_into("<HH", d, X.MUS_HDR + 5 * 4, 0, 100))
|
||||
|
||||
def test_track_without_ticks(self):
|
||||
self.bad(lambda d: struct.pack_into("<HH", d, X.MUS_HDR + 5 * 4, 500, 0))
|
||||
|
||||
def test_track_longer_than_page_counters(self):
|
||||
"""Больше 255 страниц не переживут восьмибитные счётчики в коде."""
|
||||
self.bad(lambda d: struct.pack_into("<HH", d, X.MUS_HDR + 5 * 4,
|
||||
65535, 1000))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user