
В детстве я учился программированию на старом IBM PC с монохромным зелёным монитором, поэтому давно хотел создать программу для этой системы. За последние пятнадцать лет я написал более чем сотню небольших интро. В последнее время я был не так активен, но потрясающее интро «Rainbow Surf» автора Plex размером всего 16 байт мотивировало меня снова вернуться к старым наброскам и взяться за работу.
Создание этой программы сопровождалось привычными экспериментами. Я развлекался с клеточными автоматами в графике и звуке, а также исследовал трюки sizecoding. В частности: а) полиморфные ассемблерные команды (наподобие add [bx+si],al, имеющую машинный код 0x0000) б) переход в середины команд для экономии байт и многократного использования опкодов. Из сотен мелких экспериментов мне приглянулся один, интересный одним только своим названием.
Когда я дополнял всё это и избавился от лишнего, мне трудно было понять, что же происходит. Я чесал в затылке, глядя на простую формулу, оставшуюся после удаления код‑гольфингом кучи байт. Я и сам не ожидал, что объяснение этих считанных байт окажется настолько глубоким.
В моём интро «M8trix» 2014 года по экрану уже бегали псевдослучайные буквы (в 8 байтах, а потом и в 7), но мне всегда было интересно, как добавить к ним красивый звук. Но хронологически в разработке «wakeup» я сначала занимался звуком. Хорошим подзаголовком к статье мог бы быть «16 байт, превращающие звук треугольника Серпинского в дождь символов из „Матрицы“».
Примечание: на каждом такте времени новая строка треугольника Серпинского а) воспроизводится через динамик б) отрисовывается на экране с шагом 56. Можно ощутить движение, но не увидеть его, потому что оно имеет «ширину» 8192 пикселей, но одна строка символов состоит всего из 80 байт. Если бы экран был гораздо больше, то можно было бы увидеть треугольник. Или если не пропускать пиксели и рисовать и все одновременно, он тоже был бы виден.
Оригинал демо можно скачать с Demozoo.
Итак, вот 16 байт ассемблерного кода DOS реального режима x86. При запуске кода он использует видеопамять в качестве пространства вычислений для отрисовки бесконечного фрактала Серпинского, одновременно выводя в динамик ту же геометрию.
int 10h ; 2 байта mov bh, 0xb8 ; 2 байта mov ds, bx ; 2 байта L: lodsb ; 1 байт sub si, byte 57 ; 3 байта xor [si], al ; 2 байта out 61h, al ; 2 байта jmp short L ; 2 байта
1. Холст: подготовленная пустота
Код начинается со стандартного прерывания BIOS: int 10h. Оно задаёт видеорежим 0, создающий сетку 40×25 текстового режима. Затем сегмент данных (ds) указывает на 0xb800 — адрес памяти текстового буфера VGA/CGA.
Когда BIOS очищает экран, она не заполняет память одними нулями. Каждое пространство символов состоит из двух байт: символа ASCII и атрибута цвета. Все 2000 слотов заполняются 0x20 (пробелом) и 0x07 (светло‑серым на чёрном). То есть экран выглядит пустым, но память уже заполнена однообразным паттерном.
Я написал множество звуковых интро на основе шума и клеточных автоматов, но это выделяется на их фоне. Оно было и продолжает оставаться для меня крайне неожиданным! Тонкость здесь заключается в особенностях инициализации памяти при очистке экрана и в том, что находится до и после самой видимой памяти. «Чистый» звук тоже прекрасен (при помощи дополнительных байт я тщательно подготовил всё, чтобы он звучал одинаково на всех системах), но эта тонкость, которую я всё ещё не до конца понял, делает звук ещё лучше.
2. Движок: аддитивные суммы префиксов
Переплетение звука и графики, синестезия намного глубже, чем то, что я видел во всех найденных мной крошечных интро. Я даже считаю, что она раскрывает больше математических секретов и взаимосвязей, чем системы итеративных функций в «хаотических играх» без RNG. Как бы то ни было, мне хочется, чтобы вы фундаментально разобрались в математике того, что вы слышите, а не просто добавляли случайно какие‑то операции, получая интересный звук.
Перейдём к чистой математике: мы предполагаем, что всё состояние равно нулям, а не 0x20, используем add вместо xor и делаем шаг на 16 байт за раз. Предполагаем, что накопитель al изначально содержит 2.
Сегмент DOS равен 65 536 байтам. Если мы двигаемся с шагом 16 байт, то для обхода сегмента нам понадобится ровно 4096 шагов (65536/16 = 4096), после чего si выполняет чёткий возврат к 0x0000.
Сложение значений между ячейками создаёт частичные суммы. Так как 4096 кратно 256 (размер 8-битного регистра), перенос идеально согласуется с моментом возврата к началу сегмента, сбрасывая al на значение 2 в начале каждого прохода.
Значение соответствует биномиальной последовательности, умноженной на 2:
Вот как происходит построчное накопление на первых 16 шагах:

