В прошлой статье я разобрал Fire & Forget II – гонку 1990 года – и пересобрал её на C. Но у игры, кроме пикселей, есть звук, и с ним получилась отдельная история: звуковой драйвер тоже переехал в порт целиком, вместе со всеми своими данными. А когда в руках живой музыкальный движок 1990 года – грех не заставить его объяснить, как вообще звучала та эпоха.
Начнём с конечной точки: весь саундтрек, 28 минут, играет в Winamp через плагин, для которого драйвер игры стал полноценным звуковым форматом. Справа – окно с живыми регистрами чипа, в центре – геймплей.
Если это видео оставить фоном, статья закончится раньше, чем музыка. А теперь отмотаем к самому началу. Дальше мы пройдём путь от двух портов ввода-вывода и потока байтов до этой музыки: сначала регистры чипа, по нескольку за раз, от простого к сложному, а в конце – виртуальная машина, которая ими управляла.
Карта без единого сэмпла
В 1987 году небольшая квебекская компания Ad Lib выпустила звуковую карту для IBM PC. До неё компьютер умел пищать одним-единственным способом: PC-спикер, в штатном режиме – один прямоугольный тон без аппаратной регулировки громкости. Карта Ad Lib была устроена просто: на плате меньше десятка корпусов – синтезатор Yamaha YM3812, его напарник-ЦАП YM3014 и обвязка. Для той эпохи это скромно: ни памяти, ни сэмплов, ни собственного процессора. Через два года Creative выпустит Sound Blaster и, чтобы не потерять библиотеку игр, поставит на него тот же YM3812. Так этот чип, известный как OPL2, стал стандартом де-факто для FM-звука в DOS-играх рубежа восьмидесятых и девяностых.
Отсутствие памяти – следствие инженерного трюка. Внутри синтезатора нет записанных инструментов: только генераторы синусоид, счётчики и идея FM-синтеза, лицензированная Yamaha у Стэнфорда. Девять голосов, у каждого – два «оператора»: генератора синуса со своей огибающей.
Со стороны процессора чип выглядит как один регистровый файл. В порт 0x388 пишется номер регистра, в порт 0x389 – значение. Музыкальные регистры при этом доступны только на запись: узнать их текущее содержимое невозможно, наружу чип отдаёт лишь статусный порт со служебными флагами таймеров. Поэтому каждый драйвер эпохи держал теневую копию всех регистров у себя в памяти. Сама карта регистров делится на две части: регистры операторов (по набору на каждый из 18 операторов) и регистры каналов (9 штук):
операторы (по ячейке на оператор, 18 ячеек): 0x20+s AM | VIB | EG | KSR | MULT режимы + множитель частоты 0x40+s KSL | TL ослабление (громкость оператора) 0x60+s attack | decay огибающая: атака и спад 0x80+s sustain | release огибающая: поддержка и затухание 0xE0+s waveform форма волны (0..3) каналы (по регистру на канал, 9 штук): 0xA0+ch fnum, младшие 8 бит частота 0xB0+ch KEY | block | fnum(ст. биты) «клавиша» + октава + частота 0xC0+ch feedback | connection обратная связь и схема включения особые: 0xBD ритм-секция + глубины вибрато/тремоло
Этой карты хватит на всю статью. Разберём её по частям – на пяти коротких видео, от одной ноты до целой песни. Все видео устроены одинаково: слева команды или код, справа осциллограф и регистры, о которых идёт речь; на экране показано ровно то, что в этот момент записано в чип.
Одна нота: регистры 0x43, 0xA0, 0xB0
Оператор в этом видео уже настроен за кадром – его регистрами займёмся в следующих разделах. После такой однократной настройки нота – это три записи:
out(0x43, vol); /* громкость */ out(0xA0, fnum & 0xFF); /* частота, младший байт */ out(0xB0, 0x20 | (block << 2) | (fnum >> 8)); /* KEY | октава | частота */
В видео играет до-мажорное арпеджио: сначала тихо, потом громко. По регистру на наблюдение.
0x43 – громкость, но наоборот. Регистр хранит не громкость, а ослабление (total level): 0 – самый громкий звук, 63 – тишина, шаг 0,75 дБ. Наследие аппаратных аттенюаторов. Два старших бита – KSL, автоматическое приглушение высоких нот, здесь они нули.
0xA0 и частота не в герцах. Высоту звука задаёт десятибитное число fnum, и чип считает частоту по формуле
f = fnum · 49716 / 2^(20 − block)
где 49716 Гц – частота дискретизации чипа (тактовая 3,579545 МГц, делённая на 72), а block – трёхбитная «октава»: плюс единица – умножение частоты на два. Шаг fnum на четвёртой октаве – 0,76 Гц, то есть частота задаётся практически непрерывно. Нот в железе нет вообще; ноты – соглашение софта. Четвёртая нота арпеджио в видео – тот же fnum=343, но block=5: октава берётся бесплатно.
0xB0 – упакованный регистр. В одном байте живут три поля:
бит: 7 6 5 4 3 2 1 0 - - KEY [ block ] [fnum hi]
Бит 5 – KEY, «клавиша нажата»: пока он единица, канал звучит. Отпустить ноту – значит записать тот же байт с потушенным битом: 0x32 → 0x12. Обычный read-modify-write с таким регистром не сделать – чип не читается, поэтому новый байт каждый раз собирается из теневой копии в памяти.
И бонус, который вылез при сверке нот с камертоном. Каноническая таблица fnum, которой пользовались едва ли не все DOS-игры (она пришла из драйверов самой Ad Lib), даёт ля = 437,7 Гц вместо 440: вся таблица равномерно занижена на ~10 центов. Интервалы между нотами точны до цента – строй согласован сам с собой, просто целиком «подспущен». Ухо одиночную музыку не ловит, но рядом с настроенным инструментом весь игровой DOS слегка низил. Десятилетие, фальшивившее на десять центов, – по-моему, это прекрасно.
Нота, которая гаснет сама: регистры 0x63, 0x83 и бит EG
В первом видео нота звучит ровно, пока поднят KEY, и обрывается мгновенно. Жизнью ноты во времени управляет не программа, а генератор огибающей – автономный автомат внутри каждого оператора. Его настраивают два регистра по два поля в каждом:
0x63 [ attack ][ decay ] скорость атаки и спада, 0..15 0x83 [ sustain ][ release ] уровень поддержки и скорость затухания, 0..15
Скорости здесь логарифмические: каждый шаг вниз примерно удваивает время. Attack=15 – практически мгновенный старт; release=3 – затухание за пару секунд, release=15 – почти мгновенный обрыв. Плюс один бит в регистре 0x23 – EG (в документации EG-TYP): единица – «орган», нота держится на уровне sustain, пока нажата клавиша; ноль – «пианино», после атаки нота гаснет сама со скоростью release.
В видео это видно буквально. Команда
envelope(0, 3); /* sustain = 0, release = 3 */ note(343, 4, 4); /* fnum, block, vol */
делает несколько записей – и всё, дальше в журнале тишина. А индикатор громкости ползёт вниз сам: нота затухает 2,7 секунды силами чипа. Панель регистров молчит – гаснет железо.
Второй сюжет видео – аккорд. Адресация каналов проста: регистры канала N – это регистры канала 0 плюс N, то есть 0xB1 – это KEY второго канала, 0xB2 – третьего. Три ноты до-мажора нажимаются на каналах 0, 1, 2 с интервалом в полсекунды: каждая гаснет сама, и они наслаиваются в аккорд. Единственная тонкость – повторное нажатие требует «фронта»: генератор огибающей начинает новую атаку только по переходу KEY из 0 в 1, поэтому перед вторым аккордом все три канала на мгновение отпускаются записями в 0xB0–0xB2.
Тембр и спецэффекты: регистры 0x20, 0x40, 0x60, 0xC0
До сих пор из двух операторов каждого канала работал только второй – несущий (carrier): его громкость мы крутили в 0x43, его огибающую настраивали в 0x63/0x83. Первый оператор – модулятор – простаивал. Пришло время главной особенности архитектуры. В схеме FM в динамик попадает только несущий; выход модулятора прибавляется к фазе несущего. Когда синусоида модулятора идёт вверх, частота несущего проскакивает вперёд; вниз – тормозит. Медленно это было бы вибрато. Но модулятор качает частоту со звуковой скоростью, кратной самой ноте, и ухо слышит результат целиком – как тембр. Это и есть FM-синтез.
Ручки этого тембра разложены по уже знакомой операторной карте, только теперь мы пишем в ячейки модулятора (для канала 0 это 0x20/0x40/0x60/0x80 против 0x23/0x43/0x63/0x83 у несущего – ячейки операторов идут вперемешку, инкремент на канал здесь не единица, это самая частая ошибка при работе с картой):
0x20 AM | VIB | EG | KSR | MULT
MULT – четырёхбитный множитель частоты оператора относительно ноты: от ×0.5 (значение 0) до ×15. Отношение множителей модулятора и несущего задаёт характер тембра: 1:1 – тёплая медь, ×7:×2 – колокольный металл. AM и VIB – аппаратные тремоло (3,7 Гц) и вибрато (6,1 Гц), их глубины – два старших бита регистра 0xBD.
0x40 KSL | TL модулятора
TL модулятора – не громкость (он же не звучит), а глубина модуляции, то есть яркость тембра: 63 – модулятор выключен, чистый синус; 0 – полный газ.
0xC0 - | feedback(3 бита) | connection
Feedback заворачивает выход модулятора ему же на вход; поле трёхбитное – «выключено» и семь ступеней глубины, на максимуме синус разваливается почти в шум. Connection переключает схему: 0 – FM (модулятор → несущий), 1 – операторы просто складываются, и тогда слышны оба.
Из этих кубиков в видео собраны пять узнаваемых звуков. Ни одному из них не нужен код во время звучания: регистры настраиваются один раз, нажимается клавиша, дальше физику воспроизводит сам чип. Вот, например, взрыв целиком:
static void boom(int fnum, int block) { out(0x20, 0x01); out(0x40, 0x00); /* модулятор: x1, полный газ */ out(0x60, 0xF4); out(0x80, 0x05); /* его огибающая: перкуссия */ out(0x23, 0x01); out(0x43, 0x00); /* несущий: перкуссия, громко */ out(0x63, 0xF4); out(0x83, 0x04); out(0xC0, 0x0E); /* FEEDBACK = 7: синус -> шум */ press(fnum, block); /* A0/B0, как в первой сцене */ }
Остальные четыре – вариации тех же регистров:
дверной звонок «динь-дон» – MULT ×7 у модулятора против ×2 у несущего: негармоническое отношение даёт металлические обертоны (в спектре 1 : 2,5 : 4,5 – как у настоящего колокола, где частичные тона не кратны основному);
клаксон – два канала кластером 330 и 415 Гц, FM 1:1, feedback 3 – «би-бип»;
будильник – модулятор на ×0.5 против несущего на ×15: сорокагерцовый дребезг на тоне 1200 Гц, ноль записей, пока звенит;
вертолёт – несущий на 7 герцах (чип спокойно берёт инфразвук: fnum=148, block=0) с feedback=7: каждый период – «шлепок» лопасти.
Одна находка стоит отдельного абзаца, потому что на ней я застрял сам. У модулятора – своя собственная огибающая, те же 0x60/0x80, и если не открыть ему атаку, он молчит: модуляции нет вовсе, из динамика идёт голый синус, и ты полчаса смотришь в правильные с виду регистры. Тембр в FM – тоже процесс во времени: у настоящих инструментов атака яркая, а хвост глухой, и делается это огибающей именно модулятора.
Как это включала игра: инструменты, тики и регистр 0xBD
До сих пор мы жали клавиши руками. Игра не может отдать звуку отдельный цикл ожидания – процессор занят дорогой, спрайтами и врагами, – поэтому вся музыка живёт в обработчике прерывания таймера. Fire & Forget II программирует системный таймер на делитель 0x3333: 1193182 / 13107 ≈ 91 Гц. Драйвер получает управление 91 раз в секунду, делает несколько записей в чип и отдаёт процессор обратно. Запись в OPL2, к слову, недешёвая: по даташиту после байта адреса нужно выждать 12 тактов чипа, после байта данных – 84, итого около 27 микросекунд на пару «адрес + данные». Ещё одна причина, почему драйверы держали теневые копии и писали только изменения.
Набор регистров, которыми оперирует драйвер, – ровно тот, что мы уже прошли, плюс один новый. Инструмент в данных игры – это одиннадцатибайтовая запись: по пять значений на каждый оператор (0x20, 0x40, 0x60, 0x80, 0xE0) и байт для 0xC0. Смена инструмента – одиннадцать записей в чип. Нота – две записи, 0xA0 и 0xB0, как в первой сцене. А ударные живут в отдельном регистре:
0xBD DAM | DVB | RHY | BD | SD | TOM | CYM | HH
Бит RHY переводит каналы 6–8 в ритм-режим: из их шести операторов чип собирает пять барабанов – бочку, снейр, том, тарелку и хай-хэт; часть из них строится на генераторе шума и фазовых трюках. Удар барабана – это взвод его бита в 0xBD; драм-машина в одном байте.
В видео играет не синтетика, а настоящий игровой звук: сигнал тревоги из бинарника игры. Слева – то, что драйвер читает: шестнадцать строк нот на три мелодических канала плюс хай-хэт. Тревога оказалась поучительной вещицей. Во-первых, это не цикл: программа проигрывается один раз, а «зацикленность» в игре – это игра, которая перезапускает её, пока висит опасность. Во-вторых, у тревоги два разных вызова в коде игры: попал по цели, но задание не выполнено – тревога вклинивается поверх музыки (у драйвера есть стек песен: доиграла – музыка продолжается с того же места); кончилось топливо – тревога сносит музыку насовсем, и после неё тишина. Флаг «поверх/вместо» – одна цифра в вызове, а драматургии – на целый саунд-дизайн.
Виртуальная машина музыки
Осталось главное: откуда драйвер берёт, что играть. Музыка в игре – это байткод, а драйвер – интерпретатор. Разберём его полностью.
Данные. В игре пятнадцать песен. Каждая описана записью в таблице: темп, указатель на таблицу инструментов и директория голосов – список из нескольких потоков байткода. Голос – это независимый поток со своим контекстом:
typedef struct { u16 pc; /* указатель в потоке байткода */ u8 dur; /* счётчик длительности текущей ноты */ u8 dur_reload; /* длительность из последней команды DUR*/ u16 ret; /* адрес возврата для GOSUB/RETURN */ u8 loop; /* счётчик для SETLOOP/LOOP */ u8 instr, vol; /* текущий инструмент и громкость */ u8 channel; /* какой канал OPL2 занят этим голосом */ } Voice;
У тревоги шесть голосов, у музыки первого уровня – семь: четыре мелодических и три барабанных.
Кодировка. Всё решает старший бит байта. Если он ноль – это нота, упакованная в два нибла:
0x17 = нижний нибл 7 (соль), верхний 1 (октава) -> G-1 0x0C = нижний нибл 12 -> пауза
Нижний нибл – индекс в таблице из двенадцати fnum (та самая, «минус десять центов»); значение 12 означает паузу. Верхний нибл – block. Один байт – одна нота с октавой.
Если старший бит единица – это команда. Биты 6…4 – код операции, нижний нибл – операнд:
биты 6..4 команда пример 0 DUR n 0x84 = DUR 4: длительность нот = 96>>4 = 6 тиков 1 VOL n 0x9F = VOL 15: громкость голоса (уйдёт в TL несущего) 4 GATE n 0xC0: режим ретриггера 6 INSTR n 0xEA = INSTR 10: загрузить инструмент №10 (11 записей) 7 расширенные поток управления, см. ниже
Расширенные команды – это поток управления, у части из них есть операнды в следующих байтах:
0xF0 lo hi GOSUB addr вызов фразы (адрес словом, возврат 1 уровень) 0xF1 n SETLOOP n зарядить счётчик повторов 0xF2 lo hi LOOP addr декремент счётчика, прыжок пока не ноль 0xF3 RETURN возврат из фразы 0xF4 lo hi JUMP addr безусловный переход (вечный цикл песни) 0xFF END конец голоса
Вот реальные байты из тревоги – один голос: четырёхбайтовый заголовок и фраза на 21 байт, итого 25 байт на партию:
843D: F0 58 84 FF GOSUB 0x8458; END 8458: E0 84 9F C0 INSTR 0; DUR 4; VOL 15; GATE 0 07 0C 06 07 08 0C 07 0C G-0, пауза, F#-0, G-0, G#-0, пауза, G-0, пауза 0C 0C 12 0C 17 0C 0C 0C две паузы, D-1, пауза, G-1, три паузы F3 RETURN
Шестнадцать нот, семь из них звучащие. А музыка уровня уже пользуется структурой всерьёз: заголовок голоса – это программа из GOSUB и LOOP, а фразы лежат отдельно, как библиотека:
9BAA: F1 04 SETLOOP 4 9BAC: F0 AE 9C GOSUB 0x9CAE ; фраза A (басовый ход) 9BAF: F2 AC 9B LOOP 0x9BAC ; ...четыре раза 9BB2: F1 02 SETLOOP 2 9BB4: F0 E4 9C GOSUB 0x9CE4 ; фраза B, дважды ... 9BC2: F4 AA 9B JUMP 0x9BAA ; и всё сначала, навсегда
Кто именно писал этот байткод, неизвестно: возможно, композитор руками, возможно – конвертер его рабочего инструмента. Внутренние редакторы Titus не сохранились; известно лишь, что студия французская и разрабатывала на Atari ST. Как бы то ни было, музыка здесь устроена как программа с библиотекой фраз: куплет – подпрограмма, повтор – цикл, песня – бесконечный JUMP. Тот же принцип, что и у языка уровней из прошлой статьи, – похоже, внутренняя культура студии.
Интерпретатор. Ядро драйвера – процедура тика, по одной на голос, 91 раз в секунду. В упрощённом виде:
void voice_tick(Voice *v) { if (--v->dur > 0) return; /* большинство тиков кончается тут */ for (;;) { /* счётчик дотикал: читаем поток */ u8 b = stream[v->pc++]; if (b < 0x80) { /* нота или пауза */ if ((b & 15) < 12) play_note(v, fnum_tbl[b & 15], b >> 4); /* A0 + B0 */ v->dur = v->dur_reload; /* перезарядить счётчик */ return; /* тик окончен */ } switch ((b >> 4) & 7) { /* команда: исполняем и читаем дальше */ case 0: v->dur_reload = 96 >> (b & 15); break; case 1: set_volume(v, b & 15); break; /* пишет 0x40+car */ case 6: load_instr(v, b & 15); break; /* 11 записей */ case 7: /* поток управления */ switch (b) { case 0xF0: v->ret = v->pc + 2; v->pc = word(v->pc); break; case 0xF1: v->loop = stream[v->pc++]; break; case 0xF2: v->pc = (--v->loop) ? word(v->pc) : v->pc + 2; break; case 0xF3: v->pc = v->ret; break; case 0xF4: v->pc = word(v->pc); break; case 0xFF: v->active = 0; return; } } } }
Обратите внимание на структуру: команды исполняются подряд, «бесплатно», в том же тике – цикл крутится, пока не встретит ноту или паузу. Нота завершает тик и заряжает счётчик. Поэтому смена инструмента, громкости и длительности не занимает музыкального времени: INSTR 2; DUR 3; VOL 15; G-1 – это один тик и одна нота.
Поверх всего этого тикает ещё один счётчик – делитель темпа: раз в N тиков интерпретатор пропускает такт, и та же самая партитура играет медленнее. Темп – одно число в заголовке песни, значения DUR при этом не меняются.
Видео. В последнем ролике этот интерпретатор исполняет первые двадцать секунд музыки первого уровня, растянутые в две минуты. Экран устроен как отладчик: hex-дамп программы с бегущими курсорами (у каждого из семи голосов – свой pc), байты DUR подсвечены, внизу – регистровый файл чипа целой таблицей. Видно главное: интерпретатор почти всё время ничего не делает. Подавляющее большинство тиков – это семь декрементов dur и один декремент делителя темпа. Изредка счётчик дотикивает до нуля – и тогда вспышка байтов в дампе, пара записей в чип, новый счётчик. Аккорд в этом представлении – вертикальное событие: несколько голосов дочитались до нот в один и тот же тик. А голоса с разными DUR расходятся и сходятся, как стрелки часов: барабанные фразы в темпе вдвое реже мелодии встречают её раз на два такта.
Пасхалки нашлись и здесь. В байткоде лежат «острова» – код, до которого не доходит ни одна ветка исполнения: целая вырезанная трёхголосая пьеса в соль миноре, запасной голос первого уровня, дубль фразы октавой ниже. Ассемблер композитора, как и ассемблер уровней, не вычищал мёртвый код – и спустя три с половиной десятилетия это позволяет послушать то, что не вошло в игру. Ещё выяснилось, что из пяти барабанов ритм-секции саундтрек ни разу не бьёт по двум (том и тарелка молчат все 28 минут), а самая длинная композиция игры – четвёртый уровень – имеет период повторения четырнадцать с половиной минут: длительности голосов разведены так, что их общий цикл почти не сходится.
Возвращаемся к началу
Теперь первое видео можно читать как открытую книгу. Окно регистров справа – это те самые 0xA0/0xB0 с частотами, теневые копии write-only регистров, KEY, который горит почти всегда (в языке этого драйвера пауза – не «отпустить клавишу», а «ничего не писать»), и огненная дорожка барабанных ударов из регистра 0xBD. Сам плеер – ещё одна ирония этой истории: вместо конвертации музыки в чужой формат я объявил драйвер игры форматом. Файл .FF2 несёт байты данных игры – потоки байткода, инструменты, таблицу песен, – а плагин Winamp исполняет их тем же кодом, что и игра.
Ну и последний штрих, куда же без него. Композитор прописал в инструментах разные формы волны – у OPL2 их четыре, это регистры 0xE0, – но драйвер ни разу не взводит бит, который их включает. На настоящем YM3812 все эти тембры схлопывались в чистый синус: так, как задумывал композитор, музыка на железе 1990 года не звучала никогда. На поздних картах семейства – уже да. И в этих видео – тоже: получается, мы слушаем версию, которая десятилетия ждала своего чипа.
Реконструкция игры: titustribute.com/ff2.
Комментарии (8)

