From 2349481b86f855e1f0fac50debbb327ece63a706 Mon Sep 17 00:00:00 2001 From: Alexander Petrov Date: Mon, 31 Aug 2026 16:14:53 +0300 Subject: [PATCH] =?UTF-8?q?SprPoP:=20=D0=B7=D0=B2=D1=83=D0=BA=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=8D=D1=84=D1=84=D0=B5=D0=BA=D1=82=D1=8B=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=D0=BA=D0=B8=20=E2=80=94=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=20=D1=83=D0=B5=D1=85=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=20=D0=BD=D0=B0=20=D0=B4=D0=B8=D1=81=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Часть 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 Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY --- applications/SprPoP/CLAUDE.md | 19 +- applications/SprPoP/Makefile | 118 ++++++++- applications/SprPoP/README.md | 21 +- applications/SprPoP/assets/packed/SND/snd.arc | Bin 164352 -> 147968 bytes applications/SprPoP/assets/packed/SND/snd.idx | Bin 0 -> 512 bytes applications/SprPoP/docs/README.md | 1 + applications/SprPoP/docs/ideas_backlog.md | 48 ++++ .../SprPoP/docs/music_runtime_index_plan.md | 36 ++- applications/SprPoP/docs/sound_plan.md | 245 +++++++++++++++++- applications/SprPoP/gen/pop_sound_tbl.h | 97 ------- applications/SprPoP/src/_pop_sfx.h | 11 +- applications/SprPoP/src/pop_sfx.c | 5 +- applications/SprPoP/src/pop_sfx_cold.c | 161 +++++++++++- applications/SprPoP/src/pop_snd_data.c | 34 +++ applications/SprPoP/src/pop_snd_tbl.h | 50 ++++ applications/SprPoP/tools/paths.py | 46 +++- applications/SprPoP/tools/pop_idx.py | 126 +++++++++ applications/SprPoP/tools/pop_pack_sound.py | 122 +++++---- .../SprPoP/tools/tests/test_pop_idx.py | 144 ++++++++++ 19 files changed, 1077 insertions(+), 207 deletions(-) create mode 100644 applications/SprPoP/assets/packed/SND/snd.idx delete mode 100644 applications/SprPoP/gen/pop_sound_tbl.h create mode 100644 applications/SprPoP/src/pop_snd_data.c create mode 100644 applications/SprPoP/src/pop_snd_tbl.h create mode 100644 applications/SprPoP/tools/pop_idx.py create mode 100644 applications/SprPoP/tools/tests/test_pop_idx.py diff --git a/applications/SprPoP/CLAUDE.md b/applications/SprPoP/CLAUDE.md index 77a63d2..ed1aa09 100644 --- a/applications/SprPoP/CLAUDE.md +++ b/applications/SprPoP/CLAUDE.md @@ -17,8 +17,9 @@ sprinter-cc / libc / libbgi. Действуют правила корневог ## Главное правило: SDLPoP — источник истины. Сначала читай, потом кодь **`assets/orig/SDLPoP/src/` (github.com/NagyD/SDLPoP, GPLv3) — ЕДИНСТВЕННЫЙ -авторитетный источник того, как оригинальный движок это делает.** Правило -без исключений: +авторитетный источник того, как оригинальный движок это делает.** В +репозитории его нет: заполняется `make fetch` (`make fetch-check` — что уже +на месте). Правило без исключений: 1. **Перед реализацией ЛЮБОЙ функции** (движение, коллизия, окклюзия, падение, loose-полы, стражники, отрисовка, тайминги, любые числовые @@ -69,6 +70,8 @@ make собрать build/sprpop.exe и разложить build make hdd + образ build/hdd/sprpop.chd make mame-link однократно: подставить образ в MAME (см. ниже) make -C tests/host модульные тесты движка под ucsim_z80 (секунды, без MAME) +make test-tools тесты упаковщиков на хосте (форматы, которые читает Z80) +make fetch скачать внешние данные в assets/orig/ (SDLPoP + музыка) make resources перегенерировать ресурсы из assets/orig/ make music-mp3 музыка из другого набора (flac|mp3|ogg|mt32) make clean снести build/ (ассеты не трогает) @@ -93,13 +96,19 @@ make distclean clean + снести assets/packed/ (вернуть — mak |---|---| | `src/` | рукописный C: главный цикл `sprpop.c`, холодная половина `sprpop_cold.c`, движок `pop_*.c` | | `gen/` | генерируемые заголовки (индексы архивов, таблицы кадров, шрифт, палитры). **Руками не править** — их печатают упаковщики; лежат в репозитории, потому что без них `src/` не собрать | -| `assets/orig/` | оригинальные данные (SDLPoP, MSDOS, записи музыки). Вне репозитория — копирайт; что и откуда взять, написано в `assets/orig/README.md` | +| `assets/orig/` | оригинальные данные (SDLPoP, записи музыки; MSDOS — необязательно). Вне репозитория — копирайт; вместо них в репозитории лежит `tools/fetch_orig.py`, который их качает (`make fetch`). Правила — в `assets/orig/README.md` | | `assets/packed/` | то, что ложится на диск игры, уже в раскладке диска (`BG/`, `KID/`, …). В репозитории — иначе из чистого клона не собрать | -| `tools/` | конверторы ресурсов. Раскладку путей знает ОДИН файл — `tools/paths.py`; менять пути нужно там, а не в отдельных упаковщиках | +| `tools/` | конверторы ресурсов. Раскладку путей знает ОДИН файл — `tools/paths.py`; менять пути нужно там, а не в отдельных упаковщиках. Адреса ВНЕШНИХ источников — так же в одном: `tools/fetch_orig.py` | | `docs/` | планы, доски, справочники; `docs/PoP/` — форматы ресурсов оригинала | | `tests/host/` | модульные тесты движка под ucsim_z80 | | `build/` | выход: `sprpop.exe`, каталоги ресурсов, `hdd/`, промежуточные `atl/` | +Звуковые ЭФФЕКТЫ по умолчанию берутся из SDLPoP (`SND_SRC=sdlpop`) — сборка +обязана работать без оригинального дистрибутива DOS. У кого лежит +`assets/orig/MSDOS/`, включает его набор явно (`make SND_SRC=msdos`): там +оцифровка полнее — в SDLPoP пуст звук 48 `spiked` (насаживание на пики). +Разбор — `docs/sound_plan.md`. + Музыка собирается из одного из четырёх наборов записей (`assets/orig/PoP1_DOS_music/`): `flac` по умолчанию, плюс `mp3`, `ogg` и `mt32` (исполнение Roland MT-32 — звучит иначе, длина треков другая). Набор @@ -153,6 +162,8 @@ Makefile склеивает архивы прямо в `assets/packed/<КАТА | `pop_kid.c/.h` | Анимация/движение: интерпретатор seqtbl `play_seq` + frame_table (`seg006`), окна `Char` (loadkid/loadshad), атласы Кида. | | `pop_cdraw.c/.h` | ОТРИСОВКА персонажей — одна на всех Char (порт `add_kid_to_objtable`/`add_guard_to_objtable`): спрайт, clip_char, брызги, клинок, heal; полоса HP и палитры соперника. Там же ПРОПУСК неизменившегося кадра (`pop_char_skip_mask`). | | `pop_ctrl.c/.h` | Управление: диспетчер `control()` (`seg005`) + ввод `read_user_control` (`seg006`) поверх ``. | +| `pop_sfx.c` + `pop_sfx_cold.c` | Звук: насос CBL в резиденте + холодная загрузка набора. Раскладка эффектов НЕ компилируется в EXE — читается с диска (`SND/snd.idx`, формат `PSI1`); см. `docs/sound_plan.md` §10. | +| `pop_snd_tbl.h` + `pop_snd_data.c` | Тип записи набора и инварианты (рукописный заголовок) + резидентные `pop_snd_tbl`/`pop_snd_page`/`pop_snd_pages`. Размер записи 5 байт — часть дискового контракта, проверяется статически. | | `pop_map.c/.h` | Коллизия с картой + физика падения/приземления/стены + loose-полы (`seg005/006/007`). | ## Банки: две мины, которых компилятор не видит diff --git a/applications/SprPoP/Makefile b/applications/SprPoP/Makefile index 3d5db69..eddc4df 100644 --- a/applications/SprPoP/Makefile +++ b/applications/SprPoP/Makefile @@ -100,6 +100,7 @@ BANK_FLAGS := $(foreach n,$(BANK_NUMS),\ # Модули резидента (кроме sprpop.c — его подставляет app.mk). EXTRA_SRCS := $(addprefix $(SRC_DIR)/,pop_vflip.c pop_pace.c pop_sfx.c pop_state.c \ + pop_snd_data.c \ pop_draw.c pop_tile.c pop_kid.c pop_level.c pop_geom.c pop_guard.c \ pop_qsave_io.c) @@ -166,7 +167,10 @@ SHADOW_ATL := $(foreach n,$(KID_N),$(ATL)/shadow/sk$(n).atl) \ # Звук: оригинальная оцифровка на частоте CBL (10 937,5 Гц, 8 бит моно). # Число файлов задаёт упаковщик (POP_SND_PAGES в gen/pop_sound_tbl.h) — # список ОБЯЗАН совпадать: недостающий файл это молча пропавшие эффекты. -SND_ATL := $(foreach n,0 1 2 3 4 5 6 7 8 9,$(ATL)/sound/s$(n).bin) +# Страниц звука СТАТИЧЕСКИ НЕ ПОСЧИТАТЬ: их число зависит от источника +# (SDLPoP даёт 9, MSDOS — 10, см. SND_SRC). Поэтому списка s0..sN здесь нет, +# а архив зависит от stamp'а упаковщика и берёт страницы из каталога уже +# после того, как тот отработал (см. правило SND/snd.arc ниже). TITLE_ATL := $(foreach s,0 1 2 3,$(foreach p,0 1 2 3 4,$(ATL)/title/t$(s)_$(p).atl)) # PV/INTRO: story и стадии режутся на полосы; у настоящего PV intro есть ещё # чистый фон b0_* и шесть <=16K actor-атласов — они остаются в EMM на время @@ -189,6 +193,15 @@ MUS_IDS := 24 25 26 27 28 29 30 32 33 35 36 37 39 40 41 43 50 52 53 54 55 56 # Формат входит в имя stamp'а, поэтому смена MUSIC_FMT сама по себе делает # музыку устаревшей — принудительная пересборка не нужна. MUSIC_FMT ?= flac +# Источник оцифрованных ЭФФЕКТОВ (digisnd1..3): sdlpop (умолчание) или msdos. +# Умолчание — SDLPoP, потому что он выкачивается автоматически (`make fetch`) +# и сборка обязана работать вообще без оригинального дистрибутива DOS. +# Наборы НЕ одинаковы: в MSDOS оцифровка полнее (в SDLPoP `spiked` (48) +# фактически пустой, `sword_vs_sword` (10) короче — разбор в +# docs/sound_plan.md). У кого дистрибутив есть — `make SND_SRC=msdos`. +# Источник входит в имя stamp'а, поэтому смена SND_SRC сама делает звук +# устаревшим, как и смена MUSIC_FMT. +SND_SRC ?= sdlpop # res2000 — attract-demo; res2001..res2014 — штатная игра. res2015 (copy # protection) намеренно не пакуется и не существует в маршруте загрузчика. LVL_NUMS := 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 @@ -209,7 +222,7 @@ DISK := README_E.TXT README_R.TXT \ BG/bg.arc BG/pop_tile.pal BG/pal_tile.pal \ KID/kid.arc KID/kid.pal KID/kid.ani \ GUARD/guard.arc SKEL/skel.arc VIZIER/vizier.arc SHADOW/shadow.arc \ - SND/snd.arc \ + SND/snd.arc SND/snd.idx \ $(foreach i,$(MUS_IDS),MUS/m$(i).bin) \ FONT/font.atl \ TITLE/title.arc TITLE/title.pal \ @@ -220,8 +233,11 @@ DISK := README_E.TXT README_R.TXT \ # список — это и зависимость exe, и то, что умеет чинить себя само. GEN_ARC_H := bg_arc.h kid_arc.h guard_arc.h skel_arc.h vizier_arc.h \ shadow_arc.h title_arc.h pv_arc.h +# pop_sound_tbl.h здесь БОЛЬШЕ НЕТ: раскладка эффектов уехала на диск +# (SND/snd.idx), и EXE от звукового набора не зависит — пару +# snd.idx + snd.arc можно менять без перекомпиляции. GEN_H := $(addprefix gen/,$(GEN_ARC_H) pop_bg_atlas.h kid_data.h \ - pop_guard_pal.h pop_shadow_atlas.h pop_sound_tbl.h \ + pop_guard_pal.h pop_shadow_atlas.h \ pop_music_tbl.h pop_music_ticks.h pop_font.h) PACKED_FILES := $(addprefix $(PACKED)/,$(DISK)) @@ -343,6 +359,28 @@ $(STAMP)/$(1): $(2) | $(STAMP) check-orig @touch $$@ endef +# ВАРИАНТЫ РЕСУРСА (источник звука SND_SRC, набор записей MUSIC_FMT). Оба +# варианта пишут в ОДИН выход — build/atl/sound, build/atl/music, — поэтому +# одного stamp'а на вариант мало: вернувшись к прежнему варианту, make видит +# его stamp свежим и не делает НИЧЕГО, а в выходе лежит чужое (поймано на +# sdlpop -> msdos -> sdlpop: заголовок остался с 10 страницами вместо 9). +# Лечится файлом с именем текущего варианта: он переписывается только при +# смене (cmp), а его свежесть делает stamp устаревшим. +.PHONY: variant-check +variant-check: ; +# СМЕНА ВАРИАНТА СНОСИТ STAMP'Ы СВОЕГО СЕМЕЙСТВА, а не полагается на то, +# что make заметит свежесть variant-файла. Причина — секундная +# гранулярность mtime: упаковка укладывается в одну секунду с записью +# variant, и stamp того же имени считается актуальным. Возврат к прежнему +# варианту (`sdlpop -> msdos -> sdlpop`) тогда ТИХО оставлял чужой набор. +# Нет файла — правило обязано выполниться, и время тут ни при чём. +define variant_rule +$(STAMP)/$(1).variant: variant-check | $(STAMP) + @echo "$(2)" | cmp -s - $$@ || { echo "$(2)" > $$@; rm -f $(STAMP)/$(1)-*; } +endef +$(eval $(call variant_rule,sound,$(SND_SRC))) +$(eval $(call variant_rule,music,$(MUSIC_FMT))) + $(eval $(call stamp_rule,bg,$(TOOLS)/pop_pack_bg.py $(TOOLS)/render_room.py $(TOOLS)/paths.py,pop_pack_bg.py dungeon && $(PY) pop_pack_bg.py palace)) $(eval $(call stamp_rule,kid,$(TOOLS)/pop_pack_kid.py $(TOOLS)/pop_pack_bg.py $(TOOLS)/paths.py,pop_pack_kid.py)) $(eval $(call stamp_rule,kid-bin,$(TOOLS)/pop_extract_kid_data.py $(TOOLS)/paths.py,pop_extract_kid_data.py)) @@ -350,8 +388,25 @@ $(eval $(call stamp_rule,guard,$(TOOLS)/pop_pack_guard.py $(TOOLS)/paths.py,pop_ $(eval $(call stamp_rule,skel,$(TOOLS)/pop_pack_guard.py $(TOOLS)/paths.py,pop_pack_guard.py SKEL)) $(eval $(call stamp_rule,vizier,$(TOOLS)/pop_pack_guard.py $(TOOLS)/paths.py,pop_pack_guard.py VIZIER)) $(eval $(call stamp_rule,shadow,$(TOOLS)/pop_pack_shadow.py $(TOOLS)/pop_pack_bg.py $(TOOLS)/pop_pack_kid.py $(TOOLS)/paths.py,pop_pack_shadow.py)) -$(eval $(call stamp_rule,sound,$(TOOLS)/pop_pack_sound.py $(TOOLS)/paths.py,pop_pack_sound.py)) -$(eval $(call stamp_rule,music-$(MUSIC_FMT),$(TOOLS)/pop_pack_music.py $(TOOLS)/paths.py,pop_pack_music.py --format $(MUSIC_FMT) $(MUS_IDS))) +# ЗВУК — ОСОБЫЙ СЛУЧАЙ: упаковка, сборка архива и копия индекса делаются +# ОДНИМ рецептом, а не тремя правилами через таймстемпы. +# +# Почему. Упаковщик и копирование укладываются в одну секунду, а make +# сравнивает mtime с секундной гранулярностью: цель, сделанная в ту же +# секунду, что и её зависимость, считается актуальной. При смене SND_SRC +# это давало ТИХО НЕВЕРНЫЙ результат — `sdlpop -> msdos -> sdlpop` +# перепаковывал build/atl, но в assets/packed оставался прежний набор +# (10 страниц вместо 9; поймано 2026-08-31, с паузой в секунду не +# воспроизводится). Внутри одного рецепта сравнивать нечего. +$(STAMP)/sound-$(SND_SRC): $(TOOLS)/pop_pack_sound.py $(TOOLS)/pop_idx.py \ + $(TOOLS)/paths.py $(TOOLS)/pop_pack_arc.py \ + $(STAMP)/sound.variant | $(STAMP) check-orig + cd $(TOOLS) && $(PY) pop_pack_sound.py --source $(SND_SRC) + @mkdir -p $(PACKED)/SND + $(ARC) $(PACKED)/SND/snd.arc $$(ls $(ATL)/sound/s*.bin | sort -V) + @cp $(ATL)/sound/snd.idx $(PACKED)/SND/snd.idx + @touch $@ +$(eval $(call stamp_rule,music-$(MUSIC_FMT),$(TOOLS)/pop_pack_music.py $(TOOLS)/paths.py $(STAMP)/music.variant,pop_pack_music.py --format $(MUSIC_FMT) $(MUS_IDS))) $(eval $(call stamp_rule,font,$(TOOLS)/pop_extract_font.py $(TOOLS)/paths.py,pop_extract_font.py)) $(eval $(call stamp_rule,title,$(TOOLS)/pop_pack_title.py $(TOOLS)/paths.py,pop_pack_title.py)) $(eval $(call stamp_rule,pv,$(TOOLS)/pop_pack_intro.py $(TOOLS)/paths.py,pop_pack_intro.py)) @@ -371,7 +426,6 @@ $(eval $(call from_stamp,$(GUARD_ATL) gen/pop_guard_pal.h,guard)) $(eval $(call from_stamp,$(SKEL_ATL),skel)) $(eval $(call from_stamp,$(VIZIER_ATL),vizier)) $(eval $(call from_stamp,$(SHADOW_ATL) gen/pop_shadow_atlas.h,shadow)) -$(eval $(call from_stamp,$(SND_ATL) gen/pop_sound_tbl.h,sound)) $(eval $(call from_stamp,$(foreach i,$(MUS_IDS),$(ATL)/music/m$(i).bin) gen/pop_music_tbl.h gen/pop_music_ticks.h,music-$(MUSIC_FMT))) $(eval $(call from_stamp,$(ATL)/font/font.atl gen/pop_font.h,font)) $(eval $(call from_stamp,$(TITLE_ATL) $(ATL)/title/title.pal,title)) @@ -396,9 +450,15 @@ $(eval $(call arc_rule,TITLE,title,title,$(TITLE_ATL),TITLE)) $(eval $(call arc_rule,PV,pv,pv,$(PV_ATL),PV)) # У звука своего *_arc.h нет: содержимое адресуется таблицей pop_sound_tbl.h, # которую печатает сам упаковщик звука. -$(PACKED)/SND/snd.arc: $(SND_ATL) $(TOOLS)/pop_pack_arc.py - @mkdir -p $(dir $@) - $(ARC) $@ $(SND_ATL) +# Зависимость на stamp, а не на список страниц: сколько их будет, известно +# только после запуска упаковщика (источник задаёт число). Порядок страниц +# в архиве обязан быть ЧИСЛОВЫМ — pop_snd_tbl адресует их индексом, а голый +# ls даёт s10 перед s2. sort -V, а не `-t s -k n`: буква s есть и в пути. +# Пара snd.arc + snd.idx согласована по построению: её делает ОДИН рецепт +# stamp'а выше (см. там же, почему не раздельными правилами). Здесь +# только восстановление, если файл снесли руками. +$(PACKED)/SND/snd.arc $(PACKED)/SND/snd.idx: $(STAMP)/sound-$(SND_SRC) + @test -f $@ || { $(MAKE) GENERATE=1 -B $(STAMP)/sound-$(SND_SRC); test -f $@; } # Файлы, которые ложатся на диск как есть, — простое копирование из staging. define copy_rule @@ -419,6 +479,7 @@ $(foreach i,$(MUS_IDS),$(eval $(call copy_rule,MUS/m$(i).bin,$(ATL)/music/m$(i). # читает как есть, упаковщика для них нет. check-orig здесь тоже order-only — # без оригиналов взять уровни неоткуда, и сказать об этом надо внятно. LVL_ORIG := assets/orig/SDLPoP/data/LEVELS +SND_ORIG_MSDOS := assets/orig/MSDOS define lvl_rule $(PACKED)/LEVELS/res20$(1).bin: $(LVL_ORIG)/res20$(1).bin | check-orig @mkdir -p $$(dir $$@) @@ -447,7 +508,7 @@ resources-bg: $(PACKED)/BG/bg.arc $(PACKED)/BG/pop_tile.pal $(PACKED)/BG/pal resources-kid: $(PACKED)/KID/kid.arc $(PACKED)/KID/kid.pal $(PACKED)/KID/kid.ani resources-actors: $(PACKED)/GUARD/guard.arc $(PACKED)/SKEL/skel.arc \ $(PACKED)/VIZIER/vizier.arc $(PACKED)/SHADOW/shadow.arc -resources-sound: $(PACKED)/SND/snd.arc +resources-sound: $(PACKED)/SND/snd.arc $(PACKED)/SND/snd.idx resources-music: $(foreach i,$(MUS_IDS),$(PACKED)/MUS/m$(i).bin) resources-font: $(PACKED)/FONT/font.atl resources-title: $(PACKED)/TITLE/title.arc $(PACKED)/TITLE/title.pal @@ -459,8 +520,12 @@ resources: resources-bg resources-kid resources-actors resources-sound \ check-orig: @test -d $(LVL_ORIG) || { \ - echo "SprPoP: нет assets/orig/ — генерировать ресурсы не из чего." >&2; \ - echo " Что и откуда положить, написано в assets/orig/README.md" >&2; false; } + echo "SprPoP: нет assets/orig/SDLPoP — генерировать ресурсы не из чего." >&2; \ + echo " Скачать: make fetch (подробности — assets/orig/README.md)" >&2; false; } + @test "$(SND_SRC)" != msdos || test -d $(SND_ORIG_MSDOS) || { \ + echo "SprPoP: SND_SRC=msdos, но нет $(SND_ORIG_MSDOS)." >&2; \ + echo " Дистрибутив DOS не качается — положить руками, либо собирать" >&2; \ + echo " со звуком из SDLPoP: make SND_SRC=sdlpop" >&2; false; } else # =========================================================================== @@ -510,6 +575,33 @@ $(MUSIC_TARGETS): @$(MAKE) MUSIC_FMT=$(patsubst music-%,%,$@) resources-music @$(MAKE) MUSIC_FMT=$(patsubst music-%,%,$@) stage +# --------------------------------------------------------------------------- +# Внешние исходные данные (assets/orig/) +# --------------------------------------------------------------------------- +# В репозитории чужих данных нет — есть tools/fetch_orig.py, который знает, +# откуда их взять, и таблица источников внутри него. Качаем только то, +# чего нет; `FETCH_ARGS=--force` перекачивает заново, `FETCH_ARGS=--ref v1.23` +# берёт конкретный тег SDLPoP. +# make fetch SDLPoP + записи музыки +# make fetch-sdlpop только SDLPoP (data/ + src/-эталон) +# make fetch-music только записи саундтрека (43 МБ) +# make fetch-check что уже на месте +# make fetch-list таблица источников +# MSDOS/ не качается — это копирайтный дистрибутив; он НЕОБЯЗАТЕЛЕН, всё +# нужное (уровни, оцифровка) есть в SDLPoP. См. SND_SRC выше. +FETCH := $(PY) $(TOOLS)/fetch_orig.py +fetch: ; $(FETCH) all $(FETCH_ARGS) +fetch-sdlpop: ; $(FETCH) sdlpop $(FETCH_ARGS) +fetch-music: ; $(FETCH) music $(FETCH_ARGS) +fetch-check: ; @$(FETCH) --check +fetch-list: ; @$(FETCH) --list +FETCH_TARGETS := fetch fetch-sdlpop fetch-music fetch-check fetch-list + +# Тесты инструментов (хост, питон): форматы, которые читает Z80. Секунды, +# ни эмулятора, ни оригиналов не нужно. +test-tools: + @cd $(CURDIR) && $(PY) -m unittest discover -s tools/tests + # Перелинковка приложения без пересборки библиотек и ресурсов. relink: $(MAKE) -W Makefile $(EXE) @@ -529,4 +621,4 @@ distclean: clean .PHONY: stage check-orig resources resources-rebuild resources-bg resources-kid \ resources-actors resources-sound resources-music resources-font \ resources-title resources-pv resources-levels $(MUSIC_TARGETS) \ - relink distclean mame-link + $(FETCH_TARGETS) test-tools relink distclean mame-link diff --git a/applications/SprPoP/README.md b/applications/SprPoP/README.md index dd08920..7e44cf6 100644 --- a/applications/SprPoP/README.md +++ b/applications/SprPoP/README.md @@ -13,12 +13,19 @@ make # build/sprpop.exe + ресурсы make hdd # + образ build/hdd/sprpop.chd ``` +Чужих данных в репозитории нет — есть адреса, откуда их взять. Чтобы +перегенерировать ресурсы (или получить эталон механики), сначала: + +```sh +make fetch # SDLPoP + записи саундтрека DOS-версии в assets/orig/ +``` + ## Что где ``` src/ рукописный C: главный цикл sprpop.c + движок pop_*.c gen/ генерируемые заголовки — руками не править -assets/orig/ оригинальные данные игры (вне репозитория, см. там README) +assets/orig/ оригинальные данные игры (вне репозитория, качает `make fetch`) assets/packed/ то, что ложится на диск игры, в раскладке диска tools/ конверторы ресурсов; все пути — в tools/paths.py docs/ планы, доски задач и багов, справочники (начинать с docs/README.md) @@ -33,9 +40,12 @@ build/ выход: sprpop.exe, каталоги ресурсов, hdd/, | `make` | собрать `build/sprpop.exe` и разложить ресурсы в `build/` | | `make hdd` | + образ жёсткого диска `build/hdd/sprpop.chd` | | `make mame-link` | однократно подставить образ в MAME (символьная ссылка на `IMG/test_hdd.chd`) | +| `make fetch` | скачать внешние исходные данные в `assets/orig/`: `fetch-sdlpop`, `fetch-music`, `fetch-check`, `fetch-list` | | `make resources` | перегенерировать ресурсы из `assets/orig/` | | `make music-mp3` | музыка из другого набора записей: `music-flac` (умолчание), `music-mp3`, `music-ogg`, `music-mt32` | +| `make SND_SRC=msdos` | оцифровка эффектов из дистрибутива DOS вместо SDLPoP (нужен свой `assets/orig/MSDOS/`) | | `make -C tests/host` | модульные тесты движка (секунды, без эмулятора) | +| `make test-tools` | тесты упаковщиков на хосте: форматы файлов, которые читает Z80 | | `make clean` | снести `build/` (ассеты не трогает) | | `make distclean` | `clean` + снести упакованные ассеты `assets/packed/` | @@ -54,7 +64,14 @@ build/ выход: sprpop.exe, каталоги ресурсов, hdd/, `assets/orig/` нужен только чтобы ресурсы **перегенерировать** (`make resources`) и как **эталон механики**: любая правка поведения сверяется с -`assets/orig/SDLPoP/src/` до кодинга, см. `CLAUDE.md`. +`assets/orig/SDLPoP/src/` до кодинга, см. `CLAUDE.md`. Заполняется он одной +командой `make fetch` — качается SDLPoP (ресурсы + исходники-эталон) и записи +саундтрека; адрес архива музыки скрипт берёт из самого SDLPoP. + +Оригинальный дистрибутив DOS (`assets/orig/MSDOS/`) **не нужен**: и уровни, и +оцифровка звука берутся из SDLPoP. Он даёт ровно одно — более полный набор +эффектов (в SDLPoP пуст звук `spiked`, 48); у кого дистрибутив есть, включает +его явно: `make SND_SRC=msdos`. Подробности — `assets/orig/README.md`. ## Происхождение diff --git a/applications/SprPoP/assets/packed/SND/snd.arc b/applications/SprPoP/assets/packed/SND/snd.arc index b543331b24dbfdd6d32a43250b45fca224d705bb..c44887fc1595c4d965fafe2f56103727115f6950 100644 GIT binary patch delta 3597 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 delta 10145 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 ilrQ{1{NtC!Km6#gza;+dqkr}CKP};D5SJF6=I diff --git a/applications/SprPoP/assets/packed/SND/snd.idx b/applications/SprPoP/assets/packed/SND/snd.idx new file mode 100644 index 0000000000000000000000000000000000000000..3a24b52781ba6a226aa9b8c542775e2e636618b8 GIT binary patch literal 512 zcmd6hK?=e^3`PIUG?}plt+mBM=|-UnBGrWp58%R`;D!FtjiN^=m_-suc<(=+_w!Yv zo8emo6^Ixez}B&aIQGmOHneTW#@iuy2C7Gn!lS|5V+--vzuOUuU@v`U#G61^3D+!* yicZm?Pz)61ftlh2QCuiL{gg9Wsys4h87)SY%vC4UL}Jb5$DA-=HD!i>W4{1@u@f}_ literal 0 HcmV?d00001 diff --git a/applications/SprPoP/docs/README.md b/applications/SprPoP/docs/README.md index 2a39d42..3c39d45 100644 --- a/applications/SprPoP/docs/README.md +++ b/applications/SprPoP/docs/README.md @@ -13,6 +13,7 @@ | [`impl_diff.md`](impl_diff.md) | **Осознанные расхождения с SDLPoP**: где сделано не дословно и почему. Новое расхождение — записью сюда, а не только комментарием в коде | | [`keys.txt`](keys.txt) | **Целевая раскладка управления**, к которой подгоняем SprPoP | | [`keys_plan.md`](keys_plan.md) | План перевода на неё: что совпадает, что переназначить, каких функций нет вовсе | +| [`../assets/orig/README.md`](../assets/orig/README.md) | **Откуда берутся исходные данные**: `make fetch` (SDLPoP + музыка), зачем нужен и почему необязателен `MSDOS/`, `SND_SRC` | ## Производительность diff --git a/applications/SprPoP/docs/ideas_backlog.md b/applications/SprPoP/docs/ideas_backlog.md index 2709c66..1400f58 100644 --- a/applications/SprPoP/docs/ideas_backlog.md +++ b/applications/SprPoP/docs/ideas_backlog.md @@ -133,3 +133,51 @@ raw-клавиатура (``), которую мы и так заби и трогать его ради экономии секунды стоит только на спокойную голову. **Выигрыш.** Одна-две секунды один раз на уровень. + +## Музыка одним постоянно открытым архивом (замер 2026-08-31, НЕ сейчас) + +**Идея.** Сейчас каждый трек — отдельный файл `MUS/mNN.bin`, и +`pop_music_load_begin` открывает свой на каждый запуск. Свести треки в +один файл и держать его `fd` открытым: вместо `chdir`+`open`+`chdir` +останется `lseek` к смещению трека. + +**Почему это стоит внимания — разложение окна старта музыки** (полный +замер и метод — `sound_plan.md` §9): + +| шаг | цена | уйдёт? | +|---|---:|---| +| ожидание `pop_music_service` в кадре | 22,6 мс | нет | +| `chdir` #1 | 46,1 мс | **да** | +| `open` файла трека | 34,5 мс | **да** | +| `chdir` #2 | 59,0 мс | **да** | +| чтение первых 8 КБ | 34,0 мс | нет | +| **итого** | **196,7 мс** | | + +Уходит **139,6 мс** — но это ВЕРХНЯЯ граница: появляется `lseek` к +смещению трека, которого сейчас нет вовсе (файл читается последовательно +с нуля), и его цена НЕ ИЗМЕРЕНА. Реальный выигрыш = 139,6 минус `lseek`; +померить можно тем же способом, `lseek` уже используется в +`pop_arc.c:89`. Остаётся ≈ 57 мс, то есть старт трека ускоряется +примерно вчетверо. + +**Где заметно.** Реплики PV-сцены идут встык (три трека подряд), плюс +каждый игровой джингл — смерть, зелье, подобранный меч. + +**Чем осложнено.** + +1. **`PBA1` под это не годится.** Размер элемента там `uint16` (≤ 64 КБ), + а трек — до 1,2 МБ (m56, 78 страниц); резать по страницам нельзя, + элементов вышло бы ~240 при `POP_ARC_MAX = 126`. Нужен свой индекс со + смещениями — то есть эта задача СМЫКАЕТСЯ с `MUS/MUSIC.IDX` из + `music_runtime_index_plan.md`: одно изменение раскладки, а не два. +2. **Постоянно занятый файловый манипулятор** — один из восьми (memory + `dss_fd_limit`, девятый `open` вешает DSS). Рядом свои открывают + `POP.CFG`, quicksave и загрузка уровня. +3. **`chdir` убрать нельзя** — он и есть половина выигрыша, но нужен + старым DSS (`POP_PATH_CALL`); экономия берётся не его удалением, а тем, + что открытие вообще перестаёт выполняться на каждый трек. + +**Оговорка.** Задержка старта музыки сама по себе НЕ является дефектом: +трек начинается на границе события, на слух это не сбой. У эффектов +такой задержки нет вовсе — они целиком в EMM (см. `sound_plan.md` §9.1). +Так что это оптимизация, а не исправление. diff --git a/applications/SprPoP/docs/music_runtime_index_plan.md b/applications/SprPoP/docs/music_runtime_index_plan.md index 0dab3ee..b1378c1 100644 --- a/applications/SprPoP/docs/music_runtime_index_plan.md +++ b/applications/SprPoP/docs/music_runtime_index_plan.md @@ -1,6 +1,14 @@ # Аудио без перелинковки: runtime-индексы музыки и эффектов -> Статус: план будущего улучшения, 2026-08-30. Код пока не менялся. +> Статус: **часть II (эффекты, SI0..SI4) СДЕЛАНА 2026-08-31** — разбор и +> результаты в `sound_plan.md` §10. Часть I (музыка, MI0..MI5) — план. +> +> Что в реализации разошлось с планом ниже: файл назван `SND/snd.idx` +> (единый basename с `snd.arc`, оба в нижнем регистре), а при отсутствии +> индекса поднимается ПУСТОЙ набор с блоком тишины — иначе встаёт музыка +> и игра непроходима (§10.5 sound_plan). Про `ticks60` в `PMI1` решено: +> поле в формате оставить даже если сценарии перейдут на ожидание звука — +> загрузчик волен не тащить его в память. ## 1. Цель @@ -330,13 +338,13 @@ Makefile должен: Один и тот же `sprpop.exe` должен работать с разными наборами PCM-эффектов, если в них сохранена исходная нумерация sound id 0..56. Замена набора должна -сводиться к замене согласованной пары `SND/SOUND.IDX` + `SND/SND.ARC`, без +сводиться к замене согласованной пары `SND/snd.idx` + `SND/snd.arc`, без генерации C-заголовка и перелинковки EXE. Набор состоит из двух согласованных файлов: -- `SND/SND.ARC` — обычный архив PBA1 с PCM-страницами; -- `SND/SOUND.IDX` — описание раскладки эффектов внутри этих страниц. +- `SND/snd.arc` — обычный архив PBA1 с PCM-страницами; +- `SND/snd.idx` — описание раскладки эффектов внутри этих страниц. Отдельный IDX предпочтительнее расширения заголовка `snd.arc`: PBA1 остаётся универсальным и не получает специального варианта только для звука, а @@ -381,7 +389,7 @@ typedef struct { У SDLPoP эффект 48 содержит только 7 исходных сэмплов; это свойство самого набора, а не ошибка runtime-индекса. -## 15. Файл `SND/SOUND.IDX` +## 15. Файл `SND/snd.idx` IDX занимает один 512-байтовый сектор и после короткого заголовка является точным дисковым дампом 57 записей `pop_snd_tbl`: @@ -472,12 +480,12 @@ SDLPoP-набор — 9. Предел 16 оставляет запас буду `pop_sfx_init()` должен выполнять следующую последовательность: -1. открыть `SND/SOUND.IDX` через существующий путь `POP_PATH_CALL`; +1. открыть `SND/snd.idx` через существующий путь `POP_PATH_CALL`; 2. прочитать 16-байтовый заголовок, затем 285 байт записей прямо в `pop_snd_tbl`, и проверить полный размер IDX; 3. проверить magic, версию, число/размер записей, формат и размер блока; 4. получить из IDX фактическое число страниц и проверить диапазон 1..16; -5. открыть `SND/SND.ARC` через `POP_PATH_CALL`; +5. открыть `SND/snd.arc` через `POP_PATH_CALL`; 6. прочитать обычную таблицу PBA1 с ёмкостью `POP_SND_MAX_PAGES`; 7. сверить `PSI1.pages` с числом элементов PBA1; 8. проверить каждую ненулевую запись; @@ -493,7 +501,7 @@ SDLPoP-набор — 9. Предел 16 оставляет запас буду `pop_snd_pages`. При любой ошибке блок EMM и файловый дескриптор освобождаются, таблица не -используется, эффекты остаются выключенными. Набор без `SOUND.IDX` считается +используется, эффекты остаются выключенными. Набор без `snd.idx` считается неполным; хранить в EXE старую таблицу как fallback не следует, иначе зависимость бинарника от конкретного набора останется. @@ -505,9 +513,9 @@ SDLPoP-набор — 9. Предел 16 оставляет запас буду начала/длины на 128 байт; 2. вычислить фактическое число страниц и отвергнуть набор больше 16; 3. сформировать плотные 57 записей `page/off/len`; -4. записать `SND/SOUND.IDX` в формате `PSI1`, включая фактическое число +4. записать `SND/snd.idx` в формате `PSI1`, включая фактическое число страниц; -5. собрать обычный `SND/SND.ARC` из полученных страниц; +5. собрать обычный `SND/snd.arc` из полученных страниц; 6. больше не генерировать `gen/pop_sound_tbl.h`. Чтобы число страниц не было захардкожено списком `s0.bin`..`s9.bin` в @@ -517,13 +525,13 @@ Makefile, упаковщик звука предпочтительно долж Стабильные тип и константы переносятся в обычный internal-заголовок. EXE не должен зависеть от результата упаковки звука: цель выбора набора меняет -согласованную пару `SOUND.IDX` + `SND.ARC`. +согласованную пару `snd.idx` + `snd.arc`. ## 20. Этапы реализации эффектов ### SI0 — формат и host-тест -- writer/reader отдельного `SOUND.IDX` в формате `PSI1`; +- writer/reader отдельного `snd.idx` в формате `PSI1`; - проверка точного пятибайтового LE-формата записи; - сборка и разбор обоих имеющихся DIGISND-наборов; - проверки 10 страниц MSDOS, 9 страниц SDLPoP и отличающихся записей. @@ -545,7 +553,7 @@ Makefile, упаковщик звука предпочтительно долж ### SI3 — сборка без перелинковки - убрать фиксированный `SND_ATL` из десяти имён; -- сделать `SOUND.IDX` и `SND.ARC` согласованными результатами выбора набора; +- сделать `snd.idx` и `snd.arc` согласованными результатами выбора набора; - проверить неизменность SHA/даты `sprpop.exe` при смене набора. ### SI4 — приёмка @@ -561,7 +569,7 @@ Makefile, упаковщик звука предпочтительно долж ## 21. Критерии готовности эффектов - один `sprpop.exe` работает с MSDOS- и SDLPoP-наборами; -- замена согласованной пары `SND/SOUND.IDX` + `SND/SND.ARC` не требует +- замена согласованной пары `SND/snd.idx` + `SND/snd.arc` не требует компиляции C и не меняет EXE; - `pop_snd_tbl` больше не генерируется как C-код; - загружается фактическое число страниц в диапазоне 1..16; diff --git a/applications/SprPoP/docs/sound_plan.md b/applications/SprPoP/docs/sound_plan.md index 8a02ad5..63ffb93 100644 --- a/applications/SprPoP/docs/sound_plan.md +++ b/applications/SprPoP/docs/sound_plan.md @@ -163,10 +163,16 @@ B имеет смысл только если C окажется неподъё 1.3/1.4) и распакованные ассеты `SDLPoP/data/` (версия 1.0/1.1). Разница есть, и она влияет на выбор источника. -### Оцифровка: берём MSDOS +### Оцифровка: по умолчанию SDLPoP, MSDOS — опция -Заголовок разный (`digi_new_type` против `digi_type`), но **28 звуков из -31 совпадают побайтно**. Различаются три, и все не в пользу SDLPoP: +**С 2026-08-31 умолчание — SDLPoP** (`SND_SRC=sdlpop`): он выкачивается +автоматически (`make fetch`), а оригинального дистрибутива DOS у сборки +может не быть вовсе — и она обязана работать без него. У кого дистрибутив +есть, включает лучший набор явно: `make SND_SRC=msdos`. + +Платим за это ровно тем, что разобрано ниже. Заголовок в поставках разный +(`digi_new_type` против `digi_type`), но **28 звуков из 31 совпадают +побайтно**. Различаются три, и все не в пользу SDLPoP: | id | звук | MSDOS | SDLPoP | |---|---|---:|---:| @@ -174,9 +180,13 @@ B имеет смысл только если C окажется неподъё | 11 | `sword_moving` | 1 172 | 1 172, но **другие байты** | | 48 | `spiked` | 5 069 | **7** — то есть звука нет | -`spiked` в наборе SDLPoP фактически пустой. Поэтому упаковщик читает -`MSDOS/digisnd*.dat`, а не распакованные ассеты — в отличие от графики, -где источник наоборот SDLPoP. +`spiked` в наборе SDLPoP фактически пустой: это звук насаживания на пики +(`spiked()`, seg005.c:245, кадр 177), и с умолчанием он не звучит — от пик +остаётся только шипение выдвижения (49 `spikes`). Звон клинков (10) на +секунду короче, а 11 просто другая запись. + +Всё остальное — включая уровни `LEVELS/res20xx.bin` — берётся из SDLPoP +всегда: `MSDOS/` нужен ровно для этих трёх звуков и ни для чего больше. ### MIDI: если дойдём до музыки — брать SDLPoP @@ -961,3 +971,226 @@ load_intro у оригинала и так начинается с ожидан Проверено в MAME на сборке `LEVEL=14`: после встречи с принцессой звучит тема победы (`pop_mus_id` = 56, `pop_mus_ring` = 6), курсор уходит далеко за размер кольца — то есть подкачка успевает. + +## 9. ЗАДЕРЖКА СТАРТА ЗВУКА: ЗАМЕР (2026-08-31) + +Замерено в MAME на живой сборке: точка отсчёта — подстановка заявки +отладчиком при ОСТАНОВЛЕННОЙ машине, конец — условный watchpoint +`wpset 19FF6,1,w,{wpdata != 0}` на старший байт `pop_mus_left`, то есть +момент, когда трек реально зазвучал. Масштаб тактов откалиброван не по +памяти, а по аппаратным часам: период тика насоса CBL — ровно +`128/10937,5 = 11,7029` мс, измерено 245 748 и 245 778 тактов, отсюда +**21,00 МГц** (кадр луча — 429 973 такта, развёртка 48,8 Гц, не 50). + +### 9.1 Эффекты стартуют без задержки, музыка — через ~200 мс + +Разница принципиальная и вытекает из устройства: + +* **Оцифрованные эффекты диска не касаются вовсе.** `pop_sfx_init()` + вызывается ОДИН раз за запуск (`sprpop_cold.c`), вычитывает весь + `SND/snd.arc` в EMM и закрывает файл. Дальше `pop_sfx_play` только + номинирует, а `pop_sfx_tick` берёт `page/off/len` из таблицы в памяти. + Задержка = остаток текущего кадра + ближайший запрос CBL (≤ 11,7 мс). + Держать архив открытым НЕЧЕГО: после старта он не нужен. +* **Музыка читается с диска на каждый трек** — `mNN.bin` лежат + отдельными файлами, и `pop_music_load_begin` открывает свой. + +### 9.2 Разложение окна музыки + +От заявки (`pop_mus_req`) до звучания (`pop_mus_left != 0`), одна +загрузка по шагам: + +| шаг | цена | +|---|---:| +| ожидание `pop_music_service` в кадре | 22,6 мс | +| `chdir` #1 (вход в `POP_PATH_CALL`) | **46,1 мс** | +| `open` файла трека | 34,5 мс | +| `chdir` #2 (восстановление каталога) | **59,0 мс** | +| чтение первых 8 КБ (`bank_read_page`) | 34,0 мс | +| **итого** | **196,7 мс** | + +**Главное здесь: два `chdir` стоят 105 мс — больше половины окна, и +каждый дороже самого `open`.** Убрать их нельзя: старые DSS оставляют +рабочий каталог внутри последнего подкаталога, и без восстановления +ломаются относительные пути (ради этого `POP_PATH_CALL` и заведён). +Теоретический запас — снять ОДИН из двух (≈ 50 мс), но это риск для +других мест, которые полагаются на восстановленный каталог. + +Для музыки задержка непринципиальна: трек и так начинается на границе +события. Критична она была бы для эффектов — там её нет по построению. + +### 9.3 Разброс и режимы скорости + +Семь прогонов: **189,3 … 232,8 мс**, среднее 200,1. Величина +ФИЗИЧЕСКАЯ и от режима скорости не зависит, меняется только её пересчёт +в логические кадры: + +| режим | логический кадр (замерено) | окно в кадрах | +|---|---:|---:| +| NORMAL вне боя | 81,93 мс = 4,00 кадра луча | 2,31 … 2,84 | +| FAST / FASTEST вне боя | 61,4 мс = 3,00 кадра луча | 3,09 … 3,42 | + +Худший случай (232,8 мс в коротком кадре) — 3,79 логического кадра. +Пейсер при этом работает ровно как задумано: `pop_pace_n` заказывает 4 +кадра луча вне боя при NORMAL и 3 при FAST/FASTEST, замер это +подтвердил. Подкачка трека кадр НЕ просаживает. + +### 9.4 Следствие для паузы конца уровня + +Ждать окончания мелодии по одному `pop_music_busy()` НЕЛЬЗЯ: в этом окне +он ещё ложен, и уровень сменился бы до первой ноты. Правильный признак +— состояние «аудио-событие в работе», которое выводится из уже +существующих переменных без новых сущностей: + +```c +uint8_t pop_music_active(void) __banked /* банк 9 — там ld_busy */ +{ + return (uint8_t)(pop_mus_req || ld_busy || pop_mus_left); +} +``` + +Переход «запрошен → грузится → звучит» снаружи непрерывен: `service` +обнуляет `pop_mus_req` и тут же в `load_begin` взводит `ld_busy`, а +главный цикл между этими операторами не выполняется. + +Это лучше фиксированной задержки «на раскачку»: не нужна подобранная +константа, нет зависимости от режима скорости, а при провале загрузки +(нет файла, нет памяти) все три признака ложны и уровень меняется сразу, +вместо слепого ожидания. Заодно чинится расхождение с SDLPoP при +ВЫКЛЮЧЕННОМ звуке: `pop_sfx_play` возвращается рано при `!pop_snd_ok`, +заявка не кладётся, паузы нет — как в оригинале (`seg006:651` ставит +трек только `if (is_sound_on)`, а `seg003:387` ждёт +`check_sound_playing()`). Сейчас `pop_endmus_left` взводится +безусловно, и при выключенном звуке игра держит пройденный уровень +лишние 12 с молча. + +## 10. ЭФФЕКТЫ БЕЗ ПЕРЕЛИНКОВКИ: РАСКЛАДКА УЕХАЛА НА ДИСК (2026-08-31) + +Сделана часть II плана `music_runtime_index_plan.md` (этапы SI0..SI4). +Раскладка звукового набора больше не компилируется в EXE: пара +`SND/snd.arc` + `SND/snd.idx` меняется целиком, бинарник остаётся прежним. + +### 10.1 Что именно изменилось + +`gen/pop_sound_tbl.h` УДАЛЁН — упаковщик его не печатает. Вместо него: + +* `src/pop_snd_tbl.h` — рукописный: тип записи и инварианты программы + (`POP_SND_COUNT` 57, `POP_SND_BLOCK` 128, `POP_SND_MAX_PAGES` 16, + константы блока тишины). От набора не зависит НИЧЕГО из этого; +* `src/pop_snd_data.c` — резидентные данные: `pop_snd_tbl[57]`, + `pop_snd_page[16]`, `pop_snd_pages`; +* `SND/snd.idx` — дисковый индекс, формат `PSI1` (писатель и разборщик — + `tools/pop_idx.py`, покрыт `make test-tools`, 22 теста); +* `pop_sfx_cold.c` — чтение и полная валидация индекса. + +`POP_SND_PAGES` как compile-time размер набора БОЛЬШЕ НЕ СУЩЕСТВУЕТ. Его +заменили две разные величины: `POP_SND_MAX_PAGES` — вместимость +загрузчика (16, запас под чужие наборы), и runtime `pop_snd_pages` — +сколько страниц у ЭТОГО набора. Насос сверяет переход длинного эффекта +через границу страницы со вторым, а не с первым. + +### 10.2 Формат `PSI1` и почему запись ровно 5 байт + +Один сектор DSS: 16 байт заголовка (магия, число записей, размер записи, +log2 блока, формат PCM, число страниц) и 57 записей по 5 байт — +`page` (u8), `off` (u16 LE), `len` (u16 LE), — то есть побайтовый образ +`pop_snd_tbl[]`. Записи читаются ОДНИМ `read` прямо в резидентную +таблицу, без разбора по полям. + +Поэтому `sizeof(pop_snd_ent_t) == 5` в ABI SDCC z80 — часть ДИСКОВОГО +контракта, а не деталь компилятора. Ловится с двух сторон: статически +(`pop_snd_data.c`) и полем размера записи в заголовке, которое загрузчик +сверяет со своим `sizeof`. + +### 10.3 Цена в памяти + +Таблица была `static const` в `_CODE`, стала изменяемой в `_DATA`. +Замер до/после: + +| | было | стало | Δ | +|---|---:|---:|---:| +| `_CODE` | 23865 | 23582 | −283 | +| данные | 7128 | 7421 | +293 | +| куча резидента | 239 Б | 229 Б | −10 | +| банк 8 | 14824 | 15425 | +601 | + +Суммарный резидентный расход почти не изменился — в huge-модели `_CODE` +и данные лежат подряд в одном диапазоне `0x4000..0xBFFF`. Реальные +10 байт — это расширение `pop_snd_page` с размера набора до +`POP_SND_MAX_PAGES` плюс байт счётчика. + +### 10.4 Валидация: чему именно нельзя доверять + +Индекс — файл на диске, и битый индекс увёл бы насос за пределы +выделенного блока EMM. Проверяются заголовок целиком и КАЖДАЯ запись: +`page < pop_snd_pages`, `off` внутри страницы, кратность `off`/`len` +блоку 128, непересечение с блоком тишины и то, что длинный эффект не +выходит за последнюю страницу набора. + +Последняя проверка считается В БЛОКАХ, а не в байтах: байтовый адрес +конца (`page * 16384 + off + len`) не влезает в `uint16`, а 32-битная +арифметика на Z80 дорога. В блоках весь набор — не больше +`16 * 128 = 2048`, и всё считается шестнадцатью битами. + +Число страниц из индекса дополнительно сверяется с числом элементов в +`snd.arc`: расходящаяся пара означает, что файлы из разных наборов. + +### 10.5 Нет индекса — эффектов нет, но МУЗЫКА ОСТАЁТСЯ + +Первая версия просто возвращала ошибку, и это оказалось непроходимой +игрой: тишину в паузах насос льёт из первого блока набора, поэтому без +набора CBL не открывался вовсе — а вместе с ним вставала музыка, чьи +блоки отсчитывает тот же насос. Заставка ждала конца трека, который не +начинал играть, и `pop_music_busy()` не гас никогда. + +Теперь при отказе индекса поднимается ПУСТОЙ набор: одна страница EMM с +блоком тишины. Все `len` нулевые, поэтому `pop_sfx_play` уходит по ветке +«нет оцифровки» — эффекты молчат, музыка играет, игра проходима. + +Две тонкости этой заливки, обе выяснены на живой машине: + +1. **Заливается только первый блок (128 байт), а не вся страница.** + `gfx_w0_page_prepare` ставит в страницу IRQ/NMI-стабы, чтобы + прерывание при замапленном W0 нашло валидный код. Заливка всей + страницы затирает их, и первое же прерывание уводит процессор в + мусор — чёрный экран на старте. Насосу больше и не нужно: он берёт + ровно блок тишины, да и то через W3. +2. **Запись под `IRQ_DISABLE`** — 128 байт накрывают стаб RST38, и до + `gfx_w0_unmap` прерываний быть не должно. + +### 10.6 Грабли сборки: секундная гранулярность mtime + +Смена `SND_SRC` тихо давала НЕВЕРНЫЙ результат: `sdlpop -> msdos -> +sdlpop` перепаковывал `build/atl`, а в `assets/packed` оставался прежний +набор. Причина не в логике, а во времени: упаковка укладывается в одну +секунду с записью stamp'а, а `make` считает цель, сделанную в ту же +секунду, что и зависимость, актуальной. С паузой в секунду не +воспроизводится вовсе. + +Лечится двумя приёмами, и оба убирают время из решения: + +* смена варианта СНОСИТ stamp'ы своего семейства (`rm -f + $(STAMP)/sound-*`) — нет файла, значит правило обязано выполниться; +* упаковка, сборка архива и копия индекса делаются ОДНИМ рецептом: + внутри рецепта сравнивать нечего. + +То же лечение получила и музыка (`MUSIC_FMT`) — механизм общий. + +### 10.7 Что проверено на живой машине + +* таблица в памяти совпадает с файлом из образа ПОБАЙТОВО (285 байт); +* один и тот же EXE (sha256 не меняется) поднимает SDLPoP (9 страниц) и + MSDOS (10 страниц); +* СТРОГАЯ проверка: у двух реальных наборов совпадают 23 записи из 31, + поэтому сделан отладочный ключ `pop_pack_sound.py --order reverse` — + набор с обратным порядком укладки, где от штатного отличаются 30 из 31 + записей. Игра на нём звучит правильно, значит раскладка берётся с + диска, а не из памяти; +* битый индекс (испорченная магия) — эффекты выключаются, игра жива; +* отсутствующий индекс — музыка играет, эффектов нет, PV-сцена проходит; +* Ctrl+S в обоих режимах работает штатно. + +Маркер для ручной проверки: звук 51 `princess_door_opening` (дверь в +сцене с принцессой) лежит у наборов по РАЗНЫМ страницам — 8 у SDLPoP, +9 у MSDOS. А вот 4 `gate_closing` из той же сцены совпадает побайтно и +для проверки подмены не годится. diff --git a/applications/SprPoP/gen/pop_sound_tbl.h b/applications/SprPoP/gen/pop_sound_tbl.h deleted file mode 100644 index 7a35916..0000000 --- a/applications/SprPoP/gen/pop_sound_tbl.h +++ /dev/null @@ -1,97 +0,0 @@ -/* pop_sound_tbl.h — раскладка оцифрованных эффектов PoP. - * СГЕНЕРЕНО tools/pop_pack_sound.py — НЕ править вручную. - * - * Данные: 8 бит беззнаковые, МОНО, 10937.5 Гц (CBL_FREQ_10K9) — формат - * CBL один в один, конвертировать в рантайме нечего. - * - * Начало каждого звука кратно 128 — размеру блока запроса CBL, а - * страница (16384) кратна ему же. Поэтому НИ ОДИН блок не пересекает - * границу страницы. Больше того: с 2026-08-25 каждый звук лежит - * ЦЕЛИКОМ в одной странице — не влезает в остаток, значит начинаем - * новую (страницы EMM у нас с запасом). Единственное исключение — - * эффект, который сам ДЛИННЕЕ страницы: для него насос переключает - * страницу на ходу, и эта ветка в pop_sfx_fill остаётся ради него. - * - * Смещение хранится РАЗОБРАННЫМ на страницу и адрес в окне: на Z80 - * 32-битная арифметика дорога, а так всё влезает в 8/16 бит. - * Длина округлена вверх до блока — неполных блоков не бывает. - */ -#ifndef POP_SOUND_TBL_H -#define POP_SOUND_TBL_H - -#include - -#define POP_SND_PAGES 10 -#define POP_SND_COUNT 57 -#define POP_SND_BLOCK 128 -/* Первый блок страницы 0 — тишина для насоса (см. упаковщик). */ -#define POP_SND_SILENCE_PAGE 0 -#define POP_SND_SILENCE_OFF 0x0000 - -/* Полная таблица нужна только горячей половине звука. */ -#ifdef POP_SND_TABLE -typedef struct { uint8_t page; uint16_t off; uint16_t len; } pop_snd_ent_t; - -/* len == 0 — у звука нет оцифровки (это музыка, см. sound_plan.md). */ -static const pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT] = { - { 0, 0x0080, 1664 }, /* 0 */ - { 0, 0x0700, 12032 }, /* 1 */ - { 1, 0x0000, 6272 }, /* 2 */ - { 1, 0x1880, 1280 }, /* 3 */ - { 1, 0x1D80, 1152 }, /* 4 */ - { 1, 0x2200, 1408 }, /* 5 */ - { 1, 0x2780, 5504 }, /* 6 */ - { 2, 0x0000, 896 }, /* 7 */ - { 2, 0x0380, 1664 }, /* 8 */ - { 2, 0x0A00, 2816 }, /* 9 */ - { 2, 0x1500, 4992 }, /* 10 */ - { 2, 0x2880, 1280 }, /* 11 */ - { 2, 0x2D80, 1280 }, /* 12 */ - { 3, 0x0000, 3840 }, /* 13 */ - { 3, 0x0F00, 7552 }, /* 14 */ - { 4, 0x0000, 17664 }, /* 15 */ - { 5, 0x0500, 4736 }, /* 16 */ - { 5, 0x1780, 4224 }, /* 17 */ - { 6, 0x0000, 6912 }, /* 18 */ - { 6, 0x1B00, 2816 }, /* 19 */ - { 6, 0x2600, 1408 }, /* 20 */ - { 6, 0x2B80, 1152 }, /* 21 */ - { 6, 0x3000, 1280 }, /* 22 */ - { 6, 0x3500, 1408 }, /* 23 */ - { 0, 0x0000, 0 }, /* 24 */ - { 0, 0x0000, 0 }, /* 25 */ - { 0, 0x0000, 0 }, /* 26 */ - { 0, 0x0000, 0 }, /* 27 */ - { 0, 0x0000, 0 }, /* 28 */ - { 0, 0x0000, 0 }, /* 29 */ - { 0, 0x0000, 0 }, /* 30 */ - { 0, 0x0000, 0 }, /* 31 */ - { 0, 0x0000, 0 }, /* 32 */ - { 0, 0x0000, 0 }, /* 33 */ - { 0, 0x0000, 0 }, /* 34 */ - { 0, 0x0000, 0 }, /* 35 */ - { 0, 0x0000, 0 }, /* 36 */ - { 0, 0x0000, 0 }, /* 37 */ - { 0, 0x0000, 0 }, /* 38 */ - { 0, 0x0000, 0 }, /* 39 */ - { 0, 0x0000, 0 }, /* 40 */ - { 0, 0x0000, 0 }, /* 41 */ - { 0, 0x0000, 0 }, /* 42 */ - { 0, 0x0000, 0 }, /* 43 */ - { 7, 0x0000, 2688 }, /* 44 */ - { 7, 0x0A80, 4224 }, /* 45 */ - { 7, 0x1B00, 4608 }, /* 46 */ - { 8, 0x0000, 5120 }, /* 47 */ - { 8, 0x1400, 5120 }, /* 48 */ - { 8, 0x2800, 4480 }, /* 49 */ - { 0, 0x0000, 0 }, /* 50 */ - { 9, 0x0000, 4864 }, /* 51 */ - { 0, 0x0000, 0 }, /* 52 */ - { 0, 0x0000, 0 }, /* 53 */ - { 0, 0x0000, 0 }, /* 54 */ - { 0, 0x0000, 0 }, /* 55 */ - { 0, 0x0000, 0 }, /* 56 */ -}; -#endif /* POP_SND_TABLE */ - -#endif diff --git a/applications/SprPoP/src/_pop_sfx.h b/applications/SprPoP/src/_pop_sfx.h index e70d7b2..e514f33 100644 --- a/applications/SprPoP/src/_pop_sfx.h +++ b/applications/SprPoP/src/_pop_sfx.h @@ -8,10 +8,15 @@ #define _POP_SFX_INTERNAL_H #include -#include "pop_sound_tbl.h" /* POP_SND_* и, по запросу, таблица эффектов */ +#include "pop_snd_tbl.h" /* тип записи и инварианты (НЕ генерируется) */ -/* Физические страницы набора, по индексу из pop_snd_tbl[].page. */ -extern uint8_t pop_snd_page[POP_SND_PAGES]; +/* РАСКЛАДКА НАБОРА и его страницы — общие данные обеих половин, живут в + * pop_snd_data.c. Значения приезжают с диска (SND/snd.idx): холодная + * половина их читает и проверяет, горячая только пользуется. */ +extern pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT]; +extern uint8_t pop_snd_page[POP_SND_MAX_PAGES]; +/* Сколько страниц у загруженного набора; 0 — набор не поднят. */ +extern uint8_t pop_snd_pages; /* 1 — набор поднят и CBL открыт; 0 — играем молча. */ extern uint8_t pop_snd_ok; diff --git a/applications/SprPoP/src/pop_sfx.c b/applications/SprPoP/src/pop_sfx.c index 4a4c5f3..9bda4bd 100644 --- a/applications/SprPoP/src/pop_sfx.c +++ b/applications/SprPoP/src/pop_sfx.c @@ -21,11 +21,8 @@ #include /* IRQ_DISABLE/IRQ_ENABLE вокруг курсора */ #include "pop_sfx.h" #include "pop_music.h" -#define POP_SND_TABLE /* таблица нужна только горячей половине */ #include "_pop_sfx.h" -/* Физические страницы набора; заполняет холодная половина. */ -uint8_t pop_snd_page[POP_SND_PAGES]; uint8_t pop_snd_ok; /* ХОЧЕТ ЛИ ЗВУКА ПОЛЬЗОВАТЕЛЬ (Ctrl+S) — отдельно от pop_snd_ok. @@ -170,7 +167,7 @@ int pop_sfx_fill(uint16_t n) * целиком в одну). Гард обязателен: без него ошибка в длине увела * бы насос за pop_snd_page[] и он читал бы чужую память — на слух * это «играет не тот звук», причём каждый раз другой. */ - if ((uint8_t)(sfx_pg + 1) < POP_SND_PAGES) sfx_pg++; + if ((uint8_t)(sfx_pg + 1) < pop_snd_pages) sfx_pg++; else sfx_left = n; /* обрываем */ } sfx_left -= n; diff --git a/applications/SprPoP/src/pop_sfx_cold.c b/applications/SprPoP/src/pop_sfx_cold.c index 51c4b99..109cf48 100644 --- a/applications/SprPoP/src/pop_sfx_cold.c +++ b/applications/SprPoP/src/pop_sfx_cold.c @@ -5,20 +5,163 @@ */ #include +#include +#include +#include #include #include #include "pop_sfx.h" #include "pop_arc.h" #include "_pop_sfx.h" +#include "_pop_file.h" /* POP_PATH_CALL — старые DSS и пути */ +#include /* IRQ_DISABLE вокруг записи в W0 */ +#include /* gfx_w0_map — залить блок тишины */ static uint8_t snd_blk; /* блок EMM под весь набор */ +/* --- ИНДЕКС НАБОРА (SND/snd.idx, формат PSI1) ----------------------- * + * + * Раскладка эффектов приезжает с диска, а не из EXE: так пара + * snd.idx + snd.arc заменяется целиком (SDLPoP <-> MSDOS) без + * перекомпиляции игры. Писатель формата — tools/pop_idx.py, он же + * покрыт тестом; проверки ниже повторяют его один в один, потому что + * доверять содержимому файла нельзя: битый индекс увёл бы насос за + * пределы выделенного блока EMM. + * + * Заголовок: 16 байт, дальше POP_SND_COUNT записей по 5 байт подряд — + * ровно в том виде, в каком лежит pop_snd_tbl[], поэтому читаются они + * ОДНИМ read'ом прямо туда, без разбора по полям. */ +#define IDX_HDR_SIZE 16 +#define IDX_BLOCK_LOG2 7 /* 1 << 7 == POP_SND_BLOCK */ +#define IDX_PCM_FMT 1 /* u8 mono, CBL_FREQ_10K9 */ +#define IDX_TBL_SIZE (POP_SND_COUNT * (int)sizeof(pop_snd_ent_t)) + +/* Имя знает сам модуль — литерал лежит в rodata банка 8, и open() читает + * его, пока в W3 стоит этот же банк (тот же приём, что в pop_arc.c). */ +static const char idx_path[] = "SND\\snd.idx"; + +/* Одна запись: ровно те проверки, что делает упаковщик. + * + * СЧИТАЕМ В БЛОКАХ, А НЕ В БАЙТАХ. Байтовый адрес конца эффекта + * (page * 16384 + off + len) не влезает в uint16, а 32-битная арифметика + * на Z80 дорога (memory avoid_32bit_arith_z80). В блоках по 128 весь + * набор — не больше POP_SND_MAX_PAGES * 128 = 2048, и всё считается + * шестнадцатью битами. */ +static int8_t idx_entry_ok(const pop_snd_ent_t *e, uint8_t pages) +{ + uint16_t start, end; + + if (e->len == 0) /* нет оцифровки — это музыка */ + return (e->page == 0 && e->off == 0) ? 0 : -1; + if (e->page >= pages) return -1; + if (e->off >= 0x4000) return -1; + if ((e->off | e->len) & (POP_SND_BLOCK - 1)) return -1; + /* Блок тишины неприкосновенен: насос льёт его, когда играть нечего. */ + if (e->page == POP_SND_SILENCE_PAGE && e->off < POP_SND_BLOCK) return -1; + start = (uint16_t)((uint16_t)e->page << 7) + (uint16_t)(e->off >> 7); + end = (uint16_t)(start + (uint16_t)(e->len >> 7)); + /* Длинный эффект переливается на следующие страницы — они обязаны + * быть загружены, иначе насос уйдёт в чужую память. */ + if (end > (uint16_t)((uint16_t)pages << 7)) return -1; + return 0; +} + +/* Прочитать и проверить индекс. Возвращает число страниц набора либо 0. */ +static uint8_t idx_load(void) +{ + uint8_t head[IDX_HDR_SIZE]; + uint8_t pages, i; + int fd; + + POP_PATH_CALL(fd, open(idx_path, O_RDONLY)); + if (fd < 0) return 0; + if (read(fd, head, IDX_HDR_SIZE) != IDX_HDR_SIZE || + memcmp(head, "PSI1", 4) != 0 || + head[4] != POP_SND_COUNT || + head[5] != (uint8_t)sizeof(pop_snd_ent_t) || + head[6] != IDX_BLOCK_LOG2 || + head[7] != IDX_PCM_FMT) { + close(fd); + return 0; + } + pages = head[8]; + if (pages == 0 || pages > POP_SND_MAX_PAGES) { close(fd); return 0; } + /* Записи ложатся прямо в резидентную таблицу: дисковый формат и + * структура совпадают побайтово (проверено статически в + * pop_snd_data.c и полем head[5] выше). */ + if (read(fd, pop_snd_tbl, IDX_TBL_SIZE) != IDX_TBL_SIZE) { + close(fd); + return 0; + } + close(fd); + for (i = 0; i < POP_SND_COUNT; i++) + if (idx_entry_ok(&pop_snd_tbl[i], pages) != 0) return 0; + return pages; +} + +/* НАБОРА НЕТ — НО НАСОС ВСЁ РАВНО ОБЯЗАН РАБОТАТЬ. + * + * Тишину в паузах насос льёт из ПЕРВОГО БЛОКА НАБОРА, поэтому без набора + * CBL не открывался вовсе. А вместе с ним вставала и МУЗЫКА: её блоки + * отсчитывает тот же насос (pop_mus_left убывает только в pop_sfx_fill). + * Заставка при этом висла намертво — ждала конца трека, который не + * начинал играть, и pop_music_busy() не гас никогда (поймано + * пользователем 2026-08-31 на образе без snd.idx). + * + * Поэтому при отказе индекса поднимаем ПУСТОЙ набор: одна страница EMM с + * блоком тишины. Эффектов нет — все len в таблице нулевые, и + * pop_sfx_play уходит по ветке «нет оцифровки», — а музыка играет. + * + * ЗАЛИВАЕМ ТОЛЬКО ПЕРВЫЙ БЛОК, и это не экономия: gfx_w0_page_prepare + * ставит в страницу IRQ/NMI-стабы, чтобы прерывание при замапленном W0 + * нашло валидный код. Заливка ВСЕЙ страницы затирает их, и первое же + * прерывание уводит процессор в мусор — чёрный экран на старте (поймано + * при первой попытке этого фикса). Насосу больше 128 байт и не нужно: + * он берёт ровно блок тишины, да и то через W3, а не через W0. + * Запись — под DI: 128 байт накрывают стаб RST38, и до unmap прерываний + * быть не должно. */ +static int8_t snd_silence_only(void) +{ + uint8_t blk, page; + uint8_t *w0 = (uint8_t *)0x0000; /* окно 0 — сюда ляжет страница */ + + memset(pop_snd_tbl, 0, sizeof(pop_snd_tbl)); + blk = mem_alloc_pages(1); + if (!blk) return -1; + page = mem_get_page(blk, 0); + if (!page) { mem_free_block(blk); return -1; } + gfx_w0_page_prepare(page); + IRQ_DISABLE(); + gfx_w0_map(page); + memset(w0, 0x80, POP_SND_BLOCK); /* 0x80 — тишина в 8-бит без знака */ + gfx_w0_unmap(); + IRQ_ENABLE(); + pop_snd_page[POP_SND_SILENCE_PAGE] = page; + snd_blk = blk; + pop_snd_pages = 1; + return 0; +} + int pop_sfx_init(void) __banked { - uint8_t i, blk; + uint8_t i, blk, pages; if (pop_snd_ok) return 0; + /* СНАЧАЛА ИНДЕКС, ПОТОМ ДАННЫЕ: сколько страниц читать и куда какой + * эффект лёг — знает только он. Набора без индекса не бывает: + * держать в EXE запасную таблицу нельзя, иначе привязка бинарника к + * конкретному набору вернётся через чёрный ход. */ + pages = idx_load(); + if (!pages) { + /* Набор эффектов не поднялся — но звук как таковой поднять надо, + * иначе встанет музыка (см. snd_silence_only). Возвращаем -1: + * эффектов действительно нет, и вызывающий вправе об этом знать. */ + if (snd_silence_only() != 0) return -1; + pop_snd_want = 1; + return -1; + } + /* Одним блоком: страницы блока идут подряд по логическому индексу, и * таблица звуков адресует их именно так (page = смещение >> 14). * @@ -28,14 +171,17 @@ int pop_sfx_init(void) __banked * pop_arc.h. */ { pop_arc_t arc; - uint8_t tbl[POP_SND_PAGES * POP_ARC_REC]; + uint8_t tbl[POP_SND_MAX_PAGES * POP_ARC_REC]; - if (pop_arc_open_id(&arc, POP_ARC_SND, tbl, POP_SND_PAGES) != - POP_SND_PAGES) + /* Архив обязан содержать РОВНО столько страниц, сколько обещал + * индекс: расходящейся паре доверять нельзя — она означает, что + * snd.idx и snd.arc из разных наборов. */ + if (pop_arc_open_id(&arc, POP_ARC_SND, tbl, POP_SND_MAX_PAGES) != + (int8_t)pages) return -1; - blk = mem_alloc_pages(POP_SND_PAGES); + blk = mem_alloc_pages(pages); if (!blk) { pop_arc_close(&arc); return -1; } - for (i = 0; i < POP_SND_PAGES; i++) { + for (i = 0; i < pages; i++) { pop_snd_page[i] = mem_get_page(blk, i); if (!pop_snd_page[i] || pop_arc_read(&arc, tbl, i, 0, pop_snd_page[i], 0, 0) != 16384) { @@ -47,6 +193,9 @@ int pop_sfx_init(void) __banked pop_arc_close(&arc); } snd_blk = blk; + /* Число страниц ставим ПОСЛЕДНИМ: до этого момента насос обязан + * видеть 0 и не пытаться ходить по полузаполненной таблице. */ + pop_snd_pages = pages; pop_snd_want = 1; /* набор есть — звук по умолчанию включён */ return 0; } diff --git a/applications/SprPoP/src/pop_snd_data.c b/applications/SprPoP/src/pop_snd_data.c new file mode 100644 index 0000000..cb8ac7e --- /dev/null +++ b/applications/SprPoP/src/pop_snd_data.c @@ -0,0 +1,34 @@ +/* + * pop_snd_data.c — РЕЗИДЕНТНЫЕ ДАННЫЕ звукового набора. + * + * Отдельным модулем, потому что стороны у этих данных разные: заполняет + * их ХОЛОДНЫЙ загрузчик из банка 8 (pop_sfx_cold.c), а читает ГОРЯЧИЙ + * насос из резидента (pop_sfx.c) — в том числе из прерывания. Держать + * их в любой из половин значило бы тянуть чужую в резидент. + * + * Инициализаторов нет намеренно: crt0 зануляет _DATA (memory + * sdcc_static_storage_gotcha), а «набор не загружен» — это и есть нули. + * + * ЦЕНА ПЕРЕЕЗДА. Таблица была `static const` в _CODE (285 Б); сняв + * const, мы переносим её в _DATA — суммарный резидентный расход не + * меняется, потому что в huge-модели _CODE и данные лежат подряд в одном + * диапазоне 0x4000..0xBFFF. Реально добавилось 8 байт: pop_snd_page + * вырос с размера набора до POP_SND_MAX_PAGES (запас под чужие наборы) + * плюс байт самого счётчика. + */ +#include +#include "pop_snd_tbl.h" + +/* РАСКЛАДКА НАБОРА — приезжает из SND/snd.idx целиком, одним read'ом + * прямо сюда. Поэтому размер записи обязан совпадать с дисковым: если + * ABI когда-нибудь добавит выравнивание, сборка упадёт здесь, а не + * рассыплется звуком в рантайме. */ +pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT]; +typedef char pop_snd_ent_is_5_bytes[(sizeof(pop_snd_ent_t) == 5) ? 1 : -1]; + +/* Физические страницы EMM, по индексу из pop_snd_tbl[].page. */ +uint8_t pop_snd_page[POP_SND_MAX_PAGES]; + +/* Сколько страниц у ЭТОГО набора (1..POP_SND_MAX_PAGES). Насос сверяет + * с ним переход длинного эффекта через границу страницы. */ +uint8_t pop_snd_pages; diff --git a/applications/SprPoP/src/pop_snd_tbl.h b/applications/SprPoP/src/pop_snd_tbl.h new file mode 100644 index 0000000..237d20b --- /dev/null +++ b/applications/SprPoP/src/pop_snd_tbl.h @@ -0,0 +1,50 @@ +/* + * pop_snd_tbl.h — РАСКЛАДКА ОЦИФРОВАННЫХ ЭФФЕКТОВ: тип и инварианты. + * + * Раньше этот файл ПЕЧАТАЛ упаковщик (gen/pop_sound_tbl.h), и конкретные + * page/off/len становились частью EXE — из-за чего замена звукового набора + * (SDLPoP <-> MSDOS) требовала перекомпиляции игры. Теперь значения + * приезжают с диска (SND/snd.idx, формат PSI1 — tools/pop_idx.py), а + * здесь остаётся только то, что от набора НЕ зависит: устройство записи и + * пределы, на которые рассчитан загрузчик. + * + * ЗАПИСЬ ЧИТАЕТСЯ С ДИСКА ПОБАЙТОВО, прямо в pop_snd_tbl[]. Значит + * раскладка структуры в ABI SDCC z80 (uint8 + uint16 + uint16 подряд, без + * выравнивания = 5 байт) — часть дискового контракта, а не деталь + * компилятора. Расхождение молча разъехало бы весь набор, поэтому оно + * ловится статически (pop_snd_data.c) и проверяется загрузчиком по полю + * размера записи в заголовке индекса. + */ +#ifndef POP_SND_TBL_H +#define POP_SND_TBL_H + +#include + +/* Пространство id оригинала: 0..56. От набора не зависит. */ +#define POP_SND_COUNT 57 +/* Блок запроса CBL при 8 бит; страница EMM кратна ему, поэтому ни один + * блок не пересекает границу страницы (см. docs/sound_plan.md). */ +#define POP_SND_BLOCK 128 +/* СКОЛЬКО СТРАНИЦ ГОТОВ ПРИНЯТЬ ЗАГРУЗЧИК — вместимость, а НЕ размер + * конкретного набора: тот приходит в индексе (pop_snd_pages). Запас + * ради будущих наборов без перекомпиляции; цена — 16 байт таблицы + * страниц вместо 9-10. */ +#define POP_SND_MAX_PAGES 16 + +/* ПЕРВЫЙ БЛОК СТРАНИЦЫ 0 — ТИШИНА. Насос льёт её, когда играть нечего: + * без этого железо доигрывает по кругу хвост своего буфера (жужжание). + * Это инвариант ПРОГРАММЫ — упаковщик обязан оставить блок свободным, а + * загрузчик проверяет, что ни одна запись в него не залезла. */ +#define POP_SND_SILENCE_PAGE 0 +#define POP_SND_SILENCE_OFF 0x0000 + +/* page — индекс в pop_snd_page[] (физические страницы EMM), off — начало + * внутри страницы, len — длина PCM. Оба кратны POP_SND_BLOCK; len == 0 + * значит «у этого id нет оцифровки» (это музыка, см. sound_plan.md). */ +typedef struct { + uint8_t page; + uint16_t off; + uint16_t len; +} pop_snd_ent_t; + +#endif /* POP_SND_TBL_H */ diff --git a/applications/SprPoP/tools/paths.py b/applications/SprPoP/tools/paths.py index 7577cdc..0638cf1 100644 --- a/applications/SprPoP/tools/paths.py +++ b/applications/SprPoP/tools/paths.py @@ -5,9 +5,10 @@ assets/, gen/ и build/. Раскладка: - assets/orig/ ОРИГИНАЛЬНЫЕ данные (SDLPoP, MSDOS, записи музыки). - В репозиторий не идут — копирайт; откуда их взять, - написано в assets/orig/README.md. + assets/orig/ ОРИГИНАЛЬНЫЕ данные (SDLPoP, записи музыки и, + необязательно, MSDOS). В репозиторий не идут — + копирайт; выкачивает их tools/fetch_orig.py + (`make fetch`), см. assets/orig/README.md. build/atl// промежуточный выхлоп упаковщиков: отдельные атласы, из которых потом склеиваются архивы. Пересоздаётся, в репозиторий не идёт. @@ -31,6 +32,7 @@ SDLPOP = ORIG / "SDLPoP" SDLPOP_DATA = SDLPOP / "data" SDLPOP_SRC = SDLPOP / "src" MSDOS = ORIG / "MSDOS" + MUSIC_ROOT = ORIG / "PoP1_DOS_music" # Записи DOS-версии лежат в четырёх вариантах. Имена треков во всех наборах # одинаковые, различаются каталог и расширение; ogg_MT-32 — та же музыка в @@ -52,6 +54,44 @@ def music_src(fmt="flac"): sub, ext = MUSIC_FORMATS[fmt] return MUSIC_ROOT / sub, ext +# --- источники звуковых эффектов ------------------------------------------ +# Контейнеры digisnd1..3 есть в ДВУХ поставках, и они не одинаковы: в SDLPoP +# лежит версия из репозитория (её и качает fetch_orig.py), в MSDOS/ — из +# оригинального дистрибутива 1.0, если пользователь его себе положил. +# ПО УМОЛЧАНИЮ БЕРЁМ SDLPoP: сборка обязана работать без MSDOS вообще. +# Ключ = значение SND_SRC в Makefile; регистр имён в поставках разный. +SND_SOURCES = { + "sdlpop": (SDLPOP_DATA, "DIGISND%d.DAT"), + "msdos": (MSDOS, "digisnd%d.dat"), +} + + +def snd_src(name="sdlpop"): + """(каталог, шаблон имени) контейнеров оцифровки для источника.""" + if name not in SND_SOURCES: + raise SystemExit( + "неизвестный источник звука %r; есть: %s" + % (name, ", ".join(sorted(SND_SOURCES)))) + return SND_SOURCES[name] + + +def find_ci(directory, name): + """Путь к файлу без оглядки на регистр (FAT-имена приезжают по-разному). + + Возвращает None, если файла нет. Точное совпадение проверяется первым, + чтобы на case-insensitive ФС не читать каталог зря. + """ + p = directory / name + if p.exists(): + return p + if not directory.is_dir(): + return None + low = name.lower() + for e in directory.iterdir(): + if e.name.lower() == low: + return e + return None + # --- выход ---------------------------------------------------------------- ATL = APP / "build" / "atl" # промежуточные атласы, по набору в папке PACKED = APP / "assets" / "packed" # раскладка диска игры diff --git a/applications/SprPoP/tools/pop_idx.py b/applications/SprPoP/tools/pop_idx.py new file mode 100644 index 0000000..41f7ae4 --- /dev/null +++ b/applications/SprPoP/tools/pop_idx.py @@ -0,0 +1,126 @@ +"""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 нет оцифровки (это музыка). +""" + +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)) diff --git a/applications/SprPoP/tools/pop_pack_sound.py b/applications/SprPoP/tools/pop_pack_sound.py index d607631..231470f 100644 --- a/applications/SprPoP/tools/pop_pack_sound.py +++ b/applications/SprPoP/tools/pop_pack_sound.py @@ -2,7 +2,11 @@ """ pop_pack_sound.py — упаковщик ЗВУКОВЫХ ЭФФЕКТОВ (оцифровка, chtab нет). -ИСТОЧНИК. `MSDOS/digisnd1..3.dat` — оригинальные контейнеры. Разбор: +ИСТОЧНИК. Контейнеры `digisnd1..3` — по умолчанию из `SDLPoP/data/` +(`--source sdlpop`), потому что SDLPoP скачивается автоматически и без него +сборки всё равно нет. У кого лежит оригинальный дистрибутив DOS, может +взять оцифровку оттуда: `--source msdos` (= `make SND_SRC=msdos`); наборы +НЕ одинаковы — версии данных разные. Разбор: индекс по 8 байт (id, offset, size), **первый байт ресурса — контрольная сумма**, тело за ней (спецификация `docs/POP-DAT-FormatSpecifications`, §3.1.2). Тело — один из двух вариантов заголовка: @@ -29,8 +33,12 @@ CBL — конвертировать нечего. знает слова «стык»: замапил страницу, отдал 128 байт, сдвинул указатель. Плата — до 127 байт на звук, всего ~2 КБ. -Выход: build/atl/sound/s0..sN.bin (по 16 384 Б) + gen/pop_sound_tbl.h +ВЫХОД: build/atl/sound/s0..sN.bin (страницы по 16 384 Б) + snd.idx — +дисковая раскладка набора (формат PSI1, tools/pop_idx.py). C-заголовка +БОЛЬШЕ НЕТ: раскладка уехала на диск, чтобы замена набора не требовала +перекомпиляции игры. """ +import argparse import os import struct import sys @@ -38,11 +46,11 @@ import sys 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 # формат дискового индекса PSI1 (tools/pop_idx.py) HERE = os.path.dirname(os.path.abspath(__file__)) -DAT_DIR = str(P.MSDOS) OUT_DIR = str(P.atl("sound")) -HDR = str(P.GEN / "pop_sound_tbl.h") +IDX = "snd.idx" # дисковый индекс набора, рядом с snd.arc PAGE = 16384 BLOCK = 128 # блок запроса CBL при 8 бит (см. cbl.h) @@ -101,12 +109,35 @@ def resample(src, src_rate, dst_rate): def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[1]) + ap.add_argument("--source", default="sdlpop", choices=sorted(P.SND_SOURCES), + help="откуда брать digisnd1..3 (по умолчанию sdlpop)") + ap.add_argument("--order", default="id", choices=("id", "reverse"), + help="порядок укладки эффектов в страницах: id (штатный) " + "или reverse — ОТЛАДОЧНЫЙ, для проверки того, что " + "игра берёт раскладку из snd.idx, а не из своей " + "памяти: смещения расходятся у ВСЕХ эффектов") + args = ap.parse_args() + + # Имена контейнеров в поставках отличаются регистром, и на Linux это не + # мелочь: ищем без оглядки на него (P.find_ci). + dat_dir, pattern = P.snd_src(args.source) res = {} for i in (1, 2, 3): - p = os.path.join(DAT_DIR, f"digisnd{i}.dat") - if not os.path.exists(p): - sys.exit(f"нет файла {p}") + p = P.find_ci(dat_dir, pattern % i) + if p is None: + sys.exit(f"нет файла {dat_dir / (pattern % i)} " + f"(источник {args.source}); см. assets/orig/README.md") res.update(dat_resources(p)) + print(f" источник оцифровки: {args.source} ({dat_dir})") + # У набора SDLPoP оцифровка беднее: `spiked` (48) там длиной 7 сэмплов, + # то есть звука пик фактически нет (разбор — docs/sound_plan.md). Если + # у пользователя лежит дистрибутив DOS, сказать об этом стоит вслух — + # молча отдавать тишину вместо эффекта нельзя. + if args.source == "sdlpop" and P.MSDOS.is_dir(): + print(" ВНИМАНИЕ: у вас есть assets/orig/MSDOS — там оцифровка полнее" + " (в наборе SDLPoP звук 48 `spiked` пустой).") + print(" собрать с ней: make SND_SRC=msdos") sounds = {} for sid, body in sorted(res.items()): @@ -128,7 +159,15 @@ def main(): # тесная и её отказ ронял бы весь звук. blob = bytearray([0x80] * BLOCK) table = {} - for sid in sorted(sounds): + # ПОРЯДОК УКЛАДКИ. Штатно — по возрастанию id; `--order reverse` кладёт + # те же эффекты задом наперёд. Данные и таблица при этом остаются + # согласованными (индекс печатается из table), но НИ ОДНО смещение не + # совпадает со штатным набором — только так и проверяется, что игра + # читает раскладку с диска, а не помнит её с компиляции: у двух + # реальных наборов совпадают 23 записи из 31, и подмена на них видна + # слабо. + order = sorted(sounds, reverse=(args.order == "reverse")) + for sid in order: pad = (-len(blob)) % BLOCK blob += bytes([0x80]) * pad # 0x80 — тишина в беззнаковом 8-бит # Длину округляем ВВЕРХ до блока: хвост последнего блока — это @@ -148,61 +187,34 @@ def main(): blob += bytes([0x80]) * ((-len(blob)) % PAGE) os.makedirs(OUT_DIR, exist_ok=True) + # Старые страницы СНОСИМ: у разных источников их разное число (SDLPoP 9, + # MSDOS 10), и лишняя s9.bin от прошлого прогона уехала бы в архив + # хвостом, которого нет в таблице. + for old in os.listdir(OUT_DIR): + if old.startswith("s") and old.endswith(".bin"): + os.remove(os.path.join(OUT_DIR, old)) npages = len(blob) // PAGE for i in range(npages): with open(os.path.join(OUT_DIR, f"s{i}.bin"), "wb") as f: f.write(blob[i * PAGE:(i + 1) * PAGE]) - lines = [ - "/* pop_sound_tbl.h — раскладка оцифрованных эффектов PoP.", - " * СГЕНЕРЕНО tools/pop_pack_sound.py — НЕ править вручную.", - " *", - " * Данные: 8 бит беззнаковые, МОНО, %g Гц (CBL_FREQ_10K9) — формат" % RATE, - " * CBL один в один, конвертировать в рантайме нечего.", - " *", - " * Начало каждого звука кратно %d — размеру блока запроса CBL, а" % BLOCK, - " * страница (%d) кратна ему же. Поэтому НИ ОДИН блок не пересекает" % PAGE, - " * границу страницы. Больше того: с 2026-08-25 каждый звук лежит\n * ЦЕЛИКОМ в одной странице — не влезает в остаток, значит начинаем\n * новую (страницы EMM у нас с запасом). Единственное исключение —\n * эффект, который сам ДЛИННЕЕ страницы: для него насос переключает\n * страницу на ходу, и эта ветка в pop_sfx_fill остаётся ради него.", - " *", - " * Смещение хранится РАЗОБРАННЫМ на страницу и адрес в окне: на Z80", - " * 32-битная арифметика дорога, а так всё влезает в 8/16 бит.", - " * Длина округлена вверх до блока — неполных блоков не бывает.", - " */", - "#ifndef POP_SOUND_TBL_H", - "#define POP_SOUND_TBL_H", - "", - "#include ", - "", - "#define POP_SND_PAGES %d" % npages, - "#define POP_SND_COUNT %d" % NSOUND, - "#define POP_SND_BLOCK %d" % BLOCK, - "/* Первый блок страницы 0 — тишина для насоса (см. упаковщик). */", - "#define POP_SND_SILENCE_PAGE 0", - "#define POP_SND_SILENCE_OFF 0x0000", - "", - "/* Полная таблица нужна только горячей половине звука. */", - "#ifdef POP_SND_TABLE", - "typedef struct { uint8_t page; uint16_t off; uint16_t len; } pop_snd_ent_t;", - "", - "/* len == 0 — у звука нет оцифровки (это музыка, см. sound_plan.md). */", - "static const pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT] = {", - ] - for sid in range(NSOUND): - off, ln = table.get(sid, (0, 0)) - lines.append(" { %d, 0x%04X, %5d }, /* %2d */" % (off >> 14, off & 0x3FFF, ln, sid)) - lines += [ - "};", - "#endif /* POP_SND_TABLE */", - "", - "#endif", - "", - ] - with open(HDR, "w") as f: - f.write("\n".join(lines)) + # ДИСКОВЫЙ ИНДЕКС. Та же раскладка, что и в заголовке ниже, но на + # диске: с ним пара snd.idx + snd.arc заменяется целиком, без + # перекомпиляции игры (docs/music_runtime_index_plan.md, часть II). + # Проверки внутри snd_pack — ровно те, что делает загрузчик на Z80: + # набор, который не пройдёт здесь, не пройдёт и там. + if npages > pop_idx.SND_MAX_PAGES: + sys.exit("набор занял %d страниц, а загрузчик берёт не больше %d" + % (npages, pop_idx.SND_MAX_PAGES)) + entries = [table.get(sid, (0, 0)) for sid in range(NSOUND)] + entries = [(off >> 14, off & 0x3FFF, ln) for off, ln in entries] + with open(os.path.join(OUT_DIR, IDX), "wb") as f: + f.write(pop_idx.snd_pack(entries, npages)) + print(" индекс -> %s (%d страниц)" % (os.path.join(OUT_DIR, IDX), + npages)) have = sum(1 for s in sounds.values() if s) print(f"ИТОГО: {have} эффектов, {len(blob)} Б = {npages} EMM-страниц -> {OUT_DIR}") - print(f" заголовок -> {HDR}") if __name__ == "__main__": diff --git a/applications/SprPoP/tools/tests/test_pop_idx.py b/applications/SprPoP/tools/tests/test_pop_idx.py new file mode 100644 index 0000000..377665d --- /dev/null +++ b/applications/SprPoP/tools/tests/test_pop_idx.py @@ -0,0 +1,144 @@ +"""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("