2349481b86
Часть II плана music_runtime_index_plan.md (SI0..SI4). gen/pop_sound_tbl.h БОЛЬШЕ НЕ ГЕНЕРИРУЕТСЯ: раскладка набора читается из SND/snd.idx (формат PSI1, писатель и разборщик — tools/pop_idx.py, 22 теста в make test-tools). Один и тот же sprpop.exe работает с набором SDLPoP (9 страниц) и MSDOS (10) — sha256 бинарника при смене набора не меняется. Заодно умолчание источника эффектов переведено на SDLPoP (SND_SRC=sdlpop): сборка обязана работать без оригинального дистрибутива DOS. У кого он есть, включает лучший набор явно — make SND_SRC=msdos (там полнее оцифровка: в SDLPoP звук 48 spiked пустой). Устройство: pop_snd_tbl/pop_snd_page/pop_snd_pages — резидентные данные (pop_snd_data.c), тип и инварианты — рукописный pop_snd_tbl.h. Записи читаются ОДНИМ read прямо в таблицу, поэтому sizeof(pop_snd_ent_t) == 5 стало частью дискового контракта: проверяется статически и полем размера записи в заголовке. POP_SND_PAGES как compile-time размер набора исчез — вместо него POP_SND_MAX_PAGES (вместимость, 16) и runtime pop_snd_pages. Цена: таблица переехала из _CODE в _DATA, суммарный резидент почти не изменился (куча 239 -> 229 Б); банк 8 +601 Б на чтение и валидацию. Валидация не доверяет файлу: заголовок целиком плюс каждая запись (страница, смещение, кратность блоку, непересечение с блоком тишины, выход за последнюю страницу). Последнее считается В БЛОКАХ — байтовый адрес конца не влезает в uint16, а 32-битная арифметика на Z80 дорога. НЕТ ИНДЕКСА — ЭФФЕКТОВ НЕТ, НО МУЗЫКА ИГРАЕТ. Первая версия просто возвращала ошибку, и игра становилась непроходимой: тишину льёт первый блок набора, без набора CBL не открывался, а с ним вставала музыка (её блоки считает тот же насос) — заставка ждала конца трека вечно. Теперь поднимается пустой набор с блоком тишины. Заливается ровно 128 байт и под DI: gfx_w0_page_prepare ставит в страницу IRQ-стабы, и заливка всей страницы затирала их — первое же прерывание давало чёрный экран. Грабли сборки: смена SND_SRC тихо давала неверный результат (sdlpop -> msdos -> sdlpop оставлял чужой набор в assets/packed). Причина не в логике, а в секундной гранулярности mtime. Лечение убирает время из решения: смена варианта сносит stamp'ы своего семейства, а упаковка, сборка архива и копия индекса делаются одним рецептом. То же получила и музыка (MUSIC_FMT). Проверено в MAME: таблица в памяти совпадает с файлом из образа побайтово; один EXE поднимает оба набора; отладочный --order reverse (30 из 31 записей отличаются от штатных) звучит правильно; битый индекс выключает эффекты, не роняя игру; без индекса PV-сцена проходит с музыкой; Ctrl+S работает в обоих режимах. Разбор — docs/sound_plan.md §10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
263 lines
14 KiB
C
263 lines
14 KiB
C
/*
|
||
* pop_sfx_cold.c — ХОЛОДНАЯ половина звука: поднять набор и открыть CBL.
|
||
* Отрабатывает один раз за запуск, поэтому живёт в банке 8; горячая
|
||
* половина (насос, запуск эффекта) — в резиденте, см. pop_sfx.c.
|
||
*/
|
||
|
||
#include <stdint.h>
|
||
#include <string.h>
|
||
#include <fcntl.h>
|
||
#include <unistd.h>
|
||
#include <sprinter_mem.h>
|
||
#include <cbl.h>
|
||
#include "pop_sfx.h"
|
||
#include "pop_arc.h"
|
||
#include "_pop_sfx.h"
|
||
#include "_pop_file.h" /* POP_PATH_CALL — старые DSS и пути */
|
||
#include <irq.h> /* IRQ_DISABLE вокруг записи в W0 */
|
||
#include <sprite.h> /* gfx_w0_map — залить блок тишины */
|
||
|
||
static uint8_t snd_blk; /* блок EMM под весь набор */
|
||
|
||
/* --- ИНДЕКС НАБОРА (SND/snd.idx, формат PSI1) ----------------------- *
|
||
*
|
||
* Раскладка эффектов приезжает с диска, а не из EXE: так пара
|
||
* snd.idx + snd.arc заменяется целиком (SDLPoP <-> MSDOS) без
|
||
* перекомпиляции игры. Писатель формата — tools/pop_idx.py, он же
|
||
* покрыт тестом; проверки ниже повторяют его один в один, потому что
|
||
* доверять содержимому файла нельзя: битый индекс увёл бы насос за
|
||
* пределы выделенного блока EMM.
|
||
*
|
||
* Заголовок: 16 байт, дальше POP_SND_COUNT записей по 5 байт подряд —
|
||
* ровно в том виде, в каком лежит pop_snd_tbl[], поэтому читаются они
|
||
* ОДНИМ read'ом прямо туда, без разбора по полям. */
|
||
#define IDX_HDR_SIZE 16
|
||
#define IDX_BLOCK_LOG2 7 /* 1 << 7 == POP_SND_BLOCK */
|
||
#define IDX_PCM_FMT 1 /* u8 mono, CBL_FREQ_10K9 */
|
||
#define IDX_TBL_SIZE (POP_SND_COUNT * (int)sizeof(pop_snd_ent_t))
|
||
|
||
/* Имя знает сам модуль — литерал лежит в rodata банка 8, и open() читает
|
||
* его, пока в W3 стоит этот же банк (тот же приём, что в pop_arc.c). */
|
||
static const char idx_path[] = "SND\\snd.idx";
|
||
|
||
/* Одна запись: ровно те проверки, что делает упаковщик.
|
||
*
|
||
* СЧИТАЕМ В БЛОКАХ, А НЕ В БАЙТАХ. Байтовый адрес конца эффекта
|
||
* (page * 16384 + off + len) не влезает в uint16, а 32-битная арифметика
|
||
* на Z80 дорога (memory avoid_32bit_arith_z80). В блоках по 128 весь
|
||
* набор — не больше POP_SND_MAX_PAGES * 128 = 2048, и всё считается
|
||
* шестнадцатью битами. */
|
||
static int8_t idx_entry_ok(const pop_snd_ent_t *e, uint8_t pages)
|
||
{
|
||
uint16_t start, end;
|
||
|
||
if (e->len == 0) /* нет оцифровки — это музыка */
|
||
return (e->page == 0 && e->off == 0) ? 0 : -1;
|
||
if (e->page >= pages) return -1;
|
||
if (e->off >= 0x4000) return -1;
|
||
if ((e->off | e->len) & (POP_SND_BLOCK - 1)) return -1;
|
||
/* Блок тишины неприкосновенен: насос льёт его, когда играть нечего. */
|
||
if (e->page == POP_SND_SILENCE_PAGE && e->off < POP_SND_BLOCK) return -1;
|
||
start = (uint16_t)((uint16_t)e->page << 7) + (uint16_t)(e->off >> 7);
|
||
end = (uint16_t)(start + (uint16_t)(e->len >> 7));
|
||
/* Длинный эффект переливается на следующие страницы — они обязаны
|
||
* быть загружены, иначе насос уйдёт в чужую память. */
|
||
if (end > (uint16_t)((uint16_t)pages << 7)) return -1;
|
||
return 0;
|
||
}
|
||
|
||
/* Прочитать и проверить индекс. Возвращает число страниц набора либо 0. */
|
||
static uint8_t idx_load(void)
|
||
{
|
||
uint8_t head[IDX_HDR_SIZE];
|
||
uint8_t pages, i;
|
||
int fd;
|
||
|
||
POP_PATH_CALL(fd, open(idx_path, O_RDONLY));
|
||
if (fd < 0) return 0;
|
||
if (read(fd, head, IDX_HDR_SIZE) != IDX_HDR_SIZE ||
|
||
memcmp(head, "PSI1", 4) != 0 ||
|
||
head[4] != POP_SND_COUNT ||
|
||
head[5] != (uint8_t)sizeof(pop_snd_ent_t) ||
|
||
head[6] != IDX_BLOCK_LOG2 ||
|
||
head[7] != IDX_PCM_FMT) {
|
||
close(fd);
|
||
return 0;
|
||
}
|
||
pages = head[8];
|
||
if (pages == 0 || pages > POP_SND_MAX_PAGES) { close(fd); return 0; }
|
||
/* Записи ложатся прямо в резидентную таблицу: дисковый формат и
|
||
* структура совпадают побайтово (проверено статически в
|
||
* pop_snd_data.c и полем head[5] выше). */
|
||
if (read(fd, pop_snd_tbl, IDX_TBL_SIZE) != IDX_TBL_SIZE) {
|
||
close(fd);
|
||
return 0;
|
||
}
|
||
close(fd);
|
||
for (i = 0; i < POP_SND_COUNT; i++)
|
||
if (idx_entry_ok(&pop_snd_tbl[i], pages) != 0) return 0;
|
||
return pages;
|
||
}
|
||
|
||
/* НАБОРА НЕТ — НО НАСОС ВСЁ РАВНО ОБЯЗАН РАБОТАТЬ.
|
||
*
|
||
* Тишину в паузах насос льёт из ПЕРВОГО БЛОКА НАБОРА, поэтому без набора
|
||
* CBL не открывался вовсе. А вместе с ним вставала и МУЗЫКА: её блоки
|
||
* отсчитывает тот же насос (pop_mus_left убывает только в pop_sfx_fill).
|
||
* Заставка при этом висла намертво — ждала конца трека, который не
|
||
* начинал играть, и pop_music_busy() не гас никогда (поймано
|
||
* пользователем 2026-08-31 на образе без snd.idx).
|
||
*
|
||
* Поэтому при отказе индекса поднимаем ПУСТОЙ набор: одна страница EMM с
|
||
* блоком тишины. Эффектов нет — все len в таблице нулевые, и
|
||
* pop_sfx_play уходит по ветке «нет оцифровки», — а музыка играет.
|
||
*
|
||
* ЗАЛИВАЕМ ТОЛЬКО ПЕРВЫЙ БЛОК, и это не экономия: gfx_w0_page_prepare
|
||
* ставит в страницу IRQ/NMI-стабы, чтобы прерывание при замапленном W0
|
||
* нашло валидный код. Заливка ВСЕЙ страницы затирает их, и первое же
|
||
* прерывание уводит процессор в мусор — чёрный экран на старте (поймано
|
||
* при первой попытке этого фикса). Насосу больше 128 байт и не нужно:
|
||
* он берёт ровно блок тишины, да и то через W3, а не через W0.
|
||
* Запись — под DI: 128 байт накрывают стаб RST38, и до unmap прерываний
|
||
* быть не должно. */
|
||
static int8_t snd_silence_only(void)
|
||
{
|
||
uint8_t blk, page;
|
||
uint8_t *w0 = (uint8_t *)0x0000; /* окно 0 — сюда ляжет страница */
|
||
|
||
memset(pop_snd_tbl, 0, sizeof(pop_snd_tbl));
|
||
blk = mem_alloc_pages(1);
|
||
if (!blk) return -1;
|
||
page = mem_get_page(blk, 0);
|
||
if (!page) { mem_free_block(blk); return -1; }
|
||
gfx_w0_page_prepare(page);
|
||
IRQ_DISABLE();
|
||
gfx_w0_map(page);
|
||
memset(w0, 0x80, POP_SND_BLOCK); /* 0x80 — тишина в 8-бит без знака */
|
||
gfx_w0_unmap();
|
||
IRQ_ENABLE();
|
||
pop_snd_page[POP_SND_SILENCE_PAGE] = page;
|
||
snd_blk = blk;
|
||
pop_snd_pages = 1;
|
||
return 0;
|
||
}
|
||
|
||
int pop_sfx_init(void) __banked
|
||
{
|
||
uint8_t i, blk, pages;
|
||
|
||
if (pop_snd_ok) return 0;
|
||
|
||
/* СНАЧАЛА ИНДЕКС, ПОТОМ ДАННЫЕ: сколько страниц читать и куда какой
|
||
* эффект лёг — знает только он. Набора без индекса не бывает:
|
||
* держать в EXE запасную таблицу нельзя, иначе привязка бинарника к
|
||
* конкретному набору вернётся через чёрный ход. */
|
||
pages = idx_load();
|
||
if (!pages) {
|
||
/* Набор эффектов не поднялся — но звук как таковой поднять надо,
|
||
* иначе встанет музыка (см. snd_silence_only). Возвращаем -1:
|
||
* эффектов действительно нет, и вызывающий вправе об этом знать. */
|
||
if (snd_silence_only() != 0) return -1;
|
||
pop_snd_want = 1;
|
||
return -1;
|
||
}
|
||
|
||
/* Одним блоком: страницы блока идут подряд по логическому индексу, и
|
||
* таблица звуков адресует их именно так (page = смещение >> 14).
|
||
*
|
||
* Из АРХИВА, а не десятью файлами: `open` стоит 51,4 мс против 32,6 мс
|
||
* на чтение 16 КБ (замер 2026-08-25), то есть девять лишних открытий —
|
||
* это почти полсекунды на ровном месте. Таблица живёт на стеке, см.
|
||
* pop_arc.h. */
|
||
{
|
||
pop_arc_t arc;
|
||
uint8_t tbl[POP_SND_MAX_PAGES * POP_ARC_REC];
|
||
|
||
/* Архив обязан содержать РОВНО столько страниц, сколько обещал
|
||
* индекс: расходящейся паре доверять нельзя — она означает, что
|
||
* snd.idx и snd.arc из разных наборов. */
|
||
if (pop_arc_open_id(&arc, POP_ARC_SND, tbl, POP_SND_MAX_PAGES) !=
|
||
(int8_t)pages)
|
||
return -1;
|
||
blk = mem_alloc_pages(pages);
|
||
if (!blk) { pop_arc_close(&arc); return -1; }
|
||
for (i = 0; i < pages; i++) {
|
||
pop_snd_page[i] = mem_get_page(blk, i);
|
||
if (!pop_snd_page[i] ||
|
||
pop_arc_read(&arc, tbl, i, 0, pop_snd_page[i], 0, 0) != 16384) {
|
||
pop_arc_close(&arc);
|
||
mem_free_block(blk);
|
||
return -1;
|
||
}
|
||
}
|
||
pop_arc_close(&arc);
|
||
}
|
||
snd_blk = blk;
|
||
/* Число страниц ставим ПОСЛЕДНИМ: до этого момента насос обязан
|
||
* видеть 0 и не пытаться ходить по полузаполненной таблице. */
|
||
pop_snd_pages = pages;
|
||
pop_snd_want = 1; /* набор есть — звук по умолчанию включён */
|
||
return 0;
|
||
}
|
||
|
||
/* ЗАПУСТИТЬ ВЫВОД — ОТДЕЛЬНО ОТ ЗАГРУЗКИ, и это не косметика.
|
||
*
|
||
* Пока идёт чтение файлов, CBL держать открытым НЕЛЬЗЯ: ESTEX уходит в
|
||
* диск надолго, насос не успевает долить очередные 128 байт, и железо
|
||
* крутит по кругу хвост своего 256-байтового буфера. На слух это
|
||
* сплошной скрежет (поймано пользователем на старте, 2026-08-20).
|
||
* Поэтому загрузка страниц и открытие CBL разведены: грузим молча,
|
||
* включаем звук последним действием, а на время загрузки уровня глушим
|
||
* (pop_sfx_pause).
|
||
*
|
||
* Параметры: 10 937,5 Гц, 8 бит моно — формат данных один в один
|
||
* (sound_plan.md). OTIR, а не акселератор: тот нужен только для 16 бит.
|
||
* cbl_open, а не cbl_open_silence: тишину насос льёт СВОЮ (первый блок
|
||
* набора), и просить у libc буфер через malloc не надо — куча в резиденте
|
||
* W2 тесная, и её отказ ронял бы весь звук. Заодно так malloc вообще не
|
||
* приезжает в резидент (275 Б, см. libc/cbl/_cbl_open_raw.c). */
|
||
int pop_sfx_start(void) __banked
|
||
{
|
||
if (pop_snd_ok) return 0;
|
||
if (!pop_snd_want) return 0; /* выключено пользователем (Ctrl+S) */
|
||
if (!snd_blk) return -1; /* набор не загружен */
|
||
if (cbl_open(CBL_FREQ_10K9, CBL_FMT_MONO8, CBL_PUMP_OTIR,
|
||
pop_sfx_fill) != 0)
|
||
return -1;
|
||
pop_snd_ok = 1;
|
||
return 0;
|
||
}
|
||
|
||
void pop_sfx_pause(void) __banked
|
||
{
|
||
if (!pop_snd_ok) return;
|
||
pop_sfx_stop();
|
||
cbl_close();
|
||
pop_snd_ok = 0;
|
||
}
|
||
|
||
void pop_sfx_close(void) __banked
|
||
{
|
||
pop_sfx_pause();
|
||
if (snd_blk) { mem_free_block(snd_blk); snd_blk = 0; }
|
||
}
|
||
|
||
/* Ctrl+S — порт `turn_sound_on_off((!is_sound_on) * 15)` из SDLPoP
|
||
* (seg000:657). Выключение реально ЗАКРЫВАЕТ CBL, а не глушит сэмпл:
|
||
* иначе насос продолжал бы отдавать блоки тишины 85 раз в секунду, а это
|
||
* 3,3 % процессорного времени ни за что (замер: 8 001 такт на прерывание
|
||
* при периоде 245 759). Открытие обратно дешёвое — cbl_open только
|
||
* настраивает железо, страницы набора никуда не девались. */
|
||
void pop_sfx_toggle(void) __banked
|
||
{
|
||
if (pop_snd_want) {
|
||
pop_sfx_pause(); /* pause сам не трогает want... */
|
||
pop_snd_want = 0; /* ...поэтому гасим его следом */
|
||
} else {
|
||
pop_snd_want = 1;
|
||
/* Набора может не быть вовсе (файлы не прочитались, страницы не
|
||
* дались) — тогда откатываем флаг, чтобы индикация не врала. */
|
||
if (pop_sfx_start() != 0) pop_snd_want = 0;
|
||
}
|
||
}
|