"""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(" (записи, 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("= 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(" записи. Проверки те же, что у 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(" MUS_MAX_PAGES: raise IdxError("запись %d: %d блоков — больше %d страниц, счётчики " "страниц в коде восьмибитные" % (i, blocks, MUS_MAX_PAGES)) if ticks == 0: raise IdxError("запись %d: трек есть, а длительность нулевая" % i)