Как стать автором
Обновить

Перенос Quake 3 на Rust

Время на прочтение9 мин
Количество просмотров40K
Автор оригинала: Andrei Homescu, Stephen Crane, Miguel Saldivar

Наша команда Immunant любит Rust и активно работает над C2Rust — фреймворком миграции, берущим на себя всю рутину миграции на Rust. Мы стремимся автоматически вносить в преобразованный код на Rust улучшения безопасности и помогать программисту делать это самому, когда не справляется фреймворк. Однако в первую очередь нам нужно создать надёжный транслятор, позволяющий пользователям приступить к работе с Rust. Тестирование на мелких CLI-программах потихоньку устаревает, поэтому мы решили перенести на Rust игру Quake 3. Спустя пару дней мы, скорее всего, стали первыми, кому удалось сыграть в Quake3 на Rust!

Подготовка: исходники Quake 3


Изучив исходный код оригинального Quake 3 и различных форков, мы остановились на ioquake3. Это созданный сообществом форк Quake 3, который до сих пор поддерживается и собирается на современных платформах.

В качестве отправной точки мы решили убедиться, что сможем собрать проект в его исходном виде:

$ make release

При сборке ioquake3 создаётся несколько библиотек и исполняемых файлов:

$ tree --prune -I missionpack -P "*.so|*x86_64"
.
└── build
    └── debug-linux-x86_64
        ├── baseq3
        │   ├── cgamex86_64.so          # client
        │   ├── qagamex86_64.so         # game server
        │   └── uix86_64.so             # ui
        ├── ioq3ded.x86_64              # dedicated server binary
        ├── ioquake3.x86_64             # main binary
        ├── renderer_opengl1_x86_64.so  # opengl1 renderer
        └── renderer_opengl2_x86_64.so  # opengl2 renderer

Среди этих библиотек, библиотеки UI, клиента и сервера можно собрать или как сборку Quake VM, или как нативные библиотеки общего пользования X86. В своём проекте мы решили использовать нативные версии. Транслирование VM на Rust и использование QVM-версий были бы значительно проще, но мы хотели тщательно протестировать C2Rust.

В своём проекте переноса мы сосредоточились на UI, игре, клиенте, OpenGL1-рендерере и основном исполняемом файле. Можно было бы транслировать и OpenGL2-рендерер, но мы решили пропустить это, потому что в нём используется значительный объём файлов шейдеров .glsl, которые система сборки встраивает как строковые литералы в исходном коде на C. После выполнения транспиляции мы добавим поддержку скриптов сборки для встраивания кода GLSL в строки Rust, но пока нет хорошего автоматизированного способа транспиляции этих автоматически сгенерированных временных файлов. Поэтому вместо этого мы просто транслировали библиотеку OpenGL1-рендерера и принудительно заставили игру использовать его вместо рендерера по умолчанию. Кроме того, мы решили пропустить выделенный сервер и упакованные файлы миссий, потому что их несложно будет перенести и они необязательны для нашей демонстрации.

Транспилируем Quake 3


Чтобы сохранить структуру каталогов, используемую в Quake 3, и не изменять исходный код, нам нужно было получить точно такие же двоичные файлы, как и в нативной сборке, то есть четыре библиотеки общего пользования и один исполняемый файл.

Так как C2Rust создаёт файлы сборки Cargo, каждый двоичный файл требует собственного Rust crate с соответствующим файлом Cargo.toml.

Чтобы C2Rust создавал по одному crate на каждый выходной двоичный файл, ему также понадобится список двоичных файлов с соответствующим объектом или исходными файлами, а также вызов компоновщика, используемый для создания каждого двоичного файла (применяется для определения других подробностей, например, библиотечных зависимостей).

