Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df5071a967 | |||
| 2349481b86 | |||
| 589894c50d | |||
| ea8efdb0fd | |||
| 623199337e | |||
| 602c3a20fa | |||
| 4ac3584bf9 | |||
| 3bf28da8ee | |||
| 770b946a36 | |||
| 89e9663753 | |||
| ea07a8d7b0 | |||
| d23126983e | |||
| 50570881f5 | |||
| f25ed37d85 | |||
| 3449f6f8c9 |
@@ -9,3 +9,21 @@ args = [
|
||||
"mcp<2",
|
||||
"/Volumes/SAM8/Projects/DIY/Z80/Sprinter/C-Compiler/mame/sources/MAME/src/mame_mcp.py",
|
||||
]
|
||||
|
||||
[mcp_servers.mame-z80.tools.clear_breakpoint]
|
||||
approval_mode = "approve"
|
||||
|
||||
[mcp_servers.mame-z80.tools.list_breakpoints]
|
||||
approval_mode = "approve"
|
||||
|
||||
[mcp_servers.mame-z80.tools.press_key]
|
||||
approval_mode = "approve"
|
||||
|
||||
[mcp_servers.mame-z80.tools.step_out]
|
||||
approval_mode = "approve"
|
||||
|
||||
[mcp_servers.mame-z80.tools.debugger_command]
|
||||
approval_mode = "approve"
|
||||
|
||||
[mcp_servers.mame-z80.tools.pause]
|
||||
approval_mode = "approve"
|
||||
|
||||
+6
-12
@@ -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/ (уровни из оригинала как есть).
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
# # EXTRA_SRCS := helper.c util.c # additional .c files in this dir
|
||||
# # EXTRA_FLAGS := --crt0=minimal # passed through to sprinter-cc
|
||||
# # EXTRA_DATA := test.txt # extra files to add to `make floppy`
|
||||
# # HDD_DEST_DIR := games/myapp # общий каталог файлов в `make hdd`
|
||||
#
|
||||
# include $(PROJ_ROOT)/app.mk
|
||||
#
|
||||
@@ -40,7 +41,7 @@ FLOPPY_IMG := $(MAME_DIR)/IMG/mc.img
|
||||
# образ у себя и связывает его с MAME символьной ссылкой.
|
||||
HDD_IMG ?= $(MAME_DIR)/IMG/test_hdd.chd
|
||||
MAKE_DISK := $(MAME_DIR)/make_disk.py
|
||||
MAKE_HDD := $(PROJ_ROOT)/toolchain/make_hdd.sh
|
||||
MAKE_HDD ?= $(PROJ_ROOT)/toolchain/make_hdd.sh
|
||||
RUN_MAME := $(MAME_DIR)/run_mame.sh
|
||||
|
||||
# Optional knobs — see top of file.
|
||||
@@ -67,6 +68,9 @@ SOURCES := $(MAIN_SRC) $(EXTRA_SRCS)
|
||||
# Аргументы упаковщика HDD. Обычно это exe и EXTRA_DATA; приложение со
|
||||
# своей раскладкой каталогов может переопределить переменную до include.
|
||||
HDD_PACK_ARGS ?= $(EXE) $(EXTRA_DATA)
|
||||
# Общий каталог назначения внутри HDD. Пустое значение сохраняет прежнюю
|
||||
# укладку в корень; вложенные КАТАЛОГ:файл считаются относительно него.
|
||||
HDD_DEST_DIR ?=
|
||||
|
||||
CC_FLAGS := --memory $(MEMORY)
|
||||
ifneq ($(STACK_SIZE),)
|
||||
@@ -125,7 +129,7 @@ run: floppy
|
||||
# используется MCP-мостом к MAME (run_bridge.sh). После пересборки образа
|
||||
# MAME ОБЯЗАН полный рестарт (chdman -f = новый inode; см. memory).
|
||||
hdd: $(EXE)
|
||||
$(MAKE_HDD) $(HDD_IMG) $(HDD_PACK_ARGS)
|
||||
$(MAKE_HDD) $(if $(strip $(HDD_DEST_DIR)),--dest "$(HDD_DEST_DIR)") $(HDD_IMG) $(HDD_PACK_ARGS)
|
||||
@echo
|
||||
@echo "HDD (D:) ready: $(HDD_IMG) (with $(EXAMPLE).exe$(if $(EXTRA_DATA), + $(EXTRA_DATA)))"
|
||||
@echo "ВНИМАНИЕ: перезапусти MAME (run_bridge.sh) — образ пересобран."
|
||||
|
||||
@@ -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/
|
||||
@@ -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`). |
|
||||
|
||||
## Банки: две мины, которых компилятор не видит
|
||||
|
||||
+121
-22
@@ -91,7 +91,7 @@ BANK9_SRCS := pop_menu.c pop_status.c pop_music.c pop_app.c pop_timer.c \
|
||||
# стоит 51 мс, так что трамплин банк→банк на их фоне не существует.
|
||||
# Отдельный банк для этого не нужен: в десятом лежал один pop_pal.c на
|
||||
# 485 байт, то есть 3% страницы.
|
||||
BANK10_SRCS := pop_pal.c pop_config.c pop_hof.c
|
||||
BANK10_SRCS := pop_pal.c pop_config.c pop_hof.c pop_path_bank.c
|
||||
BANK11_SRCS := pop_cutscene.c pop_intro.c
|
||||
BANK_NUMS := 1 2 3 4 5 6 7 8 9 10 11
|
||||
BANKED_SRCS := $(foreach n,$(BANK_NUMS),$(BANK$(n)_SRCS))
|
||||
@@ -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,16 +233,22 @@ 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
|
||||
GEN_H := $(addprefix gen/,$(GEN_ARC_H) pop_bg_atlas.h kid_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_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_font.h)
|
||||
|
||||
PACKED_FILES := $(addprefix $(PACKED)/,$(DISK))
|
||||
STAGED := $(addprefix $(BUILD_DIR)/,$(DISK))
|
||||
|
||||
EXTRA_DATA := $(STAGED)
|
||||
# Запись вида КАТАЛОГ:файл кладёт файл в подкаталог образа, голый путь — в
|
||||
# корень. Проверка на слэш ОБЯЗАТЕЛЬНА: без неё `README_E.TXT` превращался
|
||||
# Запись вида КАТАЛОГ:файл кладёт файл в подкаталог HDD_DEST_DIR, голый путь —
|
||||
# непосредственно в HDD_DEST_DIR. Проверка на слэш ОБЯЗАТЕЛЬНА: без неё
|
||||
# `README_E.TXT` превращался
|
||||
# в `README_E.TXT:build/README_E.TXT`, то есть упаковщик заводил каталог с
|
||||
# именем файла и клал файл внутрь него (поймано пользователем на готовом
|
||||
# образе).
|
||||
@@ -237,6 +256,9 @@ HDD_PACK_ARGS := $(BUILD_DIR)/$(EXAMPLE).exe \
|
||||
$(foreach f,$(DISK),$(if $(findstring /,$(f)),\
|
||||
$(word 1,$(subst /, ,$(f))):$(BUILD_DIR)/$(f),\
|
||||
$(BUILD_DIR)/$(f)))
|
||||
# Всё дерево SprPoP лежит рядом с EXE, но не меняет дефолт глобального
|
||||
# упаковщика: остальные приложения по-прежнему попадают в корень HDD.
|
||||
HDD_DEST_DIR := games/sprpop
|
||||
# Образ живёт в build/, а не в mame/v306/IMG — приложение автономно. Связь с
|
||||
# MAME — одна символьная ссылка, ставится однократно: make mame-link.
|
||||
HDD_IMG := $(CURDIR)/$(BUILD_DIR)/hdd/$(EXAMPLE).chd
|
||||
@@ -339,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))
|
||||
@@ -346,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))
|
||||
@@ -361,14 +422,13 @@ $(1): $(STAMP)/$(2)
|
||||
@test -f $$@ || { $(MAKE) GENERATE=1 -B $(STAMP)/$(2); test -f $$@; }
|
||||
endef
|
||||
$(eval $(call from_stamp,$(BG_ATL) $(ATL)/bg/pop_tile.pal $(ATL)/bg/pal_tile.pal gen/pop_bg_atlas.h,bg))
|
||||
$(eval $(call from_stamp,$(KID_ATL) $(ATL)/kid/kid.pal gen/kid_atlas.h,kid))
|
||||
$(eval $(call from_stamp,$(KID_ATL) $(ATL)/kid/kid.pal,kid))
|
||||
$(eval $(call from_stamp,$(ATL)/kid/kid.ani gen/kid_data.h,kid-bin))
|
||||
$(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))
|
||||
@@ -392,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
|
||||
@@ -411,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 $$@)
|
||||
@@ -443,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
|
||||
@@ -455,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
|
||||
# ===========================================================================
|
||||
@@ -506,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)
|
||||
@@ -525,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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
## Происхождение
|
||||
|
||||
|
||||
@@ -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.
@@ -35,8 +35,8 @@
|
||||
| [KBD-STUCK-WAIT](#kbd-stuck-wait) | потерянный break-код клавиши вешает игру НАСМЕРТЬ в межуровневой заставке | клавиатура/клин | **корень найден и устранён** 2026-08-28 (байты крал KEYSCAN DSS); ждёт полевой проверки |
|
||||
| [L10-BUTTON-DEBRIS](#l10-button-debris) | ур.10 к.1: плита упала на кнопку (1,8) — щебень есть, а решётки закрыты | кнопки/ворота | открыт: **не воспроизводится**, проверено многое (см. разбор) |
|
||||
| [LOOSE-SEAM-ANIM](#loose-seam-anim) | плита в шве (col −1), уроненная в прошлой комнате, исчезает без анимации падения | **мелкий** | открыт по решению пользователя: «пока пусть будет так» |
|
||||
| [SND-PACE-DEAD](#snd-pace-dead) | пейсинг сцен по насосу CBL не включается: признак «часы идут» вычисляется двумя чтениями подряд | **тайминг/звук** | открыт: диагноз полный, симптом обойдён подгонкой (2026-08-27) |
|
||||
| [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | открыт: замер есть, лечение — удешевить кадр |
|
||||
| [SND-PACE-DEAD](#snd-pace-dead) | пейсинг сцен по насосу CBL не включается: признак «часы идут» вычисляется двумя чтениями подряд | **тайминг/звук** | **снят 2026-08-28**: ветка удалена, сцена на единых часах по лучу |
|
||||
| [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | **исправлено 2026-08-28**: кадр разложен на блоки по интервалу, удешевлять не понадобилось |
|
||||
| [MUS-LEFT-TEAR](#mus-left-tear) | `pop_mus_left` (16 бит, пишет прерывание) читается из главного цикла неатомарно — возможен ложный «трек кончился» | **потенциальный** | открыт: хазард показан рассуждением, в прогоне не проявился |
|
||||
|
||||
---
|
||||
@@ -1206,7 +1206,15 @@ fade_in по построению пишет все 256 записей из сн
|
||||
---
|
||||
|
||||
<a id="snd-pace-dead"></a>
|
||||
## SND-PACE-DEAD. Пейсинг сцен по насосу CBL не включается
|
||||
## SND-PACE-DEAD. Пейсинг сцен по насосу CBL не включается — СНЯТ 2026-08-28
|
||||
|
||||
> **Закрыт удалением самой ветки.** Сцена с Джафаром переведена на ЕДИНЫЕ
|
||||
> часы — кадры луча (`intro_pv_animated`): вторая шкала по насосу и гонка,
|
||||
> которая её выбирала, убраны целиком. Насос как часы был нужен потому,
|
||||
> что кадр сцены рисовался дольше своего интервала; теперь отрисовка
|
||||
> разложена по интервалам (`pv_restore_bg`), и счёт кадров честен.
|
||||
> Побочный эффект гонки — «кода реплики каждый прогон в другом месте» —
|
||||
> исчез; проверено пользователем. Ниже — исходный разбор.
|
||||
|
||||
**Замер (MAME, 2026-08-27).** В сцене `intro_pv_scene()` зонды показали
|
||||
`snd_done == 0` на всём прогоне — то есть ветка `if (snd_pace)`, ради
|
||||
@@ -1237,7 +1245,17 @@ snd_pace = (uint8_t)(pop_snd_tick != snd_prev); /* часы реально и
|
||||
---
|
||||
|
||||
<a id="pv-render-bound"></a>
|
||||
## PV-RENDER-BOUND. Сцена с принцессой не укладывается в свой бюджет кадра
|
||||
## PV-RENDER-BOUND. Сцена с принцессой не укладывается в свой бюджет кадра — ИСПРАВЛЕНО 2026-08-28
|
||||
|
||||
> **Причина оказалась не в цене кадра, а в том, ГДЕ отсчитывался интервал.**
|
||||
> Кадр рисовался целиком, и только потом ждали vsync и ещё четыре — то есть
|
||||
> отрисовка ПРИБАВЛЯЛАСЬ к делителю. Теперь она разложена на блоки, каждый
|
||||
> из которых влезает в кадровый интервал, и после каждого ждём vsync
|
||||
> (`pv_restore_bg` + раскладка кадра в `cut_run` / `pre_room_animated` /
|
||||
> `intro_pv_draw_frame`); фон восстанавливаем только по картинке, 200 строк
|
||||
> вместо 256. Удешевлять сцену не понадобилось: отрисовка ~2 кадра при
|
||||
> бюджете 5. Подгонка `PV_MAGIC_LEAD` снята (0) — проверено пользователем:
|
||||
> кода попадает в нужный кадр. Ниже — исходный замер.
|
||||
|
||||
**Замер (MAME, 2026-08-27).** Реплика Джафара `m53` — 1403 порции насоса,
|
||||
то есть 16,42 с реального времени. За это время шкала сцены прошла
|
||||
|
||||
@@ -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` |
|
||||
|
||||
## Производительность
|
||||
|
||||
@@ -41,6 +42,7 @@
|
||||
| [`palette_plan.md`](palette_plan.md) | Карта всех 256 слотов палитры + механика fade |
|
||||
| [`status_line_text.md`](status_line_text.md) | Строка HP как статус-строка: инвентаризация ВСЕХ текстов SDLPoP |
|
||||
| [`sound_plan.md`](sound_plan.md) | Звук через CBL: разбор и архитектура |
|
||||
| [`music_runtime_index_plan.md`](music_runtime_index_plan.md) | Будущее улучшение: runtime-индексы музыки и PCM-эффектов, смена аудионаборов без перелинковки EXE |
|
||||
| [`shadow_render.md`](shadow_render.md) | Вид Тени (OR+XOR) — отложено: почему XOR несовместим с прозрачностью `#FF` |
|
||||
| [`roomnav_skip.md`](roomnav_skip.md) | Комнаты для отладочного телепорта `+`/`−`: какие пропускать и почему |
|
||||
| [`host_tests_plan.md`](host_tests_plan.md) | Модульные тесты движка под ucsim_z80: два шва, регрессии, дифф против SDLPoP |
|
||||
|
||||
@@ -89,3 +89,95 @@ raw-клавиатура (`<kbd_raw.h>`), которую мы и так заби
|
||||
после выхода — заметный. Делать после того, как закроем стражей и
|
||||
займёмся бюджетом всерьёз (там же, где батчинг кроссбанковых вызовов и
|
||||
возможный возврат `pop_bg` в резидент `--w3`).
|
||||
|
||||
## Готовить следующий уровень, пока играет мелодия конца текущего
|
||||
|
||||
**САМАЯ ДАЛЬНЯЯ ВЕРСИЯ.** Не полишинг и не порт: это улучшение ПРОТИВ
|
||||
оригинала. Планируем, но не раньше, чем закроем уровни и полишинг.
|
||||
|
||||
**Идея (пользователь, 2026-08-28).** Пока звучит мелодия конца уровня (и
|
||||
трек заставки между уровнями), экран не меняется — значит в это время можно
|
||||
успеть прочитать с диска следующий уровень, чтобы после музыки он появлялся
|
||||
сразу, а не через секунду-другую загрузки.
|
||||
|
||||
**Что УЖЕ сделано и мерено (2026-08-28).** Половина этого уже работает:
|
||||
трек заставки играет ПОВЕРХ загрузки уровня — так же, как в оригинале
|
||||
(`seg003:68-108`: `load_intro` возвращает управление, `load_level()` идёт под
|
||||
музыку, и только потом `while (check_sound_playing())` на чёрном экране).
|
||||
Насос это переживает: 500 подряд вызовов `pop_sfx_fill` через всю загрузку —
|
||||
максимальный зазор 245 832 такта при дедлайне 251 000, ни одной пропущенной
|
||||
порции. Осталась вторая половина: мелодия конца уровня досиживается на
|
||||
ЖИВОМ экране пройденного уровня (как в оригинале, `play_level_2`), и только
|
||||
после неё начинается загрузка.
|
||||
|
||||
**Оговорки, найденные при разборе.**
|
||||
|
||||
1. **Загрузку придётся разрезать надвое.** Сейчас `pop_level_switch` мешает
|
||||
дисковую работу (файл уровня в EMM-страницу, атласы стража и тайлсета) с
|
||||
экранно-палитровой (`pop_bg_load` переписывает записи палитры и атласы,
|
||||
`pop_guard_load` физически применяет цветовые слоты, обе страницы
|
||||
заливаются, `pop_pal_black`). Вторую половину НЕЛЬЗЯ выполнять, пока на
|
||||
экране ещё живёт пройденный уровень — иначе палитра поедет прямо на
|
||||
картинке. То есть в «музыкальное окно» можно вынести только диск, а всё,
|
||||
что трогает палитру и VRAM, остаётся после.
|
||||
2. **Бюджет кадра.** Ожидание мелодии идёт в игровом цикле (гейт
|
||||
`pop_endmus_left` + `pop_music_busy()`), кадр там обычный, 80-100 мс.
|
||||
Шаг подкачки должен быть такого же размера, как музыкальный — полстраницы
|
||||
(~16 мс), а не страница целиком; иначе кадры на экране пройденного уровня
|
||||
начнут дёргаться.
|
||||
3. **Память.** Страницы следующего уровня придётся держать одновременно со
|
||||
страницами текущего — проверить EMM-бюджет (memory `sprinter_emm_budget`),
|
||||
тяжёлые тут не файл уровня, а атласы тайлсета и стража.
|
||||
4. **Риск невелик, но он в самом хрупком месте.** Палитро-атласный обмен —
|
||||
источник уже пойманных багов (`PAL-DUNGEON-STALE`, `PAL-L1-AFTER-INTRO`),
|
||||
и трогать его ради экономии секунды стоит только на спокойную голову.
|
||||
|
||||
**Выигрыш.** Одна-две секунды один раз на уровень.
|
||||
|
||||
## Музыка одним постоянно открытым архивом (замер 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).
|
||||
Так что это оптимизация, а не исправление.
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
# Аудио без перелинковки: runtime-индексы музыки и эффектов
|
||||
|
||||
> Статус: **ОБЕ ЧАСТИ СДЕЛАНЫ 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. Цель
|
||||
|
||||
Один и тот же `sprpop.exe` должен корректно работать с любым штатным набором
|
||||
музыки (`flac`, `mp3`, `ogg`, `mt32`) без перекомпиляции и перелинковки.
|
||||
Замена набора должна сводиться к замене файлов в `MUS/` вместе с описывающим
|
||||
их индексом.
|
||||
|
||||
Сейчас это невозможно: упаковщик печатает зависящие от набора значения в
|
||||
`gen/pop_music_tbl.h` и `gen/pop_music_ticks.h`, после чего они становятся
|
||||
частью EXE. Если заменить только `MUS/mNN.bin`, загрузчик продолжает верить
|
||||
размерам старого набора, а сценарии — его длительностям.
|
||||
|
||||
## 2. Подтверждённое текущее состояние
|
||||
|
||||
После преобразования в PCM наборы `flac`, `mp3` и `ogg` имеют одинаковые
|
||||
длины. Исполнение MT-32 отличается. Значимые примеры:
|
||||
|
||||
| трек | FLAC/MP3/OGG | MT-32 |
|
||||
|---|---:|---:|
|
||||
| m32, конец уровня 4 | 438 тиков | 449 тиков |
|
||||
| m41, обычный конец уровня | 732 | 685 |
|
||||
| m50, принцесса ждёт | 831 | 867 |
|
||||
| m53, реплика Джафара | 985 | 1044 |
|
||||
| m56, финальная тема | 9865 блоков / 78 страниц | 10462 / 82 |
|
||||
|
||||
Все обычные треки каждого из четырёх наборов по-прежнему помещаются в
|
||||
`POP_MUS_PAGES == 20`. Только m56 требует кольцевого проигрывателя.
|
||||
`uint16_t` достаточно и для самого длинного MT-32-трека: 10462 блока.
|
||||
|
||||
Compile-time длительности используются не повсюду:
|
||||
|
||||
- `pop_music.c` читает из `pop_music_tbl.h` наличие, число страниц и блоков;
|
||||
- `pop_intro.c` использует длительности m50 и m53 для шкалы PV-сцены;
|
||||
- `pop_kid.c` использует длительности m32 и m41 для паузы конца уровня,
|
||||
включая режим с выключенной музыкой;
|
||||
- остальные ожидания уже опираются на `pop_music_busy()` и автоматически
|
||||
заработают с правильным runtime-числом блоков.
|
||||
|
||||
## 3. Что должно остаться константами программы
|
||||
|
||||
В индекс переносятся только свойства конкретного набора. Инварианты формата
|
||||
остаются в коде, а заголовок IDX лишь подтверждает их:
|
||||
|
||||
- PCM unsigned 8-bit mono;
|
||||
- частота `CBL_FREQ_10K9`;
|
||||
- один блок насоса — 128 байт;
|
||||
- пространство оригинальных sound id — 0..56;
|
||||
- `POP_MUS_PAGES == 20` для обычного загрузчика;
|
||||
- размер кольца потокового проигрывателя.
|
||||
|
||||
Это важно: IDX не должен обещать программе другой формат PCM, который насос
|
||||
физически не умеет воспроизводить.
|
||||
|
||||
## 4. Предлагаемый файл `MUS/MUSIC.IDX`
|
||||
|
||||
Предпочтителен отдельный индекс набора, а не заголовок в каждом треке:
|
||||
|
||||
- все сценарные длительности доступны сразу после старта;
|
||||
- `mNN.bin` остаются простыми сырыми PCM-потоками;
|
||||
- загрузчик трека меняется минимально;
|
||||
- один индекс легко заменить вместе с набором;
|
||||
- формат помещается в один 512-байтовый сектор DSS.
|
||||
|
||||
Предлагаемая версия `PMI1`:
|
||||
|
||||
```text
|
||||
0..3 "PMI1" magic и версия
|
||||
4 57 число плотных записей (id 0..56)
|
||||
5 4 размер записи
|
||||
6 7 log2 размера блока: 1 << 7 = 128
|
||||
7 1 формат PCM: u8 mono, CBL_FREQ_10K9
|
||||
8..15 0 резерв будущих версий
|
||||
|
||||
16.. 57 записей по 4 байта:
|
||||
+0..1 uint16 blocks длина PCM в блоках по 128 байт
|
||||
+2..3 uint16 ticks60 длительность ожидания в тиках оригинала
|
||||
|
||||
244..511 нулевой резерв до одного сектора
|
||||
```
|
||||
|
||||
Нулевые `blocks` и `ticks60` означают, что трека с таким id в наборе нет.
|
||||
|
||||
`pages` в IDX не хранится: это производная величина, и её дублирование может
|
||||
разойтись с `blocks`:
|
||||
|
||||
```c
|
||||
pages = blocks / 128 + ((blocks & 127) != 0);
|
||||
```
|
||||
|
||||
По умолчанию упаковщик вычисляет тики той же формулой, что сейчас:
|
||||
|
||||
```text
|
||||
ticks60 = round(blocks * 40 / 57)
|
||||
```
|
||||
|
||||
Хранить `ticks60` отдельно всё же полезно: в будущем момент окончания
|
||||
сценарной реплики можно будет уточнить независимо от технического хвоста PCM.
|
||||
Генератор обязан печатать предупреждение, если явно заданные тики заметно
|
||||
отличаются от длительности файла.
|
||||
|
||||
## 5. Размещение индекса в памяти
|
||||
|
||||
Загружать таблицу в обычный изменяемый `static` нельзя. Данные банковых
|
||||
модулей сейчас попадают в общий `_DATA`/W2, а свободная куча составляет около
|
||||
238 байт. Даже таблица из 228 байт практически уничтожит этот запас.
|
||||
|
||||
Индекс следует держать в одной EMM-странице:
|
||||
|
||||
1. `pop_music_init()` выделяет блок из одной страницы;
|
||||
2. загружает туда первые 512 байт `MUS\MUSIC.IDX`;
|
||||
3. проверяет magic, версию, размеры полей и формат PCM;
|
||||
4. готовит страницу для безопасного временного отображения в W0;
|
||||
5. сохраняет в W2 только номер блока/страницы и флаг готовности.
|
||||
|
||||
Потеря 16 КБ EMM ради маленькой таблицы допустима: EMM у игры с запасом, а
|
||||
W1/W2 — самый дефицитный ресурс. Не следует ради экономии страницы прятать
|
||||
индекс в хвост `kid.ani` или звукового набора: это создаст ненужную связь
|
||||
между независимыми ресурсами.
|
||||
|
||||
Доступ к записи предоставляет банковая функция наподобие:
|
||||
|
||||
```c
|
||||
int8_t pop_music_info(uint8_t id, pop_music_info_t *out) __banked;
|
||||
```
|
||||
|
||||
Она на короткое время отображает страницу IDX в W0, копирует четыре байта в
|
||||
буфер вызывающего и сразу восстанавливает окно. Наружу указатель на EMM не
|
||||
выдаётся.
|
||||
|
||||
Индекс живёт до выхода из программы. `pop_music_free()`, который вызывается
|
||||
между сценами, освобождать его не должен; для полного завершения нужен
|
||||
отдельный shutdown либо освобождение в общем маршруте выхода.
|
||||
|
||||
## 6. Изменения загрузчика музыки
|
||||
|
||||
`pop_music_load_begin(id)` должен получать из runtime-индекса:
|
||||
|
||||
- наличие трека;
|
||||
- число блоков;
|
||||
- вычисленное число EMM-страниц.
|
||||
|
||||
Далее существующая архитектура почти не меняется:
|
||||
|
||||
- `slot_blocks[]` уже хранит runtime-длину загруженного трека;
|
||||
- `pop_mus_left` получает её при `play`/`stream`;
|
||||
- насос сам останавливается на правильном блоке;
|
||||
- `pop_music_busy()` автоматически отражает фактический конец;
|
||||
- кольцевой проигрыватель получает правильные 82 страницы MT-32 m56 вместо
|
||||
78 страниц FLAC.
|
||||
|
||||
При каждом открытии `mNN.bin` надо без дополнительного `open` проверить
|
||||
фактический размер:
|
||||
|
||||
1. `lseek(fd, 0, SEEK_END)`;
|
||||
2. убедиться, что размер положительный и кратен 128;
|
||||
3. сравнить его с `blocks * 128` из IDX;
|
||||
4. вернуть позицию через `lseek(fd, 0, SEEK_SET)`.
|
||||
|
||||
`lseek(SEEK_END)` уже реализован в libc поверх DSS `MOVE_FP $15` и
|
||||
используется самой игрой для `POP.CFG`.
|
||||
|
||||
Рекомендуемая политика несовпадения:
|
||||
|
||||
- вывести диагностическое сообщение;
|
||||
- считать фактический размер файла главным;
|
||||
- пересчитать `blocks/pages/ticks60` в загруженной EMM-копии индекса;
|
||||
- продолжить работу, если размер проходит ограничения.
|
||||
|
||||
Так случайно забытый старый IDX не приведёт к чтению чужой EMM-страницы или
|
||||
обрыву трека. При этом штатная поставка обязана всегда включать согласованные
|
||||
IDX и PCM.
|
||||
|
||||
## 7. Runtime-тики без 32-битного переполнения
|
||||
|
||||
Если тики приходится восстанавливать из фактического размера, прямое
|
||||
`blocks * 40` может переполнить `uint16_t`. Та же формула считается только
|
||||
16-битной арифметикой:
|
||||
|
||||
```c
|
||||
ticks = (blocks / 57) * 40
|
||||
+ ((blocks % 57) * 40 + 28) / 57;
|
||||
```
|
||||
|
||||
Даже при `blocks == 65535` промежуточные значения остаются в `uint16_t`.
|
||||
Это холодный путь, поэтому небольшая цена деления допустима.
|
||||
|
||||
## 8. Перевод сценариев с compile-time на runtime
|
||||
|
||||
### PV-сцена (`pop_intro.c`)
|
||||
|
||||
Текущий большой `enum` смешивает два класса величин:
|
||||
|
||||
- неизменные интервалы сценария;
|
||||
- абсолютные точки, сдвигаемые длительностями m50 и m53.
|
||||
|
||||
Надо сохранить относительные сценарные константы, а при входе в PV-сцену
|
||||
один раз собрать локальную структуру `pv_timing_t`:
|
||||
|
||||
```text
|
||||
m50_end = MUS_2_START + ticks(m50)
|
||||
wait_end = m50_end + 40
|
||||
...
|
||||
dialog1_start = предыдущая фиксированная цепочка
|
||||
exit_start = dialog1_start + ticks(m53)
|
||||
anim_end = exit_start + 469
|
||||
```
|
||||
|
||||
Структуру лучше держать на стеке `intro_pv_animated()` и передавать нужным
|
||||
helper-функциям указателем. File-scope изменяемая таблица снова попала бы в
|
||||
W2.
|
||||
|
||||
m50 уже загружен до начала PV. m53 начинает подгружаться задолго до своего
|
||||
старта, поэтому метаданные обоих треков к моменту использования доступны.
|
||||
|
||||
### Конец уровня (`pop_kid.c`)
|
||||
|
||||
Вместо `POP_MUS_TICKS_32/41` редкое событие окончания уровня вызывает
|
||||
банковый accessor IDX и вычисляет `pop_endmus_left` из runtime-тиков.
|
||||
|
||||
Индекс используется даже при выключенной музыке: оригинал выдерживает эту
|
||||
паузу молча. Поэтому нельзя заменять её одним `pop_music_busy()`.
|
||||
|
||||
## 9. Генератор и сборка
|
||||
|
||||
`tools/pop_pack_music.py` должен:
|
||||
|
||||
1. как сейчас преобразовать выбранный набор в `mNN.bin`;
|
||||
2. проверить кратность каждого результата 128 байтам;
|
||||
3. собрать плотные записи 0..56;
|
||||
4. записать `MUS/MUSIC.IDX` в формате `PMI1`;
|
||||
5. больше не генерировать `pop_music_tbl.h` и `pop_music_ticks.h`.
|
||||
|
||||
Makefile должен:
|
||||
|
||||
- добавить `MUS/MUSIC.IDX` в `DISK` и staging;
|
||||
- убрать музыкальные generated-header'ы из `GEN_H`;
|
||||
- убрать их из зависимостей EXE;
|
||||
- оставить `MUSIC_FMT` зависимостью только музыкальных ресурсов;
|
||||
- гарантировать, что `make music-flac/mp3/ogg/mt32` заменяет и PCM, и IDX,
|
||||
но не перелинковывает EXE.
|
||||
|
||||
Критерий архитектуры: SHA/дата `sprpop.exe` не меняется при переключении
|
||||
между четырьмя музыкальными целями.
|
||||
|
||||
## 10. Ошибки и совместимость
|
||||
|
||||
Игра не должна падать из-за необязательной музыки:
|
||||
|
||||
- нет IDX — музыка отключена, игра продолжает работать;
|
||||
- неверная magic/версия/формат — музыка отключена с диагностикой;
|
||||
- записи нет — конкретный трек считается отсутствующим;
|
||||
- файла нет — запрос трека завершается молча/с диагностикой, как сейчас;
|
||||
- обычный трек требует больше 20 страниц — не загружать обычным путём;
|
||||
- потоковый трек имеет больше 255 страниц — отвергнуть, потому что текущие
|
||||
счётчики страниц восьмибитные;
|
||||
- IDX и файл расходятся — применить политику §6;
|
||||
- все файловые операции выполнять через `POP_PATH_CALL`, чтобы сохранить
|
||||
работу на старых DSS с повреждением текущего каталога.
|
||||
|
||||
Для отсутствующего/повреждённого IDX остаётся выбрать поведение немой паузы
|
||||
конца уровня: нулевая пауза либо небольшой канонический fallback. Это не
|
||||
мешает основной архитектуре, но решение надо принять до реализации.
|
||||
|
||||
## 11. Этапы реализации
|
||||
|
||||
### MI0 — формат и host-тест
|
||||
|
||||
- вынести writer/reader `PMI1` в тестируемый код упаковщика;
|
||||
- проверить magic, размеры, LE-поля, нулевые записи и padding;
|
||||
- для всех четырёх наборов сверить `blocks/ticks` с фактическими PCM;
|
||||
- зафиксировать тестом значения MT-32 m50/m53/m56 как отличающиеся от FLAC.
|
||||
|
||||
### MI1 — runtime-загрузка IDX
|
||||
|
||||
- `pop_music_init()`/shutdown;
|
||||
- одна EMM-страница, загрузка и валидация;
|
||||
- accessor одной записи;
|
||||
- отказ без порчи W0/W3, EMM и файловых дескрипторов.
|
||||
|
||||
### MI2 — loader/pump
|
||||
|
||||
- заменить `pop_mus_tbl[]` runtime-записью;
|
||||
- проверять реальный размер файла;
|
||||
- обычный, немедленный и кольцевой пути должны использовать одну метаинформацию;
|
||||
- удалить `pop_music_tbl.h`.
|
||||
|
||||
### MI3 — runtime-шкала сцен
|
||||
|
||||
- перевести m50/m53 в `pop_intro.c` на локальную runtime-шкалу;
|
||||
- перевести m32/m41 в `pop_kid.c`;
|
||||
- удалить `pop_music_ticks.h`.
|
||||
|
||||
### MI4 — сборка и образы
|
||||
|
||||
- добавить IDX в каждый музыкальный набор;
|
||||
- исключить музыку из зависимостей линковки;
|
||||
- собрать четыре HDD-варианта с одним EXE.
|
||||
|
||||
### MI5 — приёмка в MAME/на железе
|
||||
|
||||
- FLAC: title -> story -> PV, обычный конец уровня, конец уровня 4, ending;
|
||||
- MT-32: те же маршруты, особенно m50/m53 и полный m56;
|
||||
- музыка выключена: пауза конца уровня берётся из IDX;
|
||||
- замена набора без пересборки EXE;
|
||||
- повреждённый IDX, отсутствующий трек, несовпадающий размер;
|
||||
- контроль, что не запущено более одного MAME и предыдущий экземпляр закрыт
|
||||
перед новым прогоном.
|
||||
|
||||
## 12. Критерии готовности
|
||||
|
||||
- один бинарник запускается со всеми четырьмя наборами;
|
||||
- ни один музыкальный generated-header не входит в сборку C;
|
||||
- начало следующей сцены/уровня соответствует фактической записи;
|
||||
- MT-32 m56 проигрывает все 82 страницы и не обрывается как FLAC-вариант;
|
||||
- обычные треки не читают за пределами выделенного EMM-блока;
|
||||
- IDX не расходует сотни байт W2;
|
||||
- ошибочный набор отключает музыку безопасно;
|
||||
- переключение `music-*` не меняет EXE;
|
||||
- старые DSS продолжают работать через восстановление каталога приложения.
|
||||
|
||||
---
|
||||
|
||||
# Часть II. Звуковые эффекты без перелинковки
|
||||
|
||||
## 13. Цель и отличие от музыки
|
||||
|
||||
Один и тот же `sprpop.exe` должен работать с разными наборами PCM-эффектов,
|
||||
если в них сохранена исходная нумерация sound id 0..56. Замена набора должна
|
||||
сводиться к замене согласованной пары `SND/snd.idx` + `SND/snd.arc`, без
|
||||
генерации C-заголовка и перелинковки EXE.
|
||||
|
||||
Набор состоит из двух согласованных файлов:
|
||||
|
||||
- `SND/snd.arc` — обычный архив PBA1 с PCM-страницами;
|
||||
- `SND/snd.idx` — описание раскладки эффектов внутри этих страниц.
|
||||
|
||||
Отдельный IDX предпочтительнее расширения заголовка `snd.arc`: PBA1 остаётся
|
||||
универсальным и не получает специального варианта только для звука, а
|
||||
формат индекса можно независимо версионировать и проверять тем же способом,
|
||||
что будущий `MUS/MUSIC.IDX`.
|
||||
|
||||
Существующий формат записи менять не требуется:
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
uint8_t page;
|
||||
uint16_t off;
|
||||
uint16_t len;
|
||||
} pop_snd_ent_t;
|
||||
```
|
||||
|
||||
Это ровно необходимые проигрывателю номер логической страницы, смещение в
|
||||
ней и длина PCM. В текущем ABI SDCC/z80 запись занимает 5 байт; дисковый
|
||||
формат обязан описывать эти пять байт явно (`uint16` little-endian), а код
|
||||
должен проверять `sizeof(pop_snd_ent_t) == 5` на этапе сборки.
|
||||
|
||||
## 14. Подтверждённое текущее состояние
|
||||
|
||||
Сейчас `tools/pop_pack_sound.py` печатает раскладку в
|
||||
`gen/pop_sound_tbl.h`, а `pop_sfx.c` включает её как
|
||||
`static const pop_snd_tbl[57]`. Поэтому конкретные `page/off/len` становятся
|
||||
частью EXE.
|
||||
|
||||
В текущей сборке таблица занимает 285 байт в `_CODE`, по адресам
|
||||
`0x52D8..0x53F4`. Это общий резидентный диапазон игры: при huge-модели
|
||||
`_CODE`, `_DATA`, heap и stack совместно используют плоские 32 КБ
|
||||
`0x4000..0xBFFF`; граница W1/W2 отдельного бюджета здесь не создаёт.
|
||||
|
||||
Проверены оба имеющихся исходных набора DIGISND:
|
||||
|
||||
| набор | PCM-эффектов | страниц после упаковки | отличия раскладки |
|
||||
|---|---:|---:|---:|
|
||||
| MSDOS 1.3/1.4 | 31 из 57 | 10 | эталон текущей сборки |
|
||||
| SDLPoP 1.0/1.1 | 31 из 57 | 9 | отличаются 8 id |
|
||||
|
||||
Максимальный эффект занимает 17 664 байта, или 138 блоков по 128 байт.
|
||||
У SDLPoP эффект 48 содержит только 7 исходных сэмплов; это свойство самого
|
||||
набора, а не ошибка runtime-индекса.
|
||||
|
||||
## 15. Файл `SND/snd.idx`
|
||||
|
||||
IDX занимает один 512-байтовый сектор и после короткого заголовка является
|
||||
точным дисковым дампом 57 записей `pop_snd_tbl`:
|
||||
|
||||
```text
|
||||
0..3 "PSI1" magic и версия индекса эффектов
|
||||
4 57 число плотных записей, id 0..56
|
||||
5 5 размер записи
|
||||
6 7 log2 размера блока: 1 << 7 = 128
|
||||
7 1 формат: unsigned 8-bit mono, CBL_FREQ_10K9
|
||||
8 pages фактическое число PCM-страниц в snd.arc
|
||||
9..15 0 резерв
|
||||
|
||||
16..300 57 записей по 5 байт:
|
||||
+0 uint8 page
|
||||
+1..2 uint16 off, little-endian
|
||||
+3..4 uint16 len, little-endian
|
||||
|
||||
301..511 0 резерв до одного сектора
|
||||
```
|
||||
|
||||
`pages` — фактический размер конкретного набора, например 10 для MSDOS или
|
||||
9 для SDLPoP. Его не следует называть или трактовать как
|
||||
`POP_SND_MAX_PAGES`: максимум 16 является compile-time-вместимостью
|
||||
загрузчика, а IDX сообщает число реально нужных страниц и обязан укладываться
|
||||
в этот предел.
|
||||
|
||||
Загрузчик сверяет `pages` с числом элементов внешнего PBA1 и не доверяет
|
||||
расходящимся файлам. `off` и `len` обязаны быть кратны 128; нулевая длина
|
||||
означает отсутствие PCM для данного id.
|
||||
|
||||
IDX загружается прямо в резидентный `pop_snd_tbl`, поэтому отдельной страницы
|
||||
EMM для него не требуется. Цена решения — одно дополнительное открытие и
|
||||
чтение 512 байт при старте звука; на фоне загрузки 9–10 страниц PCM это
|
||||
приемлемый холодный расход.
|
||||
|
||||
## 16. Размещение runtime-таблицы в памяти
|
||||
|
||||
`pop_snd_tbl` становится обычным изменяемым внутренним объектом:
|
||||
|
||||
```c
|
||||
pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT];
|
||||
```
|
||||
|
||||
Его следует определить в отдельном internal data-модуле и объявить через
|
||||
`extern` в `_pop_sfx.h`, потому что таблицу заполняет холодный загрузчик из
|
||||
банка 8, а читает резидентный `pop_sfx.c`.
|
||||
|
||||
После снятия `const` SDCC перенесёт эти 285 байт из `_CODE` в `_DATA`. Это
|
||||
не добавляет 285 байт к общему резидентному расходу: `_CODE` одновременно
|
||||
уменьшается на тот же размер, а `_CODE` и `_DATA` последовательно лежат в
|
||||
одном диапазоне `0x4000..0xBFFF`. Возможна лишь небольшая разница из-за
|
||||
выравнивания, которую надо проверить итоговой map-картой и size-check.
|
||||
|
||||
Банк 8 может читать индекс напрямую в этот буфер: весь диапазон
|
||||
`0x4000..0xBFFF` остаётся доступен, пока банковый код исполняется в W3.
|
||||
Горячие обращения `pop_snd_tbl[id].page/off/len` и ISR не требуют новых
|
||||
маппингов или accessor-функций.
|
||||
|
||||
## 17. Фактическое число страниц
|
||||
|
||||
Инвариантами программы остаются:
|
||||
|
||||
```c
|
||||
#define POP_SND_MAX_PAGES 16
|
||||
#define POP_SND_COUNT 57
|
||||
#define POP_SND_BLOCK 128
|
||||
```
|
||||
|
||||
`POP_SND_PAGES == 10` больше не должен означать размер конкретного набора.
|
||||
Вместо него появляется runtime-состояние:
|
||||
|
||||
```c
|
||||
uint8_t pop_snd_pages;
|
||||
uint8_t pop_snd_page[POP_SND_MAX_PAGES];
|
||||
```
|
||||
|
||||
Загрузчик выделяет `mem_alloc_pages(pop_snd_pages)` и заполняет только
|
||||
фактическое число элементов массива. MSDOS-набор займёт 10 страниц,
|
||||
SDLPoP-набор — 9. Предел 16 оставляет запас будущим наборам без
|
||||
перекомпиляции и увеличивает постоянный массив лишь на 6 байт относительно
|
||||
текущего; ещё один байт занимает `pop_snd_pages`.
|
||||
|
||||
Насос при переходе длинного эффекта через границу страницы сравнивает
|
||||
`sfx_pg + 1` с `pop_snd_pages`, а не с compile-time-константой.
|
||||
|
||||
## 18. Загрузка и валидация
|
||||
|
||||
`pop_sfx_init()` должен выполнять следующую последовательность:
|
||||
|
||||
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`;
|
||||
6. прочитать обычную таблицу PBA1 с ёмкостью `POP_SND_MAX_PAGES`;
|
||||
7. сверить `PSI1.pages` с числом элементов PBA1;
|
||||
8. проверить каждую ненулевую запись;
|
||||
9. только после этого выделить фактическое число EMM-страниц и загрузить их.
|
||||
|
||||
Для каждой записи проверяются:
|
||||
|
||||
- `page < pop_snd_pages`;
|
||||
- `off < 16384` и `off % POP_SND_BLOCK == 0`;
|
||||
- `len % POP_SND_BLOCK == 0`;
|
||||
- диапазон `page/off/len` не выходит за загруженный набор;
|
||||
- переход длинного эффекта на следующие страницы не превышает
|
||||
`pop_snd_pages`.
|
||||
|
||||
При любой ошибке блок EMM и файловый дескриптор освобождаются, таблица не
|
||||
используется, эффекты остаются выключенными. Набор без `snd.idx` считается
|
||||
неполным; хранить в EXE старую таблицу как fallback не следует, иначе
|
||||
зависимость бинарника от конкретного набора останется.
|
||||
|
||||
## 19. Генератор и сборка
|
||||
|
||||
`tools/pop_pack_sound.py` должен:
|
||||
|
||||
1. как сейчас разобрать DIGISND, привести PCM к CBL_FREQ_10K9 и выровнять
|
||||
начала/длины на 128 байт;
|
||||
2. вычислить фактическое число страниц и отвергнуть набор больше 16;
|
||||
3. сформировать плотные 57 записей `page/off/len`;
|
||||
4. записать `SND/snd.idx` в формате `PSI1`, включая фактическое число
|
||||
страниц;
|
||||
5. собрать обычный `SND/snd.arc` из полученных страниц;
|
||||
6. больше не генерировать `gen/pop_sound_tbl.h`.
|
||||
|
||||
Чтобы число страниц не было захардкожено списком `s0.bin`..`s9.bin` в
|
||||
Makefile, упаковщик звука предпочтительно должен сразу формировать
|
||||
`assets/packed/SND/snd.arc` либо передавать упаковщику архивов динамический
|
||||
список результатов без сохранения устаревшей десятой страницы.
|
||||
|
||||
Стабильные тип и константы переносятся в обычный internal-заголовок. EXE не
|
||||
должен зависеть от результата упаковки звука: цель выбора набора меняет
|
||||
согласованную пару `snd.idx` + `snd.arc`.
|
||||
|
||||
## 20. Этапы реализации эффектов
|
||||
|
||||
### SI0 — формат и host-тест
|
||||
|
||||
- writer/reader отдельного `snd.idx` в формате `PSI1`;
|
||||
- проверка точного пятибайтового LE-формата записи;
|
||||
- сборка и разбор обоих имеющихся DIGISND-наборов;
|
||||
- проверки 10 страниц MSDOS, 9 страниц SDLPoP и отличающихся записей.
|
||||
|
||||
### SI1 — runtime-таблица
|
||||
|
||||
- изменяемый `pop_snd_tbl` в общем резидентном CODE/DATA-диапазоне;
|
||||
- загрузка и полная валидация `PSI1`;
|
||||
- удаление generated `pop_sound_tbl.h`;
|
||||
- проверка map-карты: перенос CODE -> DATA не должен съесть heap.
|
||||
|
||||
### SI2 — гибкое выделение страниц
|
||||
|
||||
- `POP_SND_MAX_PAGES == 16` и runtime `pop_snd_pages`;
|
||||
- фактический размер стека таблицы PBA1;
|
||||
- выделение/освобождение 9, 10 и тестовых 16 страниц;
|
||||
- runtime-гард перехода длинного эффекта между страницами.
|
||||
|
||||
### SI3 — сборка без перелинковки
|
||||
|
||||
- убрать фиксированный `SND_ATL` из десяти имён;
|
||||
- сделать `snd.idx` и `snd.arc` согласованными результатами выбора набора;
|
||||
- проверить неизменность SHA/даты `sprpop.exe` при смене набора.
|
||||
|
||||
### SI4 — приёмка
|
||||
|
||||
- проиграть короткий, обычный и переходящий страницу эффекты;
|
||||
- проверить приоритеты и перебивание звуков;
|
||||
- проверить музыку поверх общего CBL после runtime-загрузки эффектов;
|
||||
- отсутствующий IDX и повреждённые magic/count/pages/page/off/len должны
|
||||
безопасно отключать эффекты;
|
||||
- перед каждым MAME-прогоном завершать предыдущий экземпляр и никогда не
|
||||
запускать две копии одновременно.
|
||||
|
||||
## 21. Критерии готовности эффектов
|
||||
|
||||
- один `sprpop.exe` работает с MSDOS- и SDLPoP-наборами;
|
||||
- замена согласованной пары `SND/snd.idx` + `SND/snd.arc` не требует
|
||||
компиляции C и не меняет EXE;
|
||||
- `pop_snd_tbl` больше не генерируется как C-код;
|
||||
- загружается фактическое число страниц в диапазоне 1..16;
|
||||
- таблица не расходует дополнительную страницу EMM и не увеличивает
|
||||
суммарный резидентный CODE+DATA на свои 285 байт;
|
||||
- горячий путь и ISR используют прежние прямые `page/off/len`;
|
||||
- повреждённый индекс не приводит к чтению за пределами EMM-блока;
|
||||
- файловые операции сохраняют совместимость со старыми DSS через
|
||||
восстановление каталога приложения.
|
||||
@@ -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()` там мало: между заявкой и первой нотой проходит
|
||||
190–230 мс (§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 не было — до него надо дойти в
|
||||
игре. Это оставшийся пункт приёмки.
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/* kid_atlas.h — раскладка атласов Kid. Сгенерировано pop_pack_kid.py. */
|
||||
#ifndef KID_ATLAS_H
|
||||
#define KID_ATLAS_H
|
||||
#define KID_SHIFT 3
|
||||
#define KID_MASK 7
|
||||
#define KID_PAGES 28
|
||||
#define KID_PAL 0x70
|
||||
static const char *const kid_atl[KID_PAGES] = {
|
||||
"kid0.atl",
|
||||
"kid1.atl",
|
||||
"kid2.atl",
|
||||
"kid3.atl",
|
||||
"kid4.atl",
|
||||
"kid5.atl",
|
||||
"kid6.atl",
|
||||
"kid7.atl",
|
||||
"kid8.atl",
|
||||
"kid9.atl",
|
||||
"kid10.atl",
|
||||
"kid11.atl",
|
||||
"kid12.atl",
|
||||
"kid13.atl",
|
||||
"kid14.atl",
|
||||
"kid15.atl",
|
||||
"kid16.atl",
|
||||
"kid17.atl",
|
||||
"kid18.atl",
|
||||
"kid19.atl",
|
||||
"kid20.atl",
|
||||
"kid21.atl",
|
||||
"kid22.atl",
|
||||
"kid23.atl",
|
||||
"kid24.atl",
|
||||
"kid25.atl",
|
||||
"kid26.atl",
|
||||
"kid27.atl",
|
||||
};
|
||||
#define KID_PAL_FILE "kid.pal"
|
||||
#define KID_SWORD_ATL "sword.atl" /* chtab_0: меч в руке */
|
||||
#define KID_SWORD_ID0 0 /* индекс в атласе = sword_tbl.id - ID0 */
|
||||
#endif
|
||||
@@ -1,5 +1,5 @@
|
||||
/* pop_bg_atlas.h — раскладка атласов статического фона PoP.
|
||||
* Сгенерировано toolchain/pop_pack_bg.py — НЕ править вручную.
|
||||
* Сгенерировано tools/pop_pack_bg.py — НЕ править вручную.
|
||||
*
|
||||
* Прямая адресация (ноль remap-таблиц в W2):
|
||||
* ENV фон id N -> atlas env_bg[N>>4], idx N&15
|
||||
@@ -23,25 +23,15 @@
|
||||
#define POP_PAL_ENV 0x50
|
||||
#define POP_PAL_WALL 0x60
|
||||
|
||||
/* Имена файлов атласов по тайлсету (грузятся atlas_load). Таблицы
|
||||
* видны только тому, кто попросил POP_BG_ATLAS_NAMES: иначе копия
|
||||
* строк уедет в каждый включивший заголовок модуль. */
|
||||
/* Палитры пока остаются отдельными файлами рядом с bg.arc. Имена
|
||||
* видит только модуль загрузки, чтобы не размножать строки. */
|
||||
#ifdef POP_BG_ATLAS_NAMES
|
||||
static const char *const pop_env_atl[POP_TILESETS][POP_ENV_PAGES] = {
|
||||
{ "pop_env0.atl", "pop_env1.atl", "pop_env2.atl", "pop_env3.atl", "pop_env4.atl", "pop_env5.atl", "pop_env6.atl", "pop_env7.atl", "pop_env8.atl", "pop_env9.atl" },
|
||||
{ "pal_env0.atl", "pal_env1.atl", "pal_env2.atl", "pal_env3.atl", "pal_env4.atl", "pal_env5.atl", "pal_env6.atl", "pal_env7.atl", "pal_env8.atl", "pal_env9.atl" },
|
||||
};
|
||||
static const char *const pop_wall_atl[POP_TILESETS] = { "pop_wall.atl", "pal_wall.atl" };
|
||||
static const char *const pop_fore_atl[POP_TILESETS] = { "pop_fore.atl", "pal_fore.atl" };
|
||||
static const char *const pop_tile_pal[POP_TILESETS] = { "pop_tile.pal", "pal_tile.pal" };
|
||||
#endif /* POP_BG_ATLAS_NAMES */
|
||||
#define POP_POT_ATL "pop_pot.atl" /* chtab_1: зелья, от набора не зависит */
|
||||
#define POP_PAL_POT 0x40
|
||||
/* Пузырёк зелья: красный набор = id 16..22 (кадры оригинала),
|
||||
зелёный (перо/переворот) и синий (вред/открыть) = те же кадры
|
||||
под id 30..36 и 40..46 (draw_tile_anim, seg008:652). */
|
||||
#define POP_POT_BUBB_GREEN 30
|
||||
#define POP_POT_BUBB_BLUE 40
|
||||
#define POP_BG_PAL "pop_bg.pal"
|
||||
|
||||
#endif
|
||||
|
||||
@@ -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 */
|
||||
@@ -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 */
|
||||
@@ -1,5 +1,5 @@
|
||||
/* pop_shadow_atlas.h — раскладка атласа ТЕНИ.
|
||||
* СГЕНЕРЕНО toolchain/pop_pack_shadow.py — НЕ править вручную.
|
||||
* СГЕНЕРЕНО tools/pop_pack_shadow.py — НЕ править вручную.
|
||||
*
|
||||
* Две половины, потому что и у оригинала их две (см. шапку
|
||||
* упаковщика): кадры вне боя адресуют chtab_2 (спрайты Кида),
|
||||
@@ -25,44 +25,6 @@
|
||||
/* Половина «в бою» — SHADOW.DAT (chtab_5, кадры 150..189). */
|
||||
#define POP_SHADOW_FGT_PAGES 4
|
||||
|
||||
static const char *const pop_shadow_kid_atl[POP_SHADOW_KID_PAGES] = {
|
||||
"SHADOW\\sk0.atl",
|
||||
"SHADOW\\sk1.atl",
|
||||
"SHADOW\\sk2.atl",
|
||||
"SHADOW\\sk3.atl",
|
||||
"SHADOW\\sk4.atl",
|
||||
"SHADOW\\sk5.atl",
|
||||
"SHADOW\\sk6.atl",
|
||||
"SHADOW\\sk7.atl",
|
||||
"SHADOW\\sk8.atl",
|
||||
"SHADOW\\sk9.atl",
|
||||
"SHADOW\\sk10.atl",
|
||||
"SHADOW\\sk11.atl",
|
||||
"SHADOW\\sk12.atl",
|
||||
"SHADOW\\sk13.atl",
|
||||
"SHADOW\\sk14.atl",
|
||||
"SHADOW\\sk15.atl",
|
||||
"SHADOW\\sk16.atl",
|
||||
"SHADOW\\sk17.atl",
|
||||
"SHADOW\\sk18.atl",
|
||||
"SHADOW\\sk19.atl",
|
||||
"SHADOW\\sk20.atl",
|
||||
"SHADOW\\sk21.atl",
|
||||
"SHADOW\\sk22.atl",
|
||||
"SHADOW\\sk23.atl",
|
||||
"SHADOW\\sk24.atl",
|
||||
"SHADOW\\sk25.atl",
|
||||
"SHADOW\\sk26.atl",
|
||||
"SHADOW\\sk27.atl",
|
||||
};
|
||||
|
||||
static const char *const pop_shadow_fgt_atl[POP_SHADOW_FGT_PAGES] = {
|
||||
"SHADOW\\sf0.atl",
|
||||
"SHADOW\\sf1.atl",
|
||||
"SHADOW\\sf2.atl",
|
||||
"SHADOW\\sf3.atl",
|
||||
};
|
||||
|
||||
/* 16 цветов, запись 4 байта (B, G, R, 0) — формат gfx_pal_load. */
|
||||
static const uint8_t pop_shadow_pal[64] = {
|
||||
0x00,0x00,0x00,0x00,0xD8,0xFC,0xFC,0x00,0x6C,0x80,0xDC,0x00,0x78,0x44,0x24,0x00,0xA0,0xB8,0xD8,0x00,0xB5,0x7C,0x21,0x00,0x00,0x90,0xB8,0x00,0x5D,0x6D,0xAB,0x00,0x0C,0xF0,0x6C,0x00,0xCC,0x38,0x05,0x00,0x38,0xCC,0xC0,0x00,0x58,0xDB,0xA7,0x00,0xBB,0x9F,0x54,0x00,0x5D,0x18,0x60,0x00,0x37,0xD7,0x37,0x00,0x2F,0x17,0xD2,0x00,
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/* pop_sound_tbl.h — раскладка оцифрованных эффектов PoP.
|
||||
* СГЕНЕРЕНО toolchain/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
|
||||
|
||||
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 */
|
||||
};
|
||||
|
||||
/* Имена файлов видит ТОЛЬКО тот, кто попросил POP_SND_FILES (холодная
|
||||
* половина): иначе копия строк уезжает в каждый включивший заголовок
|
||||
* модуль, а в резиденте W1/W2 каждая сотня байт на счету. Тот же
|
||||
* приём, что у POP_BG_ATLAS_NAMES в pop_bg_atlas.h. */
|
||||
#ifdef POP_SND_FILES
|
||||
static const char *const pop_snd_files[POP_SND_PAGES] = {
|
||||
"SND\\s0.bin",
|
||||
"SND\\s1.bin",
|
||||
"SND\\s2.bin",
|
||||
"SND\\s3.bin",
|
||||
"SND\\s4.bin",
|
||||
"SND\\s5.bin",
|
||||
"SND\\s6.bin",
|
||||
"SND\\s7.bin",
|
||||
"SND\\s8.bin",
|
||||
"SND\\s9.bin",
|
||||
};
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* _pop_file.h — внутренний файловый слой SprPoP для старых версий DSS.
|
||||
*
|
||||
* DSS до исправления относительных путей оставляет рабочий каталог внутри
|
||||
* последнего подкаталога. POP_PATH_CALL восстанавливает абсолютный каталог
|
||||
* приложения до и после операции. Это макрос намеренно: код исполняется в
|
||||
* банке вызывающего, где доступен переданный указатель, и не расходует W1.
|
||||
*/
|
||||
#ifndef _POP_FILE_H
|
||||
#define _POP_FILE_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <errno.h>
|
||||
|
||||
/* BANK10: получить каталог EXE через APPINFO B=1 и сделать его текущим. */
|
||||
int8_t pop_path_init(void) __banked;
|
||||
int8_t pop_chdir_home(void) __banked;
|
||||
|
||||
/* expression вычисляется только после успешного chdir. errno файловой
|
||||
* операции сохраняется через завершающее восстановление каталога. */
|
||||
#define POP_PATH_CALL(result, expression) do { \
|
||||
if (pop_chdir_home() != 0) { \
|
||||
(result) = -1; \
|
||||
} else { \
|
||||
int _pop_path_errno; \
|
||||
(result) = (expression); \
|
||||
_pop_path_errno = errno; \
|
||||
(void)pop_chdir_home(); \
|
||||
errno = _pop_path_errno; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#endif
|
||||
@@ -1,17 +1,22 @@
|
||||
/*
|
||||
* _pop_sfx.h — внутренний контракт между половинами звука (НЕ публичный,
|
||||
* публичный — pop_sfx.h). Горячая половина живёт в резиденте
|
||||
* (pop_sfx.c), холодная в банке (pop_sfx_cold.c); делят они таблицу
|
||||
* звуков, массив физических страниц и насос.
|
||||
* (pop_sfx.c), холодная в банке (pop_sfx_cold.c); делят они массив
|
||||
* физических страниц и насос.
|
||||
*/
|
||||
#ifndef _POP_SFX_INTERNAL_H
|
||||
#define _POP_SFX_INTERNAL_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include "pop_sound_tbl.h" /* pop_snd_tbl, 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;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <string.h>
|
||||
#include <sprinter_mem.h>
|
||||
#include "pop_arc.h"
|
||||
#include "_pop_file.h"
|
||||
|
||||
/* Пути 8.3, как требует DSS. Держим их ЗДЕСЬ: строка обязана лежать в том
|
||||
* же банке, что и код, который её читает (см. pop_arc.h). */
|
||||
@@ -42,7 +43,7 @@ int8_t pop_arc_open(pop_arc_t *a, const char *path,
|
||||
|
||||
a->fd = -1;
|
||||
a->count = 0;
|
||||
fd = open(path, O_RDONLY);
|
||||
POP_PATH_CALL(fd, open(path, O_RDONLY));
|
||||
if (fd < 0) return -1;
|
||||
if (read(fd, head, sizeof(head)) != (int)sizeof(head) ||
|
||||
memcmp(head, "PBA1", 4) != 0 ||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
#include "pop_cheat.h"
|
||||
#include "pop_sfx.h"
|
||||
#include "_pop_config.h"
|
||||
#include "_pop_file.h"
|
||||
|
||||
#define CFG_BASE 0x100
|
||||
|
||||
@@ -151,7 +152,7 @@ static uint8_t cfg_read_file(uint8_t *buf, uint16_t *size)
|
||||
int fd, got;
|
||||
long n;
|
||||
|
||||
fd = open("POP.CFG", O_RDONLY);
|
||||
POP_PATH_CALL(fd, open("POP.CFG", O_RDONLY));
|
||||
if (fd < 0) return 0;
|
||||
n = lseek(fd, 0, SEEK_END);
|
||||
if (n < POP_CFG_MIN_SIZE || n > POP_CFG_MAX_SIZE ||
|
||||
@@ -172,7 +173,7 @@ int8_t pop_cfg_load(void) __banked
|
||||
|
||||
if (!cfg_read_file(buf, &size) || !pop_cfg_decode(buf, size)) return 0;
|
||||
if (buf[9] != POP_PROFILE_VANILLA)
|
||||
printf("POP.CFG: ENHANCED profile -> VANILLA\n");
|
||||
puts("POP.CFG: ENHANCED profile -> VANILLA\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -192,7 +193,7 @@ int8_t pop_cfg_save(void) __banked
|
||||
gfx_w0_unmap();
|
||||
/* Не unlink: bank_save_file сам делает O_TRUNC и не создаёт лишнего
|
||||
* окна, когда POP.CFG вообще отсутствует после ошибки unlink. */
|
||||
rc = bank_save_file(page, CFG_BASE, "POP.CFG", sizeof(buf));
|
||||
POP_PATH_CALL(rc, bank_save_file(page, CFG_BASE, "POP.CFG", sizeof(buf)));
|
||||
mem_free_block(blk);
|
||||
return (rc == sizeof(buf)) ? 1 : 0;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
#include "pop_title.h" /* титульная картинка после ввода имени */
|
||||
#include "pop_music.h" /* подкачка потоковой темы победы */
|
||||
#include "pop_hof.h"
|
||||
#include "_pop_file.h"
|
||||
|
||||
/* MAX_HOF_COUNT оригинала — шесть строк. */
|
||||
#define HOF_COUNT 6
|
||||
@@ -162,7 +163,7 @@ static void hof_load(void)
|
||||
if (!blk) return;
|
||||
page = mem_get_page(blk, 0);
|
||||
gfx_w0_page_prepare(page);
|
||||
got = bank_load_file(page, HOF_BASE, "POP.HOF", HOF_SIZE);
|
||||
POP_PATH_CALL(got, bank_load_file(page, HOF_BASE, "POP.HOF", HOF_SIZE));
|
||||
if (got == HOF_SIZE) {
|
||||
gfx_w0_map(page);
|
||||
for (i = 0; i < HOF_SIZE; i++)
|
||||
@@ -187,7 +188,11 @@ static void hof_save(void)
|
||||
gfx_w0_unmap();
|
||||
/* Неудача записи не меняет только что показанную таблицу: следующее
|
||||
* открытие разумно начнёт с прежнего файла или с пустого списка. */
|
||||
bank_save_file(page, HOF_BASE, "POP.HOF", HOF_SIZE);
|
||||
{
|
||||
int saved;
|
||||
POP_PATH_CALL(saved,
|
||||
bank_save_file(page, HOF_BASE, "POP.HOF", HOF_SIZE));
|
||||
}
|
||||
mem_free_block(blk);
|
||||
}
|
||||
|
||||
|
||||
+200
-136
@@ -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,48 +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,
|
||||
/* ЗАКЛИНАНИЕ СДВИНУТО РАНЬШЕ СЦЕНАРИЯ — ПОДГОНКА ПОД МУЗЫКУ.
|
||||
*
|
||||
* Сцена render-bound: кадр рисуется дороже своего бюджета, кадровая
|
||||
* ветка пейсинга догона не имеет, и анимация идёт ~49 тиков в секунду
|
||||
* вместо 60. Реплика Джафара при этом играет по РЕАЛЬНОМУ времени и
|
||||
* успевает кончиться, когда шкала стоит на 1854 вместо 2028 (замер в
|
||||
* MAME 2026-08-27 зондом на конец трека: длина насосу отдана полная,
|
||||
* 1403 порции, курсор дошёл до последней страницы — трек доигрывает
|
||||
* целиком, отстаёт именно картинка).
|
||||
*
|
||||
* Из-за этого кода реплики приходила примерно на секунду раньше
|
||||
* молнии. Честное лечение — удешевить кадр сцены; до тех пор двигаем
|
||||
* жест целиком (замах, шаг назад, вспышка), чтобы он не сломался
|
||||
* внутри себя. Величина подобрана НА СЛУХ и уточняется прогоном:
|
||||
* расчётные полсотни тиков (секунда отставания при ~49 тиках/с) на
|
||||
* слух почти не изменили картины, поэтому взято вдвое больше.
|
||||
*
|
||||
* Эффектов дверей это не касается: у них и звук, и картинка идут по
|
||||
* одной и той же шкале и разъехаться не могут. */
|
||||
PV_MAGIC_LEAD = 100,
|
||||
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 порций — полторы секунды, дальше догонять уже нечего. */
|
||||
@@ -430,6 +458,44 @@ static int intro_pv_draw_base(void)
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ВОССТАНОВЛЕНИЕ ФОНА ЗАДНЕЙ СТРАНИЦЫ — ДВУМЯ БЛОКАМИ ПО КАДРОВОМУ ИНТЕРВАЛУ.
|
||||
*
|
||||
* Персонажи и живой фон рисуются банком спрайтов и в ОЗУ-копию не оседают,
|
||||
* поэтому «стереть прошлый кадр» = скопировать чистый фон из теневой копии
|
||||
* соседней страницы. Раньше это была ОДНА копия на весь экран, и она стоила
|
||||
* ~547 000 тактов = 1,27 кадра луча (модель blit_cost_model). Хвост кадра
|
||||
* сцены ждал vsync ПОСЛЕ отрисовки, так что эти 1,27 кадра ПРИБАВЛЯЛИСЬ к
|
||||
* пяти интервалам делителя: сцена шла 6+ кадров вместо 5, то есть ~8,2 fps
|
||||
* вместо десяти (замер 49/60 тиков — docs/BUGS_OPEN.md PV-RENDER-BOUND).
|
||||
*
|
||||
* Лечение — не «удешевить», а РАЗЛОЖИТЬ по интервалам: каждый блок заведомо
|
||||
* влезает в кадр, и после него честно ждём vsync. Тогда отрисовка живёт
|
||||
* ВНУТРИ делителя, а не сверх него.
|
||||
* - копируем только картинку (200 строк с POP_YOFF), а не все 256:
|
||||
* сверху и снизу чёрная рамка, туда никто не рисует;
|
||||
* - двумя половинами по 100 строк: ~219 000 тактов каждая, чуть больше
|
||||
* половины кадра — запас двукратный.
|
||||
*
|
||||
* ТРАТИТ РОВНО ДВА кадровых интервала (по gfx_wait_vsync на блок) — это
|
||||
* учтено в раскладке кадра у всех трёх сцен (см. вызовы). */
|
||||
#define PV_BG_Y0 POP_YOFF
|
||||
#define PV_BG_H 200
|
||||
|
||||
static void pv_restore_bg(void)
|
||||
{
|
||||
gfx_rect_t r;
|
||||
|
||||
r.x = 0;
|
||||
r.w = GFX_WIDTH;
|
||||
r.h = PV_BG_H / 2;
|
||||
r.y = PV_BG_Y0;
|
||||
gfx_copy_page(&r, GFX_COPY_DIRECT);
|
||||
gfx_wait_vsync();
|
||||
r.y = PV_BG_Y0 + PV_BG_H / 2;
|
||||
gfx_copy_page(&r, GFX_COPY_DIRECT);
|
||||
gfx_wait_vsync();
|
||||
}
|
||||
|
||||
static void pv_blit_floor(const atlas_t *at, uint8_t frame, int x, int floor)
|
||||
{
|
||||
const uint8_t *img;
|
||||
@@ -701,12 +767,10 @@ static uint8_t intro_pv_draw_frame(const atlas_t *princess, const atlas_t *j0,
|
||||
uint8_t sand_frame, uint8_t flash,
|
||||
uint16_t tick)
|
||||
{
|
||||
gfx_rect_t full;
|
||||
uint8_t back = (uint8_t)(gfx_get_visible_page() ^ 1);
|
||||
|
||||
gfx_set_draw_page(back);
|
||||
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
|
||||
gfx_copy_page(&full, GFX_COPY_DIRECT);
|
||||
pv_restore_bg(); /* блоки 1-2: фон, два интервала */
|
||||
gfx_set_bank(GFX_BANK_SPRITE);
|
||||
/* y=116 в princess_room_torch — нижняя строка, не верх спрайта. */
|
||||
pv_blit_floor(torch, torch_left, 93, 116);
|
||||
@@ -730,7 +794,7 @@ static uint8_t intro_pv_draw_frame(const atlas_t *princess, const atlas_t *j0,
|
||||
* его надо повторить ПОСЛЕ актёров, иначе Jaffar проходит перед
|
||||
* правой передней колонной. */
|
||||
pv_blit_floor(hourglass, 0, 240, 167);
|
||||
gfx_wait_vsync();
|
||||
gfx_wait_vsync(); /* блок 3: актёры и декорации */
|
||||
gfx_set_visible_page(back);
|
||||
if (flash) {
|
||||
uint8_t pulse;
|
||||
@@ -743,9 +807,9 @@ static uint8_t intro_pv_draw_frame(const atlas_t *princess, const atlas_t *j0,
|
||||
pop_pal_flash_white(0);
|
||||
gfx_wait_vsync();
|
||||
}
|
||||
return 11;
|
||||
return 13; /* три интервала кадра + десять на молнию */
|
||||
}
|
||||
return 1;
|
||||
return 3; /* сколько кадровых интервалов съели */
|
||||
}
|
||||
|
||||
/* Сцена pv_scene() SDLPoP: ожидание Princess с факелами/звёздами, поворот,
|
||||
@@ -759,12 +823,7 @@ static int intro_pv_animated(void)
|
||||
uint16_t tick;
|
||||
uint16_t frame_end;
|
||||
uint16_t render_tick;
|
||||
uint16_t beam = 0; /* запасной путь: кадров луча ждали */
|
||||
uint16_t snd_done = 0; /* тиков насоса прошло с начала сцены */
|
||||
uint16_t snd_want = 0; /* сколько их должно было пройти */
|
||||
uint16_t snd_acc = 0; /* дробный остаток пересчёта 57/40 */
|
||||
uint8_t snd_prev = 0;
|
||||
uint8_t snd_pace = 0;
|
||||
uint16_t beam = 0; /* кадров луча потрачено с начала сцены */
|
||||
uint8_t period;
|
||||
uint8_t anim_step = 0;
|
||||
uint8_t torch_left = 1;
|
||||
@@ -813,18 +872,22 @@ static int intro_pv_animated(void)
|
||||
* Полноэкранная accel-копия кадра насосу не мешает: leaf режет DI
|
||||
* бандами по 16 строк. */
|
||||
(void)pop_sfx_start();
|
||||
/* ЧЕМ МЕРИТЬ ВРЕМЯ. Пока сцена была немой, кадры просто считали
|
||||
* ожидания vsync — и этого хватало. С музыкой выяснилось, что так
|
||||
* считаются НАШИ ожидания, а не прошедшее время: полноэкранная копия
|
||||
* страницы (~0,55 млн тактов) съедает больше кадра луча, и сцена
|
||||
* незаметно отстаёт — в MAME молния била уже ПОСЛЕ конца реплики.
|
||||
* Часы берём у насоса CBL: он идёт от расхода буфера железом, 85,4 Гц,
|
||||
* и ему безразлично, чем занят главный цикл. Если звук выключен,
|
||||
* часы стоят — тогда работает прежний путь по кадрам луча. */
|
||||
snd_prev = pop_snd_tick;
|
||||
gfx_wait_vsync();
|
||||
snd_pace = (uint8_t)(pop_snd_tick != snd_prev); /* часы реально идут? */
|
||||
snd_prev = pop_snd_tick;
|
||||
/* ЧЕМ МЕРИТЬ ВРЕМЯ — ЕДИНЫЕ ЧАСЫ: КАДРЫ ЛУЧА.
|
||||
*
|
||||
* Была вторая шкала — тики насоса CBL, и выбор между ними делался
|
||||
* гонкой на старте сцены (одна выборка `pop_snd_tick` через кадр:
|
||||
* успел ли диск раскрутить звук). Значит от прогона к прогону сцена
|
||||
* шла то по одним часам, то по другим, и кода реплики приходилась
|
||||
* каждый раз на другое место картинки (наблюдение пользователя
|
||||
* 2026-08-28; баг SND-PACE-DEAD). Насос как часы был нужен потому,
|
||||
* что кадр сцены рисовался ДОЛЬШЕ своего интервала и счёт наших
|
||||
* ожиданий врал; теперь отрисовка разложена на блоки по кадровому
|
||||
* интервалу (см. pv_restore_bg), и счёт кадров луча честен.
|
||||
*
|
||||
* Осталось учесть единственную работу, которая в интервал не влезала
|
||||
* сама, — подкачку страницы трека: шаг читает ПОЛСТРАНИЦЫ (~16 мс,
|
||||
* pop_music_load_step) и оплачивается ровно одним интервалом, как
|
||||
* любой другой блок кадра. */
|
||||
result = 0;
|
||||
princess_actor.x = 120;
|
||||
princess_actor.frame = 11; /* Pstand, facing left */
|
||||
@@ -837,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)) {
|
||||
@@ -892,59 +956,42 @@ static int intro_pv_animated(void)
|
||||
torch_right, sand_frame, flash,
|
||||
render_tick);
|
||||
pop_sfx_tick(); /* как в оригинале — в конце отрисовки кадра */
|
||||
|
||||
/* ГАРАНТИРОВАННЫЙ ШАГ ПОДКАЧКИ, а не «если останется время».
|
||||
*
|
||||
* Ниже страница следующей реплики читается в ПАУЗЕ кадра, и это
|
||||
* Раньше страница следующей реплики читалась в ПАУЗЕ кадра, и это
|
||||
* верно ровно до тех пор, пока пауза есть. В этой сцене её почти
|
||||
* нет: кадр дорогой, шкала работает по принципу «долг не догоняем»
|
||||
* (snd_want подтягивается к snd_done), и цикл ожидания выходит
|
||||
* сразу. Замер в MAME 2026-08-26: ОДНА страница за семь секунд,
|
||||
* то есть одиннадцать страниц m53 не успевали за всю сцену —
|
||||
* pop_music_play() на своём тике не находил готового трека, и
|
||||
* реплики Джафара пропадали совсем.
|
||||
* не было: кадр дорогой, шкала работает по принципу «долг не
|
||||
* догоняем», и цикл ожидания выходил сразу. Замер в MAME
|
||||
* 2026-08-26: ОДНА страница за семь секунд, то есть одиннадцать
|
||||
* страниц m53 не успевали за всю сцену — pop_music_play() на своём
|
||||
* тике не находил готового трека, и реплики Джафара пропадали
|
||||
* совсем (memory pv_music_stall_regression).
|
||||
*
|
||||
* Страница стоит 33 мс против кадра сцены в 100-133 мс, поэтому
|
||||
* один безусловный шаг в кадр сцену не ломает: он лишь тратит
|
||||
* часть той же паузы, а если паузы не было — добавляет к кадру
|
||||
* четверть его длины, и шкала это учтёт сама. */
|
||||
if (pop_music_loading()) (void)pop_music_load_step();
|
||||
* Поэтому шаг безусловный — и оплачен как обычный блок кадра: он
|
||||
* читает полстраницы (~16 мс), а следующий gfx_wait_vsync ставит
|
||||
* нас на край интервала, который мы этим и заняли. */
|
||||
if (pop_music_loading()) {
|
||||
(void)pop_music_load_step();
|
||||
gfx_wait_vsync();
|
||||
beam++;
|
||||
}
|
||||
/* Первый кадр собран под чёрной палитрой — теперь проявляем его
|
||||
* целиком, вместе с принцессой и факелами. */
|
||||
if (!faded_in) {
|
||||
faded_in = 1;
|
||||
if (pop_pal_fade_in_skip(INTRO_FADE)) { result = 1; break; }
|
||||
}
|
||||
/* ПЕЙСИНГ ПО ОРИГИНАЛЬНОЙ ШКАЛЕ. Кадр cutscene — 6/8/7 тиков по
|
||||
* 1/60 с; один тик = 85,4/60 = 1,424 тика насоса, и 57/40 = 1,425
|
||||
* повторяет это с точностью 0,07 % (30 мс на всю сцену) без
|
||||
* деления в кадре и без 32-битной арифметики. */
|
||||
if (snd_pace) {
|
||||
snd_acc += (uint16_t)period * 57u;
|
||||
while (snd_acc >= 40u) { snd_acc -= 40u; snd_want++; }
|
||||
for (;;) {
|
||||
uint8_t now = pop_snd_tick;
|
||||
|
||||
/* Разность в uint8_t: счётчик переполняется каждые 3 с, а
|
||||
* заглядываем мы в него не реже раза в кадр. */
|
||||
snd_done += (uint8_t)(now - snd_prev);
|
||||
snd_prev = now;
|
||||
if (snd_done >= snd_want) break;
|
||||
/* Страницу следующей реплики читаем ЗДЕСЬ, в паузе кадра:
|
||||
* 33 мс диска укладываются в ожидание (кадр сцены — 133 мс),
|
||||
* и подкачка не добавляется к длительности кадра, а
|
||||
* растворяется в ней. */
|
||||
if (pop_music_loading()) (void)pop_music_load_step();
|
||||
else gfx_wait_vsync();
|
||||
}
|
||||
/* ДОЛГ НЕ ДОГОНЯЕМ. Подкачка следующей реплики (страница — 33 мс)
|
||||
* и любой тяжёлый кадр оставляют snd_done впереди snd_want, и
|
||||
* дальше цикл гнал кадры без единого ожидания: пламя факелов
|
||||
* мелькало, а события соседних тиков (створка ворот и дверь
|
||||
* покоев) слипались в один кадр — заявка перезаписывала заявку, и
|
||||
* один из звуков пропадал. Тот же принцип, что у pop_pace_end:
|
||||
* якорь ставится ПО ФАКТУ, пропущенное время не наверстывается. */
|
||||
if (snd_done > snd_want) snd_want = snd_done;
|
||||
} else {
|
||||
/* ПЕЙСИНГ ПО ОРИГИНАЛЬНОЙ ШКАЛЕ. Кадр сцены — 6/8/7 тиков по
|
||||
* 1/60 с (pv_seq_period), в кадры луча переводит POP_T60, и цель
|
||||
* КУМУЛЯТИВНА: нецелые 6,67 и 5,83 не копят ошибку.
|
||||
*
|
||||
* ДОЛГ НЕ ДОГОНЯЕМ: если кадр вышел дороже своего бюджета, цикл
|
||||
* ожидания просто не сработает, но beam уже учёл потраченное — и
|
||||
* следующая цель считается от той же общей шкалы. Наверстывать
|
||||
* пропущенное нельзя: события соседних тиков (створка ворот, дверь
|
||||
* покоев) слиплись бы в один кадр, и один из звуков пропал бы. */
|
||||
{
|
||||
uint16_t target = POP_T60(tick);
|
||||
|
||||
while (beam < target) { gfx_wait_vsync(); beam++; }
|
||||
@@ -995,7 +1042,6 @@ static uint8_t pre_hourglass_state(void)
|
||||
static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
|
||||
{
|
||||
atlas_t hourglass, torch;
|
||||
gfx_rect_t full;
|
||||
uint8_t torch_left = 1, torch_right = 6, sand = 0, frame;
|
||||
uint8_t star_phase[6] = { 1, 1, 1, 1, 1, 1 };
|
||||
intro_skip_t skip;
|
||||
@@ -1025,7 +1071,6 @@ static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
|
||||
|
||||
pop_screen_present(1);
|
||||
result = 0;
|
||||
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
|
||||
intro_skip_begin(&skip);
|
||||
for (frame = 0; frame < frames; frame++) {
|
||||
uint8_t back, hold, star;
|
||||
@@ -1033,7 +1078,7 @@ static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
|
||||
if (intro_skip_requested(&skip)) { result = 1; break; }
|
||||
back = (uint8_t)(gfx_get_visible_page() ^ 1);
|
||||
gfx_set_draw_page(back);
|
||||
gfx_copy_page(&full, GFX_COPY_DIRECT);
|
||||
pv_restore_bg(); /* блоки 1-2: фон, два интервала */
|
||||
gfx_set_bank(GFX_BANK_SPRITE);
|
||||
pv_blit_floor(&torch, torch_left, 93, 116);
|
||||
pv_blit_floor(&torch, torch_right, 211, 116);
|
||||
@@ -1046,10 +1091,11 @@ static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
|
||||
pv_blit_floor(&hourglass, (uint8_t)(8 + sand), 160, 164);
|
||||
}
|
||||
pv_blit_floor(&hourglass, 0, 240, 167); /* передняя колонна */
|
||||
gfx_wait_vsync();
|
||||
gfx_wait_vsync(); /* блок 3: живой фон */
|
||||
gfx_set_visible_page(back);
|
||||
/* Первый тик уже потрачен на commit: ещё четыре = 5/50 с. */
|
||||
for (hold = 1; hold < 5; hold++) gfx_wait_vsync();
|
||||
/* Три интервала уже потрачены на блоки отрисовки — добираем до
|
||||
* пяти (5/50 с = кадр катсцены оригинала). */
|
||||
for (hold = 3; hold < 5; hold++) gfx_wait_vsync();
|
||||
|
||||
/* Проявляем ПОСЛЕ первого кадра: иначе две секунды fade идут по
|
||||
* пустой комнате, а часы и пламя возникают уже на свету. */
|
||||
@@ -1128,8 +1174,14 @@ static void intro_restore_game_palette(void)
|
||||
/* Набор PV2 (res901..930) не влезает в одну EMM: image 0..16 в a1,
|
||||
* 17..29 в a2. Номер кадра при этом остаётся исходным image-id. */
|
||||
#define CUT_PV2_SPLIT 17
|
||||
/* Один логический кадр катсцены = 5 vsync, как у остальных PV-сцен
|
||||
* (cutscene_frame_time SDLPoP; см. pre_room_animated). */
|
||||
/* Один логический кадр катсцены = 5 vsync, как у остальных PV-сцен: у
|
||||
* оригинала `cutscene_frame_time = 6` тиков по 1/60 с (reset_cutscene,
|
||||
* seg001:527, и его зовёт load_intro прямо перед сценой) = 100 мс, у нас
|
||||
* 5 кадров луча по 50 Гц = те же 100 мс.
|
||||
*
|
||||
* РАСКЛАДКА ПО ИНТЕРВАЛАМ (иначе отрисовка прибавлялась к делителю, а не
|
||||
* жила внутри него — см. pv_restore_bg): фон половинами 1-2, актёры и
|
||||
* декорации 3, служебный блок (звук/подкачка музыки) 4-5. */
|
||||
#define CUT_FRAME_VSYNC 5
|
||||
|
||||
static atlas_t cut_pv1, cut_pv2a, cut_pv2b, cut_hg, cut_torch;
|
||||
@@ -1290,11 +1342,9 @@ static void cut_shad_init(uint8_t x, uint8_t y, uint8_t seq)
|
||||
* прервал сцену (любое НОВОЕ нажатие). */
|
||||
static uint8_t cut_run(uint16_t frames, uint8_t kid_visible)
|
||||
{
|
||||
gfx_rect_t full;
|
||||
uint16_t i;
|
||||
uint8_t hold;
|
||||
|
||||
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
|
||||
for (i = 0; i < frames; i++) {
|
||||
uint8_t back;
|
||||
kframe kid_fr, pr_fr;
|
||||
@@ -1316,8 +1366,9 @@ static uint8_t cut_run(uint16_t frames, uint8_t kid_visible)
|
||||
back = (uint8_t)(gfx_get_visible_page() ^ 1);
|
||||
gfx_set_draw_page(back);
|
||||
/* Чистый фон обеих страниц лежит в shadow-копии; персонажи рисуются
|
||||
* банком спрайтов и потому в неё не оседают. */
|
||||
gfx_copy_page(&full, GFX_COPY_DIRECT);
|
||||
* банком спрайтов и потому в неё не оседают. Двумя блоками по
|
||||
* кадровому интервалу — см. pv_restore_bg. */
|
||||
pv_restore_bg(); /* блоки 1-2 */
|
||||
gfx_set_bank(GFX_BANK_SPRITE);
|
||||
pv_blit_floor(&cut_torch, cut_torch_l, 93, 116);
|
||||
pv_blit_floor(&cut_torch, cut_torch_r, 211, 116);
|
||||
@@ -1339,12 +1390,17 @@ static uint8_t cut_run(uint16_t frames, uint8_t kid_visible)
|
||||
* сквозь неё — поймано пользователем 2026-08-25). */
|
||||
pv_blit_floor(&cut_hg, 0, 240, 167);
|
||||
gfx_set_bank(GFX_BANK_NORMAL);
|
||||
gfx_wait_vsync();
|
||||
gfx_wait_vsync(); /* блок 3: актёры и декорации */
|
||||
gfx_set_visible_page(back);
|
||||
for (hold = 1; hold < CUT_FRAME_VSYNC; hold++) gfx_wait_vsync();
|
||||
|
||||
/* Блок 4 — служебный, и он НЕ добавляется к кадру, а живёт внутри
|
||||
* оставшихся интервалов: подкачка страницы трека стоит ~33 мс, то
|
||||
* есть меньше двух интервалов, которые всё равно надо переждать.
|
||||
* Порядок как в сцене с Джафаром: шаг подкачки — безусловный (см.
|
||||
* memory pv_music_stall_regression). */
|
||||
pop_sfx_tick();
|
||||
pop_music_service(); /* «встреча с принцессой» — трек, а не эффект */
|
||||
for (hold = 3; hold < CUT_FRAME_VSYNC; hold++) gfx_wait_vsync();
|
||||
cut_torch_l = (uint8_t)((cut_torch_l + 1) % 9);
|
||||
cut_torch_r = (uint8_t)((cut_torch_r + 1) % 9);
|
||||
cut_sand = (uint8_t)((cut_sand + 1) % 3);
|
||||
@@ -1662,7 +1718,9 @@ uint8_t pop_intro_show(void) __banked
|
||||
/* Первая реплика PV читается ЗДЕСЬ, под чёрным экраном и в тишине:
|
||||
* дальше внутри сцены остаётся только пуск, а следующие две реплики
|
||||
* подкачиваются по странице за кадр. */
|
||||
pop_sfx_pause();
|
||||
/* Глушение снято 2026-08-28: массовое чтение страниц насос не морит
|
||||
* (замер на загрузке уровня — ни одной пропущенной порции), а трек
|
||||
* титров может и должен доигрывать. */
|
||||
(void)pop_music_load(POP_MUS_STORY_2);
|
||||
pv_result = intro_pv_animated();
|
||||
if (pv_result > 0) {
|
||||
@@ -1740,15 +1798,21 @@ uint8_t pop_intro_show(void) __banked
|
||||
* дальше, а не чтобы досидеть до конца трека. */
|
||||
static void pre_cut_finish(uint8_t aborted)
|
||||
{
|
||||
if (!aborted) {
|
||||
intro_skip_t tail;
|
||||
|
||||
intro_skip_begin(&tail);
|
||||
while (pop_music_busy() && !intro_skip_requested(&tail))
|
||||
pop_wait_edge();
|
||||
}
|
||||
/* ТРЕК ПЕРЕЖИВАЕТ СЦЕНУ — как в оригинале. load_intro (seg001:690)
|
||||
* ничего не ждёт: гасит экран и возвращает управление, а звук играет
|
||||
* дальше поверх загрузки уровня и первых секунд игры. Мы вместо этого
|
||||
* ДОСИЖИВАЛИ трек на чёрном экране (27 — 10,7 с против 2,6 с картинки),
|
||||
* то есть дарили игроку восемь секунд пустого экрана, и только потом
|
||||
* глушили. ЗАМЕР 2026-08-28: снято вместе с глушением на загрузке
|
||||
* уровня; если загрузка не укладывается в дедлайн насоса (251 000
|
||||
* тактов), вернуть оба места разом.
|
||||
*
|
||||
* Пропуск сцены по-прежнему обрывает и музыку: игрок нажал клавишу,
|
||||
* чтобы идти дальше, а не чтобы дослушать. */
|
||||
if (aborted) {
|
||||
pop_music_free();
|
||||
pop_sfx_pause();
|
||||
}
|
||||
intro_restore_game_palette();
|
||||
}
|
||||
|
||||
@@ -1756,7 +1820,7 @@ void pop_pre_cutscene_show(pop_pre_cutscene_t scene) __banked
|
||||
{
|
||||
const pop_cs_cmd_t *script = 0;
|
||||
|
||||
pop_sfx_pause();
|
||||
/* Глушение перед чтением трека снято 2026-08-28 (см. замер насоса). */
|
||||
/* МУЗЫКА ПЕРЕЖИВАЕТ СЦЕНУ. Треки заставок между уровнями длиннее самих
|
||||
* сцен (27 — 10,7 с против 2,6 с картинки), и оригинал их не обрывает:
|
||||
* load_intro просто гасит экран и возвращает управление, а звук
|
||||
@@ -1824,7 +1888,7 @@ int pop_intro_hof_bg(uint8_t page) __banked
|
||||
|
||||
void pop_time_expired_show(void) __banked
|
||||
{
|
||||
pop_sfx_pause();
|
||||
/* Глушение снято 2026-08-28 (см. замер насоса). */
|
||||
/* time_expired (seg001:04D3) начинается с play_sound(sound_36). */
|
||||
if (pop_music_load(36) == 0) {
|
||||
(void)pop_sfx_start();
|
||||
@@ -1853,7 +1917,7 @@ void pop_time_expired_show(void) __banked
|
||||
|
||||
void pop_ending_show(void) __banked
|
||||
{
|
||||
pop_sfx_pause();
|
||||
/* Глушение снято 2026-08-28 (см. замер насоса). */
|
||||
/* Экран гасим ПЕРВЫМ действием — как load_intro оригинала, который
|
||||
* начинается с draw_rect(&screen_rect, color_0_black). Иначе загрузка
|
||||
* ресурсов сцены идёт поверх ЖИВОЙ комнаты уровня 14, и она «мелькает»
|
||||
|
||||
@@ -18,18 +18,14 @@
|
||||
#include "pop_guard.h" /* pop_guard_vflip_load — зеркала соперника */
|
||||
#include "pop_arc.h"
|
||||
#include "kid_arc.h"
|
||||
#include "_pop_file.h"
|
||||
|
||||
/* Серии в архиве обязаны лежать подряд — так их кладёт список в Makefile,
|
||||
* и только поэтому индекс считается сложением. Если порядок собьётся,
|
||||
* сборка встанет здесь, а не рассыплется картинкой на экране. */
|
||||
#if (ARC_KID_KID27 - ARC_KID_KID0) != 27
|
||||
#error "kid0..kid27 в архиве не подряд"
|
||||
#if (ARC_KID_SWORD - ARC_KID_KID0_V) != KID_PAGES
|
||||
#error "обычная и зеркальная серии Kid должны иметь одинаковую длину"
|
||||
#endif
|
||||
#if (ARC_KID_KID27_V - ARC_KID_KID0_V) != 27
|
||||
#error "kid0_v..kid27_v в архиве не подряд"
|
||||
#endif
|
||||
|
||||
#define KID_MAXPAGES 28
|
||||
|
||||
/* Таблица архива живёт на стеке вызывающего (4 байта на запись, здесь 232)
|
||||
* — держать её постоянно незачем, а W2 у игры и без того тесный. */
|
||||
@@ -39,7 +35,7 @@ int pop_kid_load(uint8_t npages) __banked
|
||||
pop_arc_t arc;
|
||||
uint8_t i;
|
||||
|
||||
if (npages > KID_MAXPAGES) npages = KID_MAXPAGES;
|
||||
if (npages > KID_PAGES) npages = KID_PAGES;
|
||||
if (pop_arc_open_id(&arc, POP_ARC_KID, tbl, ARC_KID_COUNT) < 0) return -1;
|
||||
for (i = 0; i < npages; i++)
|
||||
if (pop_arc_atlas(&arc, tbl, (uint8_t)(ARC_KID_KID0 + i),
|
||||
@@ -75,7 +71,8 @@ int pop_kid_data_load(const char *path) __banked
|
||||
blk=mem_alloc_pages(1);
|
||||
if (!blk) return -1;
|
||||
page=mem_get_page(blk,0);
|
||||
n=bank_load_file(page,KD_DATA_OFF,path,16384-KD_DATA_OFF);
|
||||
POP_PATH_CALL(n, bank_load_file(page,KD_DATA_OFF,path,
|
||||
16384-KD_DATA_OFF));
|
||||
if (n < (int)(KID_BIN_SEQTBL_OFF + KID_SEQTBL_LEN)) {
|
||||
mem_free_block(blk); return -1;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "pop_map.h" /* pop_clip_char_top (clip_char) */
|
||||
#include "pop_geom.h"
|
||||
#include "pop_level.h" /* POP_LEVEL_LAST — level 15 исключён */
|
||||
#include "pop_cutscene.h" /* POP_T60 — тики оригинала в кадры луча */
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sprinter_mem.h>
|
||||
@@ -42,8 +43,7 @@ uint8_t pop_next_level;
|
||||
* НЕ static: набор спрайтов слота POP_CH_KID общей отрисовки персонажей
|
||||
* (pop_cdraw.c, банк 4). Отдан как есть, а не через аксессор: читается раз
|
||||
* в кадр из другого банка, и вызов стоил бы дороже самого чтения. */
|
||||
#define KID_MAXPAGES 28
|
||||
atlas_t kidp[KID_MAXPAGES];
|
||||
atlas_t kidp[KID_PAGES];
|
||||
uint8_t kid_npages;
|
||||
|
||||
/* Таблица «seq_id -> адрес в seqtbl» (original_seqtbl_offsets). Определена
|
||||
@@ -358,7 +358,11 @@ void play_seq(void)
|
||||
else if (ev == 4) {
|
||||
/* seg006:646. На четвёртом уровне уход за дверь — это
|
||||
* встреча с тенью, и трек там свой; на 13-м (Джафар) и
|
||||
* 15-м оригинал молчит. */
|
||||
* 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);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,13 @@
|
||||
#include <stdint.h>
|
||||
#include "pop_char.h"
|
||||
#include "kid_data.h" /* kframe — тип кадра для pop_sword_draw */
|
||||
#include "kid_arc.h" /* границы серий атласов в kid.arc */
|
||||
#include <sprite.h> /* atlas_t — набор спрайтов Кида (см. kidp ниже) */
|
||||
|
||||
/* Число страниц Кида берём из метаданных архива: зеркальная серия начинается
|
||||
* сразу после обычной. Не дублировать литералом в загрузчике/хранилище. */
|
||||
#define KID_PAGES (ARC_KID_KID0_V - ARC_KID_KID0)
|
||||
|
||||
/* Состояние персонажа — общий тип для Кида, стража и Char (pop_char.h). */
|
||||
extern pop_char_t Kid;
|
||||
|
||||
@@ -26,6 +31,7 @@ extern int8_t knock;
|
||||
* пройден». Читает и обнуляет главный цикл. Пока уровень один, поэтому
|
||||
* там рестарт того же уровня; загрузка следующего — levels_plan.md. */
|
||||
extern uint8_t pop_next_level;
|
||||
/* Задержка смены уровня на время мелодии конца (см. pop_kid.c). */
|
||||
|
||||
/* Загрузить npages атласов Kid (kid0..kid{npages-1}.atl). ДО initgraph.
|
||||
* 0 — OK, -1 — ошибка. */
|
||||
|
||||
@@ -45,7 +45,7 @@ extern uint8_t pop_current_level;
|
||||
void pop_level_qsave(pop_qs_io_t *io);
|
||||
|
||||
/* Загрузить уровень НОМЕРОМ: освободить страницу прошлого, взять
|
||||
* `LEVELS\res20NN.bin` (fallback `a:\res20NN.bin`) и выставить
|
||||
* `LEVELS\res20NN.bin` относительно каталога EXE и выставить
|
||||
* pop_current_level. 0 — OK, -1 — файла нет / не влез (тогда
|
||||
* pop_current_level и страница НЕ тронуты — играем дальше на старом).
|
||||
* Порт связки load_lev_spr + load_level (seg000:1098/1169). */
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "pop_level.h"
|
||||
#include "_pop_level.h"
|
||||
#include "pop_geom.h" /* pop_x_bump / FIRST_ONSCREEN_COLUMN — pos_guards */
|
||||
#include "_pop_file.h"
|
||||
|
||||
/* Низкоуровневое чтение через W3 выполняет резидентный bank_load_file();
|
||||
* остальная одноразовая подготовка уровня безопасно остаётся в банке 8. */
|
||||
@@ -58,7 +59,8 @@ static int level_read_file(const char *path)
|
||||
blk=mem_alloc_pages(1);
|
||||
if (!blk) return -1;
|
||||
page=mem_get_page(blk,0);
|
||||
n=bank_load_file(page,LVL_DATA_OFF,path,16384-LVL_DATA_OFF);
|
||||
POP_PATH_CALL(n, bank_load_file(page,LVL_DATA_OFF,path,
|
||||
16384-LVL_DATA_OFF));
|
||||
if (n < (int)MIN_SIZE) { mem_free_block(blk); return -1; }
|
||||
gfx_w0_page_prepare(page);
|
||||
gfx_w0_map(page);
|
||||
@@ -80,18 +82,17 @@ int pop_level_load_num(uint8_t n) __banked
|
||||
/* Имена патчим на месте, а не через sprintf: printf-семейство тянет в
|
||||
* резидент W1 форматтер, которого здесь больше негде применить. */
|
||||
static char pri[] = "LEVELS\\res2000.bin"; /* цифры — [12],[13] */
|
||||
static char alt[] = "a:\\res2000.bin"; /* цифры — [8],[9] */
|
||||
uint8_t saved_blk = pop_lvl_blk, saved_page = pop_lvl_page, saved_ok = pop_lvl_ok;
|
||||
|
||||
if (n > POP_LEVEL_LAST) return -1;
|
||||
pri[12] = alt[8] = (char)('0' + n / 10);
|
||||
pri[13] = alt[9] = (char)('0' + n % 10);
|
||||
pri[12] = (char)('0' + n / 10);
|
||||
pri[13] = (char)('0' + n % 10);
|
||||
|
||||
/* Старую страницу отпускаем ТОЛЬКО после успешной загрузки новой:
|
||||
* level_read_file сам аллоцирует страницу, и если файла нет — остаёмся
|
||||
* на текущем уровне вместо падения в пустой уровень. */
|
||||
pop_lvl_ok = 0; /* чтобы level_load_path не мешался */
|
||||
if (level_read_file(pri) != 0 && level_read_file(alt) != 0) {
|
||||
if (level_read_file(pri) != 0) {
|
||||
pop_lvl_blk = saved_blk; pop_lvl_page = saved_page; pop_lvl_ok = saved_ok;
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
#include "pop_settings.h"
|
||||
#include "pop_cfg.h"
|
||||
#include "pop_qsave.h"
|
||||
#include "pop_music.h" /* pop_music_service — подкачка кольца, см. ui_wait_frame */
|
||||
#include "pop_status.h"
|
||||
#include "pop_sfx.h"
|
||||
|
||||
@@ -196,10 +197,21 @@ static uint8_t ui_begin(void)
|
||||
gfx_rect_t full;
|
||||
uint8_t back = (uint8_t)(gfx_get_visible_page() ^ 1);
|
||||
|
||||
/* Полностраничная accel-копия держит DI дольше половины CBL-буфера.
|
||||
* Закрываем вывод, иначе железо повторяет старые 128 байт (скрежет).
|
||||
* ui_end откроет его уже с заново заполненной тишиной. */
|
||||
pop_sfx_pause();
|
||||
/* ЗВУК ЗДЕСЬ БОЛЬШЕ НЕ ГЛУШИМ (снято 2026-08-28).
|
||||
*
|
||||
* Был вызов pop_sfx_pause() с обоснованием «полностраничная accel-копия
|
||||
* держит DI дольше половины CBL-буфера, иначе железо повторяет старые
|
||||
* 128 байт». Обоснование устарело: лист, который зовёт gfx_copy_page
|
||||
* (_bgi_scroll_rows_raw), режет DI бандами по 16 строк — это ~1,6 мс
|
||||
* против дедлайна насоса 11,7 мс, и окно прерываний между бандами есть.
|
||||
* Тот же полностраничный копир каждый кадр делают игровой цикл и
|
||||
* катсцены, и звук там не рвётся.
|
||||
*
|
||||
* А цена была слышимой: pause закрывает CBL целиком (cbl_close), курсор
|
||||
* трека остаётся на месте, и короткая мелодия начала уровня замирала
|
||||
* ровно на время перерисовки меню — «тормозится на кусочек» (жалоба
|
||||
* пользователя 2026-08-28). У оригинала такого нет и быть не может:
|
||||
* там нет насоса, который надо кормить руками. */
|
||||
gfx_set_draw_page(back);
|
||||
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
|
||||
gfx_copy_page(&full, GFX_COPY_DIRECT);
|
||||
@@ -207,10 +219,32 @@ static uint8_t ui_begin(void)
|
||||
return back;
|
||||
}
|
||||
|
||||
/* КАДР МЕНЮ: перед ожиданием фронта — обслужить музыку.
|
||||
*
|
||||
* Насос CBL живёт в прерывании и главный цикл ему не нужен, НО потоковый
|
||||
* трек (титульная тема играется КОЛЬЦОМ, pop_music.c) дочитывается с диска
|
||||
* именно из pop_music_service: очередная страница попадает в слот, который
|
||||
* насос уже прошёл. Пока открыто меню, главный цикл стоит, и без этого
|
||||
* вызова кольцо опустошалось за несколько секунд — музыка замолкала до
|
||||
* закрытия меню, а на перемещениях по пунктам это слышно как паузы
|
||||
* (жалоба пользователя 2026-08-28).
|
||||
*
|
||||
* Место — рядом с ожиданием кадра: получается тот же темп 50 раз в
|
||||
* секунду, что и в игровом цикле, и подкачка не чаще, чем нужна. */
|
||||
static void ui_wait_frame(void)
|
||||
{
|
||||
pop_music_service();
|
||||
pop_wait_edge();
|
||||
}
|
||||
|
||||
static void ui_end(uint8_t back)
|
||||
{
|
||||
pop_wait_edge(); /* готовый кадр показать только на фронте */
|
||||
ui_wait_frame(); /* готовый кадр показать только на фронте */
|
||||
gfx_set_visible_page(back);
|
||||
/* Парный к снятому pop_sfx_pause (см. ui_begin) вызов оставлен: он
|
||||
* идемпотентен (открытый CBL возвращает 0 сразу) и чинит случай, когда
|
||||
* в меню вошли при закрытом выводе — например сразу после загрузки
|
||||
* уровня, которая глушит звук намеренно. */
|
||||
(void)pop_sfx_start();
|
||||
}
|
||||
|
||||
@@ -227,7 +261,7 @@ static void ui_wait_release(void)
|
||||
kbd_raw_down(KBD_SPACE) ||
|
||||
kbd_raw_down(KBD_UP) || kbd_raw_down(KBD_DOWN) ||
|
||||
kbd_raw_down(KBD_LEFT) || kbd_raw_down(KBD_RIGHT))
|
||||
pop_wait_edge();
|
||||
ui_wait_frame();
|
||||
}
|
||||
|
||||
/* Действие = Enter ИЛИ Space: в оригинале выбор подтверждается обеими, и
|
||||
@@ -250,7 +284,7 @@ static void ui_wait_ack(void)
|
||||
return;
|
||||
}
|
||||
esc_prev = esc; act_prev = act;
|
||||
pop_wait_edge();
|
||||
ui_wait_frame();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,7 +512,7 @@ static uint8_t ui_confirm(const char *question)
|
||||
confirm_draw(question, sel);
|
||||
menu_sound(MENU_SND_MOVE);
|
||||
}
|
||||
pop_wait_edge();
|
||||
ui_wait_frame();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,10 +556,12 @@ static uint8_t settings_finish(uint8_t changed)
|
||||
int8_t rc;
|
||||
if (!changed) return 0;
|
||||
pop_settings_apply(1);
|
||||
/* ESTEX может уйти в диск на много CBL-периодов. */
|
||||
pop_sfx_pause();
|
||||
/* Звук не глушим: POP.CFG — десятки байт, а на практике HOF пишется
|
||||
* под звучащий «won» и насос это переживает (проверка пользователя
|
||||
* 2026-08-28). Прежняя осторожность «ESTEX может уйти в диск на много
|
||||
* CBL-периодов» относилась к загрузке НАБОРА страниц, а не к записи
|
||||
* одного мелкого файла. */
|
||||
rc = pop_cfg_save();
|
||||
(void)pop_sfx_start();
|
||||
ui_notice(rc > 0 ? "SETTINGS SAVED" : "SAVE ERROR");
|
||||
return 1;
|
||||
}
|
||||
@@ -609,17 +645,16 @@ static uint8_t settings_process(void)
|
||||
settings_draw(sel);
|
||||
menu_sound(sound);
|
||||
}
|
||||
pop_wait_edge();
|
||||
ui_wait_frame();
|
||||
}
|
||||
}
|
||||
|
||||
static int8_t menu_quickload(void)
|
||||
{
|
||||
uint8_t slots;
|
||||
/* probe делает open/close на D:, поэтому насос в это время выключен. */
|
||||
pop_sfx_pause();
|
||||
/* probe — это только open/close на D:, данных не читает; глушить насос
|
||||
* ради него незачем (см. settings_finish). */
|
||||
slots = pop_qsave_probe();
|
||||
(void)pop_sfx_start();
|
||||
if (slots & POP_QS_SLOT_SAVE) return POP_MENU_QUICKLOAD;
|
||||
if ((slots & POP_QS_SLOT_BACKUP) && ui_confirm("LOAD BACKUP?"))
|
||||
return POP_MENU_QUICKLOAD_BACKUP;
|
||||
@@ -641,14 +676,14 @@ static void menu_erase(uint8_t entry_page)
|
||||
/* Из shadow видимой страницы берём кадр без текстов. */
|
||||
gfx_set_draw_page(hidden);
|
||||
gfx_copy_page(&full, GFX_COPY_DIRECT);
|
||||
pop_wait_edge();
|
||||
ui_wait_frame();
|
||||
gfx_set_visible_page(hidden);
|
||||
|
||||
/* Теперь чистая hidden — источник для второй страницы. */
|
||||
gfx_set_draw_page(visible);
|
||||
gfx_copy_page(&full, GFX_COPY_DIRECT);
|
||||
if (entry_page != hidden) {
|
||||
pop_wait_edge();
|
||||
ui_wait_frame();
|
||||
gfx_set_visible_page(entry_page);
|
||||
}
|
||||
}
|
||||
@@ -679,13 +714,18 @@ int8_t pop_menu_process(void) __banked
|
||||
|
||||
/* Открыли по НАЖАТИЮ Esc: дождаться отпускания, иначе то же нажатие
|
||||
* закроет меню раньше первого кадра. pop_wait_edge опрашивает FIFO. */
|
||||
while (kbd_raw_down(KBD_ESC)) pop_wait_edge();
|
||||
while (kbd_raw_down(KBD_ESC)) ui_wait_frame();
|
||||
|
||||
entry_page = gfx_get_visible_page();
|
||||
saved_page = gfx_get_draw_page();
|
||||
saved_bank = gfx_get_bank();
|
||||
getfillsettings(&saved_fill);
|
||||
pop_sfx_pause(); /* snapshot/dim + первый full redraw */
|
||||
/* Звук НЕ глушим (снято 2026-08-28, вторая половина той же заплатки —
|
||||
* см. ui_begin). Здесь под ней были снимок палитры, затемнение фона и
|
||||
* первая полная перерисовка: копия режет DI бандами по 16 строк, а
|
||||
* палитровое затемнение идёт и в титрах с катсценами, где музыку оно не
|
||||
* рвёт. Цена глушения была слышна как пауза в мелодии ровно на
|
||||
* появление меню. */
|
||||
pop_pal_snapshot();
|
||||
/* 50 % яркости; диапазон UI остаётся ярким. Долю берём от шкалы, а не
|
||||
* числом: ступеней стало 32 вместо четырёх, и прежняя «2» означала бы
|
||||
@@ -742,11 +782,12 @@ int8_t pop_menu_process(void) __banked
|
||||
menu_draw(sel);
|
||||
menu_sound(sound);
|
||||
}
|
||||
pop_wait_edge();
|
||||
ui_wait_frame();
|
||||
pop_pace_end(pop_pace_n(0)); /* игра стоит — обычный темп */
|
||||
}
|
||||
|
||||
pop_sfx_pause(); /* две full-page копии menu_erase */
|
||||
/* И на закрытии не глушим: menu_erase — те же две полностраничные
|
||||
* копии, что и в ui_begin, с DI бандами по 16 строк. */
|
||||
menu_erase(entry_page);
|
||||
/* menu_erase вернул обе страницы из тени, снятой ДО меню: всё, что
|
||||
* статус-полосы нарисовали за это время (или сообщение, дотикавшее в
|
||||
|
||||
@@ -13,13 +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, для
|
||||
@@ -49,8 +117,12 @@ static uint8_t ld_busy;
|
||||
static int ld_fd;
|
||||
static uint8_t ld_slot;
|
||||
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)
|
||||
{
|
||||
@@ -110,26 +182,31 @@ void pop_music_free(void) __banked
|
||||
* ПОСТРАНИЧНО, А НЕ ЦЕЛИКОМ. Реплики сцены с принцессой идут встык, и
|
||||
* следующую надо читать, пока звучит текущая. Трек — до 320 КБ, целиком
|
||||
* это 0,7 с диска: на 7,5 кадрах в секунду сцена встала бы на пять кадров.
|
||||
* Одна страница стоит 33 мс — меньше четверти кадра, и подкачка растворя-
|
||||
* ется в анимации. Кто может позволить себе паузу (титры, чёрный экран
|
||||
* Шаг стоит 16 мс (полстраницы, см. pop_music_load_step) — меньше одного
|
||||
* кадрового интервала, и подкачка растворяется в анимации. Кто может позволить себе паузу (титры, чёрный экран
|
||||
* между сценами), зовёт pop_music_load — тот же цикл, но целиком. */
|
||||
static void load_abort(void)
|
||||
{
|
||||
if (ld_busy) close(ld_fd);
|
||||
ld_busy = 0;
|
||||
ld_half = 0;
|
||||
slot_release(ld_slot);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/* Грузим ВСЕГДА в не текущий слот: играющий трек читает свои страницы
|
||||
* из прерывания, и отобрать их у него нельзя. */
|
||||
@@ -139,36 +216,49 @@ int8_t pop_music_load_begin(uint8_t id) __banked
|
||||
/* Трек лежит ОДНИМ файлом, и fd остаётся открытым до последней
|
||||
* страницы: `open` стоит дороже самого чтения (51,4 мс против 32,6 мс
|
||||
* на 16 КБ), и платить его на каждую страницу — чистые потери. */
|
||||
ld_fd = open(name, O_RDONLY);
|
||||
POP_PATH_CALL(ld_fd, open(name, O_RDONLY));
|
||||
if (ld_fd < 0) return -1;
|
||||
slot_blk[n] = mem_alloc_pages(pages);
|
||||
if (!slot_blk[n]) { close(ld_fd); return -1; }
|
||||
ld_busy = 1;
|
||||
ld_slot = n;
|
||||
ld_next = 0;
|
||||
ld_half = 0;
|
||||
ld_pages = pages;
|
||||
ld_id = id;
|
||||
ld_blocks = info.blocks;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ШАГ = ПОЛСТРАНИЦЫ (8 КБ, ~16 мс), а не страница (~33 мс).
|
||||
*
|
||||
* Шаг вызывают раз в кадр из циклов, где кадр разложен на блоки по одному
|
||||
* кадровому интервалу (20 мс): катсцены после перехода на блочную
|
||||
* отрисовку, см. pop_intro.c/pv_restore_bg. Целая страница в интервал не
|
||||
* влезала и незаметно растягивала кадр сцены; половина влезает, а цена —
|
||||
* вдвое больше вызовов (11 страниц реплики = 22 кадра вместо 11, то есть
|
||||
* меньше полусекунды). Игровому циклу это безразлично: там логический
|
||||
* кадр 80-100 мс. */
|
||||
int8_t pop_music_load_step(void) __banked
|
||||
{
|
||||
uint8_t p;
|
||||
|
||||
if (!ld_busy) return 1; /* нечего грузить */
|
||||
p = mem_get_page(slot_blk[ld_slot], ld_next);
|
||||
/* Последняя страница короче — читаем сколько есть; ошибку ловим по
|
||||
* отрицательному коду, а не по «не 16384». */
|
||||
if (!p || bank_read_page(ld_fd, p, 0, 16384) < 0) {
|
||||
/* Последняя страница короче — читаем сколько есть (вторая половина
|
||||
* может дать и ноль байт); ошибку ловим по отрицательному коду. */
|
||||
if (!p || bank_read_page(ld_fd, p, ld_half ? 8192 : 0, 8192) < 0) {
|
||||
load_abort();
|
||||
return -1;
|
||||
}
|
||||
if (!ld_half) { ld_half = 1; return 0; } /* дочитаем в следующий раз */
|
||||
ld_half = 0;
|
||||
if (++ld_next < ld_pages) return 0;
|
||||
|
||||
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;
|
||||
@@ -214,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;
|
||||
@@ -268,19 +358,25 @@ 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);
|
||||
ring_fd = open(name, O_RDONLY);
|
||||
POP_PATH_CALL(ring_fd, open(name, O_RDONLY));
|
||||
if (ring_fd < 0) return -1;
|
||||
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++) {
|
||||
@@ -360,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);
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
#include "pop_guard.h"
|
||||
#include "pop_pal.h"
|
||||
#include "pop_ui.h" /* POP_COL_OUTSIDE */
|
||||
#include "_pop_file.h"
|
||||
|
||||
static void pal_file_load(const char *path, const char *fallback)
|
||||
static void pal_file_load(const char *path)
|
||||
{
|
||||
if (gfx_pal_fload(0, path) < 0) gfx_pal_fload(0, fallback);
|
||||
int rc;
|
||||
POP_PATH_CALL(rc, gfx_pal_fload(0, path));
|
||||
gfx_pal_sync();
|
||||
pop_ui_palette_snapshot();
|
||||
}
|
||||
@@ -84,7 +86,7 @@ void pop_pal_menu_dim(uint8_t fade) __banked
|
||||
|
||||
void pop_pal_game_load(void) __banked
|
||||
{
|
||||
pal_file_load("KID\\kid.pal", "a:\\kid.pal");
|
||||
pal_file_load("KID\\kid.pal");
|
||||
/* kid.pal содержит все 256 записей и намеренно затирает эти три
|
||||
* динамических диапазона (тайлсет, Тень, набор соперника).
|
||||
* Восстановить их надо ДО snapshot. */
|
||||
@@ -110,7 +112,7 @@ void pop_pal_level_load(uint8_t full) __banked
|
||||
|
||||
void pop_pal_title_load(void) __banked
|
||||
{
|
||||
pal_file_load("TITLE\\title.pal", "a:\\title.pal");
|
||||
pal_file_load("TITLE\\title.pal");
|
||||
}
|
||||
|
||||
/* ЕДИНСТВЕННОЕ ОТЛИЧИЕ ФИНАЛА ОТ ИНТРО — цвет фона текстовой рамки.
|
||||
@@ -120,7 +122,7 @@ void pop_pal_title_load(void) __banked
|
||||
* файла и до нового снимка — как тайлсет/Тень в pop_pal_game_load. */
|
||||
void pop_pal_story_load(uint8_t ending) __banked
|
||||
{
|
||||
pal_file_load("PV\\story.pal", "a:\\story.pal");
|
||||
pal_file_load("PV\\story.pal");
|
||||
if (!ending) return;
|
||||
gfx_pal_set(0, POP_PAL_STORY_BG, 0x80, 0, 0);
|
||||
gfx_pal_set(1, POP_PAL_STORY_BG, 0x80, 0, 0);
|
||||
@@ -129,5 +131,5 @@ void pop_pal_story_load(uint8_t ending) __banked
|
||||
|
||||
void pop_pal_pv_load(void) __banked
|
||||
{
|
||||
pal_file_load("PV\\pv.pal", "a:\\pv.pal");
|
||||
pal_file_load("PV\\pv.pal");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* pop_path_bank.c — каталог приложения и восстановление cwd, BANK10.
|
||||
*
|
||||
* APPINFO B=1 возвращает абсолютный каталог запущенного EXE вместе с диском.
|
||||
* Получаем его один раз при старте. Буфер объявлен const намеренно: у
|
||||
* Sprinter банковые страницы — RAM, а --constseg кладёт объект в BANK10,
|
||||
* не расходуя дефицитную резидентную _DATA в W2. Записывает его только
|
||||
* ESTEX во время pop_path_init().
|
||||
*
|
||||
* ABI: публичные функции __banked; их вызывает POP_PATH_CALL из банка
|
||||
* файлового клиента. После возврата трамплин восстанавливает прежнюю W3,
|
||||
* поэтому переданный самой файловой операции указатель снова доступен.
|
||||
*/
|
||||
#include <stdint.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "_pop_file.h"
|
||||
|
||||
static const char pop_app_dir[256] = { 0 };
|
||||
|
||||
/* ESTEX APPINFO ($47), B=1: HL=буфер, CF/A=ошибка. */
|
||||
static int appinfo_dir(const char *buf) __naked
|
||||
{
|
||||
(void)buf;
|
||||
__asm
|
||||
push ix
|
||||
ld b, #1
|
||||
ld c, #0x47
|
||||
rst #0x10
|
||||
pop ix
|
||||
jr c, _paid_err
|
||||
ld de, #0
|
||||
ret
|
||||
_paid_err:
|
||||
call __errno_set
|
||||
ld de, #-1
|
||||
ret
|
||||
__endasm;
|
||||
}
|
||||
|
||||
int8_t pop_path_init(void) __banked
|
||||
{
|
||||
const char *dir = pop_app_dir;
|
||||
|
||||
if (appinfo_dir(dir) != 0 || !dir[0]) return -1;
|
||||
return (int8_t)chdir(dir);
|
||||
}
|
||||
|
||||
int8_t pop_chdir_home(void) __banked
|
||||
{
|
||||
return (int8_t)chdir((const char *)pop_app_dir);
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
#include "pop_pace.h"
|
||||
#include "pop_timer.h"
|
||||
#include "sprpop_cold.h"
|
||||
#include "_pop_file.h"
|
||||
|
||||
#define QS_VERSION 4
|
||||
#define QS_SAVE 1
|
||||
@@ -154,6 +155,7 @@ static int8_t qs_save(uint8_t page)
|
||||
uint8_t tail[4];
|
||||
const uint8_t h[7]={'P','O','P','Q',QS_VERSION,pop_current_level,0};
|
||||
uint16_t size;
|
||||
int rc;
|
||||
qs_init(&io,page,QS_LIMIT,0,0);
|
||||
pop_qs_raw_put(&io,h,sizeof(h));
|
||||
qs_payload(&io);
|
||||
@@ -162,14 +164,18 @@ static int8_t qs_save(uint8_t page)
|
||||
tail[2]=(uint8_t)io.checksum; tail[3]=(uint8_t)(io.checksum >> 8);
|
||||
pop_qs_raw_put(&io,tail,4); size=(uint16_t)(io.pos-QS_BASE);
|
||||
if (io.failed) return -1;
|
||||
unlink("POP.NEW");
|
||||
if (bank_save_file(page,QS_BASE,"POP.NEW",size) != size) {
|
||||
unlink("POP.NEW"); return -1;
|
||||
POP_PATH_CALL(rc, unlink("POP.NEW"));
|
||||
POP_PATH_CALL(rc, bank_save_file(page,QS_BASE,"POP.NEW",size));
|
||||
if (rc != size) {
|
||||
POP_PATH_CALL(rc, unlink("POP.NEW"));
|
||||
return -1;
|
||||
}
|
||||
unlink("POP.BAK");
|
||||
(void)rename("POP.SAV", "POP.BAK");
|
||||
if (rename("POP.NEW", "POP.SAV") != 0) {
|
||||
(void)rename("POP.BAK", "POP.SAV"); return -1;
|
||||
POP_PATH_CALL(rc, unlink("POP.BAK"));
|
||||
POP_PATH_CALL(rc, rename("POP.SAV", "POP.BAK"));
|
||||
POP_PATH_CALL(rc, rename("POP.NEW", "POP.SAV"));
|
||||
if (rc != 0) {
|
||||
POP_PATH_CALL(rc, rename("POP.BAK", "POP.SAV"));
|
||||
return -1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
@@ -178,7 +184,8 @@ static int8_t qs_load_one(const char *name, uint8_t page)
|
||||
{
|
||||
uint8_t level;
|
||||
uint16_t payload_size;
|
||||
int size=bank_load_file(page,QS_BASE,name,QS_LIMIT);
|
||||
int size;
|
||||
POP_PATH_CALL(size, bank_load_file(page,QS_BASE,name,QS_LIMIT));
|
||||
if (size <= 0 || !qs_validate_page(page,(uint16_t)size,&level,
|
||||
&payload_size)) return -1;
|
||||
if (level != pop_current_level) {
|
||||
@@ -205,11 +212,11 @@ uint8_t pop_qsave_probe(void) __banked
|
||||
page = mem_get_page(blk, 0);
|
||||
gfx_w0_page_prepare(page);
|
||||
|
||||
size = bank_load_file(page, QS_BASE, "POP.SAV", QS_LIMIT);
|
||||
POP_PATH_CALL(size, bank_load_file(page, QS_BASE, "POP.SAV", QS_LIMIT));
|
||||
if (size > 0 && qs_validate_page(page, (uint16_t)size, &level,
|
||||
&payload_size))
|
||||
slots |= POP_QS_SLOT_SAVE;
|
||||
size = bank_load_file(page, QS_BASE, "POP.BAK", QS_LIMIT);
|
||||
POP_PATH_CALL(size, bank_load_file(page, QS_BASE, "POP.BAK", QS_LIMIT));
|
||||
if (size > 0 && qs_validate_page(page, (uint16_t)size, &level,
|
||||
&payload_size))
|
||||
slots |= POP_QS_SLOT_BACKUP;
|
||||
@@ -234,14 +241,16 @@ int8_t pop_qsave_process(void) __banked
|
||||
if (!blk) return -1;
|
||||
page=mem_get_page(blk,0);
|
||||
gfx_w0_page_prepare(page);
|
||||
pop_sfx_pause();
|
||||
/* Звук не глушим (2026-08-28). Единственное место из снятых, где по
|
||||
* диску реально едут 16 КБ (~33 мс, три периода насоса): если на F6/F9
|
||||
* появится скрежет — вернуть сюда pop_sfx_pause/pop_sfx_start, это
|
||||
* лечится точечно и только здесь. */
|
||||
if (request == QS_SAVE) rc = qs_save(page);
|
||||
else if (request == QS_LOAD_BACKUP) rc = qs_load_one("POP.BAK", page);
|
||||
else {
|
||||
rc = qs_load_one("POP.SAV",page);
|
||||
if (rc < 0) rc = qs_load_one("POP.BAK",page);
|
||||
}
|
||||
pop_sfx_start();
|
||||
mem_free_block(blk);
|
||||
/* Успех ничего не меняет: нужная строка висит с начала операции.
|
||||
* Отказ переписывает её на NO QUICKSAVE/NO QUICKLOAD — обычной
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
#include "pop_kid.h" /* Kid.curr_col/curr_row — порядок оверлеев */
|
||||
#include "pop_cdraw.h" /* pop_cd — габарит кадра Кида (y-порядок плиты) */
|
||||
#include "pop_map.h" /* pop_upside — переворот (зелье инверсии) */
|
||||
#include "_pop_file.h"
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
@@ -83,7 +84,8 @@ static const char *bg_path(const char *name)
|
||||
static int bg_load_tile_pal(const char *name)
|
||||
{
|
||||
uint8_t buf[64];
|
||||
int fd = open(bg_path(name), O_RDONLY);
|
||||
int fd;
|
||||
POP_PATH_CALL(fd, open(bg_path(name), O_RDONLY));
|
||||
if (fd < 0) return -1;
|
||||
if (read(fd, buf, 64) != 64) { close(fd); return -1; }
|
||||
gfx_pal_load(0, POP_PAL_ENV, 16, buf);
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
#include "pop_music.h"
|
||||
#include "_pop_sfx.h"
|
||||
|
||||
/* Физические страницы набора; заполняет холодная половина. */
|
||||
uint8_t pop_snd_page[POP_SND_PAGES];
|
||||
uint8_t pop_snd_ok;
|
||||
|
||||
/* ХОЧЕТ ЛИ ЗВУКА ПОЛЬЗОВАТЕЛЬ (Ctrl+S) — отдельно от pop_snd_ok.
|
||||
@@ -169,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;
|
||||
|
||||
@@ -5,21 +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"
|
||||
#define POP_SND_FILES /* имена файлов нужны ТОЛЬКО здесь, см. pop_sound_tbl.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).
|
||||
*
|
||||
@@ -29,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) {
|
||||
@@ -48,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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,6 +16,8 @@
|
||||
#include "pop_arc.h"
|
||||
#include "pop_ui.h"
|
||||
#include "pop_status.h"
|
||||
#include "pop_music.h" /* pop_music_service — подкачка кольца в долгих фейдах */
|
||||
#include "_pop_file.h"
|
||||
|
||||
typedef struct {
|
||||
uint8_t base;
|
||||
@@ -53,8 +55,10 @@ static uint8_t text_index(const pop_font_desc_t *font, uint8_t ch)
|
||||
|
||||
int pop_ui_init(void) __banked
|
||||
{
|
||||
int rc;
|
||||
if (font_ready) return 0;
|
||||
if (atlas_load(&font_at, "FONT\\font.atl") != 0) return -1;
|
||||
POP_PATH_CALL(rc, atlas_load(&font_at, "FONT\\font.atl"));
|
||||
if (rc != 0) return -1;
|
||||
font_ready = 1;
|
||||
return 0;
|
||||
}
|
||||
@@ -241,8 +245,10 @@ void pop_ui_palette_black(void) __banked
|
||||
uint8_t *black = pop_pal_buf;
|
||||
|
||||
/* BIOS принимает максимум 64 записи за вызов; одним буфером покрываем
|
||||
* все четыре четверти палитры, не занимая постоянную память. */
|
||||
for (i = 0; i < sizeof(black); i++) black[i] = 0;
|
||||
* все четыре четверти палитры, не занимая постоянную память.
|
||||
* sizeof(black) здесь нельзя: black — 16-битный указатель, и тогда
|
||||
* обнулятся лишь первые два байта, а цвет заливки останется случайным. */
|
||||
for (i = 0; i < sizeof(pop_pal_buf); i++) black[i] = 0;
|
||||
for (chunk = 0; chunk < 4; chunk++) {
|
||||
uint8_t start = (uint8_t)(chunk << 6);
|
||||
gfx_pal_load(0, start, 64, black);
|
||||
@@ -292,6 +298,11 @@ static uint8_t fade_run(uint8_t frames, uint8_t up, uint8_t skip)
|
||||
pop_ui_palette_dim(step, 0);
|
||||
shown = step;
|
||||
}
|
||||
/* Fade — это 2,13 с, и всё это время главный цикл стоит. Кольцо
|
||||
* потокового трека дочитывает pop_music_service, иначе музыка
|
||||
* замолкает прямо посреди перехода (та же причина, что у меню:
|
||||
* ui_wait_frame в pop_menu.c). */
|
||||
pop_music_service();
|
||||
gfx_wait_vsync();
|
||||
if (skip) {
|
||||
kbd_raw_sync();
|
||||
@@ -429,6 +440,7 @@ uint8_t pop_screen_present_ltr(void) __banked
|
||||
stripe.x = x;
|
||||
stripe.w = (uint16_t)((GFX_WIDTH - x < 4) ? GFX_WIDTH - x : 4);
|
||||
gfx_copy_page(&stripe, GFX_COPY_DIRECT);
|
||||
pop_music_service(); /* 80 кадров перехода — см. fade_run */
|
||||
gfx_wait_vsync();
|
||||
}
|
||||
return 0;
|
||||
@@ -461,8 +473,10 @@ int pop_screen_blit_atlas_strips(const char * const *parts,
|
||||
gfx_set_bank(GFX_BANK_NORMAL);
|
||||
for (part = 0; part < count; part++) {
|
||||
atlas_t at;
|
||||
int rc;
|
||||
|
||||
if (atlas_load(&at, parts[part]) != 0) return -1;
|
||||
POP_PATH_CALL(rc, atlas_load(&at, parts[part]));
|
||||
if (rc != 0) return -1;
|
||||
strip_blit(&at, x, &y);
|
||||
}
|
||||
return 0;
|
||||
|
||||
@@ -40,15 +40,6 @@ int pop_vflip_take(const atlas_t *orig, const atlas_t *mirror)
|
||||
return 0;
|
||||
}
|
||||
|
||||
int pop_vflip_add(const atlas_t *orig, const char *path)
|
||||
{
|
||||
atlas_t m;
|
||||
|
||||
if (!orig || vf_n >= VF_MAX) return -1;
|
||||
if (atlas_load(&m, path) != 0) return -1;
|
||||
return pop_vflip_take(orig, &m);
|
||||
}
|
||||
|
||||
uint8_t pop_vflip_page(uint8_t src)
|
||||
{
|
||||
uint8_t i;
|
||||
|
||||
@@ -26,11 +26,7 @@
|
||||
#include <stdint.h>
|
||||
#include <sprite.h>
|
||||
|
||||
/* Загрузить зеркальный файл path и связать его с атласом orig.
|
||||
* 0 — OK, -1 — файла нет / не хватило EMM / таблица полна. */
|
||||
int pop_vflip_add(const atlas_t *orig, const char *path);
|
||||
|
||||
/* То же, но зеркало УЖЕ загружено (например, прочитано из архива): таблица
|
||||
/* Зеркало УЖЕ загружено из архива: таблица
|
||||
* забирает его себе, и дальше он освобождается вместе с остальными. */
|
||||
int pop_vflip_take(const atlas_t *orig, const atlas_t *mirror);
|
||||
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
#include "pop_pal.h" /* lifecycle палитры title/intro/level */
|
||||
#include "pop_hof.h" /* persistent Hall of Fame — bank 9 */
|
||||
#include "pop_ui.h" /* чёрные обе страницы перед сменой палитры */
|
||||
#include "_pop_file.h" /* cwd приложения для старых DSS */
|
||||
|
||||
#include <sprinter_exit.h>
|
||||
#include <unistd.h>
|
||||
|
||||
__sfr __at (0xFE) io_border;
|
||||
|
||||
@@ -260,6 +264,11 @@ int main(void)
|
||||
uint8_t xdec_prev = 0, xinc_prev = 0, upsd_prev = 0;
|
||||
uint8_t failed = 0;
|
||||
|
||||
/* До первой файловой операции запоминаем абсолютный каталог EXE.
|
||||
* Без этого старый DSS теряет последний компонент cwd после OPEN пути
|
||||
* с подкаталогом и следующий файл ищет уровнем выше. */
|
||||
if (pop_path_init() != 0) return 1;
|
||||
|
||||
/* Загрузка ресурсов, палитра, первый экран — В БАНКЕ (sprpop_cold.c):
|
||||
* отрабатывает ровно один раз за игру, а кода там на полкилобайта. */
|
||||
pop_app_reset();
|
||||
@@ -765,10 +774,37 @@ int main(void)
|
||||
/* Смена уровня целиком уехала в банк 8 (pop_level_change): это
|
||||
* событие раз в несколько минут, а кода на нём было под сотню строк
|
||||
* в самом горячем месте программы — резидент W1 у нас в дефиците. */
|
||||
if (!is_demo && pop_next_level != pop_current_level &&
|
||||
pop_level_change(back) != 0) {
|
||||
if (!is_demo && pop_next_level != pop_current_level) {
|
||||
/* КОНЕЦ УРОВНЯ ЖДЁТ МЕЛОДИЮ (порт seg003:387, play_level_2):
|
||||
*
|
||||
* if (next_level == current_level || check_sound_playing())
|
||||
* draw_game_frame(); ... // экран уровня живёт
|
||||
* else { stop_sounds(); return next_level; }
|
||||
*
|
||||
* То есть пройденный уровень остаётся на экране — с анимацией
|
||||
* факелов, идущим таймером и всем прочим — ровно столько,
|
||||
* сколько звучит трек конца уровня; мы же уходили на смену
|
||||
* уровня сразу и обрывали мелодию на первых нотах.
|
||||
*
|
||||
* «Звучит» — это 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;
|
||||
}
|
||||
}
|
||||
if (!is_demo && pop_kid_dead) { /* demo после смерти завершается, без respawn */
|
||||
dead_frames++;
|
||||
/* Просьба нажать кнопку, мигание и таймаут — в банке
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
#include "pop_ctrl.h" /* pop_ctrl_init/close — ввод поднимает и закрывает boot */
|
||||
#include "pop_shadow.h" /* запечённый набор Тени */
|
||||
#include "pop_sfx.h" /* звуковые эффекты через CBL */
|
||||
#include "pop_music.h" /* pop_music_busy/free — ждём трек заставки */
|
||||
#include "pop_vflip.h" /* сброс зеркальных страниц на смене уровня */
|
||||
#include "pop_cheat.h" /* pop_cheats — режим разработки включает pop_boot */
|
||||
#include "pop_pace.h" /* pop_speed_mode — режим скорости в отладочной метке */
|
||||
@@ -226,7 +227,12 @@ int8_t pop_level_change(uint8_t back) __banked
|
||||
* почти всегда — поймано пользователем 2026-08-25. Ветка с
|
||||
* катсценой свободна от этого: pop_pre_cutscene_show глушит
|
||||
* звук первым действием. */
|
||||
pop_sfx_pause();
|
||||
/* Глушение снято 2026-08-28 (замер: загрузка уровня насос не
|
||||
* морит — 500 вызовов подряд, максимум 245 832 такта при
|
||||
* дедлайне 251 000). Прежнее обоснование — скрежет на палитре
|
||||
* через BIOS ($A4 с запрещёнными прерываниями), пойманный на
|
||||
* переходе без катсцены 2026-08-25. Если он вернётся, это
|
||||
* единственное место, куда его надо возвращать. */
|
||||
/* Без fade: старый кадр гасим сразу и обязательно даём
|
||||
* показать один чёрный растр ДО диска/перерисовки. */
|
||||
pop_pal_apply(POP_PAL_DIM_BLACK);
|
||||
@@ -1145,14 +1151,18 @@ int pop_boot(void) __banked
|
||||
/* pop_cheats/speed применит pop_settings_apply после pop_sfx_init. */
|
||||
pop_guard_reset();
|
||||
if (pop_kid_data_load("KID\\kid.ani") != 0) { /* кадры+seqtbl в EMM-странице */
|
||||
printf("kid.ani load failed\n");
|
||||
puts("kid.ani load failed\n");
|
||||
return -1;
|
||||
}
|
||||
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(28) != 0) { /* ВСЕ страницы: бег+стоп+разворот+присед+прыжок */
|
||||
if (pop_kid_load(KID_PAGES) != 0) { /* ВСЕ страницы: бег+стоп+разворот+присед+прыжок */
|
||||
puts("kid atlas load failed");
|
||||
return -1;
|
||||
}
|
||||
@@ -1227,7 +1237,10 @@ int pop_level_switch(void) __banked
|
||||
* дальше, а урон — нет (do_startpos дольёт до потолка). */
|
||||
hitp_beg_lev = hitp_max;
|
||||
pop_checkpoint = 0; /* уровень пройден (seg003:413) */
|
||||
pop_sfx_pause(); /* загрузка уровня — ESTEX надолго, звук глушим */
|
||||
/* ЗАМЕР 2026-08-28: глушение снято ВРЕМЕННО, чтобы измерить
|
||||
* зазоры насоса на загрузке уровня (дедлайн 251 000 тактов).
|
||||
* Если зазоры укладываются — снятие остаётся, и трек заставки
|
||||
* доигрывает поверх загрузки, как в оригинале (seg001:690). */
|
||||
if (pop_level_load_num(pop_next_level) != 0) {
|
||||
pop_next_level = pop_current_level; /* файла нет — остаёмся */
|
||||
} else {
|
||||
@@ -1294,6 +1307,34 @@ int pop_level_switch(void) __banked
|
||||
/* Атомарный commit на границе растра: впервые показать
|
||||
* готовую комнату уже с окончательной палитрой уровня.
|
||||
* Вторую страницу синхронизировать аппаратной копией. */
|
||||
/* ЖДЁМ КОНЦА ТРЕКА ЗАСТАВКИ НА ЧЁРНОМ ЭКРАНЕ — порт
|
||||
* seg003:107:
|
||||
* while (check_sound_playing() && !do_paused()) idle();
|
||||
* stop_sounds();
|
||||
* draw_level_first();
|
||||
* Порядок оригинала важен: загрузка уровня идёт ПОД
|
||||
* звучащий трек, и только потом чернота до его конца. Тогда
|
||||
* общее время черноты = max(трек, загрузка), а не их сумма,
|
||||
* и трек не обрывается на полуслове. Раньше у нас было
|
||||
* наоборот: pre_cut_finish досиживал трек, потом глушил
|
||||
* насос и грузил уровень.
|
||||
*
|
||||
* ЗАМЕР 2026-08-28: 500 подряд вызовов насоса через всю
|
||||
* загрузку — максимальный зазор 245 832 такта при дедлайне
|
||||
* 251 000, ни одной пропущенной порции. Поэтому глушение на
|
||||
* загрузке снято и музыка её переживает.
|
||||
*
|
||||
* Счётчик — страховка от вечного ожидания (потоковый трек,
|
||||
* который никогда не кончится): ~20 с и идём дальше. */
|
||||
{
|
||||
uint16_t mus_guard = 1000;
|
||||
|
||||
while (pop_music_busy() && mus_guard) {
|
||||
mus_guard--;
|
||||
pop_wait_edge();
|
||||
}
|
||||
pop_music_free(); /* порт stop_sounds() */
|
||||
}
|
||||
pop_wait_edge();
|
||||
gfx_set_visible_page(level_prepared_page);
|
||||
gfx_set_draw_page(old_page);
|
||||
|
||||
Executable
+411
@@ -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())
|
||||
@@ -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" # раскладка диска игры
|
||||
|
||||
@@ -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)
|
||||
@@ -450,7 +450,7 @@ def write_header(out_path, env_pages):
|
||||
файлов и палитра. Поэтому здесь два массива имён и один индекс."""
|
||||
lines = [
|
||||
"/* pop_bg_atlas.h — раскладка атласов статического фона PoP.",
|
||||
" * Сгенерировано toolchain/pop_pack_bg.py — НЕ править вручную.",
|
||||
" * Сгенерировано tools/pop_pack_bg.py — НЕ править вручную.",
|
||||
" *",
|
||||
" * Прямая адресация (ноль remap-таблиц в W2):",
|
||||
" * ENV фон id N -> atlas env_bg[N>>%d], idx N&%d" % (ENV_SHIFT, ENV_MASK),
|
||||
@@ -474,27 +474,12 @@ def write_header(out_path, env_pages):
|
||||
"#define POP_PAL_ENV 0x50",
|
||||
"#define POP_PAL_WALL 0x60",
|
||||
"",
|
||||
"/* Имена файлов атласов по тайлсету (грузятся atlas_load). Таблицы",
|
||||
" * видны только тому, кто попросил POP_BG_ATLAS_NAMES: иначе копия",
|
||||
" * строк уедет в каждый включивший заголовок модуль. */",
|
||||
"/* Палитры пока остаются отдельными файлами рядом с bg.arc. Имена",
|
||||
" * видит только модуль загрузки, чтобы не размножать строки. */",
|
||||
"#ifdef POP_BG_ATLAS_NAMES",
|
||||
"static const char *const pop_env_atl[POP_TILESETS][POP_ENV_PAGES] = {",
|
||||
]
|
||||
for prefix, _ in SETS.values():
|
||||
row = ", ".join(('"%s%s"' % (prefix, nm.split("_", 1)[1]) if nm else "0")
|
||||
for nm in env_pages)
|
||||
lines.append(" { %s }," % row)
|
||||
lines += [
|
||||
"};",
|
||||
'static const char *const pop_wall_atl[POP_TILESETS] = { %s };'
|
||||
% ", ".join('"%swall.atl"' % p for p, _ in SETS.values()),
|
||||
'static const char *const pop_fore_atl[POP_TILESETS] = { %s };'
|
||||
% ", ".join('"%sfore.atl"' % p for p, _ in SETS.values()),
|
||||
'static const char *const pop_tile_pal[POP_TILESETS] = { %s };'
|
||||
% ", ".join('"%stile.pal"' % p for p, _ in SETS.values()),
|
||||
"#endif /* POP_BG_ATLAS_NAMES */",
|
||||
'#define POP_POT_ATL "pop_pot.atl" /* chtab_1: зелья, от набора не зависит */',
|
||||
"#define POP_PAL_POT 0x%02X" % POT_PAL_BASE,
|
||||
"/* Пузырёк зелья: красный набор = id 16..22 (кадры оригинала),",
|
||||
" зелёный (перо/переворот) и синий (вред/открыть) = те же кадры",
|
||||
" под id %d..%d и %d..%d (draw_tile_anim, seg008:652). */"
|
||||
@@ -502,7 +487,6 @@ def write_header(out_path, env_pages):
|
||||
POT_BUBBLE_BLUE_BASE, POT_BUBBLE_BLUE_BASE + len(POT_BUBBLE_IDS) - 1),
|
||||
"#define POP_POT_BUBB_GREEN %d" % POT_BUBBLE_GREEN_BASE,
|
||||
"#define POP_POT_BUBB_BLUE %d" % POT_BUBBLE_BLUE_BASE,
|
||||
'#define POP_BG_PAL "pop_bg.pal"',
|
||||
"",
|
||||
"#endif",
|
||||
"",
|
||||
|
||||
@@ -11,7 +11,8 @@ Sprinter 0x70-0x7F (env 0x50 / wall 0x60 заняты фоном). Пиксел
|
||||
фейковые записи (как в фоне). Переиспользует pack_atlas/blob/scale6to8
|
||||
из pop_pack_bg.
|
||||
|
||||
Выход: build/atl/kid/kid0..N.atl + kid.pal + kid_atlas.h
|
||||
Выход: build/atl/kid/kid0..N.atl + зеркальные атласы + kid.pal.
|
||||
Количество страниц рантайм получает из сгенерированного описания kid_arc.h.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -200,21 +201,6 @@ def main():
|
||||
|
||||
build_palette(os.path.join(OUT_DIR, "kid.pal"))
|
||||
|
||||
# C-заголовок
|
||||
hdr = str(P.GEN / "kid_atlas.h")
|
||||
with open(hdr, "w") as f:
|
||||
f.write("/* kid_atlas.h — раскладка атласов Kid. Сгенерировано "
|
||||
"pop_pack_kid.py. */\n#ifndef KID_ATLAS_H\n#define KID_ATLAS_H\n")
|
||||
f.write(f"#define KID_SHIFT {SHIFT}\n#define KID_MASK {MASK}\n")
|
||||
f.write(f"#define KID_PAGES {len(names)}\n#define KID_PAL 0x{PAL_BASE:02X}\n")
|
||||
f.write("static const char *const kid_atl[KID_PAGES] = {\n")
|
||||
for nm in names:
|
||||
f.write(" %s,\n" % ('"%s"' % nm if nm else "0"))
|
||||
f.write('};\n#define KID_PAL_FILE "kid.pal"\n')
|
||||
f.write('#define KID_SWORD_ATL "sword.atl" /* chtab_0: меч в руке */\n')
|
||||
f.write(f"#define KID_SWORD_ID0 {SWORD_IDS[0]} /* индекс в атласе = sword_tbl.id - ID0 */\n")
|
||||
f.write("#endif\n")
|
||||
|
||||
print(f"ИТОГО: {len(ids)} спрайтов Kid, {len(names)} EMM-страниц, "
|
||||
f"{total} Б диск (+1КБ палитра) -> {OUT_DIR}")
|
||||
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -214,7 +214,7 @@ def write_header(path, counts, pal):
|
||||
vals += [b, g, r, 0] # формат gfx_pal_load: B, G, R, 0
|
||||
lines = [
|
||||
"/* pop_shadow_atlas.h — раскладка атласа ТЕНИ.",
|
||||
" * СГЕНЕРЕНО toolchain/pop_pack_shadow.py — НЕ править вручную.",
|
||||
" * СГЕНЕРЕНО tools/pop_pack_shadow.py — НЕ править вручную.",
|
||||
" *",
|
||||
" * Две половины, потому что и у оригинала их две (см. шапку",
|
||||
" * упаковщика): кадры вне боя адресуют chtab_2 (спрайты Кида),",
|
||||
@@ -240,18 +240,6 @@ def write_header(path, counts, pal):
|
||||
"/* Половина «в бою» — SHADOW.DAT (chtab_5, кадры 150..189). */",
|
||||
"#define POP_SHADOW_FGT_PAGES %d" % counts["sf"],
|
||||
"",
|
||||
"static const char *const pop_shadow_kid_atl[POP_SHADOW_KID_PAGES] = {",
|
||||
]
|
||||
lines += [' "SHADOW\\\\sk%d.atl",' % i for i in range(counts["sk"])]
|
||||
lines += [
|
||||
"};",
|
||||
"",
|
||||
"static const char *const pop_shadow_fgt_atl[POP_SHADOW_FGT_PAGES] = {",
|
||||
]
|
||||
lines += [' "SHADOW\\\\sf%d.atl",' % i for i in range(counts["sf"])]
|
||||
lines += [
|
||||
"};",
|
||||
"",
|
||||
"/* 16 цветов, запись 4 байта (B, G, R, 0) — формат gfx_pal_load. */",
|
||||
"static const uint8_t pop_shadow_pal[64] = {",
|
||||
" " + ",".join("0x%02X" % v for v in vals) + ",",
|
||||
|
||||
@@ -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,64 +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.",
|
||||
" * СГЕНЕРЕНО toolchain/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",
|
||||
"",
|
||||
"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 += [
|
||||
"};",
|
||||
"",
|
||||
"/* Имена файлов видит ТОЛЬКО тот, кто попросил POP_SND_FILES (холодная",
|
||||
" * половина): иначе копия строк уезжает в каждый включивший заголовок",
|
||||
" * модуль, а в резиденте W1/W2 каждая сотня байт на счету. Тот же",
|
||||
" * приём, что у POP_BG_ATLAS_NAMES в pop_bg_atlas.h. */",
|
||||
"#ifdef POP_SND_FILES",
|
||||
"static const char *const pop_snd_files[POP_SND_PAGES] = {",
|
||||
]
|
||||
lines += [' "SND\\\\s%d.bin",' % i for i in range(npages)]
|
||||
lines += ["};", "#endif", "", "#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()
|
||||
+67
-12
@@ -1,28 +1,64 @@
|
||||
#!/bin/bash
|
||||
# make_hdd.sh OUT.chd FILE1 [FILE2 ...]
|
||||
# make_hdd.sh [--dest DIR] OUT.chd FILE1 [FILE2 ...]
|
||||
#
|
||||
# Собирает 32 МБ RAW-образ жёсткого диска (MBR + один раздел FAT16) с
|
||||
# указанными файлами в корне и конвертит его в CHD для MAME (монтируется
|
||||
# как -hard2). Быстрая альтернатива floppy (mc.img): MAME эмулирует FDD
|
||||
# с реальной черепашьей скоростью, HDD — в разы быстрее.
|
||||
# указанными файлами и конвертит его в CHD для MAME (монтируется как -hard2).
|
||||
# По умолчанию файлы кладутся в корень. --dest DIR (или HDD_DEST_DIR)
|
||||
# переносит всё дерево под DIR, например games/sprpop.
|
||||
#
|
||||
# Требует: mtools (mpartition/mformat/mcopy) + chdman (из MAME).
|
||||
# Sprinter DSS штатно монтирует стандартный MBR+FAT16 (подтверждено).
|
||||
#
|
||||
# Использование:
|
||||
# toolchain/make_hdd.sh mame/v306/IMG/test_hdd.chd prog.exe data1 data2 ...
|
||||
# toolchain/make_hdd.sh --dest games/sprpop out.chd prog.exe data1 ...
|
||||
# toolchain/make_hdd.sh out.chd prog.exe KID:kid0.atl GUARD:g0.atl ...
|
||||
# — аргумент вида КАТАЛОГ:файл кладёт файл в подкаталог образа
|
||||
# (каталог создаётся); без префикса файл идёт в корень.
|
||||
# — аргумент вида КАТАЛОГ:файл кладёт файл в подкаталог относительно
|
||||
# DIR; без префикса файл идёт непосредственно в DIR.
|
||||
set -e
|
||||
|
||||
DEST_DIR=${HDD_DEST_DIR:-}
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dest)
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "make_hdd.sh: --dest требует путь" >&2
|
||||
exit 1
|
||||
fi
|
||||
DEST_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dest=*)
|
||||
DEST_DIR="${1#--dest=}"
|
||||
shift
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "usage: $0 OUT.chd FILE1 [FILE2 ...]" >&2
|
||||
echo "usage: $0 [--dest DIR] OUT.chd FILE1 [FILE2 ...]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUT_CHD="$1"; shift
|
||||
|
||||
# DSS использует обратный слэш, но mtools — прямой. Ведущие/замыкающие
|
||||
# разделители не несут смысла: DIR всегда задаётся относительно корня HDD.
|
||||
DEST_DIR="${DEST_DIR//\\//}"
|
||||
while [ "${DEST_DIR#/}" != "$DEST_DIR" ]; do DEST_DIR="${DEST_DIR#/}"; done
|
||||
while [ "${DEST_DIR%/}" != "$DEST_DIR" ]; do DEST_DIR="${DEST_DIR%/}"; done
|
||||
case "/$DEST_DIR/" in
|
||||
*/./*|*/../*|*:* )
|
||||
echo "make_hdd.sh: недопустимый каталог назначения: $DEST_DIR" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Геометрия: 128*16*32*512 = 32 МБ. BPS=512.
|
||||
CYLS=128; HEADS=16; SECS=32
|
||||
SECTORS=$((CYLS * HEADS * SECS)) # 65536
|
||||
@@ -57,6 +93,21 @@ mt() {
|
||||
"$@" < /dev/null
|
||||
}
|
||||
|
||||
# mmd не создаёт родителей сам: строим путь по одному компоненту. Пустой
|
||||
# путь означает корень и сохраняет прежнее поведение скрипта.
|
||||
make_dir_tree() {
|
||||
local path="$1" part current="" old_ifs="$IFS"
|
||||
|
||||
[ -z "$path" ] && return
|
||||
IFS='/'
|
||||
for part in $path; do
|
||||
[ -z "$part" ] && continue
|
||||
current="${current:+$current/}$part"
|
||||
mt mmd "z:/$current" >/dev/null 2>&1 || true
|
||||
done
|
||||
IFS="$old_ifs"
|
||||
}
|
||||
|
||||
# MBR + раздел 1 на весь диск (тип FAT16), затем формат FAT16.
|
||||
echo "--- разметка и формат образа ---"
|
||||
mt mpartition -I -c -t $CYLS -h $HEADS -s $SECS z:
|
||||
@@ -64,17 +115,21 @@ mt mformat z:
|
||||
|
||||
echo "--- копирование файлов ---"
|
||||
|
||||
make_dir_tree "$DEST_DIR"
|
||||
DEST_ROOT="z:${DEST_DIR:+/$DEST_DIR}"
|
||||
|
||||
# Аргумент может быть либо файлом (кладём в корень), либо парой
|
||||
# КАТАЛОГ:файл — тогда каталог создаётся на образе и файл кладётся туда.
|
||||
# Имена на Sprinter/DSS — 8.3, каталоги тоже (KID, GUARD, BG, LEVELS...).
|
||||
# КАТАЛОГ:файл — тогда каталог создаётся относительно DEST_ROOT и файл
|
||||
# кладётся туда. Имена на Sprinter/DSS — 8.3.
|
||||
for f in "$@"; do
|
||||
case "$f" in
|
||||
*:*) dir="${f%%:*}"; src="${f#*:}"
|
||||
target_dir="${DEST_DIR:+$DEST_DIR/}$dir"
|
||||
# Каталог может уже быть — это не ошибка; stderr НЕ прячем, иначе
|
||||
# непрошедший вопрос mtools снова окажется невидимым.
|
||||
mt mmd "z:/$dir" >/dev/null 2>&1 || true
|
||||
mt mcopy -o "$src" "z:/$dir/" ;;
|
||||
*) mt mcopy -o "$f" z: ;;
|
||||
make_dir_tree "$target_dir"
|
||||
mt mcopy -o "$src" "z:/$target_dir/" ;;
|
||||
*) mt mcopy -o "$f" "$DEST_ROOT/" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
|
||||
Reference in New Issue
Block a user