Атрибуты, макросы и статический аллокатор в Retrolang

Retrolang похож на C, но сходство лишь базовое. В действительности я не собираюсь копировать C — не только потому что это фактически мне не нужно, но и потому что программирование под PSX требует весьма специфических возможностей языка, почти на уровне DSL (при этом я не стал их прибивать гвоздями к MIPS, они теоретически могут быть реализованы на системном уровне для любой архитектуры).

Во-первых, это, конечно, атрибуты размещения и инициализации статических данных. Я почти сразу сделал поддержку вшивания в бинарник файлов — это очень полезно, чтобы писать мини-игры, которым не требуется читать файлы с диска, а также чтобы встроить в программу bitmap-шрифт или какие-то другие данные.

uchar data[] @("file.bin");

На PSX слишком мало памяти, чтобы использовать динамический аллокатор, как в современных ОС. Программа должна заранее распланировать память, которую она потребляет. Если доверить эту задачу компилятору, то он может все проверять и не допускать коллизий (в то время как ручная работа с диапазонами адресов чревата ошибками). Для этого в Retrolang используются все те же атрибуты:

macro Region = (0x80050000, 2048);
int someData[512] @Region;

Это статически выделит 512*4 байт в регионе MyRegion. Для этого в кодогенераторе предусмотрена специальная таблица регионов и аллокаций.

Преимущество такого подхода перед выделением статической памяти в секции данных в том, что бинарник остается маленьким, а это иногда критично. Кроме того, регионы поддерживают Scratchpad:

int someData[64] @SCRATCHPAD;

Выделенную память можно затем использовать в качестве арены для создания в рантайме более мелких объектов.

Во-вторых, AST-макросы. Это киллер-фича, которая делает ненужным препроцессор C. Препроцессор в Retrolang все равно есть, но возможности его ограничены, и я не планирую их развивать. Другое дело — макросы. Они позволяют переиспользовать практически любые выражения и утверждения языка, параметризуя их. Это похоже не шаблоны, но шаблон обычно параметризуется типами, а макрос — константами, идентификаторами, выражениями и другими макросами. Как и шаблоны, макросы — тоже разновидность метапрограммирования, но их главное назначение — явный инлайнинг кода. C инлайнит непредсказуемо, а Retrolang — язык с предсказуемой семантикой, в котором каждая операция транслируется в машинный код буквально. Поэтому в условиях, когда вызов лишней процедуры — дорогое удовольствие, очень важно переиспользовать код статически. Например, вместо того, чтобы создавать функцию с командами GTE, можно сделать соответствующий макрос, и команды будут вставлены непосредственно в вызывающий код без оверхеда на движение по стеку:

macro gte_upload_transform(tr)
{
    ctc2(GTE_TRX, tr->tx);
    ctc2(GTE_TRY, tr->ty);
    ctc2(GTE_TRZ, tr->tz);
    
    gte_set_matrix(tr->r);
    
    ctc2(GTE_H,   tr->h);
    ctc2(GTE_OFX, tr->ofx);
    ctc2(GTE_OFY, tr->ofy);
    ctc2(GTE_DQA, tr->dqa);
    ctc2(GTE_DQB, tr->dqb);
}

Макросы также удобны для создания удобочитаемых оберток над функциями BIOS.

Dagon 10 лет!

Проект стартовал 9 октября 2016 года, ровно 10 лет назад. Конечно, это не первая моя разработка в сфере геймдева, и я начал писать движок далеко не с полного нуля — к тому моменту у меня был DGL, набор классов, упрощающий работу с OpenGL в D (а до этого я работал с OpenGL и писал разнообразные сопутствующие библиотеки на C++). Однако именно Dagon стал наиболее успешным.

Создание игрового движка — наверное, одна из самых интересных и нетривиальных задач в программировании. Есть устойчивое убеждение, что свой движок писать вообще не нужно, и я как-то раз написал статью о том, почему это скорее миф. Существуют примеры успешных проектов как на лицензированных движках, так и на эксклюзивных, оба варианта имеют свои плюсы и минусы. На мой взгляд, главное преимущество собственной технологии — независимость от чужих лицензионных условий и архитектурных решений. В современном мире большинство проектов делается на Unity и UE, в результате чего игры слишком похожи друг на друга. Собственный движок — это ваш уникальный почерк. Понятно, конечно, что с UE конкурировать практически невозможно — продукты, в которые вложены миллионы долларов, будут всегда впереди. Но UE — это движок-монстр, все возможности которого нужны далеко не каждому. Если речь об инди, то нелепо, когда рантайм игры весит, как все ее ресурсы (если не больше), а системные требования — как у AAA. Должны же быть и маленькие движки! Ну и, наконец, психологический фактор: игра на чужом движке воспринимается как не совсем твоя.

