Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 63bfd997a9 | |||
| decbec79de | |||
| b0e7130d0b | |||
| 4e12aa50d1 | |||
| c218e8b983 | |||
| a3aaa30e53 | |||
| 90e304f071 | |||
| b55d4d11e3 | |||
| ca67895667 | |||
| a5252b1c60 | |||
| 3a0e847353 | |||
| df5071a967 | |||
| 2349481b86 | |||
| 589894c50d | |||
| ea8efdb0fd | |||
| 623199337e | |||
| 602c3a20fa | |||
| 4ac3584bf9 | |||
| 3bf28da8ee | |||
| 770b946a36 | |||
| 89e9663753 | |||
| ea07a8d7b0 | |||
| d23126983e | |||
| 50570881f5 | |||
| f25ed37d85 | |||
| 3449f6f8c9 | |||
| 6dabe9b4b1 | |||
| a05970cd36 | |||
| 2176c12cc5 | |||
| 4e43890fce | |||
| 25b2db8b0b | |||
| f76914849f | |||
| 6cc8611d03 | |||
| 659071838d | |||
| f8e96c0495 | |||
| 76f02e76db | |||
| c71981fdf9 | |||
| 808c2a5349 | |||
| 31b82661eb | |||
| 4b74478d19 | |||
| 747783c422 | |||
| a3c5c600da | |||
| faec9a7d3a | |||
| 5d912f0f3f | |||
| 4086dde1f1 | |||
| 0b15ed7b8f | |||
| f66fd0e1b6 | |||
| e5179af9d8 | |||
| 06a1772011 | |||
| 5ede8b9045 | |||
| 0db4f94707 | |||
| 4a939e42f8 | |||
| 0086ac80c2 | |||
| 7e6b38cca6 | |||
| 65797af44c | |||
| d86adc54c4 | |||
| 16d3262340 | |||
| 4327ac88b9 | |||
| ba3aca07bf | |||
| 34ba5f71d5 | |||
| ffb59b3470 | |||
| 5756101d29 | |||
| 47a4b084c4 | |||
| 7fd7f28ffc | |||
| f4b4852d51 | |||
| b6699b3aef | |||
| 536c60d14c | |||
| e2730f78e0 | |||
| 3ea4546656 | |||
| 8458ec65f4 | |||
| 63e426cc0d | |||
| 90255737c2 | |||
| 354582662c | |||
| c6828c0ad1 | |||
| a630568a8b | |||
| f159aa47e1 | |||
| e0c86a96ef | |||
| 7e2e7fbb37 | |||
| 8ea4c32e51 | |||
| 24ced6058f | |||
| a3d37bcbfe | |||
| 22cdc67c3a | |||
| 4688364091 | |||
| 9f9a8f26ae | |||
| 7fcd93a35b | |||
| d1183f7315 | |||
| 30bcc3459b | |||
| e60a04e900 | |||
| b6660d7694 | |||
| acb483897d | |||
| 47c26d1899 | |||
| 0ef8c4b60e | |||
| 10b920f156 | |||
| 6bdac70508 | |||
| 5d61224229 | |||
| 35d7bd38d4 | |||
| 5e9c6a2e9e | |||
| a6e39070af | |||
| 61b8d80275 | |||
| 7f778bba2f |
@@ -0,0 +1,29 @@
|
||||
[mcp_servers.mame-z80]
|
||||
command = "/Users/alex/.local/bin/uv"
|
||||
args = [
|
||||
"run",
|
||||
"--python",
|
||||
"3.12",
|
||||
"--no-project",
|
||||
"--with",
|
||||
"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"
|
||||
+18
@@ -8,6 +8,7 @@ build/
|
||||
|
||||
# sprinter-cc per-example intermediate directory
|
||||
.sprinter-cc-*/
|
||||
.resource-stamps/
|
||||
|
||||
# Per-program final/intermediate outputs landing alongside the source
|
||||
# (real apps under examples/ and libc feature tests under tests/).
|
||||
@@ -120,3 +121,20 @@ mame/
|
||||
# .git внутри (в коммите стали бы битыми gitlink-ссылками).
|
||||
docs/extra/
|
||||
docs/sources/
|
||||
|
||||
# Записи музыки DOS-версии PoP (43 МБ в четырёх форматах) — ИСТОЧНИК для
|
||||
# toolchain/pop_pack_music.py, а не ресурс сборки: на диск игры уходят уже
|
||||
# упакованные poc/res/music/*.bin, и они в репозитории есть. Если понадобится
|
||||
# перегенерировать музыку — положить сюда PoP1_DOS_music (flac).
|
||||
applications/PoP/PoP1_DOS_music/
|
||||
|
||||
# R1 — рабочая копия roomtest для экспериментов пользователя, в репозиторий
|
||||
# не идёт (сама roomtest и есть версируемая ветка разработки).
|
||||
applications/PoP/R1/
|
||||
|
||||
# SprPoP — автономное приложение, и правила игнора у него СВОИ:
|
||||
# applications/SprPoP/.gitignore. Он написан так, чтобы стать корневым
|
||||
# .gitignore, когда SprPoP выделят в отдельный репозиторий, — поэтому
|
||||
# здесь его содержимое НЕ дублируется (иначе разъедется). Коротко: в
|
||||
# репозиторий не идут assets/orig/ (чужие данные — их выкачивает
|
||||
# `make fetch`) и assets/packed/LEVELS/ (уровни из оригинала как есть).
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Sprinter C-Compiler — правила проекта
|
||||
|
||||
Target-слой SDCC 4.5 (z80) для компьютера Sprinter Sp2000: crt0,
|
||||
линковка, libc, mkexe. Общение и комментарии — на русском.
|
||||
|
||||
## Сборка и проверка
|
||||
|
||||
```
|
||||
make # tools + lib + libbgi + все тесты (45) + examples
|
||||
make -C libc # только libc → lib/sprinter.lib (fast) + sprinter_safe.lib
|
||||
make -C libbgi # только BGI → lib/bgi256.lib (fast) + bgi256_safe.lib
|
||||
make floppy # упаковать все .exe в mame/v306/IMG/mc.img
|
||||
make size-check # размерный регресс: _CODE vs docs/size_baseline.tsv
|
||||
make size-baseline # принять текущие размеры эталоном
|
||||
```
|
||||
|
||||
Обе библиотеки собираются в двух вариантах: fast (дефолт; `-D*_NOCHECK` —
|
||||
параметр-валидации вырезаны) и safe (линкуется по `sprinter-cc --safe`).
|
||||
Критичные гарды (напр. _fd_guard — 9-й OPEN вешает DSS) — в ОБОИХ.
|
||||
|
||||
Графика (BGI) — отдельная библиотека libbgi/ (см. ниже). Программа,
|
||||
использующая graphics.h, собирается с `--gfx 256` (или `--gfx 16` в
|
||||
Фазе 2): sprinter-cc подлинкует lib/bgi256.lib и добавит -I libbgi/include.
|
||||
|
||||
Одиночный тест: `cd tests/<имя> && make run` (пакует exe + EXTRA_DATA на
|
||||
дискету и запускает MAME автоматически через `toolchain/mame_interactive.py`,
|
||||
снимает скриншоты, выводит пути). Для сложных сценариев (диалог, несколько
|
||||
шагов ввода) — прямой вызов:
|
||||
`python3 toolchain/mame_interactive.py tests/<имя>/<имя>.exe --snap T1,T2 --timeout T`.
|
||||
Скриншоты лежат в `mame/v306/snap_auto/sprinter/` (читаются инструментом Read).
|
||||
Подробности: `docs/mame-autotest.md`.
|
||||
|
||||
После правок libc/libbgi: пересборка от чистого листа (`make -C libc clean`
|
||||
/ `make -C libbgi clean`) не обязательна — stale .rel чистятся
|
||||
автоматически; `make size-check` обязателен (рост _CODE без причины —
|
||||
регрессия).
|
||||
|
||||
## Правила libc
|
||||
|
||||
- **1 публичная функция = 1 .c-модуль** (линкер тянет .rel целиком —
|
||||
гранулярность файлов = гранулярность DCE). Никакой группировки
|
||||
«используются вместе». Internal-хелперы — тоже по одному на модуль
|
||||
(`_`-префикс); общие статики — в отдельные data-модули
|
||||
(`_xxx_state.c`); internal-заголовки (`_file.h`, `_gfx.h`, …) —
|
||||
рядом с исходниками, НЕ в libc/include.
|
||||
- Имя файла = имя функции. libc/Makefile собирает wildcard'ом —
|
||||
ничего регистрировать не надо.
|
||||
- Комментарии — на русском; шапка модуля объясняет что/зачем + ABI.
|
||||
- File-scope переменные НЕ инициализировать `= 0` (crt0 зануляет
|
||||
_DATA; см. memory/sdcc_static_storage_gotcha).
|
||||
- asm-связки между модулями: `call/jp _global` — ок; `jr/djnz` через
|
||||
границу и fall-through — НЕЛЬЗЯ (docs/libc-split-asm-cases.md).
|
||||
- Заголовки: сначала пробовать include_next-паттерн; полная замена
|
||||
SDCC-заголовка обязана дублировать его контракт
|
||||
(docs/libc-headers.md).
|
||||
- Справочник API — docs/libc-reference.md (обновлять при добавлении
|
||||
функций).
|
||||
|
||||
## ABI и платформа (кратко; детали в memory/)
|
||||
|
||||
- SDCC `__sdcccall(1)`: arg1 → HL (8-бит → A), arg2 → DE, остальные
|
||||
на стеке (callee-pops в __naked); **возврат int/ptr в DE**, uint8 в A.
|
||||
IX callee-saved (в __naked с IX — push/pop обязательны).
|
||||
- ESTEX (rst #0x10): CF=1 — ошибка, код в A → `call __errno_set`;
|
||||
все регистры клобберятся (IX сохранять); стек обязан быть в W2.
|
||||
- BIOS (rst #0x08): строки/буферы в #4000-#BFFF.
|
||||
- Квирки: ESTEX WRITE возвращает DE=0 на успехе (судить по CF/A);
|
||||
лимит 8 файловых манипуляторов, 9-й OPEN ВЕШАЕТ DSS (_fd_guard);
|
||||
ENV $46: A=0 = NOT FOUND.
|
||||
- Перед обвинением компилятора/железа — подтвердить артефактом
|
||||
(сгенерированный .asm в libc/build/ или libbgi/build/, дамп, репро) — см.
|
||||
memory/defer_unexplained_quirks.
|
||||
|
||||
## Структура
|
||||
|
||||
- `libc/<area>/*.c` — модули libc (ядро, БЕЗ графики); `libc/include/` — публичные заголовки libc
|
||||
- `libbgi/` — графика BGI (отдельная библиотека): `common/` — mode-agnostic (один исходник, .rel в обеих driver-библиотеках), `bgi256/` + `bgi16/` — mode-specific leaf'ы (реальные реализации, без обёрток); `include/` — graphics.h + gfx.h; `_bgi.h` — внутренний заголовок. Собирает `lib/bgi256.lib` (и `bgi16.lib` в Фазе 2). Выбор режима линковкой: `--gfx 256` / `--gfx 16`.
|
||||
- `runtime/` — crt0-семейство, heap, bank (bank.s собирается per-build)
|
||||
- `bin/sprinter-cc` — обёртка компилятора; `toolchain/mkexe` — упаковщик
|
||||
- `tests/` — по одному API/фиче; `examples/` — реальные приложения
|
||||
- `docs/` — дизайн-доки; `docs/TODO.md` — roadmap
|
||||
- `third_party/solid-c/` — нативный Sprinter C (референс, CP866;
|
||||
их ABI несовместим — только как образец)
|
||||
@@ -80,7 +80,9 @@ floppy: tests examples tests/seek/big.txt
|
||||
# Модульные тесты под ucsim_z80. Обвязка — testkit/, сами наборы лежат
|
||||
# рядом с кодом, который проверяют. MAME не нужна, идут за секунды;
|
||||
# ucsim идёт в комплекте нашего SDCC.
|
||||
HOST_TEST_DIRS := testkit applications/PoP/roomtest/tests-host
|
||||
# applications/PoP/roomtest заморожена (её ветка развития — SprPoP), поэтому
|
||||
# её набор здесь больше не гоняется.
|
||||
HOST_TEST_DIRS := testkit applications/SprPoP/tests/host
|
||||
|
||||
host-tests:
|
||||
@for d in $(HOST_TEST_DIRS); do $(MAKE) -C $$d || exit 1; done
|
||||
|
||||
@@ -144,7 +144,8 @@ sprinter-cc -o foo.exe foo.c [more.c ...] [options]
|
||||
--memory-manual SPEC explicit placement (CODE=W1|W2,DATA=W1|W2|SAME,BANKED=W1|W3)
|
||||
--stack-size N bytes reserved for the stack (default ~1278)
|
||||
--crt0=TYPE default | minimal | banked | small
|
||||
--bank N=FILE.c compile FILE.c into bank N (repeatable, max 15)
|
||||
--bank N=FILE.c compile FILE.c into bank N (repeatable, consecutive 1..15;
|
||||
crt0 bank count is generated automatically)
|
||||
--debug enable runtime diagnostics (defines DEBUG_RT)
|
||||
-I PATH extra include path
|
||||
-L 0xADDR / -E / -S override load / entry / stack addresses
|
||||
|
||||
@@ -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
|
||||
#
|
||||
@@ -36,14 +37,40 @@ LIB := $(PROJ_ROOT)/lib/sprinter.lib
|
||||
|
||||
MAME_DIR := $(PROJ_ROOT)/mame/v306
|
||||
FLOPPY_IMG := $(MAME_DIR)/IMG/mc.img
|
||||
HDD_IMG := $(MAME_DIR)/IMG/test_hdd.chd
|
||||
# ?= — приложение со своим каталогом выхода (applications/SprPoP) держит
|
||||
# образ у себя и связывает его с 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.
|
||||
MEMORY ?= tiny
|
||||
SOURCES := $(EXAMPLE).c $(EXTRA_SRCS)
|
||||
|
||||
# SRC_DIR / BUILD_DIR — раскладка приложения, которое НЕ держит исходники и
|
||||
# выхлоп в одной папке с Makefile (applications/SprPoP: src/ и build/). По
|
||||
# умолчанию обе пусты, то есть всё как было: ./$(EXAMPLE).c → ./$(EXAMPLE).exe.
|
||||
# Пустое значение обрабатывается отдельной веткой намеренно: "./prog.exe" и
|
||||
# "prog.exe" — разные имена целей, и склеивать префикс безусловно нельзя.
|
||||
SRC_DIR ?=
|
||||
BUILD_DIR ?=
|
||||
ifeq ($(strip $(SRC_DIR)),)
|
||||
MAIN_SRC := $(EXAMPLE).c
|
||||
else
|
||||
MAIN_SRC := $(SRC_DIR)/$(EXAMPLE).c
|
||||
endif
|
||||
ifeq ($(strip $(BUILD_DIR)),)
|
||||
EXE := $(EXAMPLE).exe
|
||||
else
|
||||
EXE := $(BUILD_DIR)/$(EXAMPLE).exe
|
||||
endif
|
||||
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),)
|
||||
@@ -51,15 +78,25 @@ CC_FLAGS += --stack-size $(STACK_SIZE)
|
||||
endif
|
||||
CC_FLAGS += $(EXTRA_FLAGS)
|
||||
|
||||
all: $(EXAMPLE).exe
|
||||
all: $(EXE)
|
||||
|
||||
# runtime/*.s (crt0-семейство, bank.s, heap.s) собираются per-build
|
||||
# внутри sprinter-cc — без этой зависимости их правка не перелинкует
|
||||
# уже собранный exe (кусало: фикс bank.s не подхватился).
|
||||
RUNTIME_DEPS := $(wildcard $(PROJ_ROOT)/runtime/*.s)
|
||||
|
||||
$(EXAMPLE).exe: $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS)
|
||||
# ПРОВЕРКА БАНКОВЫХ ВЫЗОВОВ — сразу после линковки, пока артефакты свежие.
|
||||
# Ловит прямой `call` в чужой банк: он собирается МОЛЧА и стреляет диким
|
||||
# переходом в пустой хвост банка (разбор — в шапке скрипта). Запускается
|
||||
# только если банки вообще есть, то есть по наличию каталога сборки с
|
||||
# bankN_*.asm; обычным небанковым программам ничего не стоит.
|
||||
BANK_CHECK := $(PROJ_ROOT)/toolchain/check_bank_calls.py
|
||||
|
||||
$(EXE): $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS)
|
||||
$(if $(strip $(BUILD_DIR)),@mkdir -p $(dir $@))
|
||||
$(SPRINTER_CC) $(CC_FLAGS) -o $@ $(SOURCES)
|
||||
@d=$(dir $@).sprinter-cc-$(EXAMPLE); \
|
||||
if ls $$d/bank*_*.asm >/dev/null 2>&1; then python3 $(BANK_CHECK) $$d; fi
|
||||
|
||||
$(MKEXE):
|
||||
$(MAKE) -C $(PROJ_ROOT)/toolchain/mkexe
|
||||
@@ -73,13 +110,13 @@ $(LIB):
|
||||
$(MAKE) -C $(PROJ_ROOT)/libbgi
|
||||
|
||||
clean:
|
||||
rm -rf .sprinter-cc-* $(EXAMPLE).exe
|
||||
rm -rf $(if $(strip $(BUILD_DIR)),$(BUILD_DIR),.sprinter-cc-* $(EXE))
|
||||
|
||||
# `make floppy` packs ONLY this program (+ optional EXTRA_DATA files) into
|
||||
# the MAME floppy image, replacing whatever was there. Handy for trying a
|
||||
# single program without rebuilding everything.
|
||||
floppy: $(EXAMPLE).exe
|
||||
python3 $(MAKE_DISK) $(FLOPPY_IMG) $(EXAMPLE).exe $(EXTRA_DATA)
|
||||
floppy: $(EXE)
|
||||
python3 $(MAKE_DISK) $(FLOPPY_IMG) $(EXE) $(EXTRA_DATA)
|
||||
@echo
|
||||
@echo "Floppy ready: $(FLOPPY_IMG) (with $(EXAMPLE).exe$(if $(EXTRA_DATA), + $(EXTRA_DATA))) "
|
||||
@echo "Run: cd $(MAME_DIR) && ./run_mame.sh"
|
||||
@@ -91,8 +128,8 @@ run: floppy
|
||||
# HDD image mounted as disk D: (-hard2 test_hdd.chd). Гораздо быстрее FDD —
|
||||
# используется MCP-мостом к MAME (run_bridge.sh). После пересборки образа
|
||||
# MAME ОБЯЗАН полный рестарт (chdman -f = новый inode; см. memory).
|
||||
hdd: $(EXAMPLE).exe
|
||||
$(MAKE_HDD) $(HDD_IMG) $(EXAMPLE).exe $(EXTRA_DATA)
|
||||
hdd: $(EXE)
|
||||
$(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) — образ пересобран."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# `applications/PoP/docs` — индекс + сводка по форматам ресурсов
|
||||
|
||||
## Индекс документов (актуальность на 2026-08-01)
|
||||
## Индекс документов (актуальность на 2026-08-22)
|
||||
|
||||
**Живые планы — читать перед работой:**
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
| [`perf_green_phase.md`](perf_green_phase.md) | **ЗЕЛЁНАЯ фаза (слой фона)**: раскладка тактов, способы ускорения (G1..G6), журнал правок — рабочий документ между сессиями. 2026-08-17 |
|
||||
| [`perf_cyan_phase.md`](perf_cyan_phase.md) | **ЦИАН фаза (персонажи + передний слой)**: раскладка тактов, способы ускорения (C1..C7), журнал правок — рабочий документ между сессиями. 2026-08-17 |
|
||||
| [`perf_backlog.md`](perf_backlog.md) | Отложенная оптимизация отрисовки с замерами 2026-08-10 + **как мерить** (wait-state'ы, границы кадра). Позиции 1–7 переехали в фазовые документы выше |
|
||||
| [`quicksave_plan.md`](quicksave_plan.md) | **QuickSave/QuickLoad**: разбор (это enhancement SDLPoP, в оригинале 1989 его НЕТ), инвентаризация нашего состояния, формат снимка, шаги QS1..QS6. План, код не начат. 2026-08-17 |
|
||||
| [`quicksave_plan.md`](quicksave_plan.md) | **QuickSave/QuickLoad** (✅ реализовано, F6/F9, POP.SAV+BAK): разбор (это enhancement SDLPoP, в оригинале 1989 его НЕТ), инвентаризация нашего состояния, формат снимка 'POPQ' v3, шаги QS1..QS6. Справочник. 2026-08-22 |
|
||||
| [`full_game_plan.md`](full_game_plan.md) | **Полноценная игра**: app state machine, title/intro, demo level 0, таймер, cutscenes, уровни 1..14, ending и Hall of Fame. Уровень 15 исключён. 2026-08-21 |
|
||||
| [`menu_settings_plan.md`](menu_settings_plan.md) | **Pause menu и Settings**: QuickSave/QuickLoad в основном menu, `POP.CFG`, один `POP.SAV` + `POP.BAK`, текущий VANILLA и задел под ENHANCED; §10 — выбор UI-рендера (текстовые строки + свой растровый рендерер, референс SDLPoP: два шрифта), restart без подтверждения. 2026-08-22 |
|
||||
| [`palette_plan.md`](palette_plan.md) | **Палитры и fade**: карта всех 256 слотов (kid.pal/title/story, тайлсеты dungeon/palace, стражи), механика fade (4 ступени vs ~64 у SDLPoP), план модуля `pop_pal.c` (API load/apply/black) + переход уровня через fade. 2026-08-23 |
|
||||
| [`status_line_text.md`](status_line_text.md) | **Строка HP как статус-строка**: полная инвентаризация ВСЕХ текстов SDLPoP в `rect_bottom_text` (геометрия, семантика `text_time_total`, мигание, рестарт по истечении) + что из этого уже есть у нас. 2026-08-25 |
|
||||
| [`levels_plan.md`](levels_plan.md) | Следующий этап: уровни 2+, второй тайлсет, читы SDLPoP |
|
||||
| [`levels_12_15_plan.md`](levels_12_15_plan.md) | **Уровни 12/13** (тень, Джафар, падающие плиты) + что такое 14/15 и 0. 2026-08-13 |
|
||||
| [`midtable_analysis.md`](midtable_analysis.md) | **Слои отрисовки**: как устроены back/mid/fore и objtable в оригинале, чего стоит порт, развилки. 2026-08-13 |
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
# Фиксированный логический кадр — разбор перед реализацией
|
||||
|
||||
Дата разбора: 2026-08-19. Отправная точка — тег `0.0.1-prealpha`.
|
||||
Статус: **АНАЛИЗ, кода не трогали.**
|
||||
|
||||
Задача пользователя: перевести логический кадр на фиксированный размер,
|
||||
не зависящий от длительности синей/зелёной/циан фаз. Инструмент —
|
||||
`gfx_set_fps_div`, режимы FASTEST / FAST / NORMAL.
|
||||
|
||||
|
||||
## 1. Что на самом деле меняется
|
||||
|
||||
Сейчас главный цикл (`roomtest.c`) после отрисовки ждёт **три**
|
||||
`gfx_wait_vsync()` подряд. Каждый ждёт ближайший фронт луча, поэтому
|
||||
период логического кадра равен
|
||||
|
||||
период = ceil(W) + 2 растровых кадра, W = работа в растрах
|
||||
|
||||
Первое ожидание доедает хвост текущего растра, два следующих — целые
|
||||
растры. Отсюда наблюдаемое: `W <= 1` → период 3; `W = 1.1` → период
|
||||
уже 4. То есть **реальный бюджет логического кадра сегодня — один
|
||||
растр (430 080 тактов)**, всё сверх него стоит целого лишнего растра.
|
||||
|
||||
Нужное поведение:
|
||||
|
||||
период = max(n, ceil(W))
|
||||
|
||||
При `n = 3` бюджет становится **1 290 240 тактов** — втрое больше.
|
||||
Замеренный максимум работы на 13/23 (911 862) укладывается туда с
|
||||
запасом, а 11/15 (437 484 лёгкая / ~603 000 тяжёлая позиция) — вдвойне.
|
||||
|
||||
Это главный выигрыш, и он не про скорость игры, а про **исчезновение
|
||||
скачков**: сегодня превышение растра на один такт стоит +33 % к периоду.
|
||||
|
||||
|
||||
## 2. Три режима и их привязка к оригиналу
|
||||
|
||||
Растровый кадр Sprinter: 320 строк × 896 пикселей при 14 МГц =
|
||||
**20,48 мс** (48,83 Гц). В тактах CPU (21 МГц) — **430 080**;
|
||||
замерено на холостом DSS: 430 131 на прерывание.
|
||||
|
||||
Оригинал (`SDLPoP/src/seg003.c:363`): `BASE_FPS = 60`,
|
||||
`base_speed = 5` тика = **83,3 мс**, `fight_speed = 6` = **100 мс**.
|
||||
|
||||
| режим | обычно | в бою | мс обычно | мс в бою | к оригиналу |
|
||||
|---|---:|---:|---:|---:|---|
|
||||
| FASTEST | 3 | 3 | 61,4 | 61,4 | +36 % скорости |
|
||||
| FAST | 3 | 4 | 61,4 | 81,9 | +36 % / точно |
|
||||
| NORMAL | 4 | 5 | 81,9 | 102,4 | точно (83,3 / 100) |
|
||||
|
||||
NORMAL воспроизводит оригинал с точностью 1,7 % и 2,4 % — расхождение
|
||||
только из-за 48,83 Гц против 60 Гц, целыми делителями точнее не выйдет.
|
||||
|
||||
**Условие «бой» берём у оригинала буквально** — оно проще, чем кажется:
|
||||
|
||||
```c
|
||||
if (Kid.sword == sword_2_drawn) set_timer_length(timer_1, fight_speed);
|
||||
else set_timer_length(timer_1, base_speed);
|
||||
```
|
||||
|
||||
Это не «идёт бой» и не «есть страж рядом», а **только «у Кида вынут
|
||||
меч»**, и проверяется в самом верху главного цикла, до `play_frame()`.
|
||||
У нас поле есть (`Kid.sword`, `SWORD_2_DRAWN` — `guards.c`), так что
|
||||
переключение — одна строка в том же месте цикла.
|
||||
|
||||
Это закрывает и давнюю задачу **L1-SPEED** (`TASKS_OPEN.md`): сейчас мы
|
||||
идём на 60 мс вместо 83,3 — примерно на 39 % быстрее эталона.
|
||||
|
||||
|
||||
## 3. Как устроен темп сейчас и что мешает
|
||||
|
||||
`gfx_wait_vsync()` имеет две ветки (`libbgi/common/gfx_wait_vsync.c`):
|
||||
|
||||
- **`_gfx_fps_div <= 1`** — лучевой поллинг бита 5 порта `0xFE` в тесном
|
||||
цикле, и в этом цикле зовётся **idle-хук** (`gfx_set_idle_hook`).
|
||||
roomtest вешает туда `kbd_raw_poll` — это единственное, что делает
|
||||
клавиатуру работоспособной (задача KBD-1: приёмный FIFO SIO 3 байта,
|
||||
импульс запроса прерывания живёт 32 такта и теряется в DI-окнах
|
||||
акселератора; лечится только плотным опросом раз в ~0,5 мс).
|
||||
- **`_gfx_fps_div >= 2`** — счётчиковый путь: ждёт, пока фоновый ISR
|
||||
(`_gfx_frame_isr`, слот кадровой цепочки) насчитает `n` фронтов, а
|
||||
ожидание реализовано через **`ei; halt`**.
|
||||
|
||||
**И вот здесь блокер.** На счётчиковом пути idle-хук не зовётся вообще.
|
||||
Включив `gfx_set_fps_div(3)` как есть, мы немедленно возвращаем KBD-1:
|
||||
теряются нажатия при зажатом Shift, залипают клавиши. Это не мелочь и
|
||||
не «потом поправим» — это единственная причина, по которой клавиатура
|
||||
сейчас вообще работает.
|
||||
|
||||
|
||||
## 4. Что измерено (MAME, 2026-08-19)
|
||||
|
||||
### Метод и его границы
|
||||
|
||||
Прерывания считались брейкпоинтами на резидентных адресах трамплина
|
||||
(`_irq_tramp` = 0x88B4, ветка кадрового пути `tr_frame` = 0x8995).
|
||||
|
||||
**Счёт попаданий брейкпоинтом на этом драйвере недостоверен**: sprinter
|
||||
дёргает `Z80_INPUT_LINE_WAIT` (`do_mem_wait`), инструкция пересчитывается,
|
||||
и один и тот же PC срабатывает по нескольку раз. Наблюдалось
|
||||
«попаданий больше, чем растровых кадров» и «попаданий в `tr_frame`
|
||||
больше, чем входов в трамплин» — логически невозможные результаты.
|
||||
|
||||
Достоверен только **детектор разрыва**: брейкпоинт на следующей
|
||||
инструкции пишет `temp3 = totalcycles`, брейкпоинт с условием
|
||||
`(totalcycles - temp3) > 0x9D800` (1,5 растра) останавливает машину.
|
||||
Дубли попаданий его не портят. Все числа ниже — этим методом.
|
||||
|
||||
Отдельная грабля: **литералы в отладчике MAME шестнадцатеричные**.
|
||||
Первый прогон с порогом «700000» на деле проверял 0x700000 = 17 растров
|
||||
и не срабатывал никогда.
|
||||
|
||||
### Факты
|
||||
|
||||
1. **Холостой DSS** — 799 прерываний на 343 674 880 тактов = 430 131 на
|
||||
прерывание. Подтверждает константу растра и что потерь нет, когда
|
||||
нечего рисовать.
|
||||
|
||||
2. **11/15, покой (чомпер + два факела + страж)** — потери ЕСТЬ:
|
||||
разрыв ровно 860 129 тактов = два растра = одно потерянное
|
||||
прерывание. Частота в «плохой» фазе: 12 потерь на 427 растровых
|
||||
кадров = **2,8 %**; повтор — 12 на 495 (2,4 %) при бегущем Киде.
|
||||
|
||||
3. **Та же сцена после сдвига Кида** (`]`/`[`, попиксельно) —
|
||||
**0 потерь на 3919 кадров**, и после возврата обратно **0 на 4010**.
|
||||
|
||||
4. **Полная перерисовка комнаты** (переход/`+`) — разрыв 1 720 289 =
|
||||
ровно четыре растра = **три потерянных прерывания подряд**.
|
||||
|
||||
5. **13/23** — ни в покое, ни при беге потерь не поймано; на смене
|
||||
комнаты — поймано.
|
||||
|
||||
6. **Клавиатура ни при чём**: с удержанной клавишей 3,1 %, без неё
|
||||
2,8 % — в пределах разброса.
|
||||
|
||||
### Как это читать
|
||||
|
||||
Пункты 2 и 3 вместе — самое важное. Одна и та же сцена даёт то 2,8 %,
|
||||
то ноль. Значит потеря определяется **не нагрузкой, а фазой**: попадает
|
||||
ли момент кадрового прерывания внутрь DI-окна блита.
|
||||
|
||||
Механизм подтверждён исходником MAME (`sprinter.cpp`):
|
||||
|
||||
- `irq_on` поднимает линию и заводит `irq_off_timer` на **32 такта
|
||||
неразогнанного клока (3,5 МГц) = 9,14 мкс**; `irq_off` гасит. Ядро
|
||||
z80 в MAME уровневое (`m_irq_state` без защёлки) — импульс, пришедший
|
||||
под `di`, теряется НАСОВСЕМ. Это же поведение у настоящего Spectrum
|
||||
(INT 32 такта), так что это не эмуляторный артефакт.
|
||||
- DI-окно у нас — один вызов `_bgi_blit_rows_raw`, а он по контракту
|
||||
режется вызывающим на **чанки ≤16 строк** (≈6 200 тактов ≈ 0,29 мс).
|
||||
То есть DI-окна короткие и с промежутками, отсюда и «то теряем, то
|
||||
нет»: всё решает, куда попал 9-микросекундный импульс.
|
||||
- `irqack_cb` гасит **все три** входа мержера (экран/клавиатура/CBL)
|
||||
одним подтверждением. Плюс трамплин обслуживает за вход ровно один
|
||||
источник и делает приватный RETI. Значит кадровое прерывание может
|
||||
быть съедено клавиатурной веткой или (в будущем) CBL-веткой.
|
||||
|
||||
**Вывод по фазе.** Наш период сейчас 3 растра, но иногда 4 — и каждый
|
||||
такой случай сдвигает фазу рендера относительно луча. Отсюда «полосы»:
|
||||
десятки секунд без потерь, потом полоса с потерями. При жёстком
|
||||
пейсинге период станет РОВНО n, фаза перестанет плавать — и сцена может
|
||||
**залипнуть в плохой фазе надолго**. Это хуже случайных 3 %: систематическая
|
||||
потеря по прерыванию на кадр превратит логический кадр из 3 растров в 4,
|
||||
то есть даст ровные −25 % скорости, которые никак не проявятся в
|
||||
профиле тактов.
|
||||
|
||||
|
||||
## 5. Проблемы по убыванию риска
|
||||
|
||||
| # | проблема | риск |
|
||||
|---|---|---|
|
||||
| P1 | счётчиковый путь ждёт через `halt` → idle-хук не зовётся → возврат KBD-1 (потеря нажатий, залипание клавиш) | блокер |
|
||||
| P2 | счётчик кадров теряет тики (фазозависимо, 0…3 %), при жёстком пейсинге может залипнуть в плохой фазе → ровный минус скорости | высокий |
|
||||
| P3 | полноэкранная перерисовка (вход в комнату, старт уровня) теряет 3+ тика подряд → счётчик недосчитает, ожидание растянется сильнее самой работы | средний |
|
||||
| P4 | звук через CBL (`_irq_cbl_hook`, реальный ISR в трамплине): (а) ещё один источник, крадущий кадровые тики приватным RETI; (б) обратно — любое подтверждение гасит ожидающий запрос CBL → underrun (счётчик `cbl_underruns()` уже есть); (в) ISR длинный (полный сейв обоих наборов + вызов в приложение) | средний, растёт |
|
||||
| P5 | второй call-site `gfx_wait_vsync()` — ветка `frozen` (`roomtest.c:320`) ждёт один фронт; под делителем её смысл меняется | низкий |
|
||||
| P6 | `IRQ_CHAIN_MAX = 4`; делитель занимает слот, звук — свой; запас есть, но конечный | низкий |
|
||||
| P7 | бит 5 порта `0xFE` доступен только при включённом `cbl_mode`; сейчас его лениво занимает сам `gfx_wait_vsync` через `_cbl_port_ref`, при открытии реального звука владение переходит к CBL — переход уже спроектирован, но его надо проверить в связке | низкий |
|
||||
|
||||
Отдельно, не проблема а рычаг: по сообщению разработчиков
|
||||
(IvanMak.txt:846–848) **новая прошивка позволяет акселератору работать с
|
||||
EI** — по приходу прерывания он отключается, по `RETI` включается.
|
||||
Если это подтвердится на железе и моделируется в MAME, P2 исчезает
|
||||
полностью. Проверять отдельно; строить на этом нельзя (неизвестно, какая
|
||||
прошивка у пользователя).
|
||||
|
||||
|
||||
## 6. Варианты реализации
|
||||
|
||||
### A. Включить `gfx_set_fps_div(3)` как есть
|
||||
Отвергается: P1 (убивает клавиатуру) — сразу, без вариантов.
|
||||
|
||||
### B. Свой `wait` в приложении: счётчик только на рендер, ожидание — лучом
|
||||
Счётчик кадровых прерываний отвечает на один вопрос — «сколько фронтов
|
||||
съел рендер», а само ожидание идёт **существующим лучевым поллингом с
|
||||
idle-хуком**, то есть клавиатура работает ровно как сегодня.
|
||||
|
||||
```
|
||||
k = tick - tick_at_frame_start; /* фронтов съел рендер */
|
||||
if (k >= n) k = n - 1; /* опоздали — ждём хотя бы один */
|
||||
повторить (n - k) раз: ждать фронт луча (поллинг + idle-хук)
|
||||
tick_at_frame_start = tick; /* якорь на фактическом фронте */
|
||||
```
|
||||
|
||||
Плюсы: минимальная правка, клавиатура нетронута, фаза переякоривается
|
||||
каждый кадр (ошибка не копится). Минус: остаётся P2 — при залипании в
|
||||
плохой фазе `k` систематически занижен на 1, и кадр ровно на растр
|
||||
длиннее. Профилем это не видно.
|
||||
|
||||
### C. Программный счётчик кадров по лучу (без прерываний вообще)
|
||||
Считать фронты **выборкой бита 5 в точках, которые мы и так проходим**.
|
||||
Окно бланка (строки 272…319) длится **3,07 мс**, максимальное DI-окно —
|
||||
0,29 мс, значит достаточно опрашивать чаще, чем раз в 3 мс.
|
||||
|
||||
Точки выборки: `pop_blit_b` (наша обёртка, зовётся на каждый блит —
|
||||
в фазах рисования это плотнее 0,3 мс) плюс несколько точек в синей фазе
|
||||
(она 149 106 тактов ≈ 6,9 мс без единого блита, нужно 3-4 точки).
|
||||
|
||||
Цена: `in a,(0xFE)` + проверка бита + дедуп фронта ≈ 40-50 тактов; при
|
||||
~50 выборках это 2 500 тактов = 0,6 % растра.
|
||||
|
||||
Плюсы: **точно, и точность не зависит ни от DI, ни от звука, ни от
|
||||
клавиатуры** — снимает P2, P3, P4(а) разом. Минус: заводит инвариант
|
||||
«между выборками не больше 3 мс», который легко нарушить будущей правкой.
|
||||
Инвариант проверяем в MAME тем же детектором разрыва.
|
||||
|
||||
### D. CTC как источник кадра
|
||||
`irq_ctc_install` уже есть (каналы 2+3, вектор 0x06, отдельный от 0xFF).
|
||||
Запрос CTC **защёлкивается** (daisy chain — в `_irq.h` прямо записано,
|
||||
что без `RETI` следующего прерывания не будет), поэтому под `di` он не
|
||||
теряется, а откладывается. Пресет 112 × 160 даёт ровно период кадра
|
||||
без дрейфа (обе частоты — от одного X_SP).
|
||||
|
||||
Блокер: `irq_ctc_install` вектрится напрямую и требует кода в W2 →
|
||||
сейчас только tiny/big, а roomtest — **huge**. Нужна W2-копия
|
||||
CTC-трамплина (в дизайн-доке помечена как follow-up). Плюс защёлка
|
||||
хранит только ОДИН отложенный запрос — на полноэкранной перерисовке
|
||||
(P3) всё равно недосчитает.
|
||||
|
||||
### Рекомендация
|
||||
|
||||
**B как первый шаг, C — как способ закрыть P2**, и оба под одним
|
||||
интерфейсом: приложение зовёт свой `pop_wait_logical_frame(n)`, а чем
|
||||
внутри считаются кадры — деталь реализации. Тогда B→C не трогает ни
|
||||
главный цикл, ни режимы.
|
||||
|
||||
D не нужен, пока C справляется, и требует работы в libc (W2-копия
|
||||
CTC-трамплина) ради того же результата.
|
||||
|
||||
|
||||
## 7. Порядок работ с критериями приёмки
|
||||
|
||||
**Ш0. Инструмент.** Скрипт замера потерь кадровых прерываний (детектор
|
||||
разрыва) — зафиксировать как повторяемую процедуру, он понадобится на
|
||||
каждом шаге. Критерий: воспроизводит числа §4 на 11/15 и на смене комнаты.
|
||||
|
||||
**Ш1. Свой `wait` (вариант B), делитель ещё не включён.** Вынести
|
||||
хвост главного цикла в `pop_wait_logical_frame(n)`, поведение при n=3
|
||||
должно быть **бит-в-бит прежним** (три фронта после работы). Критерий:
|
||||
такты по фазам и распределение периода не изменились, клавиатура
|
||||
работает.
|
||||
|
||||
**Ш2. Счётчик кадров.** Слот кадровой цепочки + `k = tick - anchor`.
|
||||
Критерий: в 11/15 период стал ровно 3 растра ВСЕГДА (сейчас 3/4/5), а
|
||||
на 13/23 — 3 вместо нынешних 3/4/5; клавиатура не деградировала
|
||||
(проверка Shift+стрелки по методике KBD-1, не «на глаз»).
|
||||
|
||||
**Ш3. Режимы.** `POP_SPEED_FASTEST/FAST/NORMAL` + условие боя
|
||||
`Kid.sword == SWORD_2_DRAWN` в верху цикла, как у оригинала. Критерий:
|
||||
NORMAL секундомером совпадает с живым SDLPoP на одинаковом отрезке
|
||||
(методика из L1-SPEED — секундомер, не глазомер).
|
||||
|
||||
**Ш4. Закрыть P2 (вариант C).** Выборка луча в `pop_blit_b` и в синей
|
||||
фазе. Критерий: детектор разрыва не ловит ни одного расхождения между
|
||||
программным счётчиком и лучом за 10 000 кадров, включая смену комнаты.
|
||||
|
||||
**Ш5. Звук.** Только после Ш4: открыть CBL и перемерить P4 —
|
||||
`cbl_underruns()` и потери кадровых тиков.
|
||||
|
||||
## 8. Что проверить артефактом до начала
|
||||
|
||||
1. Сколько именно фронтов съедает вход в комнату — от этого зависит,
|
||||
нужен ли отдельный «resync» на тяжёлых переходах или хватит того,
|
||||
что якорь переставляется каждый кадр.
|
||||
2. Ветка `frozen` (`roomtest.c:320`) — какой темп ей нужен под делителем.
|
||||
3. Проверить, что при n=4/5 бит 5 всё ещё единственный источник фронта
|
||||
(то есть `_cbl_port_ref` держится всё это время).
|
||||
|
||||
|
||||
---
|
||||
|
||||
# 9. Предлагаемый вариант подробно: программный счётчик кадров по лучу
|
||||
|
||||
Дополнение от 2026-08-19 по запросу: как именно получается **точное**
|
||||
число пройденных кадровых интервалов. Акселератор рассматриваем только
|
||||
в нынешнем виде — с DI/EI (режим «акселератор с EI» из новой прошивки
|
||||
из рассмотрения снят).
|
||||
|
||||
## 9.1. Сигнал и его геометрия
|
||||
|
||||
Единственный источник — **бит 5 порта `0xFE`**. Точная семантика по
|
||||
исходнику MAME (`sprinter.cpp`, `kbd_fe_r`):
|
||||
|
||||
```c
|
||||
data |= 0xe0;
|
||||
data ^= 0x40;
|
||||
if (cbl_mode()) {
|
||||
data &= ~0xa0; /* гасит биты 5 и 7 */
|
||||
data |= (vpos >= BORDER_TOP + SCREEN_YSIZE) << 5;
|
||||
data |= ... & 0x80; /* бит 7 — CBL */
|
||||
}
|
||||
```
|
||||
|
||||
То есть **бит 5 = 1 ровно тогда, когда луч ниже картинки**
|
||||
(`vpos >= 16 + 256 = 272`), и это ЧТЕНИЕ ПОЛОЖЕНИЯ ЛУЧА, а не событие:
|
||||
ни прерывания, ни защёлки, ни очереди — его невозможно «потерять»,
|
||||
можно только не посмотреть.
|
||||
|
||||
Важное следствие из той же строки: вне `cbl_mode` бит читается как 1
|
||||
всегда (его выставляет `data |= 0xe0` и уже ничто не гасит). Поэтому
|
||||
счётчик обязан работать только при взведённом `_cbl_port_ref()` — том
|
||||
самом, который сейчас лениво взводит `gfx_wait_vsync`.
|
||||
|
||||
Геометрия кадра (320 строк × 896 пикселей при 14 МГц):
|
||||
|
||||
| | строк | мс | тактов CPU (21 МГц) |
|
||||
|---|---:|---:|---:|
|
||||
| бит 5 = 1 (нижний бланк) | 48 | 3,07 | **64 512** |
|
||||
| бит 5 = 0 (картинка + верхний бордер) | 272 | 17,41 | 365 568 |
|
||||
| кадр целиком | 320 | 20,48 | 430 080 |
|
||||
|
||||
Границей кадра берём **фронт 1→0** — это `vpos = 0`, ровно то же
|
||||
событие, которого ждёт сегодняшний `gfx_wait_vsync`. Значит момент
|
||||
свопа страниц не меняется: до начала картинки остаётся верхний бордер,
|
||||
16 строк ≈ 1 мс запаса, как и сейчас.
|
||||
|
||||
## 9.2. Счётчик
|
||||
|
||||
```c
|
||||
static uint8_t beam_prev; /* бит 5 на прошлой выборке */
|
||||
static uint8_t frame_tick; /* счётчик кадров, разностная арифметика */
|
||||
|
||||
/* ~20 T-состояний тела + вызов; в тактах MAME ≈ 120 на выборку */
|
||||
void pop_beam_sample(void)
|
||||
{
|
||||
uint8_t b = in_fe() & 0x20;
|
||||
if (beam_prev && !b) frame_tick++; /* фронт 1→0 = начало кадра */
|
||||
beam_prev = b;
|
||||
}
|
||||
```
|
||||
|
||||
Вся арифметика ожидания — разностная по модулю 256, wrap безопасен
|
||||
(тот же приём, что в существующем `_gfx_fps_state`).
|
||||
|
||||
## 9.3. Почему счёт ТОЧНЫЙ (условие и запас)
|
||||
|
||||
Утверждение: **если между соседними выборками проходит меньше 64 512
|
||||
тактов, то каждый фронт 1→0 будет засчитан ровно один раз.**
|
||||
|
||||
Доказательство прямое. Пусть максимальный зазор между выборками
|
||||
Δ < 64 512. Окно «бит 5 = 1» длится 64 512 тактов, то есть длиннее Δ,
|
||||
значит в него попадает хотя бы одна выборка → `beam_prev` обязательно
|
||||
станет 1 внутри каждого бланка. Окно «бит 5 = 0» длится 365 568 — тем
|
||||
более содержит выборку → сразу после бланка `beam_prev` перейдёт в 0 и
|
||||
даст ровно один инкремент. Двойной счёт невозможен: инкремент
|
||||
происходит только на переходе 1→0, а `beam_prev` тут же обновляется.
|
||||
|
||||
Условие ОДНО и оно про зазор, а не про нагрузку, не про DI, не про
|
||||
прерывания. Отсюда все свойства варианта.
|
||||
|
||||
**Какой запас по факту.** Самый длинный неделимый кусок кода без
|
||||
возможности выборки — одно DI-окно акселератора, то есть один вызов
|
||||
`_bgi_blit_rows_raw`. Он по контракту режется вызывающим на чанки
|
||||
**≤16 строк**; при ширине 32 это ≈ 6 200 тактов, при полной высоте
|
||||
спрайта 63 строки самый дорогой замеренный блит целиком — 32 073.
|
||||
Даже если мерить самым грубым образом (одна выборка на целый блит,
|
||||
а не на чанк), зазор вдвое меньше окна бланка.
|
||||
|
||||
## 9.4. Где ставить выборки
|
||||
|
||||
Правило простое: **выборка обязана стоять так, чтобы ни один путь
|
||||
исполнения не давал зазора длиннее 64 512 тактов.** По фазам:
|
||||
|
||||
- **Зелёная и циан** (436 494 и 382 770 тактов на 13/23) состоят из
|
||||
блитов и хилов. Достаточно одной выборки на вызов наших обёрток
|
||||
`pop_blit_b`, `pop_heal_off`, `pop_heal_fast` — но НЕ только их:
|
||||
прямые вызовы `gfx_blit_*` / `gfx_heal*` разбросаны по шести файлам
|
||||
(`pop_cdraw.c`, `pop_draw.c`, `pop_kdraw.c`, `pop_room.c`,
|
||||
`pop_state.c`, `pop_tile.c`). Точный набор точек определяем НЕ
|
||||
рассуждением, а замером (см. 9.7): ставим в обёртки, меряем худший
|
||||
зазор, добавляем точки только там, где замер их требует.
|
||||
- **Синяя** (149 106 тактов) — блитов нет вообще, это 2,3 окна бланка.
|
||||
Нужны явные точки: после ввода, после физики, после `pop_process_trobs`,
|
||||
после mob-тика. Ставятся на границах, которые и так размечены
|
||||
зондами `pop_dbg_m*`.
|
||||
|
||||
Чего заведомо НЕ хватит: выборок только в главном цикле. Один
|
||||
`pop_floor_bake` — 179 914 тактов, почти три окна бланка.
|
||||
|
||||
## 9.5. Ожидание — тот же примитив
|
||||
|
||||
Ожидание фронта и есть плотная выборка, поэтому оно сливается со
|
||||
счётчиком, а опрос клавиатуры остаётся ровно таким же плотным, как
|
||||
сегодня:
|
||||
|
||||
```c
|
||||
static void wait_edge(void)
|
||||
{
|
||||
uint8_t t = frame_tick;
|
||||
do {
|
||||
pop_beam_sample();
|
||||
kbd_raw_poll(); /* то, что сейчас висит idle-хуком */
|
||||
} while (frame_tick == t);
|
||||
}
|
||||
```
|
||||
|
||||
`gfx_set_idle_hook` при этом больше не нужен — опрос зовётся прямо.
|
||||
Обязателен аварийный выход по счётчику попыток (как в нынешнем
|
||||
`gfx_wait_vsync`): на железе, где бит ведёт себя иначе, цикл не должен
|
||||
виснуть насмерть.
|
||||
|
||||
## 9.6. Пейсинг целиком
|
||||
|
||||
```c
|
||||
uint8_t k = (uint8_t)(frame_tick - anchor); /* фронтов съел рендер */
|
||||
if (k >= n) {
|
||||
wait_edge(); /* опоздали — выравниваемся на ближайший */
|
||||
} else {
|
||||
do { wait_edge(); } while ((uint8_t)(frame_tick - anchor) < n);
|
||||
}
|
||||
anchor = frame_tick; /* якорь по ФАКТУ, фаза не копится */
|
||||
flip_page();
|
||||
```
|
||||
|
||||
`anchor = frame_tick`, а не `anchor += n` — сознательно: догонять
|
||||
пропущенное время нельзя, иначе после тяжёлого кадра игра рванёт
|
||||
вперёд. Это же правило заложено в исходном дизайне делителя
|
||||
(«выравнивание на ближайший фронт, без накопления фазовой ошибки»).
|
||||
|
||||
Поведение по случаям:
|
||||
|
||||
| работа W (растров) | период | комментарий |
|
||||
|---|---|---|
|
||||
| W ≤ n | ровно n | цель задачи |
|
||||
| n < W ≤ n+1 | ceil(W) | подтормаживает ровно настолько, насколько не успели |
|
||||
| вход в комнату, W ≫ n | ceil(W) + 1 | ветка «опоздали»: один фронт, без растягивания |
|
||||
| загрузка уровня, файловые операции | ceil(W) + 1…n | выборок нет вовсе → k занижен; худшее — n лишних растров ОДИН раз |
|
||||
|
||||
Последняя строка — единственный случай, где счёт неточен, и он
|
||||
безобиден: во время `ESTEX`-вызова выбирать нечего, а ошибка живёт один
|
||||
кадр, потому что якорь переставляется по факту.
|
||||
|
||||
## 9.7. Как это доказывается, а не декларируется
|
||||
|
||||
Инструмент уже построен и проверен на нынешнем коде (§4): брейкпоинт на
|
||||
следующей инструкции пишет `temp3 = totalcycles`, второй с условием
|
||||
`(totalcycles - temp3) > 0x9D800` останавливает машину.
|
||||
|
||||
Для приёмки он ставится **на инструкцию инкремента `frame_tick`**.
|
||||
Если хоть один фронт пропущен, зазор между инкрементами станет два
|
||||
растра и детектор остановит машину. Критерий: **10 000 кадров без
|
||||
единого срабатывания**, включая смену комнаты и смерть Кида.
|
||||
|
||||
Дополнительно, в отладочной сборке — перекрёстная проверка со счётчиком
|
||||
кадровых прерываний (слот цепочки, один INC): прерывания теряются, луч
|
||||
не должен, значит `frame_tick` обязан идти НЕ МЕДЛЕННЕЕ `irq_tick`.
|
||||
Расхождение в другую сторону = пропущенная выборка.
|
||||
|
||||
Напоминание о методике: **счёт попаданий брейкпоинтом на этом драйвере
|
||||
недостоверен** (WAIT-линия, инструкция пересчитывается) — только
|
||||
детектор разрыва. И литералы в отладчике MAME шестнадцатеричные.
|
||||
|
||||
## 9.8. Цена
|
||||
|
||||
| статья | тактов |
|
||||
|---|---:|
|
||||
| одна выборка (с вызовом) | ≈ 120 |
|
||||
| ~60 выборок за логический кадр | ≈ 7 200 |
|
||||
| доля от бюджета при n=3 (1 290 240) | **0,6 %** |
|
||||
|
||||
В горячих местах (`pop_blit_b`) выборку можно заинлайнить и снять цену
|
||||
вызова.
|
||||
|
||||
## 9.9. Чем это лучше счётчика прерываний
|
||||
|
||||
| | счётчик кадровых IRQ | счётчик по лучу |
|
||||
|---|---|---|
|
||||
| теряет тик под `di` акселератора | да, фазозависимо 0…3 % | нет — читается положение луча |
|
||||
| теряет тик, если IRQ съела клавиатурная/CBL-ветка трамплина | да (приватный RETI) | нет |
|
||||
| ломается от добавления звука | да (ещё один источник, `irqack` гасит все входы мержера) | нет |
|
||||
| поведение на полной перерисовке | 3 тика подряд мимо | считает все |
|
||||
| условие корректности | никакого — не в нашей власти | зазор выборок < 64 512 тактов, проверяется артефактом |
|
||||
| риск залипнуть в плохой фазе и ровно потерять 25 % скорости | есть | нет |
|
||||
|
||||
## 9.10. Что осталось проверить зондами до кодирования
|
||||
|
||||
1. **Худший зазор между выборками** при размещении «только в обёртках»
|
||||
— сколько точек реально нужно добавить. Это же число решает, нужна
|
||||
ли выборка в синей фазе в четырёх местах или в двух.
|
||||
2. **Владение `cbl_mode`**: сейчас бит 5 доступен потому, что
|
||||
`gfx_wait_vsync` взвёл `_cbl_port_ref()` при первом вызове. Свой
|
||||
ожидатель обязан взвести его сам — значит примитив логичнее держать
|
||||
в libbgi (там доступен `_cbl_port_ref`), а не в приложении.
|
||||
3. **Ветка `frozen`** (`roomtest.c:320`) — какой темп ей нужен.
|
||||
4. Совпадает ли момент возврата `wait_edge()` с нынешним возвратом
|
||||
`gfx_wait_vsync()` с точностью до микросекунд (иначе поедет момент
|
||||
свопа и появятся разрывы картинки).
|
||||
|
||||
|
||||
---
|
||||
|
||||
# 10. РЕЗУЛЬТАТ (2026-08-19, реализовано и проверено в MAME)
|
||||
|
||||
Реализовано в приложении (`roomtest/pop_pace.c/.h`), в libbgi пока НИЧЕГО не
|
||||
переносили — по решению пользователя: сначала обкатать у себя.
|
||||
|
||||
## 10.1. Что сделано
|
||||
|
||||
- `pop_beam_sample()` — выборка бита 5 порта `0xFE`, 10 инструкций,
|
||||
быстрый путь 46 T + вызов. Модуль НЕ банковый, поэтому из банков
|
||||
зовётся прямым `call` (проверено: банки так зовут `_pop_cd_hit_slot`).
|
||||
- `pop_wait_edge()` — ожидание одного фронта; внутри тот же
|
||||
`kbd_raw_poll()`, что раньше висел idle-хуком.
|
||||
- `pop_pace_end(n)` — добрать до n фронтов от якоря; якорь ставится ПО
|
||||
ФАКТУ. Главный цикл: `pop_wait_edge()` → строб вспышки →
|
||||
`pop_pace_end(n)` → своп страниц.
|
||||
- `pop_pace_arm()` — взводит `cbl_mode` через `gfx_wait_vsync()` и
|
||||
ПРОВЕРЯЕТ, что фронты идут; если нет — `pace_ok = 0` и всё молча
|
||||
откатывается на прежние `gfx_wait_vsync`.
|
||||
- Режимы FASTEST/FAST/NORMAL, клавиша **P** по кругу, дефолт FASTEST.
|
||||
Условие боя — `Kid.sword == SWORD_2_DRAWN`, буквально как у оригинала.
|
||||
|
||||
## 10.2. Где стоят выборки и как они выбраны
|
||||
|
||||
Точки ставились **не на глаз, а по замеру**: детектор зазора между
|
||||
выборками (порог 64 512) останавливает машину, адрес возврата со стека
|
||||
называет виновника. Пять итераций «замерил → закрыл дыру → перемерил»:
|
||||
|
||||
| итерация | найденная дыра | тактов |
|
||||
|---|---|---:|
|
||||
| 1 | `kid_tick` + `pop_phys_tick` без выборок | 68 340 |
|
||||
| 1 | весь циан, когда оба персонажа «тихие» | 100 044 |
|
||||
| 2 | отрисовка персонажей идёт мимо `pop_blit_b` | 82 242 |
|
||||
| 2 | полоса HP: `pop_kid_img_blit` в цикле | 86 586 |
|
||||
| 3 | между двумя `pop_blit_b` — работа `pop_bg` | 75 000 |
|
||||
| 4 | сам блит и сам heal (выборка была только НА ВХОДЕ) | 71 900 |
|
||||
| 5 | `pop_loose_tick` | 73 990 |
|
||||
|
||||
Итог: выборки в `pop_blit_b` (вход и перед каждым `gfx_w0_unmap`),
|
||||
`pop_heal_fast` (вход и выход), после каждого `gfx_set_bank(SPRITE)` в
|
||||
`pop_cdraw/pop_kdraw/pop_room`, в 16 потайловых функциях `pop_bg`, после
|
||||
зондов `pop_dbg_p1..p8` в физике и в 21 точке главного цикла.
|
||||
|
||||
## 10.3. Замеры
|
||||
|
||||
Метод точности счётчика — **атомарный снимок одной командой отладчика**:
|
||||
`printf "%d %d", totalcycles, b@<адрес pop_frame_tick>`. Раздельные
|
||||
`lmem` и `print totalcycles` НЕ ГОДЯТСЯ: между двумя обращениями к мосту
|
||||
проходят десятки кадров, и «недосчёт» получается на ровном месте (на этом
|
||||
я сначала и обжёгся).
|
||||
|
||||
| проверка | результат |
|
||||
|---|---|
|
||||
| счётчик, 11/15 покой, 5 окон | недосчёт **0** (270 растровых кадров) |
|
||||
| счётчик, 11/15 тяжёлая позиция Кида | недосчёт **0** |
|
||||
| счётчик, 13/23 | недосчёт **0** |
|
||||
| период кадра, 11/15 | **ровно 3 растра**: ни длиннее 3,1, ни короче 2,9 на 302 логических кадрах |
|
||||
| период кадра, 13/23 | **ровно 3 растра** на 308 логических (эталон был 3/4/5) |
|
||||
| режим NORMAL | ровно 4 растра |
|
||||
| NORMAL + `Kid.sword = 2` | ровно 5 растров |
|
||||
| клавиша P | 0 → 1 → 2 → 0 |
|
||||
| клавиатура | Кид отвечает на удержание и отпускание |
|
||||
|
||||
**Цена выборок** — A/B прямо в памяти (заглушить `pop_beam_sample`
|
||||
байтом `C9` и снять `pace_ok`, чтобы игра не зависла в ожидании фронта):
|
||||
работа за кадр 543 860 с выборками против 539 832 без — **≈4 000 тактов,
|
||||
0,9 %**. На фоне бюджета, который вырос втрое, это ничто.
|
||||
|
||||
## 10.4. Грабли, стоившие времени
|
||||
|
||||
1. **Литералы в отладчике MAME шестнадцатеричные.** Порог «700000» на
|
||||
деле проверял 0x700000 = 17 растров и не срабатывал никогда.
|
||||
2. **Счёт попаданий брейкпоинтом на этом драйвере недостоверен** (WAIT-
|
||||
линия, инструкция пересчитывается): наблюдались «попаданий больше, чем
|
||||
растровых кадров». Достоверны только сравнения ВРЕМЁН.
|
||||
3. **Раздельные чтения через мост не атомарны** (см. 10.3).
|
||||
4. **Мёртвый Кид перезапускает уровень** раз в `RESPAWN_DELAY` тиков, а
|
||||
рестарт уровня — это 3-5 растров без единой выборки. Полдня я гонялся
|
||||
за «дырой в статике», которой не было: Кид успел убежать в соседнюю
|
||||
комнату и погибнуть, пока я мерил. **Проверяй, что на экране, прежде
|
||||
чем объяснять числа.**
|
||||
5. **`make LEVEL=13` без `make clean` не пересобирает** — флаги в
|
||||
зависимостях не участвуют, на диск уезжает старый уровень.
|
||||
|
||||
## 10.5. Что осталось
|
||||
|
||||
- Перенос примитива в libbgi — по решению пользователя ПОСЛЕ обкатки.
|
||||
Там же уместнее взводить `_cbl_port_ref` напрямую, без обходного
|
||||
`gfx_wait_vsync()` в `pop_pace_arm`.
|
||||
- Загрузка уровня/комнаты остаётся без выборок (ESTEX-вызовы) — счётчик
|
||||
там недосчитывает. Это безобидно: якорь переставляется по факту, и
|
||||
ошибка живёт один кадр. Отдельного «resync» не потребовалось.
|
||||
- Проверить на реальном железе, что `pace_ok` взводится (в MAME — да).
|
||||
@@ -0,0 +1,367 @@
|
||||
# От roomtest к полноценной игре — сценарий и оболочка
|
||||
|
||||
Статус: **частично реализовано; аудит обновлён 2026-08-24**. Ранее пометка
|
||||
«завершены FG0–FG12» была неверной: для многих этапов уже есть код и
|
||||
host-тесты, но их критерии приёмки на Sprinter ещё не выполнены. Фактический
|
||||
статус каждого FG приведён в [§14](#14-этапы-реализации).
|
||||
|
||||
Этот документ описывает превращение текущего игрового цикла
|
||||
`roomtest` в законченную игру: заставка, интро, демонстрационный уровень,
|
||||
сцены между уровнями, таймер, финал и Hall of Fame. План меню и постоянных
|
||||
настроек вынесен в [`menu_settings_plan.md`](menu_settings_plan.md),
|
||||
детальный план QuickSave — в [`quicksave_plan.md`](quicksave_plan.md).
|
||||
|
||||
## 1. Зафиксированный scope
|
||||
|
||||
- Целевая последовательность — оригинальная SDLPoP/DOS PoP с уровнями
|
||||
**1..14**. Уровень 14 — скрытая финальная часть после Джаффара: его номер
|
||||
игроку не показывается, победа наступает в комнате 5.
|
||||
- Уровень **15 удаляется полностью**: не пакуется на HDD, не загружается,
|
||||
отсутствует в переходах, читах и UI; специальная логика potions/copy
|
||||
protection level удаляется.
|
||||
- Уровень **0** остаётся только демонстрационным (attract mode), а не частью
|
||||
новой игры.
|
||||
- Title sequence повторяет SDLPoP. Перед ней допускается отдельный
|
||||
пропускаемый экран с информацией о Sprinter-сборке.
|
||||
- Первая версия использует текущий профиль поведения `VANILLA`. Сейчас это
|
||||
означает **существующую реализацию roomtest**, включая уже встроенные
|
||||
исправления. Аудит и разведение `VANILLA/ENHANCED` — будущая задача.
|
||||
- Программа работает **только с HDD**. Варианты без сохранения для floppy не
|
||||
проектируются.
|
||||
- Моды и выбор levelset в этот план не входят.
|
||||
|
||||
## 2. Что делает SDLPoP
|
||||
|
||||
Источники истины в локальном SDLPoP:
|
||||
|
||||
- `src/seg000.c`: `start_game()`, `show_title()`, demo mode, общий кадр,
|
||||
проверка финала;
|
||||
- `src/seg003.c`: `init_game()`, `play_level()`, `play_level_2()`;
|
||||
- `src/seg001.c`: cutscene engine, `pv_scene()`, сцены 2/4/6/8/9/12,
|
||||
`time_expired()`, `end_sequence()` и Hall of Fame;
|
||||
- `src/data.h`: `tbl_cutscenes`, параметры уровня 0, win level/room;
|
||||
- `data/TITLE`, `data/PV`, `data/LEVELS/res2000.bin`: ресурсы оболочки.
|
||||
|
||||
Штатный маршрут:
|
||||
|
||||
```text
|
||||
boot
|
||||
-> title / story screens
|
||||
-> Princess + Jaffar intro
|
||||
-> credits / Hall of Fame
|
||||
-> demo level 0
|
||||
-> title или новая игра
|
||||
-> levels 1..14
|
||||
before 2 -> princess cutscene
|
||||
before 4 -> princess cutscene
|
||||
before 6 -> princess cutscene
|
||||
before 8 -> princess + mouse
|
||||
before 9 -> princess + mouse
|
||||
before 12 -> scene selected by remaining time
|
||||
-> level 14, room 5
|
||||
-> embrace + mouse
|
||||
-> ending text/music
|
||||
-> Hall of Fame
|
||||
-> title
|
||||
```
|
||||
|
||||
Кроме уровней, здесь есть глобальный 60-минутный таймер, сцена истечения
|
||||
времени, пропуск сцен клавишей, fade/flash, ожидание музыки и возврат в
|
||||
attract loop после демо или финала.
|
||||
|
||||
## 3. Текущее состояние roomtest
|
||||
|
||||
Уже реализованы игровой кадр, комнаты, уровни, тайлсеты, Kid/Guard/Shadow,
|
||||
Джаффар, специальные события, checkpoint, переходы уровней, бесшовный
|
||||
выход 12-го уровня, перенос максимального HP и звуковые эффекты.
|
||||
|
||||
Поверх игрового цикла уже добавлены автомат оболочки, title/story, demo
|
||||
уровень 0, global timer, сценарный интерпретатор, level-flow, ending и Hall
|
||||
of Fame. Полный маршрут также собирается в HDD-образ.
|
||||
|
||||
Однако это **не означает готовность оболочки**. На момент аудита остаются
|
||||
существенные незакрытые места:
|
||||
|
||||
- lifecycle палитр: gameplay-переходы используют чёрный барьер без fade;
|
||||
cold start и полный набор dungeon/palace переходов ещё не прошли приёмку;
|
||||
- PV intro Princess/Jaffar уже покадровый (актёры, факелы, звёзды, часы,
|
||||
молния и foreground-колонна); сцены перед 2/4/6 и длинной веткой 12
|
||||
анимируют факелы, звёзды и песок, а сцены 8/9 и короткая ветка 12 пока
|
||||
используют статические позы с исходной длительностью;
|
||||
- demo отображается с игровой палитрой, проходит второй разворот/зацеп и
|
||||
доходит до боя; после смерти Кида корректно завершает цикл;
|
||||
- time-expired, ending и Hall of Fame имеют маршрут и реализацию UI, но не
|
||||
прошли сквозную MAME-проверку вместе с ресурсами и возвратом к title;
|
||||
- нет полного регресса EMM/FD для каждого перехода состояния.
|
||||
|
||||
## 4. Архитектура: автомат состояний приложения
|
||||
|
||||
Нельзя наращивать все режимы условиями внутри кадрового цикла. Текущий
|
||||
цикл должен стать реализацией одного состояния `PLAYING`:
|
||||
|
||||
```text
|
||||
BOOT -> BUILD_INFO -> TITLE -> INTRO -> DEMO
|
||||
| |
|
||||
+---- NEW_GAME <-+
|
||||
|
||||
NEW_GAME -> LEVEL_LOAD -> PLAYING <-> PAUSE_MENU
|
||||
|
|
||||
+-> CUTSCENE -> LEVEL_LOAD
|
||||
+-> TIME_EXPIRED -> TITLE
|
||||
+-> ENDING -> HALL_OF_FAME -> TITLE
|
||||
```
|
||||
|
||||
Минимальный контекст оболочки:
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
POP_APP_BOOT,
|
||||
POP_APP_BUILD_INFO,
|
||||
POP_APP_TITLE,
|
||||
POP_APP_INTRO,
|
||||
POP_APP_DEMO,
|
||||
POP_APP_LEVEL_LOAD,
|
||||
POP_APP_PLAYING,
|
||||
POP_APP_PAUSE_MENU,
|
||||
POP_APP_CUTSCENE,
|
||||
POP_APP_TIME_EXPIRED,
|
||||
POP_APP_ENDING,
|
||||
POP_APP_HALL_OF_FAME,
|
||||
POP_APP_QUIT
|
||||
} pop_app_state_t;
|
||||
```
|
||||
|
||||
Переходы задаются результатом состояния, а не прямыми рекурсивными
|
||||
вызовами наподобие SDLPoP `start_game()`/`longjmp()`. На Z80 это проще для
|
||||
стека и позволяет освобождать ресурсы каждого режима в одном месте.
|
||||
|
||||
## 5. Ресурсная модель
|
||||
|
||||
Title и cutscene-ресурсы нельзя постоянно держать рядом с игровыми
|
||||
атласами. Для каждого состояния нужен явный lifecycle:
|
||||
|
||||
```text
|
||||
enter: pause sound -> unload incompatible set -> load set -> apply palette
|
||||
run: process input/timer/animation
|
||||
leave: stop sound -> release EMM pages -> clear transient state
|
||||
```
|
||||
|
||||
Новые группы HDD:
|
||||
|
||||
```text
|
||||
TITLE\ title/story images, palette, optional build-screen assets
|
||||
PV\ princess room, Princess/Jaffar/mouse frames, palettes
|
||||
MUSIC\ intro, cutscene and ending tracks/samples
|
||||
LEVELS\ res2000..res2014.bin
|
||||
```
|
||||
|
||||
Конкретный формат атласов выбирает упаковщик. Runtime не должен разбирать
|
||||
PNG/DAT: как и игровые спрайты, он получает подготовленные `.atl`/`.bin`.
|
||||
|
||||
## 6. Экран Sprinter build
|
||||
|
||||
Отдельное состояние перед оригинальной заставкой:
|
||||
|
||||
```text
|
||||
PRINCE OF PERSIA
|
||||
SPRINTER SP2000 BUILD
|
||||
version / date / build id
|
||||
```
|
||||
|
||||
Требования:
|
||||
|
||||
- пропускается любой клавишей;
|
||||
- выключается в Settings;
|
||||
- не запускает музыку оригинального title и не меняет её тайминги;
|
||||
- данные версии генерируются сборкой, а не правятся вручную в C;
|
||||
- отсутствие экрана приводит прямо к `TITLE`.
|
||||
|
||||
## 7. Title и текстовая подсистема
|
||||
|
||||
Порядок переносится из `show_title()`:
|
||||
|
||||
1. основной титульный экран;
|
||||
2. Presents;
|
||||
3. название игры и Jordan Mechner;
|
||||
4. story frame / “In the absence…”;
|
||||
5. intro Princess + Jaffar;
|
||||
6. story “Marry Jaffar…”;
|
||||
7. credits;
|
||||
8. Hall of Fame, если таблица непуста;
|
||||
9. demo level 0.
|
||||
|
||||
Нужны общие примитивы: загрузить full-screen image, вывести строку,
|
||||
показать экран заданное время, transition left-to-right, fade in/out,
|
||||
прервать ожидание клавишей. Текст и меню должны использовать один renderer.
|
||||
|
||||
Критерий: последовательность и музыкальные точки совпадают с SDLPoP;
|
||||
Sprinter build screen не сдвигает оригинальный soundtrack.
|
||||
|
||||
## 8. Demo level 0
|
||||
|
||||
- Добавить на HDD `res2000.bin`.
|
||||
- Загружать уровень обычным loader, но выставлять demo HP и demo mode.
|
||||
- Воспроизводить `demo_moves` как синтетический источник `control_*`.
|
||||
- Пользовательский ввод прерывает демо и начинает новую игру.
|
||||
- Достижение demo end room (у SDLPoP — 24), смерть или конец скрипта
|
||||
возвращают в `TITLE`.
|
||||
- Pause menu, QuickSave и cheats в demo недоступны.
|
||||
- RNG демо и начальное состояние должны быть детерминированы.
|
||||
|
||||
Критерий: без ввода attract loop не требует перезапуска процесса;
|
||||
title -> demo -> title повторяется неограниченно.
|
||||
|
||||
## 9. Глобальный таймер
|
||||
|
||||
Состояние: минуты, тики и флаг показа. Таймер создаётся при New Game,
|
||||
переносится между уровнями и входит в QuickSave.
|
||||
|
||||
Правила `VANILLA`:
|
||||
|
||||
- на pause menu, загрузке HDD, QuickSave/QuickLoad время не идёт;
|
||||
- игровые тики следуют темпу логического кадра, а не частоте render loop;
|
||||
- поведение во время level-end sound и cutscenes сверяется буквально с
|
||||
SDLPoP;
|
||||
- после Джаффара/на финальном уровне время не должно вызвать поражение;
|
||||
- ноль времени переводит приложение в `TIME_EXPIRED`.
|
||||
|
||||
Критерий: одинаковый игровой отрезок в NORMAL даёт то же уменьшение времени,
|
||||
что SDLPoP; сохранение/загрузка не добавляет и не отнимает тики.
|
||||
|
||||
Реализация FG4 живёт одним модулем `roomtest/pop_timer.c` в bank 9:
|
||||
`60:719`, 720 тиков на минуту, счёт только в живом игровом кадре. Settings
|
||||
хранит `TIME LIMIT: 60 MIN / UNLIMITED` в `POP.CFG`; старый семибайтный v1
|
||||
payload по-прежнему читается как `60 MIN`. Читы таймера повторяют SDLPoP,
|
||||
но из-за занятого `+/-` используют F7 (−1 минута, не ниже одной) и F8
|
||||
(+1 минута). Состояние входит в QuickSave v4.
|
||||
|
||||
## 10. Cutscene engine
|
||||
|
||||
Сцены SDLPoP состоят из небольшого набора повторяемых команд. Вместо набора
|
||||
крупных C-функций нужен компактный интерпретатор:
|
||||
|
||||
```text
|
||||
SET_ACTOR actor
|
||||
SET_POS x,y,dir
|
||||
START_SEQ seq
|
||||
WAIT_FRAMES n
|
||||
PLAY_SOUND id
|
||||
WAIT_SOUND
|
||||
SET_HOURGLASS frame
|
||||
SET_SAND state
|
||||
FLASH color,frames
|
||||
FADE_IN / FADE_OUT
|
||||
CLEAR_ACTOR actor
|
||||
END
|
||||
```
|
||||
|
||||
Скрипты — `const` в холодном банке или подготовленный бинарный ресурс.
|
||||
Interpreter обязан:
|
||||
|
||||
- исполнять один шаг/кадр без блокирующих длинных циклов;
|
||||
- поддерживать пропуск сцены;
|
||||
- при пропуске выполнять cleanup и выходить в заранее заданное состояние;
|
||||
- освобождать PV-ресурсы перед загрузкой игрового тайлсета;
|
||||
- не разрешать pause menu/QuickSave внутри сцены.
|
||||
|
||||
Порядок переноса: intro, 2/6, 4, 8, 9, 12, time expired, ending. Сцена 12
|
||||
выбирает короткий или обычный вариант по остатку времени.
|
||||
|
||||
## 11. Переходы между уровнями
|
||||
|
||||
Таблица сценария должна быть отдельна от таблиц механики уровня:
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
uint8_t level;
|
||||
uint8_t pre_cutscene;
|
||||
uint8_t show_level_number;
|
||||
uint8_t ending_rule;
|
||||
} pop_level_flow_t;
|
||||
```
|
||||
|
||||
Особые правила:
|
||||
|
||||
- New Game начинает уровень 1;
|
||||
- перед 2/4/6/8/9/12 запускается сцена;
|
||||
- 12 -> 13 остаётся бесшовным;
|
||||
- после победы над Джаффаром переход идёт в 14;
|
||||
- номер 14 не показывается;
|
||||
- вход в комнату 5 уровня 14 переводит в `ENDING`;
|
||||
- значения больше 14 недопустимы и дают диагностическую ошибку, а не
|
||||
попытку открыть файл.
|
||||
|
||||
## 12. Ending и Hall of Fame
|
||||
|
||||
Ending:
|
||||
|
||||
1. загрузить PV-набор;
|
||||
2. встреча Kid и Princess;
|
||||
3. объятие;
|
||||
4. появление мыши;
|
||||
5. ending music;
|
||||
6. финальные story/title экраны;
|
||||
7. переход в Hall of Fame.
|
||||
|
||||
Hall of Fame хранится на HDD в отдельном версионированном `POP.HOF`.
|
||||
Сохраняются имя и результат; ввод имени использует тот же текстовый/UI слой.
|
||||
Повреждённый или неизвестный формат означает пустую таблицу, но не мешает
|
||||
запуску игры. После показа — возврат в `TITLE`.
|
||||
|
||||
## 13. Удаление уровня 15
|
||||
|
||||
Отдельный ранний этап, чтобы новый flow не наследовал лишний маршрут:
|
||||
|
||||
- убрать `res2015.bin` из `LVL_NUMS` и HDD image;
|
||||
- заменить последний игровой уровень на 14;
|
||||
- остановить Shift+L и прочую навигацию на 14;
|
||||
- удалить `POP_POTIONS_LEVEL` и специальный половинный урон синих зелий;
|
||||
- исключить copy protection из конфигурации и меню;
|
||||
- добавить тест: после уровня 14 приложение входит в ending и никогда не
|
||||
запрашивает `res2015.bin`.
|
||||
|
||||
## 14. Этапы реализации
|
||||
|
||||
Легенда аудита: **✓** — критерий этапа закрыт; **~** — код существует, но
|
||||
критерий приёмки ещё не закрыт; **○** — не начат. Статус отражает состояние
|
||||
исходников и последней MAME-проверки на 2026-08-24, а не только наличие
|
||||
модуля в bank 9.
|
||||
|
||||
| этап | статус | результат и фактическое состояние | критерий приёмки |
|
||||
|---|---|---|---|
|
||||
| **FG0** | ✓ | `POP_LEVEL_LAST=14`, HDD содержит `res2000..res2014`; `t_flow` отвергает 15 | HDD не содержит res2015; переход выше 14 невозможен |
|
||||
| **FG1** | ~ | автомат `pop_app` и `t_app` реализованы; сквозной ресурсный lifecycle и контроль EMM/FD ещё не измерены | старт/рестарт/выход проходят без рекурсии и утечки EMM |
|
||||
| **FG2** | ✓ | QuickSave/QuickLoad с `POP.SAV` и `POP.BAK`; отдельно проверен в MAME 2026-08-22 | критерии `quicksave_plan.md`, включая POP.BAK |
|
||||
| **FG3** | ✓ | pause menu, Settings, подтверждения и двойной буфер реализованы; меню проверялось в MAME; добавлены SDLPoP-звуки навигации и защита CBL вокруг полного redraw/файловых операций | Resume/Save/Load/Restart/Settings/Quit работают |
|
||||
| **FG4** | ~ | `pop_timer`, настройка unlimited, F7/F8 и состояние QuickSave реализованы; есть host-тест, но нет буквального сравнения темпа со SDLPoP на всех переходах | совпадение с SDLPoP и корректный save/load |
|
||||
| **FG5** | ~ | text/full-screen/fade примитивы есть; для входа в первый уровень и границ уровней выбран мгновенный чёрный барьер без fade: CBL и яркая новая палитра включаются только после подготовки обеих страниц; Level 1 проверен в MAME | тестовые экраны и переходы на Sprinter |
|
||||
| **FG6** | ~ | title-ресурсы и порядок кадров реализованы; Enter на title и Esc на первом story в MAME переводят прямо в `FIRST_LEVEL`, минуя demo; полная cold-boot приёмка fade остаётся в FG5 | основной титул/Presents/название/Mechner идут в точном порядке `show_title()`; Enter/Space/Esc/стрелки прерывают ожидание; story/intro продолжит FG8 |
|
||||
| **FG7** | ✓ | level 0, исходная таблица `demo_moves`, demo HP=4 и блокировка игрового UI реализованы; исправлены зеркалирование auto-control, боевой AI Кида и завершение после смерти; в MAME demo проходит разворот/зацеп, доходит до боя и возвращается в attract-цикл без повторного убийства | `res2000.bin`, исходная `demo_moves`, demo HP=4; бесконечный attract loop, любой ввод начинает чистую новую игру; Pause/QuickSave/читы/таймер отключены |
|
||||
| **FG8** | ~ | data-driven interpreter и покадровый PV intro работают; в MAME проверены актёры, факелы, звёзды 1x1, часы/песок, palette-0 lightning и foreground-колонна; Enter/Esc переводят прямо в `FIRST_LEVEL`; временный темп 12,5 FPS и TODO точного pacing записаны в `impl_diff.md` | story/PV intro проходит, любой raw-ввод пропускает его без удержания EMM-страниц |
|
||||
| **FG9** | ~ | `pop_flow` корректно маршрутизирует 2/4/6/8/9/12 и ветку <=5 минут (`t_flow`); 2/4/6 и длинная 12 уже обновляют часы, песок, факелы и звёзды каждые 5 кадров Sprinter; длительности всех веток сверены с SDLPoP: 2/4/6/12 — 2,6 с, 8 — 6,0 с, 9 — 7,2 с; входная клавиша gameplay/Shift+L поглощается до сцены, а новое нажатие делает skip; анимации мыши/Princess в 8/9 и разворот Princess в короткой 12 ещё статичны | таблица flow переводит в CUTSCENE ровно перед 2/4/6/8/9/12; scene 12 выбирает короткий вариант при <=5 минутах |
|
||||
| **FG10** | ~ | переход TIME_EXPIRED и экран существуют, но это ещё статическая PV-стадия; сквозной MAME-маршрут не принят | PV-сцена истечения с пропуском, затем возврат на title/attract; новая игра сбрасывает таймер |
|
||||
| **FG11** | ~ | room 5 уровня 14 переводит в ENDING (`t_flow`); объятие/мышь заменены статической стадией, полный маршрут не принят | room 5 уровня 14 переводит в ENDING; PV-финал и Hail-экран возвращают управление оболочке |
|
||||
| **FG12** | ~ | версионированный `POP.HOF`, ввод имени и восстановление после повреждённого файла реализованы; нужна сквозная MAME-проверка ending → HOF → title | версионированный `POP.HOF`, ввод имени raw-клавиатурой, повреждённый файл = пустая таблица, затем title/attract |
|
||||
|
||||
## 15. Проверки
|
||||
|
||||
- Host-тест автомата: все допустимые переходы и отсутствие уровня 15.
|
||||
- Host-тест cutscene interpreter на синтетическом скрипте и skip в каждой
|
||||
ожидающей команде.
|
||||
- Host-тест demo input: одинаковый seed даёт одинаковый поток управления.
|
||||
- MAME: cold boot -> build info -> title -> demo -> title.
|
||||
- MAME: новая игра -> принудительный переход по всем pre-level scenes.
|
||||
- MAME: time expired и пропуск сцены.
|
||||
- MAME: 13 -> 14 -> room 5 -> ending -> HOF -> title.
|
||||
- Проверка EMM/FD до и после каждого состояния: число страниц и открытых
|
||||
файлов возвращается к базовому.
|
||||
- `make size-check`; крупный cold-код размещать в банках и отдельно следить
|
||||
за лимитом 16 КБ каждого банка.
|
||||
|
||||
## 16. Не входит в план
|
||||
|
||||
- уровень 15 и copy protection;
|
||||
- моды и выбор levelset;
|
||||
- replay/recording;
|
||||
- точная эмуляция SDL video/controller options;
|
||||
- профиль ENHANCED и индивидуальные switches fixes.
|
||||
@@ -480,3 +480,170 @@ BUG-CHEAT-FIGHT-1 (выход из боя), и лечится там же.
|
||||
вывод сделан чтением трёх мест кода. Если понадобится подтверждение,
|
||||
сценарий короткий: любой бой у правого края комнаты, вытеснить стража за
|
||||
край и добить.
|
||||
|
||||
## ГСЧ разведён по доменам (у оригинала он ОДИН)
|
||||
|
||||
**Оригинал.** `random_seed` один на всё: кладка стены, анимация тайлов,
|
||||
броски боя, модификаторы падающих плит — всё тянет из одной
|
||||
последовательности (`seg009` PRNG, 32-битный LCG). Поэтому в оригинале
|
||||
бой воспроизводим вместе со всем остальным: тот же сид — тот же бой.
|
||||
|
||||
**У нас.** Сидов несколько: `pop_t_seed` (кладка, `pop_tile.h`),
|
||||
`pop_fight_seed` (броски боя, `pop_guard.h`), отдельные у trob и loose.
|
||||
Сам генератор тот же (`pop_prandom`), таблицы вероятностей —
|
||||
побайтно те же, что в `data.h`.
|
||||
|
||||
**Чем платим.** Конкретный бой у нас и в SDLPoP разойдётся: порядок
|
||||
бросков другой, значит блоки/удары лягут иначе. Статистически поведение
|
||||
то же (те же вероятности, тот же генератор), но «сверить бой кадр в кадр
|
||||
с SDLPoP» нельзя, и QuickSave обязан сохранять ВСЕ сиды, а не один.
|
||||
|
||||
**Что проверять при регрессе.** Если страж кажется сильнее/слабее
|
||||
оригинала — сначала проверить не таблицы (они сверены), а **режим
|
||||
скорости**: `fight_speed` у оригинала 100 мс, а в нашем FASTEST бой идёт
|
||||
61,4 мс, то есть в реальном времени на 63 % быстрее, и на глаз это ровно
|
||||
«страж давит сильнее». Режим NORMAL (дефолт) даёт 102,4 мс — см.
|
||||
`frame_pacing_plan.md`.
|
||||
|
||||
## PV intro: единые 12,5 FPS вместо переменных 10/7,5/8,57 FPS
|
||||
|
||||
**Оригинал.** `proc_cutscene_frame()` двигает последовательности через
|
||||
`cutscene_frame_time`: 6 тиков 60 Гц в начале, 8 после первой речи и 7 во
|
||||
время заклинания. Это соответственно 10, 7,5 и примерно 8,57 FPS.
|
||||
|
||||
**У нас (осознанное временное отличие).** Один логический кадр PV держится
|
||||
четыре физических кадра Sprinter: номинально 50/4 = 12,5 FPS. Молния живёт
|
||||
на отдельной физической шкале и не растягивается этим делителем. Если полная
|
||||
отрисовка пересечёт дополнительный фронт, реальная частота может упасть до
|
||||
10 FPS — это допустимо на текущем этапе, но должно быть измерено.
|
||||
|
||||
**TODO.** Перевести PV-сцену на тот же anchor-based механизм точного темпа,
|
||||
который gameplay использует через `pop_beam_sample/pop_pace_end`: измерять
|
||||
число реально прошедших фронтов во время сборки кадра, держать период ровно
|
||||
четыре фронта при укладывании в бюджет и явно учитывать overrun. После замера
|
||||
можно вернуть точные переменные интервалы SDLPoP без накопления фазы.
|
||||
|
||||
## Межуровневые PV-сцены: сохранён реальный период 100 мс
|
||||
|
||||
Это правило не относится к временному темпу основного Princess/Jaffar intro
|
||||
выше. `reset_cutscene()` SDLPoP задаёт для сцен перед уровнями период
|
||||
6 кадров при 60 Гц, то есть 100 мс. На Sprinter тот же период получается
|
||||
ровно как 5 кадров при 50 Гц.
|
||||
|
||||
Суммы вызовов `proc_cutscene_frame()` перенесены без изменения реального
|
||||
времени: сцены 2/4/6 и обе ветки 12 содержат 26 логических кадров (130
|
||||
физических, 2,6 с), сцена 8 — 60 (300, 6,0 с), сцена 9 — 72 (360, 7,2 с).
|
||||
Fade in/out в эти числа не входят, как и в оригинале.
|
||||
|
||||
## Gameplay: загрузка уровней через чёрный cut, без fade
|
||||
|
||||
**Оригинал.** На границах игровых уровней использует fade out/in.
|
||||
|
||||
**У нас (решение пользователя 2026-08-24).** Вход в первый уровень и
|
||||
переход между уровнями выполняются как `старый кадр -> чёрная палитра ->
|
||||
подготовка -> новый кадр с новой палитрой`. Fade на этих двух маршрутах
|
||||
отсутствует. Сюжетные title/story/PV переходы сохраняют собственные fade и
|
||||
left-to-right эффекты.
|
||||
|
||||
Чёрная палитра устанавливается до любого HDD I/O. Загрузчики guard и
|
||||
tileset сами физически правят отдельные цветовые слоты, поэтому после них
|
||||
чёрный экран подтверждается повторно. Зеркальные атласы уровня 9 готовятся
|
||||
до финального источника палитры. CBL открывается последним: старый порядок
|
||||
`level_switch -> CBL open -> BIOS fade` давал скрежет повторяющейся половины
|
||||
аппаратного буфера на входе в Level 1; после перестановки баг исчез в MAME.
|
||||
|
||||
## Тень: кайма силуэта не подкрашивается фоном
|
||||
|
||||
**Оригинал.** Спрайт Тени не хранится — он кладётся ДВАЖДЫ: обычным
|
||||
прозрачным блитом в x и «блиттером XOR» в x+1 (`draw_objtable_item`,
|
||||
seg008.c:1600). XOR идёт по 24-битному RGB того, что УЖЕ на экране
|
||||
(`blit_xor`, seg009.c:3190), поэтому там, где спрайт прозрачен в x, но
|
||||
непрозрачен в x−1, цвет получается как `фон XOR цвет спрайта`.
|
||||
|
||||
**У нас.** Пакетный блит наложения на себя не умеет, поэтому результат
|
||||
запечён в отдельный атлас (`toolchain/pop_pack_shadow.py`,
|
||||
`docs/shadow_atlas_plan.md`). Запекать пришлось для КОНКРЕТНОГО фона, и
|
||||
выбран чёрный: на нём `фон XOR цвет == цвет`, то есть запечка точна.
|
||||
|
||||
**Чем платим.** Ровно одним: **кайма в один пиксель по ЛЕВЫМ кромкам
|
||||
силуэта** на НЕчёрном фоне. У оригинала она принимает оттенок фона, у нас
|
||||
всегда «свой» цвет. Внутренность силуэта и правые кромки совпадают точно —
|
||||
там первый проход уже закрасил пиксель, и от фона результат не зависит.
|
||||
|
||||
**Почему это приемлемо.** Тень бывает на четырёх уровнях, и почти всегда
|
||||
на чёрном: у зеркала (ур. 4), в проёме (5), над пропастью (6), в бою (12).
|
||||
|
||||
**Что проверять при регрессе.** Если Тень окажется на светлом фоне и
|
||||
кайма станет резать глаз — вариантов два: запечь второй набор под светлый
|
||||
фон (ещё 32 страницы EMM) или считать эту кайму прозрачной (силуэт станет
|
||||
на пиксель уже). Оба хуже нынешнего; трогать только по факту жалобы.
|
||||
|
||||
|
||||
## QuickSave/QuickLoad: лейбл печатается ДО дисковой операции, а не после
|
||||
|
||||
**Как в оригинале.** SDLPoP печатает `QUICKSAVE` / `NO QUICKSAVE` (и пару
|
||||
для загрузки) уже ПО РЕЗУЛЬТАТУ операции — `process_quicksave` (seg000:497)
|
||||
сначала делает save/load, потом зовёт `display_text_bottom` и ставит
|
||||
`text_time_total = 24`. На PC это незаметно: файл пишется мгновенно.
|
||||
|
||||
**У нас.** `pop_qsave_process` заявляет строку ПЕРВЫМ действием, ещё до
|
||||
`mem_alloc_pages`/ESTEX, через `pop_status_show_now()` — та печатает её
|
||||
немедленно в ВИДИМУЮ страницу, не дожидаясь конца кадра. Отказ уже потом
|
||||
переписывает строку на `NO QUICKSAVE`/`NO QUICKLOAD` обычной заявкой.
|
||||
|
||||
**Зачем.** Запись снимка на диск занимает доли секунды, и всё это время
|
||||
игра стоит. При порядке оригинала игрок видел сначала необъяснённый фриз,
|
||||
и только по его окончании — надпись, объясняющую то, что уже прошло.
|
||||
Решение пользователя, 2026-08-25.
|
||||
|
||||
**Чем платим.** Строка успевает мигнуть даже там, где операция потом не
|
||||
удалась: сначала `QUICKSAVE`, следом `NO QUICKSAVE`. На практике отказ —
|
||||
редкость (нет места/диска), и «заявка → отказ» читается не хуже.
|
||||
|
||||
**Что проверять при регрессе.** Что после неудачной операции на экране
|
||||
остаётся именно `NO QUICKSAVE`/`NO QUICKLOAD`, а не первая строка: отказ
|
||||
идёт обычной заявкой и печатается кадровым проходом, то есть на кадр позже.
|
||||
|
||||
|
||||
## Смерть Кида: ждём кнопку и перезапускаем УРОВЕНЬ, а не игру
|
||||
|
||||
**Как в оригинале.** `play_kid` (seg006:1383) печатает «Press Button to
|
||||
Continue» с `text_time_total = 288`. Тик — это логический игровой кадр,
|
||||
720 тиков = минута, то есть 12 тиков в секунду: 288 тиков = **24 секунды**.
|
||||
Последние 72 тика (6 секунд) строка мигает с периодом 12 тиков, и на каждом
|
||||
появлении играет звук 38. Дальше развилок ровно две:
|
||||
|
||||
* игрок молчит все 24 секунды — `draw_game_frame` (seg000:958) зовёт
|
||||
`start_game()`, и игра начинается ЗАНОВО, с title, а не с уровня;
|
||||
* игрок нажимает **Enter или Shift** (не любую клавишу!) — seg000:584
|
||||
подменяет их на Ctrl+A: `if (rem_min != 0 && Kid.alive > 6 && (control_shift
|
||||
|| key == SDL_SCANCODE_RETURN)) key = SDL_SCANCODE_A | WITH_CTRL;` — и
|
||||
уровень перезапускается. Условия важны: время не должно быть исчерпано
|
||||
(иначе отработал `expired()`), а `Kid.alive > 6` даёт трупу улечься.
|
||||
|
||||
**У нас.** Обе развилки сведены к одной: 24-секундного выхода в начало
|
||||
игры нет вовсе, строка висит бессрочно (`MSG_HOLD`), а перезапускает уровень
|
||||
ЛЮБАЯ кнопка, а не только Enter/Shift (решение пользователя).
|
||||
`pop_start_level()` возвращает игрока на уровень. Место возрождения выбирает сам `pop_start_level` — на части
|
||||
уровней это не старт, а пройденный чекпойнт. Esc за кнопку продолжения не
|
||||
считается: он открывает pause menu. Логика ожидания живёт в банке
|
||||
(`pop_dead_prompt`, pop_status.c) — резидент W1 переполнен.
|
||||
|
||||
**Зачем.** Решение пользователя, 2026-08-25: возврат к title после каждой
|
||||
смерти в отладочной сборке съедает всё время прохода, а прежний вариант
|
||||
(авто-респавн через 400 кадров либо стрелка вверх) не объяснял игроку, чего
|
||||
от него ждут.
|
||||
|
||||
**Чем платим.** Двумя вещами. Первое: смерть больше не заканчивает
|
||||
партию — счёт попыток фактически бесконечен, тогда как оригинал через 24
|
||||
секунды бездействия отправляет в title. Второе: любая клавиша вместо
|
||||
Enter/Shift означает, что случайное нажатие (например, ещё не отпущенная
|
||||
после боя клавиша) перезапустит уровень — отсюда требование сперва отпустить
|
||||
всё. Когда дойдёт до «настоящей» игры, обе развилки придётся выбирать
|
||||
заново: вернуть таймер на 288 тиков со start_game и сузить клавиши до
|
||||
Enter/Shift — либо оставить как есть уже осознанно.
|
||||
|
||||
**Что проверять при регрессе.** Нажатие принимается только после того, как
|
||||
отпущено ВСЁ, что игрок держал в момент смерти (иначе зажатая при падении
|
||||
стрелка перезапускает уровень мгновенно), и не раньше `RESPAWN_SETTLE`
|
||||
кадров — труп должен успеть лечь.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
* Left: turn or run left
|
||||
* Right: turn or run right
|
||||
* Up: jump or climb up
|
||||
* Down: crouch or climb down
|
||||
* Down+Left/Right: hop
|
||||
* Shift: pick up things
|
||||
* Shift+Left/Right: careful step
|
||||
* Home or Up+Left: jump left
|
||||
* Page Up or Up+Right: jump right
|
||||
* Up while running: running jump
|
||||
* Shift while falling: grab onto ledge
|
||||
|
||||
* Left/Right: walk (advance or retreat)
|
||||
* Shift: strike (attack)
|
||||
* Up: block (defend)
|
||||
* Down: put sword away; press Shift to draw your sword again.
|
||||
|
||||
===
|
||||
|
||||
* Esc: Pause game.
|
||||
* Space: Show how much time is left.
|
||||
* Ctrl+A: Restart level.
|
||||
|
||||
* Ctrl+R: Return to intro.
|
||||
* Ctrl+S: Sound on/off.
|
||||
* Ctrl+M: Music on/off.
|
||||
* Ctrl+V: Show version of SprPoP.
|
||||
* Ctrl+Q: Quit game.
|
||||
|
||||
* F6: Quicksave: Save the exact state of the game.
|
||||
* F9: Quickload: Load what the last quicksave saved.
|
||||
* F12: Save a screenshot to the screenshots folder.
|
||||
* Backspace: Display the in-game menu. (Esc will also display the menu by default, but you can turn that off.)
|
||||
|
||||
* Shift+L: Go to next level.
|
||||
* -: Decrease remaining time by one minute.
|
||||
* +: Increase remaining time by one minute.
|
||||
* R: Resurrect kid.
|
||||
* K: Kill guard.
|
||||
* Shift+I: Flip the screen upside down.
|
||||
* Shift+W: Slow falling.
|
||||
* Shift+S: Restore a lost hit-point. (Like a small red potion.)
|
||||
* Shift+T: Give more hit-points. (Like a big red potion.)
|
||||
|
||||
===
|
||||
|
||||
* H: Look at the room to the left.
|
||||
* J: Look at the room to the right.
|
||||
* U: Look at the room above.
|
||||
* N: Look at the room below.
|
||||
* Ctrl+B: Go back to the room where the prince is. (Undo H,J,U,N.)
|
||||
|
||||
===
|
||||
|
||||
* [: Shift kid 1 pixel to the left.
|
||||
* ]: Shift kid 1 pixel to the right.
|
||||
* T: Toggle display of timer (remaining minutes:seconds:ticks). Also shows the total elapsed ticks during playback.
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
# Pause menu и Settings для Sprinter PoP
|
||||
|
||||
Статус: **MS0, MS2 и MS4–MS8 выполнены** (2026-08-23). Pause menu, CFG,
|
||||
Settings, диалоги, Controls и build screen находятся в bank 9. Решение о
|
||||
рендеринге и затемнении — §10.
|
||||
|
||||
Связанные документы:
|
||||
|
||||
- [`full_game_plan.md`](full_game_plan.md) — автомат состояний, title,
|
||||
demo, cutscenes и ending;
|
||||
- [`quicksave_plan.md`](quicksave_plan.md) — состав и восстановление снимка.
|
||||
|
||||
## 1. Решения
|
||||
|
||||
- Программа работает только с HDD; настройки, QuickSave и Hall of Fame
|
||||
всегда могут быть постоянными файлами.
|
||||
- Основной pause menu обязательно содержит QuickSave и QuickLoad.
|
||||
- QuickSave имеет один слот `POP.SAV`; предыдущая корректная запись хранится
|
||||
как `POP.BAK`.
|
||||
- Первая версия имеет один профиль `VANILLA`. Под этим именем пока понимается
|
||||
**текущее поведение roomtest**, включая уже встроенные исправления.
|
||||
- Дизайн файла и API предусматривает будущий `ENHANCED`, но аудит и
|
||||
переключение fixes сейчас не выполняются.
|
||||
- Уровень 15/copy protection отсутствует.
|
||||
- Моды, levelsets и меню Mods отложены.
|
||||
|
||||
## 2. Что есть в SDLPoP
|
||||
|
||||
`src/menu.c` содержит:
|
||||
|
||||
- Resume, QuickSave, QuickLoad, Restart Level, Settings, Restart Game, Quit;
|
||||
- General, Gameplay, Visuals, Mods, Controls;
|
||||
- toggle/number/key controls, пояснения, scroll и confirmation dialogs;
|
||||
- большой список fixes/enhancements и custom level options.
|
||||
|
||||
На Sprinter не переносятся SDL-специфичные параметры: fullscreen, hardware
|
||||
acceleration, scaling, aspect ratio, rumble. UI берёт структуру SDLPoP, но
|
||||
набор настроек соответствует платформе.
|
||||
|
||||
## 3. Pause menu первой версии
|
||||
|
||||
```text
|
||||
RESUME
|
||||
QUICKSAVE (F6)
|
||||
QUICKLOAD (F9)
|
||||
RESTART LEVEL
|
||||
SETTINGS
|
||||
RESTART GAME
|
||||
QUIT GAME
|
||||
```
|
||||
|
||||
Поведение:
|
||||
|
||||
- `Esc` в `PLAYING` открывает меню; повторный Esc или Resume возвращает игру;
|
||||
- игра, логический таймер и звуковой насос ставятся на паузу согласованно;
|
||||
- QuickSave/QuickLoad только взводят запрос, фактическая операция идёт на
|
||||
безопасной границе кадра;
|
||||
- QuickLoad disabled/показывает `NO QUICKLOAD`, если нет валидных SAV/BAK;
|
||||
- перед QuickLoad из меню лёгкий probe проверяет заголовок и checksum обоих
|
||||
файлов: валидный `POP.BAK` при отсутствующем/битом `POP.SAV` требует
|
||||
отдельного `LOAD BACKUP?`, а не загружается молча;
|
||||
- Restart Level и Restart Game выполняются сразу, БЕЗ подтверждения
|
||||
(2026-08-22): Restart Level перечитывает уровень, Restart Game завершает
|
||||
gameplay и возвращает к первому экрану title/intro; новая игра создаётся
|
||||
общим LEVEL_LOAD только после skip/attract;
|
||||
- Quit требует подтверждения и закрывает файлы/каналы штатным путём;
|
||||
- меню недоступно в demo, cutscene, time-expired и ending;
|
||||
- отдельная debug-комбинация немедленного выхода может остаться только в
|
||||
отладочной сборке.
|
||||
|
||||
## 4. Settings первой версии
|
||||
|
||||
```text
|
||||
GENERAL
|
||||
Sound ON / OFF
|
||||
Show Sprinter screen ON / OFF
|
||||
Restore defaults...
|
||||
|
||||
GAMEPLAY
|
||||
Speed NORMAL / FAST / FASTEST
|
||||
Gameplay profile VANILLA
|
||||
Cheats ON / OFF
|
||||
|
||||
CONTROLS
|
||||
Show key bindings
|
||||
|
||||
BACK
|
||||
```
|
||||
|
||||
`Gameplay profile: VANILLA` показывается read-only: место в модели уже есть,
|
||||
но пользователь не может выбрать ещё не реализованный ENHANCED.
|
||||
|
||||
Изменения применяются немедленно к скорости, читам и звуку, но `POP.CFG`
|
||||
записывается один раз при Back/Esc. На экране есть итог `SETTINGS SAVED` или
|
||||
`SAVE ERROR`; во втором случае runtime-значения остаются рабочими.
|
||||
|
||||
Отладочные параметры `ROOMNAV`, border profiling, stop-frame и переключение
|
||||
double buffering не являются пользовательскими Settings. Они остаются
|
||||
compile-time/debug функциями и скрываются из release UI.
|
||||
|
||||
## 5. Модель настроек
|
||||
|
||||
Игровой код не должен читать UI-структуры. Единственный runtime-контракт:
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
POP_PROFILE_VANILLA = 0,
|
||||
POP_PROFILE_ENHANCED = 1
|
||||
} pop_gameplay_profile_t;
|
||||
|
||||
typedef struct {
|
||||
uint8_t sound_enabled;
|
||||
uint8_t speed_mode;
|
||||
uint8_t gameplay_profile;
|
||||
uint8_t cheats_enabled;
|
||||
uint8_t show_build_info;
|
||||
uint16_t enhancement_flags;
|
||||
} pop_settings_t;
|
||||
```
|
||||
|
||||
В первой версии загрузчик принимает только `POP_PROFILE_VANILLA`. Значение
|
||||
ENHANCED из более нового/ручного файла заменяется на VANILLA с диагностикой,
|
||||
а не включает частично реализованный режим.
|
||||
|
||||
Будущий профиль задаёт маску возможностей централизованно:
|
||||
|
||||
```text
|
||||
VANILLA -> текущий согласованный набор
|
||||
ENHANCED -> будущий рекомендуемый набор fixes
|
||||
CUSTOM -> только если позже действительно понадобится
|
||||
```
|
||||
|
||||
До отдельного аудита существующие `fix_exit_door`, feather guard behavior,
|
||||
jump grab и sound priorities не переключаются и считаются частью текущего
|
||||
VANILLA.
|
||||
|
||||
## 6. Файл POP.CFG
|
||||
|
||||
Бинарный, компактный, версионированный формат:
|
||||
|
||||
```text
|
||||
+0 "PCFG" magic, 4 Б
|
||||
+4 format_version 1 Б
|
||||
+5 payload_size 2 Б
|
||||
+7 payload фиксированные поля little-endian
|
||||
.. checksum 2 Б
|
||||
```
|
||||
|
||||
Требования:
|
||||
|
||||
- путь рядом с exe/в выделенном каталоге игры на HDD;
|
||||
- неизвестная версия, неверная длина или checksum -> defaults;
|
||||
- неизвестные будущие хвостовые поля можно пропустить по `payload_size`;
|
||||
- запись только после Apply/выхода из Settings, не на каждый шаг курсора;
|
||||
- ошибка записи не завершает игру: показать сообщение и оставить runtime
|
||||
значения;
|
||||
- Restore defaults меняет RAM только после подтверждения и затем сохраняет.
|
||||
|
||||
CFG не содержит состояние уровня, QuickSave или Hall of Fame.
|
||||
|
||||
## 7. QuickSave / QuickLoad в меню
|
||||
|
||||
Детальный состав снимка и порядок восстановления — в
|
||||
[`quicksave_plan.md`](quicksave_plan.md). Здесь фиксируется UI и файловая
|
||||
транзакция.
|
||||
|
||||
### Один слот и backup
|
||||
|
||||
Файлы:
|
||||
|
||||
```text
|
||||
POP.SAV текущий слот
|
||||
POP.BAK предыдущий валидный слот
|
||||
POP.NEW временный файл во время записи
|
||||
```
|
||||
|
||||
Безопасная запись:
|
||||
|
||||
1. записать полный снимок в `POP.NEW`;
|
||||
2. закрыть файл;
|
||||
3. повторно открыть/прочитать заголовок и checksum;
|
||||
4. старый валидный `POP.SAV` перенести/скопировать в `POP.BAK`;
|
||||
5. `POP.NEW` сделать новым `POP.SAV`;
|
||||
6. при любой ошибке сохранить прежний `POP.SAV`.
|
||||
|
||||
Точную последовательность rename/copy выбрать после характеризации DSS.
|
||||
Если атомарный rename не гарантирован, использовать copy + fsync/close и
|
||||
никогда не удалять единственную валидную копию до проверки новой.
|
||||
|
||||
### Загрузка
|
||||
|
||||
1. проверить `POP.SAV`;
|
||||
2. если он отсутствует/повреждён/несовместим — проверить `POP.BAK`;
|
||||
3. при валидном BAK показать `LOAD BACKUP?`;
|
||||
4. несовместимая версия — `INCOMPATIBLE SAVE`, без частичной загрузки;
|
||||
5. после успеха закрыть menu, перерисовать обе страницы, перезапустить звук.
|
||||
|
||||
### Сообщения
|
||||
|
||||
Минимальный набор:
|
||||
|
||||
```text
|
||||
QUICKSAVED
|
||||
QUICKLOADED
|
||||
NO QUICKLOAD
|
||||
SAVE ERROR
|
||||
INCOMPATIBLE SAVE
|
||||
LOAD BACKUP?
|
||||
```
|
||||
|
||||
Сообщение показывается UI-слоем, но операция завершается до возврата в
|
||||
игровой кадр.
|
||||
|
||||
## 8. Restart Level / Restart Game
|
||||
|
||||
Restart Level:
|
||||
|
||||
- использует существующий штатный reset текущего уровня;
|
||||
- не перечитывает CFG;
|
||||
- не меняет `POP.SAV`;
|
||||
- сбрасывает состояние, которое сбрасывает текущая реализация roomtest.
|
||||
|
||||
Restart Game:
|
||||
|
||||
- выполняется сразу, без подтверждения;
|
||||
- завершить текущий gameplay session и вернуть автомат в TITLE;
|
||||
- начать title/intro с самого первого экрана;
|
||||
- создать новую игру с `FIRST_LEVEL` и новым глобальным таймером только
|
||||
после пользовательского skip либо ввода в attract-demo;
|
||||
- настройки оставить;
|
||||
- QuickSave не удалять.
|
||||
|
||||
## 9. Controls
|
||||
|
||||
Первая версия только показывает активную раскладку. Переназначение клавиш
|
||||
откладывается: raw PS/2 канал имеет особенности Shift и расширенных кодов,
|
||||
поэтому generic key-binding UI требует отдельного проекта.
|
||||
|
||||
Экран должен перечислить минимум:
|
||||
|
||||
- движение и Shift/action;
|
||||
- Esc/menu;
|
||||
- F6/F9 QuickSave/QuickLoad;
|
||||
- Ctrl+S sound;
|
||||
- P speed;
|
||||
- доступные cheats, только если они включены: K/Kill Guard, I/Immortal,
|
||||
Shift+L/Next Level, U/Flip Screen и F7/F8/Time −/+ на отдельных понятных
|
||||
строках. Нижней подсказки `Esc or Enter: Back` нет.
|
||||
|
||||
## 10. UI renderer и ввод
|
||||
|
||||
### 10.1. Выбор способа отрисовки: текст против спрайт-атласов
|
||||
|
||||
Ограничение платформы: стандартный текстовый вывод libbgi (`outtextxy`)
|
||||
не годится — он тянет системный знакогенератор в `_gfx_font_buf` (2 КБ
|
||||
статики в W2) плюс жирный резидентный код, а W1/W2 забиты игрой
|
||||
(тот же вывод зафиксирован комментарием в `roomtest_cold.c`, где отладочный
|
||||
борд рисуется палочками именно поэтому). Значит, любой вариант требует
|
||||
СВОЕЙ реализации вывода меню, живущей в отдельном банке (память на банк
|
||||
есть; скорость не критична — меню работает на паузе).
|
||||
|
||||
Рассматривались два подхода.
|
||||
|
||||
**Вариант A — текстовые строки + собственный растровый рендерер.**
|
||||
|
||||
Плюсы:
|
||||
- минимальные данные: шрифт 2–4 КБ + таблицы строк по сотни байт на язык;
|
||||
- весь динамический текст бесплатно: значения опций (ON/OFF,
|
||||
NORMAL/FAST/FASTEST), сообщения (`QUICKSAVED`, `INCOMPATIBLE SAVE`),
|
||||
диалоги (`LOAD BACKUP?`), экран Controls, будущий ввод инициалов
|
||||
Hall of Fame — без текстового движка HoF вообще не сделать;
|
||||
- правка формулировки = правка C-строки, мгновенные итерации;
|
||||
- локализация = вторая таблица строк (+ вторая половина глифов);
|
||||
- **решающий аргумент: так сделано в самом SDLPoP** — см. §10.2.
|
||||
|
||||
Минусы:
|
||||
- надо написать рендерер (блиттер глифа + строка + центрирование +
|
||||
подсветка) — небольшой, но свой;
|
||||
- вид определяется качеством шрифта-ассета.
|
||||
|
||||
**Вариант B — готовые спрайт-атласы** (атлас главного меню с активными/
|
||||
неактивными пунктами, атлас вложенного меню, атлас каждой опции
|
||||
On/Off и т.д.).
|
||||
|
||||
Плюсы:
|
||||
- аутентичный вид: любая типографика/декор запекаются при упаковке;
|
||||
- вывод = существующий блит атласов, текстовый движок не нужен;
|
||||
- язык = другой файл атласа с диска, ноль логики.
|
||||
|
||||
Минусы:
|
||||
- комбинаторика ассетов: 7 пунктов × состояния + вложенные меню + значения
|
||||
всех опций + все сообщения + все диалоги ≈ десятки КБ raw на язык до RLE;
|
||||
второй язык удваивает;
|
||||
- любая правка текста = перегенерация ассетов + перекладка ресурсов;
|
||||
- динамический текст (HoF initials) всё равно потребует шрифтового движка —
|
||||
получили бы ОБЕ системы сразу.
|
||||
|
||||
**Решение (2026-08-22): Вариант A**, шрифт — ассет. Спрайты остаются только
|
||||
для нетекстового декора (рамка/фон меню, маркер выделения — как arrowheads
|
||||
в SDLPoP). Титульный экран — полноэкранная картинка, тема `full_game_plan.md`.
|
||||
|
||||
### 10.2. Референс: как устроено меню в SDLPoP
|
||||
|
||||
`SDLPoP/src/menu.c` + текстовый движок `seg009` — источник структуры:
|
||||
|
||||
- **Текстовые строки + встроенный пропорциональный bitmap-шрифт**
|
||||
`hc_small_font_data[]` (menu.c:2488): символы 32..126, каждый глиф —
|
||||
монохромное изображение переменной ширины; `font_type`
|
||||
{first_char, last_char, space_between_chars, height_above_baseline, chtab}.
|
||||
Никаких per-item атласов, хотя SDL_ttf доступен.
|
||||
- Вывод — портированный движок оригинального DOS PoP (seg009):
|
||||
`draw_text_character` → `method_3_blit_mono(image, x, y, textblit,
|
||||
textcolor)`; `get_line_width` для центрирования; перенос по словам.
|
||||
Тем же движком рисуются in-game тексты и copy protection.
|
||||
- Пункты меню — data-driven C-структуры `{id, previous, next, required,
|
||||
char text[32]}` + таблицы `pause_menu_items[]` / `settings_menu_items[]`;
|
||||
`required` — указатель на флаг disabled, такие пункты пропускаются при
|
||||
навигации (prev/next пересчитываются).
|
||||
- Выделенный пункт = смена цвета текста (bright-white против обычного) +
|
||||
рамка-контур `draw_rect_contours(selection_box, lightgray)`; НЕ отдельный
|
||||
спрайт «активного пункта».
|
||||
- Фон меню — затемнение замороженного игрового кадра:
|
||||
`draw_rect_with_alpha(black, alpha=120)`, внизу просвечивает «GAME PAUSED».
|
||||
- Settings — декларативная таблица `setting_type` со стилями TOGGLE / NUMBER /
|
||||
TEXT_ONLY / KEY, геттером/сеттером/increase/decrease значения, строкой-
|
||||
explanation внизу экрана, скроллом длинных списков и фокусом «левая половина
|
||||
(список) / правая половина (значения)».
|
||||
- Диалоги — один общий `draw_confirmation_dialog(text)` + обработчик
|
||||
результата; диалог возвращает решение автомату меню.
|
||||
- Мини-спрайты только для декора значений (arrowheads up/down/left/right).
|
||||
- Навигация озвучена (menu tick), ввод клавиатура+мышь, hover по прямоугольникам.
|
||||
|
||||
### 10.3. Наша реализация
|
||||
|
||||
- Банк 9: код рендерера,
|
||||
шрифт, таблицы строк, автомат меню. Резидентно — только request-flag и
|
||||
вызов процесса на границе кадра (паттерн pop_qsave_io).
|
||||
- Рендерер повторяет минимальный контракт seg009: пропорциональные глифы,
|
||||
baseline, `draw_string` и центрирование по сумме advance. Блит идёт через
|
||||
W0-атлас, в `GFX_BANK_SPRITE`: `0xFF` в атласе пропускается, а UI временный
|
||||
и не портит теневую копию игрового фона. Перед каждым кадром UI `gfx_copy_page` переносит чистый
|
||||
shadow видимой страницы в скрытую, затем готовый кадр показывается только
|
||||
на следующем фронте. При выходе чистый фон тем же способом возвращается на
|
||||
обе страницы и восстанавливается исходная visible-страница. Поэтому
|
||||
перемещение выделения не показывает поэтапную перерисовку и не оставляет
|
||||
следов на back buffer.
|
||||
- Шрифт — АССЕТ из **оригинальных** `hc_small_font_data[]` и
|
||||
`hc_font_data[]` SDLPoP, не системный ZG и не TTF. Паковщик
|
||||
`toolchain/pop_extract_font.py` делает `FONT\\font.atl`: 95 ASCII-глифов
|
||||
малого и 95 крупного шрифта (7667 Б). Номер ленты вычисляется из ASCII,
|
||||
поэтому это один текстовый движок, а не атлас готовых надписей.
|
||||
- Двуязычность (eng/rus): строки храним в CP866 — латиница и кириллица одним
|
||||
байтовым порядком, одна кодировка на оба алфавита. Локаль = пара
|
||||
(указатель на таблицу строк, файл шрифта); переключатель — одна настройка.
|
||||
Русские строки длиннее английских ~10–15% — раскладку экранов и ширину
|
||||
колонок закладывать по русской. Второй язык можно добавить позже без
|
||||
переделки: сначала eng.
|
||||
- Визуальная композиция MS4 следует SDLPoP: замороженная сцена остаётся
|
||||
открытой, поверх неё компактный центрированный список без чёрной карточки,
|
||||
выбранная строка обведена тонким светло-серым контуром, а крупное
|
||||
`GAME PAUSED` лежит в нижнем борту. Цвета текста и контура берутся из
|
||||
стабильного диапазона палитры 0x37..0x3F.
|
||||
- Фон открытого меню: снимок текущей палитры, затемнение всех слотов кроме
|
||||
UI 0x37..0x3F и точное восстановление при выходе. Снимок хранится в
|
||||
свободном хвосте EMM-страницы шрифта, не в W2.
|
||||
- Навигация MS4: вверх/вниз, Enter/Esc, edge-triggered поверх `kbd_raw`.
|
||||
Left/right и menu tick добавляются вместе с настройками на MS5.
|
||||
|
||||
Первый UI может быть визуально простым. Критично отсутствие потери клавиш,
|
||||
предсказуемая пауза и отсутствие повреждения игрового back buffer.
|
||||
|
||||
### 10.4. Затенение экрана под меню — решение MS4
|
||||
|
||||
Режим меню виден сразу: bank 9 делает динамический снимок palette 0,
|
||||
затемняет RGB-каналы вдвое и пишет одинаковый результат в обе экранные
|
||||
палитры. Девять стабильных UI-слотов 0x37..0x3F не гасятся. При Resume/Enter
|
||||
палитра восстанавливается из EMM-снимка. Это выбранный вариант Б ниже;
|
||||
ступенчатый fade для роликов пока не нужен и остаётся отдельной будущей
|
||||
задачей, а не причиной раздувать MS4.
|
||||
|
||||
**Как сделано в SDLPoP** (`seg009.c`):
|
||||
|
||||
- Меню: `draw_rect_with_alpha(&screen_rect, color_0_black, pause_menu_alpha)`
|
||||
(menu.c:1364) — альфа-заливка чёрным поверх замороженного кадра средствами
|
||||
SDL; нижняя полоса рисуется с alpha=0, чтобы сквозь неё просвечивало
|
||||
«GAME PAUSED». Прямого аналога на Sprinter НЕТ (альфа-блендинг в железе
|
||||
отсутствует) — это SDL-специфика, переносить нечего.
|
||||
- Ролики/переходы: `fade_in_2/fade_out_2(rows)` (seg009.c:3947+, вызовы из
|
||||
seg000.c) — ПОШАГОВОЕ затухание ПАЛИТРЫ к чёрному и обратно: палитра
|
||||
копируется, каждая строка по 16 цветов гасится за несколько кадров
|
||||
(`which_rows` маской выбирает, какие строки участвуют: 0x800/0x1000/...).
|
||||
Вот этот механизм на Sprinter воспроизводим один в один.
|
||||
|
||||
Отсюда рабочая гипотеза: наш примитив = «снимок текущей палитры → ступенчатое
|
||||
приближение к затемнённой копии (кроме резервного блока для UI)», статично для
|
||||
меню и анимированно для роликов/переходов. Варианты:
|
||||
|
||||
**Вариант А — единая основная палитра (глобальный рефакторинг палитры).**
|
||||
|
||||
1. Собрать ВСЕ палитры игры (уровневые наборы `pal_env*`, kid.pal, палитра
|
||||
Тени и пр.) в одну общую 256-цветную; использовать её целиком всегда.
|
||||
Сейчас переиспользования цветов НЕТ — каждая загрузка ассетов перезаписывает
|
||||
слоты (см. pop_boot: kid.pal затирает тайловые цвета, приходится
|
||||
восстанавливать `pop_bg_pal_apply`/`pop_shadow_pal_apply`).
|
||||
2. Для затенения — затемнённая копия основной палитры, КРОМЕ зарезервированного
|
||||
блока из 16 цветов для самого меню (кандидат — стандартные 16 цветов VGA).
|
||||
3. Выход из меню — возврат к полной основной палитре.
|
||||
|
||||
Плюс: решает попутно существующую боль с перезаписью палитр при загрузках.
|
||||
Минус: большой разовый рефакторинг упаковщиков и всех загрузчиков атласов;
|
||||
нужен аудит, что все цвета всех уровней влезают в 256. **Против говорит
|
||||
план перевода камней подземелья на цвета VGA-версии PoP: там ряд уровней
|
||||
несёт ДРУГУЮ палитру, отличную от SDLPoP (VDUNGEON/VPALACE каскад,
|
||||
levels_plan.md), — единая палитра этому прямо противоречит.**
|
||||
|
||||
**Вариант Б — динамический снимок текущей палитры (сейчас выглядит
|
||||
предпочтительным).**
|
||||
|
||||
1. При открытии меню прочитать всю текущую палитру, сохранить.
|
||||
2. Записать затемнённую копию (кроме зарезервированного блока для меню).
|
||||
3. При выходе — восстановить сохранённую.
|
||||
|
||||
Плюс: локальная правка внутри меню, ничего в пайплайне ассетов не меняется;
|
||||
работает при любой текущей палитре автоматически — включая будущие
|
||||
уровне-специфичные палитры VGA-камней; тот же примитив ступенями даёт
|
||||
fade-out/fade-in для роликов и переходов между уровнями (как fade_*_2 в
|
||||
SDLPoP). Минус: чтение/запись 256 записей палитры при входе/выходе (раз на
|
||||
открытие — дёшево); затемнение «на глаз» может по-разному выглядеть на разных
|
||||
уровнях.
|
||||
|
||||
Резервный блок 16 цветов нужен в ОБОИХ вариантах; текущий диапазон 0x37..0x3F
|
||||
(стабильный, проверен) даёт 9 цветов — этого может не хватить на
|
||||
текст+подсветку+рамку, тогда резервировать отдельный блок.
|
||||
|
||||
**Следствие для архитектуры:** работа с цветом/палитрой должна собраться в
|
||||
ОДИН модуль (сейчас она разбросана: gfx_pal_* вызовы в boot, pop_bg_pal_apply,
|
||||
pop_shadow_pal_apply, вспышки урона в roomtest.c и т.д.). Модуль палитры —
|
||||
единственный владелец записи в палитру и предоставляет примитивы, которые
|
||||
понадобятся и меню, и роликам:
|
||||
|
||||
```text
|
||||
pal_snapshot()/pal_restore() — снимок/восстановление всей палитры
|
||||
pal_dim(step) / pal_undim(step) — ступени затемнения (кроме резервного блока)
|
||||
pal_fade_out(rows)/pal_fade_in(rows) — анимированное затухание по строкам
|
||||
(порт fade_out_2/fade_in_2, seg009)
|
||||
```
|
||||
|
||||
Меню уже использует snapshot+dim локально в bank 9. Когда появятся ролики,
|
||||
выделить из него общий palette/fade-модуль; вспышка урона сможет переехать
|
||||
туда же после отдельного аудита.
|
||||
|
||||
## 11. Диалоги
|
||||
|
||||
Общий диалог подтверждения:
|
||||
|
||||
```text
|
||||
QUIT GAME?
|
||||
RESTORE DEFAULTS?
|
||||
LOAD BACKUP?
|
||||
|
||||
YES / NO
|
||||
```
|
||||
|
||||
Диалог не выполняет действие напрямую: он возвращает решение автомату меню,
|
||||
который формирует команду приложению. Так UI не зависит от gameplay-модулей.
|
||||
По умолчанию выбран `NO`; Up/Down/Left/Right меняют ответ, Enter подтверждает,
|
||||
Esc отменяет. Реализованы все три вопроса: Quit, Restore defaults и backup
|
||||
QuickLoad. В Quit-dialog вопрос и `YES / [NO]` заключены в общую рамку;
|
||||
отдельная строка `Enter: Select Esc: Cancel` не выводится.
|
||||
|
||||
## 12. Будущий ENHANCED
|
||||
|
||||
Не реализуется сейчас, но дизайн обязан позволять:
|
||||
|
||||
- добавить второй профиль без смены всего UI;
|
||||
- хранить `enhancement_flags` в CFG;
|
||||
- отличать технические исправления порта (всегда включены) от изменений
|
||||
оригинальной механики;
|
||||
- провести аудит уже встроенных исправлений;
|
||||
- покрыть каждый переключаемый fix host/MAME тестом;
|
||||
- при необходимости добавить Advanced screen, не раздувая основной menu.
|
||||
|
||||
До этого момента нельзя рассыпать проверки `if (enhanced)` по горячему коду.
|
||||
Сначала составляется реестр и выбирается минимальная битовая модель.
|
||||
|
||||
## 13. Этапы реализации
|
||||
|
||||
| этап | результат | критерий приёмки |
|
||||
|---|---|---|
|
||||
| **MS0** ✓ | определить команды app/menu и структуру settings | UI возвращает команду главному циклу; прямых gameplay-вызовов нет |
|
||||
| **MS1** | проверить запись/rename/copy на HDD DSS | crash/power-loss сценарий не теряет обе копии save |
|
||||
| **MS2** ✓ | `POP.CFG`: defaults, load, validate, save | v1 codec, будущий хвост, checksum; повреждённый CFG даёт defaults |
|
||||
| **MS3** | QuickSave hotkeys + POP.SAV/BAK | полный критерий `quicksave_plan.md` |
|
||||
| **MS4** ✓ | текстовый рендерер + два шрифта (малый для пунктов, крупный для сообщений) + минимальный pause menu | SDLPoP fonts в одном W0-atlas, центрирование, dim/restore palette и tear-free page flip; все семь пунктов видимы, навигация и Resume/QuickSave работают в MAME |
|
||||
| **MS5** ✓ | General/Gameplay Settings | значения применяются сразу и после Back/Esc записываются в POP.CFG |
|
||||
| **MS6** ✓ | dialogs + backup recovery | подтверждения default-NO; QuickLoad спрашивает перед валидным POP.BAK |
|
||||
| **MS7** ✓ | Controls help | показаны движение, action, menu, save/load, звук, speed и conditional cheats; MAME проверил отдельные K/I и Shift+L/U и возврат Esc ровно на один уровень |
|
||||
| **MS8** ✓ | build info | CFG читается до первого показа; включаемый build screen получает ID и дату из Make/git |
|
||||
|
||||
QuickSave (`MS1/MS3`) можно реализовать раньше визуального menu: сначала
|
||||
F6/F9 и сообщения, затем подключить те же команды к пунктам UI.
|
||||
|
||||
## 14. Тесты
|
||||
|
||||
- Host: CFG round-trip, defaults, bad magic/version/size/checksum.
|
||||
- Host: меню navigation, disabled items, confirmations, команды приложению.
|
||||
- Host: рендерер строк — вывод глифов обеих локалей, центрирование,
|
||||
ширина строки для малого и крупного шрифта.
|
||||
- Host: SAV invalid -> BAK valid; оба invalid -> NO QUICKLOAD.
|
||||
- MAME: F6, изменение сцены, F9; затем рестарт программы и повторный F9.
|
||||
- MAME: прервать запись/испортить SAV — BAK остаётся загружаемым.
|
||||
- MAME: pause на бое/падении, Resume не меняет состояние и таймер; смена
|
||||
выбранного пункта не показывает промежуточный кадр и после закрытия не
|
||||
оставляет меню на второй странице.
|
||||
- MAME: Settings сохраняются после полного выхода и запуска с HDD.
|
||||
- MAME: включить Show Sprinter screen, перезапустить `roomtest`, увидеть
|
||||
build ID/date до первого игрового кадра и пропустить экран Esc/Enter/Space.
|
||||
- Проверка лимита 8 DSS handles на каждом error path.
|
||||
- `make size-check`; menu/text строки не должны съесть резидентный бюджет.
|
||||
|
||||
## 15. Не входит в план
|
||||
|
||||
- Mods и выбор levelset;
|
||||
- уровень 15/copy protection;
|
||||
- несколько save slots;
|
||||
- replay/recording;
|
||||
- key rebinding;
|
||||
- SDL visual/controller options;
|
||||
- фактическая реализация ENHANCED и individual fix switches.
|
||||
@@ -0,0 +1,297 @@
|
||||
# План: консолидация работы с палитрами + переход уровня через fade
|
||||
|
||||
Статус: **этапы A и B реализованы; визуальная приёмка полного маршрута ещё
|
||||
идёт** (2026-08-24). Палитры выделены в bank 10, а renderer cutscene/intro —
|
||||
в bank 11, чтобы не переполнять bank 9 оболочки.
|
||||
Обсуждение велось вокруг `roomtest/` (банк 9 — оболочка, fade из `pop_ui.c`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Текущее состояние: карта палитры
|
||||
|
||||
Палитра Sprinter — 256 записей по 4 байта (B, G, R, 0) = 1 КБ на страницу.
|
||||
У страниц дабл-буфера ДВЕ раздельные палитры (`gfx_pal_load(0,…)` и
|
||||
`gfx_pal_load(1,…)` — почти всегда парой). BIOS читает буферы только из
|
||||
#4000–#BFFF: банковую rodata напрямую отдавать нельзя (копия в стек/W2),
|
||||
см. грабли `pop_guard_set_palette` и `bg_load_tile_pal`.
|
||||
|
||||
### 1.1 Игровая палитра `KID\kid.pal` — раскладка слотов
|
||||
|
||||
Собирается `toolchain/pop_pack_kid.py build_palette()`, грузится одним
|
||||
`gfx_pal_fload` (перезаписывает все 256 записей). Атласы запекались под эти
|
||||
индексы — менять раскладку нельзя без перепаковки ассетов.
|
||||
|
||||
| Слоты | Назначение | Источник | Динамика |
|
||||
|---|---|---|---|
|
||||
| 0x00 | Цвет фона + **вспышка молнии** (подмена записи 0, `flash_bg` ← do_flash/set_bg_attr SDLPoP) | — | меняется в игре |
|
||||
| 0x01–0x2F | Не закреплены (нули) | — | свободно |
|
||||
| 0x30–0x3F | VGA16 — базовые 16 цветов для mono-блитов: пламя факелов, пузырьки зелья (+12 красный «лечение», +10 зелёный, +9 синий), кровь чомпера (12), дворцовая кладка mono (+6) | `VGA16[]` | статично |
|
||||
| ↳ 0x37–0x3F | Поддиапазон **UI**: текст/рамка меню; единственное, что `keep_ui` не затемняет (`MENU_BORDER`=0x37) | — | — |
|
||||
| 0x40–0x4F | chtab_1 пламя/зелья (`POT_PAL_BASE`) | VDUNGEON res150.pal | статично |
|
||||
| 0x50–0x5F | **ENV фон тайлсета** (`POP_PAL_ENV`) | res200.pal набора | **меняется при смене тайлсета** |
|
||||
| 0x60–0x6F | **WALL тайлсета** (`POP_PAL_WALL`) | res360.pal набора | **меняется при смене тайлсета** |
|
||||
| 0x70–0x7F | Kid (`PAL_BASE`) | KID res400.pal | статично |
|
||||
| 0x80–0x8F | Меч chtab_0 (`SWORD_PAL_BASE`) | POT res700.pal | статично |
|
||||
| 0x90–0x9F | Страж chtab_5 (`GUARD_PAL_BASE`) | res10.bin guard_palettes | **меняется по КОМНАТАМ** |
|
||||
| 0xA0–0xAF | Тень (`POP_SHADOW_PAL_BASE`) | RGB-сетка pop_pack_shadow.py | статично |
|
||||
| 0xB0–0xFF | Свободны (5 слотов) | — | — |
|
||||
|
||||
Итого динамических зон три: запись 0 (молния), env+wall (тип здания),
|
||||
стражи (per-room). Всё остальное одинаково всю игру.
|
||||
|
||||
### 1.2 Полноэкранные палитры заставок
|
||||
|
||||
Каждая перезаписывает ВСЕ 256 записей:
|
||||
|
||||
| Файл | Где используется |
|
||||
|---|---|
|
||||
| `KID\kid.pal` (+ fallback `a:\kid.pal`) | BOOT и возврат в игру после заставок |
|
||||
| `TITLE\title.pal` | экран TITLE |
|
||||
| `PV\story.pal` | INTRO и HALL_OF_FAME (одна палитра на обе фазы) |
|
||||
|
||||
### 1.3 Тайлсеты: подземелье ↔ дворец
|
||||
|
||||
Оба набора используют ОДНИ И ТЕ ЖЕ слоты 0x50–0x5F/0x60–0x6F, заполняя их
|
||||
разными цветами (атласы обоих наборов запекались под эти индексы).
|
||||
Переключение = загрузка 64 байт (32 записи env+wall) в обе страницы
|
||||
(`bg_load_tile_pal`); остальные 224 записи не трогаются.
|
||||
|
||||
Какие уровни дворец — `tbl_level_type` (`pop_level_cold.c:44`):
|
||||
**4, 5, 6, 10, 11, 14**; остальные подземелье.
|
||||
|
||||
Палитра дворца `pal_tile.pal` (расшифровка, формат записи B,G,R):
|
||||
|
||||
ENV 0x50–0x5F (пол, ковры, факелы, ворота, пики, арки):
|
||||
|
||||
| Слот | RGB | | Слот | RGB |
|
||||
|---|---|---|---|---|
|
||||
| 50 | 0,0,0 чёрный | | 58 | 202,190,178 серо-бежевый |
|
||||
| 51 | 121,89,60 коричневый | | 59 | 153,133,129 серо-лиловый |
|
||||
| 52 | 161,121,76 светло-коричневый | | 5A | 76,64,56 тёмный серо-бурый |
|
||||
| 53 | 194,149,89 песочный | | 5B | 153,97,89 кирпично-красный |
|
||||
| 54 | 230,178,113 яркий песок | | 5C | 137,80,72 тёмный кирпич |
|
||||
| 55 | 246,202,125 кремовый | | 5D | 48,125,125 бирюзовый |
|
||||
| 56 | 255,234,170 бледно-кремовый | | 5E | 12,56,89 тёмно-синий |
|
||||
| 57 | 255,255,255 белый | | 5F | 202,56,28 красно-оранжевый |
|
||||
|
||||
WALL 0x60–0x6F (вся палитра песочная): 61=(218,170,89), 62=(226,165,93),
|
||||
63=(226,170,97), 64=(218,161,85), 65=белый, 66=(226,165,93), 67=(218,165,89),
|
||||
68=(226,170,89), 69=(218,170,97), 6A=(255,210,137), 6B=(255,218,149),
|
||||
6C=(255,210,137), 6D=(255,218,145), 6E=(194,153,80 тёмный песок),
|
||||
6F=(238,186,117).
|
||||
|
||||
Чем рисуется во дворце:
|
||||
- **Тело стены — НЕ спрайты**, а сплошные заливки; цвет разыгрывается на
|
||||
комнату prandom'ом (`gen_palace_wall_colors`, `pop_bg.c:140`, порт
|
||||
seg000:1942): подряды 1 и 3 берут случайный из 0x61–0x64, подряды 0 и 2 —
|
||||
из 0x66–0x69; соседи по горизонтали не повторяются.
|
||||
- Декор стен id 3–17 — mono-силуэт цветом VGA16+6 (0x36).
|
||||
- Верх дверных проёмов дворца — спец-id 78–84 + полоса 145 («полоса под
|
||||
окнами», pop_room.c:478).
|
||||
- Остальное (пол, ковры, порталы-факелы, ворота, пики) — env-куски
|
||||
pal_env*.atl с ENV-таблицей выше.
|
||||
|
||||
### 1.4 Стражи (0x90–0x9F)
|
||||
|
||||
Цвет задаётся на КОМНАТУ (`level.guards_color[room-1]`), при входе в
|
||||
комнату зовётся `pop_guard_set_palette(color)` ДО отрисовки (слоты общие
|
||||
на экран — смена посреди кадра дала бы стража в новой палитре с полосой HP
|
||||
в старой). Только для обычных стражей (`tbl_guard_type == 0`): скелет и
|
||||
Джафар имеют собственную палитру, зашитую в kid.pal; им зовётся с color=0
|
||||
(не трогать — иначе Джафар на ур.13 покрасился бы в цвет стража своей
|
||||
комнаты). Внутри одного уровня слоты могут перезаписываться многократно.
|
||||
|
||||
## 2. Текущее состояние: механика fade
|
||||
|
||||
### 2.1 Наша реализация (`pop_ui.c`, банк 9)
|
||||
|
||||
- `pop_ui_palette_snapshot()` — снимок всех 256 записей через
|
||||
`gfx_pal_get` по 4 чанкам × 64; хранится в хвосте страницы шрифта
|
||||
FONT.ATL ([0x3C00,0x4000)), map/unmap W0. Требует `font_ready`.
|
||||
- `pop_ui_palette_dim(step, keep_ui)` — готовит ОБЕ экранные палитры из
|
||||
снимка. Шкала без умножений (только сдвиги):
|
||||
|
||||
| Шаг | Формула на канал | Яркость |
|
||||
|---|---|---|
|
||||
| 0 | x | оригинал |
|
||||
| 1 | `(x>>1)+(x>>2)` | ≈3/4 |
|
||||
| 2 | `x>>1` | 1/2 |
|
||||
| 3 | `x>>2` | 1/4 |
|
||||
| 4 | 0 | чёрный |
|
||||
|
||||
`keep_ui` пропускает 0x37–0x3F (меню остаётся ярким).
|
||||
- `pop_ui_fade_out/in(steps)` — проигрывание ступеней за `steps` кадров
|
||||
vsync (`step = i*4/steps`, целочисленно): steps=4 — канонический (по кадру
|
||||
на ступень), steps<4 — перескакивает ступени, steps>4 — повторяет (плавнее),
|
||||
steps=0 у fade_in — мгновенный restore.
|
||||
- Контракт map/unmap: обращения к EMM/W0 и BIOS-палитре строго после unmap.
|
||||
|
||||
Стоимость одного dim ≈ 15–25 тыс. тактов (~4–7 мс при 3.5 МГц) —
|
||||
укладывается в кадр vsync, на практике лагов нет.
|
||||
|
||||
### 2.2 Как сделано в SDLPoP (seg009.c, USE_FADE/gmMcgaVga)
|
||||
|
||||
- fade_out: каждый кадр КАЖДЫЙ ненулевой канал каждой записи −1; до нуля.
|
||||
- fade_in: `fade_pos` от 0x40 вниз; канал +1, пока меньше оригинала.
|
||||
- Уровней затемнения до 63–64 (VGA-канал 6 бит), полный фейд ~63 кадра ×
|
||||
wait_time=2 тика — медленно и кинематографично.
|
||||
- `which_rows` — битовая маска групп по 16 записей: можно фейдить часть
|
||||
палитры (в оригинале используется).
|
||||
- По завершении принудительно восстанавливается оригинал; после out экран
|
||||
заливается чёрным.
|
||||
|
||||
Это осознанное расхождение (скорость/такты vs плавность) — ЗАПИСАТЬ в
|
||||
`docs/impl_diff.md` (сейчас записи нет).
|
||||
|
||||
## 3. Зафиксированные решения
|
||||
|
||||
1. **Ступени затемнения: остаются 4.** Вариант 8 ступеней той же сдвиговой
|
||||
техникой — рассмотреть отдельно, сейчас не внедрять.
|
||||
2. **Предрасчёт fade-вариантов палитры отклонён.** Аргументы: чтение файла
|
||||
с диска на порядок дороже вычисления; 3–7 КБ постоянной RAM при
|
||||
MEMORY=small непозволительны; предрасчёт привязан к конкретным палитрам,
|
||||
а снимок работает с любой текущей автоматически; keep_ui удвоил бы набор.
|
||||
3. **Считать на лету**, хранить один снимок (уже есть, бесплатно в хвосте
|
||||
страницы шрифта).
|
||||
4. **Буферы на стеке**, не статика (W1/W2 мало) и не 1 КБ: обнулить 64/256
|
||||
байт дешевле, чем держать килобайт резидентно.
|
||||
5. **Контракт `gfx_pal_load(pal, start, count, data)`**: count — число
|
||||
СЛОТОВ, буфер обязан быть `count*4` байт; count=0 означает «все 256».
|
||||
6. **Leaf-applеры остаются на месте** (`pop_bg_pal_apply` — банк 7 со своими
|
||||
таблицами, `pop_shadow_pal_apply`, `pop_guard_set_palette`): банковая
|
||||
rodata чужого банка не видна, перенос сломал бы доступ к данным.
|
||||
7. **Молния (`flash_bg` в roomtest.c) не переносится** — игровой эффект
|
||||
записи 0; после вспышки восстановление записи 0 из снимка ложится на API.
|
||||
8. Модель состояния: разделены «какая палитра логически загружена» (load_*)
|
||||
и «с какой яркостью показана» (apply/fade). Любой load_* обновляет снимок;
|
||||
apply/fade показывает его с нужной глубиной. Это позволяет грузить новую
|
||||
палитру «в темноте» (экран остаётся чёрным, пока не позвали apply/fade_in).
|
||||
|
||||
## 4. Целевой API `pop_pal.c/.h` (банк 9)
|
||||
|
||||
```c
|
||||
/* сброс */
|
||||
void pop_pal_black(void) __banked;
|
||||
/* все 256 записей ОБЕИХ страниц = 0. Стековый buf[256], обнуление циклом,
|
||||
* 8 вызовов gfx_pal_load (4 чанка × 2 страницы, паттерн как в dim).
|
||||
* Зовётся СРАЗУ ПОСЛЕ initgraph в pop_boot (раньше нельзя — нет гарантий
|
||||
* состояния графического режима): закрывает кейс «мусор/палитра предыдущей
|
||||
* программы при включении графики». СНИМОК НЕ ТРОГАЕТ (контракт:
|
||||
* чёрный экран без изменения логической палитры). */
|
||||
|
||||
/* загрузка (пишет полную палитру в обе страницы + refresh снимка;
|
||||
* видимую яркость НЕ трогают — экран меняется только по apply/fade) */
|
||||
void pop_pal_file_load(const char *name) __banked;
|
||||
/* gfx_pal_fload + fallback "a:\" + gfx_pal_sync (fallback сегодня
|
||||
* скопирован в каждом из ~6 мест вызова) */
|
||||
|
||||
void pop_pal_game_load(void) __banked;
|
||||
/* file_load("KID\kid.pal") + pop_bg_pal_apply + pop_shadow_pal_apply.
|
||||
* Сегодня тройка скопирована 3 раза (roomtest_cold ~958, pop_title ~88,
|
||||
* pop_intro ~183). Единое место инварианта «kid.pal затирает слоты
|
||||
* тайлсета 0x50..0x6F и тени 0xA0..0xAF». */
|
||||
|
||||
void pop_pal_level_load(uint8_t full) __banked;
|
||||
/* палитра уровня: kid.pal/shadow + tileset 0x50..0x6F если набор сменился
|
||||
* (сравнение через pop_level_type()). full=1 — ПРИНУДИТЕЛЬНО перечитать
|
||||
* kid.pal/shadow (один экспорт с флагом, не две функции — меньше банковых
|
||||
* точек входа). СТРАЖЕЙ (0x90..0x9F) НЕ включает: это компетенция входа
|
||||
* в комнату (pop_guard_set_palette до первого draw). */
|
||||
|
||||
void pop_pal_story_load(void) __banked; /* PV\story.pal (INTRO и HOF — файл один, функция одна) */
|
||||
void pop_pal_title_load(void) __banked; /* TITLE\title.pal */
|
||||
|
||||
/* отображение */
|
||||
void pop_pal_snapshot(void) __banked; /* переезд из pop_ui, тело то же */
|
||||
void pop_pal_apply(uint8_t fade) __banked; /* = dim(fade, 0), 0..4 */
|
||||
void pop_pal_fade_in(uint8_t steps) __banked; /* переезд из pop_ui */
|
||||
void pop_pal_fade_out(uint8_t steps) __banked;
|
||||
|
||||
/* меню продолжает звать низкоуровневый dim(step, keep_ui=1) — отдельный
|
||||
* тонкий экспорт, чтобы не тащить флаг в горячий apply. Старые имена
|
||||
* pop_ui_palette_* / pop_ui_fade_* УДАЛЯЮТСЯ (без алиасов — меньше
|
||||
* экспорта банка). */
|
||||
```
|
||||
|
||||
Соответствие старое→новое: snapshot→snapshot, restore→apply(0),
|
||||
fade_out/in→fade_out/in, тройка kid.pal×3→game_load, fload+fallback+sync×6→file_load.
|
||||
|
||||
## 5. Этап A: рефакторинг — выполнен (2026-08-24)
|
||||
|
||||
1. Создан `roomtest/pop_pal.c/.h` в **bank 10**, добавлен в Makefile.
|
||||
Он владеет политикой `load logical palette → snapshot → apply brightness`.
|
||||
Низкоуровневые snapshot/dim/fade остаются физически в `pop_ui.c`: там
|
||||
владелец страницы FONT.ATL, где лежит снимок; наружу они доступны только
|
||||
через `pop_pal`.
|
||||
2. Заменены call-sites:
|
||||
- `roomtest_cold.c` ~958: black → game_load вместо тройки;
|
||||
- `pop_title.c` title_restore_game_palette → game_load; загрузка title.pal → title_load;
|
||||
- `pop_intro.c` intro_load/intro_restore → story_load/game_load;
|
||||
- `pop_hof.c` (2 × story.pal) → story_load;
|
||||
- `pop_menu.c`: fade/dim → новые имена (dim с keep_ui — низкоуровневый экспорт);
|
||||
- `roomtest.c` demo-start (snapshot+dim(4,0)+fade_in(4)) → новый API.
|
||||
3. Старые вызовы не остаются в коде приложения; внутренние функции `pop_ui`
|
||||
сохранены как реализации одного владельца памяти снимка.
|
||||
4. Сборка и host-тесты пройдены. `make size-check` неприменим: меняется
|
||||
приложение, а не libc/libbgi.
|
||||
5. MAME smoke-тест полного цикла смен палитр: boot → title (title.pal +
|
||||
fade) → intro (story/kid) → demo fade-in → игра → HOF (story.pal).
|
||||
Проверить: отсутствие мусора при включении графики (эффект black),
|
||||
меню с keep_ui остаётся ярким при затемнении, молния (запись 0)
|
||||
восстанавливается.
|
||||
|
||||
## 6. Этап B: переход уровня через fade — реализован, ждёт визуальной приёмки
|
||||
|
||||
Сценарий (обсуждён, детали уточнить по SDLPoP перед реализацией — как
|
||||
оригинал делает смену уровня, есть ли там fade в DOS-версии):
|
||||
|
||||
```
|
||||
fade_out // последний кадр уровня N темнеет
|
||||
рисуем комнату 1 уровня N+1 // во ВТОРУЮ страницу, в темноте
|
||||
pop_pal_level_load(full=0) // новая палитра: железо+снимок обновлены,
|
||||
// экран всё ещё чёрный
|
||||
флип + копия второй страницы обратно в первую
|
||||
fade_in // = анимированный apply 3→2→1→0
|
||||
```
|
||||
|
||||
Экономия: реально переезжают только 32 записи (env/wall) при смене набора
|
||||
dungeon↔palace; guards_color обновит вход в комнату. Kid/shadow не меняются
|
||||
— потому full=0.
|
||||
|
||||
Реализация находится в `roomtest.c` / `roomtest_cold.c`: последний кадр
|
||||
уровня N темнеет, `pop_level_switch()` подготавливает первый кадр N+1 и
|
||||
обновляет логический источник через `pop_pal_level_load(1)`, затем главный
|
||||
цикл показывает кадр только через fade-in. Восемь ступеней и отдельная
|
||||
анимация смерти не входят в этот этап.
|
||||
|
||||
**Этап B закрывает два открытых бага** (разборы — `roomtest/BUGS_OPEN.md`):
|
||||
- [PAL-L1-AFTER-INTRO] — вход в игру на уровень 1 после интро с чёрным
|
||||
экраном (маршрут demo_new_game; корень не установлен, воспроизведение
|
||||
нестабильно);
|
||||
- [PAL-DUNGEON-STALE] — переход 3→4 оставляет подземную палитру (корень
|
||||
ясен: fade_in восстанавливает из снимка, снятого ДО загрузки тайлсета
|
||||
дворца; быстрый фикс `fade_in_pending` 2026-08-23 сам же и проявляет этот
|
||||
дефект модели).
|
||||
|
||||
Быстрый фикс 2026-08-23 (маршрут CUTSCENE → LEVEL_LOAD → PLAYING,
|
||||
`fade_in_pending` + `pop_ui_fade_in(4)` после `pop_level_switch`) закрыл
|
||||
чёрный экран на переходах с pre-cutscene внутри подземелья (1→2), но модель
|
||||
«кто и когда меняет яркость» остаётся разношёрстной — её и приводит в
|
||||
порядок этап B.
|
||||
|
||||
## 7. Этап C: документирование
|
||||
|
||||
- Запись в `docs/impl_diff.md`: наши 4 ступени vs SDLPoP ~64 (что делает
|
||||
оригинал, что делаем мы — сдвиговая шкала ради тактов, чем платим —
|
||||
грубее градации, что проверять при регрессе).
|
||||
- После этапа B — дополнить запись про сам переход.
|
||||
|
||||
## 8. Не трогаем
|
||||
|
||||
- Молнию (`flash_bg`, roomtest.c) — включая обход SDCC-бага
|
||||
`gfx_pal_set(0,0,0,0,0)` → ручные `gfx_pal_set(0/1, 0, r,g,b)`;
|
||||
- leaf-applеры: `pop_bg_pal_apply` (банк 7), `pop_shadow_pal_apply`,
|
||||
`pop_guard_set_palette` (данные своих модулей);
|
||||
- хранилище снимка в хвосте страницы шрифта FONT.ATL (бесплатное место,
|
||||
guard `font_ready`);
|
||||
- раскладку слотов 0x00–0xAF (зафиксирована атласами).
|
||||
@@ -851,3 +851,21 @@ Apple II и по комментариям вида «DOS PoP does this» в са
|
||||
|
||||
**Что проверять в первую очередь при новом «дорогом» месте:** не сколько
|
||||
там арифметики, а сколько раз за кадр пересекается граница банка.
|
||||
|
||||
## Фиксированный логический кадр (2026-08-19) — МЕНЯЕТ ВСЕ ЦЕЛЕВЫЕ ЧИСЛА
|
||||
|
||||
Период логического кадра больше не `ceil(W) + 2`, а `max(n, ceil(W))`
|
||||
(`roomtest/pop_pace.c`, разбор — `frame_pacing_plan.md`). Поэтому:
|
||||
|
||||
- **Бюджет кадра вырос с 430 080 до 1 290 240 тактов** (n = 3, режим
|
||||
FASTEST по умолчанию). Все записи этого реестра, где «работа сверх
|
||||
430 000 стоит сразу целого растра», СЧИТАТЬ УСТАРЕВШИМИ.
|
||||
- 13/23 (максимум работы 911 862) теперь укладывается в период 3 растра —
|
||||
проверено, ни одного кадра длиннее. Прежний профиль был 3/4/5.
|
||||
- Оптимизация из спешной стала плановой: смысл резать такты остался
|
||||
(режим NORMAL при n=4 и бой при n=5 дают ещё больше запаса, а FASTEST —
|
||||
верхнюю планку скорости), но «свалиться за растр» больше не обрыв.
|
||||
- Цена самого пейсинга — ≈4 000 тактов на кадр (0,9 %), замерено A/B.
|
||||
|
||||
Приоритет P9 (G8) и остальных позиций от этого не меняется, но их
|
||||
СРОЧНОСТЬ падает: они больше не спасают от скачка периода.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# QuickSave / QuickLoad — разбор оригинала и план реализации
|
||||
|
||||
Статус: **план, код не начат** (2026-08-17). Задача на доске —
|
||||
Статус: **РЕАЛИЗОВАНО и проверено в MAME** (2026-08-22; F6/F9, POP.SAV +
|
||||
POP.BAK — см. коммит `v0.6-pop-quicksave`). Документ оставлен как
|
||||
справочник по формату снимка и разбору. Задача на доске —
|
||||
[`../roomtest/TASKS_OPEN.md#qsave`](../roomtest/TASKS_OPEN.md#qsave).
|
||||
|
||||
---
|
||||
@@ -180,12 +182,14 @@ SDLPoP, сохраняются.
|
||||
|
||||
---
|
||||
|
||||
## 4. Куда писать снимок: файл, а не EMM-страница
|
||||
## 4. Куда писать снимок: HDD-файл, а не EMM-страница
|
||||
|
||||
Рекомендация: **основной путь — файл `QUICKSAVE.SAV`; EMM-страница —
|
||||
необязательный второй слот.**
|
||||
Решение: **один основной слот `POP.SAV` на HDD; предыдущая
|
||||
валидная запись хранится в `POP.BAK`.** EMM-слота нет: программа
|
||||
работает только с HDD, а главный сценарий QuickSave обязан переживать
|
||||
перезапуск игры.
|
||||
|
||||
> Пересмотрено 2026-08-17 по вопросу пользователя «почему EMM, а не файл».
|
||||
> Пересмотрено 2026-08-21 по вопросу пользователя «почему EMM, а не файл».
|
||||
> Первая редакция плана рекомендовала EMM — это была ошибка: она взвешивала
|
||||
> скорость и недооценивала главный сценарий использования. Разбор оставлен
|
||||
> целиком, потому что довод переносится и на другие «положить в память
|
||||
@@ -214,10 +218,8 @@ MAME, обязательный после каждой пересборки об
|
||||
`_fd_guard` в libc и так стоит), а «не нужен путь и права» — экономия одной
|
||||
строки.
|
||||
|
||||
Что остаётся за EMM: мгновенный слот для «переиграть» без обращения к диску.
|
||||
Делается тем же сериализатором и добавляется, если понадобится. Поэтому
|
||||
обход состояния писать сразу так, чтобы «куда» было параметром — как у
|
||||
SDLPoP через `process_func`.
|
||||
Обход состояния всё равно писать с абстракцией чтения/записи, как у SDLPoP
|
||||
через `process_func`, но второй EMM-слот в scope не входит.
|
||||
|
||||
**Проверить ДО кодинга:** пишется ли `test_hdd.chd` из-под MAME. Если образ
|
||||
только на чтение, файловый путь упрётся в это на первом же шаге и порядок
|
||||
@@ -234,6 +236,7 @@ roomtest.
|
||||
+5 pop_current_level 1 Б
|
||||
+6 длина полезной части 2 Б (контроль, что обход совпал)
|
||||
+8 ... поля встык, ОДИН порядок на запись и на чтение ...
|
||||
.. checksum 2 Б (заголовок + payload)
|
||||
```
|
||||
|
||||
Версия проверяется первой; несовпадение — отказ, как в SDLPoP. Никаких
|
||||
@@ -282,13 +285,12 @@ static void qs_walk(qs_io_t io) /* io = запись или чтение */
|
||||
|
||||
| шаг | что | критерий готовности |
|
||||
|---|---|---|
|
||||
| **QS1** | Аксессоры/сериализаторы для `static`-состояния банковых модулей: `pop_trob.c` (`room_modif`, `room_seen`, `trobs`, `trob_seed`), `pop_room.c` (`mobs_live`), страница уровня (чтение `fg`) | хост-тест `tests-host/t_qsave.c`: обход туда-обратно на синтетическом состоянии даёт байт-в-байт исходное |
|
||||
| **QS0** | Проверить, что `D:` пишется из-под MAME (пробный файл из roomtest) | файл создался и читается обратно после рестарта программы |
|
||||
| **QS2** | Ядро: `qs_walk` + запись/чтение файла `QUICKSAVE.SAV`, магия и версия, отказ при несовпадении | сохранение и загрузка **в той же комнате, без движения** — картинка и состояние не изменились |
|
||||
| **QS1** | Аксессоры/сериализаторы для `static`-состояния банковых модулей: `pop_trob.c` (`room_modif`, `room_seen`, `trobs`, `trob_seed`), `pop_room.c` (`mobs_live`), страница уровня (чтение `fg`) | хост-тест `tests-host/t_qsave.c`: обход туда-обратно на синтетическом состоянии даёт байт-в-байт исходное |
|
||||
| **QS2** | Ядро: `qs_walk` + `POP.SAV`, магия/версия/checksum, безопасная замена с предыдущей валидной копией в `POP.BAK` | сохранение и загрузка **в той же комнате, без движения**; порча SAV не портит BAK |
|
||||
| **QS3** | Восстановление отрисовки (§6), включая обе страницы дабл-буфера | загрузка после перехода в другую комнату; нет мерцания через кадр |
|
||||
| **QS4** | Клавиши **F6/F9** (или свободные из `pop_cheat.h`) через `<kbd_raw.h>`, флаги `need_quick_save/load`, обработка **между кадрами** | загрузка посреди боя/падения не ломает `play_seq` |
|
||||
| **QS5** | Загрузка с **другого уровня** (перезагрузка уровня и атласов) | сохранить на ур. 2, уйти на ур. 12, загрузить — тайлсет и стражи верные |
|
||||
| **QS6** | Опционально: второй слот в EMM-странице тем же сериализатором | мгновенное «переиграть» без обращения к диску |
|
||||
|
||||
Порядок не переставлять: QS0 первым (он может изменить весь план), QS3 без
|
||||
QS2 нечего проверять, а QS5 обязан идти после QS3 — иначе смена тайлсета
|
||||
@@ -320,5 +322,7 @@ QS2 нечего проверять, а QS5 обязан идти после QS3
|
||||
5. **Дабл-буфер.** Самый вероятный источник «почти работает»: забыть вторую
|
||||
страницу. Симптом — мерцание через кадр
|
||||
(см. `roomtest/CLAUDE.md`, раздел про дабл-буфер).
|
||||
6. **Открытый вопрос:** нужен ли снимок в файле вообще, или EMM-страницы
|
||||
достаточно. Решать после QS3, по факту использования.
|
||||
6. **Транзакция SAV/BAK.** До кодинга проверить на DSS семантику
|
||||
rename/replace. Если атомарная замена не гарантирована, писать через
|
||||
`POP.NEW`, проверять его после close и не удалять единственную валидную копию
|
||||
до завершения новой.
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Бюджет резидента W1/W2: как мерить и как освобождать
|
||||
|
||||
Резидент huge-режима — окно `0x4100..0xBB00` (стек с 0xBB00): код в W1,
|
||||
данные в W2, между концом данных и стеком остаётся куча. Всё, что туда не
|
||||
влезло, живёт в банках.
|
||||
|
||||
## Как СМОТРЕТЬ, а не гадать
|
||||
|
||||
**Карта линкера врёт.** File-static SDCC в неё не попадает, и «дырка» между
|
||||
двумя именованными символами приписывается предыдущему целиком. По карте
|
||||
выходило, что у `pop_bg` 1529 Б данных (на деле 289), а у `pop_t_win_clear`
|
||||
1282 Б кода — при том, что это однострочник, а 1282 Б это два статических
|
||||
помощника соседнего `pop_blit_b`.
|
||||
|
||||
Точный источник — объектные файлы: строки `A <area> size <n> flags <f>` в
|
||||
`.rel` дают ровный размер каждой области модуля, а `S <sym> Def/Ref` — кто
|
||||
символ определяет и кто на него ссылается.
|
||||
|
||||
**Частоту вызовов мерить в MAME счётчиком**, а не оценивать по смыслу:
|
||||
|
||||
```
|
||||
bpset <frame_probe>,1,{printf "F %d ...",temp0,...; temp0=0;...; g}
|
||||
bpset <func_addr>,1,{temp0=temp0+1; g}
|
||||
```
|
||||
|
||||
Обязательна **канарейка** — счётчик заведомо горячей функции в том же
|
||||
прогоне. Дважды спасала: один раз показала, что перехода комнаты в окне
|
||||
замера не было (все нули), другой — что зонды вообще не встали (в zsh
|
||||
`set -- $pair` НЕ разбивает строку на слова, и адрес уезжал в мусор).
|
||||
Полную перерисовку комнаты форсировать читом `+`/`-`, ходьбой ненадёжно.
|
||||
|
||||
## Сделано
|
||||
|
||||
### 1. malloc вон из резидента (−613 Б)
|
||||
|
||||
`cbl_open` держал `malloc`/`free` в мёртвой ветке `CBL_UNDERRUN_SILENCE`, а
|
||||
линкер тянет `.rel` целиком — и куча приезжала каждому приложению. Разведены
|
||||
две публичные точки входа (`cbl_open` / `cbl_open_silence`) поверх общего
|
||||
`_cbl_open_raw`; `cbl_close` больше не зовёт `free`.
|
||||
|
||||
### 2. Разрез pop_tile: холодная половина в банк 5 (−1788 Б)
|
||||
|
||||
`pop_tile.c` был крупнейшим жильцом резидента (5 972 Б кода). Целиком он не
|
||||
уедет: его const-таблицы (`POP_TILE_DIV/MOD`, `pop_tile_table`, таблицы
|
||||
кадров) читают банки 2, 3, 7 и 8, а таблица в чужом банке не видна.
|
||||
|
||||
Отбирали ЗАМЕРОМ, на двух тайлсетах (подземелье ур. 1 и дворец ур. 4 —
|
||||
`pop_mem_b` рисует композитный кусок и мог оказаться дворцовым). Порог —
|
||||
пик не больше 3 вызовов на кадр.
|
||||
|
||||
| уехало в банк 5 | пик/кадр | | осталось в резиденте | пик/кадр |
|
||||
|---|---:|---|---|---:|
|
||||
| `pop_mem_b` | 0 | | `pop_tile_code` | 296 |
|
||||
| `pop_cd_hit` (+`hit_rect`) | 0 | | `pop_cd_touch` | 198 |
|
||||
| `pop_t_win_set/clear` | 0..1 | | `pop_blit_b` (+2 статика) | 184 |
|
||||
| `pop_heal_off` | 0..1 | | `pop_wall_modifier` | 101 |
|
||||
| `pop_potion_flask` | 0..1 | | `pop_env_b` | 73 |
|
||||
| `pop_room_set_above/below` | 1 | | `pop_tile_mod` | 70 |
|
||||
| `pop_cd_init/clear` | 1 | | `pop_cd_batch_end` | 40 |
|
||||
| `pop_bar_black` | 3 | | `pop_fore_set_clip` | 2 |
|
||||
| `pop_cd_hit_slot` | 2..3 | | все const-таблицы | — |
|
||||
|
||||
`pop_fore_set_clip` (88 Б) оставлен намеренно: не стоит отказа от прямого
|
||||
вызова из банка 4, ради которого он и заводился.
|
||||
|
||||
**Цена трамплина замерена**: 252 такта пролог + 84 эпилог + ~50 на стороне
|
||||
вызывающего = **~410 тактов** на вызов. Итого ~1 000 тактов на кадр покоя
|
||||
(0,2 % работы) и ~3 700 на кадр редрава (0,009 растра).
|
||||
|
||||
**Ключ, почему это безопасно:** вызов банк → резидент ПРЯМОЙ, трамплин не
|
||||
нужен (W1/W2 замаплены всегда). Поэтому `blit_b_clip` просто перестал быть
|
||||
`static` и объявлен в `_pop_tile.h`, а не переехал следом за `pop_mem_b`.
|
||||
|
||||
### Итог
|
||||
|
||||
| | было | стало |
|
||||
|---|---:|---:|
|
||||
| `_CODE` резидента | 24 329 | **21 928** |
|
||||
| свободно до стека | **129 Б** | **2 535 Б** |
|
||||
| BANK5 | 2 080 (13 %) | 3 954 (24 %) |
|
||||
|
||||
Проверено в MAME: уровень 1 (подземелье) и уровень 4 (дворец), переходы
|
||||
комнат читом `+`, ходьба — фон, факелы, решётки, гобелены, колонны без
|
||||
искажений.
|
||||
|
||||
## ЛОВУШКА: данные банка в его страницу — НЕ ДЕЛАТЬ без разбора
|
||||
|
||||
Отдельная попытка (`--bank-data=SRC`, коммиты 3545826/9025573) **откачена**:
|
||||
перенос писучих данных банкового модуля в его 16-КБ страницу давал цветной
|
||||
мусор блоками и ронял DSS.
|
||||
|
||||
У `pop_trob` причина найдена: `pop_trob_modif()` ВОЗВРАЩАЕТ УКАЗАТЕЛЬ на
|
||||
`room_modif[24][30]`, а зовут её из банков 2, 3, 7 и резидента — после
|
||||
переноса они пишут по 0xC000+ в СВОЮ страницу, поверх чужого кода.
|
||||
Def/Ref-анализ такого не видит: снаружи ссылки на символ нет, есть ссылка на
|
||||
функцию, отдающую его адрес. Но и `pop_room`, у которого утечки указателя
|
||||
найти не удалось, ломался так же — механизм понят не до конца.
|
||||
|
||||
Нулевая инициализация при этом ни при чём: `mkexe -p 0` был проверен по
|
||||
образу (прогон нулей 14 304 Б, самый длинный прогон 0xFF — 14).
|
||||
|
||||
**Перенос КОДА в банк — штатный путь, на нём стоят все наши банки. Ломался
|
||||
именно перенос ДАННЫХ.**
|
||||
|
||||
## Что осталось
|
||||
|
||||
- `roomtest.c` 2 508 Б и `pop_kid.c` 2 418 Б — следующие по величине, но оба
|
||||
горячие (главный цикл и `play_seq`).
|
||||
- `pop_level.c` 956 Б кода + 660 Б данных (из них `pop_dl1`/`pop_dl2` по
|
||||
256 Б — таблицы дверных связей).
|
||||
- BANK7 на 77 %: если понадобится место в нём — выносить `pop_redraw.c`.
|
||||
@@ -0,0 +1,266 @@
|
||||
# Атлас Тени — разбор и план
|
||||
|
||||
Дата: 2026-08-20. Статус: **ЗАКРЫТО.** Ш0-Ш4 сделаны, прогон
|
||||
пользователем на уровнях **4, 5, 6 и 12** — всё корректно.
|
||||
|
||||
Задача: сейчас Тень рисуется спрайтами Кида (вне боя) и спрайтами стража
|
||||
(в бою) как есть, поэтому от Кида она не отличается. В оригинале её вид
|
||||
даёт наложение спрайта на себя со сдвигом; наш пакетный блит такого не
|
||||
умеет, поэтому запекаем результат в отдельный атлас заранее.
|
||||
|
||||
## 1. Что делает оригинал (проверено по SDLPoP, не по памяти)
|
||||
|
||||
`draw_objtable_item`, `seg008.c:1600`:
|
||||
|
||||
```c
|
||||
case 1: // shadow
|
||||
add_midtable(obj_chtab, obj_id + 1, obj_xh, obj_xl, obj_y, blitters_2_or, 1);
|
||||
add_midtable(obj_chtab, obj_id + 1, obj_xh, obj_xl + 1, obj_y, blitters_3_xor, 1);
|
||||
```
|
||||
|
||||
Тот же самый спрайт кладётся ДВАЖДЫ: первый проход в x, второй в x+1.
|
||||
Два уточнения, которые меняют алгоритм запекания:
|
||||
|
||||
1. **`blitters_2_or` — это НЕ побитовое ИЛИ.** В SDLPoP он реализован
|
||||
обычным блитом с colour key = индекс 0 (`method_6_blit_img_to_scr`,
|
||||
`seg009.c:3306`: `SDL_SetColorKey(image, SDL_TRUE, 0)`). То есть
|
||||
первый проход — наш обычный прозрачный блит, один в один.
|
||||
2. **`blitters_3_xor` работает по 24-битному RGB, а не по индексам
|
||||
палитры** (`blit_xor`, `seg009.c:3190`: конвертация в 24 бита, затем
|
||||
`*p_dest ^= *p_src` побайтно). Прозрачности у него нет вообще —
|
||||
XOR'ится весь прямоугольник, но прозрачные пиксели спрайта это
|
||||
чёрный 0x000000, а XOR с нулём ничего не меняет.
|
||||
|
||||
Отсюда и берётся необходимость СВОЕЙ палитры: XOR двух цветов игровой
|
||||
палитры даёт цвет, которого в ней нет.
|
||||
|
||||
### Каким набором спрайтов рисуется Тень (проверено 2026-08-20)
|
||||
|
||||
Сначала я решил, что в боевых кадрах Тень рисуется спрайтами СТРАЖА, и
|
||||
записал это в план. **Это было неверно, поправка ниже.**
|
||||
|
||||
Набор выбирает НЕ charid. `load_frame_to_obj` (`seg008.c:1752`):
|
||||
|
||||
```c
|
||||
word chtab_base = id_chtab_2_kid; // жёстко Кид
|
||||
obj_chtab = chtab_base + (cur_frame.sword >> 6); // старшие 2 бита кадра
|
||||
```
|
||||
|
||||
то есть набор берётся из САМИХ ДАННЫХ КАДРА, поле `sword`: младшие 6 бит —
|
||||
картинка меча, старшие два — смещение chtab относительно Кида
|
||||
(`types.h:361`). Проверил обе таблицы:
|
||||
|
||||
- `frame_table_kid` — **все 241 кадра** имеют `sword & 0xC0 == 0` → chtab_2.
|
||||
Значит **Кид всегда рисуется своими спрайтами**, боевые кадры 150..189 не
|
||||
исключение;
|
||||
- `frame_tbl_guard` — все кадры имеют `0xC0` → chtab_5.
|
||||
|
||||
Тень берёт `frame_tbl_guard` для кадров 150..189 (`seg006.c:533`), значит в
|
||||
бою она идёт через chtab_5. **Но chtab_5 — это не «страж», это «соперник
|
||||
уровня»**: он грузится из `tbl_guard_dat[tbl_guard_type[уровень]]`
|
||||
(`seg000.c:1117`), а `tbl_guard_type[12] == 4` → **SHADOW.DAT**.
|
||||
|
||||
Открыл SHADOW.DAT: его палитра **побайтно равна палитре Кида** (у GUARD.DAT
|
||||
там серая рампа под перекраску), а спрайты — Кид в боевых позах, не страж.
|
||||
Отрендерил тройками «Кид / SHADOW.DAT / GUARD.DAT» для кадров
|
||||
151/153/158/161/167: первые две колонки — один и тот же персонаж, третья —
|
||||
серый страж в тюрбане.
|
||||
|
||||
**Вывод: Тень ВСЕГДА выглядит Кидом, и ощущение пользователя верно.**
|
||||
Спрайты при этом лежат в двух файлах: не-боевые кадры в chtab_2 (KID),
|
||||
боевые — в chtab_5, заполненном SHADOW.DAT.
|
||||
|
||||
### Можно ли взять для боя собственные кадры Кида
|
||||
|
||||
Механически да: `frame_table_kid` покрывает 150..189 со своей геометрией,
|
||||
и хватило бы снять спецветку для `charid_1_shadow` в `load_frame`. Но
|
||||
кадры НЕ совпадают: из 34 боевых у 31 отличается габарит (на 1-6 px), у 3
|
||||
отличаются пиксели. Это разная графика, а не одна и та же в двух файлах.
|
||||
Поэтому берём SHADOW.DAT — он и есть «кадры Кида для Тени», подготовленные
|
||||
авторами.
|
||||
|
||||
## 2. Единственное место, где запечка отличается от оригинала
|
||||
|
||||
XOR идёт по тому, что УЖЕ на экране, то есть результат зависит от фона.
|
||||
Разбор по пикселям показывает, что зависимость узкая:
|
||||
|
||||
| пиксель | первый проход | второй проход | зависит от фона? |
|
||||
|---|---|---|---|
|
||||
| спрайт непрозрачен в x | закрашен цветом спрайта | XOR с цветом из x−1 | **нет** |
|
||||
| прозрачен в x, непрозрачен в x−1 | фон | фон XOR цвет | **да** |
|
||||
| прозрачен в обоих | фон | фон | нет (не рисуем) |
|
||||
|
||||
То есть от фона зависит только **кайма в один пиксель по левым кромкам
|
||||
силуэта**. На чёрном фоне (а Тень почти всегда на нём — уровень 4 у
|
||||
зеркала, уровень 6, бой на 12-м) `фон XOR цвет == цвет`, и запечка точна.
|
||||
На светлом фоне оригинал подкрасит эту кайму, мы — нет.
|
||||
|
||||
**Это осознанное расхождение, в `impl_diff.md` при реализации.**
|
||||
|
||||
## 3. Замеры на реальных ассетах
|
||||
|
||||
Прогон алгоритма по правильным наборам (`SDLPoP/data/KID` +
|
||||
`SDLPoP/data/SHADOW`):
|
||||
|
||||
| набор | кадров | макс. габарит | разных цветов |
|
||||
|---|---:|---|---:|
|
||||
| Кид (chtab_2) | 219 | 56×57 | 44 |
|
||||
| SHADOW.DAT (chtab_5 на ур. 12) | 32 | 49×38 | 22 |
|
||||
| **вместе** | **251** | 57×57 (с учётом +1 px сдвига) | **45** |
|
||||
|
||||
Цветов вместе почти столько же, сколько у одного Кида: SHADOW.DAT сидит на
|
||||
той же палитре, новых сочетаний XOR почти не даёт. Всего пикселей во всех
|
||||
кадрах Тени — 91 045.
|
||||
|
||||
### Насколько заметна подмена
|
||||
|
||||
«Затронуто» само по себе ничего не говорит — важно, НА СКОЛЬКО сместился
|
||||
цвет. Порог различимости на плоской заливке ~30-40 единиц евклида в RGB
|
||||
(максимум возможного — 441). Пробовал три стратегии подбора:
|
||||
|
||||
- **A** — оставить N самых частых цветов ТОЧНО, остальные в ближайший;
|
||||
- **B** — взвешенный k-means по всем цветам (двигает вообще все);
|
||||
- **C** — гибрид: часть слотов под точные частые, остаток — кластеры хвоста.
|
||||
|
||||
| палитра | стратегия | изменено px | заметно (40..90) | сильно (>90) | худшая |
|
||||
|---|---|---:|---:|---:|---:|
|
||||
| 16 | A самые частые | 959 (1,05 %) | 569 | 381 | 128 |
|
||||
| **16** | **C гибрид 5+11** | 6 984 (7,67 %) | 1 078 | **76** | 114 |
|
||||
| 32 | A самые частые | 50 (0,05 %) | 40 | 4 | 113 |
|
||||
| 32 | C гибрид 28+4 | 73 (0,08 %) | 58 | **0** | 90 |
|
||||
|
||||
Читается так. При 32 цветах всё практически идеально: 73 пикселя на 251
|
||||
кадр, грубых промахов нет вовсе. При 16 цветах выбор стратегии виден:
|
||||
«самые частые» трогает меньше пикселей (959), но 381 из них уезжает СИЛЬНО;
|
||||
гибрид размазывает ошибку — грубых остаётся 76, то есть примерно **0,3
|
||||
пикселя на кадр**.
|
||||
|
||||
### Решение: **16 цветов, стратегия C (гибрид 5 + 11)**
|
||||
|
||||
Числа выше — про пиксели, а решает глаз. Отрендерил одни и те же кадры в
|
||||
трёх видах (точный цвет / 32 / 16) и сравнил в увеличении ×3: **отличий
|
||||
не видно**. Объяснение в самих числах: перцептивно значимых пикселей при
|
||||
16 цветах гибридом — 1 154 на 251 кадр, это ~4,6 пикселя на кадр при
|
||||
~1 000 видимых, и они РАССЫПАНЫ по контуру, а не собраны в пятно.
|
||||
|
||||
Поэтому берём 16, а не 32: экономим блок палитры (пригодится под будущие
|
||||
наборы — принцесса, визирь, мышь), а разница неразличима.
|
||||
|
||||
**Но стратегия обязана быть гибридной.** При 16 цветах «взять самые
|
||||
частые» впятеро хуже по грубым промахам (381 пиксель против 76), и это
|
||||
единственное место, где выбор стратегии виден. Гибрид: 5 самых частых
|
||||
берём ТОЧНО, оставшиеся 11 слотов отдаём под взвешенные кластеры хвоста.
|
||||
|
||||
Если в реальной игре кайма всё же будет резать глаз — переход на 32 цвета
|
||||
это одна константа в упаковщике и один блок палитры, данные не меняются.
|
||||
|
||||
## 4. Палитра: что занято и куда класть
|
||||
|
||||
| блок | кто | примечание |
|
||||
|---|---|---|
|
||||
| 0x30..0x3F | VGA-16 | общая; из неё цвет вспышки, пузырьки зелий, отладочная метка |
|
||||
| 0x40..0x4F | chtab_1 | зелья, пламя |
|
||||
| 0x50..0x5F | env тайлсета | **меняется** подземелье/дворец |
|
||||
| 0x60..0x6F | wall тайлсета | **меняется** подземелье/дворец |
|
||||
| 0x70..0x7F | chtab_2 | Кид |
|
||||
| 0x80..0x8F | chtab_0 | меч в руке |
|
||||
| 0x90..0x9F | chtab_5 | страж, перезаливается цветом стража |
|
||||
|
||||
Занято 112 слотов из 256, **свободно 144** — девять выровненных блоков по
|
||||
16. Оговорки: 0xFF в наших атласах это маркер прозрачности, а запись 0
|
||||
правит `flash_bg`, так что блоки 0x00 и 0xF0 лучше не трогать.
|
||||
|
||||
**Берём 0xA0..0xAF** (16 слотов) — сразу за стражем, персонажи остаются
|
||||
сгруппированы, а блок 0xB0 остаётся свободным (под 32 цвета Тени, если
|
||||
понадобится, или под будущие наборы).
|
||||
|
||||
Важно: в наших атласах прозрачность кодируется байтом 0xFF, а исходный
|
||||
индекс 0 в них означает «прозрачно». У Тени **чёрный — настоящий цвет**
|
||||
(это XOR-погашенная середина силуэта, 51 % всех её пикселей), поэтому у
|
||||
неё маппинг свой: «нет пикселя» → 0xFF, цвет k → 0xA0 + k, и слот 0xA0 =
|
||||
чёрный НЕПРОЗРАЧНЫЙ.
|
||||
|
||||
## 5. Объём
|
||||
|
||||
251 кадр против 219 у Кида — по страницам EMM примерно как нынешний
|
||||
набор Кида (28 страниц), плюс пара на кадры из SHADOW.DAT. При 215 свободных
|
||||
страницах на старте (memory `sprinter_emm_budget`) это не проблема.
|
||||
|
||||
Кадры смерти («убитый Кид») пока НЕ вырезаем — экономия несколько
|
||||
страниц, а риск промахнуться мимо нужного кадра реальный: Тень на 12-м
|
||||
уровне умирает.
|
||||
|
||||
## 6. План работ
|
||||
|
||||
**Ш0. Довезти SHADOW.DAT.** Его у нас нет вовсе (см. BUG-SHADOW-SET) —
|
||||
добавить каталог ассетов и правило в Makefile рядом с GUARD/SKEL/VIZIER.
|
||||
|
||||
**Ш1. Упаковщик** `toolchain/pop_pack_shadow.py`: прогнать оба набора
|
||||
через алгоритм §1, собрать 16-цветную палитру гибридом (10 точных + 6
|
||||
кластеров хвоста), выдать
|
||||
`poc/res/shadow/shadow0..N.atl` + `shadow.pal` + `pop_shadow_atlas.h`.
|
||||
Критерий: предпросмотр PNG совпадает с видом Тени в SDLPoP.
|
||||
|
||||
**Ш2. Загрузка**: `pop_shadow_load()` рядом с `pop_kid_load`, палитра в
|
||||
0xA0..0xAF, и обе страницы дабл-буфера (как `bg_load_tile_pal`).
|
||||
Грузить ЛЕНИВО — только когда на уровне есть Тень (4, 5, 6, 12), иначе
|
||||
28 страниц EMM висят зря.
|
||||
|
||||
**Ш3. Отрисовка**: одно место — `pop_cdraw.c:551..568`, где выбирается
|
||||
`pages`. Сейчас там для соперника берётся `gp`, а для не-боевых кадров
|
||||
Тени подменяется на `kidp`; станет «charid == CHARID_1_SHADOW → shadowp»
|
||||
БЕЗ подмены: обе половины (кадры Кида и кадры SHADOW.DAT) лежат в ОДНОМ
|
||||
атласе Тени, так что ветка становится проще нынешней.
|
||||
|
||||
**Ш4. Проверка в MAME**: уровень 4 (Тень у зеркала), уровень 6 (Тень
|
||||
крадёт зелье), уровень 12 (бой с Тенью — там она в боевых кадрах, то
|
||||
есть проверяется вторая половина набора).
|
||||
|
||||
## 7. Что проверить артефактом до Ш3
|
||||
|
||||
1. Индекс кадра для Тени в боевых кадрах: `frame_tbl_guard` адресуется
|
||||
как `frame + add_frame - 149` (`seg006.c:535`), то есть у нашего
|
||||
атласа Тени нумерация двух половин должна совпадать с тем, что уже
|
||||
делает `pop_frame_tbl_is_guard`.
|
||||
2. Перекраска Тени НЕ нужна: SHADOW.DAT приходит уже в палитре Кида, а
|
||||
`curr_guard_color` у не-стражей равен 0 (`seg002:183`). Ветку
|
||||
`pop_guard_set_palette` для типа 4 звать нельзя — она затрёт палитру
|
||||
Тени палитрой стража.
|
||||
|
||||
---
|
||||
|
||||
# 8. Как сделали (2026-08-20)
|
||||
|
||||
- `toolchain/pop_pack_shadow.py` — запекает обе половины, собирает
|
||||
16-цветную палитру гибридом (получилось 5 частых точно + 11 кластеров
|
||||
хвоста), пишет `poc/res/shadow/sk0..27.atl`, `sf0..3.atl` и
|
||||
`roomtest/pop_shadow_atlas.h`. Итог: **251 спрайт, 32 EMM-страницы,
|
||||
225 178 Б**.
|
||||
- `roomtest/pop_shadow.c/.h` (банк 8) — загрузка. **Грузим один раз при
|
||||
старте**, а не по уровням: страниц EMM с запасом, а забыть перезагрузку
|
||||
на границе легко — ровно так и появился BUG-SHADOW-SET.
|
||||
- `pop_cdraw.c` — одна ветка выбора атласа; заодно брызги урона теперь
|
||||
берут атлас отдельным указателем (`spl`), потому что у оригинала они
|
||||
всегда из «своего» chtab, а у Тени кадр может прийти из другой
|
||||
половины набора.
|
||||
|
||||
## Грабля, стоившая одного прогона
|
||||
|
||||
Набор загрузился, силуэт нарисовался правильной формы — и **целиком
|
||||
чёрный**. Причина: `gfx_pal_fload("KID\\kid.pal")` заливает ВСЕ 256
|
||||
записей палитры и затирает любые слоты, выставленные до него. Ровно та
|
||||
же беда уже была с тайлсетом — сразу за этим вызовом стоит
|
||||
`pop_bg_pal_apply`. Поэтому палитра Тени вынесена в отдельный
|
||||
`pop_shadow_pal_apply()` и зовётся там же, а не внутри загрузки атласов.
|
||||
|
||||
**Правило на будущее: любой новый набор палитровых слотов красится ПОСЛЕ
|
||||
kid.pal, рядом с pop_bg_pal_apply.**
|
||||
|
||||
## Проверено
|
||||
|
||||
Уровни 4 (рождение из зеркала), 5 (кража зелья), 6 (прыжок через
|
||||
пропасть) и 12 (бой — там работает вторая половина набора, `sf*`) —
|
||||
прогон пользователя 2026-08-20, расхождений не найдено.
|
||||
|
||||
Расхождение из §2 (кайма в один пиксель по левым кромкам на НЕчёрном
|
||||
фоне) записано в `impl_diff.md`.
|
||||
@@ -0,0 +1,963 @@
|
||||
# Звук в порте PoP — разбор и план
|
||||
|
||||
Дата: 2026-08-20, музыка дописана 2026-08-25. Статус: **PCM-эффекты
|
||||
реализованы; музыка — путь C (PCM через CBL), первый трек играет.**
|
||||
|
||||
Задача пользователя: добавить звук. Приоритет — эффекты; музыку, если
|
||||
найдётся способ. Эффекты — **обязательно WAV, а не PC-спикер**
|
||||
(уточнение 2026-08-20; см. §1а — оказалось, что они и так все в WAV). Ниже — что реально лежит в ассетах, что умеет железо, и
|
||||
почему получившийся план вышел проще, чем ожидалось.
|
||||
|
||||
## 1. Главный вывод
|
||||
|
||||
**Ни MIDI разбирать, ни ноты сочинять не придётся, и ресэмплировать тоже.**
|
||||
|
||||
- Эффекты уже лежат **8-битным беззнаковым PCM на 11 000 Гц**, а у CBL есть
|
||||
режим **10 937,5 Гц** — расхождение 0,6 %, на слух неразличимо. Формат
|
||||
сэмпла совпадает с нашим CBL байт в байт (`cbl.h`: 8 бит, беззнаковый,
|
||||
центр 0x80). То есть данные играются **как есть**, без конверсии.
|
||||
- Музыка есть в виде **списков нот PC-спикера — 7 КБ на всю игру**, а нота
|
||||
там задана прямо в ГЕРЦАХ. Пересчёт в делитель AY — одно деление.
|
||||
- AY и COVOX на Sp2000 сведены в **один ЦАП TDA1543** (док Ивана Мака,
|
||||
§5), значит музыка на AY и эффекты через CBL звучат ОДНОВРЕМЕННО, и
|
||||
смешивать их программно не надо.
|
||||
|
||||
## 1а. Уточнение после разбора ВСЕХ наборов MS-DOS версии (2026-08-20)
|
||||
|
||||
Пользователь попросил, чтобы эффекты были не PC-спикером, а WAV, и заодно
|
||||
посмотреть `mt32snd[1-2].dat`. Разобрал все восемь `.dat` из `MSDOS/`.
|
||||
Ответ короткий: **эффекты И ТАК все до одного есть в WAV, а вот у музыки
|
||||
WAV нет ни в одном наборе.**
|
||||
|
||||
| набор | формат | какие звуки | сколько |
|
||||
|---|---|---|---|
|
||||
| `digisnd1..3` | **WAV**, 8 бит PCM | эффекты 0..23, 44..49, 51 | **31** |
|
||||
| `mt32snd1..2` | MIDI для Roland MT-32 | ТЕ ЖЕ эффекты 0..23, 44..51 | 31 |
|
||||
| `midisnd1..2` | MIDI (AdLib/GM) | **музыка** 24..43, 50, 52..56 | 22 |
|
||||
| `ibm_snd1..2` | ноты PC-спикера | **всё подряд, 0..56** | 57 |
|
||||
|
||||
Здесь пряталась ловушка: `mt32snd` по имени похож на «музыку получше», а
|
||||
на деле это набор ЭФФЕКТОВ для владельцев MT-32 — те же id, что у
|
||||
`digisnd`. Музыки в нём нет вовсе.
|
||||
|
||||
Частоты WAV: 28 звуков на 11 000 Гц, по одному на 8 200, 14 000 и 2 750.
|
||||
Итого 112 922 сэмпла = **11,4 с, ~110 КБ ≈ 6,7 EMM-страниц**.
|
||||
|
||||
Только PC-спикером, без альтернатив, остаются четыре id: 31, 34, 42
|
||||
(пустые) и **38 `blink`** — четыре ноты. То есть на весь звук игры
|
||||
спикер нужен ровно для одного писка.
|
||||
|
||||
### Музыка: WAV нет, есть три пути
|
||||
|
||||
| путь | данные | что получится | цена |
|
||||
|---|---|---|---|
|
||||
| **A. Ноты PC-спикера на AY** | 7 КБ | один квадратный голос — ровно то, что слышали на IBM PC 1989 | секвенсор на полсотни строк |
|
||||
| **B. MIDI -> AY, три голоса** | 27 КБ исходника | богаче: бас + мелодия + арпеджио | разбор MIDI + раскладка по каналам |
|
||||
| **C. MIDI -> WAV на хосте, стрим через CBL** | см. ниже | настоящее звучание, любое | нужен синтезатор на хосте + место |
|
||||
|
||||
Про объём для пути C (замерено по длительностям треков):
|
||||
|
||||
| группа | треков | длительность | WAV 11 кГц |
|
||||
|---|---:|---:|---|
|
||||
| звучат ПО ХОДУ игры (гимн уровня, смерть, зелья, перо, победа) | 12 | 74,7 с | **803 КБ = 50 EMM-страниц** |
|
||||
| заставки и титры | 10 | 248,1 с | 2 665 КБ = 167 страниц |
|
||||
|
||||
Игровая половина в EMM **влезает** (при ~215 свободных страницах), а
|
||||
заставочная — нет, её пришлось бы стримить с диска. Но заставки идут
|
||||
тогда, когда игра ничего не рисует, так что стрим там как раз уместен.
|
||||
|
||||
**Предложение:** начинать с A (7 КБ, работает сразу, ноль рисков), а C
|
||||
держать как отдельную фазу — она ортогональна: проигрыватель WAV для
|
||||
музыки это тот же `cbl_push`, что и для эффектов, только длиннее буфер.
|
||||
B имеет смысл только если C окажется неподъёмным по месту.
|
||||
|
||||
## 1б. РЕШЕНИЯ (пользователь, 2026-08-20)
|
||||
|
||||
1. **Эффекты — WAV через CBL, 8 бит, МОНО, единая частота.** Проверено по
|
||||
`convert_digi_sound` (`seg009.c:2358`): один байт на кадр, то есть
|
||||
моно, и байт беззнаковый (`(b | b<<8) - 32768`), центр 0x80 — ровно
|
||||
формат нашего CBL. Стерео в данных нет вовсе: каналы у оригинала
|
||||
размножаются уже на выходе (`digi_audiospec->channels`).
|
||||
2. **Музыка, первый заход — путь A** (ноты спикера на AY).
|
||||
3. **Заставки и титры — потом WAV.** Конфликта с эффектами там нет:
|
||||
одновременно они не звучат.
|
||||
4. **Музыка ПО ХОДУ игры** (она может совпасть с эффектом) — открыто, два
|
||||
варианта: либо тоже WAV с ГАШЕНИЕМ эффектов на время музыки (музыка
|
||||
важнее — **проверить на слух**), либо путь B (MIDI -> три голоса AY).
|
||||
5. **Все эффекты привести к одной частоте.**
|
||||
6. Синтезатор для MIDI -> WAV — решать ближе к делу; годятся и онлайн-
|
||||
конвертеры, хоть вручную, если fluidsynth/timidity не поставится.
|
||||
|
||||
### Про единую частоту (замер)
|
||||
|
||||
Приводим не к 11 000, а ровно к **10 937,5 Гц — частоте CBL**
|
||||
(`CBL_FREQ_10K9`). Тогда тон точен, а не «на 0,6 % ниже»: проигрывание
|
||||
11 000 Гц данных на 10 937,5 даёт сдвиг **−9,9 цента**, что на коротком
|
||||
эффекте не слышно, но бесплатно избавиться от него всё равно приятно —
|
||||
пересчитывать три файла всё равно придётся.
|
||||
|
||||
| id | звук | было | станет | дельта |
|
||||
|---|---|---|---|---:|
|
||||
| 15 | `leveldoor_sliding` | 2 750 Гц, 4 436 сэмплов | 17 643 | **+13 207 Б** |
|
||||
| 23 | `footstep` | 8 200 Гц, 996 | 1 329 | +333 Б |
|
||||
| 51 | `princess_door_opening` | 14 000 Гц, 6 188 | 4 834 | −1 354 Б |
|
||||
| — | остальные 28 (11 000 Гц) | — | ×0,9943 | −588 Б |
|
||||
|
||||
Итог: **112 922 -> 124 531 Б, 6,9 -> 7,6 EMM-страниц.** Рост целиком от
|
||||
`leveldoor_sliding`: источник у него 2 750 Гц, вчетверо реже целевой, и
|
||||
апсэмплинг не улучшит звучание — только уравняет формат. Платим 0,8
|
||||
страницы за то, что **CBL открывается ОДИН раз и частоту менять не надо
|
||||
никогда** — ни между эффектами, ни при переходе на музыку-WAV.
|
||||
|
||||
(Альтернатива для него — хранить как есть и повторять каждый сэмпл
|
||||
четырежды в рантайме: 2 750 × 4 = 11 000 ровно. Это код в `fill()` ради
|
||||
13 КБ; не стоит того, но если место когда-нибудь прижмёт — вариант есть.)
|
||||
|
||||
## 1в. Бюджет памяти EMM (живой замер 2026-08-20)
|
||||
|
||||
Замерено `mem_info` из работающей программы (уровень 1), а не посчитано на
|
||||
бумаге: инструментовка ставилась временно и откатана.
|
||||
|
||||
| | страниц | КБ |
|
||||
|---|---:|---:|
|
||||
| всего в машине | 256 | 4 096 |
|
||||
| система (DSS) + сам exe: база + 8 банков кода | **43** | 688 |
|
||||
| наши ассеты | **81** | 1 296 |
|
||||
| **занято** | **124** | 1 984 |
|
||||
| **свободно** | **132** | **2 112 (2,06 МБ)** |
|
||||
|
||||
Разбивка 81 страницы ассетов (сходится точно):
|
||||
|
||||
| набор | страниц |
|
||||
|---|---:|
|
||||
| **Тень** (`sk*` 28 + `sf*` 4) | **32** |
|
||||
| Кид (`kid0..27`) | 28 |
|
||||
| фон тайлсета (env 10 + wall 1 + fore 1) | 12 |
|
||||
| страж | 5 |
|
||||
| зелья (chtab_1), меч, `kid_data.bin`, страница уровня | по 1 |
|
||||
|
||||
Самый крупный потребитель теперь — **набор Тени, 32 страницы**, больше
|
||||
самого Кида. Если место когда-нибудь прижмёт, там есть очевидный резерв
|
||||
(кадры смерти и позы, в которых Тень не бывает), но при 132 свободных
|
||||
страницах трогать незачем.
|
||||
|
||||
### Что из этого следует для звука
|
||||
|
||||
| статья | страниц | останется свободно |
|
||||
|---|---:|---:|
|
||||
| эффекты WAV, все 31, 10 937,5 Гц | **8** | 124 |
|
||||
| музыка ПО ХОДУ игры в WAV (путь C, 12 треков) | 50 | 74 |
|
||||
| заставки и титры в WAV (10 треков, 248 с) | 167 | **не влезает** |
|
||||
|
||||
То есть эффекты — капля, игровая музыка в WAV тоже поместится, а
|
||||
заставочную придётся стримить с диска в любом случае (что и планировалось:
|
||||
во время заставок игра ничего не рисует).
|
||||
|
||||
Оговорка: 132 свободных страницы — это на уровне 1. На уровне 9 добавятся
|
||||
зеркальные наборы (`pop_vflip_load_all`: 28 Кид + 5 страж + меч = 34
|
||||
страницы), останется ~98. Проверять запас надо ИМЕННО ТАМ.
|
||||
|
||||
## 1г. MSDOS против SDLPoP: чем отличаются наборы (сверено 2026-08-20)
|
||||
|
||||
У нас лежат ДВЕ копии звука — оригинальные `.dat` в `MSDOS/` (версия
|
||||
1.3/1.4) и распакованные ассеты `SDLPoP/data/` (версия 1.0/1.1). Разница
|
||||
есть, и она влияет на выбор источника.
|
||||
|
||||
### Оцифровка: берём MSDOS
|
||||
|
||||
Заголовок разный (`digi_new_type` против `digi_type`), но **28 звуков из
|
||||
31 совпадают побайтно**. Различаются три, и все не в пользу SDLPoP:
|
||||
|
||||
| id | звук | MSDOS | SDLPoP |
|
||||
|---|---|---:|---:|
|
||||
| 10 | `sword_vs_sword` | 5 020 сэмплов | 3 504 |
|
||||
| 11 | `sword_moving` | 1 172 | 1 172, но **другие байты** |
|
||||
| 48 | `spiked` | 5 069 | **7** — то есть звука нет |
|
||||
|
||||
`spiked` в наборе SDLPoP фактически пустой. Поэтому упаковщик читает
|
||||
`MSDOS/digisnd*.dat`, а не распакованные ассеты — в отличие от графики,
|
||||
где источник наоборот SDLPoP.
|
||||
|
||||
### MIDI: если дойдём до музыки — брать SDLPoP
|
||||
|
||||
Здесь всё наоборот. Содержимое музыкально то же (деление 480, те же
|
||||
каналы 0..7 плюс ударные), но:
|
||||
|
||||
| | MSDOS | SDLPoP |
|
||||
|---|---|---|
|
||||
| формат MIDI | **0** — всё слито в ОДНУ дорожку | **1** — 8-9 дорожек |
|
||||
| размер (звук 24) | 327 Б | 448 Б |
|
||||
| размер (звук 56) | 13 587 Б | 12 773 Б |
|
||||
|
||||
Формат 1 с отдельной дорожкой на инструмент — это готовое разделение
|
||||
голосов. Для пути B (MIDI -> три канала AY) оно решает половину задачи:
|
||||
дорожки можно выбирать напрямую (бас / мелодия / гармония), а не
|
||||
разбирать слитый поток и догадываться, что чем было.
|
||||
|
||||
**Итог: эффекты из MSDOS, музыка (когда дойдёт) из SDLPoP.**
|
||||
|
||||
## 2. Что лежит в ассетах (замерено, а не по памяти)
|
||||
|
||||
Звук в PoP адресуется как ресурс `10000 + N`, N = 0..56 — 57 звуков
|
||||
(`load_sound`, `seg009.c:2289`). Наборов три, и они ПАРАЛЛЕЛЬНЫЕ: один и
|
||||
тот же звук есть в нескольких видах.
|
||||
|
||||
| набор | что это | объём | покрытие |
|
||||
|---|---|---:|---|
|
||||
| `DIGISND1..3.DAT` | оцифровка, 8 бит PCM | 103 941 Б | **31 звук** (эффекты) |
|
||||
| `MIDISND1..2.DAT` | MIDI-музыка | 27 776 Б | музыка |
|
||||
| `IBM_SND1..2` (распакованы) | ноты PC-спикера | **7 КБ** | **все 57** |
|
||||
|
||||
### 2.1 Оцифровка (эффекты)
|
||||
|
||||
Разбор контейнера: индекс по 8 байт на запись (id, offset, size), **первый
|
||||
байт ресурса — контрольная сумма**, тело за ней (спецификация
|
||||
`POP-DAT-FormatSpecifications`, §3.1.2 — на этом я сначала споткнулся и
|
||||
читал мусор). Тело — `digi_type`: `word rate, word count, word unk,
|
||||
byte size`, дальше сэмплы.
|
||||
|
||||
- 31 звук, все 8-битные;
|
||||
- частоты: **28 звуков на 11 000 Гц**, по одному на 8 200 и 14 000;
|
||||
- 103 701 сэмпл = **9,4 секунды**, **103 941 Б ≈ 6,3 EMM-страницы**.
|
||||
|
||||
### 2.2 Ноты PC-спикера (и эффекты, и музыка)
|
||||
|
||||
Формат: `byte type(=0), word tempo`, дальше тройки `word frequency,
|
||||
byte length`; `frequency <= 1` — пауза, `0x12` — конец. Ключевое, что
|
||||
пришлось смотреть в `play_speaker_sound`/`speaker_callback` (`seg009.c`):
|
||||
|
||||
- **`frequency` — это ГЕРЦЫ напрямую** (`generate_square_wave(stream,
|
||||
(float)note->frequency, ...)`), а не делитель PIT, как кажется по
|
||||
маленьким числам;
|
||||
- длительность ноты = `length / tempo` СЕКУНД.
|
||||
|
||||
Замеры по всем 57 звукам: **2212 нот**, частоты 16..65507 Гц,
|
||||
длительности 1,74..2571 мс.
|
||||
|
||||
Из них 23 звука — те, у которых оцифровки НЕТ, то есть вся музыка:
|
||||
заставки, гимны уровней, смерть, победа, титры (id 24..43, 50..56).
|
||||
**1469 нот**, и вот их длительности:
|
||||
|
||||
| длительность ноты | нот | доля |
|
||||
|---|---:|---:|
|
||||
| < 3 мс | 0 | 0 % |
|
||||
| 3..12 мс | 2 | 0,1 % |
|
||||
| 12..25 мс | 140 | 9,5 % |
|
||||
| > 25 мс | 1327 | 90,3 % |
|
||||
|
||||
Это число решает вопрос про таймер — см. §4.
|
||||
|
||||
## 3. Что умеет железо (док Ивана Мака §5 + MAME)
|
||||
|
||||
- **AY-3-8910/8912** в ПЛМ, «программируется по стандартным описаниям» —
|
||||
то есть ZX-порты; в MAME он заведён как `AY8910(config, "ay8912",
|
||||
X_SP/24)`, то есть **тактовая 1,75 МГц**. Период канала = 109375 /
|
||||
частота(Гц), 12 бит (макс 4095) → снизу берутся частоты от ~27 Гц.
|
||||
Точные Z80-адреса портов идут через таблицу DCP, а не напрямую —
|
||||
**проверить артефактом до кодинга** (ожидаем ZX-стандарт 0xFFFD/0xBFFD).
|
||||
- **CBL** — COVOX с буфером 256 Б, две половины по 128; бит 7 порта 0xFE
|
||||
показывает играющую половину, порт управления 0x4E. Прерывание —
|
||||
когда половина сменилась.
|
||||
- **Бипер** (бит 5 порта 0xFE) — туда же в ЦАП. Нам не нужен.
|
||||
- Всё это **сведено в один ЦАП**, поэтому AY и CBL звучат вместе.
|
||||
|
||||
У нас уже есть готовая обвязка CBL (`libc/include/cbl.h`): callback
|
||||
`fill(n)`, выдача блока через `cbl_push_otir()` (порт 0x4F) или через
|
||||
акселератор, коды частот, счётчик недоливов. Своего кольца библиотека не
|
||||
держит — данные пропихиваются прямо из наших EMM-страниц.
|
||||
|
||||
## 4. Предлагаемая архитектура
|
||||
|
||||
```
|
||||
эффекты (31 шт, 8 бит 11 кГц) музыка (23 шт, ноты)
|
||||
│ │
|
||||
EMM-страницы (6,3) 7 КБ нот в банке
|
||||
│ │
|
||||
cbl_push_otir из fill() запись 3 регистров AY
|
||||
│ │
|
||||
CBL (10,9 кГц) ──────┐ ┌────────── AY (1,75 МГц)
|
||||
▼ ▼
|
||||
TDA1543 (аппаратное смешивание)
|
||||
```
|
||||
|
||||
**Один источник прерываний — CBL.** Его callback приходит каждые 128
|
||||
сэмплов = **11,7 мс** при 10,9 кГц, и он же двигает секвенсор музыки.
|
||||
Отдельный таймер (CTC) НЕ нужен: по таблице из §2.2 короче 12 мс всего
|
||||
2 ноты из 1469 — они растянутся на один тик, чего не слышно.
|
||||
|
||||
Почему это важно: `irq_ctc_install` сейчас требует кода в W2 (tiny/big),
|
||||
а roomtest — huge, и попадёт ли туда CTC-трамплин, зависит от раскладки.
|
||||
Обойтись без него — значит не открывать этот фронт вовсе.
|
||||
|
||||
**Пейсинг кадра при этом не страдает.** Наш темп считается ПО ЛУЧУ
|
||||
(`pop_pace.h`), а не по кадровым прерываниям, поэтому то, что CBL-ветка
|
||||
трамплина делает приватный RETI и съедает кадровые прерывания, нам
|
||||
безразлично. Если бы пейсинг остался на прерываниях — звук бы его сломал.
|
||||
|
||||
## 5. Объём работ
|
||||
|
||||
| фаза | что | оценка |
|
||||
|---|---|---|
|
||||
| **З1** | распаковщик `pop_pack_sound.py`: DAT → `.snd`-страницы EMM (эффекты) + `.not` (ноты музыки) | формат уже разобран |
|
||||
| **З2** | `pop_sfx.c`: `cbl_open` + `fill()`, таблица «звук → страница/смещение/длина», `pop_sfx_play(id)` | ядро |
|
||||
| **З3** | развесить вызовы: 66 мест `play_sound` в оригинале; у нас часть уже помечена TODO (`pop_ctrl.c:408`, `guards.c:992`, `pop_map.c:3035/3107`, …). **Плюс бесплатный кусок**: опкод `SEQ_SOUND` в seqtbl уже разбирается нашим `play_seq` (`pop_kid.c:326`) — шаги, приземления и прочее поедут сами | механическая |
|
||||
| **З4** | `pop_music.c`: секвенсор нот на AY (путь A), тик из CBL-callback | небольшая |
|
||||
| **З6** | заставки и титры — WAV-музыка потоком (эффекты в это время не звучат) | после З1-З4 |
|
||||
| **З7** | музыка по ходу игры: WAV с гашением эффектов ЛИБО путь B — решать по итогам З6 | открыто |
|
||||
| **З5** | приоритеты и вытеснение: у оригинала `play_sound` глушит предыдущий (`stop_sounds`), музыка и эффект — разные каналы | правила из seg009 |
|
||||
|
||||
## 6. Что проверить артефактом ДО кодинга
|
||||
|
||||
1. **Порты AY на Sprinter** — записать в 0xFFFD/0xBFFD и убедиться, что
|
||||
MAME отдаёт звук (порты идут через DCP-таблицу, «стандартные ZX» —
|
||||
это ожидание, а не факт).
|
||||
2. **CBL в режиме huge.** Шапка `cbl.h` говорит «код/данные в W2
|
||||
(tiny/big)», но это скорее всего устаревшая оговорка: IM2-трамплин
|
||||
давно переделан на all-modes, и наш кадровый путь в huge работает.
|
||||
Проверить `cbl_open` из roomtest.
|
||||
3. **Совместное владение портом 0xFE.** Бит 5 (луч) у нас держится через
|
||||
`_cbl_port_ref` «немым» кодом частоты; когда откроется НАСТОЯЩИЙ CBL,
|
||||
владение переходит к нему. Убедиться, что пейсинг переживает
|
||||
`cbl_open`/`cbl_close`.
|
||||
4. **Цена fill() в кадре.** 128 байт через `cbl_push_otir` раз в 11,7 мс
|
||||
— замерить тем же способом, что и остальное (брейкпоинт + totalcycles).
|
||||
|
||||
## 7. Про MIDI — почему не он
|
||||
|
||||
Музыка в MIDISND — настоящие MThd/MTrk чанки, 27 КБ. Чтобы играть их на
|
||||
AY, нужен разбор MIDI, раскладка каналов на три голоса и таблица
|
||||
инструментов — это отдельный проект, и звучать он будет НЕ так, как
|
||||
оригинал на PC. А набор PC-спикера — это ровно то, что слышал игрок на
|
||||
IBM PC 1989 года: один квадратный голос. Он у нас есть целиком, весит
|
||||
7 КБ и ложится на AY напрямую.
|
||||
|
||||
Если позже захочется богаче — материал уже будет разобран, и можно
|
||||
разложить те же мелодии на три канала AY (бас/мелодия/арпеджио), не трогая
|
||||
ни данные, ни секвенсор.
|
||||
|
||||
---
|
||||
|
||||
## 8. Разводка вызовов по коду (сделано 2026-08-20)
|
||||
|
||||
Портированы ВСЕ места `play_sound()` SDLPoP, у которых есть оцифровка
|
||||
(id 0..23, 44..49, 51 — остальные id это музыка, у них в
|
||||
`pop_sound_tbl.h` длина 0, и вызов просто глушит текущий эффект).
|
||||
|
||||
| id | что | где у нас | оригинал |
|
||||
|----|-----|-----------|----------|
|
||||
| 0 | разбился насмерть | `pop_map.c` land | seg005 |
|
||||
| 1 | крик падения | `pop_map.c` do_fall | seg005:39 |
|
||||
| 2 | плита рухнула | `pop_room.c` (обе ветки посадки) | seg007 |
|
||||
| 3 | кнопка нажата | `pop_trob.c` | seg007 |
|
||||
| 4/5/6/7 | ворота: закрываются / открываются / рухнули / стоп | `pop_trob.c` | seg007 |
|
||||
| 8 | удар о стену | `pop_map.c` bumped_fall/bumped_floor + seqtbl | seg004/seg006 |
|
||||
| 9 | зацеп за карниз | `pop_map.c` check_grab | seg006 |
|
||||
| 10 | клинок о клинок | `roomtest.c` после `check_sword_hurt`, если один из бойцов в кадре 167 | seg000:1353 |
|
||||
| 11 | свист клинка мимо | `guards.c` check_hurting | seg002:0DAE |
|
||||
| 12/13 | ранен соперник / Кид | `guards.c` hurt_by_sword | seg002:0C1F |
|
||||
| 13 | Кид ранен зельем | `pop_map.c` ветка «злого» зелья | seg006:1894 |
|
||||
| 14/15 | дверь уровня: закрывается / едет | `pop_trob.c` | seg007 |
|
||||
| 16 | средняя посадка; толчок о стража | `pop_map.c` land / bump_into_opponent | seg005/seg003:0654 |
|
||||
| 17 | мягкая посадка | `pop_map.c` land | seg005 |
|
||||
| 18 | пьёт | seqtbl (SEQ_SOUND) | seg006 |
|
||||
| 19 | вынул меч | `pop_ctrl.c` | seg005:945 |
|
||||
| 20/21/22 | дрожит плита | `pop_map.c` loose_shake | seg007:0E55 |
|
||||
| 23 | шаг | seqtbl (SEQ_SOUND) | seg006 |
|
||||
| 44 | скелет оживает | `guards.c` pop_check_skel | seg002:106D |
|
||||
| 45 | прыжок в зеркало | `pop_map.c` jump_through_mirror | seg003:0617 |
|
||||
| 46 | сожрал чомпер | `pop_map.c` | seg004 |
|
||||
| 47 | чомпер щёлкнул | `pop_trob.c` (кадр 2) | seg007 |
|
||||
| 48 | напоролся на пики | `pop_map.c` | seg005 |
|
||||
| 49 | пики пошли | `pop_map.c` start_anim_spike | seg007:08F6 |
|
||||
|
||||
Что осталось не разведено — только МУЗЫКА (24/28 смерть, 25 презентация,
|
||||
26 объятия,
|
||||
27/35/40 заставки, 29 встреча Джафара, 30/33 зелья, 32/41 конец уровня,
|
||||
36 время вышло, 37 победа, 43 смерть Джафара, 50/52/53 сюжетные вставки)
|
||||
и 51 (дверь принцессы, тоже из заставки). Их черёд — фаза «музыка».
|
||||
|
||||
**Квирк, за которым следить.** Звук ворот у оригинала звучит не всегда, а
|
||||
по условию видимости (`play_door_sound_if_visible`, seg007:1250): либо
|
||||
ворота в комнате слева и стоят в 9-й колонке, либо ворота в НАРИСОВАННОЙ
|
||||
комнате и колонка не 9-я. У нас это параметр `audible` у `animate_door`.
|
||||
|
||||
**Тряска плиты — свой домен prandom.** Оригинал берёт номер сэмпла (20/21/22)
|
||||
из общего генератора и вдобавок «сжигает» один бросок ради совместимости с
|
||||
DOS-версией; у нас последовательности разведены по доменам
|
||||
(`impl_diff.md`), поэтому у тряски свой сид, а холостой бросок не делаем —
|
||||
на розыгрыши физики и кладки это не влияет.
|
||||
|
||||
## 9. Цена звука в тактах (замер 2026-08-20, MAME)
|
||||
|
||||
Вопрос был поставлен так: звук идёт по прерываниям, значит размазан по всем
|
||||
фазам кадра — и если фазы укладываются, всё хорошо? Да, но проверять это
|
||||
надо не по фазам, а по двум числам, потому что **нагрузка от звука
|
||||
постоянная и от сцены не зависит вовсе**.
|
||||
|
||||
### 9.1 Одно прерывание CBL
|
||||
|
||||
Зонды: `bpset` на входе трамплина (`_irq_tramp`) и на `reti` ветки CBL,
|
||||
разница `totalcycles`. 398 замеров в сцене 11/15.
|
||||
|
||||
| величина | значение |
|
||||
|---|---:|
|
||||
| цена одного прерывания | **7 825 тактов ровно**, с разбросом до 8 299 (среднее 8 001) |
|
||||
| период между прерываниями | 245 759 тактов (= 128 сэмплов на 10 937,5 Гц) |
|
||||
| **доля процессорного времени** | **8 001 / 245 759 = 3,26 %** |
|
||||
|
||||
Цена постоянная, потому что работа фиксированная: OTIR ровно 128 байт плюс
|
||||
скобка сохранения контекста. Ветвлений по данным в насосе нет.
|
||||
|
||||
### 9.2 Дрожание обслуживания — риск для ЗВУКА, не для кадра
|
||||
|
||||
Период плавает 228 804 … 262 734, то есть прерывание опаздывает максимум на
|
||||
**~17 000 тактов = 0,8 мс**. Это самая длинная DI-скобка в коде
|
||||
(акселератор режется по 16 строк, memory `sprinter_wait_states_2x`).
|
||||
Буфер CBL — 128 сэмплов = **11,7 мс**, запас **14×**. Недолива быть не
|
||||
может; счётчик `cbl_underruns()` это подтверждает косвенно (наш `fill`
|
||||
всегда возвращает 1, поэтому он ловит только отсутствие данных, не
|
||||
опоздание).
|
||||
|
||||
### 9.3 A/B в одном прогоне (Ctrl+S), сцена 11/15
|
||||
|
||||
Один и тот же кадр, звук выключается на ходу — сравнение чистое.
|
||||
|
||||
| | работа min | работа max | работа avg | прерываний CBL на кадр |
|
||||
|---|---:|---:|---:|---:|
|
||||
| звук ВКЛ | 453 132 | 572 532 | **498 064** | 1,40 |
|
||||
| звук ВЫКЛ | 444 348 | 559 434 | **487 372** | 0,00 |
|
||||
| разница | +8 784 | +13 098 | **+10 692 (+2,2 %)** | |
|
||||
|
||||
Разница на лёгком кадре (+8 784) — ровно одно прерывание, сходится с §9.1.
|
||||
`CBL/кадр = 0` при выключенном звуке подтверждает, что Ctrl+S реально
|
||||
ЗАКРЫВАЕТ CBL, а не глушит сэмпл: иначе насос продолжал бы отдавать блоки
|
||||
тишины и платить те же 3,26 %.
|
||||
|
||||
### 9.4 Укладываемся ли
|
||||
|
||||
Логический кадр (`pop_pace.h`): NORMAL = 4 растра вне боя = **1 720 000
|
||||
тактов**, FASTEST = 3 растра = **1 290 000**.
|
||||
|
||||
| | работа | доля NORMAL | доля FASTEST |
|
||||
|---|---:|---:|---:|
|
||||
| 11/15, обычная позиция | 498 064 | 29 % | 39 % |
|
||||
| 11/15, тяжёлая позиция (Кид на 7 px правее) | 750 066 макс | 44 % | 58 % |
|
||||
|
||||
Период кадра за все прогоны: 1 719 936 … 1 720 752 — ровно 4 растра, ни
|
||||
одного проскока. **Звук занимает 1,1 % бюджета NORMAL и 1,5 % FASTEST.**
|
||||
|
||||
### 9.5 Где 3,26 % МОГЛИ БЫ стоить дорого
|
||||
|
||||
Ответ «всё размазано, если фазы влезли — ок» верен с одной оговоркой.
|
||||
Пейсинг квантован растром: работа 1,00 растра и 1,02 растра дают РАЗНЫЙ
|
||||
период кадра (3 против 4 интервалов), то есть скачок сразу на 20 мс.
|
||||
Значит звук опасен ровно в одной ситуации — когда сцена стоит в пределах
|
||||
~8 000 тактов НИЖЕ кратного растру порога. Сейчас ближайший запас — 540 000
|
||||
тактов до порога FASTEST, то есть в 60 раз больше цены звука. Проверять
|
||||
эту оговорку заново стоит только если работа кадра подберётся к 430 000 или
|
||||
860 000 вплотную.
|
||||
|
||||
## 10. Мусор при включении и щелчок на выходе (разбор 2026-08-20)
|
||||
|
||||
Жалоба: «при старте, когда разрешается звук, проходит кусок мусора».
|
||||
Разобрано записью выхода MAME в WAV (`-wavwrite`) — по огибающей и
|
||||
автокорреляции, а не на слух.
|
||||
|
||||
### 10.1 На старте мусора НЕТ; это настоящие звуки
|
||||
|
||||
От `cbl_open` до первого эффекта в записи **точная цифровая тишина**
|
||||
(размах 1 при разрешении 16 бит). Дальше — два штатных звука:
|
||||
|
||||
| что | когда | длительность |
|
||||
|---|---|---|
|
||||
| `gate_closing_fast` (6) — решётка в комнате СЛЕВА | +0,30 с после `cbl_open` | обрывается на 80 мс |
|
||||
| `soft_land` (17) — Кид приземляется | +0,38 с | 383 мс |
|
||||
|
||||
Опознаны корреляцией огибающих с оригинальными сэмплами `digisnd`:
|
||||
звук 6 даёт +0,72 с начала записи, звук 17 — +0,32 со сдвигом 80 мс.
|
||||
Приземление на старте КОРРЕКТНО: `start_pos` уровня 1 — тайл (0,0), а он
|
||||
`space`, то есть Кид падает на ряд ниже, на площадку с факелами.
|
||||
|
||||
Обрыв первого звука вторым — тоже поведение оригинала, а не наш дефект:
|
||||
`play_digi_sound` (seg009.c:2402) начинается с `stop_digi()`, голос ОДИН.
|
||||
Ощущение «мусора» даёт именно 80-мс огрызок скрежещущей решётки.
|
||||
|
||||
Звук кнопки (3) при этом не слышен: `pop_sfx_play(3)` случается ДО
|
||||
`cbl_open` и глохнет. С SDLPoP совпадает (там на старте тоже только
|
||||
решётка), но держится это на порядке вызовов — если поднимать звук раньше
|
||||
`pop_start_level`, щелчок кнопки станет слышен.
|
||||
|
||||
### 10.2 Незалитый буфер CBL — дефект есть, но в MAME он немой
|
||||
|
||||
Буфер CBL (256 слотов) железо не чистит ни сбросом, ни записью в порт
|
||||
управления, а эта запись сразу пускает воспроизведение с нулевого слота.
|
||||
Значит первые 256 сэмплов (23,4 мс) — то, что лежало раньше. Разбор по
|
||||
`sprinter.cpp`: `case 0x89` делает `m_cbl_cnt = 0; m_cbl_wa = 0`, а
|
||||
прерывание «долей половину» приходит только на 128-м слоте и ставит
|
||||
указатель на ПРОТИВОПОЛОЖНУЮ половину — своими данными звук идёт лишь с
|
||||
третьей половины.
|
||||
|
||||
В MAME это не слышно: эмулируемый буфер стартует нулями, а ЦАП
|
||||
двухдополнительный, то есть 0 = середина шкалы. На ЖЕЛЕЗЕ там
|
||||
неинициализированное ОЗУ — ровно тот мусор, который ловился ещё на
|
||||
тестовых примерах CBL. Лечение — `_cbl_prime` в `cbl_open`: сразу после
|
||||
включения 256 записей байта тишины в порт данных (заранее нельзя, запись
|
||||
проходит только при поднятом bit7). Стоит 6 400 тактов один раз за
|
||||
открытие; за это время таймер уходит на два-три слота.
|
||||
|
||||
### 10.3 Щелчок на выходе — ГОЛОДАНИЕ насоса, вылечено
|
||||
|
||||
На выходе по ESC в записи было **ровно 11 мс шума на полной громкости**
|
||||
(размах 33 671 — громче всего в прогоне), потом мгновенная тишина. 11 мс
|
||||
= один блок CBL (128 сэмплов = 11,7 мс), то есть один пропущенный долив:
|
||||
`pop_shutdown` звал `closegraph`/`pop_bg_free`/`pop_kid_free` (а это
|
||||
ESTEX на каждый атлас) ПРИ ОТКРЫТОМ звуке, насос не успевал, и железо
|
||||
доигрывало несвежую половину.
|
||||
|
||||
Лечение: `pop_sfx_close()` первым действием `pop_shutdown`. Проверено
|
||||
второй записью — всплеска на выходе больше нет. Механизм тот же, из-за
|
||||
которого звук глушится на время загрузки уровня.
|
||||
|
||||
## 11. Приоритеты и перебиваемость: звук у оригинала НЕ «всегда перебивать»
|
||||
|
||||
Пользователь услышал расхождение: у нас решётка обрывалась приземлением
|
||||
Кида, в SDLPoP — доигрывала до звонкого конца, а приземления не было
|
||||
слышно вовсе. Разбор исходника показал, что мы упустили ЦЕЛЫЙ МЕХАНИЗМ.
|
||||
|
||||
### 11.1 Модель оригинала
|
||||
|
||||
```
|
||||
play_sound(id) seg000:12C5 — только НОМИНИРУЕТ кандидата на кадр:
|
||||
if next < 0 || prio[id] <= prio[next]: next = id
|
||||
|
||||
play_next_sound() seg000:1304 — раз в кадр решает, запускать ли:
|
||||
if next >= 0:
|
||||
if !играет_что_то ||
|
||||
(перебиваем[текущий] && prio[next] <= prio[текущий]):
|
||||
текущий = next; запустить
|
||||
next = -1 // НЕ запустили -> номинант ВЫБРОШЕН, очереди нет
|
||||
```
|
||||
|
||||
Три следствия, каждое слышно:
|
||||
|
||||
- **Неперебиваемый звук доигрывает целиком.** У `gate_closing_fast` (6)
|
||||
`interruptible = 0`, поэтому приземление Кида (17) в этот момент
|
||||
пропадает совсем — не откладывается, а именно теряется.
|
||||
- **Внутри кадра выживает важнейший.** Меньше `prio` — важнее; при
|
||||
равенстве побеждает ПОСЛЕДНИЙ (сравнение `<=`).
|
||||
- **Два источника не «чередуются как получится».** Челюсти (47, prio
|
||||
0x10) всегда важнее решётки (4, prio 0x32): решётка не может перебить
|
||||
укус, а укус решётку — может. Отсюда и картина на ур. 9 к. 9, где
|
||||
решётка звучит только в паузах между укусами.
|
||||
|
||||
### 11.2 Что сделано у нас
|
||||
|
||||
`pop_sfx_play` теперь только номинирует; запуск — в `pop_sfx_tick`,
|
||||
который зовётся раз в кадр в конце отрисовки (там же, где оригинал зовёт
|
||||
`play_next_sound`, seg000:954). Таблицы `snd_prio` (57 байт) и битовая
|
||||
карта `snd_intr` (8 байт) — в резиденте, значения из SDLPoP С УЧЁТОМ
|
||||
`fix_sound_priorities()`: в `config.h` SDLPoP `FIX_SOUND_PRIORITIES`
|
||||
определён безусловно, значит сравниваемся мы с исправленным вариантом
|
||||
(звук 10 → 0x0D, 48 → 0x15, 49 перебиваем).
|
||||
|
||||
Створка двери уровня (15) — единственная запись, которую оригинал правит
|
||||
на ходу: перебиваема при закрытии, нет при открытии (seg007:442/464).
|
||||
Держим отдельным байтом `pop_sfx_slide_intr`, чтобы таблица осталась в
|
||||
ПЗУ. Там же добавлен пропущенный `stop_sounds()` на завершении открытия
|
||||
двери (seg007:455) — без него неперебиваемый съезд (1,6 с) блокировал бы
|
||||
очередь.
|
||||
|
||||
Звуки без оцифровки (музыка, длина 0) не номинируются вовсе — порт
|
||||
проверки `if (NULL == sound_pointers[id]) return;`. Раньше такой id
|
||||
глушил живой эффект.
|
||||
|
||||
### 11.3 Проверка
|
||||
|
||||
Записью MAME, старт уровня 1:
|
||||
|
||||
| | всплески | что это |
|
||||
|---|---|---|
|
||||
| до | 135 мс + 210 мс | решётка, обрезанная приземлением на 80 мс |
|
||||
| после | **один, 455 мс** | решётка целиком, корреляция огибающей со звуком 6 **+0,889** |
|
||||
|
||||
Цена: резидент +~250 Б (таблицы + логика), куча ужалась с 347 до 134 Б —
|
||||
довод в пользу давно назревшей реорганизации базовой памяти.
|
||||
|
||||
## 12. Ворота: гейт слышимости и «решётка встала» (2026-08-20)
|
||||
|
||||
Проверка на сцене, которую предложил пользователь — уровень 9, комната 9:
|
||||
кнопка (1,8), челюсти (1,2), а ворота **в комнате 4, тайл (1,9)**, то есть
|
||||
в комнате СЛЕВА. Нашлись три расхождения сразу.
|
||||
|
||||
### 12.1 Гейт слышимости был неверный
|
||||
|
||||
У нас стояло `audible = (room == cur_room)`. У оригинала
|
||||
(`play_door_sound_if_visible`, seg007:1239) правило другое:
|
||||
|
||||
- ворота в комнате СЛЕВА и в колонке 9 — СЛЫШНЫ (створка видна в шве);
|
||||
- ворота в отрисованной комнате и НЕ в колонке 9 — слышны;
|
||||
- особый случай: уровень 3, комната 2 — слышны всегда.
|
||||
|
||||
Сцена 9/9 попадает ровно в первый пункт, поэтому спуск решётки у нас
|
||||
молчал. Подъём при этом совпадал с оригиналом — потому что звук открытия
|
||||
(5) идёт БЕЗ гейта (seg007:386, прямой `play_sound`). Эта асимметрия и была
|
||||
подсказкой.
|
||||
|
||||
Взят вариант под `FIX_GATE_SOUNDS` (условия через ИЛИ): в config.h SDLPoP
|
||||
он определён безусловно.
|
||||
|
||||
### 12.2 Потерян звук «решётка встала» (7)
|
||||
|
||||
`gate_stop()` (seg007:05E3) зовётся из ТРЁХ мест `animate_door` и каждый раз
|
||||
играет звук 7 через гейт слышимости: конец закрытия, открытие насовсем и
|
||||
ветка «уже 0xFF». У нас во всех трёх стояло только `*type = -1` без звука.
|
||||
Добавлено. Лязг после ОБЫЧНОГО открытия (seg007:395) остаётся без гейта —
|
||||
там оригинал зовёт `play_sound` напрямую.
|
||||
|
||||
### 12.3 Кнопка: у оригинала есть параметр playsound
|
||||
|
||||
`trigger_button(playsound, ...)` — в трёх местах он нулевой: вход на уровень
|
||||
(seg003:170), выход Джаффара (seg002:520) и зелье «открыть» (seg006:1890, у
|
||||
нас не портировано). Мы играли щелчок всегда. Добавлен параметр `snd`.
|
||||
|
||||
### 12.4 Почему щелчок кнопки слышно через раз — это НЕ баг
|
||||
|
||||
Бюджет сцены 9/9 (длительности после пересчёта на 10 937,5 Гц):
|
||||
|
||||
| звук | длительность | prio |
|
||||
|---|---:|---:|
|
||||
| челюсти (47) | 465 мс | 0x10 |
|
||||
| решётка вниз (4) | 97 мс | 0x32 |
|
||||
| решётка вверх (5) | 123 мс | 0x37 |
|
||||
| решётка встала (7) | 75 мс | 0x30 |
|
||||
| кнопка (3) | 106 мс | 0x66 |
|
||||
|
||||
Цикл челюстей — 15 кадров = 1229 мс (замерено брейкпоинтом на номинации:
|
||||
25 805 000 тактов между укусами). Значит укус занимает 465 мс, пауза 764 мс.
|
||||
Кнопка (prio 0x66) перебить челюсти не может (0x66 > 0x10), поэтому слышна
|
||||
только если нажатие попало в паузу — примерно в 6 случаях из 10.
|
||||
Подтверждено пользователем на живой сцене.
|
||||
|
||||
### 12.5 Грабли сцены
|
||||
|
||||
Если игра стартует ПРЯМО в комнате с челюстями, они не заводятся сами:
|
||||
нужно сходить Кидом на левую кнопку и вернуться. Это поведение оригинала
|
||||
(trob челюстей создаётся событием), а не наш дефект — учитывать при
|
||||
постановке автотестов.
|
||||
|
||||
## 13. Повторный аудит игрового звука (2026-08-24)
|
||||
|
||||
Проверены три независимых слоя: содержимое атласов, места вызова и живой
|
||||
тракт `play -> tick -> CBL`.
|
||||
|
||||
- В восьми `SND*.ATL` есть все 31 оцифрованных ресурса: `0..23`, `44..49`
|
||||
и `51`; ненулевая страница/длина есть у каждой записи таблицы.
|
||||
- Для всех 30 PCM-эффектов, которые могут возникать непосредственно в игре,
|
||||
есть место вызова. Последним пропуском был звук 10 при столкновении
|
||||
клинков; условие перенесено буквально из `check_sword_vs_sword` SDLPoP.
|
||||
Звук 51 относится к сцене с принцессой, а не к игровому циклу.
|
||||
- Звук 19 «Кид достал меч» проверен в MAME брейкпоинтами. В момент вызова
|
||||
предыдущий PCM уже закончился (`sfx_left=0`), номинация дошла до
|
||||
`pop_sfx_tick`, после чего курсор получил id 19, страницу 5, смещение
|
||||
`0x1500` и длину 2816 байт. В этом прогоне его не подавляли решётка,
|
||||
плиты, шаги или приоритеты. Если он всё ещё субъективно не слышен, искать
|
||||
надо после выбора эффекта — в непрерывности CBL/громкости самого сэмпла.
|
||||
- После продолжительного прогона title/intro/demo/menu CBL обслужил 3303
|
||||
блока и сообщил 0 программных недоливов (`cbl_requests=0x0CE7`,
|
||||
`cbl_underruns=0`). Это исключает возврат `fill=0`, но само по себе не
|
||||
измеряет запоздание прерывания внутри слишком длинной секции `DI`.
|
||||
- Регрессия full-game на входе в Level 1 оказалась именно запозданием CBL:
|
||||
`pop_level_switch` открывал его ДО блокирующего BIOS fade-in. Demo был
|
||||
чистым, потому что включал палитру без fade. Теперь загрузчик оставляет
|
||||
CBL закрытым, а caller открывает его после окончательной палитры/QuickLoad;
|
||||
скрежет на Level 1 исчез в MAME. Однократный щелчок самого первого
|
||||
`cbl_open` за всю MAME-сессию остаётся отдельной низкоприоритетной задачей.
|
||||
- Открытие pause menu у SDLPoP беззвучно; движение играет 21, вход/выход
|
||||
из подменю — 22, изменение настройки — 10. Эти вызовы перенесены. На
|
||||
время полного копирования страницы и файловых операций CBL закрывается,
|
||||
после flip открывается снова: аппаратная половина не должна повторять
|
||||
старые данные и давать «скрежет».
|
||||
|
||||
Отдельно остаётся игровая музыка и сигнальные мелодии без PCM: смерть
|
||||
(`24/28`), начало/появление Shadow (`25`), встреча Jaffar (`29`), большое
|
||||
и малое зелья (`30/33`), Shadow (`32`), победа/меч (`37`), перо (`39`),
|
||||
конец уровня (`41`) и победа над Jaffar (`43`). Вызовы и AY-проигрыватель
|
||||
для них ещё не реализованы; наличие всех PCM-эффектов эту задачу не закрывает.
|
||||
|
||||
|
||||
## 4. МУЗЫКА: решение пересмотрено (2026-08-25) — путь C вместо A
|
||||
|
||||
В §1б первым заходом был выбран **путь A** (ноты PC-спикера на AY), а путь
|
||||
C (запись -> WAV -> CBL) стоил дорого из-за строки «нужен синтезатор на
|
||||
хосте». Это обстоятельство отпало: у пользователя есть **готовые записи
|
||||
DOS-версии** — `applications/PoP/PoP1_DOS_music` (flac/mp3/ogg/ogg_MT-32,
|
||||
22 трека). Синтезировать нечего, остаётся `ffmpeg -ac 1 -ar 10937 -f u8`.
|
||||
|
||||
### Что померено по этим записям
|
||||
|
||||
| группа | длительность | PCM 10 937,5 Гц |
|
||||
|---|---:|---:|
|
||||
| всё вместе (22 трека) | 339 с | **3 625 КБ = 227 EMM-страниц** |
|
||||
| игровые джинглы (10) | 57 с | 608 КБ |
|
||||
| заставки и титры | 282 с | 3 017 КБ |
|
||||
| финальный `won` один | 115 с | 1 233 КБ |
|
||||
|
||||
Свободной EMM на старте ~3 440 КБ, так что вся музыка разом в память не
|
||||
влезает и не должна: трек грузится под сцену и освобождается после.
|
||||
|
||||
### Что сделано
|
||||
|
||||
`toolchain/pop_pack_music.py` -> один файл `MUS\m<id>.bin` на трек
|
||||
(читается порциями по 16 КБ из одного открытого fd) + каталог
|
||||
`pop_music_tbl.h`. Длина трека хранится **порциями по 128 байт**,
|
||||
а не байтами: 169 КБ в uint16 не влезает, 1350 блоков — легко.
|
||||
|
||||
`pop_music.c` (банк 9) грузит трек в EMM и ставит курсор; насос
|
||||
`pop_sfx_fill` (резидент) получил **третий источник**: эффект важнее
|
||||
музыки, музыка важнее тишины. Эффект музыку не сбрасывает — её курсор
|
||||
стоит, пока эффект доигрывает, и она продолжается с места.
|
||||
|
||||
Проверено в MAME записью звука: трек `story_1_absence` на первом экране
|
||||
истории, корреляция огибающих с эталоном **0,836**, RMS 22,6 против 18,3
|
||||
(разница — 8-битное квантование). Эффекты двери в PV-сцене после него
|
||||
звучат как прежде, то есть освобождение страниц и возврат к набору
|
||||
эффектов работают.
|
||||
|
||||
### Цена и что осталось
|
||||
|
||||
* CBL один: пока играет музыка, эффектов нет. Для заставок это не важно
|
||||
(их там не бывает), для игровых джинглов — открытый вопрос §1б.4.
|
||||
* Резидент вырос на 83 байта (третий источник в насосе) плюс 25 байт
|
||||
данных под курсор и таблицу страниц; запас W2 — 151 байт. Кучи в
|
||||
приложении нет (`malloc` не слинкован), так что это чистый запас роста.
|
||||
* Банк 9 занят на 88 %. Следующий модуль туда уже не влезет — либо
|
||||
переносить, либо заводить банк 12.
|
||||
* `won` (77 страниц) в POP_MUS_PAGES=20 не помещается: финал придётся либо
|
||||
резать, либо стримить кусками по ходу.
|
||||
|
||||
|
||||
## 5. СКРЕЖЕТ ПРИ BIOS-ВЫЗОВАХ: причина и решение (2026-08-25)
|
||||
|
||||
Симптом: во время затемнения (fade) звук хрипел — одинаково с играющей
|
||||
музыкой и в тишине. Пользователь заметил ключевое: **повтор тишины обязан
|
||||
звучать тишиной**, значит дело не в недоливе буфера.
|
||||
|
||||
### Как искали
|
||||
|
||||
Отладочные клавиши, каждая делает ровно один кусок fade:
|
||||
|
||||
| клавиша | что делала | результат |
|
||||
|---|---|---|
|
||||
| H | только ожидание 8 кадров | чисто |
|
||||
| V | только чтение палитры | скрежет |
|
||||
| G | затемнение целиком | скрежет |
|
||||
| J | только запись палитры | скрежет |
|
||||
| L | 512 раз `bios_get_place()` — видео не трогает | **скрежет** |
|
||||
|
||||
`L` и решил вопрос: виновата не палитра, а **любой вызов BIOS**.
|
||||
|
||||
### Причина
|
||||
|
||||
Вход в BIOS — это `rst 8`, то есть `out ($7C),a`. В драйвере MAME он
|
||||
правит `m_rom_sys` и вызывает `update_memory()`, которая перестраивает
|
||||
**окно 0**: `m_pages[0]` + `m_bank_view0.select(1)`. ПЗУ ложится ПОВЕРХ
|
||||
страничного регистра.
|
||||
|
||||
Насос CBL брал окно взаймы именно у W0 (`_io_page_w0 = phys` + `OTIR` по
|
||||
адресу < 0x4000). Пока BIOS работает, запись в порт `0x82` ничего не
|
||||
меняет, и `OTIR` вычитывает ПЗУ, отдавая его в звук.
|
||||
|
||||
Побочно выяснилось, почему `DI` вокруг BIOS помогал лишь иногда: он не
|
||||
даёт войти в ISR (тогда блок просто пропускается, что неслышно), но в
|
||||
обработчиках BIOS есть `EI`, так что защита негарантированная.
|
||||
|
||||
### Решение
|
||||
|
||||
Насос переведён на **W3** (идея пользователя): это окно управляется только
|
||||
портом `0xE2`, подмену из прерывания никто не перекрывает, а BIOS во время
|
||||
нашего ISR не исполняется — окно возвращается до выхода.
|
||||
|
||||
```c
|
||||
saved = _io_page_w3;
|
||||
_io_page_w3 = phys;
|
||||
cbl_push_otir((const void *)(0xC000u + ptr), n);
|
||||
_io_page_w3 = saved;
|
||||
```
|
||||
|
||||
После этого BIOS безопасен везде: и палитра, и любые другие функции.
|
||||
Временный обход палитры мимо BIOS (`gfx_pal_write`) стал не нужен — он
|
||||
остался в libbgi как более быстрый примитив (2,5 тыс. тактов на 64 цвета
|
||||
против 10,8 тыс. у BIOS), но игра его не зовёт.
|
||||
|
||||
Бонус: в W3 нет стаба восстановления окна, который в W0 занимал начало
|
||||
страницы, — звуковые страницы можно использовать целиком.
|
||||
|
||||
## 6. ВСЯ ЗАСТАВКА ОЗВУЧЕНА (2026-08-25)
|
||||
|
||||
К `story_1_absence` добавлены остальные четыре трека заставки:
|
||||
**54** intro_theme (титры), **50** story_2_princess, **53**
|
||||
story_3_Jaffar_enters, **52** story_4_Jaffar_leaves (сцена с принцессой).
|
||||
`MUS_IDS` в Makefile — 50 52 53 54 55, всего 1056 КБ на образе.
|
||||
|
||||
### Два слота вместо одного
|
||||
|
||||
Реплики оригинала идут ВСТЫК: следующая начинается там, где кончилась
|
||||
предыдущая, паузы под загрузку нет. Поэтому `pop_music` держит два слота
|
||||
EMM: `pop_music_load*` всегда пишет в НЕ играющий, `pop_music_play`
|
||||
подменяет резидентную таблицу страниц и отпускает прошлый слот. Своей
|
||||
копии таблицы слот не хранит — её и так держит блок EMM, `mem_get_page`
|
||||
отдаёт номер по индексу (иначе −40 байт W2 у игры, а там их нет).
|
||||
|
||||
Плюс **постраничная загрузка**: `pop_music_load_begin` / `_load_step`
|
||||
читают по одной странице за вызов. Страница стоит 33 мс — четверть
|
||||
логического кадра заставки (133 мс), поэтому подкачка следующей реплики
|
||||
прямо посреди анимации не видна. Кто может позволить себе паузу (титры,
|
||||
чёрный экран между сценами) — зовёт прежний `pop_music_load`.
|
||||
|
||||
### Тайминги приведены к шкале оригинала
|
||||
|
||||
Все длительности сцен взяты из SDLPoP в его тиках (60 Гц), а ждём мы
|
||||
кадрами луча (~50 Гц). Пока сцены были немыми, разбег в 20 % не был
|
||||
виден; с музыкой он слышен сразу — реплика кончается раньше картинки.
|
||||
Введён `POP_T60(t)` (pop_cutscene.h), и на него переведены титры,
|
||||
`intro_before_pv`, хвост после PV и пейсинг самой PV-сцены (счётчик
|
||||
потраченных кадров луча против `POP_T60(tick)`, вместо прежних жёстких
|
||||
четырёх кадров на логический).
|
||||
|
||||
**Паузы-реплики.** Там, где оригинал ждёт конца сэмпла, у нас теперь
|
||||
стоит реальная длина нашей записи: m50 — 831 тик, m53 — 985. Отсюда
|
||||
новая шкала PV: конец m50 на 846, вход Джафара (m53) на 1046, уход
|
||||
(m52) на 2073, конец сцены 2500 тиков (было 1959).
|
||||
|
||||
**Fade перед PV** (вопрос пользователя: наш fade короче, 4 ступени против
|
||||
64). Совпасть должен момент полной темноты, считая от пуска m55:
|
||||
у SDLPoP это 80 (transition) + 600 (wait) + 128 (fade_out_2: 0x40 шагов
|
||||
по 2 тика); у нас переход занимает 80 кадров луча = 96 тиков, а fade —
|
||||
5 тиков. Отсюда `WAIT = 80 + 600 + 128 − 96 − 5 = 707` тиков. Дальше и
|
||||
там и там экран уже чёрный, а трек доигрывает: этой паузой заставка и
|
||||
стыкуется с PV.
|
||||
|
||||
**Музыка переживает смену сцен.** m54 звучит с титров и до первого
|
||||
экрана истории (`pop_intro_show` больше не глушит CBL на входе), m52
|
||||
начинается в PV и доигрывает уже на экране «свадьбы» — как seg000:2051.
|
||||
|
||||
Проверено в MAME: цепочка 54 → 55 → 50 → 53 → 52 отыгрывается целиком,
|
||||
курсор `pop_mus_id` меняется ровно на своих кадрах, интро доходит до
|
||||
демо-режима. Слуховая проверка (нет ли хрипа от диска при играющей
|
||||
музыке) — за пользователем.
|
||||
|
||||
## 7. МУЗЫКА ПО ХОДУ ИГРЫ (2026-08-26)
|
||||
|
||||
Звуки 24..43 в наборе оцифровки ПУСТЫЕ — в оригинале это Adlib-музыка, и
|
||||
в digisnd её нет вовсе. На этом и построено подключение: `pop_sfx_play`
|
||||
для звука с нулевой длиной не пытается его играть, а кладёт НОМЕР в
|
||||
`pop_mus_req` (один байт). Заявку разбирает `pop_music_service()` — один
|
||||
вызов на кадр из любого цикла (игрового, интро, катсцены); всё чтение с
|
||||
диска живёт там.
|
||||
|
||||
**Стриминг вместо загрузки.** Ждать полной загрузки джингла нельзя — это
|
||||
фриз на треть секунды посреди игры. `pop_music_stream` читает ПЕРВУЮ
|
||||
страницу (33 мс) и сразу пускает трек: она звучит 1,5 с, а следующая
|
||||
читается те же 33 мс — запас сорокакратный. Остальные доливаются по
|
||||
одной за кадр, пока `pop_music_loading()`. Номера страниц известны сразу
|
||||
после `mem_alloc_pages`, поэтому таблица для насоса заполняется целиком —
|
||||
данные появятся раньше, чем насос до них дойдёт.
|
||||
|
||||
**Что и где играет** (номера и места — из SDLPoP):
|
||||
|
||||
| трек | событие | место у нас |
|
||||
|------|---------|-------------|
|
||||
| 24 / 28 / 32 | смерть: обычная / в бою / от руки тени | `ctrl_kid_death` |
|
||||
| 25 | вступление 1-го уровня (Кид сидит), тень 6-го | `control_crouched`, `guards.c` |
|
||||
| — | НА ДЕМО-УРОВНЕ музыки нет вовсе: там одни эффекты | гейт в `pop_music_service` |
|
||||
| 27 / 35 / 40 | сцены перед 2/4/6/12, 8/9, «времени мало» | `pop_pre_cutscene_show` |
|
||||
| 29 | встреча с Джафаром | `pop_meet_jaffar` |
|
||||
| 30 / 33 | большая склянка / малая | `pop_proc_get_object` |
|
||||
| 36 | время вышло | `pop_time_expired_show` |
|
||||
| 37 / 43 | меч найден, страж убит / смерть Джафара | `pop_proc_get_object`, `on_guard_killed` |
|
||||
| 39 | перо (медленное падение) | `pop_proc_get_object` |
|
||||
| 41 / 32 | конец уровня / конец 4-го (тень) | опкод SND_LEVEL в `play_seq` |
|
||||
| 26 | встреча с принцессой | `cut_ending` |
|
||||
|
||||
**Вступление первого уровня — автомат, а не «звук при приседе»** (seg005:02EB).
|
||||
Пока `need_level1_music` не ноль, `control_crouched` НЕ ЧИТАЕТ управление:
|
||||
Кид сидит, тема играет, и лишь когда она смолкла, он может встать. Наша
|
||||
первая версия просто играла трек при первом приседе — и тема догоняла
|
||||
игрока посреди уровня (пробежал, спрыгнул, присел — заиграла). Признак
|
||||
«ещё звучит» берём у курсора насоса `pop_mus_left`: он резидентный, и если
|
||||
музыка выключена, курсор остаётся нулём — Кид просто встаёт.
|
||||
|
||||
Темы, которые звучат один раз за заход на уровень (вступление 1-го, тень
|
||||
6-го), сбрасывает `pop_music_level_start()` из `pop_start_level`. Оригинал
|
||||
для этого портит переменную двери (`leveldoor_open = 0x4D`) — у нас на это
|
||||
есть свои два байта.
|
||||
|
||||
**ГДЕ КОНЧАЕТСЯ МУЗЫКА СЦЕНЫ** (уточнено 2026-08-26). Сначала мы отдали
|
||||
трек «доигрывать в игре»: у оригинала load_intro после сцены просто гасит
|
||||
экран и возвращает управление. На слух оказалось хуже, чем в оригинале —
|
||||
музыка спотыкается: загрузка уровня (ESTEX плюс сборка комнаты) не даёт
|
||||
насосу долить блок вовремя. У DOS-версии этой проблемы нет, там звук
|
||||
живёт своей жизнью на аппаратуре.
|
||||
|
||||
Поэтому дослушиваем ПОД ЧЁРНЫМ ЭКРАНОМ, до отрисовки уровня: следующий
|
||||
load_intro у оригинала и так начинается с ожидания тишины (seg001:681), то
|
||||
есть к новому уровню трек в любом случае смолкает. Пропуск сцены обрывает
|
||||
и музыку — игрок нажал клавишу, чтобы идти дальше.
|
||||
|
||||
**ДЛИТЕЛЬНОСТЬ FADE.** fade_in_1/fade_out_1 — это 64 шага палитры по два
|
||||
тика, 128 тиков = 2,13 с; сцена перед уровнем 2 занимает с ними около семи
|
||||
секунд. Наши четыре ступени укладывались в восемь сотых секунды, и сцена
|
||||
выходила втрое короче. Теперь `INTRO_FADE = POP_T60(128)`, а ступеней в
|
||||
`pop_ui_palette_dim` тридцать две вместо четырёх: на четырёх растянутых
|
||||
ступенях затемнение выглядело бы скачками. Половина от оригинальных 64 —
|
||||
на глаз от них не отличается (ступень каждые 66 мс), а вот шестнадцать уже
|
||||
видно. Сумма «fade in + сцена + fade out» при этом совпадает с оригиналом
|
||||
сама собой: длительность каждого fade та же, что у fade_*_1.
|
||||
|
||||
**Цена ступени** (замеры в MAME 2026-08-26, такты 21 МГц; кадр 430 000):
|
||||
|
||||
| версия | такты | что изменилось |
|
||||
|--------|-------|----------------|
|
||||
| исходная | 2 440 000 | снимок копировался побайтовым циклом на C |
|
||||
| + таблица яркости на стеке | 1 250 000 | 768 умножений uint16 заменены 256 сложениями |
|
||||
| + memcpy для снимка | 487 000 | LDIR вместо цикла — главный выигрыш |
|
||||
|
||||
Из оставшихся 487 тысяч 136 тысяч — заливка палитры через BIOS (8 вызовов
|
||||
`gfx_pal_load` по 17 000). Дальше можно было бы хранить готовые таблицы
|
||||
яркости файлом, но при 1,2 мс на построение это уже незаметно.
|
||||
|
||||
ВАЖНО: ступень пересчитывается только когда она СМЕНИЛАСЬ. Наивный цикл
|
||||
«ступень на каждый кадр» звал пересчёт сто раз и растягивал fade до
|
||||
десяти с лишним секунд.
|
||||
|
||||
## 8. ПОТОКОВЫЙ ТРЕК: ФИНАЛЬНАЯ ТЕМА (2026-08-26)
|
||||
|
||||
`won` (56) — 115 с, 1,2 МБ, 78 страниц EMM. В память он не влезает ни при
|
||||
каком бюджете, поэтому играется КОЛЬЦОМ из шести страниц (96 КБ = 9 с): насос
|
||||
идёт по кругу, а `pop_music_service` дочитывает файл в те страницы, которые
|
||||
насос уже прошёл.
|
||||
|
||||
**Кто кого догоняет.** Страница звучит 1,5 с, а читается 33 мс — запас
|
||||
сорокакратный. Дистанция считается без отдельных счётчиков: страница ровно
|
||||
128 блоков насоса, поэтому проигранных страниц = (всего блоков − осталось)
|
||||
/ 128. Пока прочитано меньше, чем проиграно плюс размер кольца, в кольце
|
||||
есть свободный слот. Файл читается ПОСЛЕДОВАТЕЛЬНО, без `lseek`.
|
||||
|
||||
**Что пришлось учесть.**
|
||||
|
||||
* Насос заворачивает страницу только при `pop_mus_ring != 0`; конец трека
|
||||
по-прежнему определяет `left`. Обычный трек этой ветки не касается.
|
||||
* Кольцо обязано сниматься при любом обычном запуске (`pop_music_play`,
|
||||
`_stream`, `_load_begin`): иначе следующий трек играет по кругу первых
|
||||
шести страниц — поймано на титрах сразу после победы.
|
||||
* Живые сцены комнаты принцессы открывают CBL сами (`cut_begin`):
|
||||
`pop_ending_show` глушит звук первым действием, и «arrived to princess»
|
||||
(26) не звучал вовсе.
|
||||
* Тема дослушивается до конца (прерывается клавишей), как `while
|
||||
(check_sound_playing() && !key_test_quit())` в seg001:637. У оригинала
|
||||
между титрами и этим ожиданием стоит ввод имени в таблицу рекордов —
|
||||
когда он появится у нас, ожидание переедет за него (задача HOF-ENTRY).
|
||||
|
||||
Проверено в MAME на сборке `LEVEL=14`: после встречи с принцессой звучит
|
||||
тема победы (`pop_mus_id` = 56, `pop_mus_ring` = 6), курсор уходит далеко
|
||||
за размер кольца — то есть подкачка успевает.
|
||||
@@ -0,0 +1,218 @@
|
||||
# Текст в нижней статус-строке (строке HP) — полная инвентаризация SDLPoP
|
||||
|
||||
Разбор `SDLPoP/src/` на 2026-08-25. Цель — знать ВЕСЬ набор сообщений,
|
||||
которые оригинал печатает в ту же полосу, где нарисованы деления HP,
|
||||
и правила их появления/исчезновения. Это входные данные для порта:
|
||||
у нас пока туда пишется только `GAME PAUSED`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Геометрия: одна полоса на HP и на текст
|
||||
|
||||
```
|
||||
rect_bottom_text = { top 193, left 70, bottom 202, right 250 } // data.h:217
|
||||
display_text_bottom: draw_rect(чёрным) + show_text(halign_center, valign_bottom)
|
||||
```
|
||||
|
||||
* Деления HP **Кида** — от `x = 0` вправо, шаг 7, максимум 10 → занимают `x 0..69`.
|
||||
* Деления HP **стража** — от `x = 314` влево, шаг 7, максимум 10 → занимают `x 245..320`.
|
||||
* Текст живёт РОВНО в промежутке `x 70..250` и по X с делениями не пересекается.
|
||||
* По Y деления на `y = 194..200`, текст (`valign_bottom` к 202) — на `y = 195..201`,
|
||||
то есть на строку ниже. Именно поэтому в оригинале текст выглядит «сидящим»
|
||||
чуть ниже стрелок HP.
|
||||
|
||||
**У нас**: `POP_HP_Y = 194` (`pop_cdraw.h`), экран сдвинут на `POP_YOFF = 28`,
|
||||
базовая линия крупного шрифта `POP_YOFF + POP_HP_Y + 8 = 230` — силуэт
|
||||
ложится на `223..229`, то есть та же картинка.
|
||||
|
||||
## 2. Два примитива и два таймера
|
||||
|
||||
| Имя | Что делает |
|
||||
|-----|------------|
|
||||
| `display_text_bottom(text)` (seg008:2644) | стереть прямоугольник цветом 0 и напечатать текст по центру |
|
||||
| `erase_bottom_text(arg)` (seg008:266D) | стереть прямоугольник; при `arg != 0` ещё и обнулить оба таймера |
|
||||
| `text_time_remaining` | сколько игровых тиков сообщение ещё висит; 0 — ничего не висит |
|
||||
| `text_time_total` | **идентификатор сообщения**, а не только его длительность |
|
||||
|
||||
Обработка тика — в `draw_game_frame`/`idle` (seg000:956). Комментарий в
|
||||
оригинале прямой: *«Note: texts are identified by their total time!»* Значения
|
||||
`text_time_total`, у которых есть особое поведение:
|
||||
|
||||
| `total` | Смысл | Что происходит по истечении |
|
||||
|---------|-------|------------------------------|
|
||||
| 12 | «1 SECOND LEFT» | обычное стирание |
|
||||
| 24 | обычное короткое сообщение | обычное стирание |
|
||||
| 36 | смерть на демо-уровне (0) или на уровне зелий (15) — **текста нет** | `start_game()` — рестарт игры |
|
||||
| 288 | «Press Button to Continue» | `start_game()` — рестарт игры |
|
||||
| 1188 | защита от копирования (уровень 15) | **не убывает и не исчезает** |
|
||||
|
||||
Мигание: при `total == 288` и `remaining < 72` сообщение мигает с периодом 12
|
||||
тиков — 4 тика видно (`blink_frame <= 3`), 8 нет; в кадре `blink_frame == 3`
|
||||
заново печатается текст и играет звук 38 (`sound_38_blink`).
|
||||
|
||||
Сброс: `init_game()` (seg003:32) обнуляет оба таймера и `is_show_time` — то есть
|
||||
любое сообщение умирает на старте уровня.
|
||||
|
||||
---
|
||||
|
||||
## 3. Полный список сообщений
|
||||
|
||||
### 3.1. Состояние программы
|
||||
|
||||
| Текст | Где | Таймер |
|
||||
|-------|-----|--------|
|
||||
| `GAME PAUSED` | seg000:1769, пока `is_paused` | **без таймера**: печатается на входе в паузу, `erase_bottom_text(1)` на выходе (seg000:1784) |
|
||||
|
||||
### 3.2. Уровень и оставшееся время (`show_level` / `show_time`, seg008)
|
||||
|
||||
| Текст | Условие | `total` |
|
||||
|-------|---------|---------|
|
||||
| `LEVEL %d` | `show_level()` при старте уровня; только `1..12` (`hide_level_number_from_level = 14`), не при `seamless`; уровень 13 показывается как **12** (`level_13_level_number`) | 24, дальше сразу `is_show_time = 1` |
|
||||
| `%d MINUTES LEFT` | каждая минута, кратная 5, и каждая из последних 5 | 24 |
|
||||
| `%d SECONDS LEFT` | последняя минута, раз в 12 тиков | 24 |
|
||||
| `1 SECOND LEFT` | остался 1 с | **12** |
|
||||
| `TIME HAS EXPIRED!` | `rem_min == 0` | 24 |
|
||||
| `%d MINUTES PASSED` / `1 MINUTE PASSED` | только SDLPoP (`ALLOW_INFINITE_TIME`), при отрицательном таймере | 24 |
|
||||
|
||||
Что взводит `is_show_time` (все → следующий кадр печатает время):
|
||||
|
||||
* **Space** — seg000:612, штатная клавиша оригинала «сколько осталось»;
|
||||
* читы **`-`/`+` numpad** (изменение времени) — seg000:762 / 777, при этом
|
||||
таймеры сообщения обнуляются, чтобы новое напечаталось немедленно;
|
||||
* **смерть Джафара** — `on_guard_killed()` seg006:1936, уровень 13
|
||||
(`jaffar_victory_level`): вспышка + показать время;
|
||||
* истечение очередной минуты — seg008:1796;
|
||||
* сразу после `show_level()`.
|
||||
|
||||
Обнуляет `is_show_time`: `play_kid()` при смерти (seg006:1365) и
|
||||
`show_copyprot(1)` (seg000:2385).
|
||||
|
||||
### 3.3. Смерть Кида
|
||||
|
||||
| Текст | Где | `total` |
|
||||
|-------|-----|---------|
|
||||
| `Press Button to Continue` | `play_kid()` seg006:1383 — умер на обычном уровне | **288** (мигает, затем рестарт игры) |
|
||||
| *(без текста)* | тот же код, но уровень 0 (демо) или 15 (зелья) | **36** (тихая пауза, затем рестарт игры) |
|
||||
|
||||
Стирается: `fell_out()` (seg006:1342, упал из комнаты 0) и чит **R**
|
||||
(воскрешение, seg000:783) — оба зовут `erase_bottom_text(1)`.
|
||||
|
||||
### 3.4. Сохранение и загрузка
|
||||
|
||||
| Текст | Клавиша | `total` |
|
||||
|-------|---------|---------|
|
||||
| `GAME SAVED` / `UNABLE TO SAVE GAME` | Ctrl+G (`save_game`, seg000:2211) | `total` не ставится, `remaining = 24` |
|
||||
| `QUICKSAVE` / `NO QUICKSAVE` | F6 (расширение SDLPoP, seg000:497) | 24 |
|
||||
| `QUICKLOAD` / `NO QUICKLOAD` | F9 (расширение SDLPoP, seg000:514) | 24 |
|
||||
|
||||
### 3.5. Ответы на клавиши (`answer_text` → `need_show_text`, все `total = 24`)
|
||||
|
||||
| Текст | Клавиша |
|
||||
|-------|---------|
|
||||
| `SOUND ON` / `SOUND OFF` | Ctrl+S |
|
||||
| `KEYBOARD MODE` | Ctrl+K |
|
||||
| `JOYSTICK MODE` / `JOYSTICK NOT FOUND` / `JOYSTICK UNAVAILABLE` | Ctrl+J |
|
||||
| `PRINCE OF PERSIA V1.0` (в SDLPoP заменено на `SDLPoP v%s`) | Ctrl+V |
|
||||
| `SDL COMP v… LINK v…` | Ctrl+C — только SDLPoP |
|
||||
|
||||
### 3.6. Отладочные читы (`cheats_enabled`, `total = 24`)
|
||||
|
||||
| Текст | Клавиша | Смысл |
|
||||
|-------|---------|-------|
|
||||
| `S%d L%d R%d A%d B%d` | `C` | номер отрисованной комнаты и её соседей L/R/A/B |
|
||||
| `AL%d AR%d BL%d BR%d` | Shift+`C` | диагональные соседи |
|
||||
|
||||
### 3.7. Защита от копирования (только уровень 15)
|
||||
|
||||
| Текст | Где | `total` |
|
||||
|-------|-----|---------|
|
||||
| `WORD %d LINE %d PAGE %d` | `show_copyprot(1)` seg000:2389 | **1188** — висит, пока не сменится уровень |
|
||||
|
||||
### 3.8. Только SDLPoP, в оригинале 1989 отсутствует
|
||||
|
||||
| Текст | Где |
|
||||
|-------|-----|
|
||||
| `RECORDING`, `REPLAY SAVED`, `REPLAY CANCELED` | replay.c:599/626/628 |
|
||||
| имя файла скриншота | screenshot.c:62 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Что из этого касается нашего порта
|
||||
|
||||
Реализовано (`pop_status.c`, таймер 24 тика как у оригинала):
|
||||
|
||||
* `GAME PAUSED` — без таймера, рисует само меню (`pop_menu.c`);
|
||||
* `QUICKSAVE` / `NO QUICKSAVE`, `QUICKLOAD` / `NO QUICKLOAD` — заявка стоит
|
||||
в `pop_qsave_process`, то есть в единственном месте, где известно, что
|
||||
именно делали. Лейбл печатается ДО дисковой операции — осознанное
|
||||
расхождение, см. `impl_diff.md`;
|
||||
* `SOUND ON` / `SOUND OFF` — Ctrl+S;
|
||||
* `LEVEL %d` — порт `show_level()` целиком: демо-уровень 0 и номера от 14
|
||||
молчат, тринадцатый показывается двенадцатым, бесшовный переход 12→13
|
||||
пропускается и гасит флаг за собой.
|
||||
|
||||
* вся группа времени — `N MINUTES LEFT`, `N SECONDS LEFT`, `1 SECOND LEFT`,
|
||||
`TIME HAS EXPIRED!`. Флаг `pop_show_time` (порт `is_show_time`) взводит
|
||||
само ядро таймера на круглых пятёрках и каждую секунду последней минуты,
|
||||
а также старт уровня и читы времени; значение 2 означает «перебить
|
||||
текущую строку», как оригинал делает в последнюю минуту;
|
||||
* `Press Button to Continue` — висит бессрочно (`MSG_HOLD`), уровень
|
||||
перезапускает кнопка. Расхождение с оригиналом, см. `impl_diff.md`.
|
||||
|
||||
Пока НЕ печатается:
|
||||
|
||||
* номера комнат (`C`/Shift+`C`) — у нас отдельная отладочная строка;
|
||||
* copy protection и SDLPoP-расширения (replay, скриншоты) — не нужны.
|
||||
|
||||
Отладочная строка вдобавок показывает оставшееся время `##:##` у правого
|
||||
края. На табло уходит `minutes-1`: у оригинала `rem_min` — это НОМЕР идущей
|
||||
минуты, а не остаток целых (старт 60 при `rem_tick` 719 = «почти 60:00»).
|
||||
Секунды считаются делением раз в 12 кадров, а не каждый кадр.
|
||||
|
||||
Нам не нужно: copy protection (уровень 15 исключён из порта — см.
|
||||
`full_game_plan.md`), joystick-режимы, replay, скриншоты.
|
||||
|
||||
Механика, которую придётся портировать целиком, если брать группу времени:
|
||||
пара таймеров `text_time_total`/`text_time_remaining` с семантикой
|
||||
«идентификатор сообщения» — иначе не воспроизвести ни мигание, ни рестарт по
|
||||
истечении 36/288.
|
||||
|
||||
---
|
||||
|
||||
## 5. Цена вывода и что делать, если упрёмся
|
||||
|
||||
Блит одного глифа стоит ~4,6 тыс. тактов почти независимо от размера — это
|
||||
цена вызова, а не пикселей (memory `blit_cost_model`). Полсотни символов =
|
||||
полкадра. Что уже сделано в `pop_status.c` / `pop_ui.c`:
|
||||
|
||||
* **change-driven**: пока показанное не изменилось, не рисуем вовсе;
|
||||
* **по полям**: смена комнаты — две цифры (~9 тыс. тактов, 2% кадра), а не
|
||||
вся строка; подписи рисуются только при полной инвалидации;
|
||||
* **пробелы не блитятся**: их глиф целиком прозрачен, а стоит как буква —
|
||||
на полной отладочной строке это девять сэкономленных блитов;
|
||||
* **заливка только поля** при входе в комнату (`pop_screen_fill_field`):
|
||||
борта от комнаты к комнате не меняются, это и четверть заливки, и то, что
|
||||
обе полосы вход переживают.
|
||||
|
||||
Запас, если бюджета всё же не хватит (идеи пользователя, 2026-08-25):
|
||||
|
||||
1. **Растянуть вывод на несколько кадров, не показывая полуготовую строку.**
|
||||
Печатать по нескольку букв за кадр, держа цвет шрифта чёрным (отдельный
|
||||
индекс палитры), а по готовности подменить этот индекс на белый — строка
|
||||
появится целиком и мгновенно. Стоит ноль байт памяти и укладывается в
|
||||
нашу же технику «два разных чёрных» (`POP_COL_OUTSIDE`).
|
||||
2. **Собирать строку в один спрайт** в свободном хвосте страницы шрифта и
|
||||
блитить одним вызовом. Дороже по подготовке (~35 тыс. тактов на
|
||||
копирование), но выгодно там, где строка ЦЕЛИКОМ меняется каждый раз.
|
||||
Для меню этот путь уже рассматривался и был отвергнут; для статус-строк
|
||||
он имеет смысл только вместе с п.1.
|
||||
|
||||
Про QuickSave/QuickLoad оптимизация не нужна вовсе: там игра и так стоит на
|
||||
время дисковой операции.
|
||||
|
||||
## 6. Ловушка: свисающие глифы
|
||||
|
||||
Зона стирания текста обязана захватывать строку НИЖЕ базовой линии. В малом
|
||||
шрифте `'p'` имеет высоту 7 при ascent 5, `','` — 6: они свисают под базовую
|
||||
линию. Стирание ровно до неё оставляло от хвоста «p» в «Speed:» одинокую
|
||||
точку (поймано в MAME 2026-08-25).
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user