Compare commits

..

3 Commits

Author SHA1 Message Date
snark13 df5071a967 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
2026-08-31 17:17:59 +03:00
snark13 2349481b86 SprPoP: звуковые эффекты без перелинковки — раскладка уехала на диск
Часть II плана music_runtime_index_plan.md (SI0..SI4).  gen/pop_sound_tbl.h
БОЛЬШЕ НЕ ГЕНЕРИРУЕТСЯ: раскладка набора читается из SND/snd.idx (формат
PSI1, писатель и разборщик — tools/pop_idx.py, 22 теста в make test-tools).
Один и тот же sprpop.exe работает с набором SDLPoP (9 страниц) и MSDOS
(10) — sha256 бинарника при смене набора не меняется.

Заодно умолчание источника эффектов переведено на SDLPoP (SND_SRC=sdlpop):
сборка обязана работать без оригинального дистрибутива DOS.  У кого он
есть, включает лучший набор явно — make SND_SRC=msdos (там полнее
оцифровка: в SDLPoP звук 48 spiked пустой).

Устройство: pop_snd_tbl/pop_snd_page/pop_snd_pages — резидентные данные
(pop_snd_data.c), тип и инварианты — рукописный pop_snd_tbl.h.  Записи
читаются ОДНИМ read прямо в таблицу, поэтому sizeof(pop_snd_ent_t) == 5
стало частью дискового контракта: проверяется статически и полем размера
записи в заголовке.  POP_SND_PAGES как compile-time размер набора исчез —
вместо него POP_SND_MAX_PAGES (вместимость, 16) и runtime pop_snd_pages.

Цена: таблица переехала из _CODE в _DATA, суммарный резидент почти не
изменился (куча 239 -> 229 Б); банк 8 +601 Б на чтение и валидацию.

Валидация не доверяет файлу: заголовок целиком плюс каждая запись
(страница, смещение, кратность блоку, непересечение с блоком тишины,
выход за последнюю страницу).  Последнее считается В БЛОКАХ — байтовый
адрес конца не влезает в uint16, а 32-битная арифметика на Z80 дорога.

НЕТ ИНДЕКСА — ЭФФЕКТОВ НЕТ, НО МУЗЫКА ИГРАЕТ.  Первая версия просто
возвращала ошибку, и игра становилась непроходимой: тишину льёт первый
блок набора, без набора CBL не открывался, а с ним вставала музыка (её
блоки считает тот же насос) — заставка ждала конца трека вечно.  Теперь
поднимается пустой набор с блоком тишины.  Заливается ровно 128 байт и
под DI: gfx_w0_page_prepare ставит в страницу IRQ-стабы, и заливка всей
страницы затирала их — первое же прерывание давало чёрный экран.

Грабли сборки: смена SND_SRC тихо давала неверный результат
(sdlpop -> msdos -> sdlpop оставлял чужой набор в assets/packed).  Причина
не в логике, а в секундной гранулярности mtime.  Лечение убирает время из
решения: смена варианта сносит stamp'ы своего семейства, а упаковка,
сборка архива и копия индекса делаются одним рецептом.  То же получила и
музыка (MUSIC_FMT).

Проверено в MAME: таблица в памяти совпадает с файлом из образа побайтово;
один EXE поднимает оба набора; отладочный --order reverse (30 из 31
записей отличаются от штатных) звучит правильно; битый индекс выключает
эффекты, не роняя игру; без индекса PV-сцена проходит с музыкой; Ctrl+S
работает в обоих режимах.  Разбор — docs/sound_plan.md §10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 16:14:53 +03:00
snark13 589894c50d SprPoP: автономность — внешние данные качаются, а не хранятся
В репозитории нет ни байта чужих данных, но есть знание, откуда их взять:
tools/fetch_orig.py качает SDLPoP (ресурсы + исходники-эталон) и записи
саундтрека, причём адрес архива музыки читает из самого SDLPoP
(ReadMe.amigaos4, секция «AUDIO IS SLOW/AWFULL»); запасной адрес вшит
константой.  Цели: make fetch / fetch-sdlpop / fetch-music / fetch-check /
fetch-list.

Версия SDLPoP пишется в .fetch.json вместе с манифестом sha256 всего
дерева.  По нему следующий fetch отличает НАШИ отладочные врезки
(POP_TRACE — покадровая трасса Кида, дампы палитры и спрайтов) от
нетронутых файлов и не сносит их молча: без --force каталог не трогается
вовсе, с --force старая копия уезжает в бэкап .cache/.

MSDOS/ не качается и НЕ НУЖЕН: уровни и оцифровка берутся из SDLPoP.

У SprPoP теперь свой .gitignore, написанный так, чтобы стать корневым при
выделении в отдельный репозиторий (пути от корня приложения, ничего про
applications/).  Из корневого .gitignore тулчейна SprPoP-секция убрана,
чтобы две копии не разъезжались.  Заодно закрылась дыра: шаблон
applications/*/*/*.exe не покрывал артефакты в корне SprPoP.