Я начал интересоваться геймдевом еще в далеком 2005 году, когда открыл для себя Game Maker, Blitz3D и другие популярные любительские технологии того времени. Затем я пробовал работать с Irrlicht, OGRE, Blender Game Engine, но с точки зрения архитектуры меня очень впечатлила GLScene, графическая библиотека для Delphi. Мои собственные разработки во многом вдохновлены классами GLScene, хотя напрямую я оттуда ничего не копировал. Я вообще почти не заглядываю в исходники чужих движков — не знаю, хорошо это или плохо, но у меня свое понимание того, что удобно, а что нет. Боже упаси создавать клон Unity или что-то подобное. Не мыслите шаблонами индустрии, изобретайте свои технологии!

Я никогда не писал на DirectX, как-то почти сразу освоил OpenGL и всю жизнь сидел на нем. В нулевых это были версии 1.x-2.x, еще с фиксированным конвейером, где шейдеры были необязательной частью. Потом — короткий период 3.x, и, наконец, к концу десятых — 4.x. Я проследил практически всю эволюцию OpenGL и использовал его до последнего, когда стало уже очевидно, что пора двигаться дальше и осваивать эксплицитные API. Я, однако, не фанат чистого Vulkan. На нем не надо писать приложения напрямую по той же причине, по которой не пишут напрямую на ассемблере. Vulkan — это машинно-ориентированный бэкенд, поверх которого нужна абстракция, удобная для человека. Я терпеливо ждал появления таких абстракций, они появились, и вот теперь можно со спокойной душой уходить с OpenGL.

Что еще могу сказать на основе десятилетнего опыта? Разрабатывая большую систему, такую, как игровой движок, рано или поздно приходишь к идее неизбежности компромисса между двумя силами, тянущими проект в две разные стороны — абстракцией и специализацией. Абстракция — это сокрытие деталей под высокоуровневым интерфейсом. Абстракция повышает удобство на стороне пользователя, но снижает гибкость и возможности оптимизации. Специализация, напротив, подразумевает, что пользователь сам должен предоставить реализацию некоего интерфейса — то есть, углубиться в детали и интегрировать в движок свое решение специфической задачи. Это дает гибкость и возможности оптимизации, но снижает удобство. Хорошая система включает в себя и то, и другое. Дзен в балансе: нужно постоянно решать, в какой степени система должна быть абстрактной, и в какой — специализированной. Например, один из первых 3D-движков, с которыми я работал — Xtreme3D для Game Maker 6 — имел очевидный приоритет абстракции над специализацией: в нем были готовые декодеры разных форматов 3D-моделей, но полное отсутствие возможности создать свой собственный (то есть, собрать модель вручную из вершин и индексов). А вот встроенный 3D-рендер Game Maker, наоборот, не включал практически никаких абстракций и вынуждал программировать графику на низком уровне, составляя списки отрисовки из отдельных полигонов. В первом случае удобно, но не гибко, во втором — гибко, но неудобно. Учтя эти особенности, я решил делать движок, который охватывает оба сценария использования. Если вам нужно просто загрузить glTF, то это легко. А если вы хотите создать свой формат сцен, то это, пусть и с оговорками и требованиями, но реализуемо. Я всегда подчеркиваю, что Dagon — это фреймворк, то есть, каркас игрового приложения. Его ядро включает только создание окна и контекста OpenGL, управление таймером главного цикла и очередью событий. Рендер — это уже надстройка; в Dagon можно не использовать встроенный рендер и написать свой. То же касается абсолютного большинства всех подсистем — они не прибиты гвоздями. Принципиальной основой работы в Dagon является инверсия контроля, как и во всех фреймворках, но ядро минималистично — оно не отвечает за все процессы, и вы можете заменить почти все, что угодно, на самописные компоненты.

Разработка не стоит на месте, я постоянно что-то улучшаю и переписываю, чему, главным образом, и посвящен этот блог. Не хочется загадывать, но надеюсь, что в следующем году доделаю Dagon 2.0 — начинается новая большая страница в истории движка.

Retrolang — язык для PlayStation 1

PS1 — наверное, одна из самых культовых консолей всех времен. Количество часов моего детства, проведенных за ней, бессчетно. Я иногда даже вставал пораньше, чтобы часок поиграть перед школой) Классические части Need for Speed и Final Fantasy, Tekken и Soul Blade, Twisted Metal, Driver, TOCA, Crash Bandicoot, Spyro the Dragon, Medievil, Sim Theme Park — список моих любимых игр с первой «соньки» можно продолжать долго. В некоторые кроссплатформенные игры типа GTA, Quake 2, Myst я тоже впервые поиграл именно на PS. 3D-графика в то время сносила крышу и казалась совершеннейшей фантастикой — я ей увлекся как раз благодаря любимой консоли. А с появлением компьютера и Интернета я быстро освоил эмуляцию, стал качать образы игр, которые было не найти в магазинах, и записывать свои диски.