Однако мы быстро столкнулись с одним ограничением, вызванным тем, как C2Rust перехватывает нативный процесс сборки: C2Rust получает на входе файл базы данных компиляции, который содержит список команд компиляции, выполняемых во время сборки. Однако эта база данных содержит только команды компиляции без вызовов компоновщика. Большинство инструментов, создающих эту базу данных, имеют это намеренное ограничение, например, cmake с CMAKE_EXPORT_COMPILE_COMMANDS, bear и compiledb. По нашему опыту, единственным инструментом, включающим команды компоновки, является build-logger, созданный CodeChecker, который мы не использовали, потому что узнали о нём только после написания собственных обёрток (о них рассказывается ниже). Это означало, что для транспиляции программы на C с несколькими двоичными файлами мы не могли использовать файл compile_commands.json, созданный любым из распространённых инструментов.

Поэтому мы написали собственные скрипты обёрток компилятора и компоновщика, которые дампят все вызовы компилятора и компоновщика в базу данных, а затем преобразуют его в расширенный compile_commands.json. Вместо обычной сборки, использующей команду наподобие:

$ make release

мы добавили обёртки для перехвата сборки при помощи:

$ make release CC=/path/to/C2Rust/scripts/cc-wrappers/cc

Обёртки создают каталог из нескольких файлов JSON, по одному на вызов. Второй скрипт собирает всех их в один новый файл compile_commands.json, который содержит команды и компиляции, и компоновки. Затем мы расширили C2Rust, чтобы он считывал команды компоновки из базы данных и создавал отдельный crate на каждый скомпонованный двоичный файл. Кроме того, C2Rust теперь ещё и считывает библиотечные зависимости для каждого двоичного файла и автоматически добавляет их в файл build.rs соответствующего crate.

Для повышения удобства все двоичные файлы можно собрать за раз, расположив их внутри workspace. C2Rust создаёт файл workspace Cargo.toml верхнего уровня, поэтому мы можем собрать проект единственной командой cargo build в каталоге quake3-rs:

$ tree -L 1
.
├── Cargo.lock
├── Cargo.toml
├── cgamex86_64
├── ioquake3
├── qagamex86_64
├── renderer_opengl1_x86_64
├── rust-toolchain
└── uix86_64

$ cargo build --release

Устраняем шероховатости


Когда мы впервые попробовали собрать транслированный код, то столкнулись с парой проблем с исходниками Quake 3: возникли граничные случаи, которые C2Rust не мог обработать (ни корректно, ни вообще хоть как-нибудь).

Указатели на массивы


В нескольких местах оригинального исходного кода содержатся выражения, указывающие на следующий после последнего элемент массива. Вот упрощённый пример кода на C:

int array[1024];
int *p;

// ...

if (p >= &array[1024]) {
   // error...
}

Стандарт C (см., например, C11, Section 6.5.6) позволяет указателям на элемент выходить за конец массива. Однако Rust запрещает это, даже если мы только берём адрес элемента. Мы нашли примеры такого паттерна в функции AAS_TraceClientBBox.

Компилятор Rust также сигнализировал о подобном, но на самом деле содержащем ошибку примере в G_TryPushingEntity, где условная инструкция имеет вид >, а не >=. Вышедший за границы указатель затем разыменуется после условной конструкции, что является багом безопасности памяти.

Чтобы избежать этой проблемы в будущем, мы исправили транспилятор C2Rust так, чтобы он использовал арифметику указателей для вычисления адреса элемента массива, а не применял операцию индексирования массива. Благодаря этому исправлению код, использующий подобный паттерн «адрес элемента за концом массива», теперь корректно транслируется и выполняется без модификаций.

Элементы массивов переменной длины


Мы запустили игру, чтобы всё протестировать, и сразу же получили panic от Rust:

thread 'main' panicked at 'index out of bounds: the len is 4 but the index is 4', quake3-client/src/cm_polylib.rs:973:17

Взглянув на cm_polylib.c, мы заметили, что он разыменовывает поле p в следующей структуре:

typedef struct
{
	int		numpoints;
	vec3_t	p[4];		// variable sized
} winding_t;

Поле p в структуре — это неподдерживаемая до стандарта C99 версия элемента массива переменной длины (flexible array member), который тем не менее принимается gcc. C2Rust распознаёт элементы массивов переменной длины с синтаксисом C99 (vec3_t p[]) и реализует простую эвристику, чтобы также определить версии этого паттерна до C99 (массивы размером 0 и 1 в конце структур; также мы нашли несколько таких примеров в исходном коде ioquake3).