alliumnsk
20.07.2026 14:58Был ещё хак, позволяющий проигрывать на Ad Lib оцифрованный звук, но использовался редко, т.к. нагружал процессор

BiTL
20.07.2026 14:58никогда он не использовался, да и не было такого хака

n0isy Автор
20.07.2026 14:58del

BiTL
20.07.2026 14:58У adlib есть недокументированный режим Speech mode, во всех документациях описано только как его включить. Но нафига он нужен и как им пользоваться - нигде не описано. В теории, предполагалось, что можно иммитировать нечто вроде человеческой речи с помощью резонансных частот (нечто похожее, но на чипе OPN2 : https://www.youtube.com/watch?v=BPDP-3FphM0 ).
Но это все же не проигрывание PCM. Да и реальных примеров на Adlib я не встречал.

n0isy Автор
20.07.2026 14:58Это не похоже на PCM-хак?
https://www.youtube.com/watch?v=mpq-wErz1dc
BiTL
20.07.2026 14:58Как-то не убедительно звучит, такие звуки можно и без "хаков" извлечь. И что-то мне подсказывает, что оно только в DOSBox и будет работать. Папробывал бы он на реальном железе :)
Zhabrozavr
Музон, конечно, мозговысверливающий )) Но могу себе представить, что для того времени это было технологическое Откровение ))
BiTL
не, не было.