3. Кристаллизация: XOR и сдвиг Серпинского
Теперь вернёмся к комбинаторике. По особым законам, при выполнении сложения по модулю два, возникает треугольник Серпинского. Именно этот конкретный бит подаётся на динамик, а другие биты игнорируются.
Чтобы разделить битовые плоскости, используется XOR, а не add, поскольку сложение битов без переноса — это как раз XOR.
Так как код начинается с 2 (двоичного 00000010), между 0x00 и 0x02 меняется только бит 1. Это идеально соответствует правилу 60 в элементарных клеточных автоматах:
Теорема Люка гарантирует, что это соответствует биту 1 из таблицы сложений. Убедитесь сами («2» означает, что бит 1 установлен):

4. Голос машины: превращение данных в аудио
А вот и трюк: out 61h, al
Порт 61h общается с PC speaker. Бит 1 выдвигает (1) и втягивает (0) диффузор динамика. Код вычисляет фрактал при помощи XOR, записывает результат в память и отправляет этот байт прямиком в порт динамика.
Нули и единицы из фрактала создают прямоугольные волны, естественным образом сдвигающие ширину и частоту импульсов:

При линейном воспроизведении создаётся самоподобный байтовый бит с почти не меняющимся темпом.
Но и это ещё не всё: на динамик выводится не только текст, но и оставшиеся байты 64-килобайтного сегмента, которые в данном случае содержат код видео‑BIOS, скопированный из ROM в теневую память — секретный ингредиент панковского «шершавого» звука, сильно отличающийся от ожидаемого бита прямоугольных волн на основе линий Серпинского.
5. Шаг в 56 байт: октавные смещения и диагональные перемещения
Для воссоздания эффекта M8trix ячейки необходимо распределять по экрану так, чтобы буфер звука был не очень большим, а экран был заполнен символами с красивыми промежутками.
То есть код выполняет шаги не на 16. sub si, byte 57 плюс lodsb означают, что он сдвигается на -56 байт за итерацию, то есть движется назад.
Звук
65 536 не делится на 56 без остатка. Код только попадает в смещения, кратные 8, делая 8192 шага и возвращаясь в начало 7 раз перед сбросом. Это удваивает длину цикла, вдвое уменьшая основную частоту. Звук снижается на одну октаву.
Графика
Возврат назад на 56 байт на экране шириной 80 байт аналогичен сдвигу вперёд на 24 байт (12 столбцов). Мы посещаем только 10 уникальных столбцов. Фрактал не отрисовывается, как сплошное изображение, а располагается по в десяти столбцах символов диагонали, двигающихся по экрану.
6. Реальное оборудование и мысли напоследок
Участник демосцены miragept сам записал видео моего демо, мотивировав это так:
«Это настолько круто, что я обязан был запустить интро на реальном железе. Зелёный текст естественным образом сочетается с MDA/Hercules, поэтому я пропатчил адрес с 0xB800 на 0xB000, который использует MDA. У меня нет компьютера IBM, но я использовал 286 с EGA‑картой, способной эмулировать MDA/Hercules, и настоящий MDA‑монитор, так что почти всё соответствует.»
Прошу прощения за низкое качество звука (постоянный шум идёт от самой машины). Следует учесть, что у этого монитора (IBM 5151) ВЫСОКАЯ длительность послесвечения люминофора, что, как мне кажется, в этом случае ухудшает картинку из‑за высокой скорости интро.»
Я ответил ему так:
«HellMood @miragept: большое спасибо за это♥ Рад видеть, что всё работает, как и задумывалось, пусть и с немного другим звуком из‑за замены байта. Возможно, интро и в самом деле было бы лучше при более низкой скорости, но мне кажется примечательным, что структура треугольника Серпинского становится более видимой (ближе к концу), чем в моей версии».
Артефакты в ОЗУ эмуляторов и разных версий BIOS немного различаются. Так как XOR кода выполняется с тем, что там находится, вывод сильно зависит от окружения. Предварительная очистка памяти обеспечила бы совершенно одинаковый вывод, но за это пришлось бы расплачиваться драгоценными байтами. Использование естественного состояния оборудования — одна из приятных сторон sizecoding.
Ссылки и ресурсы
Nanogems — курируемый список лучших маленьких интро демосцены
Видео, записанное miragept на 286/MDA/Hercules
Rainbow Surf — 16 байт x86 авторства Plex
M8trix — 8 байт авторства HellMood