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
+15 -94
View File
@@ -23,7 +23,7 @@ open 51,4 мс против 32,6 мс на чтение 16 КБ. Этот же
блоками по 128 байт, и остаток «не полного блока» ему пришлось бы
досчитывать в прерывании.
Длина трека уходит в pop_music_tbl.h блоками по 128 байт, а не байтами:
Длина трека уходит в MUS/mus.idx блоками по 128 байт, а не байтами:
самый длинный трек (won, 115 с) — это 1,2 МБ, что не влезает в uint16, а
в блоках даёт 9855. 32-битной арифметики на Z80 избегаем (см. memory
avoid_32bit_arith_z80).
@@ -35,14 +35,14 @@ from pathlib import Path
import sys as _sys, os as _os
_sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__)))
import paths as P # раскладка каталогов SprPoP (tools/paths.py)
import pop_idx # формат дискового индекса PMI1 (tools/pop_idx.py)
# Формат источника выбирается ключом --format (по умолчанию flac — он же
# лучший по качеству исходник). Каталог и расширение знает tools/paths.py.
FMT = "flac"
SRC_DIR, SRC_EXT = P.music_src(FMT)
OUT_DIR = P.atl("music")
OUT_H = P.GEN / "pop_music_tbl.h"
OUT_TICKS = P.GEN / "pop_music_ticks.h"
OUT_IDX = "mus.idx" # дисковый индекс набора, ложится в MUS/ рядом с треками
CBL_RATE = 10937.5 # CBL_FREQ_10K9 — частота железа, не 11 000
PAGE = 16384 # EMM-страница
@@ -125,98 +125,19 @@ def main() -> None:
print(f" {tid:2d} {TRACKS[tid]:<40} {chunks:2d} стр, "
f"{blocks * BLOCK / 1024:7.1f} КБ, {blocks * BLOCK / CBL_RATE:5.1f} с")
max_id = max(t for t, *_ in rows)
lines = [
"/*",
" * pop_music_tbl.h — каталог музыкальных треков, СГЕНЕРИРОВАН.",
f" * Источник: assets/orig/PoP1_DOS_music/{P.MUSIC_FORMATS[FMT][0]}"
" (записи DOS-версии).",
" * Генератор: tools/pop_pack_music.py. Руками не править.",
" *",
" * Нумерация id — оригинала (SDLPoP data/music/names.txt). pages —",
" * сколько кусков m<id>_<nn>.bin лежит на диске, blocks — длина трека",
" * в порциях насоса по 128 байт (в байтах не влезло бы в uint16).",
" *",
" * Длительности реплик в тиках вынесены в pop_music_ticks.h: таблица",
" * ниже — 171 байт ДАННЫХ, и модулю, которому нужна одна константа,",
" * платить за неё копией таблицы незачем (поймано на pop_intro.c:",
" * +172 байта в банк 11).",
" *",
" * POP_MUS_TICKS_<id> — та же длина в ТИКАХ СЦЕНЫ (60 Гц оригинала).",
" * Нужна там, где сценарий ждёт окончания реплики: у оригинала это",
" * `do { frame(1) } while (check_sound_playing())`, у нас — пауза",
" * длиной в нашу запись. Считается ЗДЕСЬ, а не в C, по двум",
" * причинам: blocks*40 переполняет uint16 на won (9855 порций), и",
" * число обязано соответствовать НАБОРУ, которым собрали (flac и",
" * mt32 — разные исполнения разной длины).",
" *",
" * Отношение 40/57 — обратное к тому, которым шкала сцены переводит",
" * тики в порции насоса (57/40, см. pop_intro.c). Берём именно его,",
" * а не точное 10937,5/128/60, чтобы длина реплики и ход шкалы",
" * считались одной и той же арифметикой. Округление — к ближайшему",
" * (+28 перед делением): отбрасывание вниз теряло по тику на реплику.",
" */",
"#ifndef POP_MUSIC_TBL_H",
"#define POP_MUSIC_TBL_H",
"",
"#include <stdint.h>",
"",
f"#define POP_MUS_COUNT {max_id + 1}",
f"#define POP_MUS_BLOCK {BLOCK}",
f"#define POP_MUS_MAX_PAGES {max(c for _, _, c, _ in rows)}",
"",
]
lines += [
"",
"typedef struct {",
" uint8_t pages; /* 0 — трека нет */",
" uint16_t blocks; /* длина в порциях по 128 байт */",
"} pop_mus_ent_t;",
"",
"static const pop_mus_ent_t pop_mus_tbl[POP_MUS_COUNT] = {",
]
by_id = {t: (c, b, n) for t, n, c, b in rows}
for i in range(max_id + 1):
if i in by_id:
c, b, n = by_id[i]
lines.append(f" {{ {c:2d}, {b:5d} }}, /* {i:2d} {n} */")
else:
lines.append(f" {{ 0, 0 }}, /* {i:2d} */")
lines += ["};", "", "#endif /* POP_MUSIC_TBL_H */", ""]
OUT_H.write_text("\n".join(lines))
# Отдельный заголовок ТОЛЬКО с длительностями реплик. Данных в нём нет,
# поэтому его может включать кто угодно, ничего не утаскивая в свой банк.
tk = [
"/*",
" * pop_music_ticks.h — длительности треков в ТИКАХ СЦЕНЫ, СГЕНЕРИРОВАН.",
f" * Набор: {P.MUSIC_FORMATS[FMT][0]}. Генератор: tools/pop_pack_music.py.",
" * Руками не править.",
" *",
" * Нужны там, где сценарий ждёт окончания реплики: у оригинала это",
" * `do { frame(1) } while (check_sound_playing())`, у нас — пауза длиной",
" * в НАШУ запись. Число обязано соответствовать набору, которым собрана",
" * сборка: одна и та же реплика на flac и mt32 разной длины (m53 — 985",
" * тиков против 1044), и захардкоженная константа молча ломает сцену.",
" *",
" * Считается здесь, а не в C: blocks*40 переполняет uint16 на won",
" * (9855 порций). Отношение 40/57 — обратное к тому, которым шкала",
" * сцены переводит тики в порции насоса (57/40, pop_intro.c); берём",
" * именно его, чтобы длина реплики и ход шкалы считались одной",
" * арифметикой. Округление к ближайшему: вниз теряло по тику.",
" */",
"#ifndef POP_MUSIC_TICKS_H",
"#define POP_MUSIC_TICKS_H",
"",
]
for tid, name, _c, blocks in rows:
tk.append(f"#define POP_MUS_TICKS_{tid:<3d} {(blocks * 40 + 28) // 57:5d}"
f" /* {name} */")
tk += ["", "#endif /* POP_MUSIC_TICKS_H */", ""]
OUT_TICKS.write_text("\n".join(tk))
# ДИСКОВЫЙ ИНДЕКС набора: длины треков и длительности реплик. Пока
# печатаются и заголовки выше — переход на runtime идёт по шагам, и
# индекс обязан описывать ровно то же, что они (docs/music_runtime_index_plan.md).
# Тики считает pop_idx.mus_ticks — общая формула для обеих сторон.
if len(rows) != len(TRACKS):
print(f" ВНИМАНИЕ: упаковано {len(rows)} треков из {len(TRACKS)}"
f"индекс описывает только их")
entries = [(0, 0)] * pop_idx.MUS_COUNT
for tid, _name, _chunks, blocks in rows:
entries[tid] = (blocks, pop_idx.mus_ticks(blocks))
(OUT_DIR / OUT_IDX).write_bytes(pop_idx.mus_pack(entries))
print(f"индекс: {OUT_DIR / OUT_IDX}")
print(f"итого {total / 1024:.0f} КБ в {OUT_DIR}")
print(f"каталог: {OUT_H}")
print(f"тики: {OUT_TICKS}")
if __name__ == "__main__":