
В операционной системе 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). upd: Как подсказал в комментах WGH, на самом деле проблема была в том, что я забыл явно включить поддержку SSE в cr0/cr4, так что с QEMU всё в порядке.
Происходит это не в самом 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. upd: Как подсказал в комментах WGH, на самом деле проблема была в том, что я забыл явно включить поддержку SSE в cr0/cr4, так что с 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 на платформе с аппаратным графическим ускорением, а пока останавливаюсь на том, что есть.
