Портирование Quake3
В операционной системе Embox (разработчиком которой я являюсь) какое-то время назад появилась поддержка OpenGL, но толковой проверки работоспособности не было, только отрисовка сцен с несколькими графическими примитивами.
Я никогда особо не интересовался геймдевом, хотя, само собой, игры мне нравятся, и решил — вот хороший способ развлечься, а заодно проверить OpenGL и посмотреть, как игры взаимодействуют с ОС.
В этой статье я расскажу о том, как собирал и запускал Quake3 на Embox.
Точнее, будем запускать не сам Quake3, а основанный на нём ioquake3, у которого тоже открытый исходный код. Для простоты будем называть ioquake3 просто квейком :)
Сразу оговорюсь, что в статье не анализируется сам исходный код Quake и его архитектура (про это можно почитать здесь, есть переводы на Хабре), а в этой статье речь пойдёт именно про то, как обеспечить запуск игры на новой операционной системе.
Приводимые в статье фрагменты кода упрощены для лучшего понимания: пропущены проверки на наличие ошибок, используется псевдокод и так далее. Оригинальные исходники можно найти в нашем репозитории.
Зависимости
Как ни странно, для сборки Quake3 нужно не так уж много библиотек. Нам потребуются:
- POSIX + LibC —
malloc()
/memcpy()
/printf()
и так далее - libcurl — работа с сетью
- Mesa3D — поддержка OpenGL
- SDL — поддержка устройств ввода и аудио
С первым пунктом и так всё понятно — без этих функций сложно обойтись при разработке на C, и использование этих вызовов вполне ожидаемо. Поэтому поддержка данных интерфейсов так или иначе есть практически во всех операционных системах, и в данном случае добавлять функционал практически не пришлось. Вот с остальными пришлось разбираться.
libcurl
Это было самое простое. Для сборки libcurl достаточно libc (конечно, часть фич будет недоступна, но они и не потребуется). Сконфигурить и собрать эту библиотеку статически очень просто.
Обычно и приложения, и библиотеки линкуются динамически, но т.к. в Embox основным режимом является линковка в один образ, будем линковать всё статически.
В зависимости от используемой системы сборки, конкретные шаги будут отличаться, но смысл примерно такой:
wget https://curl.haxx.se/download/curl-7.61.1.tar.gz
tar -xf curl-7.61.1.tar.gz
cd curl-7.61.1
./configure --enable-static --host=i386-unknown-none -disable-shared
make
ls ./lib/.libs/libcurl.a # Вот с этим и будем линковаться
Mesa/OpenGL
Mesa — это фреймворк с открытым исходным кодом для работы с графикой, поддерживается ряд интерфейсов (OpenCL, Vulkan и прочие), но в данном случае нас интересует именно OpenGL. Портирование такого большого фреймворка — тема отдельной статьи. Ограничусь лишь тем, что в ОС Embox Mesa3D уже есть :) Само собой, сюда подойдёт любая реализация OpenGL.
SDL
SDL — это кросс-платформенный фреймворк для работы с устройствами ввода, аудио и графикой.
Пока что забиваем всё, кроме графики, а для отрисовки кадров, напишем функции-заглушки, чтобы увидеть, когда они начнут вызываться.
Бэкэнды для работы с графикой задаются в SDL2-2.0.8/src/video/SDL_video.c
.
Выглядит это примерно так:
/* Available video drivers */
static VideoBootStrap *bootstrap[] = {
#if SDL_VIDEO_DRIVER_COCOA
&COCOA_bootstrap,
#endif
#if SDL_VIDEO_DRIVER_X11
&X11_bootstrap,
#endif
...
}
Чтобы не заморачиваться с «нормальной» поддержкой новой платформы, просто добавим свой VideoBootStrap
Для простоты можно взять что-нибудь за основу, например src/video/qnx/video.c
или src/video/raspberry/SDL_rpivideo.c
, но для начала сделаем реализацию вообще почти пустой:
/* SDL_sysvideo.h */
typedef struct VideoBootStrap
{
const char *name;
const char *desc;```
int (*available) (void);
SDL_VideoDevice *(*create) (int devindex);
} VideoBootStrap;
/* embox_video.c */
static SDL_VideoDevice *createDevice(int devindex)
{
SDL_VideoDevice *device;
device = (SDL_VideoDevice *)SDL_calloc(1, sizeof(SDL_VideoDevice));
if (device == NULL) {
return NULL;
}
return device;
}
static int available() {
return 1;
}
VideoBootStrap EMBOX_bootstrap = {
"embox", "EMBOX Screen",
available, createDevice
};
Добавляем свой VideoBootStrap
в массив:
/* Available video drivers */
static VideoBootStrap *bootstrap[] = {
&EMBOX_bootstrap,
#if SDL_VIDEO_DRIVER_COCOA
&COCOA_bootstrap,
#endif
#if SDL_VIDEO_DRIVER_X11
&X11_bootstrap,
#endif
...
}
В принципе, на этом этапе уже можно компилировать SDL. Как и с libcurl, детали компиляции будут зависеть от конкретной системы сборки, но так или иначе нужно сделать примерно следующее:
./configure --host=i386-unknown-none \
--enable-static \
--enable-audio=no \
--enable-video-directfb=no \
--enable-directfb-shared=no \
--enable-video-vulkan=no \
--enable-video-dummy=no \
--with-x=no
make
ls build/.libs/libSDL2.a # Этот файл нам и нужен
Собираем сам Quake
Quake3 предполагает использование динамических библиотек, но мы будем линковать его статически, как и всё остальное.
Для этого выставим некоторые переменные в Makefile
CROSS_COMPILING=1
USE_OPENAL=0
USE_OPENAL_DLOPEN=0
USE_RENDERER_DLOPEN=0
SHLIBLDFLAGS=-static
Первый запуск
Для простоты будем запускать на qemu/x86. Для этого нужно его поставить (здесь и далее будут команды для Debian, для других дистрибутивов пакеты могут называться по-другому).
sudo apt install qemu-system-i386
И сам запуск:
qemu-system-i386 -kernel build/base/bin/embox -m 1024 -vga std -serial stdio
Однако при запуске Quake сразу получаем ошибку
> quake3
EXCEPTION [0x6]: error = 00000000
EAX=00000001 EBX=00d56370 ECX=80200001 EDX=0781abfd
GS=00000010 FS=00000010 ES=00000010 DS=00000010
EDI=007b5740 ESI=007b5740 EBP=338968ec EIP=0081d370
CS=00000008 EFLAGS=00210202 ESP=37895d6d SS=53535353
Ошибка выводится не игрой, а операционной системой. Дебаг показал, что эта ошибка вызвана неполной поддержкой SIMD для x86 в QEMU: часть инструкций не поддерживается и генерирует исключение неизвестной команды (Invalid Opcode).
Происходит это не в самом Quake, а в OpenLibM (это библиотека, которую мы используем для реализации математических функций — sin()
, expf()
и тому подобных). Патчим OpenLibm, чтобы __test_sse()
не делала настоящую проверку на SSE, а просто считала, что поддержки нет.
Перечисленных выше шагов хватает на запуск, в консоли виден такой вывод:
> quake3
ioq3 1.36 linux-x86_64 Nov 1 2018
SSE instruction set not available
----- FS_Startup -----
We are looking in the current search path:
//.q3a/baseq3
./baseq3
----------------------
0 files in pk3 files
"pak0.pk3" is missing. Please copy it from your legitimate Q3 CDROM. Point Release files are missing. Please re-install the 1.32 point release. Also check that your ioq3 executable is in the correct place and that every file in the "baseq3
" directory is present and readable
ERROR: couldn't open crashlog.txt
Уже неплохо, Quake3 пытается запуститься и даже выводит сообщение об ошибке! Как видно, ему не хватает файлов в директории baseq3
. Там содержатся звуки, текстуры и всякое такое. Заметьте, pak0.pk3
должен быть взять с лицензионного CD-диска (да, открытый исходный код не подразумевает бесплатное использование).
Подготовка диска
sudo apt install qemu-utils
# Создаём qcow2-образ
qemu-img create -f qcow2 quake.img 1G
# Добавляем модуль nbd
sudo modprobe nbd max_part=63
# Форматируем qcow2-образ и пишем туда нужные файлы
sudo qemu-nbd -c /dev/nbd0 quake.img
sudo mkfs.ext4 /dev/nbd0
sudo mount /dev/nbd0 /mnt
cp -r path/to/q3/baseq3 /mnt
sync
sudo umount /mnt
sudo qemu-nbd -d /dev/nbd0
Теперь можно передавать блочное устройство в qemu
qemu-system-i386 -kernel build/base/bin/embox -m 1024 -vga std -serial stdio -hda quake.img
При старте системы замаунтим диск на /mnt
и запустим quake3 в этой директории, на этот раз падает позже
> mount -t ext4 /dev/hda1 /mnt
> cd /mnt
> quake3
ioq3 1.36 linux-x86_64 Nov 1 2018
SSE instruction set not available
----- FS_Startup -----
We are looking in the current search path:
//.q3a/baseq3
./baseq3
./baseq3/pak8.pk3 (9 files)
./baseq3/pak7.pk3 (4 files)
./baseq3/pak6.pk3 (64 files)
./baseq3/pak5.pk3 (7 files)
./baseq3/pak4.pk3 (272 files)
./baseq3/pak3.pk3 (4 files)
./baseq3/pak2.pk3 (148 files)
./baseq3/pak1.pk3 (26 files)
./baseq3/pak0.pk3 (3539 files)
----------------------
4073 files in pk3 files
execing default.cfg
couldn't exec q3config.cfg
couldn't exec autoexec.cfg
Hunk_Clear: reset the hunk ok
Com_RandomBytes: using weak randomization
----- Client Initialization -----
Couldn't read q3history.
----- Initializing Renderer ----
-------------------------------
QKEY building random string
Com_RandomBytes: using weak randomization
QKEY generated
----- Client Initialization Complete -----
----- R_Init -----
tty]EXCEPTION [0xe]: error = 00000000
EAX=00000000 EBX=00d2a2d4 ECX=00000000 EDX=111011e0
GS=00000010 FS=00000010 ES=00000010 DS=00000010
EDI=0366d158 ESI=111011e0 EBP=37869918 EIP=00000000
CS=00000008 EFLAGS=00010212 ESP=006ef6ca SS=111011e0
EXCEPTION [0xe]: error = 00000000
Эта опять ошибка с SIMD в Qemu. На этот раз инструкции используются в виртуальной машине Quake3 для x86. Проблема решилось заменой реализации для x86 на интерпретируемую ВМ (подробнее про виртуальную машину Quake3 и в принципе про архитектурные особенности можно почитать всё в той же статье). После этого начинают вызываться наши функции для SDL, но, само собой, ничего не происходит, т.к. эти функции пока что ничего не делают.
Добавляем поддержку графики
static SDL_VideoDevice *createDevice(int devindex) {
...
device->GL_GetProcAddress = glGetProcAddress;
device->GL_CreateContext = glCreateContext;
...
}
/* Здесь инициализируем OpenGL-контекст */
SDL_GLContext glCreateContext(_THIS, SDL_Window *window) {
OSMesaContext ctx;
/* Здесь делаем ОС-зависимую инициализацию -- мэпируем видеопамять и т.п. */
sdl_init_buffers();
/* Дальше инициализируем контекст Mesa */
ctx = OSMesaCreateContextExt(OSMESA_BGRA, 16, 0, 0, NULL);
OSMesaMakeCurrent(ctx, fb_base, GL_UNSIGNED_BYTE, fb_width, fb_height);
return ctx;
}
Второй хэндлер нужен для того, чтобы сказать SDL, какие функции вызывать при работе с OpenGL.
Для этого заводим массив и от запуска к запуску проверяем, каких вызовов не хватает, примерно так:
static struct {
char *proc;
void *fn;
} embox_sdl_tbl[] = {
{ "glClear", glClear },
{ "glClearColor", glClearColor },
{ "glColor4f", glColor4f },
{ "glColor4ubv", glColor4ubv },
{ 0 },
};
void *glGetProcAddress(_THIS, const char *proc) {
for (int i = 0; embox_sdl_tbl[i].proc != 0; i++) {
if (!strcmp(embox_sdl_tbl[i].proc, proc)) {
return embox_sdl_tbl[i].fn;
}
}
printf("embox/sdl: Failed to find %s\n", proc);
return 0;
}
За несколько перезапусков список становится достаточным полным, чтобы нарисовались заставка и меню. Благо, в Mesa есть все необходимые функции. Единственное — почему-то нет функции glGetString()
, вместо неё пришлось использовать _mesa_GetString()
.
Теперь при запуске приложения появляется заставка, ура!
Добавляем устройства ввода
Добавим поддержку клавиатуры и мыши в SDL.
Для работы с событиями нужно добавить хэндлер
static SDL_VideoDevice *createDevice(int devindex) {
...
device->PumpEvents = pumpEvents;
...
}
Начнём с клавиатуры. Вешаем функцию на прерывание нажатия/отпускания клавиши. Эта функция должна запоминать событие (в простейшем случае, просто пишем в локальную переменную, по желанию можно использовать очереди), для простоты будем хранить только последнее событие.
static struct input_event last_event;
static int sdl_indev_eventhnd(struct input_dev *indev) {
/* Пока есть новые события, переписываем ими last_event */
while (0 == input_dev_event(indev, &last_event)) { }
}
Затем в pumpEvents()
обрабатываем событие и передаём его в SDL:
static void pumpEvents(_THIS) {
SDL_Scancode scancode;
bool pressed;
scancode = scancode_from_event(&last_event);
pressed = is_press(last_event);
if (pressed) {
SDL_SendKeyboardKey(SDL_PRESSED, scancode);
} else {
SDL_SendKeyboardKey(SDL_RELEASED, scancode);
}
}
В SDL используется свой enum для кодов клавиш, поэтому придётся преобразовать код клавиши ОС в код SDL.
Список этих кодов определяется в файле SDL_scancode.h
Например, ASCII-код преобразовать можно вот так (здесь не все ASCII-символы, но этих вполне хватит):
static int key_to_sdl[] = {
[' '] = SDL_SCANCODE_SPACE,
['\r'] = SDL_SCANCODE_RETURN,
[27] = SDL_SCANCODE_ESCAPE,
['0'] = SDL_SCANCODE_0,
['1'] = SDL_SCANCODE_1,
...
['8'] = SDL_SCANCODE_8,
['9'] = SDL_SCANCODE_9,
['a'] = SDL_SCANCODE_A,
['b'] = SDL_SCANCODE_B,
['c'] = SDL_SCANCODE_C,
...
['x'] = SDL_SCANCODE_X,
['y'] = SDL_SCANCODE_Y,
['z'] = SDL_SCANCODE_Z,
};
На этом c клавиатурой всё, остальным будут заниматься SDL и сам Quake. Кстати, примерно тут выяснилось, что где-то в обработке нажатия клавиш quake использует инструкции, не поддерживаемые QEMU, приходится переключиться на интерпретируюмую виртуальную машину с виртуальной машины для x86, для этого добавляем BASE_CFLAGS += -DNO_VM_COMPILED
в Makefile.
После этого, наконец, можно торжественно «проскипать» заставки и даже запустить игру (закостылив некоторые error-ы :)). Приятно удивило то, что всё отрисовывается как надо, хоть и с очень низким fps.
Теперь можно приступить к поддержке мыши. Для прерываний мыши понадобится ещё один хэндлер, и обработку событий потребуется немного усложнить. Ограничимся только левой клавишей мыши. Понятно, что аналогичным образом можно добавить правую клавишу, колёсико и т.п.
static void pumpEvents(_THIS) {
if (from_keyboard(&last_event)) {
/* Здесь наш старый обработчик клавиатуры */
...
} else {
/* Здесь будем обрабатывать события мыши */
if (is_left_click(&last_event)) {
/* Зажата левая клавиша мыши */
SDL_SendMouseButton(0, 0, SDL_PRESSED, SDL_BUTTON_LEFT);
} else if (is_left_release(&last_event)) {
/* Отпущена левая клавиша мыши */
SDL_SendMouseButton(0, 0, SDL_RELEASED, SDL_BUTTON_LEFT);
} else {
/* Перемещение мыши */
SDL_SendMouseMotion(0, 0, 1,
mouse_diff_x(), /* Сюда передаём горизонтальное смещение мыши */
mouse_diff_y()); /* Сюда передаём вертикальное смещение мыши */
}
}
}
После этого появляется возможность управлять камерой и стрелять, ура! Фактически, этого уже достаточно для того, чтобы играть :)
Оптимизация
Круто, конечно, что есть управление и какая-то графика, но такой FPS совсем никуда не годится. Скорее всего, большая часть времени тратится на работу OpenGL (а он программный, и, более того, не используется SIMD), а реализация аппаратной поддержки — слишком долгая и сложная задача.
Попытаемся ускорить игру «малой кровью».
Оптимизация компилятора и снижение разрешения
Собираем игру, все библиотеки и саму ОС с -O3
(если, вдруг, кто-то очитал до этого места, но не знает, что это за флаг — подробнее про флаги оптимизации GCC можно почитать здесь).
Кроме того, используем минимальное разрешение — 320×240, чтобы облегчить работу процессору.
KVM
KVM (Kernel-based Virtual Machine) позволяет использовать аппаратную виртуализацию (Intel VT и AMD-V) для повышения производительности. Qemu поддерживает этот механизм, для его использования нужно сделать следующее.
Во-первых, нужно включить поддержку виртуализации в BIOS. У меня материнка Gigabyte B450M DS3H, и AMD-V включается через M.I. T. → Advanced Frequency Settings → Advanced CPU Core Settings → SVM Mode → Enabled (Gigabyte, что с тобой не так?).
Затем ставим нужный пакет и добавляем соответствующий модуль
sudo apt install qemu-kvm
sudo modprobe kvm-amd # Или kvm-intel
Всё, теперь можно передавать qemu флаг -enable-kvm
(или -no-kvm
, чтобы не использовать аппаратное ускорение).
Итог
Игра запустилась, графика отображается как нужно, управление работает. К сожалению, графика рисуется на CPU в один поток, ещё и без SIMD, из-за низкого fps (2–3 кадра в секунду) управлять очень неудобно.
Процесс портирования был интересным. Может быть, в будущем получится запустить quake на платформе с аппаратным графическим ускорением, а пока останавливаюсь на том, что есть.