Музыка: PCM-трек через CBL, первый экран истории озвучен

Путь C из sound_plan.md вместо ранее выбранного A (ноты спикера на AY).
Причина смены: появились готовые записи DOS-версии, и строка «нужен
синтезатор на хосте», из-за которой C считался дорогим, отпала —
осталось `ffmpeg -ac 1 -ar 10937 -f u8`.  Оцифровки музыки в самой игре
нет вовсе: digisnd*.dat содержит только эффекты, музыка лежит MIDI под
Adlib, так что конвертировать ресурс игры всё равно было не из чего.

* toolchain/pop_pack_music.py — запись flac -> сырой u8 на 10 937,5 Гц
  (частота CBL, чтобы её не переключать никогда), нарезка на куски
  MUS\m<id>_<nn>.bin по 16 КБ: bank_load_file читает файл только целиком
  и только в одну страницу.  Каталог -> pop_music_tbl.h; длина трека
  хранится ПОРЦИЯМИ по 128 байт — 169 КБ в uint16 не влезает, 1350
  блоков влезает (32-бит арифметики на Z80 избегаем).
* pop_music.c (банк 9) — загрузка трека в EMM и курсор; pop_sfx_fill
  (резидент) получил третий источник: эффект важнее музыки, музыка
  важнее тишины.  Эффект музыку не сбрасывает — её курсор стоит, пока
  тот доигрывает, и она продолжается с места.
* story_1_absence звучит на первом экране истории, где была тишина.
  Грузим ПОСЛЕ сборки картинки: чтение идёт через ESTEX, и при открытом
  CBL насос не успел бы долить блок.

Проверено записью звука из MAME: корреляция огибающих с эталоном 0,836
при сдвиге 1,8 с, RMS 22,6 против 18,3 (разница — 8-битное квантование).
Эффекты двери в PV-сцене после трека звучат по-прежнему.

