diff --git a/applications/SprPoP/CLAUDE.md b/applications/SprPoP/CLAUDE.md index ed1aa09..d7907fe 100644 --- a/applications/SprPoP/CLAUDE.md +++ b/applications/SprPoP/CLAUDE.md @@ -163,6 +163,7 @@ Makefile склеивает архивы прямо в `assets/packed/<КАТА | `pop_cdraw.c/.h` | ОТРИСОВКА персонажей — одна на всех Char (порт `add_kid_to_objtable`/`add_guard_to_objtable`): спрайт, clip_char, брызги, клинок, heal; полоса HP и палитры соперника. Там же ПРОПУСК неизменившегося кадра (`pop_char_skip_mask`). | | `pop_ctrl.c/.h` | Управление: диспетчер `control()` (`seg005`) + ввод `read_user_control` (`seg006`) поверх ``. | | `pop_sfx.c` + `pop_sfx_cold.c` | Звук: насос CBL в резиденте + холодная загрузка набора. Раскладка эффектов НЕ компилируется в EXE — читается с диска (`SND/snd.idx`, формат `PSI1`); см. `docs/sound_plan.md` §10. | +| `pop_music.c` | Музыка (банк 9): загрузка треков, кольцо для финальной темы. Длины и длительности — из `MUS/mus.idx` (EMM-страница + accessor `pop_music_info`), в EXE их нет; см. `docs/sound_plan.md` §11. | | `pop_snd_tbl.h` + `pop_snd_data.c` | Тип записи набора и инварианты (рукописный заголовок) + резидентные `pop_snd_tbl`/`pop_snd_page`/`pop_snd_pages`. Размер записи 5 байт — часть дискового контракта, проверяется статически. | | `pop_map.c/.h` | Коллизия с картой + физика падения/приземления/стены + loose-полы (`seg005/006/007`). | diff --git a/applications/SprPoP/Makefile b/applications/SprPoP/Makefile index eddc4df..5726396 100644 --- a/applications/SprPoP/Makefile +++ b/applications/SprPoP/Makefile @@ -223,7 +223,7 @@ DISK := README_E.TXT README_R.TXT \ KID/kid.arc KID/kid.pal KID/kid.ani \ GUARD/guard.arc SKEL/skel.arc VIZIER/vizier.arc SHADOW/shadow.arc \ SND/snd.arc SND/snd.idx \ - $(foreach i,$(MUS_IDS),MUS/m$(i).bin) \ + $(foreach i,$(MUS_IDS),MUS/m$(i).bin) MUS/mus.idx \ FONT/font.atl \ TITLE/title.arc TITLE/title.pal \ PV/pv.arc PV/story.pal PV/pv.pal \ @@ -236,9 +236,11 @@ GEN_ARC_H := bg_arc.h kid_arc.h guard_arc.h skel_arc.h vizier_arc.h \ # pop_sound_tbl.h здесь БОЛЬШЕ НЕТ: раскладка эффектов уехала на диск # (SND/snd.idx), и EXE от звукового набора не зависит — пару # snd.idx + snd.arc можно менять без перекомпиляции. +# Ни звукового, ни музыкальных заголовков здесь БОЛЬШЕ НЕТ: раскладка +# эффектов и длины треков уехали на диск (SND/snd.idx, MUS/mus.idx), и EXE +# не зависит ни от звукового набора, ни от набора записей. GEN_H := $(addprefix gen/,$(GEN_ARC_H) pop_bg_atlas.h kid_data.h \ - pop_guard_pal.h pop_shadow_atlas.h \ - pop_music_tbl.h pop_music_ticks.h pop_font.h) + pop_guard_pal.h pop_shadow_atlas.h pop_font.h) PACKED_FILES := $(addprefix $(PACKED)/,$(DISK)) STAGED := $(addprefix $(BUILD_DIR)/,$(DISK)) @@ -406,7 +408,7 @@ $(STAMP)/sound-$(SND_SRC): $(TOOLS)/pop_pack_sound.py $(TOOLS)/pop_idx.py \ $(ARC) $(PACKED)/SND/snd.arc $$(ls $(ATL)/sound/s*.bin | sort -V) @cp $(ATL)/sound/snd.idx $(PACKED)/SND/snd.idx @touch $@ -$(eval $(call stamp_rule,music-$(MUSIC_FMT),$(TOOLS)/pop_pack_music.py $(TOOLS)/paths.py $(STAMP)/music.variant,pop_pack_music.py --format $(MUSIC_FMT) $(MUS_IDS))) +$(eval $(call stamp_rule,music-$(MUSIC_FMT),$(TOOLS)/pop_pack_music.py $(TOOLS)/pop_idx.py $(TOOLS)/paths.py $(STAMP)/music.variant,pop_pack_music.py --format $(MUSIC_FMT) $(MUS_IDS))) $(eval $(call stamp_rule,font,$(TOOLS)/pop_extract_font.py $(TOOLS)/paths.py,pop_extract_font.py)) $(eval $(call stamp_rule,title,$(TOOLS)/pop_pack_title.py $(TOOLS)/paths.py,pop_pack_title.py)) $(eval $(call stamp_rule,pv,$(TOOLS)/pop_pack_intro.py $(TOOLS)/paths.py,pop_pack_intro.py)) @@ -426,7 +428,7 @@ $(eval $(call from_stamp,$(GUARD_ATL) gen/pop_guard_pal.h,guard)) $(eval $(call from_stamp,$(SKEL_ATL),skel)) $(eval $(call from_stamp,$(VIZIER_ATL),vizier)) $(eval $(call from_stamp,$(SHADOW_ATL) gen/pop_shadow_atlas.h,shadow)) -$(eval $(call from_stamp,$(foreach i,$(MUS_IDS),$(ATL)/music/m$(i).bin) gen/pop_music_tbl.h gen/pop_music_ticks.h,music-$(MUSIC_FMT))) +$(eval $(call from_stamp,$(foreach i,$(MUS_IDS),$(ATL)/music/m$(i).bin) $(ATL)/music/mus.idx,music-$(MUSIC_FMT))) $(eval $(call from_stamp,$(ATL)/font/font.atl gen/pop_font.h,font)) $(eval $(call from_stamp,$(TITLE_ATL) $(ATL)/title/title.pal,title)) $(eval $(call from_stamp,$(PV_ATL) $(ATL)/pv/story.pal $(ATL)/pv/pv.pal,pv)) @@ -475,6 +477,7 @@ $(eval $(call copy_rule,TITLE/title.pal,$(ATL)/title/title.pal)) $(eval $(call copy_rule,PV/story.pal,$(ATL)/pv/story.pal)) $(eval $(call copy_rule,PV/pv.pal,$(ATL)/pv/pv.pal)) $(foreach i,$(MUS_IDS),$(eval $(call copy_rule,MUS/m$(i).bin,$(ATL)/music/m$(i).bin))) +$(eval $(call copy_rule,MUS/mus.idx,$(ATL)/music/mus.idx)) # Уровни идут на диск ПРЯМО из оригинала: формат res20xx.bin наш загрузчик # читает как есть, упаковщика для них нет. check-orig здесь тоже order-only — # без оригиналов взять уровни неоткуда, и сказать об этом надо внятно. @@ -509,7 +512,7 @@ resources-kid: $(PACKED)/KID/kid.arc $(PACKED)/KID/kid.pal $(PACKED)/KID/kid. resources-actors: $(PACKED)/GUARD/guard.arc $(PACKED)/SKEL/skel.arc \ $(PACKED)/VIZIER/vizier.arc $(PACKED)/SHADOW/shadow.arc resources-sound: $(PACKED)/SND/snd.arc $(PACKED)/SND/snd.idx -resources-music: $(foreach i,$(MUS_IDS),$(PACKED)/MUS/m$(i).bin) +resources-music: $(foreach i,$(MUS_IDS),$(PACKED)/MUS/m$(i).bin) $(PACKED)/MUS/mus.idx resources-font: $(PACKED)/FONT/font.atl resources-title: $(PACKED)/TITLE/title.arc $(PACKED)/TITLE/title.pal resources-pv: $(PACKED)/PV/pv.arc $(PACKED)/PV/story.pal $(PACKED)/PV/pv.pal diff --git a/applications/SprPoP/assets/packed/MUS/mus.idx b/applications/SprPoP/assets/packed/MUS/mus.idx new file mode 100644 index 0000000..9da6442 Binary files /dev/null and b/applications/SprPoP/assets/packed/MUS/mus.idx differ diff --git a/applications/SprPoP/docs/music_runtime_index_plan.md b/applications/SprPoP/docs/music_runtime_index_plan.md index b1378c1..40d3326 100644 --- a/applications/SprPoP/docs/music_runtime_index_plan.md +++ b/applications/SprPoP/docs/music_runtime_index_plan.md @@ -1,7 +1,8 @@ # Аудио без перелинковки: runtime-индексы музыки и эффектов -> Статус: **часть II (эффекты, SI0..SI4) СДЕЛАНА 2026-08-31** — разбор и -> результаты в `sound_plan.md` §10. Часть I (музыка, MI0..MI5) — план. +> Статус: **ОБЕ ЧАСТИ СДЕЛАНЫ 2026-08-31** — эффекты (SI0..SI4) и музыка +> (MI0..MI5). Разбор и результаты: `sound_plan.md` §10 (эффекты) и §11 +> (музыка). Ниже — исходный план; расхождения перечислены следом. > > Что в реализации разошлось с планом ниже: файл назван `SND/snd.idx` > (единый basename с `snd.arc`, оба в нижнем регистре), а при отсутствии @@ -9,6 +10,16 @@ > и игра непроходима (§10.5 sound_plan). Про `ticks60` в `PMI1` решено: > поле в формате оставить даже если сценарии перейдут на ожидание звука — > загрузчик волен не тащить его в память. +> +> Расхождения части I с планом ниже: +> * файл назван `MUS/mus.idx` (единый стиль с `snd.idx`); +> * пауза конца уровня переведена НЕ на runtime-тики, а на состояние +> «заявка/загрузка/звучание» (`pop_music_active`) — §11.2: это снимает и +> зависимость от набора, и враньё делителя `/4` в быстрых режимах, и +> расхождение с SDLPoP при выключенном звуке; +> * PV-сцена (§8 плана) — не структура времён через пять функций, а четыре +> якоря-статики (8 байт), от которых отсчитываются прежние выражения; +> * `pages` в PMI1 не хранится — считается из `blocks`. ## 1. Цель diff --git a/applications/SprPoP/docs/sound_plan.md b/applications/SprPoP/docs/sound_plan.md index 63ffb93..96af4c6 100644 --- a/applications/SprPoP/docs/sound_plan.md +++ b/applications/SprPoP/docs/sound_plan.md @@ -1194,3 +1194,93 @@ sdlpop` перепаковывал `build/atl`, а в `assets/packed` остав сцене с принцессой) лежит у наборов по РАЗНЫМ страницам — 8 у SDLPoP, 9 у MSDOS. А вот 4 `gate_closing` из той же сцены совпадает побайтно и для проверки подмены не годится. + +## 11. МУЗЫКА БЕЗ ПЕРЕЛИНКОВКИ: ДЛИНЫ И ДЛИТЕЛЬНОСТИ УЕХАЛИ НА ДИСК (2026-08-31) + +Сделана часть I плана `music_runtime_index_plan.md` (этапы MI0..MI5, у нас +B0..B7). `gen/pop_music_tbl.h` и `gen/pop_music_ticks.h` УДАЛЕНЫ: длины +треков и длительности реплик читаются из `MUS/mus.idx` (формат `PMI1`). +Один и тот же `sprpop.exe` работает с любым из четырёх наборов записей. + +### 11.1 Где живёт индекс и почему именно там + +228 байт таблицы в W2 положить нельзя — там свободной кучи порядка двух +сотен. Поэтому индекс лежит в ОДНОЙ странице EMM, а в резиденте от него +остаются два байта (номер блока и страницы). Наружу указатель на EMM не +выдаётся: `pop_music_info()` подключает страницу в W0, копирует четыре +байта в буфер вызывающего и сразу снимает окно. + +**Данные в странице лежат со смещения `POP_MUS_IDX_BASE` (0x100), а не с +нуля.** Причина в `gfx_w0_page_prepare`: он делает страницу безопасной +для W0, записывая В НЕЁ переход на IRQ-восстановитель (0x38) и RETN для +NMI (0x66). С нуля эти байты легли бы прямо в записи (id 10 и 21) и +испортили индекс; со смещением стабы и данные не пересекаются, и работает +штатная защита от прерываний, а не запрет их. Тот же приём и по той же +причине — в `pop_config.c` (`CFG_BASE`). + +Числа страниц в индексе НЕТ: это производная от `blocks`, и дублирование +однажды разъехалось бы с ним. Считает `pop_music_pages()`. + +### 11.2 Пауза конца уровня: состояние вместо счётчика + +`pop_endmus_left` и `POP_MUS_TICKS_32/41` УДАЛЕНЫ. Главный цикл ждёт +`pop_music_active()` — «заявка лежит, идёт загрузка или трек звучит». + +Одного `pop_music_busy()` там мало: между заявкой и первой нотой проходит +190–230 мс (§9), и в этой щели он ложен. Раньше щель закрывал счётчик по +длине записи, но он тянул за собой две беды: зависимость EXE от набора и +жёсткий делитель `/4`, который врал в быстрых режимах (там логический кадр +3 кадра луча, а не 4). Три признака подряд закрывают щель без единой +подобранной константы, потому что `pop_music_service` обнуляет `req` и +тут же взводит `ld_busy`, а тот держится до записи `pop_mus_left`. + +Побочно исправилось расхождение с оригиналом: при ВЫКЛЮЧЕННОМ звуке +`pop_sfx_play` не кладёт заявку, все три признака ложны, и уровень +меняется сразу — как в SDLPoP, где трек ставится под `if (is_sound_on)` +(seg006:651), а ожидание это `check_sound_playing()` (seg003:387). +Прежний счётчик взводился безусловно и держал пройденный уровень лишние +12 секунд в тишине. + +### 11.3 PV-сцена: четыре якоря вместо compile-time шкалы + +Шкала сцены осталась абсолютной (переход на ожидание звука — отдельный +разговор, см. план), но точки, зависящие от длины реплик, считаются при +входе в сцену: `pv_m50_end`, `pv_dialog1`, `pv_exit`, `pv_anim_end`. +Остальные выражения не изменились — они отсчитываются от якорей. + +Восемь байт статики вместо протаскивания структуры времён через пять +функций (`pv_princess_step`, `pv_jaffar_step`, `pv_seq_period`, +`intro_pv_draw_frame`, `intro_pv_animated`), как предлагал план: сцена +одна на запуск, и переделка всех сигнатур того не стоит. + +Нет индекса — длительности нулевые, и сцена просто идёт без пауз на +реплики: ждать нечего, звука-то нет. + +### 11.4 Цена в памяти + +За обе фазы (эффекты и музыка) резидент не вырос, а освободился: + +| | до фазы A | после фазы B | +|---|---:|---:| +| `_CODE` | 23865 | 23544 | +| куча резидента | 239 Б | 256 Б | + +Банк 9 похудел на 118 Б (ушла `pop_mus_tbl` из его rodata), банк 11 — на +длительности реплик. Прибавка: 2 байта на страницу индекса музыки и 8 на +якоря PV. + +### 11.5 Что проверено на живой машине + +* `sprpop.exe` ПОБАЙТОВО одинаков для наборов flac и mt32; +* все 22 трека в индексах различаются, и контрольные значения совпали с + тем, что план предсказывал по замерам: m32 438→449, m41 732→685, + m50 831→867, m53 985→1044, m56 9865→10462 блоков (78→82 страницы); +* на mt32-наборе PV-сцена проходит целиком: m50 → m53 → m52 звучат по + длительностям mt32, хотя в EXE их больше нет; +* без `mus.idx` музыки нет, эффекты работают, игра проходима — сцены + проскакивают без пауз на реплики. + +**НЕ ПРОВЕРЕНО:** потоковый трек m56 на 82 страницах (у flac 78). +Кольцевой проигрыватель получает число страниц из индекса, и код к этому +готов, но живого прогона финала на mt32 не было — до него надо дойти в +игре. Это оставшийся пункт приёмки. diff --git a/applications/SprPoP/gen/pop_music_tbl.h b/applications/SprPoP/gen/pop_music_tbl.h deleted file mode 100644 index cfd32b2..0000000 --- a/applications/SprPoP/gen/pop_music_tbl.h +++ /dev/null @@ -1,104 +0,0 @@ -/* - * pop_music_tbl.h — каталог музыкальных треков, СГЕНЕРИРОВАН. - * Источник: assets/orig/PoP1_DOS_music/flac (записи DOS-версии). - * Генератор: tools/pop_pack_music.py. Руками не править. - * - * Нумерация id — оригинала (SDLPoP data/music/names.txt). pages — - * сколько кусков m_.bin лежит на диске, blocks — длина трека - * в порциях насоса по 128 байт (в байтах не влезло бы в uint16). - * - * Длительности реплик в тиках вынесены в pop_music_ticks.h: таблица - * ниже — 171 байт ДАННЫХ, и модулю, которому нужна одна константа, - * платить за неё копией таблицы незачем (поймано на pop_intro.c: - * +172 байта в банк 11). - * - * POP_MUS_TICKS_ — та же длина в ТИКАХ СЦЕНЫ (60 Гц оригинала). - * Нужна там, где сценарий ждёт окончания реплики: у оригинала это - * `do { frame(1) } while (check_sound_playing())`, у нас — пауза - * длиной в нашу запись. Считается ЗДЕСЬ, а не в C, по двум - * причинам: blocks*40 переполняет uint16 на won (9855 порций), и - * число обязано соответствовать НАБОРУ, которым собрали (flac и - * mt32 — разные исполнения разной длины). - * - * Отношение 40/57 — обратное к тому, которым шкала сцены переводит - * тики в порции насоса (57/40, см. pop_intro.c). Берём именно его, - * а не точное 10937,5/128/60, чтобы длина реплики и ход шкалы - * считались одной и той же арифметикой. Округление — к ближайшему - * (+28 перед делением): отбрасывание вниз теряло по тику на реплику. - */ -#ifndef POP_MUSIC_TBL_H -#define POP_MUSIC_TBL_H - -#include - -#define POP_MUS_COUNT 57 -#define POP_MUS_BLOCK 128 -#define POP_MUS_MAX_PAGES 78 - - -typedef struct { - uint8_t pages; /* 0 — трека нет */ - uint16_t blocks; /* длина в порциях по 128 байт */ -} pop_mus_ent_t; - -static const pop_mus_ent_t pop_mus_tbl[POP_MUS_COUNT] = { - { 0, 0 }, /* 0 */ - { 0, 0 }, /* 1 */ - { 0, 0 }, /* 2 */ - { 0, 0 }, /* 3 */ - { 0, 0 }, /* 4 */ - { 0, 0 }, /* 5 */ - { 0, 0 }, /* 6 */ - { 0, 0 }, /* 7 */ - { 0, 0 }, /* 8 */ - { 0, 0 }, /* 9 */ - { 0, 0 }, /* 10 */ - { 0, 0 }, /* 11 */ - { 0, 0 }, /* 12 */ - { 0, 0 }, /* 13 */ - { 0, 0 }, /* 14 */ - { 0, 0 }, /* 15 */ - { 0, 0 }, /* 16 */ - { 0, 0 }, /* 17 */ - { 0, 0 }, /* 18 */ - { 0, 0 }, /* 19 */ - { 0, 0 }, /* 20 */ - { 0, 0 }, /* 21 */ - { 0, 0 }, /* 22 */ - { 0, 0 }, /* 23 */ - { 3, 305 }, /* 24 regular_death */ - { 2, 196 }, /* 25 find_mirror_and_find_shadow */ - { 9, 1132 }, /* 26 embrace */ - { 8, 915 }, /* 27 cutscene_before_2_4_6_12 */ - { 6, 656 }, /* 28 fight_death */ - { 4, 428 }, /* 29 meet_Jaffar */ - { 3, 299 }, /* 30 life_potion */ - { 0, 0 }, /* 31 */ - { 5, 624 }, /* 32 end_level_4_and_killed_by_shadow */ - { 2, 205 }, /* 33 potion */ - { 0, 0 }, /* 34 */ - { 7, 888 }, /* 35 cutscene_before_8_9 */ - { 10, 1256 }, /* 36 out_of_time */ - { 3, 280 }, /* 37 guard_death_and_obtaining_the_sword */ - { 0, 0 }, /* 38 */ - { 13, 1591 }, /* 39 floating */ - { 5, 596 }, /* 40 cutscene_not_much_time_left */ - { 9, 1043 }, /* 41 story_5_level_end_and_merge_with_shadow */ - { 0, 0 }, /* 42 */ - { 3, 285 }, /* 43 killed_Jaffar */ - { 0, 0 }, /* 44 */ - { 0, 0 }, /* 45 */ - { 0, 0 }, /* 46 */ - { 0, 0 }, /* 47 */ - { 0, 0 }, /* 48 */ - { 0, 0 }, /* 49 */ - { 10, 1184 }, /* 50 story_2_princess */ - { 0, 0 }, /* 51 */ - { 20, 2443 }, /* 52 story_4_Jaffar_leaves */ - { 11, 1403 }, /* 53 story_3_Jaffar_enters */ - { 17, 2064 }, /* 54 intro_theme */ - { 11, 1350 }, /* 55 story_1_absence */ - { 78, 9865 }, /* 56 won */ -}; - -#endif /* POP_MUSIC_TBL_H */ diff --git a/applications/SprPoP/gen/pop_music_ticks.h b/applications/SprPoP/gen/pop_music_ticks.h deleted file mode 100644 index a4fa21e..0000000 --- a/applications/SprPoP/gen/pop_music_ticks.h +++ /dev/null @@ -1,44 +0,0 @@ -/* - * pop_music_ticks.h — длительности треков в ТИКАХ СЦЕНЫ, СГЕНЕРИРОВАН. - * Набор: flac. Генератор: tools/pop_pack_music.py. - * Руками не править. - * - * Нужны там, где сценарий ждёт окончания реплики: у оригинала это - * `do { frame(1) } while (check_sound_playing())`, у нас — пауза длиной - * в НАШУ запись. Число обязано соответствовать набору, которым собрана - * сборка: одна и та же реплика на flac и mt32 разной длины (m53 — 985 - * тиков против 1044), и захардкоженная константа молча ломает сцену. - * - * Считается здесь, а не в C: blocks*40 переполняет uint16 на won - * (9855 порций). Отношение 40/57 — обратное к тому, которым шкала - * сцены переводит тики в порции насоса (57/40, pop_intro.c); берём - * именно его, чтобы длина реплики и ход шкалы считались одной - * арифметикой. Округление к ближайшему: вниз теряло по тику. - */ -#ifndef POP_MUSIC_TICKS_H -#define POP_MUSIC_TICKS_H - -#define POP_MUS_TICKS_24 214 /* regular_death */ -#define POP_MUS_TICKS_25 138 /* find_mirror_and_find_shadow */ -#define POP_MUS_TICKS_26 794 /* embrace */ -#define POP_MUS_TICKS_27 642 /* cutscene_before_2_4_6_12 */ -#define POP_MUS_TICKS_28 460 /* fight_death */ -#define POP_MUS_TICKS_29 300 /* meet_Jaffar */ -#define POP_MUS_TICKS_30 210 /* life_potion */ -#define POP_MUS_TICKS_32 438 /* end_level_4_and_killed_by_shadow */ -#define POP_MUS_TICKS_33 144 /* potion */ -#define POP_MUS_TICKS_35 623 /* cutscene_before_8_9 */ -#define POP_MUS_TICKS_36 881 /* out_of_time */ -#define POP_MUS_TICKS_37 196 /* guard_death_and_obtaining_the_sword */ -#define POP_MUS_TICKS_39 1116 /* floating */ -#define POP_MUS_TICKS_40 418 /* cutscene_not_much_time_left */ -#define POP_MUS_TICKS_41 732 /* story_5_level_end_and_merge_with_shadow */ -#define POP_MUS_TICKS_43 200 /* killed_Jaffar */ -#define POP_MUS_TICKS_50 831 /* story_2_princess */ -#define POP_MUS_TICKS_52 1714 /* story_4_Jaffar_leaves */ -#define POP_MUS_TICKS_53 985 /* story_3_Jaffar_enters */ -#define POP_MUS_TICKS_54 1448 /* intro_theme */ -#define POP_MUS_TICKS_55 947 /* story_1_absence */ -#define POP_MUS_TICKS_56 6923 /* won */ - -#endif /* POP_MUSIC_TICKS_H */ diff --git a/applications/SprPoP/src/pop_intro.c b/applications/SprPoP/src/pop_intro.c index 802f443..2ffe184 100644 --- a/applications/SprPoP/src/pop_intro.c +++ b/applications/SprPoP/src/pop_intro.c @@ -15,7 +15,6 @@ #include "pop_shadow.h" #include "pop_sfx.h" #include "pop_music.h" -#include "pop_music_ticks.h" /* POP_MUS_TICKS_ — длина реплик набора */ #include "pop_arc.h" #include "pv_arc.h" #include "pop_cutscene.h" @@ -60,9 +59,10 @@ * своего куска (например, всё поведение Джафара — от PV_DIALOG1_START). * 2) «ЖДЁМ КОНЦА РЕПЛИКИ» — PV_M50_END и PV_EXIT_START. Это целиком * длина НАШЕЙ записи, и она у каждого набора своя: m50 — 831 тик на - * flac против 867 на mt32, m53 — 985 против 1044. Поэтому берём их - * из POP_MUS_TICKS_, которые печатает упаковщик того набора, - * которым собрана сборка (gen/pop_music_tbl.h). + * flac против 867 на mt32, m53 — 985 против 1044. Раньше их + * печатал упаковщик в gen/pop_music_ticks.h, из-за чего EXE был + * привязан к набору; теперь они приезжают из индекса набора + * (MUS/mus.idx) при входе в сцену — см. ЯКОРЯ ниже. * * Всё, что стоит ПОСЛЕ реплики, сдвигается вместе с её концом — отсюда * сложения от PV_M50_END и PV_EXIT_START вместо литералов. */ @@ -72,46 +72,76 @@ enum { /* Конец m50: кадр становится 8. Прежний литерал 846 отвечал длине 834 * тика — на три больше нашей записи (831); расхождение осталось с * времён, когда длину подставляли руками. */ - PV_M50_END = PV_MUS_2_START + POP_MUS_TICKS_50, - PV_WAIT_END = PV_M50_END + 40, /* +frame(5) -> створка ворот */ - PV_GATE_END = PV_WAIT_END + 48, - PV_DOOR_END = PV_GATE_END + 24, - PV_TURN_START = PV_DOOR_END, - PV_WALK1_START = PV_DOOR_END + 40, - PV_DIALOG1_START = PV_WALK1_START + 48, /* Джафар входит — реплика m53 */ - /* Ниже — биты сценария внутри реплики Джафара, все от её начала. */ - PV_MUS_4_LOAD = PV_DIALOG1_START + 54, /* подкачка m52 */ - PV_WALK2_START = PV_DIALOG1_START + 176, - PV_DIALOG2_START = PV_DIALOG1_START + 416, - /* ПОДГОНКА ЗАКЛИНАНИЯ ПОД МУЗЫКУ СНЯТА (2026-08-28): теперь 0. - * - * История: сцена была render-bound — кадр рисовался дороже своего - * бюджета, кадровая ветка пейсинга догона не имела, и анимация шла - * ~49 тиков в секунду вместо 60 (замер 2026-08-27). Реплика Джафара - * при этом играет по РЕАЛЬНОМУ времени, поэтому кода приходила - * примерно на секунду раньше молнии, и жест целиком (замах, шаг - * назад, вспышка) сдвигали раньше сценария на подобранные на слух - * 100 тиков. - * - * Причину убрали: отрисовка разложена по кадровым интервалам - * (pv_restore_bg), сцена идёт по единственным часам — кадрам луча, — - * и подкачка трека оплачивается интервалом (pop_music_load_step). - * После этого подгонка стала вредной: молния била больше чем на - * секунду раньше коды (проверка пользователем 2026-08-28). Константу - * оставляем на месте — если запись другого набора (mt32/ogg) снова - * разъедется, крутить надо её, а не тайминги сценария. */ - PV_MAGIC_LEAD = 0, - PV_RAISE_START = PV_DIALOG1_START + 696 - PV_MAGIC_LEAD, - PV_STEPBACK_START = PV_DIALOG1_START + 703 - PV_MAGIC_LEAD, - PV_MAGIC_START = PV_DIALOG1_START + 822 - PV_MAGIC_LEAD, - /* Конец m53 — снова длина записи, а не бит сценария. */ - PV_EXIT_START = PV_DIALOG1_START + POP_MUS_TICKS_53, - PV_MUS_4_START = PV_EXIT_START + 42, /* +frame(6) -> «Джафар уходит» */ - PV_GLASS_DONE = PV_EXIT_START + 210, - PV_SLUMP_START = PV_EXIT_START + 273, - PV_ANIM_TICKS = PV_EXIT_START + 469 + PV_MAGIC_LEAD = 0 }; +/* ПОДГОНКА ЗАКЛИНАНИЯ ПОД МУЗЫКУ СНЯТА (2026-08-28): теперь 0. + * + * История: сцена была render-bound — кадр рисовался дороже своего + * бюджета, кадровая ветка пейсинга догона не имела, и анимация шла + * ~49 тиков в секунду вместо 60 (замер 2026-08-27). Реплика Джафара + * при этом играет по РЕАЛЬНОМУ времени, поэтому кода приходила + * примерно на секунду раньше молнии, и жест целиком (замах, шаг + * назад, вспышка) сдвигали раньше сценария на подобранные на слух + * 100 тиков. + * + * Причину убрали: отрисовка разложена по кадровым интервалам + * (pv_restore_bg), сцена идёт по единственным часам — кадрам луча, — + * и подкачка трека оплачивается интервалом (pop_music_load_step). + * После этого подгонка стала вредной: молния била больше чем на + * секунду раньше коды (проверка пользователем 2026-08-28). Константу + * оставляем на месте — если запись другого набора (mt32/ogg) снова + * разъедется, крутить надо её, а не тайминги сценария. */ + +/* ЯКОРЯ ШКАЛЫ. Четыре точки, от которых отсчитывается всё остальное; + * считаются ОДИН РАЗ при входе в сцену из длительностей набора + * (pop_music_info). Держать их статикой, а не тащить структуру времён + * через пять функций: восемь байт против переделки всех сигнатур, а + * сцена всё равно одна на запуск. + * + * Нет индекса или трека — длительность нулевая, и сцена просто проходит + * без пауз на реплики: ждать нечего, звука-то нет. */ +static uint16_t pv_m50_end; /* конец «принцесса ждёт» (m50) */ +static uint16_t pv_dialog1; /* Джафар входит — начало реплики m53 */ +static uint16_t pv_exit; /* конец m53: Джафар уходит */ +static uint16_t pv_anim_end; /* конец всей сцены */ + +/* Ниже — те же выражения, что были в enum: биты сценария отсчитываются от + * своего якоря и от записи НЕ зависят. */ +#define PV_M50_END pv_m50_end +#define PV_WAIT_END ((uint16_t)(pv_m50_end + 40)) +#define PV_GATE_END ((uint16_t)(pv_m50_end + 88)) +#define PV_DOOR_END ((uint16_t)(pv_m50_end + 112)) +#define PV_TURN_START PV_DOOR_END +#define PV_WALK1_START ((uint16_t)(pv_m50_end + 152)) +#define PV_DIALOG1_START pv_dialog1 +#define PV_MUS_4_LOAD ((uint16_t)(pv_dialog1 + 54)) +#define PV_WALK2_START ((uint16_t)(pv_dialog1 + 176)) +#define PV_DIALOG2_START ((uint16_t)(pv_dialog1 + 416)) +#define PV_RAISE_START ((uint16_t)(pv_dialog1 + 696 - PV_MAGIC_LEAD)) +#define PV_STEPBACK_START ((uint16_t)(pv_dialog1 + 703 - PV_MAGIC_LEAD)) +#define PV_MAGIC_START ((uint16_t)(pv_dialog1 + 822 - PV_MAGIC_LEAD)) +#define PV_EXIT_START pv_exit +#define PV_MUS_4_START ((uint16_t)(pv_exit + 42)) +#define PV_GLASS_DONE ((uint16_t)(pv_exit + 210)) +#define PV_SLUMP_START ((uint16_t)(pv_exit + 273)) +#define PV_ANIM_TICKS pv_anim_end + +/* Собрать шкалу под НАШ набор записей. 200 — цепочка битов сценария от + * конца m50 до входа Джафара (40+48+24+40+48), 469 — хвост после его + * ухода; оба от записи не зависят. */ +static void pv_timing_init(void) +{ + pop_mus_info_t m50, m53; + + (void)pop_music_info(POP_MUS_STORY_2, &m50); + (void)pop_music_info(POP_MUS_STORY_3, &m53); + pv_m50_end = (uint16_t)(PV_MUS_2_START + m50.ticks); + pv_dialog1 = (uint16_t)(pv_m50_end + 200); + pv_exit = (uint16_t)(pv_dialog1 + m53.ticks); + pv_anim_end = (uint16_t)(pv_exit + 469); +} + /* Возврат долга шкалы (см. цикл сцены): сколько порций насоса отдаём за один * кадр сцены и сколько их вообще имеет смысл копить. 1 порция — 11,7 мс; * 128 порций — полторы секунды, дальше догонять уже нечего. */ @@ -870,6 +900,7 @@ static int intro_pv_animated(void) jaffar_actor.visible = 0; jaffar_actor.step = 0; tick = 0; + pv_timing_init(); /* шкала под длительности НАШЕГО набора */ intro_skip_begin(&skip); while (tick < PV_ANIM_TICKS) { if (intro_skip_requested(&skip)) { diff --git a/applications/SprPoP/src/pop_kid.c b/applications/SprPoP/src/pop_kid.c index 3ed8877..f65b564 100644 --- a/applications/SprPoP/src/pop_kid.c +++ b/applications/SprPoP/src/pop_kid.c @@ -19,7 +19,6 @@ #include "pop_geom.h" #include "pop_level.h" /* POP_LEVEL_LAST — level 15 исключён */ #include "pop_cutscene.h" /* POP_T60 — тики оригинала в кадры луча */ -#include "pop_music_ticks.h" /* длина мелодии конца уровня */ #include #include #include @@ -38,14 +37,6 @@ int8_t knock; * оригинале, чтобы читы «следующий/предыдущий уровень» (levels_plan.md §4) * легли сюда же без переделки. */ uint8_t pop_next_level; -/* СКОЛЬКО ЛОГИЧЕСКИХ КАДРОВ ЕЩЁ ДЕРЖАТЬ УРОВЕНЬ, пока играет мелодия конца. - * У оригинала (seg003:387, play_level_2) уровень не сменяется, пока - * `check_sound_playing()`: экран пройденного уровня живёт с анимацией - * факелов ровно столько, сколько звучит трек. У нас признак «звучит» — - * pop_music_busy(), но он ложен при ВЫКЛЮЧЕННОЙ музыке, а оригинал - * выдерживает паузу и молча. Поэтому здесь взводится счётчик по длине - * записи (gen/pop_music_ticks.h), а главный цикл ждёт большего из двух. */ -uint8_t pop_endmus_left; /* ---- Атласы Kid (прямая адресация image>>3) ------------------------ * @@ -367,17 +358,13 @@ void play_seq(void) else if (ev == 4) { /* seg006:646. На четвёртом уровне уход за дверь — это * встреча с тенью, и трек там свой; на 13-м (Джафар) и - * 15-м оригинал молчит. Вместе с треком взводим - * задержку смены уровня (см. pop_endmus_left): кадров - * = длина записи в тиках -> кадры луча -> делённое на - * длину логического кадра (4 кадра луча вне боя). */ - if (pop_current_level == 4) { - pop_sfx_play(32); - pop_endmus_left = (uint8_t)(POP_T60(POP_MUS_TICKS_32) / 4); - } else if (pop_current_level != 13) { - pop_sfx_play(41); - pop_endmus_left = (uint8_t)(POP_T60(POP_MUS_TICKS_41) / 4); - } + * 15-м оригинал молчит. Держать уровень на экране, + * пока трек звучит, — забота главного цикла: он ждёт + * pop_music_active(), и своей задержки здесь больше + * нет (была по длине записи из gen/pop_music_ticks.h, + * из-за чего EXE зависел от набора музыки). */ + if (pop_current_level == 4) pop_sfx_play(32); + else if (pop_current_level != 13) pop_sfx_play(41); } } break; diff --git a/applications/SprPoP/src/pop_kid.h b/applications/SprPoP/src/pop_kid.h index 899dfc0..9f8d323 100644 --- a/applications/SprPoP/src/pop_kid.h +++ b/applications/SprPoP/src/pop_kid.h @@ -32,7 +32,6 @@ extern int8_t knock; * там рестарт того же уровня; загрузка следующего — levels_plan.md. */ extern uint8_t pop_next_level; /* Задержка смены уровня на время мелодии конца (см. pop_kid.c). */ -extern uint8_t pop_endmus_left; /* Загрузить npages атласов Kid (kid0..kid{npages-1}.atl). ДО initgraph. * 0 — OK, -1 — ошибка. */ diff --git a/applications/SprPoP/src/pop_music.c b/applications/SprPoP/src/pop_music.c index 40c08f4..714ba9e 100644 --- a/applications/SprPoP/src/pop_music.c +++ b/applications/SprPoP/src/pop_music.c @@ -13,14 +13,81 @@ #include #include #include +#include +#include /* gfx_w0_map/unmap — доступ к странице индекса */ #include "pop_music.h" -#include "pop_music_tbl.h" #include "pop_sfx.h" #include "pop_app.h" /* POP_APP_DEMO — в демке музыки нет */ #include "_pop_file.h" uint8_t pop_mus_page[POP_MUS_PAGES]; +/* --- ИНДЕКС НАБОРА ---------------------------------------------------- * + * + * Страница EMM с прочитанным mus.idx; 0 — индекса нет. В W2 от него + * остаются ровно эти два байта, сама таблица (228 Б) лежит в странице. + * + * Данные лежат со смещения POP_MUS_IDX_BASE, а не с начала страницы: + * gfx_w0_page_prepare пишет туда стабы прерываний (0x38 и 0x66), и с + * нуля они попали бы прямо в записи. Со смещением всё расходится, и + * страница подключается штатно — без запрета прерываний. */ +static uint8_t mus_idx_blk; +static uint8_t mus_idx_page; + +/* "MUS\mus.idx" — имя знает сам модуль (тот же приём, что в pop_arc.c: + * литерал лежит в rodata банка 9, и open() читает его, пока в W3 стоит + * этот банк). */ +static const char mus_idx_path[] = "MUS\\mus.idx"; + +int8_t pop_music_index_load(void) __banked +{ + uint8_t *w0 = (uint8_t *)POP_MUS_IDX_BASE; + uint8_t blk, page, ok; + int fd; + + if (mus_idx_page) return 0; + POP_PATH_CALL(fd, open(mus_idx_path, O_RDONLY)); + if (fd < 0) return -1; + blk = mem_alloc_pages(1); + if (!blk) { close(fd); return -1; } + page = mem_get_page(blk, 0); + if (!page || + bank_read_page(fd, page, POP_MUS_IDX_BASE, POP_MUS_IDX_SIZE) < 0) { + close(fd); + mem_free_block(blk); + return -1; + } + close(fd); + gfx_w0_page_prepare(page); /* стабы лягут вне данных, см. BASE */ + gfx_w0_map(page); + ok = (uint8_t)(memcmp(w0, "PMI1", 4) == 0 && + w0[4] == POP_MUS_COUNT && + w0[5] == POP_MUS_IDX_REC && + w0[6] == 7 && /* log2(128) — блок насоса */ + w0[7] == 1); /* u8 mono, CBL_FREQ_10K9 */ + gfx_w0_unmap(); + if (!ok) { mem_free_block(blk); return -1; } + mus_idx_blk = blk; + mus_idx_page = page; + return 0; +} + +int8_t pop_music_info(uint8_t id, pop_mus_info_t *out) __banked +{ + const uint8_t *rec; + const uint8_t *w0 = (const uint8_t *)POP_MUS_IDX_BASE; + + out->blocks = 0; + out->ticks = 0; + if (!mus_idx_page || id >= POP_MUS_COUNT) return -1; + gfx_w0_map(mus_idx_page); + rec = w0 + POP_MUS_IDX_HDR + (uint16_t)id * POP_MUS_IDX_REC; + out->blocks = (uint16_t)(rec[0] | ((uint16_t)rec[1] << 8)); + out->ticks = (uint16_t)(rec[2] | ((uint16_t)rec[3] << 8)); + gfx_w0_unmap(); + return out->blocks ? 0 : -1; +} + /* Два слота: играющий и подгружаемый. Своей таблицы страниц слот НЕ * держит — её и так хранит блок EMM, и mem_get_page отдаёт номер по * индексу. Резидентный pop_mus_page[] заполняется на pop_music_play, для @@ -53,6 +120,9 @@ static uint8_t ld_next; /* следующая страница к static uint8_t ld_half; /* 0 = впереди первая половина страницы */ static uint8_t ld_pages; static uint8_t ld_id; +/* Длина трека из индекса, снятая при открытии: лезть в страницу индекса + * на каждом шаге загрузки незачем — она не меняется. */ +static uint16_t ld_blocks; static void slot_release(uint8_t n) { @@ -126,13 +196,17 @@ static void load_abort(void) int8_t pop_music_load_begin(uint8_t id) __banked { char name[12]; + pop_mus_info_t info; uint8_t pages, n; if (ring_on) pop_music_ring_stop(); /* потоковый трек и слоты не дружат */ if (ld_busy) load_abort(); if (id >= POP_MUS_COUNT) return -1; - pages = pop_mus_tbl[id].pages; - if (!pages || pages > POP_MUS_PAGES) return -1; + /* Длину и число страниц знает ИНДЕКС НАБОРА, а не EXE: у mt32 те же + * треки другой длины (docs/music_runtime_index_plan.md). */ + if (pop_music_info(id, &info) != 0) return -1; + pages = pop_music_pages(info.blocks); + if (pages > POP_MUS_PAGES) return -1; /* Грузим ВСЕГДА в не текущий слот: играющий трек читает свои страницы * из прерывания, и отобрать их у него нельзя. */ @@ -152,6 +226,7 @@ int8_t pop_music_load_begin(uint8_t id) __banked ld_half = 0; ld_pages = pages; ld_id = id; + ld_blocks = info.blocks; return 0; } @@ -183,7 +258,7 @@ int8_t pop_music_load_step(void) __banked close(ld_fd); ld_busy = 0; slot_pages[ld_slot] = ld_pages; - slot_blocks[ld_slot] = pop_mus_tbl[ld_id].blocks; + slot_blocks[ld_slot] = ld_blocks; slot_id[ld_slot] = ld_id; mus_ready = (uint8_t)(ld_slot + 1); return 1; @@ -229,7 +304,7 @@ int8_t pop_music_stream(uint8_t id) __banked if (n != mus_cur) slot_release(mus_cur); mus_cur = n; slot_pages[n] = ld_pages; - slot_blocks[n] = pop_mus_tbl[id].blocks; + slot_blocks[n] = ld_blocks; slot_id[n] = id; pop_mus_id = id; pop_mus_pg = 0; @@ -283,9 +358,15 @@ void pop_music_ring_stop(void) __banked int8_t pop_music_ring_start(uint8_t id) __banked { char name[12]; + pop_mus_info_t info; uint8_t i, page; - if (id >= POP_MUS_COUNT || !pop_mus_tbl[id].pages) return -1; + if (id >= POP_MUS_COUNT || pop_music_info(id, &info) != 0) return -1; + /* Потоковый трек НЕ ограничен POP_MUS_PAGES: он и не помещается в + * память целиком, ради того и кольцо. Предел здесь — восьмибитные + * счётчики страниц (у mt32 финальная тема на 82 страницы против 78 + * у flac). */ + if (pop_music_pages(info.blocks) > 255) return -1; pop_music_free(); /* обычные слоты кольцу не нужны */ pop_music_ring_stop(); mus_name(name, id); @@ -294,8 +375,8 @@ int8_t pop_music_ring_start(uint8_t id) __banked ring_fd_open = 1; ring_blk = mem_alloc_pages(RING_PAGES); if (!ring_blk) { ring_close_file(); return -1; } - ring_file_pages = pop_mus_tbl[id].pages; - ring_total = pop_mus_tbl[id].blocks; + ring_file_pages = (uint8_t)pop_music_pages(info.blocks); + ring_total = info.blocks; /* Заполняем кольцо целиком: старт с полным буфером даёт девять секунд * форы, и первая же тяжёлая сцена финала не догонит насос. */ for (i = 0; i < RING_PAGES; i++) { @@ -375,3 +456,24 @@ uint8_t pop_music_busy(void) __banked { return (uint8_t)(pop_mus_left != 0); } + +/* ТРЕК В РАБОТЕ — заявка лежит, идёт загрузка или он звучит. + * + * Одного pop_music_busy() там, где ждут КОНЦА мелодии, мало: между + * заявкой (pop_sfx_play кладёт pop_mus_req) и первым звуком проходит + * 190-230 мс — открытие файла и чтение первой полустраницы (замер и + * разбор — docs/sound_plan.md §9). В этой щели busy ещё ложен, и + * ожидание закончилось бы, не начавшись. + * + * Три признака подряд закрывают её без единой подобранной константы: + * pop_music_service обнуляет req и тут же взводит ld_busy в load_begin, + * а тот держится до самой записи pop_mus_left. Снаружи переход + * «запрошен -> грузится -> звучит» непрерывен, потому что между этими + * операторами главный цикл не выполняется. + * + * И наоборот: если трека нет или память кончилась, все три ложны сразу — + * ждать нечего, как и должно быть. */ +uint8_t pop_music_active(void) __banked +{ + return (uint8_t)(pop_mus_req || ld_busy || pop_mus_left); +} diff --git a/applications/SprPoP/src/pop_music.h b/applications/SprPoP/src/pop_music.h index 9182d4a..9a63c6d 100644 --- a/applications/SprPoP/src/pop_music.h +++ b/applications/SprPoP/src/pop_music.h @@ -28,6 +28,47 @@ * либо резать, либо стримить, и это отдельная задача. */ #define POP_MUS_PAGES 20 +/* --- ИНДЕКС НАБОРА (MUS/mus.idx, формат PMI1) ------------------------- * + * + * Длины треков и длительности реплик приезжают С ДИСКА, а не из EXE: так + * набор записей (flac/mp3/ogg/mt32) меняется без перекомпиляции игры. + * Формат пишет tools/pop_idx.py, он же покрыт make test-tools. + * + * Индекс живёт в ОДНОЙ странице EMM, а не в W2: таблица на 57 записей — + * 228 байт, а свободной кучи в резиденте порядка двух сотен. Наружу + * указатель на EMM не выдаётся: pop_music_info копирует запись в буфер + * вызывающего, на короткое время подключая страницу в W0. */ +#define POP_MUS_COUNT 57 /* пространство id оригинала, 0..56 */ +#define POP_MUS_IDX_REC 4 /* blocks (u16) + ticks60 (u16) */ +#define POP_MUS_IDX_HDR 16 +#define POP_MUS_IDX_SIZE 512 /* сектор DSS */ +/* Индекс кладётся в страницу СО СМЕЩЕНИЯ 0x100, а не с нуля. Причина в + * gfx_w0_page_prepare: он делает страницу безопасной в W0, записывая в + * неё переход на IRQ-восстановитель (0x38) и RETN для NMI (0x66). С + * нуля эти байты легли бы прямо в записи (id 10 и 21) и испортили + * индекс; со смещением 0x100 стабы и данные не пересекаются, и работает + * штатная защита от прерываний, а не запрет их. Тот же приём и по той + * же причине — в pop_config.c (CFG_BASE). */ +#define POP_MUS_IDX_BASE 0x100 + +typedef struct { + uint16_t blocks; /* длина трека в порциях насоса по 128 байт */ + uint16_t ticks; /* та же длина в тиках сцены (60 Гц оригинала) */ +} pop_mus_info_t; + +/* Поднять индекс (один раз за запуск). 0 — ок, -1 — нет файла либо он + * не той версии; во втором случае музыки не будет, но игра работает. */ +int8_t pop_music_index_load(void) __banked; + +/* Запись трека в буфер вызывающего. -1 — индекса нет, id вне диапазона + * или трека нет в наборе (тогда поля нулевые). */ +int8_t pop_music_info(uint8_t id, pop_mus_info_t *out) __banked; + +/* Сколько EMM-страниц занимает трек такой длины. Считается, а НЕ хранится + * в индексе: дублирование однажды разъедется с blocks. */ +#define pop_music_pages(blocks) \ + (uint16_t)(((blocks) >> 7) + (((blocks) & 127) != 0)) + /* Номера треков — нумерация оригинала (SDLPoP data/music/names.txt). */ #define POP_MUS_INTRO 54 /* тема заставки, звучит на титрах */ #define POP_MUS_STORY_1 55 /* «In the absence of the Sultan...» */ @@ -102,4 +143,8 @@ void pop_music_ring_stop(void) __banked; /* Трек ещё звучит? 0 — доиграл или не запускался. */ uint8_t pop_music_busy(void) __banked; +/* «Трек в работе»: заявка/загрузка/звучание. Для мест, где ждут КОНЦА + * мелодии, — busy там ложен в окне запуска (см. pop_music.c). */ +uint8_t pop_music_active(void) __banked; + #endif /* POP_MUSIC_H */ diff --git a/applications/SprPoP/src/sprpop.c b/applications/SprPoP/src/sprpop.c index 60e1d37..7ed525f 100644 --- a/applications/SprPoP/src/sprpop.c +++ b/applications/SprPoP/src/sprpop.c @@ -366,7 +366,6 @@ int main(void) * девелоперская сборка может до title держать дворец LEVEL=11. */ if (pop_app_state == POP_APP_DEMO) { pop_next_level = 0; - pop_endmus_left = 0; pop_seamless = 0; pop_checkpoint = 0; if (pop_level_switch() != 0) { @@ -787,14 +786,22 @@ int main(void) * сколько звучит трек конца уровня; мы же уходили на смену * уровня сразу и обрывали мелодию на первых нотах. * - * «Звучит» проверяем двумя способами: pop_music_busy() пока - * трек играет и счётчик pop_endmus_left по длине записи (его - * взводит play_seq вместе с самим треком) — иначе с - * ВЫКЛЮЧЕННОЙ музыкой паузы не было бы вовсе, а оригинал - * выдерживает её и молча. */ - if (pop_endmus_left) pop_endmus_left--; - if (!pop_endmus_left && !pop_music_busy() && - pop_level_change(back) != 0) { + * «Звучит» — это pop_music_active(), а не busy: между заявкой + * на трек и первой нотой проходит 190-230 мс на открытие + * файла и чтение первой полустраницы, и в этой щели busy ещё + * ложен (замер — docs/sound_plan.md §9). Раньше щель + * закрывал счётчик по длине записи, но он же тянул за собой + * зависимость EXE от набора музыки и жёсткий делитель `/4`, + * враньё которого вылезало в быстрых режимах (там кадр 3 + * луча, а не 4). Состояние «заявка/загрузка/звучание» + * закрывает щель без единой константы. + * + * ЗВУК ВЫКЛЮЧЕН — ПАУЗЫ НЕТ, и это тоже верно: pop_sfx_play + * не кладёт заявку при !pop_snd_ok, все три признака ложны, и + * уровень меняется сразу. Ровно так в оригинале, где трек + * ставится под `if (is_sound_on)` (seg006:651), а ожидание — + * `check_sound_playing()` (seg003:387). */ + if (!pop_music_active() && pop_level_change(back) != 0) { failed = 1; quit = 1; break; } } diff --git a/applications/SprPoP/src/sprpop_cold.c b/applications/SprPoP/src/sprpop_cold.c index ed85971..422d69f 100644 --- a/applications/SprPoP/src/sprpop_cold.c +++ b/applications/SprPoP/src/sprpop_cold.c @@ -1157,6 +1157,10 @@ int pop_boot(void) __banked pop_guard_load(); /* атласы стража (GUARD\\g0..g4) */ pop_shadow_load(); /* запечённый набор Тени — один раз на запуск */ pop_sfx_init(); /* звук: 8 страниц оцифровки (вывод пока молчит) */ + /* Индекс музыкального набора — одна страница EMM. Без него музыки не + * будет (pop_music_info вернёт -1 на любой трек), но игра работает: + * это необязательный ресурс, как и сами треки. */ + (void)pop_music_index_load(); pop_settings_apply(1); /* CFG -> speed/cheats/CBL */ if (pop_kid_load(KID_PAGES) != 0) { /* ВСЕ страницы: бег+стоп+разворот+присед+прыжок */ puts("kid atlas load failed"); diff --git a/applications/SprPoP/tools/pop_idx.py b/applications/SprPoP/tools/pop_idx.py index 41f7ae4..212c227 100644 --- a/applications/SprPoP/tools/pop_idx.py +++ b/applications/SprPoP/tools/pop_idx.py @@ -30,6 +30,30 @@ ABI SDCC z80 (uint8 + uint16 + uint16 без выравнивания). Заг молча разъедет всю раскладку; на стороне C это проверяется статически. Нулевая `len` означает, что у этого id нет оцифровки (это музыка). + +PMI1 — индекс МУЗЫКИ, тоже один сектор: + + 0..3 'PMI1' магия и версия + 4 57 число плотных записей, id 0..56 + 5 4 размер записи + 6 7 log2 размера блока + 7 1 формат PCM + 8..15 0 резерв + 16..243 57 записей по 4 байта: blocks (u16), ticks60 (u16) + 244..511 0 добивка до сектора + +Числа страниц здесь НЕТ намеренно: это производная от `blocks`, и +дублирование однажды разъедется с ним. Загрузчик считает сам: + + pages = blocks / 128 + ((blocks & 127) != 0) + +`ticks60` — длительность в тиках сцены (60 Гц оригинала), нужная там, где +сценарий ждёт конца реплики. Считается из blocks отношением 40/57 — +обратным к тому, которым шкала сцены переводит тики в порции насоса. +Хранится ОТДЕЛЬНО, а не выводится в рантайме: так момент окончания +реплики можно будет уточнить независимо от технического хвоста PCM. + +Нулевой `blocks` означает, что трека с таким id в наборе нет. """ import struct @@ -124,3 +148,83 @@ def _check_entry(i, page, off, ln, pages): if last >= pages: raise IdxError("запись %d выходит за набор: кончается в стр.%d из %d" % (i, last, pages)) + + +# --- PMI1: музыка ---------------------------------------------------------- +MUS_MAGIC = b"PMI1" +MUS_COUNT = 57 # то же пространство id оригинала +MUS_REC = 4 +MUS_HDR = 16 +# Потоковый трек (финальная тема) заведомо длиннее обычных, и предел ему +# ставят не страницы загрузчика, а восьмибитные счётчики страниц в коде. +MUS_MAX_PAGES = 255 + + +def mus_ticks(blocks): + """Длительность в тиках сцены (60 Гц) по длине в блоках. + + Отношение 40/57 — обратное к тому, которым шкала сцены переводит тики + в порции насоса (57/40, pop_intro.c). Берём именно его, а не точное + 10937,5/128/60: длина реплики и ход шкалы обязаны считаться ОДНОЙ + арифметикой, иначе они разъедутся на длинных треках. Округление к + ближайшему: отбрасывание вниз теряло по тику на реплику. + """ + return (blocks * 40 + 28) // 57 + + +def mus_pack(entries): + """Собрать PMI1 из 57 записей (blocks, ticks60).""" + if len(entries) != MUS_COUNT: + raise IdxError("записей должно быть %d, а не %d" + % (MUS_COUNT, len(entries))) + out = bytearray(SECTOR) + out[0:4] = MUS_MAGIC + out[4] = MUS_COUNT + out[5] = MUS_REC + out[6] = BLOCK_LOG2 + out[7] = PCM_U8_MONO_10K9 + for i, (blocks, ticks) in enumerate(entries): + _check_mus(i, blocks, ticks) + struct.pack_into(" записи. Проверки те же, что у Z80.""" + if len(data) != SECTOR: + raise IdxError("размер %d, а должен быть сектор %d" % (len(data), SECTOR)) + if data[0:4] != MUS_MAGIC: + raise IdxError("не PMI1: %r" % (bytes(data[0:4]),)) + if data[4] != MUS_COUNT: + raise IdxError("записей %d, ожидалось %d" % (data[4], MUS_COUNT)) + if data[5] != MUS_REC: + raise IdxError("размер записи %d, ожидался %d" % (data[5], MUS_REC)) + if data[6] != BLOCK_LOG2: + raise IdxError("log2 блока %d, ожидался %d" % (data[6], BLOCK_LOG2)) + if data[7] != PCM_U8_MONO_10K9: + raise IdxError("формат PCM %d — насос умеет только %d" + % (data[7], PCM_U8_MONO_10K9)) + entries = [] + for i in range(MUS_COUNT): + blocks, ticks = struct.unpack_from(" MUS_MAX_PAGES: + raise IdxError("запись %d: %d блоков — больше %d страниц, счётчики " + "страниц в коде восьмибитные" + % (i, blocks, MUS_MAX_PAGES)) + if ticks == 0: + raise IdxError("запись %d: трек есть, а длительность нулевая" % i) diff --git a/applications/SprPoP/tools/pop_pack_music.py b/applications/SprPoP/tools/pop_pack_music.py index 1466e13..09920fa 100644 --- a/applications/SprPoP/tools/pop_pack_music.py +++ b/applications/SprPoP/tools/pop_pack_music.py @@ -23,7 +23,7 @@ open 51,4 мс против 32,6 мс на чтение 16 КБ. Этот же блоками по 128 байт, и остаток «не полного блока» ему пришлось бы досчитывать в прерывании. -Длина трека уходит в pop_music_tbl.h блоками по 128 байт, а не байтами: +Длина трека уходит в MUS/mus.idx блоками по 128 байт, а не байтами: самый длинный трек (won, 115 с) — это 1,2 МБ, что не влезает в uint16, а в блоках даёт 9855. 32-битной арифметики на Z80 избегаем (см. memory avoid_32bit_arith_z80). @@ -35,14 +35,14 @@ from pathlib import Path import sys as _sys, os as _os _sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__))) import paths as P # раскладка каталогов SprPoP (tools/paths.py) +import pop_idx # формат дискового индекса PMI1 (tools/pop_idx.py) # Формат источника выбирается ключом --format (по умолчанию flac — он же # лучший по качеству исходник). Каталог и расширение знает tools/paths.py. FMT = "flac" SRC_DIR, SRC_EXT = P.music_src(FMT) OUT_DIR = P.atl("music") -OUT_H = P.GEN / "pop_music_tbl.h" -OUT_TICKS = P.GEN / "pop_music_ticks.h" +OUT_IDX = "mus.idx" # дисковый индекс набора, ложится в MUS/ рядом с треками CBL_RATE = 10937.5 # CBL_FREQ_10K9 — частота железа, не 11 000 PAGE = 16384 # EMM-страница @@ -125,98 +125,19 @@ def main() -> None: print(f" {tid:2d} {TRACKS[tid]:<40} {chunks:2d} стр, " f"{blocks * BLOCK / 1024:7.1f} КБ, {blocks * BLOCK / CBL_RATE:5.1f} с") - max_id = max(t for t, *_ in rows) - lines = [ - "/*", - " * pop_music_tbl.h — каталог музыкальных треков, СГЕНЕРИРОВАН.", - f" * Источник: assets/orig/PoP1_DOS_music/{P.MUSIC_FORMATS[FMT][0]}" - " (записи DOS-версии).", - " * Генератор: tools/pop_pack_music.py. Руками не править.", - " *", - " * Нумерация id — оригинала (SDLPoP data/music/names.txt). pages —", - " * сколько кусков m_.bin лежит на диске, blocks — длина трека", - " * в порциях насоса по 128 байт (в байтах не влезло бы в uint16).", - " *", - " * Длительности реплик в тиках вынесены в pop_music_ticks.h: таблица", - " * ниже — 171 байт ДАННЫХ, и модулю, которому нужна одна константа,", - " * платить за неё копией таблицы незачем (поймано на pop_intro.c:", - " * +172 байта в банк 11).", - " *", - " * POP_MUS_TICKS_ — та же длина в ТИКАХ СЦЕНЫ (60 Гц оригинала).", - " * Нужна там, где сценарий ждёт окончания реплики: у оригинала это", - " * `do { frame(1) } while (check_sound_playing())`, у нас — пауза", - " * длиной в нашу запись. Считается ЗДЕСЬ, а не в C, по двум", - " * причинам: blocks*40 переполняет uint16 на won (9855 порций), и", - " * число обязано соответствовать НАБОРУ, которым собрали (flac и", - " * mt32 — разные исполнения разной длины).", - " *", - " * Отношение 40/57 — обратное к тому, которым шкала сцены переводит", - " * тики в порции насоса (57/40, см. pop_intro.c). Берём именно его,", - " * а не точное 10937,5/128/60, чтобы длина реплики и ход шкалы", - " * считались одной и той же арифметикой. Округление — к ближайшему", - " * (+28 перед делением): отбрасывание вниз теряло по тику на реплику.", - " */", - "#ifndef POP_MUSIC_TBL_H", - "#define POP_MUSIC_TBL_H", - "", - "#include ", - "", - f"#define POP_MUS_COUNT {max_id + 1}", - f"#define POP_MUS_BLOCK {BLOCK}", - f"#define POP_MUS_MAX_PAGES {max(c for _, _, c, _ in rows)}", - "", - ] - lines += [ - "", - "typedef struct {", - " uint8_t pages; /* 0 — трека нет */", - " uint16_t blocks; /* длина в порциях по 128 байт */", - "} pop_mus_ent_t;", - "", - "static const pop_mus_ent_t pop_mus_tbl[POP_MUS_COUNT] = {", - ] - by_id = {t: (c, b, n) for t, n, c, b in rows} - for i in range(max_id + 1): - if i in by_id: - c, b, n = by_id[i] - lines.append(f" {{ {c:2d}, {b:5d} }}, /* {i:2d} {n} */") - else: - lines.append(f" {{ 0, 0 }}, /* {i:2d} */") - lines += ["};", "", "#endif /* POP_MUSIC_TBL_H */", ""] - OUT_H.write_text("\n".join(lines)) - - # Отдельный заголовок ТОЛЬКО с длительностями реплик. Данных в нём нет, - # поэтому его может включать кто угодно, ничего не утаскивая в свой банк. - tk = [ - "/*", - " * pop_music_ticks.h — длительности треков в ТИКАХ СЦЕНЫ, СГЕНЕРИРОВАН.", - f" * Набор: {P.MUSIC_FORMATS[FMT][0]}. Генератор: tools/pop_pack_music.py.", - " * Руками не править.", - " *", - " * Нужны там, где сценарий ждёт окончания реплики: у оригинала это", - " * `do { frame(1) } while (check_sound_playing())`, у нас — пауза длиной", - " * в НАШУ запись. Число обязано соответствовать набору, которым собрана", - " * сборка: одна и та же реплика на flac и mt32 разной длины (m53 — 985", - " * тиков против 1044), и захардкоженная константа молча ломает сцену.", - " *", - " * Считается здесь, а не в C: blocks*40 переполняет uint16 на won", - " * (9855 порций). Отношение 40/57 — обратное к тому, которым шкала", - " * сцены переводит тики в порции насоса (57/40, pop_intro.c); берём", - " * именно его, чтобы длина реплики и ход шкалы считались одной", - " * арифметикой. Округление к ближайшему: вниз теряло по тику.", - " */", - "#ifndef POP_MUSIC_TICKS_H", - "#define POP_MUSIC_TICKS_H", - "", - ] - for tid, name, _c, blocks in rows: - tk.append(f"#define POP_MUS_TICKS_{tid:<3d} {(blocks * 40 + 28) // 57:5d}" - f" /* {name} */") - tk += ["", "#endif /* POP_MUSIC_TICKS_H */", ""] - OUT_TICKS.write_text("\n".join(tk)) + # ДИСКОВЫЙ ИНДЕКС набора: длины треков и длительности реплик. Пока + # печатаются и заголовки выше — переход на runtime идёт по шагам, и + # индекс обязан описывать ровно то же, что они (docs/music_runtime_index_plan.md). + # Тики считает pop_idx.mus_ticks — общая формула для обеих сторон. + if len(rows) != len(TRACKS): + print(f" ВНИМАНИЕ: упаковано {len(rows)} треков из {len(TRACKS)} — " + f"индекс описывает только их") + entries = [(0, 0)] * pop_idx.MUS_COUNT + for tid, _name, _chunks, blocks in rows: + entries[tid] = (blocks, pop_idx.mus_ticks(blocks)) + (OUT_DIR / OUT_IDX).write_bytes(pop_idx.mus_pack(entries)) + print(f"индекс: {OUT_DIR / OUT_IDX}") print(f"итого {total / 1024:.0f} КБ в {OUT_DIR}") - print(f"каталог: {OUT_H}") - print(f"тики: {OUT_TICKS}") if __name__ == "__main__": diff --git a/applications/SprPoP/tools/tests/test_pop_idx.py b/applications/SprPoP/tools/tests/test_pop_idx.py index 377665d..6650421 100644 --- a/applications/SprPoP/tools/tests/test_pop_idx.py +++ b/applications/SprPoP/tools/tests/test_pop_idx.py @@ -140,5 +140,105 @@ class SndPackGuards(unittest.TestCase): X.snd_pack(e, 1) +def mus_empty(): + return [(0, 0)] * X.MUS_COUNT + + +class MusFormat(unittest.TestCase): + """PMI1: раскладка байтов индекса музыки.""" + + def test_size_is_one_sector(self): + self.assertEqual(len(X.mus_pack(mus_empty())), X.SECTOR) + + def test_header_fields(self): + d = X.mus_pack(mus_empty()) + self.assertEqual(d[0:4], b"PMI1") + self.assertEqual(d[4], 57) + self.assertEqual(d[5], 4) + self.assertEqual(d[6], 7) + self.assertEqual(d[7], 1) + self.assertEqual(d[8:16], b"\0" * 8) + + def test_entry_is_little_endian_at_fixed_offset(self): + e = mus_empty() + e[41] = (1043, 732) + d = X.mus_pack(e) + at = X.MUS_HDR + 41 * X.MUS_REC + self.assertEqual(d[at:at + 2], b"\x13\x04") # blocks LE + self.assertEqual(d[at + 2:at + 4], b"\xdc\x02") # ticks60 LE + self.assertEqual(struct.unpack_from("