[Перевод] Портируем Quake 3 на Rust
Команда поклонников Rust сайта Immutant усердно трудилась над C2Rust, фреймворком для миграции программ, который существенно упрощает их перевод на Rust. Мы стремились везде, где можем, добиться автоматической оптимизации безопасности получаемого кода Rust. В иных случаях делали этот процесс простым для самостоятельной реализации программистами в дальнейшем. Однако для начала потребовалось создать надежное средство переноса, которое позволит людям свободно использовать Rust.
Тестирование на небольших программах командной строки постепенно устаревает, поэтому для переноса мы выбрали ничто иное, как Quake 3. Стоит ли говорить, что буквально через пару дней мы уже были первыми, кто играл в Rust-версию этого популярного шутера.
Подготовка: исходники Quake 3
Посмотрев оригинальный код Quake 3 и различные форки, мы остановились на ioquake3. Это форк коммьюнити, который до сих пор обслуживается и строится на современных платформах.
Для начала мы убедились, что сможем воссоздать проект как есть:
$ make release
Сборка ioquake3 создает несколько библиотек и исполняемых файлов:
$ tree --prune -I missionpack -P "*.so|*x86_64"
.
└── build
└── debug-linux-x86_64
├── baseq3
│ ├── cgamex86_64.so # клиент
│ ├── qagamex86_64.so # игровой сервер
│ └── uix86_64.so # ui
├── ioq3ded.x86_64 # исполняемый файл выделенного сервера
├── ioquake3.x86_64 # основной исполняемый файл
├── renderer_opengl1_x86_64.so # модуль рендеринга opengl1
└── renderer_opengl2_x86_64.so # модуль рендеринга opengl2
Клиентскую, серверную и UI библиотеки можно собрать в виде Quake VM либо как нативные общие библиотеки
x86
. Мы предпочли второй вариант. Переносить VM на Rust и использовать версии QVM было бы существенно проще, но задачей было протестировать C2Rust максимально тщательно.Сосредоточились мы на UI, игровом сервере, клиенте, модуле рендеринга OpenGL1 и основном исполняемом файле. Можно было также перевести модуль OpenGL2, но мы решили его не трогать, так как он активно использует файлы шейдеров .glsl
, которые система сборки включает в исходники Си в виде строковых литералов. Конечно, можно было добавить поддержку скрипта кастомной сборки для встраивания кода GLSL в строки Rust после транспиляции, но нас остановило отсутствие надежного автоматического способа транспилировать эти автогенерируемые временные файлы.
Вместо этого мы перевели библиотеку рендеринга OpenGL1 и определили ее использование игрой в качестве предустановленного отрисовщика. Мы также решили пропустить файлы выделенного сервера и missionpack
. Перевести их было несложно, но для нашей демонстрации они не требовались.
Транспиляция
Чтобы сохранить используемую в Quake 3 структуру каталогов и не прибегать к изменению ее исходного кода, нужно было создать в точности такие же двоичные файлы, что и в нативной сборке, то есть четыре общие библиотеки и один двоичный файл. Поскольку C2Rust использует для сборки файлов Cargo, каждому исполняемому файлу требуется собственный контейнер Rust с соответствующим файлом
Cargo.toml
. Чтобы C2Rust на выходе создал для каждого исполняемого файла контейнер, ему нужно предоставить список двоичных файлов вместе с соответствующими им объектными или исходными файлами, а также вызов компоновщика, определяющего прочие детали, такие как зависимости.Тем не менее мы вскоре столкнулись с ограничением, относящимся к тому, как C2Rust перехватывает внутренний процесс сборки: C2Rust получает на входе файл базы данных компиляции, который содержит список команд компиляции, выполняемых при сборке. Однако эта база данных содержит только команды компиляции, но не вызовы компоновщика.
В большинстве инструментов, создающих такую базу данных, подобное ограничение присутствует преднамеренно, например cmake
с CMAKE_EXPORT_COMPILE_COMMANDS
, bear
и compiledb
. Насколько нам известно, единственным инструментом, включающим команды компоновки, является build-logger из CodeChecker
, который мы не задействовали только потому, что узнали о нем после написания собственных оберток (приводятся ниже). Это означало невозможность использовать файл compile_commands.json
, создаваемый любым типовым инструментом для транспиляции мультибинарной программы Си.
Тогда мы решили написать собственные скрипты оберток, которые внесут в базу данных все необходимые вызовы компилятора и компоновщика, а затем преобразуют их в расширенный compile_commands.json
. Вместо обычной сборки, запускаемой командой:
$ make release
Мы добавили обертки для перехвата процесса сборки:
$ make release CC=/path/to/C2Rust/scripts/cc-wrappers/cc
Обертки создают каталог с файлами JSON, по одному файлу за вызов. Второй скрипт агрегирует все эти файлы в новый
compile_commands.json
, который содержит уже и команды компиляции, и команды сборки. Мы расширили C2Rust для считывания компонующих команд из базы данных и создания отдельного контейнера для каждого связанного двоичного файла. Кроме того, теперь C2Rust считывает зависимости каждого исполняемого файла и автоматически добавляет их в файл build.rs
его контейнера. В качестве облегчения процесса все исполняемые файлы можно собрать за раз, если они будут находиться в одном рабочем пространстве. C2Rust производит высокоуровневый файл рабочего пространства 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 не мог корректно обработать или не обрабатывал совсем.
Указатели на массивы
В нескольких местах оригинальный код содержит выражения, указывающие на элемент, следующий за последним элементом массива. Вот упрощенный пример кода Си:
int array[1024];
int *p;
// ...
if (p >= &array[1024]) {
// error...
}
Стандарт Си (загляните, например, в C11, Section 6.5.6) допускает указатели на элемент, выходящий за границу массива. Проблема же в том, что Rust это запрещает даже при получении только адреса элемента. Примеры этого шаблона мы нашли в функции
AAS_TraceClientBBox
.Компилятор Rust также отметил похожий, но уже действительно ошибочный пример в G_TryPushingEntity
, где условие представлено как >
, а не >=
. После этого условия выходящий за границы указатель разыменовывался, что являлось реальной ошибкой безопасности памяти.
Чтобы избежать повторения подобной проблемы в дальнейшем, мы скорректировали транспилятор C2Rust на вычисление адреса элемента с помощью арифметики указателей, а не операции индексирования по массиву. После данного исправления код, использующий этот шаблон «адрес элемента за границей массива» будет корректно выполнять перевод и выполняться без дополнительных модификаций.
Члены динамических массивов
На первый проверочный запуск игры 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]; // переменный размер
} winding_t;
Поле
p
здесь — это более ранняя несовместимая с C99 версия «члена массива переменной длины», которая до сих пор принимается gcc
. C2Rust распознает членов динамических массивов с синтаксисом С99 (vec3_t p[]
) и реализует простую эвристику для попутного обнаружения более ранних версий этого шаблона (массивов с размером 0 и 1 в конце структур; в исходниках ioquake3 мы нашли несколько таких).Панику удалось устранить, изменив вышеприведенную структуру на синтаксис C99:
typedef struct
{
int numpoints;
vec3_t p[]; // переменный размер
} winding_t;
Реализация автоматического исправления этого шаблона для общих случаев (массивы с размером не 0 или 1) оказалась бы слишком сложной, поскольку пришлось бы различать членов стандартных массивов от членов массивов с произвольными размерами. Вместо этого мы рекомендуем корректировать исходный код Си вручную — как сделали сами в случае с ioquake3.
Связанные операнды во встроенном ассемблере
Еще одним источником сбоев был встроенный в Си код ассемблера из системного заголовка
/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
: связанные входные/выходные операнды разного размера.Выходной операнд "=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. Для этого мы реализовали новый контейнер c2rust-asm-casts
, корректирующий проблему через систему типов Rust с помощью типажа (trait) и вспомогательных функций, которые автоматически расширяют и усекают значения связанных операндов до внутреннего размера, достаточного для хранения обоих операндов.
Вышеприведенный код корректно транспилируется в следующее:
let mut __d0: c_int = 0;
let mut __d1: c_int = 0;
// Ссылка на выходное значение первого операнда
let fresh5 = &mut __d0;
// Внутреннее хранилище для первого связанного операнда
let fresh6;
// Ссылка на выходное значение второго операнда
let fresh7 = &mut __d1;
// Внутреннее хранилище для второго операнда
let fresh8;
// Входное значение первого операнда
let fresh9 = (::std::mem::size_of::() as c_ulong).wrapping_div(::std::mem::size_of::<__fd_mask>() as c_ulong);
// Входное значение второго операнда
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),
// Приведение входных операндов к внутреннему типу хранилища
// с дополнительным нулевым или знаковым расширением
"0" (AsmCast::cast_in(fresh5, fresh9)),
"1" (AsmCast::cast_in(fresh7, fresh10))
: "memory"
: "volatile");
// Приведение операндов к внешнему типу (с выведением типов) и усечение
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 пока что решили ее вручную с помощью небольшого патча. Этот патч заменяет 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"
Иии…
Перед нами рабочая Rust-версия Quake3!
Вот видео, в котором мы транспилируем игру, загружаем ее и недолго тестируем игровой процесс:
Транспилированный исходный код лежит в ветке
transpiled
нашего репозитория. Там также есть ветка refactored
, содержащая те же файлы, но уже с примененными командами рефакторинга. Инструкции по транспиляции
Если вы захотите повторить аналогичный процесс переноса и запуска Quake 3, то имейте в виду, что вам понадобятся либо оригинальные ресурсы игры, либо скачанные из интернета демоверсии таких ресурсов. Помимо этого, потребуется установить C2Rust (минимальная необходимая ночная версия Rust на момент написания — это
nightly-2019-12-05
, но мы рекомендуем заглянуть в репозиторий C2Rust или на сайт crates.io на предмет наличия последней): $ cargo +nightly-2019-12-05 install c2rust
а также копии репозиториев C2Rust и ioquake3:
$ git clone git@github.com:immunant/c2rust.git
$ git clone git@github.com:immunant/ioq3.git
В качестве альтернативы установке
c2rust
вышеприведенной командой вы можете собрать C2Rust вручную с помощью cargo build --release
. В обоих случаях вам все равно потребуется репозиторий C2Rust, так как там находятся скрипты оберток компилятора, необходимые для транспиляции ioquake3. Мы предоставляем скрипт, который автоматически транспилирует код Си и применяет патч ssemask
. Чтобы его использовать, выполните из верхнего уровня репозитория ioq3
следующую команду:
$ ./transpile.sh
Эта команда должна создать подкаталог
quake3-rs
, содержащий код Rust, где можно будет последовательно выполнять cargo build --release
и остальные ранее описанные шаги.