assets/orig/README.md выведен из-под игнора — без него в чистом клоне не
написано, откуда брать данные, а это и есть смысл затеи.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 16:14:12 +03:00
34 changed files with 2234 additions and 545 deletions
+6 -12
View File
@@ -132,15 +132,9 @@ applications/PoP/PoP1_DOS_music/
# не идёт (сама roomtest и есть версируемая ветка разработки).
applications/PoP/R1/
# SprPoP — автономное приложение. В репозиторий идут исходники, генерируемые
# заголовки (gen/) и упакованные ресурсы (assets/packed/), потому что без них
# из чистого клона ничего не собрать. НЕ идут:
# assets/orig/ оригинальные данные игры — копирайт (SDLPoP, MSDOS,
# записи музыки DOS-версии); откуда их взять,
# написано в assets/orig/README.md
# assets/packed/LEVELS/ уровни res20xx.bin ложатся на диск прямо из
# оригинала, конвертации нет — то есть это тот же
# копирайтный материал, только в раскладке диска
# build/ уже покрыт общим правилом build/ выше
applications/SprPoP/assets/orig/
applications/SprPoP/assets/packed/LEVELS/
# SprPoP — автономное приложение, и правила игнора у него СВОИ:
# applications/SprPoP/.gitignore. Он написан так, чтобы стать корневым
# .gitignore, когда SprPoP выделят в отдельный репозиторий, — поэтому
# здесь его содержимое НЕ дублируется (иначе разъедется). Коротко: в
# репозиторий не идут assets/orig/ (чужие данные — их выкачивает
# `make fetch`) и assets/packed/LEVELS/ (уровни из оригинала как есть).
+75
View File
@@ -0,0 +1,75 @@
# ===========================================================================
# SprPoP — .gitignore приложения
# ===========================================================================
# Пока SprPoP лежит внутри репозитория C-Compiler, git читает этот файл как
# правила ДЛЯ ЭТОГО ПОДДЕРЕВА, и он дополняет корневой .gitignore тулчейна.
# Когда SprPoP выделят в самостоятельный проект, файл станет КОРНЕВЫМ и
# продолжит работать как есть: все пути написаны от корня SprPoP и ничего
# не знают ни про applications/, ни про раскладку тулчейна.
# ---------------------------------------------------------------------------
# ЧУЖИЕ ДАННЫЕ — в репозиторий не идут никогда
# ---------------------------------------------------------------------------
# assets/orig/ — оригинальные данные игры (копирайт). В репозитории вместо
# них лежит ЗНАНИЕ, откуда их взять: tools/fetch_orig.py (`make fetch`)
# качает SDLPoP и записи саундтрека, MSDOS/ пользователь кладёт сам (он
# необязателен). Скачанные архивы кэшируются в assets/orig/.cache/.
#
# Шаблон со ЗВЁЗДОЧКОЙ, а не `assets/orig/`: git не заходит внутрь
# игнорируемого каталога, и исключение ниже иначе не сработало бы, а
# README.md — единственное, что отсюда ОБЯЗАНО быть в репозитории: без
# него в чистом клоне не написано, откуда брать данные.
assets/orig/*
!assets/orig/README.md
# Уровни res20xx.bin ложатся на диск ПРЯМО из оригинала, конвертации нет —
# то есть это тот же копирайтный материал, только в раскладке диска.
# Остальное содержимое assets/packed/ версионируется намеренно: без него из
# чистого клона не собрать игру, а исходников (assets/orig/) может не быть.
assets/packed/LEVELS/
# ---------------------------------------------------------------------------
# ВЫХОД СБОРКИ
# ---------------------------------------------------------------------------
# build/ — .exe, раскладка диска, hdd/, промежуточные атласы, stamp'ы.
# Правило без слэша спереди намеренно: под ним и tests/host/build/.
build/
# Промежуточный каталог sprinter-cc.
.sprinter-cc-*/
# Артефакты компиляции, которые SDCC кладёт рядом с целью — в корень
# приложения. Только корень: единственный версионируемый ассемблерный
# файл (tests/host/bank_stub.s) лежит глубже и под правило не попадает.
/*.exe
/*.asm
/*.lst
/*.lk
/*.ihx
/*.noi
/*.sym
/*.map
/*.rel
/*.cdb
/*.mem
/*.rst
# Хостовые тесты и утилиты собираются нативным cc.
*.o
*.obj
*.dSYM/
# ---------------------------------------------------------------------------
# ОС / редакторы / локальные настройки
# ---------------------------------------------------------------------------
.DS_Store
._*
*~
*.swp
*.bak
*.orig
*.rej
.vscode/
.idea/
.kilo/
.claude/
+16 -4
View File
@@ -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,9 @@ 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`) поверх `<kbd_raw.h>`. |
| `pop_sfx.c` + `pop_sfx_cold.c` | Звук: насос CBL в резиденте + холодная загрузка набора. Раскладка эффектов НЕ компилируется в EXE — читается с диска (`SND/snd.idx`, формат `PSI1`); см. `docs/sound_plan.md` §10. |
| `pop_music.c` | Музыка (банк 9): загрузка треков, кольцо для финальной темы. Длины и длительности — из `MUS/mus.idx` (EMM-страница + accessor `pop_music_info`), в EXE их нет; см. `docs/sound_plan.md` §11. |
| `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`). |
## Банки: две мины, которых компилятор не видит
+112 -17
View File
@@ -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,8 +222,8 @@ 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 \
$(foreach i,$(MUS_IDS),MUS/m$(i).bin) \
SND/snd.arc SND/snd.idx \
$(foreach i,$(MUS_IDS),MUS/m$(i).bin) MUS/mus.idx \
FONT/font.atl \
TITLE/title.arc TITLE/title.pal \
PV/pv.arc PV/story.pal PV/pv.pal \
@@ -220,9 +233,14 @@ 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 можно менять без перекомпиляции.
# Ни звукового, ни музыкальных заголовков здесь БОЛЬШЕ НЕТ: раскладка
# эффектов и длины треков уехали на диск (SND/snd.idx, MUS/mus.idx), и EXE
# не зависит ни от звукового набора, ни от набора записей.
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_music_tbl.h pop_music_ticks.h pop_font.h)
pop_guard_pal.h pop_shadow_atlas.h pop_font.h)
PACKED_FILES := $(addprefix $(PACKED)/,$(DISK))
STAGED := $(addprefix $(BUILD_DIR)/,$(DISK))
@@ -343,6 +361,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 +390,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)/pop_idx.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,8 +428,7 @@ $(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,$(foreach i,$(MUS_IDS),$(ATL)/music/m$(i).bin) $(ATL)/music/mus.idx,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))
$(eval $(call from_stamp,$(PV_ATL) $(ATL)/pv/story.pal $(ATL)/pv/pv.pal,pv))
@@ -396,9 +452,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
@@ -415,10 +477,12 @@ $(eval $(call copy_rule,TITLE/title.pal,$(ATL)/title/title.pal))
$(eval $(call copy_rule,PV/story.pal,$(ATL)/pv/story.pal))
$(eval $(call copy_rule,PV/pv.pal,$(ATL)/pv/pv.pal))
$(foreach i,$(MUS_IDS),$(eval $(call copy_rule,MUS/m$(i).bin,$(ATL)/music/m$(i).bin)))
$(eval $(call copy_rule,MUS/mus.idx,$(ATL)/music/mus.idx))
# Уровни идут на диск ПРЯМО из оригинала: формат res20xx.bin наш загрузчик
# читает как есть, упаковщика для них нет. 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,8 +511,8 @@ 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-music: $(foreach i,$(MUS_IDS),$(PACKED)/MUS/m$(i).bin)
resources-sound: $(PACKED)/SND/snd.arc $(PACKED)/SND/snd.idx
resources-music: $(foreach i,$(MUS_IDS),$(PACKED)/MUS/m$(i).bin) $(PACKED)/MUS/mus.idx
resources-font: $(PACKED)/FONT/font.atl
resources-title: $(PACKED)/TITLE/title.arc $(PACKED)/TITLE/title.pal
resources-pv: $(PACKED)/PV/pv.arc $(PACKED)/PV/story.pal $(PACKED)/PV/pv.pal
@@ -459,8 +523,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 +578,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 +624,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
+19 -2
View File
@@ -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`.
## Происхождение
+82
View File
@@ -0,0 +1,82 @@
# assets/orig — оригинальные данные
Здесь лежат **исходные** данные Prince of Persia, из которых `make resources`
делает ресурсы Sprinter-версии. В репозиторий этот каталог не идёт —
материал копирайтный (см. `.gitignore`). Вместо данных в репозитории лежит
**знание, откуда они берутся**: `tools/fetch_orig.py` и таблица источников
внутри него.
## Как заполнить
```
make fetch # SDLPoP + записи саундтрека (всё, что качается)
make fetch-check # что уже на месте
make fetch-list # таблица источников с адресами
```
Тонкости:
```
make fetch-sdlpop только SDLPoP
make fetch-music только музыка (43 МБ)
make FETCH_ARGS=--force fetch перекачать заново
make FETCH_ARGS="--ref v1.23" fetch-sdlpop конкретный тег SDLPoP
```
Скачанные архивы кэшируются в `assets/orig/.cache/` — повторный `make fetch`
сеть не трогает. Версия SDLPoP записывается в `assets/orig/SDLPoP/.fetch.json`
(ветка/тег, sha, дата), и следующий `make fetch` по ней понимает, последняя
у нас версия или нет.
Собрать игру без `assets/orig/` **можно**: упакованные ресурсы лежат в
`../packed/`, а генерируемые заголовки — в `../../gen/`. `assets/orig` нужен
только чтобы ресурсы **перегенерировать**.
Исключение — `LEVELS`: файлы `res20xx.bin` ложатся на диск игры как есть, без
конвертации, поэтому `assets/packed/LEVELS/` тоже вне репозитория и заполняется
из `orig` командой `make resources-levels`.
## Что и откуда
| Каталог | Что это | Где взять |
|---|---|---|
| `SDLPoP/data/` | Распакованные ресурсы DOS-версии: PNG-спрайты (`VDUNGEON`, `VPALACE`, `KID`, `GUARD`, `SKEL`, `VIZIER`, `SHADOW`, `TITLE`, `PV`, `PRINCE`), палитры, `LEVELS/res20xx.bin`, оцифровка `DIGISND*.DAT` | `make fetch-sdlpop` — <https://github.com/NagyD/SDLPoP> (GPLv3) |
| `SDLPoP/src/` | Исходники SDLPoP. Нужны не только упаковщикам (`menu.c` и `seg009.c` содержат встроенные шрифты, из `seg005/seg006` берутся таблицы кадров и `seqtbl`), но и как **эталон механики**: расхождение в поведении всегда сверяется с ними | тем же `make fetch-sdlpop` |
| `PoP1_DOS_music/` | Записи музыки DOS-версии в четырёх наборах: `flac/` (по умолчанию), `mp3/`, `ogg/`, `ogg_MT-32/` (исполнение Roland MT-32). Имена треков во всех наборах одинаковые; какой брать — задаёт `MUSIC_FMT` | `make fetch-music`. Адрес архива скрипт читает из самого SDLPoP (`ReadMe.amigaos4`, секция «AUDIO IS SLOW/AWFULL»), запасной — popot.org |
| `MSDOS/` | **Необязательно.** Оригинальная поставка PoP 1.0 для MS-DOS. Используется только `digisnd*.dat` со звуком, и только если попросить: `make SND_SRC=msdos` | Не качается — дистрибутив игры, положить руками |
## MSDOS: зачем он и почему без него живётся
По умолчанию **всё** берётся из SDLPoP — и уровни, и оцифровка звука. Так
сборка работает на чистой машине сразу после `make fetch`.
`MSDOS/` даёт ровно одно: более полный набор оцифрованных эффектов. Из 31
звука 28 совпадают побайтно, а три — нет, и все три лучше в дистрибутиве:
`spiked` (48, насаживание на пики) в наборе SDLPoP длиной 7 сэмплов, то есть
его нет; `sword_vs_sword` (10) короче; `sword_moving` (11) — другая запись.
Разбор — `docs/sound_plan.md`. Включается это явно:
```
make SND_SRC=msdos resources-sound # перепаковать звук из дистрибутива
make SND_SRC=msdos # и собрать с ним
```
Источник входит в имя stamp'а, поэтому переключение `SND_SRC` само делает
звук устаревшим — `-B` не нужен.
## Кто что читает
| Упаковщик | Читает |
|---|---|
| `pop_pack_bg.py`, `render_room.py` | `SDLPoP/data/{VDUNGEON,VPALACE,LEVELS}` |
| `pop_pack_kid.py` | `SDLPoP/data/KID` |
| `pop_pack_guard.py` | `SDLPoP/data/{GUARD,SKEL,VIZIER,PRINCE}` |
| `pop_pack_shadow.py` | `SDLPoP/data/{KID,SHADOW}` |
| `pop_pack_title.py`, `pop_pack_intro.py` | `SDLPoP/data/{TITLE,PV,PRINCE,KID}` |
| `pop_extract_font.py` | `SDLPoP/src/{menu.c,seg009.c}` |
| `pop_extract_kid_data.py` | `SDLPoP/src/` (таблицы кадров и `seqtbl`) |
| `pop_pack_sound.py` | `SDLPoP/data/DIGISND*.DAT` (`--source sdlpop`, умолчание) либо `MSDOS/digisnd*.dat` (`--source msdos`) |
| `pop_pack_music.py` | `PoP1_DOS_music/<набор>/` (`--format flac\|mp3\|ogg\|mt32`) |
Точная раскладка путей — в `tools/paths.py`; менять её нужно там, а не в
отдельных упаковщиках. Адреса внешних источников — в `tools/fetch_orig.py`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -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` |
## Производительность
+48
View File
@@ -133,3 +133,51 @@ raw-клавиатура (`<kbd_raw.h>`), которую мы и так заби
и трогать его ради экономии секунды стоит только на спокойную голову.
**Выигрыш.** Одна-две секунды один раз на уровень.
## Музыка одним постоянно открытым архивом (замер 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).
Так что это оптимизация, а не исправление.
@@ -1,6 +1,25 @@
# Аудио без перелинковки: runtime-индексы музыки и эффектов
> Статус: план будущего улучшения, 2026-08-30. Код пока не менялся.
> Статус: **ОБЕ ЧАСТИ СДЕЛАНЫ 2026-08-31** — эффекты (SI0..SI4) и музыка
> (MI0..MI5). Разбор и результаты: `sound_plan.md` §10 (эффекты) и §11
> (музыка). Ниже — исходный план; расхождения перечислены следом.
>
> Что в реализации разошлось с планом ниже: файл назван `SND/snd.idx`
> (единый basename с `snd.arc`, оба в нижнем регистре), а при отсутствии
> индекса поднимается ПУСТОЙ набор с блоком тишины — иначе встаёт музыка
> и игра непроходима (§10.5 sound_plan). Про `ticks60` в `PMI1` решено:
> поле в формате оставить даже если сценарии перейдут на ожидание звука —
> загрузчик волен не тащить его в память.
>
> Расхождения части I с планом ниже:
> * файл назван `MUS/mus.idx` (единый стиль с `snd.idx`);
> * пауза конца уровня переведена НЕ на runtime-тики, а на состояние
> «заявка/загрузка/звучание» (`pop_music_active`) — §11.2: это снимает и
> зависимость от набора, и враньё делителя `/4` в быстрых режимах, и
> расхождение с SDLPoP при выключенном звуке;
> * PV-сцена (§8 плана) — не структура времён через пять функций, а четыре
> якоря-статики (8 байт), от которых отсчитываются прежние выражения;
> * `pages` в PMI1 не хранится — считается из `blocks`.
## 1. Цель
@@ -330,13 +349,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 +400,7 @@ typedef struct {
У SDLPoP эффект 48 содержит только 7 исходных сэмплов; это свойство самого
набора, а не ошибка runtime-индекса.
## 15. Файл `SND/SOUND.IDX`
## 15. Файл `SND/snd.idx`
IDX занимает один 512-байтовый сектор и после короткого заголовка является
точным дисковым дампом 57 записей `pop_snd_tbl`:
@@ -472,12 +491,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 +512,7 @@ SDLPoP-набор — 9. Предел 16 оставляет запас буду
`pop_snd_pages`.
При любой ошибке блок EMM и файловый дескриптор освобождаются, таблица не
используется, эффекты остаются выключенными. Набор без `SOUND.IDX` считается
используется, эффекты остаются выключенными. Набор без `snd.idx` считается
неполным; хранить в EXE старую таблицу как fallback не следует, иначе
зависимость бинарника от конкретного набора останется.
@@ -505,9 +524,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 +536,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 +564,7 @@ Makefile, упаковщик звука предпочтительно долж
### SI3 — сборка без перелинковки
- убрать фиксированный `SND_ATL` из десяти имён;
- сделать `SOUND.IDX` и `SND.ARC` согласованными результатами выбора набора;
- сделать `snd.idx` и `snd.arc` согласованными результатами выбора набора;
- проверить неизменность SHA/даты `sprpop.exe` при смене набора.
### SI4 — приёмка
@@ -561,7 +580,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;
+329 -6
View File
@@ -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,316 @@ 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` из той же сцены совпадает побайтно и
для проверки подмены не годится.
## 11. МУЗЫКА БЕЗ ПЕРЕЛИНКОВКИ: ДЛИНЫ И ДЛИТЕЛЬНОСТИ УЕХАЛИ НА ДИСК (2026-08-31)
Сделана часть I плана `music_runtime_index_plan.md` (этапы MI0..MI5, у нас
B0..B7). `gen/pop_music_tbl.h` и `gen/pop_music_ticks.h` УДАЛЕНЫ: длины
треков и длительности реплик читаются из `MUS/mus.idx` (формат `PMI1`).
Один и тот же `sprpop.exe` работает с любым из четырёх наборов записей.
### 11.1 Где живёт индекс и почему именно там
228 байт таблицы в W2 положить нельзя — там свободной кучи порядка двух
сотен. Поэтому индекс лежит в ОДНОЙ странице EMM, а в резиденте от него
остаются два байта (номер блока и страницы). Наружу указатель на EMM не
выдаётся: `pop_music_info()` подключает страницу в W0, копирует четыре
байта в буфер вызывающего и сразу снимает окно.
**Данные в странице лежат со смещения `POP_MUS_IDX_BASE` (0x100), а не с
нуля.** Причина в `gfx_w0_page_prepare`: он делает страницу безопасной
для W0, записывая В НЕЁ переход на IRQ-восстановитель (0x38) и RETN для
NMI (0x66). С нуля эти байты легли бы прямо в записи (id 10 и 21) и
испортили индекс; со смещением стабы и данные не пересекаются, и работает
штатная защита от прерываний, а не запрет их. Тот же приём и по той же
причине — в `pop_config.c` (`CFG_BASE`).
Числа страниц в индексе НЕТ: это производная от `blocks`, и дублирование
однажды разъехалось бы с ним. Считает `pop_music_pages()`.
### 11.2 Пауза конца уровня: состояние вместо счётчика
`pop_endmus_left` и `POP_MUS_TICKS_32/41` УДАЛЕНЫ. Главный цикл ждёт
`pop_music_active()` — «заявка лежит, идёт загрузка или трек звучит».
Одного `pop_music_busy()` там мало: между заявкой и первой нотой проходит
190230 мс (§9), и в этой щели он ложен. Раньше щель закрывал счётчик по
длине записи, но он тянул за собой две беды: зависимость EXE от набора и
жёсткий делитель `/4`, который врал в быстрых режимах (там логический кадр
3 кадра луча, а не 4). Три признака подряд закрывают щель без единой
подобранной константы, потому что `pop_music_service` обнуляет `req` и
тут же взводит `ld_busy`, а тот держится до записи `pop_mus_left`.
Побочно исправилось расхождение с оригиналом: при ВЫКЛЮЧЕННОМ звуке
`pop_sfx_play` не кладёт заявку, все три признака ложны, и уровень
меняется сразу — как в SDLPoP, где трек ставится под `if (is_sound_on)`
(seg006:651), а ожидание это `check_sound_playing()` (seg003:387).
Прежний счётчик взводился безусловно и держал пройденный уровень лишние
12 секунд в тишине.
### 11.3 PV-сцена: четыре якоря вместо compile-time шкалы
Шкала сцены осталась абсолютной (переход на ожидание звука — отдельный
разговор, см. план), но точки, зависящие от длины реплик, считаются при
входе в сцену: `pv_m50_end`, `pv_dialog1`, `pv_exit`, `pv_anim_end`.
Остальные выражения не изменились — они отсчитываются от якорей.
Восемь байт статики вместо протаскивания структуры времён через пять
функций (`pv_princess_step`, `pv_jaffar_step`, `pv_seq_period`,
`intro_pv_draw_frame`, `intro_pv_animated`), как предлагал план: сцена
одна на запуск, и переделка всех сигнатур того не стоит.
Нет индекса — длительности нулевые, и сцена просто идёт без пауз на
реплики: ждать нечего, звука-то нет.
### 11.4 Цена в памяти
За обе фазы (эффекты и музыка) резидент не вырос, а освободился:
| | до фазы A | после фазы B |
|---|---:|---:|
| `_CODE` | 23865 | 23544 |
| куча резидента | 239 Б | 256 Б |
Банк 9 похудел на 118 Б (ушла `pop_mus_tbl` из его rodata), банк 11 — на
длительности реплик. Прибавка: 2 байта на страницу индекса музыки и 8 на
якоря PV.
### 11.5 Что проверено на живой машине
* `sprpop.exe` ПОБАЙТОВО одинаков для наборов flac и mt32;
* все 22 трека в индексах различаются, и контрольные значения совпали с
тем, что план предсказывал по замерам: m32 438→449, m41 732→685,
m50 831→867, m53 985→1044, m56 9865→10462 блоков (78→82 страницы);
* на mt32-наборе PV-сцена проходит целиком: m50 → m53 → m52 звучат по
длительностям mt32, хотя в EXE их больше нет;
* без `mus.idx` музыки нет, эффекты работают, игра проходима — сцены
проскакивают без пауз на реплики.
**НЕ ПРОВЕРЕНО:** потоковый трек m56 на 82 страницах (у flac 78).
Кольцевой проигрыватель получает число страниц из индекса, и код к этому
готов, но живого прогона финала на mt32 не было — до него надо дойти в
игре. Это оставшийся пункт приёмки.
-104
View File
@@ -1,104 +0,0 @@
/*
* pop_music_tbl.h каталог музыкальных треков, СГЕНЕРИРОВАН.
* Источник: assets/orig/PoP1_DOS_music/flac (записи 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>
#define POP_MUS_COUNT 57
#define POP_MUS_BLOCK 128
#define POP_MUS_MAX_PAGES 78
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] = {
{ 0, 0 }, /* 0 */
{ 0, 0 }, /* 1 */
{ 0, 0 }, /* 2 */
{ 0, 0 }, /* 3 */
{ 0, 0 }, /* 4 */
{ 0, 0 }, /* 5 */
{ 0, 0 }, /* 6 */
{ 0, 0 }, /* 7 */
{ 0, 0 }, /* 8 */
{ 0, 0 }, /* 9 */
{ 0, 0 }, /* 10 */
{ 0, 0 }, /* 11 */
{ 0, 0 }, /* 12 */
{ 0, 0 }, /* 13 */
{ 0, 0 }, /* 14 */
{ 0, 0 }, /* 15 */
{ 0, 0 }, /* 16 */
{ 0, 0 }, /* 17 */
{ 0, 0 }, /* 18 */
{ 0, 0 }, /* 19 */
{ 0, 0 }, /* 20 */
{ 0, 0 }, /* 21 */
{ 0, 0 }, /* 22 */
{ 0, 0 }, /* 23 */
{ 3, 305 }, /* 24 regular_death */
{ 2, 196 }, /* 25 find_mirror_and_find_shadow */
{ 9, 1132 }, /* 26 embrace */
{ 8, 915 }, /* 27 cutscene_before_2_4_6_12 */
{ 6, 656 }, /* 28 fight_death */
{ 4, 428 }, /* 29 meet_Jaffar */
{ 3, 299 }, /* 30 life_potion */
{ 0, 0 }, /* 31 */
{ 5, 624 }, /* 32 end_level_4_and_killed_by_shadow */
{ 2, 205 }, /* 33 potion */
{ 0, 0 }, /* 34 */
{ 7, 888 }, /* 35 cutscene_before_8_9 */
{ 10, 1256 }, /* 36 out_of_time */
{ 3, 280 }, /* 37 guard_death_and_obtaining_the_sword */
{ 0, 0 }, /* 38 */
{ 13, 1591 }, /* 39 floating */
{ 5, 596 }, /* 40 cutscene_not_much_time_left */
{ 9, 1043 }, /* 41 story_5_level_end_and_merge_with_shadow */
{ 0, 0 }, /* 42 */
{ 3, 285 }, /* 43 killed_Jaffar */
{ 0, 0 }, /* 44 */
{ 0, 0 }, /* 45 */
{ 0, 0 }, /* 46 */
{ 0, 0 }, /* 47 */
{ 0, 0 }, /* 48 */
{ 0, 0 }, /* 49 */
{ 10, 1184 }, /* 50 story_2_princess */
{ 0, 0 }, /* 51 */
{ 20, 2443 }, /* 52 story_4_Jaffar_leaves */
{ 11, 1403 }, /* 53 story_3_Jaffar_enters */
{ 17, 2064 }, /* 54 intro_theme */
{ 11, 1350 }, /* 55 story_1_absence */
{ 78, 9865 }, /* 56 won */
};
#endif /* POP_MUSIC_TBL_H */
-44
View File
@@ -1,44 +0,0 @@
/*
* pop_music_ticks.h длительности треков в ТИКАХ СЦЕНЫ, СГЕНЕРИРОВАН.
* Набор: flac. Генератор: 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
#define POP_MUS_TICKS_24 214 /* regular_death */
#define POP_MUS_TICKS_25 138 /* find_mirror_and_find_shadow */
#define POP_MUS_TICKS_26 794 /* embrace */
#define POP_MUS_TICKS_27 642 /* cutscene_before_2_4_6_12 */
#define POP_MUS_TICKS_28 460 /* fight_death */
#define POP_MUS_TICKS_29 300 /* meet_Jaffar */
#define POP_MUS_TICKS_30 210 /* life_potion */
#define POP_MUS_TICKS_32 438 /* end_level_4_and_killed_by_shadow */
#define POP_MUS_TICKS_33 144 /* potion */
#define POP_MUS_TICKS_35 623 /* cutscene_before_8_9 */
#define POP_MUS_TICKS_36 881 /* out_of_time */
#define POP_MUS_TICKS_37 196 /* guard_death_and_obtaining_the_sword */
#define POP_MUS_TICKS_39 1116 /* floating */
#define POP_MUS_TICKS_40 418 /* cutscene_not_much_time_left */
#define POP_MUS_TICKS_41 732 /* story_5_level_end_and_merge_with_shadow */
#define POP_MUS_TICKS_43 200 /* killed_Jaffar */
#define POP_MUS_TICKS_50 831 /* story_2_princess */
#define POP_MUS_TICKS_52 1714 /* story_4_Jaffar_leaves */
#define POP_MUS_TICKS_53 985 /* story_3_Jaffar_enters */
#define POP_MUS_TICKS_54 1448 /* intro_theme */
#define POP_MUS_TICKS_55 947 /* story_1_absence */
#define POP_MUS_TICKS_56 6923 /* won */
#endif /* POP_MUSIC_TICKS_H */
-97
View File
@@ -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 <stdint.h>
#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
+8 -3
View File
@@ -8,10 +8,15 @@
#define _POP_SFX_INTERNAL_H
#include <stdint.h>
#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;
+73 -42
View File
@@ -15,7 +15,6 @@
#include "pop_shadow.h"
#include "pop_sfx.h"
#include "pop_music.h"
#include "pop_music_ticks.h" /* POP_MUS_TICKS_<id> — длина реплик набора */
#include "pop_arc.h"
#include "pv_arc.h"
#include "pop_cutscene.h"
@@ -60,9 +59,10 @@
* своего куска (например, всё поведение Джафара от PV_DIALOG1_START).
* 2) «ЖДЁМ КОНЦА РЕПЛИКИ» PV_M50_END и PV_EXIT_START. Это целиком
* длина НАШЕЙ записи, и она у каждого набора своя: m50 831 тик на
* flac против 867 на mt32, m53 985 против 1044. Поэтому берём их
* из POP_MUS_TICKS_<id>, которые печатает упаковщик того набора,
* которым собрана сборка (gen/pop_music_tbl.h).
* flac против 867 на mt32, m53 985 против 1044. Раньше их
* печатал упаковщик в gen/pop_music_ticks.h, из-за чего EXE был
* привязан к набору; теперь они приезжают из индекса набора
* (MUS/mus.idx) при входе в сцену см. ЯКОРЯ ниже.
*
* Всё, что стоит ПОСЛЕ реплики, сдвигается вместе с её концом отсюда
* сложения от PV_M50_END и PV_EXIT_START вместо литералов. */
@@ -72,46 +72,76 @@ enum {
/* Конец m50: кадр становится 8. Прежний литерал 846 отвечал длине 834
* тика на три больше нашей записи (831); расхождение осталось с
* времён, когда длину подставляли руками. */
PV_M50_END = PV_MUS_2_START + POP_MUS_TICKS_50,
PV_WAIT_END = PV_M50_END + 40, /* +frame(5) -> створка ворот */
PV_GATE_END = PV_WAIT_END + 48,
PV_DOOR_END = PV_GATE_END + 24,
PV_TURN_START = PV_DOOR_END,
PV_WALK1_START = PV_DOOR_END + 40,
PV_DIALOG1_START = PV_WALK1_START + 48, /* Джафар входит — реплика m53 */
/* Ниже — биты сценария внутри реплики Джафара, все от её начала. */
PV_MUS_4_LOAD = PV_DIALOG1_START + 54, /* подкачка m52 */
PV_WALK2_START = PV_DIALOG1_START + 176,
PV_DIALOG2_START = PV_DIALOG1_START + 416,
/* ПОДГОНКА ЗАКЛИНАНИЯ ПОД МУЗЫКУ СНЯТА (2026-08-28): теперь 0.
*
* История: сцена была render-bound кадр рисовался дороже своего
* бюджета, кадровая ветка пейсинга догона не имела, и анимация шла
* ~49 тиков в секунду вместо 60 (замер 2026-08-27). Реплика Джафара
* при этом играет по РЕАЛЬНОМУ времени, поэтому кода приходила
* примерно на секунду раньше молнии, и жест целиком (замах, шаг
* назад, вспышка) сдвигали раньше сценария на подобранные на слух
* 100 тиков.
*
* Причину убрали: отрисовка разложена по кадровым интервалам
* (pv_restore_bg), сцена идёт по единственным часам кадрам луча,
* и подкачка трека оплачивается интервалом (pop_music_load_step).
* После этого подгонка стала вредной: молния била больше чем на
* секунду раньше коды (проверка пользователем 2026-08-28). Константу
* оставляем на месте если запись другого набора (mt32/ogg) снова
* разъедется, крутить надо её, а не тайминги сценария. */
PV_MAGIC_LEAD = 0,
PV_RAISE_START = PV_DIALOG1_START + 696 - PV_MAGIC_LEAD,
PV_STEPBACK_START = PV_DIALOG1_START + 703 - PV_MAGIC_LEAD,
PV_MAGIC_START = PV_DIALOG1_START + 822 - PV_MAGIC_LEAD,
/* Конец m53 — снова длина записи, а не бит сценария. */
PV_EXIT_START = PV_DIALOG1_START + POP_MUS_TICKS_53,
PV_MUS_4_START = PV_EXIT_START + 42, /* +frame(6) -> «Джафар уходит» */
PV_GLASS_DONE = PV_EXIT_START + 210,
PV_SLUMP_START = PV_EXIT_START + 273,
PV_ANIM_TICKS = PV_EXIT_START + 469
PV_MAGIC_LEAD = 0
};
/* ПОДГОНКА ЗАКЛИНАНИЯ ПОД МУЗЫКУ СНЯТА (2026-08-28): теперь 0.
*
* История: сцена была render-bound кадр рисовался дороже своего
* бюджета, кадровая ветка пейсинга догона не имела, и анимация шла
* ~49 тиков в секунду вместо 60 (замер 2026-08-27). Реплика Джафара
* при этом играет по РЕАЛЬНОМУ времени, поэтому кода приходила
* примерно на секунду раньше молнии, и жест целиком (замах, шаг
* назад, вспышка) сдвигали раньше сценария на подобранные на слух
* 100 тиков.
*
* Причину убрали: отрисовка разложена по кадровым интервалам
* (pv_restore_bg), сцена идёт по единственным часам кадрам луча,
* и подкачка трека оплачивается интервалом (pop_music_load_step).
* После этого подгонка стала вредной: молния била больше чем на
* секунду раньше коды (проверка пользователем 2026-08-28). Константу
* оставляем на месте если запись другого набора (mt32/ogg) снова
* разъедется, крутить надо её, а не тайминги сценария. */
/* ЯКОРЯ ШКАЛЫ. Четыре точки, от которых отсчитывается всё остальное;
* считаются ОДИН РАЗ при входе в сцену из длительностей набора
* (pop_music_info). Держать их статикой, а не тащить структуру времён
* через пять функций: восемь байт против переделки всех сигнатур, а
* сцена всё равно одна на запуск.
*
* Нет индекса или трека длительность нулевая, и сцена просто проходит
* без пауз на реплики: ждать нечего, звука-то нет. */
static uint16_t pv_m50_end; /* конец «принцесса ждёт» (m50) */
static uint16_t pv_dialog1; /* Джафар входит — начало реплики m53 */
static uint16_t pv_exit; /* конец m53: Джафар уходит */
static uint16_t pv_anim_end; /* конец всей сцены */
/* Ниже — те же выражения, что были в enum: биты сценария отсчитываются от
* своего якоря и от записи НЕ зависят. */
#define PV_M50_END pv_m50_end
#define PV_WAIT_END ((uint16_t)(pv_m50_end + 40))
#define PV_GATE_END ((uint16_t)(pv_m50_end + 88))
#define PV_DOOR_END ((uint16_t)(pv_m50_end + 112))
#define PV_TURN_START PV_DOOR_END
#define PV_WALK1_START ((uint16_t)(pv_m50_end + 152))
#define PV_DIALOG1_START pv_dialog1
#define PV_MUS_4_LOAD ((uint16_t)(pv_dialog1 + 54))
#define PV_WALK2_START ((uint16_t)(pv_dialog1 + 176))
#define PV_DIALOG2_START ((uint16_t)(pv_dialog1 + 416))
#define PV_RAISE_START ((uint16_t)(pv_dialog1 + 696 - PV_MAGIC_LEAD))
#define PV_STEPBACK_START ((uint16_t)(pv_dialog1 + 703 - PV_MAGIC_LEAD))
#define PV_MAGIC_START ((uint16_t)(pv_dialog1 + 822 - PV_MAGIC_LEAD))
#define PV_EXIT_START pv_exit
#define PV_MUS_4_START ((uint16_t)(pv_exit + 42))
#define PV_GLASS_DONE ((uint16_t)(pv_exit + 210))
#define PV_SLUMP_START ((uint16_t)(pv_exit + 273))
#define PV_ANIM_TICKS pv_anim_end
/* Собрать шкалу под НАШ набор записей. 200 — цепочка битов сценария от
* конца m50 до входа Джафара (40+48+24+40+48), 469 хвост после его
* ухода; оба от записи не зависят. */
static void pv_timing_init(void)
{
pop_mus_info_t m50, m53;
(void)pop_music_info(POP_MUS_STORY_2, &m50);
(void)pop_music_info(POP_MUS_STORY_3, &m53);
pv_m50_end = (uint16_t)(PV_MUS_2_START + m50.ticks);
pv_dialog1 = (uint16_t)(pv_m50_end + 200);
pv_exit = (uint16_t)(pv_dialog1 + m53.ticks);
pv_anim_end = (uint16_t)(pv_exit + 469);
}
/* Возврат долга шкалы (см. цикл сцены): сколько порций насоса отдаём за один
* кадр сцены и сколько их вообще имеет смысл копить. 1 порция 11,7 мс;
* 128 порций полторы секунды, дальше догонять уже нечего. */
@@ -870,6 +900,7 @@ static int intro_pv_animated(void)
jaffar_actor.visible = 0;
jaffar_actor.step = 0;
tick = 0;
pv_timing_init(); /* шкала под длительности НАШЕГО набора */
intro_skip_begin(&skip);
while (tick < PV_ANIM_TICKS) {
if (intro_skip_requested(&skip)) {
+7 -20
View File
@@ -19,7 +19,6 @@
#include "pop_geom.h"
#include "pop_level.h" /* POP_LEVEL_LAST — level 15 исключён */
#include "pop_cutscene.h" /* POP_T60 — тики оригинала в кадры луча */
#include "pop_music_ticks.h" /* длина мелодии конца уровня */
#include <fcntl.h>
#include <unistd.h>
#include <sprinter_mem.h>
@@ -38,14 +37,6 @@ int8_t knock;
* оригинале, чтобы читы «следующий/предыдущий уровень» (levels_plan.md §4)
* легли сюда же без переделки. */
uint8_t pop_next_level;
/* СКОЛЬКО ЛОГИЧЕСКИХ КАДРОВ ЕЩЁ ДЕРЖАТЬ УРОВЕНЬ, пока играет мелодия конца.
* У оригинала (seg003:387, play_level_2) уровень не сменяется, пока
* `check_sound_playing()`: экран пройденного уровня живёт с анимацией
* факелов ровно столько, сколько звучит трек. У нас признак «звучит»
* pop_music_busy(), но он ложен при ВЫКЛЮЧЕННОЙ музыке, а оригинал
* выдерживает паузу и молча. Поэтому здесь взводится счётчик по длине
* записи (gen/pop_music_ticks.h), а главный цикл ждёт большего из двух. */
uint8_t pop_endmus_left;
/* ---- Атласы Kid (прямая адресация image>>3) ------------------------ *
@@ -367,17 +358,13 @@ void play_seq(void)
else if (ev == 4) {
/* seg006:646. На четвёртом уровне уход за дверь — это
* встреча с тенью, и трек там свой; на 13-м (Джафар) и
* 15-м оригинал молчит. Вместе с треком взводим
* задержку смены уровня (см. pop_endmus_left): кадров
* = длина записи в тиках -> кадры луча -> делённое на
* длину логического кадра (4 кадра луча вне боя). */
if (pop_current_level == 4) {
pop_sfx_play(32);
pop_endmus_left = (uint8_t)(POP_T60(POP_MUS_TICKS_32) / 4);
} else if (pop_current_level != 13) {
pop_sfx_play(41);
pop_endmus_left = (uint8_t)(POP_T60(POP_MUS_TICKS_41) / 4);
}
* 15-м оригинал молчит. Держать уровень на экране,
* пока трек звучит, забота главного цикла: он ждёт
* pop_music_active(), и своей задержки здесь больше
* нет (была по длине записи из gen/pop_music_ticks.h,
* из-за чего EXE зависел от набора музыки). */
if (pop_current_level == 4) pop_sfx_play(32);
else if (pop_current_level != 13) pop_sfx_play(41);
}
}
break;
-1
View File
@@ -32,7 +32,6 @@ extern int8_t knock;
* там рестарт того же уровня; загрузка следующего levels_plan.md. */
extern uint8_t pop_next_level;
/* Задержка смены уровня на время мелодии конца (см. pop_kid.c). */
extern uint8_t pop_endmus_left;
/* Загрузить npages атласов Kid (kid0..kid{npages-1}.atl). ДО initgraph.
* 0 OK, -1 ошибка. */
+110 -8
View File
@@ -13,14 +13,81 @@
#include <fcntl.h>
#include <unistd.h>
#include <sprinter_mem.h>
#include <string.h>
#include <sprite.h> /* gfx_w0_map/unmap — доступ к странице индекса */
#include "pop_music.h"
#include "pop_music_tbl.h"
#include "pop_sfx.h"
#include "pop_app.h" /* POP_APP_DEMO — в демке музыки нет */
#include "_pop_file.h"
uint8_t pop_mus_page[POP_MUS_PAGES];
/* --- ИНДЕКС НАБОРА ---------------------------------------------------- *
*
* Страница EMM с прочитанным mus.idx; 0 индекса нет. В W2 от него
* остаются ровно эти два байта, сама таблица (228 Б) лежит в странице.
*
* Данные лежат со смещения POP_MUS_IDX_BASE, а не с начала страницы:
* gfx_w0_page_prepare пишет туда стабы прерываний (0x38 и 0x66), и с
* нуля они попали бы прямо в записи. Со смещением всё расходится, и
* страница подключается штатно без запрета прерываний. */
static uint8_t mus_idx_blk;
static uint8_t mus_idx_page;
/* "MUS\mus.idx" — имя знает сам модуль (тот же приём, что в pop_arc.c:
* литерал лежит в rodata банка 9, и open() читает его, пока в W3 стоит
* этот банк). */
static const char mus_idx_path[] = "MUS\\mus.idx";
int8_t pop_music_index_load(void) __banked
{
uint8_t *w0 = (uint8_t *)POP_MUS_IDX_BASE;
uint8_t blk, page, ok;
int fd;
if (mus_idx_page) return 0;
POP_PATH_CALL(fd, open(mus_idx_path, O_RDONLY));
if (fd < 0) return -1;
blk = mem_alloc_pages(1);
if (!blk) { close(fd); return -1; }
page = mem_get_page(blk, 0);
if (!page ||
bank_read_page(fd, page, POP_MUS_IDX_BASE, POP_MUS_IDX_SIZE) < 0) {
close(fd);
mem_free_block(blk);
return -1;
}
close(fd);
gfx_w0_page_prepare(page); /* стабы лягут вне данных, см. BASE */
gfx_w0_map(page);
ok = (uint8_t)(memcmp(w0, "PMI1", 4) == 0 &&
w0[4] == POP_MUS_COUNT &&
w0[5] == POP_MUS_IDX_REC &&
w0[6] == 7 && /* log2(128) — блок насоса */
w0[7] == 1); /* u8 mono, CBL_FREQ_10K9 */
gfx_w0_unmap();
if (!ok) { mem_free_block(blk); return -1; }
mus_idx_blk = blk;
mus_idx_page = page;
return 0;
}
int8_t pop_music_info(uint8_t id, pop_mus_info_t *out) __banked
{
const uint8_t *rec;
const uint8_t *w0 = (const uint8_t *)POP_MUS_IDX_BASE;
out->blocks = 0;
out->ticks = 0;
if (!mus_idx_page || id >= POP_MUS_COUNT) return -1;
gfx_w0_map(mus_idx_page);
rec = w0 + POP_MUS_IDX_HDR + (uint16_t)id * POP_MUS_IDX_REC;
out->blocks = (uint16_t)(rec[0] | ((uint16_t)rec[1] << 8));
out->ticks = (uint16_t)(rec[2] | ((uint16_t)rec[3] << 8));
gfx_w0_unmap();
return out->blocks ? 0 : -1;
}
/* Два слота: играющий и подгружаемый. Своей таблицы страниц слот НЕ
* держит её и так хранит блок EMM, и mem_get_page отдаёт номер по
* индексу. Резидентный pop_mus_page[] заполняется на pop_music_play, для
@@ -53,6 +120,9 @@ static uint8_t ld_next; /* следующая страница к
static uint8_t ld_half; /* 0 = впереди первая половина страницы */
static uint8_t ld_pages;
static uint8_t ld_id;
/* Длина трека из индекса, снятая при открытии: лезть в страницу индекса
* на каждом шаге загрузки незачем она не меняется. */
static uint16_t ld_blocks;
static void slot_release(uint8_t n)
{
@@ -126,13 +196,17 @@ static void load_abort(void)
int8_t pop_music_load_begin(uint8_t id) __banked
{
char name[12];
pop_mus_info_t info;
uint8_t pages, n;
if (ring_on) pop_music_ring_stop(); /* потоковый трек и слоты не дружат */
if (ld_busy) load_abort();
if (id >= POP_MUS_COUNT) return -1;
pages = pop_mus_tbl[id].pages;
if (!pages || pages > POP_MUS_PAGES) return -1;
/* Длину и число страниц знает ИНДЕКС НАБОРА, а не EXE: у mt32 те же
* треки другой длины (docs/music_runtime_index_plan.md). */
if (pop_music_info(id, &info) != 0) return -1;
pages = pop_music_pages(info.blocks);
if (pages > POP_MUS_PAGES) return -1;
/* Грузим ВСЕГДА в не текущий слот: играющий трек читает свои страницы
* из прерывания, и отобрать их у него нельзя. */
@@ -152,6 +226,7 @@ int8_t pop_music_load_begin(uint8_t id) __banked
ld_half = 0;
ld_pages = pages;
ld_id = id;
ld_blocks = info.blocks;
return 0;
}
@@ -183,7 +258,7 @@ int8_t pop_music_load_step(void) __banked
close(ld_fd);
ld_busy = 0;
slot_pages[ld_slot] = ld_pages;
slot_blocks[ld_slot] = pop_mus_tbl[ld_id].blocks;
slot_blocks[ld_slot] = ld_blocks;
slot_id[ld_slot] = ld_id;
mus_ready = (uint8_t)(ld_slot + 1);
return 1;
@@ -229,7 +304,7 @@ int8_t pop_music_stream(uint8_t id) __banked
if (n != mus_cur) slot_release(mus_cur);
mus_cur = n;
slot_pages[n] = ld_pages;
slot_blocks[n] = pop_mus_tbl[id].blocks;
slot_blocks[n] = ld_blocks;
slot_id[n] = id;
pop_mus_id = id;
pop_mus_pg = 0;
@@ -283,9 +358,15 @@ void pop_music_ring_stop(void) __banked
int8_t pop_music_ring_start(uint8_t id) __banked
{
char name[12];
pop_mus_info_t info;
uint8_t i, page;
if (id >= POP_MUS_COUNT || !pop_mus_tbl[id].pages) return -1;
if (id >= POP_MUS_COUNT || pop_music_info(id, &info) != 0) return -1;
/* Потоковый трек НЕ ограничен POP_MUS_PAGES: он и не помещается в
* память целиком, ради того и кольцо. Предел здесь восьмибитные
* счётчики страниц (у mt32 финальная тема на 82 страницы против 78
* у flac). */
if (pop_music_pages(info.blocks) > 255) return -1;
pop_music_free(); /* обычные слоты кольцу не нужны */
pop_music_ring_stop();
mus_name(name, id);
@@ -294,8 +375,8 @@ int8_t pop_music_ring_start(uint8_t id) __banked
ring_fd_open = 1;
ring_blk = mem_alloc_pages(RING_PAGES);
if (!ring_blk) { ring_close_file(); return -1; }
ring_file_pages = pop_mus_tbl[id].pages;
ring_total = pop_mus_tbl[id].blocks;
ring_file_pages = (uint8_t)pop_music_pages(info.blocks);
ring_total = info.blocks;
/* Заполняем кольцо целиком: старт с полным буфером даёт девять секунд
* форы, и первая же тяжёлая сцена финала не догонит насос. */
for (i = 0; i < RING_PAGES; i++) {
@@ -375,3 +456,24 @@ uint8_t pop_music_busy(void) __banked
{
return (uint8_t)(pop_mus_left != 0);
}
/* ТРЕК В РАБОТЕ — заявка лежит, идёт загрузка или он звучит.
*
* Одного pop_music_busy() там, где ждут КОНЦА мелодии, мало: между
* заявкой (pop_sfx_play кладёт pop_mus_req) и первым звуком проходит
* 190-230 мс открытие файла и чтение первой полустраницы (замер и
* разбор docs/sound_plan.md §9). В этой щели busy ещё ложен, и
* ожидание закончилось бы, не начавшись.
*
* Три признака подряд закрывают её без единой подобранной константы:
* pop_music_service обнуляет req и тут же взводит ld_busy в load_begin,
* а тот держится до самой записи pop_mus_left. Снаружи переход
* «запрошен -> грузится -> звучит» непрерывен, потому что между этими
* операторами главный цикл не выполняется.
*
* И наоборот: если трека нет или память кончилась, все три ложны сразу
* ждать нечего, как и должно быть. */
uint8_t pop_music_active(void) __banked
{
return (uint8_t)(pop_mus_req || ld_busy || pop_mus_left);
}
+45
View File
@@ -28,6 +28,47 @@
* либо резать, либо стримить, и это отдельная задача. */
#define POP_MUS_PAGES 20
/* --- ИНДЕКС НАБОРА (MUS/mus.idx, формат PMI1) ------------------------- *
*
* Длины треков и длительности реплик приезжают С ДИСКА, а не из EXE: так
* набор записей (flac/mp3/ogg/mt32) меняется без перекомпиляции игры.
* Формат пишет tools/pop_idx.py, он же покрыт make test-tools.
*
* Индекс живёт в ОДНОЙ странице EMM, а не в W2: таблица на 57 записей
* 228 байт, а свободной кучи в резиденте порядка двух сотен. Наружу
* указатель на EMM не выдаётся: pop_music_info копирует запись в буфер
* вызывающего, на короткое время подключая страницу в W0. */
#define POP_MUS_COUNT 57 /* пространство id оригинала, 0..56 */
#define POP_MUS_IDX_REC 4 /* blocks (u16) + ticks60 (u16) */
#define POP_MUS_IDX_HDR 16
#define POP_MUS_IDX_SIZE 512 /* сектор DSS */
/* Индекс кладётся в страницу СО СМЕЩЕНИЯ 0x100, а не с нуля. Причина в
* gfx_w0_page_prepare: он делает страницу безопасной в W0, записывая в
* неё переход на IRQ-восстановитель (0x38) и RETN для NMI (0x66). С
* нуля эти байты легли бы прямо в записи (id 10 и 21) и испортили
* индекс; со смещением 0x100 стабы и данные не пересекаются, и работает
* штатная защита от прерываний, а не запрет их. Тот же приём и по той
* же причине в pop_config.c (CFG_BASE). */
#define POP_MUS_IDX_BASE 0x100
typedef struct {
uint16_t blocks; /* длина трека в порциях насоса по 128 байт */
uint16_t ticks; /* та же длина в тиках сцены (60 Гц оригинала) */
} pop_mus_info_t;
/* Поднять индекс (один раз за запуск). 0 — ок, -1 — нет файла либо он
* не той версии; во втором случае музыки не будет, но игра работает. */
int8_t pop_music_index_load(void) __banked;
/* Запись трека в буфер вызывающего. -1 — индекса нет, id вне диапазона
* или трека нет в наборе (тогда поля нулевые). */
int8_t pop_music_info(uint8_t id, pop_mus_info_t *out) __banked;
/* Сколько EMM-страниц занимает трек такой длины. Считается, а НЕ хранится
* в индексе: дублирование однажды разъедется с blocks. */
#define pop_music_pages(blocks) \
(uint16_t)(((blocks) >> 7) + (((blocks) & 127) != 0))
/* Номера треков — нумерация оригинала (SDLPoP data/music/names.txt). */
#define POP_MUS_INTRO 54 /* тема заставки, звучит на титрах */
#define POP_MUS_STORY_1 55 /* «In the absence of the Sultan...» */
@@ -102,4 +143,8 @@ void pop_music_ring_stop(void) __banked;
/* Трек ещё звучит? 0 — доиграл или не запускался. */
uint8_t pop_music_busy(void) __banked;
/* «Трек в работе»: заявка/загрузка/звучание. Для мест, где ждут КОНЦА
* мелодии, busy там ложен в окне запуска (см. pop_music.c). */
uint8_t pop_music_active(void) __banked;
#endif /* POP_MUSIC_H */
+1 -4
View File
@@ -21,11 +21,8 @@
#include <irq.h> /* 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;
+155 -6
View File
@@ -5,20 +5,163 @@
*/
#include <stdint.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sprinter_mem.h>
#include <cbl.h>
#include "pop_sfx.h"
#include "pop_arc.h"
#include "_pop_sfx.h"
#include "_pop_file.h" /* POP_PATH_CALL — старые DSS и пути */
#include <irq.h> /* IRQ_DISABLE вокруг записи в W0 */
#include <sprite.h> /* 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;
}
+34
View File
@@ -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 <stdint.h>
#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;
+50
View File
@@ -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 <stdint.h>
/* Пространство 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 */
+16 -9
View File
@@ -366,7 +366,6 @@ int main(void)
* девелоперская сборка может до title держать дворец LEVEL=11. */
if (pop_app_state == POP_APP_DEMO) {
pop_next_level = 0;
pop_endmus_left = 0;
pop_seamless = 0;
pop_checkpoint = 0;
if (pop_level_switch() != 0) {
@@ -787,14 +786,22 @@ int main(void)
* сколько звучит трек конца уровня; мы же уходили на смену
* уровня сразу и обрывали мелодию на первых нотах.
*
* «Звучит» проверяем двумя способами: pop_music_busy() пока
* трек играет и счётчик pop_endmus_left по длине записи (его
* взводит play_seq вместе с самим треком) иначе с
* ВЫКЛЮЧЕННОЙ музыкой паузы не было бы вовсе, а оригинал
* выдерживает её и молча. */
if (pop_endmus_left) pop_endmus_left--;
if (!pop_endmus_left && !pop_music_busy() &&
pop_level_change(back) != 0) {
* «Звучит» это pop_music_active(), а не busy: между заявкой
* на трек и первой нотой проходит 190-230 мс на открытие
* файла и чтение первой полустраницы, и в этой щели busy ещё
* ложен (замер docs/sound_plan.md §9). Раньше щель
* закрывал счётчик по длине записи, но он же тянул за собой
* зависимость EXE от набора музыки и жёсткий делитель `/4`,
* враньё которого вылезало в быстрых режимах (там кадр 3
* луча, а не 4). Состояние «заявка/загрузка/звучание»
* закрывает щель без единой константы.
*
* ЗВУК ВЫКЛЮЧЕН ПАУЗЫ НЕТ, и это тоже верно: pop_sfx_play
* не кладёт заявку при !pop_snd_ok, все три признака ложны, и
* уровень меняется сразу. Ровно так в оригинале, где трек
* ставится под `if (is_sound_on)` (seg006:651), а ожидание
* `check_sound_playing()` (seg003:387). */
if (!pop_music_active() && pop_level_change(back) != 0) {
failed = 1; quit = 1; break;
}
}
+4
View File
@@ -1157,6 +1157,10 @@ int pop_boot(void) __banked
pop_guard_load(); /* атласы стража (GUARD\\g0..g4) */
pop_shadow_load(); /* запечённый набор Тени — один раз на запуск */
pop_sfx_init(); /* звук: 8 страниц оцифровки (вывод пока молчит) */
/* Индекс музыкального набора — одна страница EMM. Без него музыки не
* будет (pop_music_info вернёт -1 на любой трек), но игра работает:
* это необязательный ресурс, как и сами треки. */
(void)pop_music_index_load();
pop_settings_apply(1); /* CFG -> speed/cheats/CBL */
if (pop_kid_load(KID_PAGES) != 0) { /* ВСЕ страницы: бег+стоп+разворот+присед+прыжок */
puts("kid atlas load failed");
+411
View File
@@ -0,0 +1,411 @@
#!/usr/bin/env python3
"""fetch_orig.py — забирает ВНЕШНИЕ исходные данные в assets/orig/.
Репозиторий SprPoP не хранит ни байта чужих данных: `assets/orig/` целиком
в .gitignore. Хранится вместо них ЗНАНИЕ, откуда эти данные берутся,
таблица SOURCES ниже, и этот скрипт, который по ней всё выкачивает.
python3 tools/fetch_orig.py # SDLPoP + музыка (чего нет)
python3 tools/fetch_orig.py --check # что уже на месте, ничего не качать
python3 tools/fetch_orig.py --list # печать таблицы источников
python3 tools/fetch_orig.py sdlpop --force --ref v1.23
Что откуда:
SDLPoP (GPLv3, github.com/NagyD/SDLPoP) распакованные ресурсы DOS-версии
(`data/`: спрайты, палитры, LEVELS/res20xx.bin, DIGISND*.DAT) и исходники
(`src/`), которые нам одновременно ЭТАЛОН МЕХАНИКИ. Тянем архивом ветки
или тега, а не git-клоном: в assets/orig/ не нужен вложенный .git, а
зафиксировать версию всё равно надо пишем её в SDLPoP/.fetch.json.
PoP1_DOS_music (popot.org, 43 МБ) записи саундтрека DOS-версии в четырёх
наборах (flac/mp3/ogg/ogg_MT-32), из них `pop_pack_music.py` делает треки
для CBL. Ссылку берём ИЗ САМОГО SDLPoP (ReadMe.amigaos4, секция «AUDIO IS
SLOW/AWFULL»), чтобы она не протухла молча вместе с нашей копией; если
секцию не нашли падаем на константу MUSIC_URL_FALLBACK.
MSDOS/ оригинальная поставка PoP 1.0 для MS-DOS. НЕ качается: это
копирайтный дистрибутив, качать его неоткуда и незачем. Он НЕОБЯЗАТЕЛЕН
всё, что нужно сборке (уровни, звуки), лежит в SDLPoP. Если папка у вас
есть, её звуковые .dat можно включить явно: `make SND_SRC=msdos ...`
(см. assets/orig/README.md).
"""
import argparse
import io
import json
import os
import re
import shutil
import sys
import tarfile
import time
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import paths as P # раскладка каталогов SprPoP (tools/paths.py)
# --- источники -------------------------------------------------------------
SDLPOP_REPO = "https://github.com/NagyD/SDLPoP"
SDLPOP_REF = "master" # ветка по умолчанию = «последняя версия»
SDLPOP_API = "https://api.github.com/repos/NagyD/SDLPoP"
# Секция «AUDIO IS SLOW/AWFULL» в этом файле SDLPoP держит ссылку на записи.
MUSIC_README = "ReadMe.amigaos4"
MUSIC_URL_FALLBACK = \
"https://www.popot.org/get_the_games/various/PoP1_DOS_music.zip"
UA = {"User-Agent": "SprPoP-fetch-orig/1"}
CACHE = P.ORIG / ".cache" # скачанные архивы; переживает make clean
SOURCES = [
("sdlpop", P.SDLPOP, SDLPOP_REPO,
"ресурсы DOS-версии (data/) + исходники-эталон (src/)"),
("music", P.MUSIC_ROOT, MUSIC_URL_FALLBACK,
"записи саундтрека DOS-версии, 4 набора (flac/mp3/ogg/ogg_MT-32)"),
("MSDOS (необязательно, вручную)", P.MSDOS, "дистрибутив игры",
"оригинальные .dat; нужен только для SND_SRC=msdos"),
]
# --- мелочи ----------------------------------------------------------------
def say(msg):
print("fetch: " + msg, flush=True)
def fetch_url(url, dest, expect_type=None):
"""Скачать url в dest (через .part), с индикатором на одной строке.
Если dest уже есть и его размер совпал с Content-Length не качаем.
"""
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=60) as r:
total = int(r.headers.get("Content-Length") or 0)
ctype = r.headers.get("Content-Type", "")
if expect_type and expect_type not in ctype:
say(f"ПРЕДУПРЕЖДЕНИЕ: {url} отдал Content-Type {ctype!r}")
if dest.exists() and total and dest.stat().st_size == total:
say(f"уже скачано: {dest.name} ({total} Б)")
return dest
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(dest.suffix + ".part")
# Проценты — только в терминал: в перенаправленном выводе (лог сборки,
# CI) \r не схлопывается, и одна закачка даёт полторы сотни строк.
tty = sys.stdout.isatty()
got, t0 = 0, time.time()
with open(tmp, "wb") as f:
while True:
chunk = r.read(256 * 1024)
if not chunk:
break
f.write(chunk)
got += len(chunk)
if total and tty:
print("\r %s: %3d%% (%d/%d КБ)"
% (dest.name, got * 100 // total,
got >> 10, total >> 10), end="", flush=True)
print("%s %s: готово, %d КБ за %.1f с%s"
% ("\r" if tty else "", dest.name, got >> 10,
time.time() - t0, " " * 20 if tty else ""))
tmp.replace(dest)
return dest
def safe_members(names, root):
"""Проверка архива на выход за пределы каталога распаковки."""
for n in names:
p = Path(n)
if p.is_absolute() or ".." in p.parts:
sys.exit(f"архив содержит небезопасный путь {n!r} — распаковка отменена")
return root
def backup_dir(dest):
"""Сложить текущий каталог в .cache архивом — перед заменой."""
CACHE.mkdir(parents=True, exist_ok=True)
tar = CACHE / ("%s-backup-%s.tar.gz" % (dest.name,
time.strftime("%Y%m%d-%H%M%S")))
with tarfile.open(tar, "w:gz") as t:
t.add(dest, arcname=dest.name)
say(f"старая копия сохранена: {tar} ({tar.stat().st_size >> 10} КБ)")
return tar
def swap_dir(new, dest):
"""Поставить свежераспакованный каталог на место старого."""
dest.parent.mkdir(parents=True, exist_ok=True)
old = dest.with_name(dest.name + ".old")
if old.exists():
shutil.rmtree(old)
if dest.exists():
dest.rename(old)
new.rename(dest)
if old.exists():
shutil.rmtree(old)
# --- SDLPoP ----------------------------------------------------------------
def sdlpop_head(ref):
"""(sha, дата) верхушки ref на GitHub; (None, None) если API недоступен."""
try:
req = urllib.request.Request(f"{SDLPOP_API}/commits/{ref}", headers=UA)
with urllib.request.urlopen(req, timeout=30) as r:
d = json.load(r)
return d["sha"], d["commit"]["committer"]["date"]
except (urllib.error.URLError, KeyError, ValueError) as e:
# Лимит запросов GitHub или отсутствие сети: не повод падать —
# просто не сможем сказать «у нас уже последняя».
say(f"GitHub API недоступен ({e}); версию не проверяю")
return None, None
def stamp_path(dest):
return dest / ".fetch.json"
def manifest(root):
"""{относительный путь: sha256} по всему дереву, кроме служебного."""
import hashlib
out = {}
for f in sorted(root.rglob("*")):
if not f.is_file() or f.name == ".fetch.json":
continue
rel = str(f.relative_to(root))
h = hashlib.sha256()
with open(f, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 16), b""):
h.update(chunk)
out[rel] = h.hexdigest()
return out
def local_edits(dest):
"""Файлы, изменённые ПОСЛЕ распаковки: (изменённые, добавленные).
Считается по манифесту из .fetch.json. Нет манифеста вернуть None:
происхождение копии неизвестно, и трогать её нельзя.
"""
base = read_stamp(dest).get("files")
if not base:
return None
now = manifest(dest)
changed = sorted(k for k, v in now.items() if k in base and base[k] != v)
added = sorted(k for k in now if k not in base)
return changed, added
def read_stamp(dest):
try:
return json.loads(stamp_path(dest).read_text())
except (OSError, ValueError):
return {}
def fetch_sdlpop(ref=SDLPOP_REF, force=False):
dest = P.SDLPOP
sha, date = sdlpop_head(ref)
have = read_stamp(dest)
if dest.exists() and (dest / "data").is_dir():
if sha and have.get("sha") == sha and not force:
say(f"SDLPoP уже последний: {ref} {sha[:8]} от {date[:10]}")
return
# ЗАМЕНА КАТАЛОГА — ТОЛЬКО ОСОЗНАННО. Наша копия SDLPoP не просто
# справочник: в неё внесены отладочные врезки (POP_TRACE — покадровая
# трасса Кида для сверки с портом, дампы спрайтов и палитры), и
# перекачка их сотрёт. Поэтому существующее дерево не трогаем без
# --force, а что именно потеряется — говорим заранее.
edits = local_edits(dest)
if edits is None:
note = ("происхождение копии неизвестно (нет манифеста .fetch.json) "
"— возможно, в ней есть ваши правки")
else:
changed, added = edits
if changed or added:
note = "локально изменено: %s%s" % (
", ".join(changed[:8]) + ("" if len(changed) > 8 else ""),
"; добавлено %d файлов" % len(added) if added else "")
else:
note = ""
if not force:
say("SDLPoP уже на месте" + (f"; {note}" if note else ""))
say(" перекачать поверх: make FETCH_ARGS=--force fetch-sdlpop")
say(" (правки будут сохранены в бэкап, см. ниже)")
return
if note:
say("ВНИМАНИЕ: " + note)
backup_dir(dest)
# Тег и ветка лежат по разным путям codeload; пробуем оба.
urls = [f"https://codeload.github.com/NagyD/SDLPoP/tar.gz/refs/heads/{ref}",
f"https://codeload.github.com/NagyD/SDLPoP/tar.gz/refs/tags/{ref}"]
CACHE.mkdir(parents=True, exist_ok=True)
tar = CACHE / f"SDLPoP-{ref.replace('/', '_')}.tar.gz"
last = None
for u in urls:
try:
say(f"качаю SDLPoP {ref} <- {u}")
fetch_url(u, tar)
last = None
break
except urllib.error.HTTPError as e:
last = e
if last is not None:
sys.exit(f"не удалось скачать SDLPoP {ref!r}: {last}")
tmp = P.ORIG / ".SDLPoP.new"
if tmp.exists():
shutil.rmtree(tmp)
tmp.mkdir(parents=True)
with tarfile.open(tar, "r:gz") as t:
names = t.getnames()
safe_members(names, tmp)
# В архиве GitHub всё лежит в одном каталоге SDLPoP-<ref>/ — снимаем его.
top = os.path.commonpath([n for n in names if n]) if names else ""
t.extractall(tmp)
src = tmp / top if top else tmp
inner = P.ORIG / ".SDLPoP.unpacked"
if inner.exists():
shutil.rmtree(inner)
src.rename(inner)
shutil.rmtree(tmp, ignore_errors=True)
if not (inner / "data").is_dir() or not (inner / "src").is_dir():
shutil.rmtree(inner)
sys.exit("в архиве SDLPoP нет data/ или src/ — раскладка репозитория "
"изменилась, чините fetch_orig.py")
# Манифест распакованного дерева: по нему следующий fetch отличит наши
# правки от нетронутых файлов и не сотрёт их молча.
stamp_path(inner).write_text(json.dumps(
{"repo": SDLPOP_REPO, "ref": ref, "sha": sha, "date": date,
"fetched": time.strftime("%Y-%m-%d %H:%M:%S"),
"files": manifest(inner)},
ensure_ascii=False, indent=2) + "\n")
swap_dir(inner, dest)
say(f"SDLPoP: {ref} {(sha or '?')[:8]} -> {dest}")
# --- музыка ----------------------------------------------------------------
def music_url():
"""Ссылка на архив записей: из ReadMe SDLPoP, иначе константа."""
rm = P.SDLPOP / MUSIC_README
if rm.is_file():
text = rm.read_text(errors="replace")
m = re.search(r"https?://\S*[Mm]usic\S*\.zip", text)
if m:
url = m.group(0).rstrip(".,)")
say(f"ссылка на музыку из SDLPoP/{MUSIC_README}: {url}")
return url
say(f"в SDLPoP/{MUSIC_README} ссылки не нашлось — беру запасную")
else:
say(f"нет SDLPoP/{MUSIC_README} (сначала `fetch_orig.py sdlpop`) — "
"беру запасную ссылку")
return MUSIC_URL_FALLBACK
def fetch_music(force=False):
dest = P.MUSIC_ROOT
if dest.is_dir() and not force:
have = [d for d, _ in P.MUSIC_FORMATS.values() if (dest / d).is_dir()]
if len(have) == len(P.MUSIC_FORMATS):
say(f"музыка уже на месте: {dest} ({len(have)} набора)")
return
say(f"музыка неполная (наборы: {', '.join(have) or 'нет'}) — перекачиваю")
url = music_url()
CACHE.mkdir(parents=True, exist_ok=True)
zpath = CACHE / os.path.basename(url)
fetch_url(url, zpath, expect_type="zip")
with zipfile.ZipFile(zpath) as z:
names = z.namelist()
safe_members(names, P.ORIG)
top = {Path(n).parts[0] for n in names if n}
if top != {dest.name}:
sys.exit(f"в архиве музыки ожидался один каталог {dest.name}/, "
f"а там {sorted(top)} — чините fetch_orig.py")
tmp = P.ORIG / ".music.new"
if tmp.exists():
shutil.rmtree(tmp)
say(f"распаковываю {len(names)} файлов")
z.extractall(tmp)
swap_dir(tmp / dest.name, dest)
shutil.rmtree(tmp, ignore_errors=True)
got = [d for d, _ in P.MUSIC_FORMATS.values() if (dest / d).is_dir()]
say(f"музыка: наборы {', '.join(sorted(got))} -> {dest}")
# --- отчёт -----------------------------------------------------------------
def do_list():
print("Внешние источники SprPoP (assets/orig/ в репозиторий не идёт):\n")
for name, path, where, what in SOURCES:
print(f" {name}")
print(f" куда: {path}")
print(f" откуда: {where}")
print(f" что: {what}\n")
def do_check():
ok = True
st = read_stamp(P.SDLPOP)
if (P.SDLPOP / "data").is_dir() and (P.SDLPOP / "src").is_dir():
v = f"{st.get('ref', '?')} {(st.get('sha') or '?')[:8]}"
print(f" SDLPoP есть ({v}, {st.get('fetched', 'версия неизвестна')})")
else:
print(" SDLPoP НЕТ -> make fetch-sdlpop")
ok = False
have = [d for d, _ in P.MUSIC_FORMATS.values() if (P.MUSIC_ROOT / d).is_dir()]
if len(have) == len(P.MUSIC_FORMATS):
print(f" музыка есть (наборы: {', '.join(sorted(have))})")
elif have:
print(f" музыка ЧАСТЬ (только {', '.join(sorted(have))}) -> make fetch-music")
ok = False
else:
print(" музыка НЕТ -> make fetch-music")
ok = False
if P.MSDOS.is_dir():
print(" MSDOS есть (необязательно; звуки оттуда — make SND_SRC=msdos)")
else:
print(" MSDOS нет (необязательно: звуки и уровни берутся из SDLPoP)")
return 0 if ok else 1
def main():
ap = argparse.ArgumentParser(
description="скачать внешние исходные данные SprPoP в assets/orig/")
ap.add_argument("what", nargs="*", default=["all"],
help="что качать: all (умолчание), sdlpop, music")
ap.add_argument("--force", action="store_true",
help="качать заново, даже если данные на месте")
ap.add_argument("--ref", default=SDLPOP_REF,
help=f"ветка или тег SDLPoP (по умолчанию {SDLPOP_REF})")
ap.add_argument("--check", action="store_true",
help="только показать, что уже на месте")
ap.add_argument("--list", action="store_true",
help="только показать таблицу источников")
a = ap.parse_args()
if a.list:
do_list()
return 0
if a.check:
return do_check()
what = set(a.what) or {"all"}
bad = what - {"all", "sdlpop", "music"}
if bad:
ap.error("не знаю, что такое %s; есть: all, sdlpop, music"
% ", ".join(sorted(bad)))
if "all" in what:
what = {"sdlpop", "music"}
P.ORIG.mkdir(parents=True, exist_ok=True)
if "sdlpop" in what:
fetch_sdlpop(a.ref, a.force)
if "music" in what:
fetch_music(a.force)
say("готово; дальше — make resources")
return 0
if __name__ == "__main__":
sys.exit(main())
+43 -3
View File
@@ -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/<set>/ промежуточный выхлоп упаковщиков: отдельные атласы,
из которых потом склеиваются архивы. Пересоздаётся,
в репозиторий не идёт.
@@ -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" # раскладка диска игры
+230
View File
@@ -0,0 +1,230 @@
"""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 нет оцифровки (это музыка).
PMI1 индекс МУЗЫКИ, тоже один сектор:
0..3 'PMI1' магия и версия
4 57 число плотных записей, id 0..56
5 4 размер записи
6 7 log2 размера блока
7 1 формат PCM
8..15 0 резерв
16..243 57 записей по 4 байта: blocks (u16), ticks60 (u16)
244..511 0 добивка до сектора
Числа страниц здесь НЕТ намеренно: это производная от `blocks`, и
дублирование однажды разъедется с ним. Загрузчик считает сам:
pages = blocks / 128 + ((blocks & 127) != 0)
`ticks60` длительность в тиках сцены (60 Гц оригинала), нужная там, где
сценарий ждёт конца реплики. Считается из blocks отношением 40/57
обратным к тому, которым шкала сцены переводит тики в порции насоса.
Хранится ОТДЕЛЬНО, а не выводится в рантайме: так момент окончания
реплики можно будет уточнить независимо от технического хвоста PCM.
Нулевой `blocks` означает, что трека с таким 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("<BHH", out, SND_HDR + i * SND_REC, page, off, ln)
return bytes(out)
def snd_unpack(data):
"""Разобрать PSI1 -> (записи, 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("<BHH", data, SND_HDR + i * SND_REC)
_check_entry(i, page, off, ln, pages)
entries.append((page, off, ln))
return entries, pages
def _check_entry(i, page, off, ln, pages):
"""Одна запись: то же, что обязан проверить загрузчик перед EMM.
ПЕРВЫЙ БЛОК СТРАНИЦЫ 0 ТИШИНА. Насос льёт её, когда играть нечего
(POP_SND_SILENCE_PAGE/OFF), поэтому набор не имеет права положить туда
эффект: иначе вместо паузы будет обрывок звука по кругу.
"""
if ln == 0:
if (page, off) != (0, 0):
raise IdxError("запись %d пуста, но page/off не нулевые" % i)
return
if page >= 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))
# --- PMI1: музыка ----------------------------------------------------------
MUS_MAGIC = b"PMI1"
MUS_COUNT = 57 # то же пространство id оригинала
MUS_REC = 4
MUS_HDR = 16
# Потоковый трек (финальная тема) заведомо длиннее обычных, и предел ему
# ставят не страницы загрузчика, а восьмибитные счётчики страниц в коде.
MUS_MAX_PAGES = 255
def mus_ticks(blocks):
"""Длительность в тиках сцены (60 Гц) по длине в блоках.
Отношение 40/57 обратное к тому, которым шкала сцены переводит тики
в порции насоса (57/40, pop_intro.c). Берём именно его, а не точное
10937,5/128/60: длина реплики и ход шкалы обязаны считаться ОДНОЙ
арифметикой, иначе они разъедутся на длинных треках. Округление к
ближайшему: отбрасывание вниз теряло по тику на реплику.
"""
return (blocks * 40 + 28) // 57
def mus_pack(entries):
"""Собрать PMI1 из 57 записей (blocks, ticks60)."""
if len(entries) != MUS_COUNT:
raise IdxError("записей должно быть %d, а не %d"
% (MUS_COUNT, len(entries)))
out = bytearray(SECTOR)
out[0:4] = MUS_MAGIC
out[4] = MUS_COUNT
out[5] = MUS_REC
out[6] = BLOCK_LOG2
out[7] = PCM_U8_MONO_10K9
for i, (blocks, ticks) in enumerate(entries):
_check_mus(i, blocks, ticks)
struct.pack_into("<HH", out, MUS_HDR + i * MUS_REC, blocks, ticks)
return bytes(out)
def mus_unpack(data):
"""Разобрать PMI1 -> записи. Проверки те же, что у Z80."""
if len(data) != SECTOR:
raise IdxError("размер %d, а должен быть сектор %d" % (len(data), SECTOR))
if data[0:4] != MUS_MAGIC:
raise IdxError("не PMI1: %r" % (bytes(data[0:4]),))
if data[4] != MUS_COUNT:
raise IdxError("записей %d, ожидалось %d" % (data[4], MUS_COUNT))
if data[5] != MUS_REC:
raise IdxError("размер записи %d, ожидался %d" % (data[5], MUS_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))
entries = []
for i in range(MUS_COUNT):
blocks, ticks = struct.unpack_from("<HH", data, MUS_HDR + i * MUS_REC)
_check_mus(i, blocks, ticks)
entries.append((blocks, ticks))
return entries
def mus_pages(blocks):
"""Сколько EMM-страниц занимает трек такой длины."""
return (blocks + 127) // 128
def _check_mus(i, blocks, ticks):
if blocks == 0:
if ticks:
raise IdxError("запись %d: трека нет, но тики ненулевые" % i)
return
if mus_pages(blocks) > MUS_MAX_PAGES:
raise IdxError("запись %d: %d блоков — больше %d страниц, счётчики "
"страниц в коде восьмибитные"
% (i, blocks, MUS_MAX_PAGES))
if ticks == 0:
raise IdxError("запись %d: трек есть, а длительность нулевая" % i)
+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__":
+67 -55
View File
@@ -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 <stdint.h>",
"",
"#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__":
@@ -0,0 +1,244 @@
"""test_pop_idx — формат дисковых индексов аудио (tools/pop_idx.py).
Индекс читают ДВЕ стороны: упаковщик на хосте и загрузчик на Z80. Тест
закрепляет ровно то, на что рассчитывает вторая сторона: точные смещения
полей, little-endian, размер сектора и НАБОР ОТКАЗОВ потому что битый
индекс обязан выключить звук, а не увести насос в чужую память.
Запуск: make test-tools (либо python3 -m unittest discover tools/tests)
"""
import os
import struct
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pop_idx as X
def empty():
return [(0, 0, 0)] * X.SND_COUNT
class SndFormat(unittest.TestCase):
"""PSI1: раскладка байтов, на которую смотрит Z80."""
def test_size_is_one_sector(self):
self.assertEqual(len(X.snd_pack(empty(), 1)), X.SECTOR)
def test_header_fields(self):
d = X.snd_pack(empty(), 9)
self.assertEqual(d[0:4], b"PSI1")
self.assertEqual(d[4], 57) # число записей
self.assertEqual(d[5], 5) # sizeof(pop_snd_ent_t) в SDCC z80
self.assertEqual(d[6], 7) # log2(128)
self.assertEqual(d[7], 1) # u8 mono 10937.5
self.assertEqual(d[8], 9) # фактических страниц
self.assertEqual(d[9:16], b"\0" * 7)
def test_entry_is_little_endian_at_fixed_offset(self):
"""Смещение записи и порядок байтов — контракт с загрузчиком."""
e = empty()
e[3] = (2, 0x1500, 0x0480)
d = X.snd_pack(e, 9)
at = X.SND_HDR + 3 * X.SND_REC
self.assertEqual(d[at], 2)
self.assertEqual(d[at + 1:at + 3], b"\x00\x15") # off LE
self.assertEqual(d[at + 3:at + 5], b"\x80\x04") # len LE
self.assertEqual(struct.unpack_from("<BHH", d, at), (2, 0x1500, 0x0480))
def test_tail_is_zero_padded(self):
d = X.snd_pack(empty(), 1)
self.assertEqual(d[X.SND_HDR + X.SND_COUNT * X.SND_REC:], b"\0" * 211)
def test_roundtrip(self):
e = empty()
e[10] = (0, 128, 3584)
e[48] = (8, 0x1400, 128)
e[56] = (8, 0x2600, 4864)
d = X.snd_pack(e, 9)
back, pages = X.snd_unpack(d)
self.assertEqual(pages, 9)
self.assertEqual(back, e)
class SndRejects(unittest.TestCase):
"""Битый индекс обязан отвергаться, а не молча разъезжать раскладку."""
def bad(self, mutate):
e = empty()
e[1] = (0, 128, 256)
d = bytearray(X.snd_pack(e, 4))
mutate(d)
with self.assertRaises(X.IdxError):
X.snd_unpack(bytes(d))
def test_wrong_magic(self):
self.bad(lambda d: d.__setitem__(slice(0, 4), b"PSI2"))
def test_wrong_count(self):
self.bad(lambda d: d.__setitem__(4, 56))
def test_wrong_record_size(self):
self.bad(lambda d: d.__setitem__(5, 4))
def test_wrong_block_log2(self):
self.bad(lambda d: d.__setitem__(6, 8))
def test_unknown_pcm_format(self):
self.bad(lambda d: d.__setitem__(7, 2))
def test_pages_zero(self):
self.bad(lambda d: d.__setitem__(8, 0))
def test_pages_over_capacity(self):
self.bad(lambda d: d.__setitem__(8, X.SND_MAX_PAGES + 1))
def test_truncated(self):
e = empty()
d = X.snd_pack(e, 1)
with self.assertRaises(X.IdxError):
X.snd_unpack(d[:-1])
def test_page_beyond_set(self):
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 9, 0, 128))
def test_offset_not_block_aligned(self):
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 1, 100, 128))
def test_length_not_block_aligned(self):
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 1, 128, 100))
def test_entry_runs_past_last_page(self):
"""Эффект длиннее остатка набора увёл бы насос в чужую память."""
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5,
3, 0x3F80, 16384))
def test_entry_in_silence_block(self):
"""Первый блок страницы 0 занят тишиной насоса — он неприкосновенен."""
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 0, 0, 128))
def test_empty_entry_must_be_all_zero(self):
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 2, 256, 0))
class SndPackGuards(unittest.TestCase):
"""Упаковщик не имеет права выпустить набор, который отвергнет Z80."""
def test_rejects_too_many_pages(self):
with self.assertRaises(X.IdxError):
X.snd_pack(empty(), X.SND_MAX_PAGES + 1)
def test_rejects_wrong_entry_count(self):
with self.assertRaises(X.IdxError):
X.snd_pack([(0, 0, 0)] * 56, 1)
def test_rejects_entry_in_silence_block(self):
e = empty()
e[0] = (0, 0, 128)
with self.assertRaises(X.IdxError):
X.snd_pack(e, 1)
def mus_empty():
return [(0, 0)] * X.MUS_COUNT
class MusFormat(unittest.TestCase):
"""PMI1: раскладка байтов индекса музыки."""
def test_size_is_one_sector(self):
self.assertEqual(len(X.mus_pack(mus_empty())), X.SECTOR)
def test_header_fields(self):
d = X.mus_pack(mus_empty())
self.assertEqual(d[0:4], b"PMI1")
self.assertEqual(d[4], 57)
self.assertEqual(d[5], 4)
self.assertEqual(d[6], 7)
self.assertEqual(d[7], 1)
self.assertEqual(d[8:16], b"\0" * 8)
def test_entry_is_little_endian_at_fixed_offset(self):
e = mus_empty()
e[41] = (1043, 732)
d = X.mus_pack(e)
at = X.MUS_HDR + 41 * X.MUS_REC
self.assertEqual(d[at:at + 2], b"\x13\x04") # blocks LE
self.assertEqual(d[at + 2:at + 4], b"\xdc\x02") # ticks60 LE
self.assertEqual(struct.unpack_from("<HH", d, at), (1043, 732))
def test_roundtrip(self):
e = mus_empty()
e[24] = (305, X.mus_ticks(305))
e[56] = (9865, X.mus_ticks(9865))
self.assertEqual(X.mus_unpack(X.mus_pack(e)), e)
class MusTicks(unittest.TestCase):
"""Формула тиков закреплена ЗНАЧЕНИЯМИ нынешней сборки (набор flac).
Она переехала из упаковщика в общий модуль, и разойтись ей нельзя:
по этим числам сцены отмеряют паузы, пока они не перешли на ожидание
звука. Слева blocks из gen/pop_music_tbl.h, справа тики из
gen/pop_music_ticks.h.
"""
CASES = ((624, 438), # m32 end_level_4
(1043, 732), # m41 end_level
(1184, 831), # m50 princess
(1403, 985), # m53 Jaffar
(9865, 6923), # m56 won — самый длинный
(305, 214), # m24 regular_death — самый короткий из игровых
(196, 138)) # m25
def test_matches_current_build(self):
for blocks, ticks in self.CASES:
self.assertEqual(X.mus_ticks(blocks), ticks, "blocks=%d" % blocks)
def test_no_uint16_overflow_on_longest(self):
"""blocks*40 переполнил бы uint16 — формула обязана это переживать."""
self.assertLess(X.mus_ticks(65535), 65536)
def test_pages_rounds_up(self):
self.assertEqual(X.mus_pages(128), 1)
self.assertEqual(X.mus_pages(129), 2)
self.assertEqual(X.mus_pages(9865), 78) # m56 flac
self.assertEqual(X.mus_pages(10462), 82) # m56 mt32 — длиннее
class MusRejects(unittest.TestCase):
def bad(self, mutate):
e = mus_empty()
e[41] = (1043, 732)
d = bytearray(X.mus_pack(e))
mutate(d)
with self.assertRaises(X.IdxError):
X.mus_unpack(bytes(d))
def test_wrong_magic(self):
self.bad(lambda d: d.__setitem__(slice(0, 4), b"PSI1"))
def test_wrong_count(self):
self.bad(lambda d: d.__setitem__(4, 56))
def test_wrong_record_size(self):
self.bad(lambda d: d.__setitem__(5, 5))
def test_unknown_pcm_format(self):
self.bad(lambda d: d.__setitem__(7, 3))
def test_ticks_without_track(self):
self.bad(lambda d: struct.pack_into("<HH", d, X.MUS_HDR + 5 * 4, 0, 100))
def test_track_without_ticks(self):
self.bad(lambda d: struct.pack_into("<HH", d, X.MUS_HDR + 5 * 4, 500, 0))
def test_track_longer_than_page_counters(self):
"""Больше 255 страниц не переживут восьмибитные счётчики в коде."""
self.bad(lambda d: struct.pack_into("<HH", d, X.MUS_HDR + 5 * 4,
65535, 1000))
if __name__ == "__main__":
unittest.main()