Files
Sprinter-SDCC/applications/SprPoP/docs/sound_plan.md
T
snark13 df5071a967 SprPoP: музыка без перелинковки — длины и длительности уехали на диск
Часть I плана music_runtime_index_plan.md (MI0..MI5).  gen/pop_music_tbl.h
и gen/pop_music_ticks.h УДАЛЕНЫ: длины треков и длительности реплик
читаются из MUS/mus.idx (формат PMI1, tools/pop_idx.py, тесты в
make test-tools).  Один и тот же sprpop.exe работает с любым из четырёх
наборов записей — sha256 бинарника при смене MUSIC_FMT не меняется.

ГДЕ ЖИВЁТ ИНДЕКС.  228 байт таблицы в W2 не положить (свободной кучи там
порядка двух сотен), поэтому индекс лежит в одной странице EMM, а в
резиденте от него два байта.  Данные в странице — со смещения 0x100:
gfx_w0_page_prepare пишет в неё стабы прерываний (0x38 и 0x66), и с нуля
они попали бы прямо в записи id 10 и 21.  Со смещением работает штатная
защита, а не запрет прерываний (тот же приём, что CFG_BASE в
pop_config.c).  Число страниц в индексе не хранится — считается из blocks,
чтобы не разъехалось.

ПАУЗА КОНЦА УРОВНЯ — СОСТОЯНИЕМ, А НЕ СЧЁТЧИКОМ.  pop_endmus_left и
POP_MUS_TICKS_32/41 удалены; главный цикл ждёт pop_music_active() —
«заявка лежит, идёт загрузка или трек звучит».  Одного busy мало: между
заявкой и первой нотой 190-230 мс (замер в sound_plan §9).  Прежний
счётчик закрывал эту щель ценой зависимости EXE от набора и жёсткого
делителя /4, который врал в режимах FAST/FASTEST (там логический кадр 3
кадра луча, а не 4).  Побочно исправилось расхождение с SDLPoP: при
выключенном звуке заявка не кладётся, и уровень меняется сразу, как в
оригинале (seg006:651 + seg003:387) — раньше игра держала пройденный
уровень лишние 12 секунд в тишине.

PV-СЦЕНА — на четырёх якорях (8 байт статики), которые считаются из
индекса при входе в сцену; прежние выражения шкалы не изменились.  План
предлагал протащить структуру времён через пять функций — для сцены,
которая идёт раз за запуск, это того не стоит.

ПАМЯТЬ.  За обе фазы резидент не вырос, а освободился: _CODE 23865 ->
23544, куча 239 -> 256 Б.  Банк 9 похудел на 118 Б (ушла pop_mus_tbl из
rodata), банк 11 — на длительности реплик.

ПРОВЕРЕНО В MAME: exe побайтово одинаков для flac и mt32; все 22 трека в
индексах различаются, и контрольные значения совпали с предсказанными
планом (m41 732->685, m50 831->867, m53 985->1044, m56 9865->10462
блоков, 78->82 страницы); на mt32 PV-сцена проходит целиком по его
длительностям; без mus.idx музыки нет, эффекты работают, игра проходима.

НЕ ПРОВЕРЕНО: потоковый m56 на 82 страницах — до финала надо дойти в
игре.  Единственный оставшийся пункт приёмки, отмечен в sound_plan §11.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 17:17:59 +03:00

93 KiB
Raw Blame History

Звук в порте 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 не исполняется — окно возвращается до выхода.

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() НЕЛЬЗЯ: в этом окне он ещё ложен, и уровень сменился бы до первой ноты. Правильный признак — состояние «аудио-событие в работе», которое выводится из уже существующих переменных без новых сущностей:

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 из той же сцены совпадает побайтно и для проверки подмены не годится.

11. МУЗЫКА БЕЗ ПЕРЕЛИНКОВКИ: ДЛИНЫ И ДЛИТЕЛЬНОСТИ УЕХАЛИ НА ДИСК (2026-08-31)

Сделана часть I плана music_runtime_index_plan.md (этапы MI0..MI5, у нас B0..B7). gen/pop_music_tbl.h и gen/pop_music_ticks.h УДАЛЕНЫ: длины треков и длительности реплик читаются из MUS/mus.idx (формат PMI1). Один и тот же sprpop.exe работает с любым из четырёх наборов записей.

11.1 Где живёт индекс и почему именно там

