2349481b86
Часть II плана music_runtime_index_plan.md (SI0..SI4). gen/pop_sound_tbl.h БОЛЬШЕ НЕ ГЕНЕРИРУЕТСЯ: раскладка набора читается из SND/snd.idx (формат PSI1, писатель и разборщик — tools/pop_idx.py, 22 теста в make test-tools). Один и тот же sprpop.exe работает с набором SDLPoP (9 страниц) и MSDOS (10) — sha256 бинарника при смене набора не меняется. Заодно умолчание источника эффектов переведено на SDLPoP (SND_SRC=sdlpop): сборка обязана работать без оригинального дистрибутива DOS. У кого он есть, включает лучший набор явно — make SND_SRC=msdos (там полнее оцифровка: в SDLPoP звук 48 spiked пустой). Устройство: pop_snd_tbl/pop_snd_page/pop_snd_pages — резидентные данные (pop_snd_data.c), тип и инварианты — рукописный pop_snd_tbl.h. Записи читаются ОДНИМ read прямо в таблицу, поэтому sizeof(pop_snd_ent_t) == 5 стало частью дискового контракта: проверяется статически и полем размера записи в заголовке. POP_SND_PAGES как compile-time размер набора исчез — вместо него POP_SND_MAX_PAGES (вместимость, 16) и runtime pop_snd_pages. Цена: таблица переехала из _CODE в _DATA, суммарный резидент почти не изменился (куча 239 -> 229 Б); банк 8 +601 Б на чтение и валидацию. Валидация не доверяет файлу: заголовок целиком плюс каждая запись (страница, смещение, кратность блоку, непересечение с блоком тишины, выход за последнюю страницу). Последнее считается В БЛОКАХ — байтовый адрес конца не влезает в uint16, а 32-битная арифметика на Z80 дорога. НЕТ ИНДЕКСА — ЭФФЕКТОВ НЕТ, НО МУЗЫКА ИГРАЕТ. Первая версия просто возвращала ошибку, и игра становилась непроходимой: тишину льёт первый блок набора, без набора CBL не открывался, а с ним вставала музыка (её блоки считает тот же насос) — заставка ждала конца трека вечно. Теперь поднимается пустой набор с блоком тишины. Заливается ровно 128 байт и под DI: gfx_w0_page_prepare ставит в страницу IRQ-стабы, и заливка всей страницы затирала их — первое же прерывание давало чёрный экран. Грабли сборки: смена SND_SRC тихо давала неверный результат (sdlpop -> msdos -> sdlpop оставлял чужой набор в assets/packed). Причина не в логике, а в секундной гранулярности mtime. Лечение убирает время из решения: смена варианта сносит stamp'ы своего семейства, а упаковка, сборка архива и копия индекса делаются одним рецептом. То же получила и музыка (MUSIC_FMT). Проверено в MAME: таблица в памяти совпадает с файлом из образа побайтово; один EXE поднимает оба набора; отладочный --order reverse (30 из 31 записей отличаются от штатных) звучит правильно; битый индекс выключает эффекты, не роняя игру; без индекса PV-сцена проходит с музыкой; Ctrl+S работает в обоих режимах. Разбор — docs/sound_plan.md §10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
145 lines
5.4 KiB
Python
145 lines
5.4 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)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|