Каждый, кто хоть раз заглядывал на Hacker News или r/ItRunsDoom, знает традицию: DOOM должен работать на всём. PDF‑файлы, SQL‑запросы, кишечные бактерии, тест на беременность — список бесконечен, и каждый новый порт абсурднее и интереснее предыдущего

Но я задался вопросом: а можно ли запустить DOOM внутри шрифта?

На вопрос «зачем?» ответить я могу тоже самое, что и авторы остальных портов: «Руководствуясь академическим интересом», «Для исследования границ, казалось бы привычных систем», ну и «Просто по приколу». Суть была в том, чтобы запустить не «рядом» со шрифтом, не «в браузере, который рендерит шрифт». А именно внутри — чтобы сам шрифтовой движок вычислял 3D‑геометрию стен

Оказалось, что можно. И вот как:

Упрощённое представление
Упрощённое представление

Что такое TrueType hinting и почему он неожиданно мощный?

Когда Apple проектировала формат TrueType в конце 1980-х, перед инженерами стояла конкретная задача: шрифты должны выглядеть идеально на любом экране, при любом размере. Для этого в формат встроили «hinting» - набор инструкций, которые двигают контрольные точки глифов, подгоняя их под пиксельную сетку.

Набор инструкций оказался... щедрым. Вот что есть в TrueType hinting VM:

  • FDEF/ENDF — определение функций

  • CALL — вызов функций

  • RS/WS — чтение/запись в storage (26+ регистров)

  • IF/ELSE/EIF — условия

  • JMPR — безусловный переход (можно сделать while)

  • MUL/DIV/ADD/SUB — арифметика (F26Dot6 fixed‑point)

  • SCFS/GC — манипуляция координатами контрольных точек

Функции, циклы, условия, регистры, арифметика. Это стек‑машина, и она Тьюринг‑полна (с оговоркой на конечную память). Кто-то должен был это доказать чем-то поинтереснее счётчика :-)

Архитектура: шрифт как GPU

Идея простая, реализация - нет.

JavaScript играет роль CPU: обрабатывает ввод с клавиатуры, хранит состояние игры (позиция игрока, угол поворота), управляет тайминагми.

Шрифт играет роль GPU: получает позицию и угол через font-variation-settings оси, запускает рейкастинг внутри hinting VM, вычисляет высоту стен для каждого столбца экрана, и возвращает результат через координаты контрольных точек глифа, которые JS читает обратно.

Коммуникация:

  • JS -> Шрифт: записать позицию/угол в variation axes

  • Шрифт: запустить hinting-программу, вычислить 3D

  • Шрифт -> JS: прочитать координаты глифа (это вычисленная высота стен)

P.S.

Если нажать Tab в демке, откроется debug mode - там видно, как font-variation-settings оси меняются в реальном времени при движении игрока

Компилятор: потому что писать байткод руками - это боль

Первые эксперименты я делал вручную, записывая TT-инструкции по одной. К десятой функции стало ясно, что нужен компилятор.

Собственно, получился мини-компилятор из трёх частей:

  • Лексер - разбивает DSL-код на токены

  • Парсер - строит AST

  • Кодогенератор - транслирует AST в последовательность TT-инструкций

DSL выглядит примерно так: вы пишете функции с арифметикой и условиями, а компилятор превращает это в FDEF/ENDF блоки с правильной стековой адресацией. 451 тест помогает не сойти с ума при рефакторинге (однако их написание - нет).

Итоговый результат
Итоговый результат

Баги, которые заставили усомниться в реальности

За время разработки я нашёл несколько багов (или «особенностей»), которые стоят отдельного упоминания.

MUL truncation: 1 × 4 = 0. TrueType использует F26Dot6 fixed-point формат: 26 бит на целую часть, 6 бит на дробную. Когда вы умножаете два числа, каждое из которых меньше 1.0 в этом формате, результат округляется до нуля. Это не баг - это особенность. Но когда рейкастер вдруг перестаёт рисовать стены, а вы два дня ищете ошибку в логике... Короче, неприятно.

SVTCA[0] = Y, а не X. Инструкция SVTCA задаёт вектор проекции. Я предполагал, что [0] - это X-ось (как в любом здравом API). Нет. Спецификация индексирует с Y=0, X=1. Два часа жизни.

«return» не возвращает из while. В TT bytecode нет настоящего return. Точнее, есть, но он выходит из FDEF, а не из «цикла», который на самом деле реализован через JMPR. Пришлось изобретать флаговые переменные для имитации break/return.

Идейный вдохновитель

llama.ttf (июнь 2024) - LLM внутри шрифта через HarfBuzz WebAssembly shaper. Wasm - это по определению универсальный рантайм, так что «шрифт» тут скорее контейнер. Увидев этот проект, стало интересно разобраться в том, как вообще блин можно запустить модель в шрифте, но, как оказалось, всё куда интереснее, так что спасибо авторам за их проект, он действительно очень необычный и интересный.

Что это значит для безопасности?

Это не просто забавный факт. Каждый браузер, который рендерит TrueType-шрифты с включённым хинтингом, выполняет произвольный код внутри шрифтовой VM. В 2025 году были опубликованы исследования по side-channel атакам через hinting (микроархитектурные тайминги), но вычислительная мощность самой VM пока мало изучена.

Теоретически, вредоносный шрифт мог бы использовать hinting‑программу для чего угодно — от fingerprinting до утечки данных через тайминги рендеринга. TTF‑DOOM — это proof‑of‑concept того, насколько мощен этот «безобидный» механизм

Попробуйте сами

Демо (Chrome/Edge, нужна поддержка variable fonts)

Исходники

Нажмите Tab для debug mode - увидите, как font-variation-settings оси обновляются в реальном времени

Если у вас есть идеи, что ещё можно запустить внутри TrueType hinting VM или хотите обсудить угрозы для безопасности - вперёд! Я весь внимание :-)