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:
2026-08-31 17:17:59 +03:00
parent 2349481b86
commit df5071a967
17 changed files with 587 additions and 330 deletions
@@ -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()