# Звук в порте 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). Разница есть, и она влияет на выбор источника. ### Оцифровка: берём 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 фактически пустой. Поэтому упаковщик читает `MSDOS/digisnd*.dat`, а не распакованные ассеты — в отличие от графики, где источник наоборот SDLPoP. ### 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), а roomtest — 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` из roomtest. 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 | клинок о клинок | `roomtest.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.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` | | 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` | Темы, которые звучат один раз за заход на уровень (вступление 1-го, тень 6-го), сбрасывает `pop_music_level_start()` из `pop_start_level`. Оригинал для этого портит переменную двери (`leveldoor_open = 0x4D`) — у нас на это есть свои два байта. **Чего пока нет.** Финальный `won` (56): 115 с, 1,2 МБ, 78 страниц EMM — в `POP_MUS_PAGES` (20) он не помещается даже теоретически. Ему нужен КОЛЬЦЕВОЙ стриминг: держать в памяти несколько страниц и дочитывать в те, что насос уже прошёл. Это отдельная задача — курсор насоса придётся научить заворачиваться, а загрузчик — держать дистанцию от него.