Изменение представленной выше структуры на синтаксис C99 устранило panic:

typedef struct
{
	int		numpoints;
	vec3_t	p[];		// variable sized
} winding_t;

Попытка автоматического исправления этого паттерна в общем случае (при размерах массивов, отличающихся от 0 и 1) будет чрезвычайно сложной, потому что нам придётся различать обычные массивы и элементы массивов переменной длины произвольных размеров. Поэтому вместо этого мы рекомендуем исправлять оригинальный код на C вручную, как мы и сделали с ioquake3.

Tied Operands во встроенном ассемблерном коде


Ещё одним источником сбоев был этот встроенный в C ассемблерный код из системного заголовка /usr/include/bits/select.h:

# define __FD_ZERO(fdsp)                                            \
  do {                                                              \
    int __d0, __d1;                                                 \
    __asm__ __volatile__ ("cld; rep; " __FD_ZERO_STOS               \
                          : "=c" (__d0), "=D" (__d1)                \
                          : "a" (0), "0" (sizeof (fd_set)           \
                                          / sizeof (__fd_mask)),    \
                            "1" (&__FDS_BITS (fdsp)[0])             \
                          : "memory");                              \
  } while (0)

определяющий внутреннюю версию макроса __FD_ZERO. Это определение вызывает редкий граничный случай встроенного ассемблерного кода gcc: tied operands ввода-вывода с разными размерами. Оператор вывода "=D" (__d1) привязывает регистр edi к переменной __d1 как 32-битное значение, а "1" (&__FDS_BITS (fdsp)[0]) привязывает тот же регистр к адресу fdsp->fds_bits как 64-битный указатель. gcc и clang устраняют это несовпадение. используя 64-битный регистр rdi и усекая его значение перед присвоением значения __d1, а Rust по умолчанию использует семантику LLVM, в которой такой случай остаётся неопределённым. В отладочных сборках (не в релизных, которые вели себя хорошо) мы увидели, что оба операнда можно присвоить регистру edi, из-за чего указатель усекается до 32 бит перед встроенным ассемблерным кодом, что вызывает сбои.

Так как rustc передаёт встроенный ассемблерный код Rust в LLVM с очень небольшими изменениями, мы решили исправить этот конкретный случай в C2Rust. Мы реализовали новый crate c2rust-asm-casts, устраняющий данную проблему благодаря системе типов Rust, использующей trait и вспомогательные функции, автоматически расширяющие и усекающие значения tied operands до внутреннего размера, который достаточно велик, чтобы хранить оба операнда. Представленный выше код корректно транспилируется в следующее:

let mut __d0: c_int = 0;
let mut __d1: c_int = 0;
// Reference to the output value of the first operand
let fresh5 = &mut __d0;
// The internal storage for the first tied operand
let fresh6;
// Reference to the output value of the second operand
let fresh7 = &mut __d1;
// The internal storage for the second tied operand
let fresh8;
// Input value of the first operand
let fresh9 = (::std::mem::size_of::<fd_set>() as c_ulong).wrapping_div(::std::mem::size_of::<__fd_mask>() as c_ulong);
// Input value of the second operand
let fresh10 = &mut *fdset.__fds_bits.as_mut_ptr().offset(0) as *mut __fd_mask;
asm!("cld; rep; stosq"
     : "={cx}" (fresh6), "={di}" (fresh8)
     : "{ax}" (0),
       // Cast the input operands into the internal storage type
       // with optional zero- or sign-extension
       "0" (AsmCast::cast_in(fresh5, fresh9)),
       "1" (AsmCast::cast_in(fresh7, fresh10))
     : "memory"
     : "volatile");
// Cast the operands out (types are inferred) with truncation
AsmCast::cast_out(fresh5, fresh9, fresh6);
AsmCast::cast_out(fresh7, fresh10, fresh8);