Надо ли говорить, что мне всегда хотелось научиться программировать под PS. Я редко пишу об этом, но этой темой я периодически занимаюсь с конца нулевых, и с тех пор скопил много информации, инструментов и исходников. Началось все с того, что я научился компилировать проги при помощи утекшего в Сеть старого официального SDK PsyQ, который использует C89. Именно на нем было создано большинство хитов. С ним работать довольно удобно, несмотря на то, что это древний софт, написанный еще в эпоху Windows 95. Но мне показалось, что как-то нехорошо зависеть от пиратского ПО, пусть и заброшенного. К тому же, он закрытый, его невозможно модифицировать. Поэтому я взялся писать собственную графическую библиотеку PSXLib. Дело пошло, я довольно глубоко погрузился в детали на уровне ассемблера, но мне ни один существующий ассемблер для консоли не понравился по ряду причин, о которых сейчас излишне рассказывать. Я решил сделать свой собственный, который назвал Retrolang, попутно изучив архитектуру MIPS-I и протокол загрузки PlayStation во всех деталях.

Недавно я взял этот проект и ради прикола скормил его Claude, чтобы он сгенерировал под это дело компилятор языка чуть ниже уровнем, чем C. Получилось лучше, чем я ожидал! Я добавил простейший препроцессор (#define, #include) плюс еще кое-какие расширения, и получилась вполне рабочая штука для низкоуровневых экспериментов. Недавно я допилил все это до состояния альфа-версии и выложил на GitHub:

https://github.com/gecko0307/retrolang

Язык во многом похож на C, но поддерживает не все его фичи. Используется стандартный для 32-битной архитектуры набор целочисленных типов (char/uchar, short/ushort, int/uint). 64-битных чисел нет, как нет и float’ов. Поддерживаются указатели, массивы, строковые литералы, структуры (в том числе вложенные). Вызовы функций соответствуют ABI O32, за исключением аргументов на стеке — они пока не поддерживаются, поэтому функции могут принимать только по 4 аргумента.

В данный момент можно выводить текст в последовательный порт (местный аналог TTY), определять нажатия кнопок геймпада и рисовать простейшую графику, причем все это получается более-менее читаемо. Например, вот как выглядит Hello, World:

void main()
{
    bios_a(0x13, "Hello, World!\n");
}

Вызовы BIOS являются интринсиками компилятора — это фича, которой не было ни в PsyQ, ни где-либо еще, такие вызовы раньше всегда делались через ассемблерные вставки.

Есть возможность встраивать файлы в бинарник — очень не хватало такого в C. Я для этого ввел специальный синтаксис атрибутов для глобальных переменных:

char* timFile @("assets/img.tim");

Кодогенератор интерпретирует это как обычный статический массив, данные которого нужно прочитать из внешнего файла. Таким образом можно легко запихнуть в игру ресурсы (если их совсем мало), вместо того, чтобы читать с диска в рантайме.

И, конечно, есть ассемблер, с которого проект и начался. Ассемблерные вставки добавляются в программу при помощи таких же атрибутов:

void asmFunc() @("asmFunc.s");

Код тела такой функции реализуется во внешнем ассемблерном листинге. При этом asmFunc — обычная функция с прологом и эпилогом.

Я работаю над стандартной библиотекой с собственным драйвером GPU, который отчасти основан на коде из PSXLib. Реализован как прямой рендеринг через GP0, так и непрямой через командные буферы и DMA. Есть начальная поддержка 3D-графики — трансформация вершин в экранное пространство и Z-упорядочивание. Правда, 3D там намного сложнее, чем 2D, и требует самых изощренных хаков, с которыми я сталкивался в своей жизни как графический разработчик. Это вам не в Unity кнопочки тыкать. Перед людьми, которые придумали, как рендерить 3D-модели на платформе с 1 мегабайтом видеопамяти и без Z-буфера, я снимаю шляпу (хотя имен их не знаю).

Трудно сказать, для чего все это сегодня нужно — эпоха 32-битных консолей давно в прошлом, и оценить подобные штуки могут лишь единичные любители ретрокомпьютинга и системного программирования. Это, конечно, в основном хобби для души. С другой стороны, MIPS отлично подходит в качестве учебной архитектуры — она довольно простая, можно на ее примере изучить работу процессора и то, как абстрактные логико-математические концепции отображаются на реальное железо. Опыт работы на таком низком уровне незаменим. Так что, надеюсь, мои наработки кому-нибудь пригодятся, хотя бы как референс.