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
231 lines
12 KiB
Python
231 lines
12 KiB
Python
"""pop_idx.py — ДИСКОВЫЕ ИНДЕКСЫ аудио-наборов SprPoP.
|
||
|
||
Зачем. Раскладка звукового набора (какой эффект в какой странице, по
|
||
какому смещению и какой длины) сегодня печатается упаковщиком в
|
||
gen/pop_sound_tbl.h и становится частью EXE. Из-за этого замена набора
|
||
(SDLPoP <-> MSDOS) требует перекомпиляции игры. Индекс переносит эту
|
||
раскладку НА ДИСК: пара `SND/snd.idx` + `SND/snd.arc` меняется целиком,
|
||
а бинарник остаётся прежним.
|
||
|
||
Формат читают ДВЕ стороны — этот упаковщик и загрузчик на Z80, — поэтому
|
||
писатель и разборщик живут в одном месте и покрыты тестом
|
||
(tools/tests/test_pop_idx.py). Числа little-endian: у Z80 это родной
|
||
порядок, и разбор на нём — просто чтение байтов подряд.
|
||
|
||
PSI1 — индекс ЭФФЕКТОВ, ровно один сектор DSS (512 Б):
|
||
|
||
0..3 'PSI1' магия и версия
|
||
4 57 число плотных записей, id 0..56
|
||
5 5 размер записи
|
||
6 7 log2 размера блока: 1 << 7 = 128
|
||
7 1 формат PCM: 8 бит без знака, моно, CBL_FREQ_10K9
|
||
8 pages сколько PCM-страниц в снд-архиве (1..16)
|
||
9..15 0 резерв
|
||
16..300 57 записей по 5 байт: page (u8), off (u16), len (u16)
|
||
301..511 0 добивка до сектора
|
||
|
||
Размер записи 5 — это НЕ произвольное число, а sizeof(pop_snd_ent_t) в
|
||
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
|
||
|
||
SECTOR = 512
|
||
|
||
# --- PSI1: эффекты ---------------------------------------------------------
|
||
SND_MAGIC = b"PSI1"
|
||
SND_COUNT = 57 # пространство id оригинала: 0..56
|
||
SND_REC = 5 # sizeof(pop_snd_ent_t) в ABI SDCC z80
|
||
SND_HDR = 16
|
||
SND_MAX_PAGES = 16 # compile-time вместимость загрузчика
|
||
BLOCK = 128 # блок запроса CBL при 8 бит
|
||
BLOCK_LOG2 = 7
|
||
PCM_U8_MONO_10K9 = 1 # единственный формат, который умеет наш насос
|
||
PAGE = 16384
|
||
|
||
|
||
class IdxError(Exception):
|
||
"""Индекс не разобрался: битый, чужой версии или противоречивый."""
|
||
|
||
|
||
def snd_pack(entries, pages):
|
||
"""Собрать PSI1 из 57 записей (page, off, len) и числа страниц."""
|
||
if len(entries) != SND_COUNT:
|
||
raise IdxError("записей должно быть %d, а не %d"
|
||
% (SND_COUNT, len(entries)))
|
||
if not 1 <= pages <= SND_MAX_PAGES:
|
||
raise IdxError("страниц %d — вне 1..%d" % (pages, SND_MAX_PAGES))
|
||
|
||
out = bytearray(SECTOR)
|
||
out[0:4] = SND_MAGIC
|
||
out[4] = SND_COUNT
|
||
out[5] = SND_REC
|
||
out[6] = BLOCK_LOG2
|
||
out[7] = PCM_U8_MONO_10K9
|
||
out[8] = pages
|
||
for i, (page, off, ln) in enumerate(entries):
|
||
_check_entry(i, page, off, ln, pages)
|
||
struct.pack_into("<BHH", out, SND_HDR + i * SND_REC, page, off, ln)
|
||
return bytes(out)
|
||
|
||
|
||
def snd_unpack(data):
|
||
"""Разобрать PSI1 -> (записи, pages). Проверки те же, что у Z80."""
|
||
if len(data) != SECTOR:
|
||
raise IdxError("размер %d, а должен быть сектор %d" % (len(data), SECTOR))
|
||
if data[0:4] != SND_MAGIC:
|
||
raise IdxError("не PSI1: %r" % (bytes(data[0:4]),))
|
||
if data[4] != SND_COUNT:
|
||
raise IdxError("записей %d, ожидалось %d" % (data[4], SND_COUNT))
|
||
if data[5] != SND_REC:
|
||
raise IdxError("размер записи %d, ожидался %d" % (data[5], SND_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))
|
||
pages = data[8]
|
||
if not 1 <= pages <= SND_MAX_PAGES:
|
||
raise IdxError("страниц %d — вне 1..%d" % (pages, SND_MAX_PAGES))
|
||
|
||
entries = []
|
||
for i in range(SND_COUNT):
|
||
page, off, ln = struct.unpack_from("<BHH", data, SND_HDR + i * SND_REC)
|
||
_check_entry(i, page, off, ln, pages)
|
||
entries.append((page, off, ln))
|
||
return entries, pages
|
||
|
||
|
||
def _check_entry(i, page, off, ln, pages):
|
||
"""Одна запись: то же, что обязан проверить загрузчик перед EMM.
|
||
|
||
ПЕРВЫЙ БЛОК СТРАНИЦЫ 0 — ТИШИНА. Насос льёт её, когда играть нечего
|
||
(POP_SND_SILENCE_PAGE/OFF), поэтому набор не имеет права положить туда
|
||
эффект: иначе вместо паузы будет обрывок звука по кругу.
|
||
"""
|
||
if ln == 0:
|
||
if (page, off) != (0, 0):
|
||
raise IdxError("запись %d пуста, но page/off не нулевые" % i)
|
||
return
|
||
if page >= pages:
|
||
raise IdxError("запись %d: page %d >= pages %d" % (i, page, pages))
|
||
if off >= PAGE:
|
||
raise IdxError("запись %d: off %d вне страницы" % (i, off))
|
||
if off % BLOCK or ln % BLOCK:
|
||
raise IdxError("запись %d: off/len не кратны блоку %d" % (i, BLOCK))
|
||
if page == 0 and off < BLOCK:
|
||
raise IdxError("запись %d залезает в блок тишины (стр.0, off 0)" % i)
|
||
# Длинный эффект переливается на следующие страницы — они обязаны быть.
|
||
last = (page * PAGE + off + ln - 1) // PAGE
|
||
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)
|