Стоит заметить, что этот код не требует никаких типов для входных и выходных значений в конструкции ассемблерного кода, при разрешении конфликтов типов полагаясь вместо них на вывод типов Rust (в основном типов fresh6 и fresh8).

Выровненные глобальные переменные


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

static unsigned char ssemask[16] __attribute__((aligned(16))) =
{
	"\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x00\x00\x00\x00"
};

В настоящее время Rust поддерживает атрибут выравнивания для структурных типов, но не для глобальных переменных, т.е. элементов static. Мы рассматривали способы решения этой проблемы в общем случае или в Rust, или в C2Rust, но в ioquake3 пока решили устранить её вручную коротким файлом patch. Этот файл patch заменяет эквивалент Rust ssemask следующим:

#[repr(C, align(16))]
struct SseMask([u8; 16]);
static mut ssemask: SseMask = SseMask([
    255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0,
]);

Запуск quake3-rs


При запуске cargo build --release создаются двоичные файлы, но они создаются под target/release со структурой каталогов, который двоичный файл ioquake3 не распознаёт. Мы написали скрипт, создающий символьные ссылки в текущем каталоге для воссоздания правильной структуры каталогов (в том числе ссылки на файлы .pk3, содержащие ресурсы игры):

$ /path/to/make_quake3_rs_links.sh /path/to/quake3-rs/target/release /path/to/paks

Путь /path/to/paks должен указывать на каталог, содержащий файлы .pk3.

Теперь давайте запустим игру! Нам нужно передать +set vm_game 0 и т.п., поэтому мы загружаем эти модули как библиотеки общего пользования Rust, а не как сборку QVM, а также cl_renderer для использования OpenGL1-рендерера.

$ ./ioquake3 +set sv_pure 0 +set vm_game 0 +set vm_cgame 0 +set vm_ui 0 +set cl_renderer "opengl1"

И…


Мы запустили Quake3 на Rust!


Вот видео того, как мы транспилируем Quake 3, загружаем игру и немного в неё играем:


Можете изучить транспилированные исходники в ветке transpiled нашего репозитория. Также там есть ветка refactored, содержащая те же исходники с несколькими предварительно применёнными командами рефакторинга.

Как выполнить транспиляцию


Если вы хотите попробовать транспилировать Quake 3 и запустить его самостоятельно, то учтите, что вам потребуются собственные игровые ресурсы Quake 3 или ресурсы демо из Интернета. Также нужно будет установить C2Rust (на момент написания требуемая nightly-версия — это nightly-2019-12-05, но мы рекомендуем заглянуть в репозиторий C2Rust или в crates.io, чтобы найти самую новую версию):

$ cargo +nightly-2019-12-05 install c2rust

и копии наших репозиториев C2Rust и ioquake3:

$ git clone <a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="dcbbb5a89cbbb5a8b4a9bef2bfb3b1">[email protected]</a>:immunant/c2rust.git
$ git clone <a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="dcbbb5a89cbbb5a8b4a9bef2bfb3b1">[email protected]</a>:immunant/ioq3.git

В качестве альтернативы установке c2rust с помощью приведённой выше команды можно собрать C2Rust вручную при помощи cargo build --release. В любом случае репозиторий C2Rust всё равно понадобится, потому что он содержит скрипты обёрток компилятора, требуемые для транспиляции ioquake3.

Мы выложили скрипт, автоматически транспилирующий код на C и применяющий патч ssemask. Чтобы воспользоваться им, запустите следующую команду из верхнего уровня репозитория ioq3:

$ ./transpile.sh </path/to/C2Rust repository> </path/to/c2rust binary>

Эта команда должна создать подкаталог quake3-rs, содержащий код на Rust, для которого можно затем выполнить команду cargo build --release и оставшиеся шаги, описанные выше.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 98: ↑96 и ↓2+130
Комментарии78

Публикации

Истории

Работа

Ближайшие события

2 – 18 декабря
Yandex DataLens Festival 2024
МоскваОнлайн
11 – 13 декабря
Международная конференция по AI/ML «AI Journey»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань