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
1197 lines
86 KiB
Markdown
1197 lines
86 KiB
Markdown
# Звук в порте PoP — разбор и план
|
||
|
||
Дата: 2026-08-20, музыка дописана 2026-08-25. Статус: **PCM-эффекты
|
||
реализованы; музыка — путь C (PCM через CBL), первый трек играет.**
|
||
|
||
Задача пользователя: добавить звук. Приоритет — эффекты; музыку, если
|
||
найдётся способ. Эффекты — **обязательно WAV, а не PC-спикер**
|
||
(уточнение 2026-08-20; см. §1а — оказалось, что они и так все в WAV). Ниже — что реально лежит в ассетах, что умеет железо, и
|
||
почему получившийся план вышел проще, чем ожидалось.
|
||
|
||
## 1. Главный вывод
|
||
|
||
**Ни MIDI разбирать, ни ноты сочинять не придётся, и ресэмплировать тоже.**
|
||
|
||
- Эффекты уже лежат **8-битным беззнаковым PCM на 11 000 Гц**, а у CBL есть
|
||
режим **10 937,5 Гц** — расхождение 0,6 %, на слух неразличимо. Формат
|
||
сэмпла совпадает с нашим CBL байт в байт (`cbl.h`: 8 бит, беззнаковый,
|
||
центр 0x80). То есть данные играются **как есть**, без конверсии.
|
||
- Музыка есть в виде **списков нот PC-спикера — 7 КБ на всю игру**, а нота
|
||
там задана прямо в ГЕРЦАХ. Пересчёт в делитель AY — одно деление.
|
||
- AY и COVOX на Sp2000 сведены в **один ЦАП TDA1543** (док Ивана Мака,
|
||
§5), значит музыка на AY и эффекты через CBL звучат ОДНОВРЕМЕННО, и
|
||
смешивать их программно не надо.
|
||
|
||
## 1а. Уточнение после разбора ВСЕХ наборов MS-DOS версии (2026-08-20)
|
||
|
||
Пользователь попросил, чтобы эффекты были не PC-спикером, а WAV, и заодно
|
||
посмотреть `mt32snd[1-2].dat`. Разобрал все восемь `.dat` из `MSDOS/`.
|
||
Ответ короткий: **эффекты И ТАК все до одного есть в WAV, а вот у музыки
|
||
WAV нет ни в одном наборе.**
|
||
|
||
| набор | формат | какие звуки | сколько |
|
||
|---|---|---|---|
|
||
| `digisnd1..3` | **WAV**, 8 бит PCM | эффекты 0..23, 44..49, 51 | **31** |
|
||
| `mt32snd1..2` | MIDI для Roland MT-32 | ТЕ ЖЕ эффекты 0..23, 44..51 | 31 |
|
||
| `midisnd1..2` | MIDI (AdLib/GM) | **музыка** 24..43, 50, 52..56 | 22 |
|
||
| `ibm_snd1..2` | ноты PC-спикера | **всё подряд, 0..56** | 57 |
|
||
|
||
Здесь пряталась ловушка: `mt32snd` по имени похож на «музыку получше», а
|
||
на деле это набор ЭФФЕКТОВ для владельцев MT-32 — те же id, что у
|
||
`digisnd`. Музыки в нём нет вовсе.
|
||
|
||
Частоты WAV: 28 звуков на 11 000 Гц, по одному на 8 200, 14 000 и 2 750.
|
||
Итого 112 922 сэмпла = **11,4 с, ~110 КБ ≈ 6,7 EMM-страниц**.
|
||
|
||
Только PC-спикером, без альтернатив, остаются четыре id: 31, 34, 42
|
||
(пустые) и **38 `blink`** — четыре ноты. То есть на весь звук игры
|
||
спикер нужен ровно для одного писка.
|
||
|
||
### Музыка: WAV нет, есть три пути
|
||
|
||
| путь | данные | что получится | цена |
|
||
|---|---|---|---|
|
||
| **A. Ноты PC-спикера на AY** | 7 КБ | один квадратный голос — ровно то, что слышали на IBM PC 1989 | секвенсор на полсотни строк |
|
||
| **B. MIDI -> AY, три голоса** | 27 КБ исходника | богаче: бас + мелодия + арпеджио | разбор MIDI + раскладка по каналам |
|
||
| **C. MIDI -> WAV на хосте, стрим через CBL** | см. ниже | настоящее звучание, любое | нужен синтезатор на хосте + место |
|
||
|
||
Про объём для пути C (замерено по длительностям треков):
|
||
|
||
| группа | треков | длительность | WAV 11 кГц |
|
||
|---|---:|---:|---|
|
||
| звучат ПО ХОДУ игры (гимн уровня, смерть, зелья, перо, победа) | 12 | 74,7 с | **803 КБ = 50 EMM-страниц** |
|
||
| заставки и титры | 10 | 248,1 с | 2 665 КБ = 167 страниц |
|
||
|
||
Игровая половина в EMM **влезает** (при ~215 свободных страницах), а
|
||
заставочная — нет, её пришлось бы стримить с диска. Но заставки идут
|
||
тогда, когда игра ничего не рисует, так что стрим там как раз уместен.
|
||
|
||
**Предложение:** начинать с A (7 КБ, работает сразу, ноль рисков), а C
|
||
держать как отдельную фазу — она ортогональна: проигрыватель WAV для
|
||
музыки это тот же `cbl_push`, что и для эффектов, только длиннее буфер.
|
||
B имеет смысл только если C окажется неподъёмным по месту.
|
||
|
||
## 1б. РЕШЕНИЯ (пользователь, 2026-08-20)
|
||
|
||
1. **Эффекты — WAV через CBL, 8 бит, МОНО, единая частота.** Проверено по
|
||
`convert_digi_sound` (`seg009.c:2358`): один байт на кадр, то есть
|
||
моно, и байт беззнаковый (`(b | b<<8) - 32768`), центр 0x80 — ровно
|
||
формат нашего CBL. Стерео в данных нет вовсе: каналы у оригинала
|
||
размножаются уже на выходе (`digi_audiospec->channels`).
|
||
2. **Музыка, первый заход — путь A** (ноты спикера на AY).
|
||
3. **Заставки и титры — потом WAV.** Конфликта с эффектами там нет:
|
||
одновременно они не звучат.
|
||
4. **Музыка ПО ХОДУ игры** (она может совпасть с эффектом) — открыто, два
|
||
варианта: либо тоже WAV с ГАШЕНИЕМ эффектов на время музыки (музыка
|
||
важнее — **проверить на слух**), либо путь B (MIDI -> три голоса AY).
|
||
5. **Все эффекты привести к одной частоте.**
|
||
6. Синтезатор для MIDI -> WAV — решать ближе к делу; годятся и онлайн-
|
||
конвертеры, хоть вручную, если fluidsynth/timidity не поставится.
|
||
|
||
### Про единую частоту (замер)
|
||
|
||
Приводим не к 11 000, а ровно к **10 937,5 Гц — частоте CBL**
|
||
(`CBL_FREQ_10K9`). Тогда тон точен, а не «на 0,6 % ниже»: проигрывание
|
||
11 000 Гц данных на 10 937,5 даёт сдвиг **−9,9 цента**, что на коротком
|
||
эффекте не слышно, но бесплатно избавиться от него всё равно приятно —
|
||
пересчитывать три файла всё равно придётся.
|
||
|
||
| id | звук | было | станет | дельта |
|
||
|---|---|---|---|---:|
|
||
| 15 | `leveldoor_sliding` | 2 750 Гц, 4 436 сэмплов | 17 643 | **+13 207 Б** |
|
||
| 23 | `footstep` | 8 200 Гц, 996 | 1 329 | +333 Б |
|
||
| 51 | `princess_door_opening` | 14 000 Гц, 6 188 | 4 834 | −1 354 Б |
|
||
| — | остальные 28 (11 000 Гц) | — | ×0,9943 | −588 Б |
|
||
|
||
Итог: **112 922 -> 124 531 Б, 6,9 -> 7,6 EMM-страниц.** Рост целиком от
|
||
`leveldoor_sliding`: источник у него 2 750 Гц, вчетверо реже целевой, и
|
||
апсэмплинг не улучшит звучание — только уравняет формат. Платим 0,8
|
||
страницы за то, что **CBL открывается ОДИН раз и частоту менять не надо
|
||
никогда** — ни между эффектами, ни при переходе на музыку-WAV.
|
||
|
||
(Альтернатива для него — хранить как есть и повторять каждый сэмпл
|
||
четырежды в рантайме: 2 750 × 4 = 11 000 ровно. Это код в `fill()` ради
|
||
13 КБ; не стоит того, но если место когда-нибудь прижмёт — вариант есть.)
|
||
|
||
## 1в. Бюджет памяти EMM (живой замер 2026-08-20)
|
||
|
||
Замерено `mem_info` из работающей программы (уровень 1), а не посчитано на
|
||
бумаге: инструментовка ставилась временно и откатана.
|
||
|
||
| | страниц | КБ |
|
||
|---|---:|---:|
|
||
| всего в машине | 256 | 4 096 |
|
||
| система (DSS) + сам exe: база + 8 банков кода | **43** | 688 |
|
||
| наши ассеты | **81** | 1 296 |
|
||
| **занято** | **124** | 1 984 |
|
||
| **свободно** | **132** | **2 112 (2,06 МБ)** |
|
||
|
||
Разбивка 81 страницы ассетов (сходится точно):
|
||
|
||
| набор | страниц |
|
||
|---|---:|
|
||
| **Тень** (`sk*` 28 + `sf*` 4) | **32** |
|
||
| Кид (`kid0..27`) | 28 |
|
||
| фон тайлсета (env 10 + wall 1 + fore 1) | 12 |
|
||
| страж | 5 |
|
||
| зелья (chtab_1), меч, `kid_data.bin`, страница уровня | по 1 |
|
||
|
||
Самый крупный потребитель теперь — **набор Тени, 32 страницы**, больше
|
||
самого Кида. Если место когда-нибудь прижмёт, там есть очевидный резерв
|
||
(кадры смерти и позы, в которых Тень не бывает), но при 132 свободных
|
||
страницах трогать незачем.
|
||
|
||
### Что из этого следует для звука
|
||
|
||
| статья | страниц | останется свободно |
|
||
|---|---:|---:|
|
||
| эффекты WAV, все 31, 10 937,5 Гц | **8** | 124 |
|
||
| музыка ПО ХОДУ игры в WAV (путь C, 12 треков) | 50 | 74 |
|
||
| заставки и титры в WAV (10 треков, 248 с) | 167 | **не влезает** |
|
||
|
||
То есть эффекты — капля, игровая музыка в WAV тоже поместится, а
|
||
заставочную придётся стримить с диска в любом случае (что и планировалось:
|
||
во время заставок игра ничего не рисует).
|
||
|
||
Оговорка: 132 свободных страницы — это на уровне 1. На уровне 9 добавятся
|
||
зеркальные наборы (`pop_vflip_load_all`: 28 Кид + 5 страж + меч = 34
|
||
страницы), останется ~98. Проверять запас надо ИМЕННО ТАМ.
|
||
|
||
## 1г. MSDOS против SDLPoP: чем отличаются наборы (сверено 2026-08-20)
|
||
|
||
У нас лежат ДВЕ копии звука — оригинальные `.dat` в `MSDOS/` (версия
|
||
1.3/1.4) и распакованные ассеты `SDLPoP/data/` (версия 1.0/1.1). Разница
|
||
есть, и она влияет на выбор источника.
|
||
|
||
### Оцифровка: по умолчанию SDLPoP, MSDOS — опция
|
||
|
||
**С 2026-08-31 умолчание — SDLPoP** (`SND_SRC=sdlpop`): он выкачивается
|
||
автоматически (`make fetch`), а оригинального дистрибутива DOS у сборки
|
||
может не быть вовсе — и она обязана работать без него. У кого дистрибутив
|
||
есть, включает лучший набор явно: `make SND_SRC=msdos`.
|
||
|
||
Платим за это ровно тем, что разобрано ниже. Заголовок в поставках разный
|
||
(`digi_new_type` против `digi_type`), но **28 звуков из 31 совпадают
|
||
побайтно**. Различаются три, и все не в пользу SDLPoP:
|
||
|
||
| id | звук | MSDOS | SDLPoP |
|
||
|---|---|---:|---:|
|
||
| 10 | `sword_vs_sword` | 5 020 сэмплов | 3 504 |
|
||
| 11 | `sword_moving` | 1 172 | 1 172, но **другие байты** |
|
||
| 48 | `spiked` | 5 069 | **7** — то есть звука нет |
|
||
|
||
`spiked` в наборе SDLPoP фактически пустой: это звук насаживания на пики
|
||
(`spiked()`, seg005.c:245, кадр 177), и с умолчанием он не звучит — от пик
|
||
остаётся только шипение выдвижения (49 `spikes`). Звон клинков (10) на
|
||
секунду короче, а 11 просто другая запись.
|
||
|
||
Всё остальное — включая уровни `LEVELS/res20xx.bin` — берётся из SDLPoP
|
||
всегда: `MSDOS/` нужен ровно для этих трёх звуков и ни для чего больше.
|
||
|
||
### MIDI: если дойдём до музыки — брать SDLPoP
|
||
|
||
Здесь всё наоборот. Содержимое музыкально то же (деление 480, те же
|
||
каналы 0..7 плюс ударные), но:
|
||
|
||
| | MSDOS | SDLPoP |
|
||
|---|---|---|
|
||
| формат MIDI | **0** — всё слито в ОДНУ дорожку | **1** — 8-9 дорожек |
|
||
| размер (звук 24) | 327 Б | 448 Б |
|
||
| размер (звук 56) | 13 587 Б | 12 773 Б |
|
||
|
||
Формат 1 с отдельной дорожкой на инструмент — это готовое разделение
|
||
голосов. Для пути B (MIDI -> три канала AY) оно решает половину задачи:
|
||
дорожки можно выбирать напрямую (бас / мелодия / гармония), а не
|
||
разбирать слитый поток и догадываться, что чем было.
|
||
|
||
**Итог: эффекты из MSDOS, музыка (когда дойдёт) из SDLPoP.**
|
||
|
||
## 2. Что лежит в ассетах (замерено, а не по памяти)
|
||
|
||
Звук в PoP адресуется как ресурс `10000 + N`, N = 0..56 — 57 звуков
|
||
(`load_sound`, `seg009.c:2289`). Наборов три, и они ПАРАЛЛЕЛЬНЫЕ: один и
|
||
тот же звук есть в нескольких видах.
|
||
|
||
| набор | что это | объём | покрытие |
|
||
|---|---|---:|---|
|
||
| `DIGISND1..3.DAT` | оцифровка, 8 бит PCM | 103 941 Б | **31 звук** (эффекты) |
|
||
| `MIDISND1..2.DAT` | MIDI-музыка | 27 776 Б | музыка |
|
||
| `IBM_SND1..2` (распакованы) | ноты PC-спикера | **7 КБ** | **все 57** |
|
||
|
||
### 2.1 Оцифровка (эффекты)
|
||
|
||
Разбор контейнера: индекс по 8 байт на запись (id, offset, size), **первый
|
||
байт ресурса — контрольная сумма**, тело за ней (спецификация
|
||
`POP-DAT-FormatSpecifications`, §3.1.2 — на этом я сначала споткнулся и
|
||
читал мусор). Тело — `digi_type`: `word rate, word count, word unk,
|
||
byte size`, дальше сэмплы.
|
||
|
||
- 31 звук, все 8-битные;
|
||
- частоты: **28 звуков на 11 000 Гц**, по одному на 8 200 и 14 000;
|
||
- 103 701 сэмпл = **9,4 секунды**, **103 941 Б ≈ 6,3 EMM-страницы**.
|
||
|
||
### 2.2 Ноты PC-спикера (и эффекты, и музыка)
|
||
|
||
Формат: `byte type(=0), word tempo`, дальше тройки `word frequency,
|
||
byte length`; `frequency <= 1` — пауза, `0x12` — конец. Ключевое, что
|
||
пришлось смотреть в `play_speaker_sound`/`speaker_callback` (`seg009.c`):
|
||
|
||
- **`frequency` — это ГЕРЦЫ напрямую** (`generate_square_wave(stream,
|
||
(float)note->frequency, ...)`), а не делитель PIT, как кажется по
|
||
маленьким числам;
|
||
- длительность ноты = `length / tempo` СЕКУНД.
|
||
|
||
Замеры по всем 57 звукам: **2212 нот**, частоты 16..65507 Гц,
|
||
длительности 1,74..2571 мс.
|
||
|
||
Из них 23 звука — те, у которых оцифровки НЕТ, то есть вся музыка:
|
||
заставки, гимны уровней, смерть, победа, титры (id 24..43, 50..56).
|
||
**1469 нот**, и вот их длительности:
|
||
|
||
| длительность ноты | нот | доля |
|
||
|---|---:|---:|
|
||
| < 3 мс | 0 | 0 % |
|
||
| 3..12 мс | 2 | 0,1 % |
|
||
| 12..25 мс | 140 | 9,5 % |
|
||
| > 25 мс | 1327 | 90,3 % |
|
||
|
||
Это число решает вопрос про таймер — см. §4.
|
||
|
||
## 3. Что умеет железо (док Ивана Мака §5 + MAME)
|
||
|
||
- **AY-3-8910/8912** в ПЛМ, «программируется по стандартным описаниям» —
|
||
то есть ZX-порты; в MAME он заведён как `AY8910(config, "ay8912",
|
||
X_SP/24)`, то есть **тактовая 1,75 МГц**. Период канала = 109375 /
|
||
частота(Гц), 12 бит (макс 4095) → снизу берутся частоты от ~27 Гц.
|
||
Точные Z80-адреса портов идут через таблицу DCP, а не напрямую —
|
||
**проверить артефактом до кодинга** (ожидаем ZX-стандарт 0xFFFD/0xBFFD).
|
||
- **CBL** — COVOX с буфером 256 Б, две половины по 128; бит 7 порта 0xFE
|
||
показывает играющую половину, порт управления 0x4E. Прерывание —
|
||
когда половина сменилась.
|
||
- **Бипер** (бит 5 порта 0xFE) — туда же в ЦАП. Нам не нужен.
|
||
- Всё это **сведено в один ЦАП**, поэтому AY и CBL звучат вместе.
|
||
|
||
У нас уже есть готовая обвязка CBL (`libc/include/cbl.h`): callback
|
||
`fill(n)`, выдача блока через `cbl_push_otir()` (порт 0x4F) или через
|
||
акселератор, коды частот, счётчик недоливов. Своего кольца библиотека не
|
||
держит — данные пропихиваются прямо из наших EMM-страниц.
|
||
|
||
## 4. Предлагаемая архитектура
|
||
|
||
```
|
||
эффекты (31 шт, 8 бит 11 кГц) музыка (23 шт, ноты)
|
||
│ │
|
||
EMM-страницы (6,3) 7 КБ нот в банке
|
||
│ │
|
||
cbl_push_otir из fill() запись 3 регистров AY
|
||
│ │
|
||
CBL (10,9 кГц) ──────┐ ┌────────── AY (1,75 МГц)
|
||
▼ ▼
|
||
TDA1543 (аппаратное смешивание)
|
||
```
|
||
|
||
**Один источник прерываний — CBL.** Его callback приходит каждые 128
|
||
сэмплов = **11,7 мс** при 10,9 кГц, и он же двигает секвенсор музыки.
|
||
Отдельный таймер (CTC) НЕ нужен: по таблице из §2.2 короче 12 мс всего
|
||
2 ноты из 1469 — они растянутся на один тик, чего не слышно.
|
||
|
||
Почему это важно: `irq_ctc_install` сейчас требует кода в W2 (tiny/big),
|
||
а SprPoP — huge, и попадёт ли туда CTC-трамплин, зависит от раскладки.
|
||
Обойтись без него — значит не открывать этот фронт вовсе.
|
||
|
||
**Пейсинг кадра при этом не страдает.** Наш темп считается ПО ЛУЧУ
|
||
(`pop_pace.h`), а не по кадровым прерываниям, поэтому то, что CBL-ветка
|
||
трамплина делает приватный RETI и съедает кадровые прерывания, нам
|
||
безразлично. Если бы пейсинг остался на прерываниях — звук бы его сломал.
|
||
|
||
## 5. Объём работ
|
||
|
||
| фаза | что | оценка |
|
||
|---|---|---|
|
||
| **З1** | распаковщик `pop_pack_sound.py`: DAT → `.snd`-страницы EMM (эффекты) + `.not` (ноты музыки) | формат уже разобран |
|
||
| **З2** | `pop_sfx.c`: `cbl_open` + `fill()`, таблица «звук → страница/смещение/длина», `pop_sfx_play(id)` | ядро |
|
||
| **З3** | развесить вызовы: 66 мест `play_sound` в оригинале; у нас часть уже помечена TODO (`pop_ctrl.c:408`, `guards.c:992`, `pop_map.c:3035/3107`, …). **Плюс бесплатный кусок**: опкод `SEQ_SOUND` в seqtbl уже разбирается нашим `play_seq` (`pop_kid.c:326`) — шаги, приземления и прочее поедут сами | механическая |
|
||
| **З4** | `pop_music.c`: секвенсор нот на AY (путь A), тик из CBL-callback | небольшая |
|
||
| **З6** | заставки и титры — WAV-музыка потоком (эффекты в это время не звучат) | после З1-З4 |
|
||
| **З7** | музыка по ходу игры: WAV с гашением эффектов ЛИБО путь B — решать по итогам З6 | открыто |
|
||
| **З5** | приоритеты и вытеснение: у оригинала `play_sound` глушит предыдущий (`stop_sounds`), музыка и эффект — разные каналы | правила из seg009 |
|
||
|
||
## 6. Что проверить артефактом ДО кодинга
|
||
|
||
1. **Порты AY на Sprinter** — записать в 0xFFFD/0xBFFD и убедиться, что
|
||
MAME отдаёт звук (порты идут через DCP-таблицу, «стандартные ZX» —
|
||
это ожидание, а не факт).
|
||
2. **CBL в режиме huge.** Шапка `cbl.h` говорит «код/данные в W2
|
||
(tiny/big)», но это скорее всего устаревшая оговорка: IM2-трамплин
|
||
давно переделан на all-modes, и наш кадровый путь в huge работает.
|
||
Проверить `cbl_open` из SprPoP.
|
||
3. **Совместное владение портом 0xFE.** Бит 5 (луч) у нас держится через
|
||
`_cbl_port_ref` «немым» кодом частоты; когда откроется НАСТОЯЩИЙ CBL,
|
||
владение переходит к нему. Убедиться, что пейсинг переживает
|
||
`cbl_open`/`cbl_close`.
|
||
4. **Цена fill() в кадре.** 128 байт через `cbl_push_otir` раз в 11,7 мс
|
||
— замерить тем же способом, что и остальное (брейкпоинт + totalcycles).
|
||
|
||
## 7. Про MIDI — почему не он
|
||
|
||
Музыка в MIDISND — настоящие MThd/MTrk чанки, 27 КБ. Чтобы играть их на
|
||
AY, нужен разбор MIDI, раскладка каналов на три голоса и таблица
|
||
инструментов — это отдельный проект, и звучать он будет НЕ так, как
|
||
оригинал на PC. А набор PC-спикера — это ровно то, что слышал игрок на
|
||
IBM PC 1989 года: один квадратный голос. Он у нас есть целиком, весит
|
||
7 КБ и ложится на AY напрямую.
|
||
|
||
Если позже захочется богаче — материал уже будет разобран, и можно
|
||
разложить те же мелодии на три канала AY (бас/мелодия/арпеджио), не трогая
|
||
ни данные, ни секвенсор.
|
||
|
||
---
|
||
|
||
## 8. Разводка вызовов по коду (сделано 2026-08-20)
|
||
|
||
Портированы ВСЕ места `play_sound()` SDLPoP, у которых есть оцифровка
|
||
(id 0..23, 44..49, 51 — остальные id это музыка, у них в
|
||
`pop_sound_tbl.h` длина 0, и вызов просто глушит текущий эффект).
|
||
|
||
| id | что | где у нас | оригинал |
|
||
|----|-----|-----------|----------|
|
||
| 0 | разбился насмерть | `pop_map.c` land | seg005 |
|
||
| 1 | крик падения | `pop_map.c` do_fall | seg005:39 |
|
||
| 2 | плита рухнула | `pop_room.c` (обе ветки посадки) | seg007 |
|
||
| 3 | кнопка нажата | `pop_trob.c` | seg007 |
|
||
| 4/5/6/7 | ворота: закрываются / открываются / рухнули / стоп | `pop_trob.c` | seg007 |
|
||
| 8 | удар о стену | `pop_map.c` bumped_fall/bumped_floor + seqtbl | seg004/seg006 |
|
||
| 9 | зацеп за карниз | `pop_map.c` check_grab | seg006 |
|
||
| 10 | клинок о клинок | `sprpop.c` после `check_sword_hurt`, если один из бойцов в кадре 167 | seg000:1353 |
|
||
| 11 | свист клинка мимо | `guards.c` check_hurting | seg002:0DAE |
|
||
| 12/13 | ранен соперник / Кид | `guards.c` hurt_by_sword | seg002:0C1F |
|
||
| 13 | Кид ранен зельем | `pop_map.c` ветка «злого» зелья | seg006:1894 |
|
||
| 14/15 | дверь уровня: закрывается / едет | `pop_trob.c` | seg007 |
|
||
| 16 | средняя посадка; толчок о стража | `pop_map.c` land / bump_into_opponent | seg005/seg003:0654 |
|
||
| 17 | мягкая посадка | `pop_map.c` land | seg005 |
|
||
| 18 | пьёт | seqtbl (SEQ_SOUND) | seg006 |
|
||
| 19 | вынул меч | `pop_ctrl.c` | seg005:945 |
|
||
| 20/21/22 | дрожит плита | `pop_map.c` loose_shake | seg007:0E55 |
|
||
| 23 | шаг | seqtbl (SEQ_SOUND) | seg006 |
|
||
| 44 | скелет оживает | `guards.c` pop_check_skel | seg002:106D |
|
||
| 45 | прыжок в зеркало | `pop_map.c` jump_through_mirror | seg003:0617 |
|
||
| 46 | сожрал чомпер | `pop_map.c` | seg004 |
|
||
| 47 | чомпер щёлкнул | `pop_trob.c` (кадр 2) | seg007 |
|
||
| 48 | напоролся на пики | `pop_map.c` | seg005 |
|
||
| 49 | пики пошли | `pop_map.c` start_anim_spike | seg007:08F6 |
|
||
|
||
Что осталось не разведено — только МУЗЫКА (24/28 смерть, 25 презентация,
|
||
26 объятия,
|
||
27/35/40 заставки, 29 встреча Джафара, 30/33 зелья, 32/41 конец уровня,
|
||
36 время вышло, 37 победа, 43 смерть Джафара, 50/52/53 сюжетные вставки)
|
||
и 51 (дверь принцессы, тоже из заставки). Их черёд — фаза «музыка».
|
||
|
||
**Квирк, за которым следить.** Звук ворот у оригинала звучит не всегда, а
|
||
по условию видимости (`play_door_sound_if_visible`, seg007:1250): либо
|
||
ворота в комнате слева и стоят в 9-й колонке, либо ворота в НАРИСОВАННОЙ
|
||
комнате и колонка не 9-я. У нас это параметр `audible` у `animate_door`.
|
||
|
||
**Тряска плиты — свой домен prandom.** Оригинал берёт номер сэмпла (20/21/22)
|
||
из общего генератора и вдобавок «сжигает» один бросок ради совместимости с
|
||
DOS-версией; у нас последовательности разведены по доменам
|
||
(`impl_diff.md`), поэтому у тряски свой сид, а холостой бросок не делаем —
|
||
на розыгрыши физики и кладки это не влияет.
|
||
|
||
## 9. Цена звука в тактах (замер 2026-08-20, MAME)
|
||
|
||
Вопрос был поставлен так: звук идёт по прерываниям, значит размазан по всем
|
||
фазам кадра — и если фазы укладываются, всё хорошо? Да, но проверять это
|
||
надо не по фазам, а по двум числам, потому что **нагрузка от звука
|
||
постоянная и от сцены не зависит вовсе**.
|
||
|
||
### 9.1 Одно прерывание CBL
|
||
|
||
Зонды: `bpset` на входе трамплина (`_irq_tramp`) и на `reti` ветки CBL,
|
||
разница `totalcycles`. 398 замеров в сцене 11/15.
|
||
|
||
| величина | значение |
|
||
|---|---:|
|
||
| цена одного прерывания | **7 825 тактов ровно**, с разбросом до 8 299 (среднее 8 001) |
|
||
| период между прерываниями | 245 759 тактов (= 128 сэмплов на 10 937,5 Гц) |
|
||
| **доля процессорного времени** | **8 001 / 245 759 = 3,26 %** |
|
||
|
||
Цена постоянная, потому что работа фиксированная: OTIR ровно 128 байт плюс
|
||
скобка сохранения контекста. Ветвлений по данным в насосе нет.
|
||
|
||
### 9.2 Дрожание обслуживания — риск для ЗВУКА, не для кадра
|
||
|
||
Период плавает 228 804 … 262 734, то есть прерывание опаздывает максимум на
|
||
**~17 000 тактов = 0,8 мс**. Это самая длинная DI-скобка в коде
|
||
(акселератор режется по 16 строк, memory `sprinter_wait_states_2x`).
|
||
Буфер CBL — 128 сэмплов = **11,7 мс**, запас **14×**. Недолива быть не
|
||
может; счётчик `cbl_underruns()` это подтверждает косвенно (наш `fill`
|
||
всегда возвращает 1, поэтому он ловит только отсутствие данных, не
|
||
опоздание).
|
||
|
||
### 9.3 A/B в одном прогоне (Ctrl+S), сцена 11/15
|
||
|
||
Один и тот же кадр, звук выключается на ходу — сравнение чистое.
|
||
|
||
| | работа min | работа max | работа avg | прерываний CBL на кадр |
|
||
|---|---:|---:|---:|---:|
|
||
| звук ВКЛ | 453 132 | 572 532 | **498 064** | 1,40 |
|
||
| звук ВЫКЛ | 444 348 | 559 434 | **487 372** | 0,00 |
|
||
| разница | +8 784 | +13 098 | **+10 692 (+2,2 %)** | |
|
||
|
||
Разница на лёгком кадре (+8 784) — ровно одно прерывание, сходится с §9.1.
|
||
`CBL/кадр = 0` при выключенном звуке подтверждает, что Ctrl+S реально
|
||
ЗАКРЫВАЕТ CBL, а не глушит сэмпл: иначе насос продолжал бы отдавать блоки
|
||
тишины и платить те же 3,26 %.
|
||
|
||
### 9.4 Укладываемся ли
|
||
|
||
Логический кадр (`pop_pace.h`): NORMAL = 4 растра вне боя = **1 720 000
|
||
тактов**, FASTEST = 3 растра = **1 290 000**.
|
||
|
||
| | работа | доля NORMAL | доля FASTEST |
|
||
|---|---:|---:|---:|
|
||
| 11/15, обычная позиция | 498 064 | 29 % | 39 % |
|
||
| 11/15, тяжёлая позиция (Кид на 7 px правее) | 750 066 макс | 44 % | 58 % |
|
||
|
||
Период кадра за все прогоны: 1 719 936 … 1 720 752 — ровно 4 растра, ни
|
||
одного проскока. **Звук занимает 1,1 % бюджета NORMAL и 1,5 % FASTEST.**
|
||
|
||
### 9.5 Где 3,26 % МОГЛИ БЫ стоить дорого
|
||
|
||
Ответ «всё размазано, если фазы влезли — ок» верен с одной оговоркой.
|
||
Пейсинг квантован растром: работа 1,00 растра и 1,02 растра дают РАЗНЫЙ
|
||
период кадра (3 против 4 интервалов), то есть скачок сразу на 20 мс.
|
||
Значит звук опасен ровно в одной ситуации — когда сцена стоит в пределах
|
||
~8 000 тактов НИЖЕ кратного растру порога. Сейчас ближайший запас — 540 000
|
||
тактов до порога FASTEST, то есть в 60 раз больше цены звука. Проверять
|
||
эту оговорку заново стоит только если работа кадра подберётся к 430 000 или
|
||
860 000 вплотную.
|
||
|
||
## 10. Мусор при включении и щелчок на выходе (разбор 2026-08-20)
|
||
|
||
Жалоба: «при старте, когда разрешается звук, проходит кусок мусора».
|
||
Разобрано записью выхода MAME в WAV (`-wavwrite`) — по огибающей и
|
||
автокорреляции, а не на слух.
|
||
|
||
### 10.1 На старте мусора НЕТ; это настоящие звуки
|
||
|
||
От `cbl_open` до первого эффекта в записи **точная цифровая тишина**
|
||
(размах 1 при разрешении 16 бит). Дальше — два штатных звука:
|
||
|
||
| что | когда | длительность |
|
||
|---|---|---|
|
||
| `gate_closing_fast` (6) — решётка в комнате СЛЕВА | +0,30 с после `cbl_open` | обрывается на 80 мс |
|
||
| `soft_land` (17) — Кид приземляется | +0,38 с | 383 мс |
|
||
|
||
Опознаны корреляцией огибающих с оригинальными сэмплами `digisnd`:
|
||
звук 6 даёт +0,72 с начала записи, звук 17 — +0,32 со сдвигом 80 мс.
|
||
Приземление на старте КОРРЕКТНО: `start_pos` уровня 1 — тайл (0,0), а он
|
||
`space`, то есть Кид падает на ряд ниже, на площадку с факелами.
|
||
|
||
Обрыв первого звука вторым — тоже поведение оригинала, а не наш дефект:
|
||
`play_digi_sound` (seg009.c:2402) начинается с `stop_digi()`, голос ОДИН.
|
||
Ощущение «мусора» даёт именно 80-мс огрызок скрежещущей решётки.
|
||
|
||
Звук кнопки (3) при этом не слышен: `pop_sfx_play(3)` случается ДО
|
||
`cbl_open` и глохнет. С SDLPoP совпадает (там на старте тоже только
|
||
решётка), но держится это на порядке вызовов — если поднимать звук раньше
|
||
`pop_start_level`, щелчок кнопки станет слышен.
|
||
|
||
### 10.2 Незалитый буфер CBL — дефект есть, но в MAME он немой
|
||
|
||
Буфер CBL (256 слотов) железо не чистит ни сбросом, ни записью в порт
|
||
управления, а эта запись сразу пускает воспроизведение с нулевого слота.
|
||
Значит первые 256 сэмплов (23,4 мс) — то, что лежало раньше. Разбор по
|
||
`sprinter.cpp`: `case 0x89` делает `m_cbl_cnt = 0; m_cbl_wa = 0`, а
|
||
прерывание «долей половину» приходит только на 128-м слоте и ставит
|
||
указатель на ПРОТИВОПОЛОЖНУЮ половину — своими данными звук идёт лишь с
|
||
третьей половины.
|
||
|
||
В MAME это не слышно: эмулируемый буфер стартует нулями, а ЦАП
|
||
двухдополнительный, то есть 0 = середина шкалы. На ЖЕЛЕЗЕ там
|
||
неинициализированное ОЗУ — ровно тот мусор, который ловился ещё на
|
||
тестовых примерах CBL. Лечение — `_cbl_prime` в `cbl_open`: сразу после
|
||
включения 256 записей байта тишины в порт данных (заранее нельзя, запись
|
||
проходит только при поднятом bit7). Стоит 6 400 тактов один раз за
|
||
открытие; за это время таймер уходит на два-три слота.
|
||
|
||
### 10.3 Щелчок на выходе — ГОЛОДАНИЕ насоса, вылечено
|
||
|
||
На выходе по ESC в записи было **ровно 11 мс шума на полной громкости**
|
||
(размах 33 671 — громче всего в прогоне), потом мгновенная тишина. 11 мс
|
||
= один блок CBL (128 сэмплов = 11,7 мс), то есть один пропущенный долив:
|
||
`pop_shutdown` звал `closegraph`/`pop_bg_free`/`pop_kid_free` (а это
|
||
ESTEX на каждый атлас) ПРИ ОТКРЫТОМ звуке, насос не успевал, и железо
|
||
доигрывало несвежую половину.
|
||
|
||
Лечение: `pop_sfx_close()` первым действием `pop_shutdown`. Проверено
|
||
второй записью — всплеска на выходе больше нет. Механизм тот же, из-за
|
||
которого звук глушится на время загрузки уровня.
|
||
|
||
## 11. Приоритеты и перебиваемость: звук у оригинала НЕ «всегда перебивать»
|
||
|
||
Пользователь услышал расхождение: у нас решётка обрывалась приземлением
|
||
Кида, в SDLPoP — доигрывала до звонкого конца, а приземления не было
|
||
слышно вовсе. Разбор исходника показал, что мы упустили ЦЕЛЫЙ МЕХАНИЗМ.
|
||
|
||
### 11.1 Модель оригинала
|
||
|
||
```
|
||
play_sound(id) seg000:12C5 — только НОМИНИРУЕТ кандидата на кадр:
|
||
if next < 0 || prio[id] <= prio[next]: next = id
|
||
|
||
play_next_sound() seg000:1304 — раз в кадр решает, запускать ли:
|
||
if next >= 0:
|
||
if !играет_что_то ||
|
||
(перебиваем[текущий] && prio[next] <= prio[текущий]):
|
||
текущий = next; запустить
|
||
next = -1 // НЕ запустили -> номинант ВЫБРОШЕН, очереди нет
|
||
```
|
||
|
||
Три следствия, каждое слышно:
|
||
|
||
- **Неперебиваемый звук доигрывает целиком.** У `gate_closing_fast` (6)
|
||
`interruptible = 0`, поэтому приземление Кида (17) в этот момент
|
||
пропадает совсем — не откладывается, а именно теряется.
|
||
- **Внутри кадра выживает важнейший.** Меньше `prio` — важнее; при
|
||
равенстве побеждает ПОСЛЕДНИЙ (сравнение `<=`).
|
||
- **Два источника не «чередуются как получится».** Челюсти (47, prio
|
||
0x10) всегда важнее решётки (4, prio 0x32): решётка не может перебить
|
||
укус, а укус решётку — может. Отсюда и картина на ур. 9 к. 9, где
|
||
решётка звучит только в паузах между укусами.
|
||
|
||
### 11.2 Что сделано у нас
|
||
|
||
`pop_sfx_play` теперь только номинирует; запуск — в `pop_sfx_tick`,
|
||
который зовётся раз в кадр в конце отрисовки (там же, где оригинал зовёт
|
||
`play_next_sound`, seg000:954). Таблицы `snd_prio` (57 байт) и битовая
|
||
карта `snd_intr` (8 байт) — в резиденте, значения из SDLPoP С УЧЁТОМ
|
||
`fix_sound_priorities()`: в `config.h` SDLPoP `FIX_SOUND_PRIORITIES`
|
||
определён безусловно, значит сравниваемся мы с исправленным вариантом
|
||
(звук 10 → 0x0D, 48 → 0x15, 49 перебиваем).
|
||
|
||
Створка двери уровня (15) — единственная запись, которую оригинал правит
|
||
на ходу: перебиваема при закрытии, нет при открытии (seg007:442/464).
|
||
Держим отдельным байтом `pop_sfx_slide_intr`, чтобы таблица осталась в
|
||
ПЗУ. Там же добавлен пропущенный `stop_sounds()` на завершении открытия
|
||
двери (seg007:455) — без него неперебиваемый съезд (1,6 с) блокировал бы
|
||
очередь.
|
||
|
||
Звуки без оцифровки (музыка, длина 0) не номинируются вовсе — порт
|
||
проверки `if (NULL == sound_pointers[id]) return;`. Раньше такой id
|
||
глушил живой эффект.
|
||
|
||
### 11.3 Проверка
|
||
|
||
Записью MAME, старт уровня 1:
|
||
|
||
| | всплески | что это |
|
||
|---|---|---|
|
||
| до | 135 мс + 210 мс | решётка, обрезанная приземлением на 80 мс |
|
||
| после | **один, 455 мс** | решётка целиком, корреляция огибающей со звуком 6 **+0,889** |
|
||
|
||
Цена: резидент +~250 Б (таблицы + логика), куча ужалась с 347 до 134 Б —
|
||
довод в пользу давно назревшей реорганизации базовой памяти.
|
||
|
||
## 12. Ворота: гейт слышимости и «решётка встала» (2026-08-20)
|
||
|
||
Проверка на сцене, которую предложил пользователь — уровень 9, комната 9:
|
||
кнопка (1,8), челюсти (1,2), а ворота **в комнате 4, тайл (1,9)**, то есть
|
||
в комнате СЛЕВА. Нашлись три расхождения сразу.
|
||
|
||
### 12.1 Гейт слышимости был неверный
|
||
|
||
У нас стояло `audible = (room == cur_room)`. У оригинала
|
||
(`play_door_sound_if_visible`, seg007:1239) правило другое:
|
||
|
||
- ворота в комнате СЛЕВА и в колонке 9 — СЛЫШНЫ (створка видна в шве);
|
||
- ворота в отрисованной комнате и НЕ в колонке 9 — слышны;
|
||
- особый случай: уровень 3, комната 2 — слышны всегда.
|
||
|
||
Сцена 9/9 попадает ровно в первый пункт, поэтому спуск решётки у нас
|
||
молчал. Подъём при этом совпадал с оригиналом — потому что звук открытия
|
||
(5) идёт БЕЗ гейта (seg007:386, прямой `play_sound`). Эта асимметрия и была
|
||
подсказкой.
|
||
|
||
Взят вариант под `FIX_GATE_SOUNDS` (условия через ИЛИ): в config.h SDLPoP
|
||
он определён безусловно.
|
||
|
||
### 12.2 Потерян звук «решётка встала» (7)
|
||
|
||
`gate_stop()` (seg007:05E3) зовётся из ТРЁХ мест `animate_door` и каждый раз
|
||
играет звук 7 через гейт слышимости: конец закрытия, открытие насовсем и
|
||
ветка «уже 0xFF». У нас во всех трёх стояло только `*type = -1` без звука.
|
||
Добавлено. Лязг после ОБЫЧНОГО открытия (seg007:395) остаётся без гейта —
|
||
там оригинал зовёт `play_sound` напрямую.
|
||
|
||
### 12.3 Кнопка: у оригинала есть параметр playsound
|
||
|
||
`trigger_button(playsound, ...)` — в трёх местах он нулевой: вход на уровень
|
||
(seg003:170), выход Джаффара (seg002:520) и зелье «открыть» (seg006:1890, у
|
||
нас не портировано). Мы играли щелчок всегда. Добавлен параметр `snd`.
|
||
|
||
### 12.4 Почему щелчок кнопки слышно через раз — это НЕ баг
|
||
|
||
Бюджет сцены 9/9 (длительности после пересчёта на 10 937,5 Гц):
|
||
|
||
| звук | длительность | prio |
|
||
|---|---:|---:|
|
||
| челюсти (47) | 465 мс | 0x10 |
|
||
| решётка вниз (4) | 97 мс | 0x32 |
|
||
| решётка вверх (5) | 123 мс | 0x37 |
|
||
| решётка встала (7) | 75 мс | 0x30 |
|
||
| кнопка (3) | 106 мс | 0x66 |
|
||
|
||
Цикл челюстей — 15 кадров = 1229 мс (замерено брейкпоинтом на номинации:
|
||
25 805 000 тактов между укусами). Значит укус занимает 465 мс, пауза 764 мс.
|
||
Кнопка (prio 0x66) перебить челюсти не может (0x66 > 0x10), поэтому слышна
|
||
только если нажатие попало в паузу — примерно в 6 случаях из 10.
|
||
Подтверждено пользователем на живой сцене.
|
||
|
||
### 12.5 Грабли сцены
|
||
|
||
Если игра стартует ПРЯМО в комнате с челюстями, они не заводятся сами:
|
||
нужно сходить Кидом на левую кнопку и вернуться. Это поведение оригинала
|
||
(trob челюстей создаётся событием), а не наш дефект — учитывать при
|
||
постановке автотестов.
|
||
|
||
## 13. Повторный аудит игрового звука (2026-08-24)
|
||
|
||
Проверены три независимых слоя: содержимое атласов, места вызова и живой
|
||
тракт `play -> tick -> CBL`.
|
||
|
||
- В восьми `SND*.ATL` есть все 31 оцифрованных ресурса: `0..23`, `44..49`
|
||
и `51`; ненулевая страница/длина есть у каждой записи таблицы.
|
||
- Для всех 30 PCM-эффектов, которые могут возникать непосредственно в игре,
|
||
есть место вызова. Последним пропуском был звук 10 при столкновении
|
||
клинков; условие перенесено буквально из `check_sword_vs_sword` SDLPoP.
|
||
Звук 51 относится к сцене с принцессой, а не к игровому циклу.
|
||
- Звук 19 «Кид достал меч» проверен в MAME брейкпоинтами. В момент вызова
|
||
предыдущий PCM уже закончился (`sfx_left=0`), номинация дошла до
|
||
`pop_sfx_tick`, после чего курсор получил id 19, страницу 5, смещение
|
||
`0x1500` и длину 2816 байт. В этом прогоне его не подавляли решётка,
|
||
плиты, шаги или приоритеты. Если он всё ещё субъективно не слышен, искать
|
||
надо после выбора эффекта — в непрерывности CBL/громкости самого сэмпла.
|
||
- После продолжительного прогона title/intro/demo/menu CBL обслужил 3303
|
||
блока и сообщил 0 программных недоливов (`cbl_requests=0x0CE7`,
|
||
`cbl_underruns=0`). Это исключает возврат `fill=0`, но само по себе не
|
||
измеряет запоздание прерывания внутри слишком длинной секции `DI`.
|
||
- Регрессия full-game на входе в Level 1 оказалась именно запозданием CBL:
|
||
`pop_level_switch` открывал его ДО блокирующего BIOS fade-in. Demo был
|
||
чистым, потому что включал палитру без fade. Теперь загрузчик оставляет
|
||
CBL закрытым, а caller открывает его после окончательной палитры/QuickLoad;
|
||
скрежет на Level 1 исчез в MAME. Однократный щелчок самого первого
|
||
`cbl_open` за всю MAME-сессию остаётся отдельной низкоприоритетной задачей.
|
||
- Открытие pause menu у SDLPoP беззвучно; движение играет 21, вход/выход
|
||
из подменю — 22, изменение настройки — 10. Эти вызовы перенесены. На
|
||
время полного копирования страницы и файловых операций CBL закрывается,
|
||
после flip открывается снова: аппаратная половина не должна повторять
|
||
старые данные и давать «скрежет».
|
||
|
||
Отдельно остаётся игровая музыка и сигнальные мелодии без PCM: смерть
|
||
(`24/28`), начало/появление Shadow (`25`), встреча Jaffar (`29`), большое
|
||
и малое зелья (`30/33`), Shadow (`32`), победа/меч (`37`), перо (`39`),
|
||
конец уровня (`41`) и победа над Jaffar (`43`). Вызовы и AY-проигрыватель
|
||
для них ещё не реализованы; наличие всех PCM-эффектов эту задачу не закрывает.
|
||
|
||
|
||
## 4. МУЗЫКА: решение пересмотрено (2026-08-25) — путь C вместо A
|
||
|
||
В §1б первым заходом был выбран **путь A** (ноты PC-спикера на AY), а путь
|
||
C (запись -> WAV -> CBL) стоил дорого из-за строки «нужен синтезатор на
|
||
хосте». Это обстоятельство отпало: у пользователя есть **готовые записи
|
||
DOS-версии** — `applications/PoP/PoP1_DOS_music` (flac/mp3/ogg/ogg_MT-32,
|
||
22 трека). Синтезировать нечего, остаётся `ffmpeg -ac 1 -ar 10937 -f u8`.
|
||
|
||
### Что померено по этим записям
|
||
|
||
| группа | длительность | PCM 10 937,5 Гц |
|
||
|---|---:|---:|
|
||
| всё вместе (22 трека) | 339 с | **3 625 КБ = 227 EMM-страниц** |
|
||
| игровые джинглы (10) | 57 с | 608 КБ |
|
||
| заставки и титры | 282 с | 3 017 КБ |
|
||
| финальный `won` один | 115 с | 1 233 КБ |
|
||
|
||
Свободной EMM на старте ~3 440 КБ, так что вся музыка разом в память не
|
||
влезает и не должна: трек грузится под сцену и освобождается после.
|
||
|
||
### Что сделано
|
||
|
||
`toolchain/pop_pack_music.py` -> один файл `MUS\m<id>.bin` на трек
|
||
(читается порциями по 16 КБ из одного открытого fd) + каталог
|
||
`pop_music_tbl.h`. Длина трека хранится **порциями по 128 байт**,
|
||
а не байтами: 169 КБ в uint16 не влезает, 1350 блоков — легко.
|
||
|
||
`pop_music.c` (банк 9) грузит трек в EMM и ставит курсор; насос
|
||
`pop_sfx_fill` (резидент) получил **третий источник**: эффект важнее
|
||
музыки, музыка важнее тишины. Эффект музыку не сбрасывает — её курсор
|
||
стоит, пока эффект доигрывает, и она продолжается с места.
|
||
|
||
Проверено в MAME записью звука: трек `story_1_absence` на первом экране
|
||
истории, корреляция огибающих с эталоном **0,836**, RMS 22,6 против 18,3
|
||
(разница — 8-битное квантование). Эффекты двери в PV-сцене после него
|
||
звучат как прежде, то есть освобождение страниц и возврат к набору
|
||
эффектов работают.
|
||
|
||
### Цена и что осталось
|
||
|
||
* CBL один: пока играет музыка, эффектов нет. Для заставок это не важно
|
||
(их там не бывает), для игровых джинглов — открытый вопрос §1б.4.
|
||
* Резидент вырос на 83 байта (третий источник в насосе) плюс 25 байт
|
||
данных под курсор и таблицу страниц; запас W2 — 151 байт. Кучи в
|
||
приложении нет (`malloc` не слинкован), так что это чистый запас роста.
|
||
* Банк 9 занят на 88 %. Следующий модуль туда уже не влезет — либо
|
||
переносить, либо заводить банк 12.
|
||
* `won` (77 страниц) в POP_MUS_PAGES=20 не помещается: финал придётся либо
|
||
резать, либо стримить кусками по ходу.
|
||
|
||
|
||
## 5. СКРЕЖЕТ ПРИ BIOS-ВЫЗОВАХ: причина и решение (2026-08-25)
|
||
|
||
Симптом: во время затемнения (fade) звук хрипел — одинаково с играющей
|
||
музыкой и в тишине. Пользователь заметил ключевое: **повтор тишины обязан
|
||
звучать тишиной**, значит дело не в недоливе буфера.
|
||
|
||
### Как искали
|
||
|
||
Отладочные клавиши, каждая делает ровно один кусок fade:
|
||
|
||
| клавиша | что делала | результат |
|
||
|---|---|---|
|
||
| H | только ожидание 8 кадров | чисто |
|
||
| V | только чтение палитры | скрежет |
|
||
| G | затемнение целиком | скрежет |
|
||
| J | только запись палитры | скрежет |
|
||
| L | 512 раз `bios_get_place()` — видео не трогает | **скрежет** |
|
||
|
||
`L` и решил вопрос: виновата не палитра, а **любой вызов BIOS**.
|
||
|
||
### Причина
|
||
|
||
Вход в BIOS — это `rst 8`, то есть `out ($7C),a`. В драйвере MAME он
|
||
правит `m_rom_sys` и вызывает `update_memory()`, которая перестраивает
|
||
**окно 0**: `m_pages[0]` + `m_bank_view0.select(1)`. ПЗУ ложится ПОВЕРХ
|
||
страничного регистра.
|
||
|
||
Насос CBL брал окно взаймы именно у W0 (`_io_page_w0 = phys` + `OTIR` по
|
||
адресу < 0x4000). Пока BIOS работает, запись в порт `0x82` ничего не
|
||
меняет, и `OTIR` вычитывает ПЗУ, отдавая его в звук.
|
||
|
||
Побочно выяснилось, почему `DI` вокруг BIOS помогал лишь иногда: он не
|
||
даёт войти в ISR (тогда блок просто пропускается, что неслышно), но в
|
||
обработчиках BIOS есть `EI`, так что защита негарантированная.
|
||
|
||
### Решение
|
||
|
||
Насос переведён на **W3** (идея пользователя): это окно управляется только
|
||
портом `0xE2`, подмену из прерывания никто не перекрывает, а BIOS во время
|
||
нашего ISR не исполняется — окно возвращается до выхода.
|
||
|
||
```c
|
||
saved = _io_page_w3;
|
||
_io_page_w3 = phys;
|
||
cbl_push_otir((const void *)(0xC000u + ptr), n);
|
||
_io_page_w3 = saved;
|
||
```
|
||
|
||
После этого BIOS безопасен везде: и палитра, и любые другие функции.
|
||
Временный обход палитры мимо BIOS (`gfx_pal_write`) стал не нужен — он
|
||
остался в libbgi как более быстрый примитив (2,5 тыс. тактов на 64 цвета
|
||
против 10,8 тыс. у BIOS), но игра его не зовёт.
|
||
|
||
Бонус: в W3 нет стаба восстановления окна, который в W0 занимал начало
|
||
страницы, — звуковые страницы можно использовать целиком.
|
||
|
||
## 6. ВСЯ ЗАСТАВКА ОЗВУЧЕНА (2026-08-25)
|
||
|
||
К `story_1_absence` добавлены остальные четыре трека заставки:
|
||
**54** intro_theme (титры), **50** story_2_princess, **53**
|
||
story_3_Jaffar_enters, **52** story_4_Jaffar_leaves (сцена с принцессой).
|
||
`MUS_IDS` в Makefile — 50 52 53 54 55, всего 1056 КБ на образе.
|
||
|
||
### Два слота вместо одного
|
||
|
||
Реплики оригинала идут ВСТЫК: следующая начинается там, где кончилась
|
||
предыдущая, паузы под загрузку нет. Поэтому `pop_music` держит два слота
|
||
EMM: `pop_music_load*` всегда пишет в НЕ играющий, `pop_music_play`
|
||
подменяет резидентную таблицу страниц и отпускает прошлый слот. Своей
|
||
копии таблицы слот не хранит — её и так держит блок EMM, `mem_get_page`
|
||
отдаёт номер по индексу (иначе −40 байт W2 у игры, а там их нет).
|
||
|
||
Плюс **постраничная загрузка**: `pop_music_load_begin` / `_load_step`
|
||
читают по одной странице за вызов. Страница стоит 33 мс — четверть
|
||
логического кадра заставки (133 мс), поэтому подкачка следующей реплики
|
||
прямо посреди анимации не видна. Кто может позволить себе паузу (титры,
|
||
чёрный экран между сценами) — зовёт прежний `pop_music_load`.
|
||
|
||
### Тайминги приведены к шкале оригинала
|
||
|
||
Все длительности сцен взяты из SDLPoP в его тиках (60 Гц), а ждём мы
|
||
кадрами луча (~50 Гц). Пока сцены были немыми, разбег в 20 % не был
|
||
виден; с музыкой он слышен сразу — реплика кончается раньше картинки.
|
||
Введён `POP_T60(t)` (pop_cutscene.h), и на него переведены титры,
|
||
`intro_before_pv`, хвост после PV и пейсинг самой PV-сцены (счётчик
|
||
потраченных кадров луча против `POP_T60(tick)`, вместо прежних жёстких
|
||
четырёх кадров на логический).
|
||
|
||
**Паузы-реплики.** Там, где оригинал ждёт конца сэмпла, у нас теперь
|
||
стоит реальная длина нашей записи: m50 — 831 тик, m53 — 985. Отсюда
|
||
новая шкала PV: конец m50 на 846, вход Джафара (m53) на 1046, уход
|
||
(m52) на 2073, конец сцены 2500 тиков (было 1959).
|
||
|
||
**Fade перед PV** (вопрос пользователя: наш fade короче, 4 ступени против
|
||
64). Совпасть должен момент полной темноты, считая от пуска m55:
|
||
у SDLPoP это 80 (transition) + 600 (wait) + 128 (fade_out_2: 0x40 шагов
|
||
по 2 тика); у нас переход занимает 80 кадров луча = 96 тиков, а fade —
|
||
5 тиков. Отсюда `WAIT = 80 + 600 + 128 − 96 − 5 = 707` тиков. Дальше и
|
||
там и там экран уже чёрный, а трек доигрывает: этой паузой заставка и
|
||
стыкуется с PV.
|
||
|
||
**Музыка переживает смену сцен.** m54 звучит с титров и до первого
|
||
экрана истории (`pop_intro_show` больше не глушит CBL на входе), m52
|
||
начинается в PV и доигрывает уже на экране «свадьбы» — как seg000:2051.
|
||
|
||
Проверено в MAME: цепочка 54 → 55 → 50 → 53 → 52 отыгрывается целиком,
|
||
курсор `pop_mus_id` меняется ровно на своих кадрах, интро доходит до
|
||
демо-режима. Слуховая проверка (нет ли хрипа от диска при играющей
|
||
музыке) — за пользователем.
|
||
|
||
## 7. МУЗЫКА ПО ХОДУ ИГРЫ (2026-08-26)
|
||
|
||
Звуки 24..43 в наборе оцифровки ПУСТЫЕ — в оригинале это Adlib-музыка, и
|
||
в digisnd её нет вовсе. На этом и построено подключение: `pop_sfx_play`
|
||
для звука с нулевой длиной не пытается его играть, а кладёт НОМЕР в
|
||
`pop_mus_req` (один байт). Заявку разбирает `pop_music_service()` — один
|
||
вызов на кадр из любого цикла (игрового, интро, катсцены); всё чтение с
|
||
диска живёт там.
|
||
|
||
**Стриминг вместо загрузки.** Ждать полной загрузки джингла нельзя — это
|
||
фриз на треть секунды посреди игры. `pop_music_stream` читает ПЕРВУЮ
|
||
страницу (33 мс) и сразу пускает трек: она звучит 1,5 с, а следующая
|
||
читается те же 33 мс — запас сорокакратный. Остальные доливаются по
|
||
одной за кадр, пока `pop_music_loading()`. Номера страниц известны сразу
|
||
после `mem_alloc_pages`, поэтому таблица для насоса заполняется целиком —
|
||
данные появятся раньше, чем насос до них дойдёт.
|
||
|
||
**Что и где играет** (номера и места — из SDLPoP):
|
||
|
||
| трек | событие | место у нас |
|
||
|------|---------|-------------|
|
||
| 24 / 28 / 32 | смерть: обычная / в бою / от руки тени | `ctrl_kid_death` |
|
||
| 25 | вступление 1-го уровня (Кид сидит), тень 6-го | `control_crouched`, `guards.c` |
|
||
| — | НА ДЕМО-УРОВНЕ музыки нет вовсе: там одни эффекты | гейт в `pop_music_service` |
|
||
| 27 / 35 / 40 | сцены перед 2/4/6/12, 8/9, «времени мало» | `pop_pre_cutscene_show` |
|
||
| 29 | встреча с Джафаром | `pop_meet_jaffar` |
|
||
| 30 / 33 | большая склянка / малая | `pop_proc_get_object` |
|
||
| 36 | время вышло | `pop_time_expired_show` |
|
||
| 37 / 43 | меч найден, страж убит / смерть Джафара | `pop_proc_get_object`, `on_guard_killed` |
|
||
| 39 | перо (медленное падение) | `pop_proc_get_object` |
|
||
| 41 / 32 | конец уровня / конец 4-го (тень) | опкод SND_LEVEL в `play_seq` |
|
||
| 26 | встреча с принцессой | `cut_ending` |
|
||
|
||
**Вступление первого уровня — автомат, а не «звук при приседе»** (seg005:02EB).
|
||
Пока `need_level1_music` не ноль, `control_crouched` НЕ ЧИТАЕТ управление:
|
||
Кид сидит, тема играет, и лишь когда она смолкла, он может встать. Наша
|
||
первая версия просто играла трек при первом приседе — и тема догоняла
|
||
игрока посреди уровня (пробежал, спрыгнул, присел — заиграла). Признак
|
||
«ещё звучит» берём у курсора насоса `pop_mus_left`: он резидентный, и если
|
||
музыка выключена, курсор остаётся нулём — Кид просто встаёт.
|
||
|
||
Темы, которые звучат один раз за заход на уровень (вступление 1-го, тень
|
||
6-го), сбрасывает `pop_music_level_start()` из `pop_start_level`. Оригинал
|
||
для этого портит переменную двери (`leveldoor_open = 0x4D`) — у нас на это
|
||
есть свои два байта.
|
||
|
||
**ГДЕ КОНЧАЕТСЯ МУЗЫКА СЦЕНЫ** (уточнено 2026-08-26). Сначала мы отдали
|
||
трек «доигрывать в игре»: у оригинала load_intro после сцены просто гасит
|
||
экран и возвращает управление. На слух оказалось хуже, чем в оригинале —
|
||
музыка спотыкается: загрузка уровня (ESTEX плюс сборка комнаты) не даёт
|
||
насосу долить блок вовремя. У DOS-версии этой проблемы нет, там звук
|
||
живёт своей жизнью на аппаратуре.
|
||
|
||
Поэтому дослушиваем ПОД ЧЁРНЫМ ЭКРАНОМ, до отрисовки уровня: следующий
|
||
load_intro у оригинала и так начинается с ожидания тишины (seg001:681), то
|
||
есть к новому уровню трек в любом случае смолкает. Пропуск сцены обрывает
|
||
и музыку — игрок нажал клавишу, чтобы идти дальше.
|
||
|
||
**ДЛИТЕЛЬНОСТЬ FADE.** fade_in_1/fade_out_1 — это 64 шага палитры по два
|
||
тика, 128 тиков = 2,13 с; сцена перед уровнем 2 занимает с ними около семи
|
||
секунд. Наши четыре ступени укладывались в восемь сотых секунды, и сцена
|
||
выходила втрое короче. Теперь `INTRO_FADE = POP_T60(128)`, а ступеней в
|
||
`pop_ui_palette_dim` тридцать две вместо четырёх: на четырёх растянутых
|
||
ступенях затемнение выглядело бы скачками. Половина от оригинальных 64 —
|
||
на глаз от них не отличается (ступень каждые 66 мс), а вот шестнадцать уже
|
||
видно. Сумма «fade in + сцена + fade out» при этом совпадает с оригиналом
|
||
сама собой: длительность каждого fade та же, что у fade_*_1.
|
||
|
||
**Цена ступени** (замеры в MAME 2026-08-26, такты 21 МГц; кадр 430 000):
|
||
|
||
| версия | такты | что изменилось |
|
||
|--------|-------|----------------|
|
||
| исходная | 2 440 000 | снимок копировался побайтовым циклом на C |
|
||
| + таблица яркости на стеке | 1 250 000 | 768 умножений uint16 заменены 256 сложениями |
|
||
| + memcpy для снимка | 487 000 | LDIR вместо цикла — главный выигрыш |
|
||
|
||
Из оставшихся 487 тысяч 136 тысяч — заливка палитры через BIOS (8 вызовов
|
||
`gfx_pal_load` по 17 000). Дальше можно было бы хранить готовые таблицы
|
||
яркости файлом, но при 1,2 мс на построение это уже незаметно.
|
||
|
||
ВАЖНО: ступень пересчитывается только когда она СМЕНИЛАСЬ. Наивный цикл
|
||
«ступень на каждый кадр» звал пересчёт сто раз и растягивал fade до
|
||
десяти с лишним секунд.
|
||
|
||
## 8. ПОТОКОВЫЙ ТРЕК: ФИНАЛЬНАЯ ТЕМА (2026-08-26)
|
||
|
||
`won` (56) — 115 с, 1,2 МБ, 78 страниц EMM. В память он не влезает ни при
|
||
каком бюджете, поэтому играется КОЛЬЦОМ из шести страниц (96 КБ = 9 с): насос
|
||
идёт по кругу, а `pop_music_service` дочитывает файл в те страницы, которые
|
||
насос уже прошёл.
|
||
|
||
**Кто кого догоняет.** Страница звучит 1,5 с, а читается 33 мс — запас
|
||
сорокакратный. Дистанция считается без отдельных счётчиков: страница ровно
|
||
128 блоков насоса, поэтому проигранных страниц = (всего блоков − осталось)
|
||
/ 128. Пока прочитано меньше, чем проиграно плюс размер кольца, в кольце
|
||
есть свободный слот. Файл читается ПОСЛЕДОВАТЕЛЬНО, без `lseek`.
|
||
|
||
**Что пришлось учесть.**
|
||
|
||
* Насос заворачивает страницу только при `pop_mus_ring != 0`; конец трека
|
||
по-прежнему определяет `left`. Обычный трек этой ветки не касается.
|
||
* Кольцо обязано сниматься при любом обычном запуске (`pop_music_play`,
|
||
`_stream`, `_load_begin`): иначе следующий трек играет по кругу первых
|
||
шести страниц — поймано на титрах сразу после победы.
|
||
* Живые сцены комнаты принцессы открывают CBL сами (`cut_begin`):
|
||
`pop_ending_show` глушит звук первым действием, и «arrived to princess»
|
||
(26) не звучал вовсе.
|
||
* Тема дослушивается до конца (прерывается клавишей), как `while
|
||
(check_sound_playing() && !key_test_quit())` в seg001:637. У оригинала
|
||
между титрами и этим ожиданием стоит ввод имени в таблицу рекордов —
|
||
когда он появится у нас, ожидание переедет за него (задача HOF-ENTRY).
|
||
|
||
Проверено в MAME на сборке `LEVEL=14`: после встречи с принцессой звучит
|
||
тема победы (`pop_mus_id` = 56, `pop_mus_ring` = 6), курсор уходит далеко
|
||
за размер кольца — то есть подкачка успевает.
|
||
|
||
## 9. ЗАДЕРЖКА СТАРТА ЗВУКА: ЗАМЕР (2026-08-31)
|
||
|
||
Замерено в MAME на живой сборке: точка отсчёта — подстановка заявки
|
||
отладчиком при ОСТАНОВЛЕННОЙ машине, конец — условный watchpoint
|
||
`wpset 19FF6,1,w,{wpdata != 0}` на старший байт `pop_mus_left`, то есть
|
||
момент, когда трек реально зазвучал. Масштаб тактов откалиброван не по
|
||
памяти, а по аппаратным часам: период тика насоса CBL — ровно
|
||
`128/10937,5 = 11,7029` мс, измерено 245 748 и 245 778 тактов, отсюда
|
||
**21,00 МГц** (кадр луча — 429 973 такта, развёртка 48,8 Гц, не 50).
|
||
|
||
### 9.1 Эффекты стартуют без задержки, музыка — через ~200 мс
|
||
|
||
Разница принципиальная и вытекает из устройства:
|
||
|
||
* **Оцифрованные эффекты диска не касаются вовсе.** `pop_sfx_init()`
|
||
вызывается ОДИН раз за запуск (`sprpop_cold.c`), вычитывает весь
|
||
`SND/snd.arc` в EMM и закрывает файл. Дальше `pop_sfx_play` только
|
||
номинирует, а `pop_sfx_tick` берёт `page/off/len` из таблицы в памяти.
|
||
Задержка = остаток текущего кадра + ближайший запрос CBL (≤ 11,7 мс).
|
||
Держать архив открытым НЕЧЕГО: после старта он не нужен.
|
||
* **Музыка читается с диска на каждый трек** — `mNN.bin` лежат
|
||
отдельными файлами, и `pop_music_load_begin` открывает свой.
|
||
|
||
### 9.2 Разложение окна музыки
|
||
|
||
От заявки (`pop_mus_req`) до звучания (`pop_mus_left != 0`), одна
|
||
загрузка по шагам:
|
||
|
||
| шаг | цена |
|
||
|---|---:|
|
||
| ожидание `pop_music_service` в кадре | 22,6 мс |
|
||
| `chdir` #1 (вход в `POP_PATH_CALL`) | **46,1 мс** |
|
||
| `open` файла трека | 34,5 мс |
|
||
| `chdir` #2 (восстановление каталога) | **59,0 мс** |
|
||
| чтение первых 8 КБ (`bank_read_page`) | 34,0 мс |
|
||
| **итого** | **196,7 мс** |
|
||
|
||
**Главное здесь: два `chdir` стоят 105 мс — больше половины окна, и
|
||
каждый дороже самого `open`.** Убрать их нельзя: старые DSS оставляют
|
||
рабочий каталог внутри последнего подкаталога, и без восстановления
|
||
ломаются относительные пути (ради этого `POP_PATH_CALL` и заведён).
|
||
Теоретический запас — снять ОДИН из двух (≈ 50 мс), но это риск для
|
||
других мест, которые полагаются на восстановленный каталог.
|
||
|
||
Для музыки задержка непринципиальна: трек и так начинается на границе
|
||
события. Критична она была бы для эффектов — там её нет по построению.
|
||
|
||
### 9.3 Разброс и режимы скорости
|
||
|
||
Семь прогонов: **189,3 … 232,8 мс**, среднее 200,1. Величина
|
||
ФИЗИЧЕСКАЯ и от режима скорости не зависит, меняется только её пересчёт
|
||
в логические кадры:
|
||
|
||
| режим | логический кадр (замерено) | окно в кадрах |
|
||
|---|---:|---:|
|
||
| NORMAL вне боя | 81,93 мс = 4,00 кадра луча | 2,31 … 2,84 |
|
||
| FAST / FASTEST вне боя | 61,4 мс = 3,00 кадра луча | 3,09 … 3,42 |
|
||
|
||
Худший случай (232,8 мс в коротком кадре) — 3,79 логического кадра.
|
||
Пейсер при этом работает ровно как задумано: `pop_pace_n` заказывает 4
|
||
кадра луча вне боя при NORMAL и 3 при FAST/FASTEST, замер это
|
||
подтвердил. Подкачка трека кадр НЕ просаживает.
|
||
|
||
### 9.4 Следствие для паузы конца уровня
|
||
|
||
Ждать окончания мелодии по одному `pop_music_busy()` НЕЛЬЗЯ: в этом окне
|
||
он ещё ложен, и уровень сменился бы до первой ноты. Правильный признак
|
||
— состояние «аудио-событие в работе», которое выводится из уже
|
||
существующих переменных без новых сущностей:
|
||
|
||
```c
|
||
uint8_t pop_music_active(void) __banked /* банк 9 — там ld_busy */
|
||
{
|
||
return (uint8_t)(pop_mus_req || ld_busy || pop_mus_left);
|
||
}
|
||
```
|
||
|
||
Переход «запрошен → грузится → звучит» снаружи непрерывен: `service`
|
||
обнуляет `pop_mus_req` и тут же в `load_begin` взводит `ld_busy`, а
|
||
главный цикл между этими операторами не выполняется.
|
||
|
||
Это лучше фиксированной задержки «на раскачку»: не нужна подобранная
|
||
константа, нет зависимости от режима скорости, а при провале загрузки
|
||
(нет файла, нет памяти) все три признака ложны и уровень меняется сразу,
|
||
вместо слепого ожидания. Заодно чинится расхождение с SDLPoP при
|
||
ВЫКЛЮЧЕННОМ звуке: `pop_sfx_play` возвращается рано при `!pop_snd_ok`,
|
||
заявка не кладётся, паузы нет — как в оригинале (`seg006:651` ставит
|
||
трек только `if (is_sound_on)`, а `seg003:387` ждёт
|
||
`check_sound_playing()`). Сейчас `pop_endmus_left` взводится
|
||
безусловно, и при выключенном звуке игра держит пройденный уровень
|
||
лишние 12 с молча.
|
||
|
||
## 10. ЭФФЕКТЫ БЕЗ ПЕРЕЛИНКОВКИ: РАСКЛАДКА УЕХАЛА НА ДИСК (2026-08-31)
|
||
|
||
Сделана часть II плана `music_runtime_index_plan.md` (этапы SI0..SI4).
|
||
Раскладка звукового набора больше не компилируется в EXE: пара
|
||
`SND/snd.arc` + `SND/snd.idx` меняется целиком, бинарник остаётся прежним.
|
||
|
||
### 10.1 Что именно изменилось
|
||
|
||
`gen/pop_sound_tbl.h` УДАЛЁН — упаковщик его не печатает. Вместо него:
|
||
|
||
* `src/pop_snd_tbl.h` — рукописный: тип записи и инварианты программы
|
||
(`POP_SND_COUNT` 57, `POP_SND_BLOCK` 128, `POP_SND_MAX_PAGES` 16,
|
||
константы блока тишины). От набора не зависит НИЧЕГО из этого;
|
||
* `src/pop_snd_data.c` — резидентные данные: `pop_snd_tbl[57]`,
|
||
`pop_snd_page[16]`, `pop_snd_pages`;
|
||
* `SND/snd.idx` — дисковый индекс, формат `PSI1` (писатель и разборщик —
|
||
`tools/pop_idx.py`, покрыт `make test-tools`, 22 теста);
|
||
* `pop_sfx_cold.c` — чтение и полная валидация индекса.
|
||
|
||
`POP_SND_PAGES` как compile-time размер набора БОЛЬШЕ НЕ СУЩЕСТВУЕТ. Его
|
||
заменили две разные величины: `POP_SND_MAX_PAGES` — вместимость
|
||
загрузчика (16, запас под чужие наборы), и runtime `pop_snd_pages` —
|
||
сколько страниц у ЭТОГО набора. Насос сверяет переход длинного эффекта
|
||
через границу страницы со вторым, а не с первым.
|
||
|
||
### 10.2 Формат `PSI1` и почему запись ровно 5 байт
|
||
|
||
Один сектор DSS: 16 байт заголовка (магия, число записей, размер записи,
|
||
log2 блока, формат PCM, число страниц) и 57 записей по 5 байт —
|
||
`page` (u8), `off` (u16 LE), `len` (u16 LE), — то есть побайтовый образ
|
||
`pop_snd_tbl[]`. Записи читаются ОДНИМ `read` прямо в резидентную
|
||
таблицу, без разбора по полям.
|
||
|
||
Поэтому `sizeof(pop_snd_ent_t) == 5` в ABI SDCC z80 — часть ДИСКОВОГО
|
||
контракта, а не деталь компилятора. Ловится с двух сторон: статически
|
||
(`pop_snd_data.c`) и полем размера записи в заголовке, которое загрузчик
|
||
сверяет со своим `sizeof`.
|
||
|
||
### 10.3 Цена в памяти
|
||
|
||
Таблица была `static const` в `_CODE`, стала изменяемой в `_DATA`.
|
||
Замер до/после:
|
||
|
||
| | было | стало | Δ |
|
||
|---|---:|---:|---:|
|
||
| `_CODE` | 23865 | 23582 | −283 |
|
||
| данные | 7128 | 7421 | +293 |
|
||
| куча резидента | 239 Б | 229 Б | −10 |
|
||
| банк 8 | 14824 | 15425 | +601 |
|
||
|
||
Суммарный резидентный расход почти не изменился — в huge-модели `_CODE`
|
||
и данные лежат подряд в одном диапазоне `0x4000..0xBFFF`. Реальные
|
||
10 байт — это расширение `pop_snd_page` с размера набора до
|
||
`POP_SND_MAX_PAGES` плюс байт счётчика.
|
||
|
||
### 10.4 Валидация: чему именно нельзя доверять
|
||
|
||
Индекс — файл на диске, и битый индекс увёл бы насос за пределы
|
||
выделенного блока EMM. Проверяются заголовок целиком и КАЖДАЯ запись:
|
||
`page < pop_snd_pages`, `off` внутри страницы, кратность `off`/`len`
|
||
блоку 128, непересечение с блоком тишины и то, что длинный эффект не
|
||
выходит за последнюю страницу набора.
|
||
|
||
Последняя проверка считается В БЛОКАХ, а не в байтах: байтовый адрес
|
||
конца (`page * 16384 + off + len`) не влезает в `uint16`, а 32-битная
|
||
арифметика на Z80 дорога. В блоках весь набор — не больше
|
||
`16 * 128 = 2048`, и всё считается шестнадцатью битами.
|
||
|
||
Число страниц из индекса дополнительно сверяется с числом элементов в
|
||
`snd.arc`: расходящаяся пара означает, что файлы из разных наборов.
|
||
|
||
### 10.5 Нет индекса — эффектов нет, но МУЗЫКА ОСТАЁТСЯ
|
||
|
||
Первая версия просто возвращала ошибку, и это оказалось непроходимой
|
||
игрой: тишину в паузах насос льёт из первого блока набора, поэтому без
|
||
набора CBL не открывался вовсе — а вместе с ним вставала музыка, чьи
|
||
блоки отсчитывает тот же насос. Заставка ждала конца трека, который не
|
||
начинал играть, и `pop_music_busy()` не гас никогда.
|
||
|
||
Теперь при отказе индекса поднимается ПУСТОЙ набор: одна страница EMM с
|
||
блоком тишины. Все `len` нулевые, поэтому `pop_sfx_play` уходит по ветке
|
||
«нет оцифровки» — эффекты молчат, музыка играет, игра проходима.
|
||
|
||
Две тонкости этой заливки, обе выяснены на живой машине:
|
||
|
||
1. **Заливается только первый блок (128 байт), а не вся страница.**
|
||
`gfx_w0_page_prepare` ставит в страницу IRQ/NMI-стабы, чтобы
|
||
прерывание при замапленном W0 нашло валидный код. Заливка всей
|
||
страницы затирает их, и первое же прерывание уводит процессор в
|
||
мусор — чёрный экран на старте. Насосу больше и не нужно: он берёт
|
||
ровно блок тишины, да и то через W3.
|
||
2. **Запись под `IRQ_DISABLE`** — 128 байт накрывают стаб RST38, и до
|
||
`gfx_w0_unmap` прерываний быть не должно.
|
||
|
||
### 10.6 Грабли сборки: секундная гранулярность mtime
|
||
|
||
Смена `SND_SRC` тихо давала НЕВЕРНЫЙ результат: `sdlpop -> msdos ->
|
||
sdlpop` перепаковывал `build/atl`, а в `assets/packed` оставался прежний
|
||
набор. Причина не в логике, а во времени: упаковка укладывается в одну
|
||
секунду с записью stamp'а, а `make` считает цель, сделанную в ту же
|
||
секунду, что и зависимость, актуальной. С паузой в секунду не
|
||
воспроизводится вовсе.
|
||
|
||
Лечится двумя приёмами, и оба убирают время из решения:
|
||
|
||
* смена варианта СНОСИТ stamp'ы своего семейства (`rm -f
|
||
$(STAMP)/sound-*`) — нет файла, значит правило обязано выполниться;
|
||
* упаковка, сборка архива и копия индекса делаются ОДНИМ рецептом:
|
||
внутри рецепта сравнивать нечего.
|
||
|
||
То же лечение получила и музыка (`MUSIC_FMT`) — механизм общий.
|
||
|
||
### 10.7 Что проверено на живой машине
|
||
|
||
* таблица в памяти совпадает с файлом из образа ПОБАЙТОВО (285 байт);
|
||
* один и тот же EXE (sha256 не меняется) поднимает SDLPoP (9 страниц) и
|
||
MSDOS (10 страниц);
|
||
* СТРОГАЯ проверка: у двух реальных наборов совпадают 23 записи из 31,
|
||
поэтому сделан отладочный ключ `pop_pack_sound.py --order reverse` —
|
||
набор с обратным порядком укладки, где от штатного отличаются 30 из 31
|
||
записей. Игра на нём звучит правильно, значит раскладка берётся с
|
||
диска, а не из памяти;
|
||
* битый индекс (испорченная магия) — эффекты выключаются, игра жива;
|
||
* отсутствующий индекс — музыка играет, эффектов нет, PV-сцена проходит;
|
||
* Ctrl+S в обоих режимах работает штатно.
|
||
|
||
Маркер для ручной проверки: звук 51 `princess_door_opening` (дверь в
|
||
сцене с принцессой) лежит у наборов по РАЗНЫМ страницам — 8 у SDLPoP,
|
||
9 у MSDOS. А вот 4 `gate_closing` из той же сцены совпадает побайтно и
|
||
для проверки подмены не годится.
|