SprPoP: звуковые эффекты без перелинковки — раскладка уехала на диск
Часть 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
This commit is contained in:
@@ -8,10 +8,15 @@
|
||||
#define _POP_SFX_INTERNAL_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include "pop_sound_tbl.h" /* POP_SND_* и, по запросу, таблица эффектов */
|
||||
#include "pop_snd_tbl.h" /* тип записи и инварианты (НЕ генерируется) */
|
||||
|
||||
/* Физические страницы набора, по индексу из pop_snd_tbl[].page. */
|
||||
extern uint8_t pop_snd_page[POP_SND_PAGES];
|
||||
/* РАСКЛАДКА НАБОРА и его страницы — общие данные обеих половин, живут в
|
||||
* pop_snd_data.c. Значения приезжают с диска (SND/snd.idx): холодная
|
||||
* половина их читает и проверяет, горячая только пользуется. */
|
||||
extern pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT];
|
||||
extern uint8_t pop_snd_page[POP_SND_MAX_PAGES];
|
||||
/* Сколько страниц у загруженного набора; 0 — набор не поднят. */
|
||||
extern uint8_t pop_snd_pages;
|
||||
/* 1 — набор поднят и CBL открыт; 0 — играем молча. */
|
||||
extern uint8_t pop_snd_ok;
|
||||
|
||||
|
||||
@@ -21,11 +21,8 @@
|
||||
#include <irq.h> /* IRQ_DISABLE/IRQ_ENABLE вокруг курсора */
|
||||
#include "pop_sfx.h"
|
||||
#include "pop_music.h"
|
||||
#define POP_SND_TABLE /* таблица нужна только горячей половине */
|
||||
#include "_pop_sfx.h"
|
||||
|
||||
/* Физические страницы набора; заполняет холодная половина. */
|
||||
uint8_t pop_snd_page[POP_SND_PAGES];
|
||||
uint8_t pop_snd_ok;
|
||||
|
||||
/* ХОЧЕТ ЛИ ЗВУКА ПОЛЬЗОВАТЕЛЬ (Ctrl+S) — отдельно от pop_snd_ok.
|
||||
@@ -170,7 +167,7 @@ int pop_sfx_fill(uint16_t n)
|
||||
* целиком в одну). Гард обязателен: без него ошибка в длине увела
|
||||
* бы насос за pop_snd_page[] и он читал бы чужую память — на слух
|
||||
* это «играет не тот звук», причём каждый раз другой. */
|
||||
if ((uint8_t)(sfx_pg + 1) < POP_SND_PAGES) sfx_pg++;
|
||||
if ((uint8_t)(sfx_pg + 1) < pop_snd_pages) sfx_pg++;
|
||||
else sfx_left = n; /* обрываем */
|
||||
}
|
||||
sfx_left -= n;
|
||||
|
||||
@@ -5,20 +5,163 @@
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sprinter_mem.h>
|
||||
#include <cbl.h>
|
||||
#include "pop_sfx.h"
|
||||
#include "pop_arc.h"
|
||||
#include "_pop_sfx.h"
|
||||
#include "_pop_file.h" /* POP_PATH_CALL — старые DSS и пути */
|
||||
#include <irq.h> /* IRQ_DISABLE вокруг записи в W0 */
|
||||
#include <sprite.h> /* gfx_w0_map — залить блок тишины */
|
||||
|
||||
static uint8_t snd_blk; /* блок EMM под весь набор */
|
||||
|
||||
/* --- ИНДЕКС НАБОРА (SND/snd.idx, формат PSI1) ----------------------- *
|
||||
*
|
||||
* Раскладка эффектов приезжает с диска, а не из EXE: так пара
|
||||
* snd.idx + snd.arc заменяется целиком (SDLPoP <-> MSDOS) без
|
||||
* перекомпиляции игры. Писатель формата — tools/pop_idx.py, он же
|
||||
* покрыт тестом; проверки ниже повторяют его один в один, потому что
|
||||
* доверять содержимому файла нельзя: битый индекс увёл бы насос за
|
||||
* пределы выделенного блока EMM.
|
||||
*
|
||||
* Заголовок: 16 байт, дальше POP_SND_COUNT записей по 5 байт подряд —
|
||||
* ровно в том виде, в каком лежит pop_snd_tbl[], поэтому читаются они
|
||||
* ОДНИМ read'ом прямо туда, без разбора по полям. */
|
||||
#define IDX_HDR_SIZE 16
|
||||
#define IDX_BLOCK_LOG2 7 /* 1 << 7 == POP_SND_BLOCK */
|
||||
#define IDX_PCM_FMT 1 /* u8 mono, CBL_FREQ_10K9 */
|
||||
#define IDX_TBL_SIZE (POP_SND_COUNT * (int)sizeof(pop_snd_ent_t))
|
||||
|
||||
/* Имя знает сам модуль — литерал лежит в rodata банка 8, и open() читает
|
||||
* его, пока в W3 стоит этот же банк (тот же приём, что в pop_arc.c). */
|
||||
static const char idx_path[] = "SND\\snd.idx";
|
||||
|
||||
/* Одна запись: ровно те проверки, что делает упаковщик.
|
||||
*
|
||||
* СЧИТАЕМ В БЛОКАХ, А НЕ В БАЙТАХ. Байтовый адрес конца эффекта
|
||||
* (page * 16384 + off + len) не влезает в uint16, а 32-битная арифметика
|
||||
* на Z80 дорога (memory avoid_32bit_arith_z80). В блоках по 128 весь
|
||||
* набор — не больше POP_SND_MAX_PAGES * 128 = 2048, и всё считается
|
||||
* шестнадцатью битами. */
|
||||
static int8_t idx_entry_ok(const pop_snd_ent_t *e, uint8_t pages)
|
||||
{
|
||||
uint16_t start, end;
|
||||
|
||||
if (e->len == 0) /* нет оцифровки — это музыка */
|
||||
return (e->page == 0 && e->off == 0) ? 0 : -1;
|
||||
if (e->page >= pages) return -1;
|
||||
if (e->off >= 0x4000) return -1;
|
||||
if ((e->off | e->len) & (POP_SND_BLOCK - 1)) return -1;
|
||||
/* Блок тишины неприкосновенен: насос льёт его, когда играть нечего. */
|
||||
if (e->page == POP_SND_SILENCE_PAGE && e->off < POP_SND_BLOCK) return -1;
|
||||
start = (uint16_t)((uint16_t)e->page << 7) + (uint16_t)(e->off >> 7);
|
||||
end = (uint16_t)(start + (uint16_t)(e->len >> 7));
|
||||
/* Длинный эффект переливается на следующие страницы — они обязаны
|
||||
* быть загружены, иначе насос уйдёт в чужую память. */
|
||||
if (end > (uint16_t)((uint16_t)pages << 7)) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Прочитать и проверить индекс. Возвращает число страниц набора либо 0. */
|
||||
static uint8_t idx_load(void)
|
||||
{
|
||||
uint8_t head[IDX_HDR_SIZE];
|
||||
uint8_t pages, i;
|
||||
int fd;
|
||||
|
||||
POP_PATH_CALL(fd, open(idx_path, O_RDONLY));
|
||||
if (fd < 0) return 0;
|
||||
if (read(fd, head, IDX_HDR_SIZE) != IDX_HDR_SIZE ||
|
||||
memcmp(head, "PSI1", 4) != 0 ||
|
||||
head[4] != POP_SND_COUNT ||
|
||||
head[5] != (uint8_t)sizeof(pop_snd_ent_t) ||
|
||||
head[6] != IDX_BLOCK_LOG2 ||
|
||||
head[7] != IDX_PCM_FMT) {
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
pages = head[8];
|
||||
if (pages == 0 || pages > POP_SND_MAX_PAGES) { close(fd); return 0; }
|
||||
/* Записи ложатся прямо в резидентную таблицу: дисковый формат и
|
||||
* структура совпадают побайтово (проверено статически в
|
||||
* pop_snd_data.c и полем head[5] выше). */
|
||||
if (read(fd, pop_snd_tbl, IDX_TBL_SIZE) != IDX_TBL_SIZE) {
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
close(fd);
|
||||
for (i = 0; i < POP_SND_COUNT; i++)
|
||||
if (idx_entry_ok(&pop_snd_tbl[i], pages) != 0) return 0;
|
||||
return pages;
|
||||
}
|
||||
|
||||
/* НАБОРА НЕТ — НО НАСОС ВСЁ РАВНО ОБЯЗАН РАБОТАТЬ.
|
||||
*
|
||||
* Тишину в паузах насос льёт из ПЕРВОГО БЛОКА НАБОРА, поэтому без набора
|
||||
* CBL не открывался вовсе. А вместе с ним вставала и МУЗЫКА: её блоки
|
||||
* отсчитывает тот же насос (pop_mus_left убывает только в pop_sfx_fill).
|
||||
* Заставка при этом висла намертво — ждала конца трека, который не
|
||||
* начинал играть, и pop_music_busy() не гас никогда (поймано
|
||||
* пользователем 2026-08-31 на образе без snd.idx).
|
||||
*
|
||||
* Поэтому при отказе индекса поднимаем ПУСТОЙ набор: одна страница EMM с
|
||||
* блоком тишины. Эффектов нет — все len в таблице нулевые, и
|
||||
* pop_sfx_play уходит по ветке «нет оцифровки», — а музыка играет.
|
||||
*
|
||||
* ЗАЛИВАЕМ ТОЛЬКО ПЕРВЫЙ БЛОК, и это не экономия: gfx_w0_page_prepare
|
||||
* ставит в страницу IRQ/NMI-стабы, чтобы прерывание при замапленном W0
|
||||
* нашло валидный код. Заливка ВСЕЙ страницы затирает их, и первое же
|
||||
* прерывание уводит процессор в мусор — чёрный экран на старте (поймано
|
||||
* при первой попытке этого фикса). Насосу больше 128 байт и не нужно:
|
||||
* он берёт ровно блок тишины, да и то через W3, а не через W0.
|
||||
* Запись — под DI: 128 байт накрывают стаб RST38, и до unmap прерываний
|
||||
* быть не должно. */
|
||||
static int8_t snd_silence_only(void)
|
||||
{
|
||||
uint8_t blk, page;
|
||||
uint8_t *w0 = (uint8_t *)0x0000; /* окно 0 — сюда ляжет страница */
|
||||
|
||||
memset(pop_snd_tbl, 0, sizeof(pop_snd_tbl));
|
||||
blk = mem_alloc_pages(1);
|
||||
if (!blk) return -1;
|
||||
page = mem_get_page(blk, 0);
|
||||
if (!page) { mem_free_block(blk); return -1; }
|
||||
gfx_w0_page_prepare(page);
|
||||
IRQ_DISABLE();
|
||||
gfx_w0_map(page);
|
||||
memset(w0, 0x80, POP_SND_BLOCK); /* 0x80 — тишина в 8-бит без знака */
|
||||
gfx_w0_unmap();
|
||||
IRQ_ENABLE();
|
||||
pop_snd_page[POP_SND_SILENCE_PAGE] = page;
|
||||
snd_blk = blk;
|
||||
pop_snd_pages = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int pop_sfx_init(void) __banked
|
||||
{
|
||||
uint8_t i, blk;
|
||||
uint8_t i, blk, pages;
|
||||
|
||||
if (pop_snd_ok) return 0;
|
||||
|
||||
/* СНАЧАЛА ИНДЕКС, ПОТОМ ДАННЫЕ: сколько страниц читать и куда какой
|
||||
* эффект лёг — знает только он. Набора без индекса не бывает:
|
||||
* держать в EXE запасную таблицу нельзя, иначе привязка бинарника к
|
||||
* конкретному набору вернётся через чёрный ход. */
|
||||
pages = idx_load();
|
||||
if (!pages) {
|
||||
/* Набор эффектов не поднялся — но звук как таковой поднять надо,
|
||||
* иначе встанет музыка (см. snd_silence_only). Возвращаем -1:
|
||||
* эффектов действительно нет, и вызывающий вправе об этом знать. */
|
||||
if (snd_silence_only() != 0) return -1;
|
||||
pop_snd_want = 1;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Одним блоком: страницы блока идут подряд по логическому индексу, и
|
||||
* таблица звуков адресует их именно так (page = смещение >> 14).
|
||||
*
|
||||
@@ -28,14 +171,17 @@ int pop_sfx_init(void) __banked
|
||||
* pop_arc.h. */
|
||||
{
|
||||
pop_arc_t arc;
|
||||
uint8_t tbl[POP_SND_PAGES * POP_ARC_REC];
|
||||
uint8_t tbl[POP_SND_MAX_PAGES * POP_ARC_REC];
|
||||
|
||||
if (pop_arc_open_id(&arc, POP_ARC_SND, tbl, POP_SND_PAGES) !=
|
||||
POP_SND_PAGES)
|
||||
/* Архив обязан содержать РОВНО столько страниц, сколько обещал
|
||||
* индекс: расходящейся паре доверять нельзя — она означает, что
|
||||
* snd.idx и snd.arc из разных наборов. */
|
||||
if (pop_arc_open_id(&arc, POP_ARC_SND, tbl, POP_SND_MAX_PAGES) !=
|
||||
(int8_t)pages)
|
||||
return -1;
|
||||
blk = mem_alloc_pages(POP_SND_PAGES);
|
||||
blk = mem_alloc_pages(pages);
|
||||
if (!blk) { pop_arc_close(&arc); return -1; }
|
||||
for (i = 0; i < POP_SND_PAGES; i++) {
|
||||
for (i = 0; i < pages; i++) {
|
||||
pop_snd_page[i] = mem_get_page(blk, i);
|
||||
if (!pop_snd_page[i] ||
|
||||
pop_arc_read(&arc, tbl, i, 0, pop_snd_page[i], 0, 0) != 16384) {
|
||||
@@ -47,6 +193,9 @@ int pop_sfx_init(void) __banked
|
||||
pop_arc_close(&arc);
|
||||
}
|
||||
snd_blk = blk;
|
||||
/* Число страниц ставим ПОСЛЕДНИМ: до этого момента насос обязан
|
||||
* видеть 0 и не пытаться ходить по полузаполненной таблице. */
|
||||
pop_snd_pages = pages;
|
||||
pop_snd_want = 1; /* набор есть — звук по умолчанию включён */
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* pop_snd_data.c — РЕЗИДЕНТНЫЕ ДАННЫЕ звукового набора.
|
||||
*
|
||||
* Отдельным модулем, потому что стороны у этих данных разные: заполняет
|
||||
* их ХОЛОДНЫЙ загрузчик из банка 8 (pop_sfx_cold.c), а читает ГОРЯЧИЙ
|
||||
* насос из резидента (pop_sfx.c) — в том числе из прерывания. Держать
|
||||
* их в любой из половин значило бы тянуть чужую в резидент.
|
||||
*
|
||||
* Инициализаторов нет намеренно: crt0 зануляет _DATA (memory
|
||||
* sdcc_static_storage_gotcha), а «набор не загружен» — это и есть нули.
|
||||
*
|
||||
* ЦЕНА ПЕРЕЕЗДА. Таблица была `static const` в _CODE (285 Б); сняв
|
||||
* const, мы переносим её в _DATA — суммарный резидентный расход не
|
||||
* меняется, потому что в huge-модели _CODE и данные лежат подряд в одном
|
||||
* диапазоне 0x4000..0xBFFF. Реально добавилось 8 байт: pop_snd_page
|
||||
* вырос с размера набора до POP_SND_MAX_PAGES (запас под чужие наборы)
|
||||
* плюс байт самого счётчика.
|
||||
*/
|
||||
#include <stdint.h>
|
||||
#include "pop_snd_tbl.h"
|
||||
|
||||
/* РАСКЛАДКА НАБОРА — приезжает из SND/snd.idx целиком, одним read'ом
|
||||
* прямо сюда. Поэтому размер записи обязан совпадать с дисковым: если
|
||||
* ABI когда-нибудь добавит выравнивание, сборка упадёт здесь, а не
|
||||
* рассыплется звуком в рантайме. */
|
||||
pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT];
|
||||
typedef char pop_snd_ent_is_5_bytes[(sizeof(pop_snd_ent_t) == 5) ? 1 : -1];
|
||||
|
||||
/* Физические страницы EMM, по индексу из pop_snd_tbl[].page. */
|
||||
uint8_t pop_snd_page[POP_SND_MAX_PAGES];
|
||||
|
||||
/* Сколько страниц у ЭТОГО набора (1..POP_SND_MAX_PAGES). Насос сверяет
|
||||
* с ним переход длинного эффекта через границу страницы. */
|
||||
uint8_t pop_snd_pages;
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* pop_snd_tbl.h — РАСКЛАДКА ОЦИФРОВАННЫХ ЭФФЕКТОВ: тип и инварианты.
|
||||
*
|
||||
* Раньше этот файл ПЕЧАТАЛ упаковщик (gen/pop_sound_tbl.h), и конкретные
|
||||
* page/off/len становились частью EXE — из-за чего замена звукового набора
|
||||
* (SDLPoP <-> MSDOS) требовала перекомпиляции игры. Теперь значения
|
||||
* приезжают с диска (SND/snd.idx, формат PSI1 — tools/pop_idx.py), а
|
||||
* здесь остаётся только то, что от набора НЕ зависит: устройство записи и
|
||||
* пределы, на которые рассчитан загрузчик.
|
||||
*
|
||||
* ЗАПИСЬ ЧИТАЕТСЯ С ДИСКА ПОБАЙТОВО, прямо в pop_snd_tbl[]. Значит
|
||||
* раскладка структуры в ABI SDCC z80 (uint8 + uint16 + uint16 подряд, без
|
||||
* выравнивания = 5 байт) — часть дискового контракта, а не деталь
|
||||
* компилятора. Расхождение молча разъехало бы весь набор, поэтому оно
|
||||
* ловится статически (pop_snd_data.c) и проверяется загрузчиком по полю
|
||||
* размера записи в заголовке индекса.
|
||||
*/
|
||||
#ifndef POP_SND_TBL_H
|
||||
#define POP_SND_TBL_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/* Пространство id оригинала: 0..56. От набора не зависит. */
|
||||
#define POP_SND_COUNT 57
|
||||
/* Блок запроса CBL при 8 бит; страница EMM кратна ему, поэтому ни один
|
||||
* блок не пересекает границу страницы (см. docs/sound_plan.md). */
|
||||
#define POP_SND_BLOCK 128
|
||||
/* СКОЛЬКО СТРАНИЦ ГОТОВ ПРИНЯТЬ ЗАГРУЗЧИК — вместимость, а НЕ размер
|
||||
* конкретного набора: тот приходит в индексе (pop_snd_pages). Запас
|
||||
* ради будущих наборов без перекомпиляции; цена — 16 байт таблицы
|
||||
* страниц вместо 9-10. */
|
||||
#define POP_SND_MAX_PAGES 16
|
||||
|
||||
/* ПЕРВЫЙ БЛОК СТРАНИЦЫ 0 — ТИШИНА. Насос льёт её, когда играть нечего:
|
||||
* без этого железо доигрывает по кругу хвост своего буфера (жужжание).
|
||||
* Это инвариант ПРОГРАММЫ — упаковщик обязан оставить блок свободным, а
|
||||
* загрузчик проверяет, что ни одна запись в него не залезла. */
|
||||
#define POP_SND_SILENCE_PAGE 0
|
||||
#define POP_SND_SILENCE_OFF 0x0000
|
||||
|
||||
/* page — индекс в pop_snd_page[] (физические страницы EMM), off — начало
|
||||
* внутри страницы, len — длина PCM. Оба кратны POP_SND_BLOCK; len == 0
|
||||
* значит «у этого id нет оцифровки» (это музыка, см. sound_plan.md). */
|
||||
typedef struct {
|
||||
uint8_t page;
|
||||
uint16_t off;
|
||||
uint16_t len;
|
||||
} pop_snd_ent_t;
|
||||
|
||||
#endif /* POP_SND_TBL_H */
|
||||
Reference in New Issue
Block a user