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
245 lines
9.2 KiB
Python
245 lines
9.2 KiB
Python
"""test_pop_idx — формат дисковых индексов аудио (tools/pop_idx.py).
|
|
|
|
Индекс читают ДВЕ стороны: упаковщик на хосте и загрузчик на Z80. Тест
|
|
закрепляет ровно то, на что рассчитывает вторая сторона: точные смещения
|
|
полей, little-endian, размер сектора и НАБОР ОТКАЗОВ — потому что битый
|
|
индекс обязан выключить звук, а не увести насос в чужую память.
|
|
|
|
Запуск: make test-tools (либо python3 -m unittest discover tools/tests)
|
|
"""
|
|
import os
|
|
import struct
|
|
import sys
|
|
import unittest
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
import pop_idx as X
|
|
|
|
|
|
def empty():
|
|
return [(0, 0, 0)] * X.SND_COUNT
|
|
|
|
|
|
class SndFormat(unittest.TestCase):
|
|
"""PSI1: раскладка байтов, на которую смотрит Z80."""
|
|
|
|
def test_size_is_one_sector(self):
|
|
self.assertEqual(len(X.snd_pack(empty(), 1)), X.SECTOR)
|
|
|
|
def test_header_fields(self):
|
|
d = X.snd_pack(empty(), 9)
|
|
self.assertEqual(d[0:4], b"PSI1")
|
|
self.assertEqual(d[4], 57) # число записей
|
|
self.assertEqual(d[5], 5) # sizeof(pop_snd_ent_t) в SDCC z80
|
|
self.assertEqual(d[6], 7) # log2(128)
|
|
self.assertEqual(d[7], 1) # u8 mono 10937.5
|
|
self.assertEqual(d[8], 9) # фактических страниц
|
|
self.assertEqual(d[9:16], b"\0" * 7)
|
|
|
|
def test_entry_is_little_endian_at_fixed_offset(self):
|
|
"""Смещение записи и порядок байтов — контракт с загрузчиком."""
|
|
e = empty()
|
|
e[3] = (2, 0x1500, 0x0480)
|
|
d = X.snd_pack(e, 9)
|
|
at = X.SND_HDR + 3 * X.SND_REC
|
|
self.assertEqual(d[at], 2)
|
|
self.assertEqual(d[at + 1:at + 3], b"\x00\x15") # off LE
|
|
self.assertEqual(d[at + 3:at + 5], b"\x80\x04") # len LE
|
|
self.assertEqual(struct.unpack_from("<BHH", d, at), (2, 0x1500, 0x0480))
|
|
|
|
def test_tail_is_zero_padded(self):
|
|
d = X.snd_pack(empty(), 1)
|
|
self.assertEqual(d[X.SND_HDR + X.SND_COUNT * X.SND_REC:], b"\0" * 211)
|
|
|
|
def test_roundtrip(self):
|
|
e = empty()
|
|
e[10] = (0, 128, 3584)
|
|
e[48] = (8, 0x1400, 128)
|
|
e[56] = (8, 0x2600, 4864)
|
|
d = X.snd_pack(e, 9)
|
|
back, pages = X.snd_unpack(d)
|
|
self.assertEqual(pages, 9)
|
|
self.assertEqual(back, e)
|
|
|
|
|
|
class SndRejects(unittest.TestCase):
|
|
"""Битый индекс обязан отвергаться, а не молча разъезжать раскладку."""
|
|
|
|
def bad(self, mutate):
|
|
e = empty()
|
|
e[1] = (0, 128, 256)
|
|
d = bytearray(X.snd_pack(e, 4))
|
|
mutate(d)
|
|
with self.assertRaises(X.IdxError):
|
|
X.snd_unpack(bytes(d))
|
|
|
|
def test_wrong_magic(self):
|
|
self.bad(lambda d: d.__setitem__(slice(0, 4), b"PSI2"))
|
|
|
|
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, 4))
|
|
|
|
def test_wrong_block_log2(self):
|
|
self.bad(lambda d: d.__setitem__(6, 8))
|
|
|
|
def test_unknown_pcm_format(self):
|
|
self.bad(lambda d: d.__setitem__(7, 2))
|
|
|
|
def test_pages_zero(self):
|
|
self.bad(lambda d: d.__setitem__(8, 0))
|
|
|
|
def test_pages_over_capacity(self):
|
|
self.bad(lambda d: d.__setitem__(8, X.SND_MAX_PAGES + 1))
|
|
|
|
def test_truncated(self):
|
|
e = empty()
|
|
d = X.snd_pack(e, 1)
|
|
with self.assertRaises(X.IdxError):
|
|
X.snd_unpack(d[:-1])
|
|
|
|
def test_page_beyond_set(self):
|
|
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 9, 0, 128))
|
|
|
|
def test_offset_not_block_aligned(self):
|
|
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 1, 100, 128))
|
|
|
|
def test_length_not_block_aligned(self):
|
|
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 1, 128, 100))
|
|
|
|
def test_entry_runs_past_last_page(self):
|
|
"""Эффект длиннее остатка набора увёл бы насос в чужую память."""
|
|
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5,
|
|
3, 0x3F80, 16384))
|
|
|
|
def test_entry_in_silence_block(self):
|
|
"""Первый блок страницы 0 занят тишиной насоса — он неприкосновенен."""
|
|
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 0, 0, 128))
|
|
|
|
def test_empty_entry_must_be_all_zero(self):
|
|
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 2, 256, 0))
|
|
|
|
|
|
class SndPackGuards(unittest.TestCase):
|
|
"""Упаковщик не имеет права выпустить набор, который отвергнет Z80."""
|
|
|
|
def test_rejects_too_many_pages(self):
|
|
with self.assertRaises(X.IdxError):
|
|
X.snd_pack(empty(), X.SND_MAX_PAGES + 1)
|
|
|
|
def test_rejects_wrong_entry_count(self):
|
|
with self.assertRaises(X.IdxError):
|
|
X.snd_pack([(0, 0, 0)] * 56, 1)
|
|
|
|
def test_rejects_entry_in_silence_block(self):
|
|
e = empty()
|
|
e[0] = (0, 0, 128)
|
|
with self.assertRaises(X.IdxError):
|
|
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()
|