Бюджет: резидент +83 Б кода и +25 Б данных, запас W2 151 Б (кучи в
приложении нет, malloc не слинкован); банк 9 занят на 88 %.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 15:21:22 +03:00
parent ffb59b3470
commit 34ba5f71d5
20 changed files with 531 additions and 10 deletions
+6
View File
@@ -121,3 +121,9 @@ mame/
# .git внутри (в коммите стали бы битыми gitlink-ссылками). # .git внутри (в коммите стали бы битыми gitlink-ссылками).
docs/extra/ docs/extra/
docs/sources/ docs/sources/
# Записи музыки DOS-версии PoP (43 МБ в четырёх форматах) — ИСТОЧНИК для
# toolchain/pop_pack_music.py, а не ресурс сборки: на диск игры уходят уже
# упакованные poc/res/music/*.bin, и они в репозитории есть. Если понадобится
# перегенерировать музыку — положить сюда PoP1_DOS_music (flac).
applications/PoP/PoP1_DOS_music/
+53 -1
View File
@@ -1,6 +1,7 @@
# Звук в порте PoP — разбор и план # Звук в порте PoP — разбор и план
Дата: 2026-08-20. Статус: **PCM-эффекты реализованы; музыка/AY не реализованы.** Дата: 2026-08-20, музыка дописана 2026-08-25. Статус: **PCM-эффекты
реализованы; музыка — путь C (PCM через CBL), первый трек играет.**
Задача пользователя: добавить звук. Приоритет — эффекты; музыку, если Задача пользователя: добавить звук. Приоритет — эффекты; музыку, если
найдётся способ. Эффекты — **обязательно WAV, а не PC-спикер** найдётся способ. Эффекты — **обязательно WAV, а не PC-спикер**
@@ -682,3 +683,54 @@ play_next_sound() seg000:1304 — раз в кадр решает, зап
и малое зелья (`30/33`), Shadow (`32`), победа/меч (`37`), перо (`39`), и малое зелья (`30/33`), Shadow (`32`), победа/меч (`37`), перо (`39`),
конец уровня (`41`) и победа над Jaffar (`43`). Вызовы и AY-проигрыватель конец уровня (`41`) и победа над Jaffar (`43`). Вызовы и AY-проигрыватель
для них ещё не реализованы; наличие всех PCM-эффектов эту задачу не закрывает. для них ещё не реализованы; наличие всех 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>_<nn>.bin` по 16 КБ
(bank_load_file читает файл только целиком и только в одну страницу) +
каталог `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 не помещается: финал придётся либо
резать, либо стримить кусками по ходу.
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
+22 -4
View File
@@ -57,7 +57,7 @@ BANK8_SRCS := roomtest_cold.c pop_level_cold.c pop_kboot.c pop_guard_cold.c \
# Банк 9 — оболочка: menu/settings/config/font, автомат, global timer, # Банк 9 — оболочка: menu/settings/config/font, автомат, global timer,
# title, attract-demo и таблица рекордов. Здесь остаются небольшие редкие # title, attract-demo и таблица рекордов. Здесь остаются небольшие редкие
# верхнеуровневые точки входа, но не рендеры крупных заставок. # верхнеуровневые точки входа, но не рендеры крупных заставок.
BANK9_SRCS := pop_menu.c pop_status.c pop_config.c pop_app.c pop_timer.c pop_ui.c pop_title.c pop_demo.c \ BANK9_SRCS := pop_menu.c pop_status.c pop_music.c pop_config.c pop_app.c pop_timer.c pop_ui.c pop_title.c pop_demo.c \
pop_flow.c pop_hof.c pop_flow.c pop_hof.c
# Банк 10 — единый владелец экранных палитр/fade. Он намеренно отделён от UI: # Банк 10 — единый владелец экранных палитр/fade. Он намеренно отделён от UI:
# его будут вызывать и игровой lifecycle, и все cutscene-ветки. # его будут вызывать и игровой lifecycle, и все cutscene-ветки.
@@ -173,6 +173,15 @@ SND_DIR := $(CURDIR)/../poc/res/sound
# Список ОБЯЗАН совпадать: недостающий файл — это молча пропавшие эффекты. # Список ОБЯЗАН совпадать: недостающий файл — это молча пропавшие эффекты.
SND_DATA := $(foreach n,0 1 2 3 4 5 6 7 8 9,$(SND_DIR)/s$(n).bin) SND_DATA := $(foreach n,0 1 2 3 4 5 6 7 8 9,$(SND_DIR)/s$(n).bin)
# МУЗЫКА: записи DOS-версии, приведённые к формату CBL и порезанные на
# страницы EMM (toolchain/pop_pack_music.py). Пока в игре звучит один
# трек — story_1_absence на первом экране intro; список расширяется
# добавлением номеров в MUS_IDS, имена кусков задаёт упаковщик.
MUS_DIR := $(CURDIR)/../poc/res/music
MUS_IDS := 55
MUS_CHUNKS := 00 01 02 03 04 05 06 07 08 09 10
MUS_DATA := $(foreach i,$(MUS_IDS),$(foreach c,$(MUS_CHUNKS),$(MUS_DIR)/m$(i)_$(c).bin))
# UI: два исходных шрифта SDLPoP в одном EMM-атласе. Файл короче страницы: # UI: два исходных шрифта SDLPoP в одном EMM-атласе. Файл короче страницы:
# хвост использует menu для временного snapshot палитры (без расхода W2). # хвост использует menu для временного snapshot палитры (без расхода W2).
FONT_DIR := $(CURDIR)/../poc/res/font FONT_DIR := $(CURDIR)/../poc/res/font
@@ -203,7 +212,7 @@ PV_DATA := $(PV_DIR)/story.pal $(PV_DIR)/pv.pal \
$(PV_ANIM) $(PV_ANIM)
EXTRA_DATA := $(BG_DATA) $(KID_DATA) $(KID_BIN) $(GUARD_DATA) $(SKEL_DATA) \ EXTRA_DATA := $(BG_DATA) $(KID_DATA) $(KID_BIN) $(GUARD_DATA) $(SKEL_DATA) \
$(VIZIER_DATA) $(SHADOW_DATA) $(SND_DATA) $(FONT_DATA) $(TITLE_DATA) $(PV_DATA) $(LVL_DATA) $(VIZIER_DATA) $(SHADOW_DATA) $(SND_DATA) $(MUS_DATA) $(FONT_DATA) $(TITLE_DATA) $(PV_DATA) $(LVL_DATA)
# Образ HDD для MAME: ресурсы раскладываются ПО КАТАЛОГАМ (8.3, как в DSS), # Образ HDD для MAME: ресурсы раскладываются ПО КАТАЛОГАМ (8.3, как в DSS),
# чтобы корень диска не зарастал десятками .atl. # чтобы корень диска не зарастал десятками .atl.
# BG\ — фон (env/wall/fore/pot) # BG\ — фон (env/wall/fore/pot)
@@ -221,6 +230,7 @@ HDD_PACK_ARGS := $(EXAMPLE).exe \
$(foreach f,$(VIZIER_DATA),VIZIER:$(f)) \ $(foreach f,$(VIZIER_DATA),VIZIER:$(f)) \
$(foreach f,$(SHADOW_DATA),SHADOW:$(f)) \ $(foreach f,$(SHADOW_DATA),SHADOW:$(f)) \
$(foreach f,$(SND_DATA),SND:$(f)) \ $(foreach f,$(SND_DATA),SND:$(f)) \
$(foreach f,$(MUS_DATA),MUS:$(f)) \
$(foreach f,$(FONT_DATA),FONT:$(f)) \ $(foreach f,$(FONT_DATA),FONT:$(f)) \
$(foreach f,$(TITLE_DATA),TITLE:$(f)) \ $(foreach f,$(TITLE_DATA),TITLE:$(f)) \
$(foreach f,$(PV_DATA),PV:$(f)) \ $(foreach f,$(PV_DATA),PV:$(f)) \
@@ -241,6 +251,7 @@ SKEL_STAMP := $(RESOURCE_STAMP_DIR)/skel
VIZIER_STAMP := $(RESOURCE_STAMP_DIR)/vizier VIZIER_STAMP := $(RESOURCE_STAMP_DIR)/vizier
SHADOW_STAMP := $(RESOURCE_STAMP_DIR)/shadow SHADOW_STAMP := $(RESOURCE_STAMP_DIR)/shadow
SOUND_STAMP := $(RESOURCE_STAMP_DIR)/sound SOUND_STAMP := $(RESOURCE_STAMP_DIR)/sound
MUSIC_STAMP := $(RESOURCE_STAMP_DIR)/music
KID_STAMP := $(RESOURCE_STAMP_DIR)/kid KID_STAMP := $(RESOURCE_STAMP_DIR)/kid
KID_BIN_STAMP := $(RESOURCE_STAMP_DIR)/kid-bin KID_BIN_STAMP := $(RESOURCE_STAMP_DIR)/kid-bin
FONT_STAMP := $(RESOURCE_STAMP_DIR)/font FONT_STAMP := $(RESOURCE_STAMP_DIR)/font
@@ -286,6 +297,12 @@ $(SOUND_STAMP): $(TC)/pop_pack_sound.py | $(RESOURCE_STAMP_DIR)
$(SND_DATA) pop_sound_tbl.h: $(SOUND_STAMP) $(SND_DATA) pop_sound_tbl.h: $(SOUND_STAMP)
@test -f $@ || { $(MAKE) -B $(SOUND_STAMP); test -f $@; } @test -f $@ || { $(MAKE) -B $(SOUND_STAMP); test -f $@; }
$(MUSIC_STAMP): $(TC)/pop_pack_music.py | $(RESOURCE_STAMP_DIR)
cd $(TC) && python3 pop_pack_music.py $(MUS_IDS)
touch $@
$(MUS_DATA) pop_music_tbl.h: $(MUSIC_STAMP)
@test -f $@ || { $(MAKE) -B $(MUSIC_STAMP); test -f $@; }
$(KID_STAMP): $(TC)/pop_pack_kid.py $(TC)/pop_pack_bg.py | $(RESOURCE_STAMP_DIR) $(KID_STAMP): $(TC)/pop_pack_kid.py $(TC)/pop_pack_bg.py | $(RESOURCE_STAMP_DIR)
cd $(TC) && python3 pop_pack_kid.py cd $(TC) && python3 pop_pack_kid.py
touch $@ touch $@
@@ -349,11 +366,12 @@ resources-bg: $(BG_DATA)
resources-kid: $(KID_DATA) $(KID_BIN) resources-kid: $(KID_DATA) $(KID_BIN)
resources-actors: $(GUARD_DATA) $(SKEL_DATA) $(VIZIER_DATA) $(SHADOW_DATA) resources-actors: $(GUARD_DATA) $(SKEL_DATA) $(VIZIER_DATA) $(SHADOW_DATA)
resources-sound: $(SND_DATA) resources-sound: $(SND_DATA)
resources-music: $(MUS_DATA)
resources-font: $(FONT_DATA) resources-font: $(FONT_DATA)
resources-title: $(TITLE_DATA) resources-title: $(TITLE_DATA)
resources-pv: $(PV_DATA) resources-pv: $(PV_DATA)
resources-levels: $(LVL_DATA) resources-levels: $(LVL_DATA)
resources: resources-bg resources-kid resources-actors resources-sound resources-font resources-title resources-pv resources-levels resources: resources-bg resources-kid resources-actors resources-sound resources-music resources-font resources-title resources-pv resources-levels
resources-rebuild: resources-rebuild:
$(MAKE) -B resources $(MAKE) -B resources
@@ -378,4 +396,4 @@ clean-resources:
rm -rf $(RESOURCE_STAMP_DIR) rm -rf $(RESOURCE_STAMP_DIR)
.PHONY: resources resources-rebuild resources-bg resources-kid resources-actors relink \ .PHONY: resources resources-rebuild resources-bg resources-kid resources-actors relink \
resources-sound resources-font resources-title resources-pv resources-levels clean-resources resources-sound resources-music resources-font resources-title resources-pv resources-levels clean-resources
+12
View File
@@ -14,6 +14,7 @@
#include "pop_bg.h" #include "pop_bg.h"
#include "pop_shadow.h" #include "pop_shadow.h"
#include "pop_sfx.h" #include "pop_sfx.h"
#include "pop_music.h"
#include "pop_cutscene.h" #include "pop_cutscene.h"
#include "pop_intro.h" #include "pop_intro.h"
#include "pop_pal.h" #include "pop_pal.h"
@@ -1305,10 +1306,21 @@ uint8_t pop_intro_show(void) __banked
if (intro_draw_new_palette(INTRO_STORY_ABSENCE) == 0) if (intro_draw_new_palette(INTRO_STORY_ABSENCE) == 0)
pop_pal_fade_in(INTRO_FADE); pop_pal_fade_in(INTRO_FADE);
} }
/* Первый экран истории — единственное место, где музыка уже звучит.
* Грузим ПОСЛЕ того, как картинка собрана: чтение кусков идёт через
* ESTEX, и при открытом CBL насос не успел бы долить блок (скрежет,
* см. pop_sfx_start). Порядок обязателен: load -> start -> play. */
if (pop_music_load(POP_MUS_STORY_1) == 0) {
(void)pop_sfx_start();
pop_music_play();
}
if (intro_run(intro_before_pv)) { if (intro_run(intro_before_pv)) {
pop_music_free();
intro_restore_game_palette(); intro_restore_game_palette();
return 1; return 1;
} }
pop_music_free();
pop_sfx_pause();
pv_result = intro_pv_animated(); pv_result = intro_pv_animated();
if (pv_result > 0) { if (pv_result > 0) {
intro_restore_game_palette(); intro_restore_game_palette();
+95
View File
@@ -0,0 +1,95 @@
/*
* pop_music.c — загрузка и запуск музыкального трека, БАНК 9.
*
* Здесь только холодная часть: выделить страницы EMM, вычитать в них куски
* m<id>_<nn>.bin и выставить курсор. Сам вывод делает насос CBL в
* резиденте (pop_sfx.c): когда эффект не звучит, он берёт байты отсюда, а
* не из блока тишины. Контракт и мотивация — в pop_music.h.
*
* Имя файла собираем вручную, без sprintf: printf-семейство в резидент не
* тянем, а тут и формат-то из двух чисел.
*/
#include <stdint.h>
#include <sprinter_mem.h>
#include "pop_music.h"
#include "pop_music_tbl.h"
#include "pop_sfx.h"
uint8_t pop_mus_page[POP_MUS_PAGES];
static uint8_t mus_blk; /* блок EMM под трек */
static uint8_t mus_pages; /* сколько страниц реально занято */
static uint16_t mus_blocks; /* длина трека в порциях по 128 Б */
/* "MUS\mNN_KK.bin" — ровно 8.3, как требует DSS. */
static void mus_name(char *buf, uint8_t id, uint8_t chunk)
{
buf[0] = 'M'; buf[1] = 'U'; buf[2] = 'S'; buf[3] = '\\';
buf[4] = 'm';
buf[5] = (char)('0' + id / 10);
buf[6] = (char)('0' + id % 10);
buf[7] = '_';
buf[8] = (char)('0' + chunk / 10);
buf[9] = (char)('0' + chunk % 10);
buf[10] = '.'; buf[11] = 'b'; buf[12] = 'i'; buf[13] = 'n';
buf[14] = 0;
}
void pop_music_stop(void) __banked
{
pop_mus_left = 0;
}
void pop_music_free(void) __banked
{
pop_mus_left = 0;
if (mus_blk) {
mem_free_block(mus_blk);
mus_blk = 0;
}
mus_pages = 0;
mus_blocks = 0;
}
int8_t pop_music_load(uint8_t id) __banked
{
char name[15];
uint8_t i, pages;
if (id >= POP_MUS_COUNT) return -1;
pages = pop_mus_tbl[id].pages;
if (!pages || pages > POP_MUS_PAGES) return -1;
pop_music_free();
mus_blk = mem_alloc_pages(pages);
if (!mus_blk) return -1;
for (i = 0; i < pages; i++) {
pop_mus_page[i] = mem_get_page(mus_blk, i);
mus_name(name, id, i);
/* Последний кусок короче страницы — читаем сколько есть; ошибку
* ловим по отрицательному коду, а не по «не 16384». */
if (!pop_mus_page[i] ||
bank_load_file(pop_mus_page[i], 0, name, 16384) < 0) {
pop_music_free();
return -1;
}
}
mus_pages = pages;
mus_blocks = pop_mus_tbl[id].blocks;
return 0;
}
void pop_music_play(void) __banked
{
if (!mus_pages) return;
/* Порядок важен: сначала позиция, потом длина. Насос стартует ровно
* с той порции, где left стал ненулевым, и промежуточное состояние
* «играем, но курсор от прошлого трека» не возникает. */
pop_mus_pg = 0;
pop_mus_ptr = 0;
pop_mus_left = mus_blocks;
}
uint8_t pop_music_busy(void) __banked
{
return (uint8_t)(pop_mus_left != 0);
}
+58
View File
@@ -0,0 +1,58 @@
/*
* pop_music.h — музыкальный трек через тот же CBL, что и эффекты.
*
* ПОЧЕМУ PCM, А НЕ AY. Оцифровки музыки в игре нет вовсе: digisnd*.dat
* содержит только эффекты, музыка лежит MIDI под Adlib. Зато есть готовые
* записи DOS-версии, и путь «запись -> сырой поток CBL» не требует ни
* синтезатора на хосте, ни аранжировки под три канала AY (разбор вариантов
* — ../docs/sound_plan.md §1). Плата известна: CBL один, поэтому пока
* играет музыка, эффектов нет. Оригинал ведёт себя так же — play_sound
* там глушит предыдущий звук.
*
* ГДЕ УМЕСТНО. Заставки и титры: эффектов там не бывает, картинка
* статична, и загрузка сотни-другой килобайт с диска никому не мешает.
* Музыка ПО ХОДУ игры (джинглы смерти, зелья) — отдельный вопрос, её
* придётся либо гасить эффектами, либо уводить на AY.
*
* ФОРМАТ на диске — пачка кусков m<id>_<nn>.bin по 16 КБ (bank_load_file
* читает файл только целиком и только в одну страницу). Каталог с
* длинами генерирует toolchain/pop_pack_music.py в pop_music_tbl.h.
*/
#ifndef POP_MUSIC_H
#define POP_MUSIC_H
#include <stdint.h>
/* Сколько страниц EMM держим под трек. Двадцать = 320 КБ, это все
* заставочные треки, кроме финального «won» (115 с, 1,2 МБ): тот придётся
* либо резать, либо стримить, и это отдельная задача. */
#define POP_MUS_PAGES 20
/* Номера треков — нумерация оригинала (SDLPoP data/music/names.txt). */
#define POP_MUS_STORY_1 55 /* «In the absence of the Sultan...» */
/* Курсор проигрывания — читает НАСОС (pop_sfx.c, резидент), пишет
* загрузчик. Отдельный от эффектов: они звучат поверх музыки, а не
* вместо неё, и у каждого свой источник. */
extern uint8_t pop_mus_page[POP_MUS_PAGES]; /* физические страницы EMM */
extern volatile uint8_t pop_mus_pg; /* индекс текущей страницы */
extern volatile uint16_t pop_mus_ptr; /* адрес в окне W0 */
extern volatile uint16_t pop_mus_left; /* байт до конца трека, 0 — молчим */
/* Загрузить трек в EMM (НЕ запускает). Звать там, где допустима пауза на
* диск: CBL в это время обязан быть закрыт, иначе насос не успевает долить
* блок и железо повторяет старый — на слух скрежет (pop_sfx.h).
* 0 — готово, -1 — нет трека/памяти/файла. */
int8_t pop_music_load(uint8_t id) __banked;
/* Отпустить страницы. Останавливает проигрывание, если оно шло. */
void pop_music_free(void) __banked;
/* Пустить загруженный трек с начала / оборвать. */
void pop_music_play(void) __banked;
void pop_music_stop(void) __banked;
/* Трек ещё звучит? 0 — доиграл или не запускался. */
uint8_t pop_music_busy(void) __banked;
#endif /* POP_MUSIC_H */
+83
View File
@@ -0,0 +1,83 @@
/*
* pop_music_tbl.h — каталог музыкальных треков, СГЕНЕРИРОВАН.
* Источник: applications/PoP/PoP1_DOS_music/flac (записи DOS-версии).
* Генератор: toolchain/pop_pack_music.py. Руками не править.
*
* Нумерация id — оригинала (SDLPoP data/music/names.txt). pages —
* сколько кусков m<id>_<nn>.bin лежит на диске, blocks — длина трека
* в порциях насоса по 128 байт (в байтах не влезло бы в uint16).
*/
#ifndef POP_MUSIC_TBL_H
#define POP_MUSIC_TBL_H
#include <stdint.h>
#define POP_MUS_COUNT 56
#define POP_MUS_BLOCK 128
#define POP_MUS_MAX_PAGES 11
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 */
{ 0, 0 }, /* 24 */
{ 0, 0 }, /* 25 */
{ 0, 0 }, /* 26 */
{ 0, 0 }, /* 27 */
{ 0, 0 }, /* 28 */
{ 0, 0 }, /* 29 */
{ 0, 0 }, /* 30 */
{ 0, 0 }, /* 31 */
{ 0, 0 }, /* 32 */
{ 0, 0 }, /* 33 */
{ 0, 0 }, /* 34 */
{ 0, 0 }, /* 35 */
{ 0, 0 }, /* 36 */
{ 0, 0 }, /* 37 */
{ 0, 0 }, /* 38 */
{ 0, 0 }, /* 39 */
{ 0, 0 }, /* 40 */
{ 0, 0 }, /* 41 */
{ 0, 0 }, /* 42 */
{ 0, 0 }, /* 43 */
{ 0, 0 }, /* 44 */
{ 0, 0 }, /* 45 */
{ 0, 0 }, /* 46 */
{ 0, 0 }, /* 47 */
{ 0, 0 }, /* 48 */
{ 0, 0 }, /* 49 */
{ 0, 0 }, /* 50 */
{ 0, 0 }, /* 51 */
{ 0, 0 }, /* 52 */
{ 0, 0 }, /* 53 */
{ 0, 0 }, /* 54 */
{ 11, 1350 }, /* 55 story_1_absence */
};
#endif /* POP_MUSIC_TBL_H */
+36 -5
View File
@@ -20,6 +20,7 @@
#include <cbl.h> #include <cbl.h>
#include <irq.h> /* IRQ_DISABLE/IRQ_ENABLE вокруг курсора */ #include <irq.h> /* IRQ_DISABLE/IRQ_ENABLE вокруг курсора */
#include "pop_sfx.h" #include "pop_sfx.h"
#include "pop_music.h"
#include "_pop_sfx.h" #include "_pop_sfx.h"
/* Физические страницы набора; заполняет холодная половина. */ /* Физические страницы набора; заполняет холодная половина. */
@@ -42,6 +43,12 @@ uint8_t pop_snd_want;
* Всё в 8/16 бит — 32-битной арифметики на Z80 избегаем (см. memory * Всё в 8/16 бит — 32-битной арифметики на Z80 избегаем (см. memory
* avoid_32bit_arith_z80); поэтому и в таблице смещение уже разобрано на * avoid_32bit_arith_z80); поэтому и в таблице смещение уже разобрано на
* страницу и адрес в окне. */ * страницу и адрес в окне. */
/* Курсор МУЗЫКИ. Лежит здесь, а не в pop_music.c, по той же причине, что
* и курсор эффектов: его читает насос из прерывания, то есть резидент. */
volatile uint8_t pop_mus_pg;
volatile uint16_t pop_mus_ptr;
volatile uint16_t pop_mus_left;
static volatile uint8_t sfx_pg; /* индекс в pop_snd_page[] */ static volatile uint8_t sfx_pg; /* индекс в pop_snd_page[] */
static volatile uint16_t sfx_ptr; /* адрес в окне W0: 0x0000..0x3FFF */ static volatile uint16_t sfx_ptr; /* адрес в окне W0: 0x0000..0x3FFF */
static volatile uint16_t sfx_left; /* сколько байт осталось (кратно 128) */ static volatile uint16_t sfx_left; /* сколько байт осталось (кратно 128) */
@@ -66,15 +73,39 @@ static volatile uint16_t sfx_left; /* сколько байт осталось
* тесном резиденте W2 отказала бы и уронила весь звук. */ * тесном резиденте W2 отказала бы и уронила весь звук. */
int pop_sfx_fill(uint16_t n) int pop_sfx_fill(uint16_t n)
{ {
uint8_t saved, pg; uint8_t saved, phys;
uint16_t ptr; uint16_t ptr;
if (sfx_left) { pg = sfx_pg; ptr = sfx_ptr; }
else { pg = POP_SND_SILENCE_PAGE; ptr = POP_SND_SILENCE_OFF; } /* Три источника по убыванию важности: эффект, музыка, тишина. Эффект
* перебивает музыку — как у оригинала, где play_sound глушит всё, что
* звучало; голос-то один. Музыка при этом НЕ теряет позицию: её
* курсор стоит, пока эффект доигрывает, и она продолжается с места. */
if (sfx_left) {
phys = pop_snd_page[sfx_pg]; ptr = sfx_ptr;
} else if (pop_mus_left) {
phys = pop_mus_page[pop_mus_pg]; ptr = pop_mus_ptr;
} else {
phys = pop_snd_page[POP_SND_SILENCE_PAGE];
ptr = POP_SND_SILENCE_OFF;
}
saved = _io_page_w0; saved = _io_page_w0;
_io_page_w0 = pop_snd_page[pg]; _io_page_w0 = phys;
cbl_push_otir((const void *)ptr, n); cbl_push_otir((const void *)ptr, n);
_io_page_w0 = saved; _io_page_w0 = saved;
if (!sfx_left) return 1; /* играли тишину — курсор не двигаем */ if (!sfx_left) {
if (!pop_mus_left) return 1; /* тишина — курсор не двигаем */
/* Длину трека считаем ПОРЦИЯМИ насоса, а не байтами: 169 КБ в
* uint16 не влезает, а 1350 блоков — легко (32-бит арифметики на
* Z80 избегаем, тем более в прерывании). */
pop_mus_left--;
pop_mus_ptr += n;
if (pop_mus_ptr >= 0x4000) {
pop_mus_ptr = 0;
if ((uint8_t)(pop_mus_pg + 1) < POP_MUS_PAGES) pop_mus_pg++;
else pop_mus_left = 0;
}
return 1;
}
sfx_ptr += n; sfx_ptr += n;
if (sfx_ptr >= 0x4000) { /* граница страницы — ровно по блоку */ if (sfx_ptr >= 0x4000) { /* граница страницы — ровно по блоку */
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""pop_pack_music.py — музыка PoP в сырой поток для CBL.
Источник — готовые записи DOS-версии (applications/PoP/PoP1_DOS_music,
формат flac). Оцифровки музыки в самой игре НЕТ: в digisnd*.dat лежат
только эффекты, а музыка хранится как MIDI под Adlib. Поэтому здесь мы
не «конвертируем ресурс игры», а укладываем готовую запись в формат,
который наше железо играет без единого преобразования в рантайме.
ФОРМАТ — тот же, что у эффектов (см. pop_pack_sound.py и sound_plan.md):
8 бит БЕЗЗНАКОВЫЙ моно на 10 937,5 Гц — ровно CBL_FREQ_10K9. Частоту
берём не 11 000, а частоту железа: тогда CBL открывается ОДИН раз за
запуск и не переключается ни между эффектами, ни при переходе на музыку.
РЕЖЕМ НА СТРАНИЦЫ EMM по 16 КБ. bank_load_file умеет читать файл только
целиком и только в одну страницу, поэтому трек на диске лежит пачкой
кусков M<id>_<nn>.BIN — ровно как набор эффектов лежит s0..s9.bin.
Последний кусок дополняется тишиной (0x80) до кратности 128: насос отдаёт
CBL блоками по 128 байт, и остаток «не полного блока» ему пришлось бы
досчитывать в прерывании.
Длина трека уходит в pop_music_tbl.h блоками по 128 байт, а не байтами:
самый длинный трек (won, 115 с) — это 1,2 МБ, что не влезает в uint16, а
в блоках даёт 9855. 32-битной арифметики на Z80 избегаем (см. memory
avoid_32bit_arith_z80).
"""
import subprocess
import sys
from pathlib import Path
POP = Path(__file__).resolve().parent.parent # applications/PoP
SRC_DIR = POP / "PoP1_DOS_music" / "flac"
OUT_DIR = POP / "poc" / "res" / "music"
OUT_H = POP / "roomtest" / "pop_music_tbl.h"
CBL_RATE = 10937.5 # CBL_FREQ_10K9 — частота железа, не 11 000
PAGE = 16384 # EMM-страница
BLOCK = 128 # порция насоса CBL
SILENCE = 0x80 # центр беззнакового 8-битного сэмпла
# id — нумерация звуков ОРИГИНАЛА (SDLPoP data/music/names.txt). Держим её,
# чтобы игровой код просил трек тем же номером, каким оригинал зовёт
# play_sound, и таблицу можно было сверить глазами.
TRACKS = {
24: "regular_death",
25: "find_mirror_and_find_shadow",
26: "embrace",
27: "cutscene_before_2_4_6_12",
28: "fight_death",
29: "meet_Jaffar",
30: "life_potion",
32: "end_level_4_and_killed_by_shadow",
33: "potion",
35: "cutscene_before_8_9",
36: "out_of_time",
37: "guard_death_and_obtaining_the_sword",
39: "floating",
40: "cutscene_not_much_time_left",
41: "story_5_level_end_and_merge_with_shadow",
43: "killed_Jaffar",
50: "story_2_princess",
52: "story_4_Jaffar_leaves",
53: "story_3_Jaffar_enters",
54: "intro_theme",
55: "story_1_absence",
56: "won",
}
def decode(path: Path) -> bytes:
"""flac -> сырой u8 моно на частоте CBL."""
out = subprocess.run(
["ffmpeg", "-v", "error", "-i", str(path),
"-ac", "1", "-ar", str(int(CBL_RATE)), "-f", "u8", "-"],
capture_output=True, check=True)
return out.stdout
def pack(track_id: int, name: str) -> tuple[int, int]:
src = SRC_DIR / f"{name}.flac"
if not src.exists():
raise SystemExit(f"нет источника: {src}")
data = bytearray(decode(src))
# Добить до кратности блока насоса — хвост тишиной, а не обрезкой:
# обрезать значило бы щёлкнуть на последнем сэмпле.
tail = len(data) % BLOCK
if tail:
data += bytes([SILENCE]) * (BLOCK - tail)
chunks = (len(data) + PAGE - 1) // PAGE
OUT_DIR.mkdir(parents=True, exist_ok=True)
for i in range(chunks):
part = data[i * PAGE:(i + 1) * PAGE]
(OUT_DIR / f"m{track_id:02d}_{i:02d}.bin").write_bytes(part)
return chunks, len(data) // BLOCK
def main() -> None:
wanted = [int(a) for a in sys.argv[1:]] or sorted(TRACKS)
rows = []
total = 0
for tid in wanted:
if tid not in TRACKS:
raise SystemExit(f"неизвестный трек {tid}")
chunks, blocks = pack(tid, TRACKS[tid])
rows.append((tid, TRACKS[tid], chunks, blocks))
total += blocks * BLOCK
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 — каталог музыкальных треков, СГЕНЕРИРОВАН.",
" * Источник: applications/PoP/PoP1_DOS_music/flac (записи DOS-версии).",
" * Генератор: toolchain/pop_pack_music.py. Руками не править.",
" *",
" * Нумерация id — оригинала (SDLPoP data/music/names.txt). pages —",
" * сколько кусков m<id>_<nn>.bin лежит на диске, blocks — длина трека",
" * в порциях насоса по 128 байт (в байтах не влезло бы в uint16).",
" */",
"#ifndef POP_MUSIC_TBL_H",
"#define POP_MUSIC_TBL_H",
"",
"#include <stdint.h>",
"",
f"#define POP_MUS_COUNT {max_id + 1}",
f"#define POP_MUS_BLOCK {BLOCK}",
f"#define POP_MUS_MAX_PAGES {max(c for _, _, c, _ in rows)}",
"",
"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))
print(f"итого {total / 1024:.0f} КБ в {OUT_DIR}")
print(f"каталог: {OUT_H}")
if __name__ == "__main__":
main()