228 байт таблицы в W2 положить нельзя — там свободной кучи порядка двух сотен. Поэтому индекс лежит в ОДНОЙ странице EMM, а в резиденте от него остаются два байта (номер блока и страницы). Наружу указатель на EMM не выдаётся: pop_music_info() подключает страницу в W0, копирует четыре байта в буфер вызывающего и сразу снимает окно.

Данные в странице лежат со смещения POP_MUS_IDX_BASE (0x100), а не с нуля. Причина в gfx_w0_page_prepare: он делает страницу безопасной для W0, записывая В НЕЁ переход на IRQ-восстановитель (0x38) и RETN для NMI (0x66). С нуля эти байты легли бы прямо в записи (id 10 и 21) и испортили индекс; со смещением стабы и данные не пересекаются, и работает штатная защита от прерываний, а не запрет их. Тот же приём и по той же причине — в pop_config.c (CFG_BASE).

Числа страниц в индексе НЕТ: это производная от blocks, и дублирование однажды разъехалось бы с ним. Считает pop_music_pages().

11.2 Пауза конца уровня: состояние вместо счётчика

pop_endmus_left и POP_MUS_TICKS_32/41 УДАЛЕНЫ. Главный цикл ждёт pop_music_active() — «заявка лежит, идёт загрузка или трек звучит».

Одного pop_music_busy() там мало: между заявкой и первой нотой проходит 190–230 мс (§9), и в этой щели он ложен. Раньше щель закрывал счётчик по длине записи, но он тянул за собой две беды: зависимость EXE от набора и жёсткий делитель /4, который врал в быстрых режимах (там логический кадр 3 кадра луча, а не 4). Три признака подряд закрывают щель без единой подобранной константы, потому что pop_music_service обнуляет req и тут же взводит ld_busy, а тот держится до записи pop_mus_left.

Побочно исправилось расхождение с оригиналом: при ВЫКЛЮЧЕННОМ звуке pop_sfx_play не кладёт заявку, все три признака ложны, и уровень меняется сразу — как в SDLPoP, где трек ставится под if (is_sound_on) (seg006:651), а ожидание это check_sound_playing() (seg003:387). Прежний счётчик взводился безусловно и держал пройденный уровень лишние 12 секунд в тишине.

11.3 PV-сцена: четыре якоря вместо compile-time шкалы

Шкала сцены осталась абсолютной (переход на ожидание звука — отдельный разговор, см. план), но точки, зависящие от длины реплик, считаются при входе в сцену: pv_m50_end, pv_dialog1, pv_exit, pv_anim_end. Остальные выражения не изменились — они отсчитываются от якорей.

Восемь байт статики вместо протаскивания структуры времён через пять функций (pv_princess_step, pv_jaffar_step, pv_seq_period, intro_pv_draw_frame, intro_pv_animated), как предлагал план: сцена одна на запуск, и переделка всех сигнатур того не стоит.

Нет индекса — длительности нулевые, и сцена просто идёт без пауз на реплики: ждать нечего, звука-то нет.

11.4 Цена в памяти

За обе фазы (эффекты и музыка) резидент не вырос, а освободился:

до фазы A после фазы B
_CODE 23865 23544
куча резидента 239 Б 256 Б

Банк 9 похудел на 118 Б (ушла pop_mus_tbl из его rodata), банк 11 — на длительности реплик. Прибавка: 2 байта на страницу индекса музыки и 8 на якоря PV.

11.5 Что проверено на живой машине

  • sprpop.exe ПОБАЙТОВО одинаков для наборов flac и mt32;
  • все 22 трека в индексах различаются, и контрольные значения совпали с тем, что план предсказывал по замерам: m32 438→449, m41 732→685, m50 831→867, m53 985→1044, m56 9865→10462 блоков (78→82 страницы);
  • на mt32-наборе PV-сцена проходит целиком: m50 → m53 → m52 звучат по длительностям mt32, хотя в EXE их больше нет;
  • без mus.idx музыки нет, эффекты работают, игра проходима — сцены проскакивают без пауз на реплики.

НЕ ПРОВЕРЕНО: потоковый трек m56 на 82 страницах (у flac 78). Кольцевой проигрыватель получает число страниц из индекса, и код к этому готов, но живого прогона финала на mt32 не было — до него надо дойти в игре. Это оставшийся пункт приёмки.