Портирование Quake3

or0qlb1hvs2q5j4-xfsfv5uofys.png

В операционной системе 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().

Теперь при запуске приложения появляется заставка, ура!


_otepvhrhcyqp1aotjvgcylm4fs.png

Добавляем устройства ввода

Добавим поддержку клавиатуры и мыши в 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_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.

fkrxcjn_k-kheteoo4ak1tdwb2q.png

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

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()); /* Сюда передаём вертикальное смещение мыши   */
        }
    }
}

После этого появляется возможность управлять камерой и стрелять, ура! Фактически, этого уже достаточно для того, чтобы играть :)

q5pzlbtkbrinpcmgdggt4e5vfma.png


Оптимизация

Круто, конечно, что есть управление и какая-то графика, но такой 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 на платформе с аппаратным графическим ускорением, а пока останавливаюсь на том, что есть.

© Habrahabr.ru