[Перевод] Портирование движка Zelda Classic в веб

rraayag9qpzsmtoqljh_rssm8wa.png


Скриншот пользователя Mitchfork, победивший в соревнованиях 2021 Screenshot of the Year

Я портировал Zelda Classic (игровой движок, основанный на первой части Zelda) в веб. В него можно поиграть здесь, хватайте геймпад, если он у вас есть!

Это приложение PWA, так что можно его даже установить.


8owz-njdrdhso4xewid30kns7wi.png


Редактор квестов Zelda Classic под названием ZQuest

Zelda Classic

 — это игровой движок, изначально созданный для воссоздания и модифицирования первой Legend of Zelda. Этому движку уже более двадцати лет. Движок развивался и теперь поддерживает гораздо больше функций, чем оригинальная игра, на нём уже создано более шестисот игр. Сообщество называет их квестами.

Многие из них стали духовными потомками оригинала, иногда с улучшенной графикой, но всё равно узнаваемыми как игры в стиле Zelda. Все они имеют очень разную сложность, качество и длительность. Стоит сказать, что некоторые просто ужасны, так что будьте внимательны и смотрите на рейтинги.

Если вы фанат оригинальных двухмерных игр Zelda, то многие квесты Zelda Classic определённо стоят вашего внимания. Есть игры длительностью более двадцати часов, с большими мирами и увлекательными уникальными подземельями. Сегодня движок поддерживает скрипты, и многие разработчики воспользовались этим, чтобы расширить свои возможности: в некоторых квестах реализованы классы персонажей, онлайн-режимы или достижения; при этом всё это сделано на движке, который должен был воссоздавать оригинальную Zelda.

Однако самые новые версии Zelda Classic поддерживали только Windows… до недавнего времени!

Переходим в веб


Я потратил последние два месяца (около 150 часов) на портирование Zelda Classic, чтобы его можно было запускать в веб-браузере.

Квестов очень много, ниже представлена лишь небольшая выборка. Нажмите на любую из ссылок, чтобы перейти к квесту:

ruzt5nnhxi0fdpcwv54gq-fycr8.png


BS Zelda 1st Quest

qxgogokomg4nbby5dixjurkrcgk.png


Link’s Quest for the Hookshot 2

cxcjiemel-jkf6aplmqzd6cg32i.gif


Hero of Dreams

5zi17yyvmfbm2r0ija2o6ymplo4.png


Go Gollab: The Conflictions of Morality

welgjwfl3ll_afvmiva3si9i0hm.png


Legend of Link: The New Legacy

eytedoe-p-jaa-awkoqpdppvg_0.gif


Castle Haunt II

Надеюсь, мои труды позволят расширить аудиторию Zelda Classic. Это была сложная работа, далёкая от моей зоны комфорта в веб-разработке, и я многое узнал о WebAssembly, CMake и многопоточности. Параллельно работе я нашёл баги в нескольких проектах, приложил должные усилия к их устранению, и даже предложил внести изменения в спецификацию HTML.


В оставшейся части статьи я расскажу о техническом процессе портирования Zelda Classic в веб.

Если вас интересуют подробности, то я выложил онлайн свои ежедневные заметки. Я впервые вёл подобные заметки, и оказалось, что этот процесс существенно улучшил мою рабочую память и помог в написании этой статьи.


Работа


Emscripten


Emscripten — это компиляторный тулчейн для сборки C/C++ в WebAssembly. Он использует clang для преобразования получившегося байт-кода LLVM в Wasm. Недостаточно просто скомпилировать код в Wasm — Emscripten также предоставляет функции среды исполнения Unix, реализуя их при помощи JavaScript/Web API (например: реализации большинства системных вызовов; файловая система в памяти или с поддержкой IndexedDB; поддержка pthreads при помощи Web Workers). Так как многие проекты C/C++ собираются при помощи Make и CMake, Emscripten также предоставляет инструментарий для взаимодействия с этими инструментами: emmake и emcmake. Чаще всего, если программа на C/C++ портируема, её можно собрать с помощью Emscripten и запустить в браузере, однако, скорее всего, придётся внести изменения для соответствия основному циклу браузера.

Если вы разрабатываете Wasm-приложение, то вам не обойтись без расширения Chrome DevTools DWARF. Информацию о его использовании см. в этой статье. Оно работает превосходно. Чтобы получить наилучшие результаты, возможно, придётся отказаться от всех оптимизаций. Но даже без прохода оптимизации я сталкивался со случаями, когда некоторые кадры трассировки стека вызовов были очевидно неверными, поэтому иногда приходилось использовать отладку с printf.


Приступаем к работе


Zelda Classic написан на C++ и использует Allegro — низкоуровневую кросс-платформенную библиотеку для управления окнами, отрисовки экрана, воспроизведения звуков и т. п. На самом деле, движок использует Allegro 4, выпущенный примерно в 2007 году. Allegro 4 нельзя напрямую компилировать с помощью Emscripten, однако Allegro 5 можно. Эти две версии сильно отличаются, но, к счастью, существует библиотека-адаптер под названием Allegro Legacy, позволяющая собрать приложение Allegro 4 с помощью Allegro 5.

Вот и первое препятствие — Zelda Classic нужно портировать на Allegro 5, и его CMakeLists.txt нужно изменить так, чтобы собирать allegro из исходников.

Allegro 5 может поддерживать сборку при помощи Emscripten, поскольку может использовать в качестве бэкенда SDL, которую Emscripten хорошо поддерживает.


Прежде чем приступать к работе, мне нужно было восполнить свои пробелы в знаниях о CMake и Allegro.

Изучаем CMake, Allegro и Emscripten


Заявляется, что Allegro поддерживает Emscripten, но я хотел убедиться в этом самостоятельно. К счастью, у библиотеки есть инструкции о том, как выполнять сборку с Emscripten. Мои первые PR были отправлены разработчикам Allegro, чтобы они улучшили эту документацию.

Я потратил несколько часов из-за различий между bash и zsh.


Потом я нашёл интересный пример программы, демонстрирующей замену палитр — она кодирует битовую карту как индексы произвольного набора цветов, которые можно менять во время выполнения. Но после сборки Emscripten она не заработала. Чтобы попрактиковаться с Allegro, я поработал над усовершенствованием этого примера.

Фрагментный шейдер:

uniform sampler2D al_tex;
uniform vec3 pal[256];
varying vec4 varying_color;
varying vec2 varying_texcoord;
void main()
{
  vec4 c = texture2D(al_tex, varying_texcoord);
  int index = int(c.r * 255.0);
  if (index != 0) {
    gl_FragColor = vec4(pal[index], 1);
  }
  else {
    gl_FragColor = vec4(0, 0, 0, 0);
  };
}


Allegro передаёт шейдеру текстуру битовой карты как al_tex, и в этой программе битовая карта — это просто серия чисел в диапазоне 0–255. В качестве входящих данных к шейдеру подключается палитра цветов pal, и в среде выполнения программа заменяет палитру, меняя цвета, которые рендерит шейдер. Здесь есть две ошибки, из-за которых шейдер не работает в WebGL:

  1. Отсутствует объявление точности. В WebGL это не является опциональным. Исправить это очень просто — достаточно добавить precision mediump float;
  2. Для индексирования массива используется непостоянное выражение. WebGL не поддерживает этого, поэтому нужно полностью изменить структуру шейдера. Эта проблема была более сложной, поэтому я просто укажу ссылку на PR


Получившаяся программа выложена здесь.

Оказалось, что знания о замене палитр в Allegro 5 не пригодятся при обновлении Allegro для Zelda Classic, хотя изначально я думал, что они нужны. Тем не менее, это позволило мне познакомиться с библиотекой.


Далее я захотел написать простой CMakeLists.txt, в котором мог бы разобраться, он должен был собирать Allegro из исходников и поддерживать сборку при помощи Emscripten.

Emscripten поддерживает сборку проектов, сконфигурированных при помощи CMake через emcmake — небольшой программы, конфигурирующей тулчейн Emscripten CMake. По сути, команда emcmake cmake конфигурирует сборку так, чтобы она использовала в качестве компилятора emcc.


Я потратил много времени на чтение разных туториалов по CMake, изучение реальных CMakeLists.txt и их построчный разбор. Документация CMake очень пригодилась при этом процессе. В конечном итоге, я получил следующее:

https://github.com/connorjclark/allegro-project/blob/main/CMakeLists.txt

cmake_minimum_required(VERSION 3.5)
project (AllegroProject)
include(FetchContent)

FetchContent_Declare(
  allegro5
  GIT_REPOSITORY https://github.com/liballeg/allegro5.git
  GIT_TAG        5.2.7.0
)
FetchContent_GetProperties(allegro5)
if(NOT allegro5_POPULATED)
  FetchContent_Populate(allegro5)
	if (MSVC)
		set(SHARED ON)
	else()
		set(SHARED OFF)
	endif()
	set(WANT_TESTS OFF)
	set(WANT_EXAMPLES OFF)
	set(WANT_DEMO OFF)
  add_subdirectory(${allegro5_SOURCE_DIR} ${allegro5_BINARY_DIR} EXCLUDE_FROM_ALL)
endif()

add_executable(al_example src/main.c)
target_include_directories(al_example PUBLIC ${allegro5_SOURCE_DIR}/include)
target_include_directories(al_example PUBLIC ${allegro5_BINARY_DIR}/include)
target_link_libraries(al_example LINK_PUBLIC allegro allegro_main allegro_font allegro_primitives)

# Эти файлы include обычно копируются в нужные места целевой платформой
# установки allegro, но мы делаем это вручную.
file(COPY ${allegro5_SOURCE_DIR}/addons/font/allegro5/allegro_font.h
	DESTINATION ${allegro5_SOURCE_DIR}/include/allegro5
)
file(COPY ${allegro5_SOURCE_DIR}/addons/primitives/allegro5/allegro_primitives.h
	DESTINATION ${allegro5_SOURCE_DIR}/include/allegro5
)


Код мог быть проще, но CMakeLists.txt Allegro требует некоторых модификаций, чтобы его можно было использовать как зависимость.


Изначально я попробовал использовать ExternalProject CMake вместо FetchContent, но это было проблематично с Emscripten, поскольку внутри него используется cmake, и, похоже, он не знает о тулчейне, предоставляемом emcmake. Не знаю, почему не удалось заставить его работать, но мне известно, что FetchContent новее и с ним мне повезло больше.

Allegro Legacy


Allegro 4 и 5 можно считать совершенно разными библиотеками:

  • Был переписан практически каждый API, и не один в один
  • A4 использует для событий опросы, а A5 — очереди/циклы событий
  • A4 поддерживает только программный рендеринг и напрямую поддерживает палитры (что активно используется в ZC); A5 поддерживает шейдеры/рендеринг с GPU-ускорением (однако в нём нет манипуляций с палитрами)
  • И, что самое важное, только A5 можно скомпилировать с помощью Emscripten (это очень легко благодаря поддержке SDL)


Для замены вызовов API A4 на API A5, по сути, нужно переписывать код, а учитывая размер Zelda Classic, этот вариант отпадал. К счастью, здесь на помощь пришла Allegro Legacy.

Для поддержки многоплатформенности Allegro абстрагирует всё связанное с операционной системой в «системный драйвер». Он существует для каждой поддерживаемой платформы, реализующей низкроуровневые операции наподобие доступа к файловой системе, управления окнами и т. п. Allegro Legacy наводит мосты между A4 и A5, создавая системный драйвер, использующий A5 для реализации системных интерфейсов A4. Иными словами, Allegro Legacy — это просто A4, использующая A5 в качестве драйвера. Все файлы в src — это просто A4 (с небольшими модификациями), за исключением папки a5, которая обеспечивает реализацию A5.

Вот полная архитектура запуска Zelda Classic в браузере:

ASCII diagram of Zelda Classic running on the web


Я устранил или обошёл баги в каждом слое этой структуры.

Воспользовавшись новообретёнными знаниями о CMake для настройки CMakeLists.txt Zelda Classic, я собрал Allegro 5 и Allegro Legacy из исходников. Для использования Allegro Legacy практически ничего не пришлось делать. Поначалу я боролся с ошибкой компоновщика «unresolved symbol» для функции, которая, как я был уверен, была включена в компиляцию, но это оказалось простым недосмотром в файле заголовка. Я не специалист в C/C++, поэтому для отладки понадобилась куча времени!

После того, как всё скомпоновалось и компиляция завершилась успешно, Allegro Legacy просто работала, хоть мне и пришлось устранить несколько мелких багов, связанных с вводом мышью и путями к файлам.

Я отправил PR для апгрейда до Allegro 5 в репозиторий Zelda Classic, но, скорее всего, его не объединят в ветку до будущего основного релиза.


Начинаем собирать Zelda Classic с помощью Emscripten


Хотя Zelda Classic теперь работал с A5 и собирал её из исходников, для музыки по-прежнему использовалось несколько уже собранных библиотек. Я не хотел пока разбираться с этим, поэтому перекрыл слой музыки функциями-заглушками, чтобы всё остальное продолжало компоноваться Emscripten.

zcmusic_fake.cpp

#include 
#include "zcmusic.h"

int32_t zcmusic_bufsz = 64;

bool zcmusic_init(int32_t flags) { return false; }
bool zcmusic_poll(int32_t flags) { return false; }
void zcmusic_exit() {}

ZCMUSIC const *zcmusic_load_file(char *filename) { return NULL; }
ZCMUSIC const *zcmusic_load_file_ex(char *filename) { return NULL; }
bool zcmusic_play(ZCMUSIC *zcm, int32_t vol) { return false; }
bool zcmusic_pause(ZCMUSIC *zcm, int32_t pause) { return false; }
bool zcmusic_stop(ZCMUSIC *zcm) { return false; }
void zcmusic_unload_file(ZCMUSIC *&zcm) {}
int32_t zcmusic_get_tracks(ZCMUSIC *zcm) { return 0; }
int32_t zcmusic_change_track(ZCMUSIC *zcm, int32_t tracknum) { return 0; }
int32_t zcmusic_get_curpos(ZCMUSIC *zcm) { return 0; }
void zcmusic_set_curpos(ZCMUSIC *zcm, int32_t value) {}
void zcmusic_set_speed(ZCMUSIC *zcm, int32_t value) {}


Zelda Classic считывает с диска различные файлы конфигураций, в том числе файлы данных, содержащие большие элементы, например, MIDI. Emscripten может упаковывать такие данные вместе со средами Wasm с помощью флага --preload-data. Эти файлы могут быть довольно большими (zc.data занимает примерно 9 МБ), поэтому лучше всего использовать стратегию долговременного кэширования: --use-preload-cache — это удобная функция Emscripten, кэширующая этот файл в IndexedDB. Однако используемый ею ключ уникален для каждой сборки, поэтому любое развёртывание делает кэш недействительным для всех пользователей. Это плохо, но существует простой хак, позволяющий использовать хэшированный контент:

# См. https://github.com/emscripten-core/emscripten/issues/11952
HASH=$(shasum -a 256 module.data | awk '{print $1}')
sed -i -e "s/\"package_uuid\": \"[^\"]*\"/\"package_uuid\":\"$HASH\"/" module.data.js
if ! grep -q "$HASH" module.data.js
then
  echo "failed to replace data hash"
  exit 1
fi


Кроме того, я отправил PR в Emscripten, чтобы исправить приведённую выше проблему.


Да будут потоки


Сразу после того, как у меня удалось собрать Zelda Classic с помощью Emscripten и запустить его в браузере, я столкнулся со страницей, которая не делает ничего, кроме того, что приводит к зависанию основного потока. Включив паузу в DevTools, я увидел проблему:

static BITMAP * a5_display_init(int w, int h, int vw, int vh, int color_depth)
{
    BITMAP * bp;
    ALLEGRO_STATE old_state;
    int pixel_format;

    _a5_new_display_flags = al_get_new_display_flags();
    _a5_new_bitmap_flags = al_get_new_bitmap_flags();
    al_identity_transform(&_a5_transform);
    bp = create_bitmap(w, h);
    if(bp)
    {
      if(!_a5_disable_threaded_display)
      {
        _a5_display_creation_done = 0;
        _a5_display_width = w;
        _a5_display_height = h;
        _a5_screen_thread = al_create_thread(_a5_display_thread, NULL);
        al_start_thread(_a5_screen_thread);
        while(!_a5_display_creation_done); // <<<<<<<<<<<<<<<<<< Зависание происходит здесь!
      }
      else
      {
        if(!_a5_setup_screen(w, h))
        {
          return NULL;
        }
      }
      gfx_driver->w = bp->w;
      gfx_driver->h = bp->h;
      return bp;
    }
    return NULL;
}


Этот паттерн зависающего цикла while проблематичен, поскольку он впустую тратит циклы ЦП. Однако на самом деле это вполне нормально, поскольку ожидается, что код инициализации должен завершиться быстро. В общем случае предпочтительна условная переменная, чтобы позволить потоку «уснуть», пока не начнёт меняться важное для него состояние.


Emscripten может собирать многопоточные приложения, работающие в вебе, при этом он использует Web Workers и SharedArrayBuffer, но по умолчанию он выполняет сборку без поддержки потоков, поэтому всё происходит в основном потоке.

Чтобы глубоко изучить потоки в Wasm, прочитайте эту статью.


SharedArrayBuffer требует задания особых заголовков ответов, даже для localhost. Проще всего это сделать при помощи stattik Пола Айриша: достаточно выполнить npx statikk --port 8000 --coi


В приведённом выше случае создаётся поток, который должен мгновенно задавать _a5_display_creation_done, однако из-за отсутствия потоков этого не происходит, поэтому основной поток зависает навечно.

Очевидно было, что мне нужно включить поддержку pthread.

Также я понял, что лучше будет включить и PROXY_TO_PTHREAD, перемещающий основной поток приложения в pthread, он же web worker (вместо основного потока браузера), однако это решение оказалось тупиковым из-за различных неожиданных проблем с SDL, что означает отсутствие поддержки этого параметра.

Я почти заставил PROXY_TO_PTHREAD работать, но этого было недостаточно.


Вместо этого мне пришлось добавить rest(0) во многие места, где Zelda Classic ожидает основной поток приложения; в противном случае функция ASYNCIFY Emscripten не имела бы возможности получить основной поток в браузере, что привело бы к зависанию страницы. Например, этот код проблематично выполнять в основном потоке:

do
{
}
while(gui_mouse_b());


потому что ввод мышью может регистрироваться только когда управление находится у основного потока браузера. rest (0) устраняет зависание, возвращая управление браузеру при помощи ASYNCIFY:

do
{
  // ASYNCIFY сохранит стек, передаст его браузеру
  // (обрабатывающему пользовательский ввод или рендеринг), а затем
  // восстановит стек и продолжит работу.
  rest(0);
}
while(gui_mouse_b());


О мьютексах и deadlock


Самой сложной проблемой, с которой я столкнулся при выполнении проекта, стала отладка deadlock. Несколько дней работы закончились ничем, я разбирался по логам, когда блокировка получалась/освобождалась и каким потоком (огромная трата времени!).

Со временем я осознал, что мне нужно перестать отлаживать запутанный код программы и попытаться воспроизвести проблему с нуля.

SDL предоставляет интерфейс для мьютексов, который в Unix использует pthread. Очевидно, некоторые платформы не поддерживают рекурсивных мьютексов, таким образом позволяя потоку блокировать один и тот же мьютекс несколько раз и освобождая блокировку только тогда, когда она соответствует равному количеству разблокировок. Для поддержки платформ без этой функциональности SDL имитирует её.

SDL_sysmutex.c

/* Блокируем мьютекс */
int
SDL_LockMutex(SDL_mutex * mutex)
{
#if FAKE_RECURSIVE_MUTEX
    pthread_t this_thread;
#endif

    if (mutex == NULL) {
        return SDL_InvalidParamError("mutex");
    }

#if FAKE_RECURSIVE_MUTEX
    this_thread = pthread_self();
    if (mutex->owner == this_thread) {
        ++mutex->recursive;
    } else {
        /* Порядок операций важен.
           Мы задаём id блокирующего потока после получения блокировки,
           поэтому разблокировка из других потоков будет невозможна.
         */
        if (pthread_mutex_lock(&mutex->id) == 0) {
            mutex->owner = this_thread;
            mutex->recursive = 0;
        } else {
            return SDL_SetError("pthread_mutex_lock() failed");
        }
    }
#else
    if (pthread_mutex_lock(&mutex->id) != 0) {
        return SDL_SetError("pthread_mutex_lock() failed");
    }
#endif
    return 0;
}


После того, как я понял, что deadlock не происходит, когда не используются условные переменные, мне удалось создать небольшое воспроизведение, приводившее к deadlock при работе с Emscripten, но не при сборке для Mac. Я сообщил о баге разработчикам SDL, и даже предложил патч для улучшения кода имитации рекурсивного мьютекса, (по крайней мере, устраняющий мой deadlock), но оказалось, что смешение условных переменных и рекурсивных мьютексов — это очень плохая идея, и в общем случае её невозможно реализовать верно.

Постепенно я осознал странность того, что Emscripten не поддерживает рекурсивные мьютексы. После создания короткого примера программы я определил, что на самом деле он их поддерживает. Оказалось, что проблема заключалась в том, что конфигурация заголовка SDL для Emscripten не указывает, что рекурсивные мьютексы поддерживаются.

Делаем систему полнофункциональной


Воспроизведение MIDI через Timidity


Файлы .qst Zelda Classic содержат MIDI, но браузеры не могут напрямую воспроизводить файлы MIDI. Чтобы синтезировать звук из файла MIDI, нужны:

  • база данных сэмплов звуков
  • код для интерпретации различных команд MIDI, например, включения или отключения нот


Emscripten поддерживает различные форматы аудио при помощи SDL_mixer, сконфигурированного через SDL2_MIXER_FORMATS. Однако поддержка MIDI там отсутствует. К счастью, SDL_mixer поддерживает воспроизведение MIDI (оно использует Timidity). Настроить систему портов Emscripten, позволявшую при запросах включать поддержку Timidity, было очень легко.

В качестве звуковых сэмплов я взял бесплатные сэмплы под названием freepats. Изначально я добавил их в файл данных предварительной загрузки Wasm, но они довольно большие (больше 30 МБ), поэтому лучше загружать отдельные сэмплы по сети по мере их запроса. Я знал, что существует форк Timidity, выполняющий именно эту задачу, поэтому изучал его работу. При загрузке файла MIDI этот форк проверяет все инструменты, которые используются в композиции, и фиксирует в логе, какие из них отсутствуют. Затем код на JS проверяет этот лог, запрашивает отсутствующие сэмплы, и перезагружает данные. По сути, я сделал то же самое, но внутри Timidity/EM_JS.

Эти скачивания сэмплов тормозят игру (но не основной поток браузера!), пока не будут завершены полностью, что не очень мешает, когда начинаешь квест, но может быть очень раздражающим, когда добираешься до новой области, где воспроизводится композиция с новыми инструментами MIDI. Чтобы ситуация стала более терпимой, я написал функцию fetchWithProgress, отображающую полосу прогресса в заголовке страницы.

Хотя библиотека freepats очень приятна (она свободна, мала по размерам и имеет хорошее качество), в ней отсутствуют многие инструменты. Чтобы заполнить пробелы, я нашёл звуковые файлы GUS из 90-х на сайте, посвящённом моддингу DOOM. На этой странице есть комментарий о том, что PPL160 обладают ещё более высоким качеством, поэтому я нашёл и их тоже. Я не очень доволен результатом объединения всех этих разношёрстных инструментов. Уверен, ситуацию можно улучшить, но, по крайней мере, ни в каких файлах MIDI нет отсутствующих инструментов.


Музыка работает, но нет звуковых эффектов?


Zelda Classic использует разные каналы вывода для музыки и SFX, что довольно стандартно для игр. Особенно потому, что иногда нужно сэмплировать их с разными частотами, то есть для этого нельзя использовать одинаковый канал вывода. Музыка обычно сэмплируется с более высокой частотой из соображений качества, что требует больше времени на обработку, но это нормально, потому что задержка буфера — не такая уж большая проблема, если вы только не синхронизируете музыку с видео или не выполняете какую-то другую подобную задачу. SFX обычно сэмплируются с меньшей частотой, потому что звуковой эффект нужно воспроизвести как можно быстрее для реакции на геймплей.

Благодаря поддержке MIDI на главном экране теперь играет музыка, но SFX не воспроизводятся. Я скомпилировал пример работы со звуком Allegro ex_saw, который, как я знал, уже работал с Emscripten, потому что пример на Wasm работал. Однако при локальной сборке ничего не воспроизводилось, то есть я нашёл ещё один баг в Allegro, который нужно устранить.

Я добавил несколько printf в SDL_SetError и заметил, что когда Allegro вызывала SDL_Init(SDL_INIT_EVERYTHING), он выдавал ошибку "SDL not built with haptic support", после чего SDL начинала всё ломать! SDL не удавалось настроить подсистему тактильной обратной связи, потому что она не предоставляет для неё Emscripten-реализацию. А поскольку Allegro инициализировала SDL, запрашивая всё, SDL сопротивлялась. Это не объясняет того, почему всё работало раньше, но не сегодня; я выполнил git blame для функции SDL_Init и увидел, что недавно было внесено изменение, закрывающее всё при любых ошибках подсистем. Загадка разгадана, я отправил PR разработчикам Allegro, чтобы они это исправили.

oiodzthnuc8u1itdgda_cjbiowa.png


Web имеет Vibration API (для вибрации мобильного устройства) и экспериментальную поддержку тактильной обратной связи геймпада, то есть SDL определённо может их поддерживать.


Теперь при локальной сборке пример ex_saw работал, но SFX всё равно не воспроизводились в Zelda Classic. Потратив время на отладку с помощью printf, я заметил, что SDL не удаётся открыть второй аудиоканал для SFX. Странно… Я открыл реализацию аудио SDL для Emscripten, и моё внимание привлекла переменная OnlyHasDefaultOutputDevice:

static SDL_bool
EMSCRIPTENAUDIO_Init(SDL_AudioDriverImpl * impl)
{
    SDL_bool available, capture_available;

    /* Задаются указатели функций */
    impl->OpenDevice = EMSCRIPTENAUDIO_OpenDevice;
    impl->CloseDevice = EMSCRIPTENAUDIO_CloseDevice;

    impl->OnlyHasDefaultOutputDevice = SDL_TRUE;
    // ...


Я подумал «ну не может же это сработать», присвоил ей значение SDL_FALSE и… сработало! Я сообщил об этом баге здесь. Неочевидно, что это является правильным способом устранения проблемы, поэтому какое-то время это не будет ресолвиться в SDL. Что приводит нас к следующей теме…

Хакинг скриптов сборки


При исправлении бага в зависимости обычно нужно подождать, прежде чем можно будет нормально использовать новую версию этой зависимости. Это не проблема, поскольку есть и другие способы использования неофициальной версии зависимости:

  • Менеджеры пакетов можно настроить так, чтобы они указывали на конкретный форк или коммит
  • Можно самому поставлять зависимость, сделав её частью системы контроля версий своего проекта
  • Можно поддерживать набор diff-патчей, применяющихся поверх официального релиза


Есть и путь для ленивых: сначала я разобрался с возможностями команд sed. я буду находить баг в зависимости, разбираться, как использовать sed, чтобы починить его локально, вставлять его в свой скрипт сборки, и оставлять заметку, что нужно через какое-то время отправить исправление бага в апстрим.

# Временные решения, пока различные проблемы не будут решены в апстриме.

if [ ! -d "$EMCC_CACHE_DIR/ports/sdl2" ]
then
  # Проверяем, что исходный код SDL был скачан.
  embuilder build sdl2
fi
# Нужно вручную удалить библиотеку SDL, чтобы заставить Emscripten пересобрать её.
rm -rf "$EMCC_CACHE_LIB_DIR"/libSDL2.a "$EMCC_CACHE_LIB_DIR"/libSDL2-mt.a

# См. https://github.com/libsdl-org/SDL/pull/5496
if ! grep -q SDL_THREAD_PTHREAD_RECURSIVE_MUTEX "$EMCC_CACHE_DIR/ports/sdl2/SDL-release-2.0.20/include/SDL_config_emscripten.h"; then
  echo "#define SDL_THREAD_PTHREAD_RECURSIVE_MUTEX 1" >> "$EMCC_CACHE_DIR/ports/sdl2/SDL-release-2.0.20/include/SDL_config_emscripten.h"
fi

# SDL emscripten audio указывает только одно устройство вывода звука по умолчанию, но оказывается,
# что это можно игнорировать и всё работает. Без этого будут воспроизводиться только SFX, 
# а MIDI будут выдавать ошибку при открытии идентификатора аудиоустройства.
# См. https://github.com/libsdl-org/SDL/issues/5485
sed -i -e 's/impl->OnlyHasDefaultOutputDevice = 1/impl->OnlyHasDefaultOutputDevice = 0/' "$EMCC_CACHE_DIR/ports/sdl2/SDL-release-2.0.20/src/audio/emscripten/SDL_emscriptenaudio.c"


И это только изменения в SDL. Для Allegro нужно было проделать ещё больше работы…

Благодаря тому, что поначалу я не усложнял, развитие продолжалось, но когда изменения стали больше, чем просто модификация одной-двух строк, этот процесс стал неудобным. В результате я создал простую систему: довольно простое использование git diff и patch. Раздражало, что для использования патчей портов Emscripten требовалось очищать кэш, но всё было не так уж плохо. Вот как всё выглядит в целом:

#!/bin/bash

# Очень простая система патчинга. Поддерживает только по одному патчу на папку.
# Для обновления патча:
#   1) cd в папку
#   2) вносим изменения
#   3) git add .
#   4) git diff --staged | pbcopy
#   5) перезаписываем старый файл патча новым

set -e

SCRIPT_DIR=` cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd `
EMCC_DIR="$(dirname $(which emcc))"
EMCC_CACHE_DIR="$EMCC_DIR/cache"

NO_GIT_CLEAN=false
GIT_CLEAN=true

# патч папки
function apply_patch {
  cd "$1"
  echo "Applying patch: $2"

  if [ -d .git ]; then
    git restore --staged .
    # Разумно проводить очистку, только если нет генерируемых во время сборки файлов
    # (например, allegro создаёт конфигурацию заголовков на основании окружения).
    if $3 ; then
      git clean -fdq
    fi
    git checkout -- .
  else
    git init > /dev/null
    git add .
    git commit -m init
  fi

  patch -s -p1 < "$2"
  cd - > /dev/null
}

echo "Applying patches ..."

apply_patch "$EMCC_DIR" "$SCRIPT_DIR/emscripten.patch" $GIT_CLEAN

# Проверяем, что исходный код SDL скачан, 
# иначе патчи не удастся применить.
if [ ! -d "$EMCC_CACHE_DIR/ports/sdl2" ]
then
  embuilder build sdl2
fi
if [ ! -d "$EMCC_CACHE_DIR/ports/sdl2_mixer/SDL_mixer-release-2.0.4" ]
then
  rm -rf "$EMCC_CACHE_DIR/ports/sdl2_mixer"
  embuilder build sdl2_mixer
fi

# Вручную удаляем библиотеки из кэша Emscripten, чтобы произошла пересборка.
rm -rf "$EMCC_CACHE_LIB_DIR"/libSDL2-mt.a
rm -rf "$EMCC_CACHE_LIB_DIR"/libSDL2_mixer_gme_mid-mod-mp3-ogg.a

apply_patch "$EMCC_CACHE_DIR/ports/sdl2/SDL-4b8d69a41687e5f6f4b05f7fd9804dd9fcac0347" "$SCRIPT_DIR/sdl2.patch" $GIT_CLEAN
apply_patch "$EMCC_CACHE_DIR/ports/sdl2_mixer/SDL_mixer-release-2.0.4" "$SCRIPT_DIR/sdl2_mixer.patch" $GIT_CLEAN
apply_patch _deps/allegro5-src "$SCRIPT_DIR/allegro5.patch" $NO_GIT_CLEAN

echo "Done applying patches!"


Совершенствуем систему


Список квестов


До этого момента играбельной была лишь оригинальная Zelda. Теперь, когда заработал звук, я хотел иметь возможность играть в самодельные квесты. Для своей предыдущей работы над Quest Maker я скачал более шестисот квестов и их метаданные с PureZC.com. Каждый квест — это единый файл .qst, и мне нужно было найти способ передавать Zelda Classic их данные. Добавить их в --preload-data нельзя, потому что в сумме они занимают около 2 ГБ! Нет, каждый файл должен загружаться только по запросу.

Quest Maker был моей попыткой переделать Zelda Classic. Со временем я понял, что для воссоздания двадцатилетнего игрового движка потребуется ещё двадцать лет, поэтому сдался.


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

// Эта функция вызывается в начале настройки main().
EM_ASYNC_JS(void, em_init_fs_, (), {
  // Инициализируем файловую систему файлами по 0 байт для каждого квеста.
  const quests = await ZC.fetch("https://hoten.cc/quest-maker/play/quest-manifest.json");
  FS.mkdir('/_quests');

  function writeFakeFile(path, url) {
    FS.writeFile(path, '');
    window.ZC.pathToUrl[path] = 'https://hoten.cc/quest-maker/play/' + url;
  }

  for (let i = 0; i < quests.length; i++) {
    const quest = quests[i];
    if (!quest.urls.length) continue;

    const url = quest.urls[0];
    const path = window.ZC.createPathFromUrl(url);
    writeFakeFile(path, url);
  }
});


duwqkplsqlxzknqctkkvmopmkxc.png


Внутриигровое окно выбора файла. Квесты хранятся в отдельных папках вида: _quests/1/OcarinaOfPower.qst, то есть вам нужно знать, где находится нужный квест и несколько щелчков мышью, чтобы перейти к нему.

Перед тем, как Zelda Classic откроет файл, вызывается em_fetch_file_, а данные запрашиваются и записываются в файловую систему.

EM_ASYNC_JS(void, em_fetch_file_, (const char *path), {
  try {
    path = UTF8ToString(path);
    if (FS.stat(path).size) return;

    const url = window.ZC.pathToUrl[path];
    if (!url) return;

    const data = await ZC.fetch(url);
    FS.writeFile(path, data);
  } catch (e) {
    // Запрос выполнить не удалось (возможно, пользователь офлайн) или путь не существует.
    console.error(`error loading ${path}`, e);
  }
});


Также есть несколько квестов с внешними музыкальными файлами (mp3, ogg). Их тоже можно добавить в эту «ленивую» файловую систему:

for (const extraResourceUrl of quest.extraResources || []) {
  writeFakeFile(window.ZC.createPathFromUrl(extraResourceUrl), extraResourceUrl);
}


Однако это диалоговое окно выбора файла очень неудобное. Давайте воспользуемся одной из сверхспособностей веба: URL. Я создал папку «Quest List», в которой есть ссылка Play!:

https://hoten.cc/zc/play/? quest=731/GoGollab_1_FunnyEdition.qst

а в Zelda Classic я получал этот параметр запроса и хаком на главном экране выполнял одно из следующих действий: 1) запускал новый файл сохранения с квестом, или 2) загружал старый файл сохранения этого квеста. Это сильно упрощает переход к нужному квесту Zelda Classic.

В редакторе Zelda Classic есть функция тестирования, позволяющая редактору квестов переходить в игру на текущем редактируемом экране. Нативно это делается при помощи аргументов командной строки, а для веба у нас есть наш друг — URL. Нажмите на этот URL, и окажетесь в конце игры!

https://hoten.cc/zc/play/? quest=bs3.1/NewBS+3.1±+1st+Quest.qst&dmap=9&screen=58

Также можно получить глубокую ссылку, чтобы открыть конкретный экран в редакторе:

https://hoten.cc/zc/create/? quest=bs3.1/NewBS+3.1±+1st+Quest.qst&map=0&screen=55

MP3, OGG и ретро-музыка


Помните, я создавал имитацию zcmusic, просто чтобы всё собиралось и использовалась готовая библиотека звуков? Позже я понял, что SDL_mixer поддерживает OGG и MP3, поэтому можно легко будет реализовать zcmusic при помощи SDL_mixer. SDL_mixer и Emscripten знают, как синтезировать эти форматы аудио, поэтому мне не нужно разбираться, как самому компилировать эти звуковые библиотеки.

Стоит сказать, что у Zelda Classic есть два отдельных пути исполнения кода для музыки: один для MIDI, о котором мы уже говорили, второй для "zcmusic", который является просто обёрткой поверх различных звуковых библиотек для поддержки OGG, MP3 и различных форматов ретро-игр:

  • gbs (GameBoy Sound)
  • nsf (NES Sound Format)
  • spc (SNES Sound)
  • vgm (Video Game Music — сборник для различных игровых систем)


Итак, Emscripten + SDL_mixer обрабатывают всё, кроме этих ретро-форматов. Для них Zelda Classic использует библиотеку Game Music Emulator (GME). К счастью, я нашёл форк SDL_mixer под названием SDL_mixer X, интегрирующий GME в SDL. Было очень легко взять его и слить изменения в порт, который использует Emscriten. Также мне нужно было добавить GME в систему портов Emscripten, что оказалось довольно просто.

Я отправил разработчикам SDL_mixer PR для добавления GME. Если его сольют, то я добавлю в Emscripten опцию gme. Но пока меня вполне устраивает мой процесс с патчингом.


Для zcmusic мне достаточно оказалось реализовать небольшую поверхность API, использующую непосредственно SDL_mixer. В нативной версии библиотеки применяются библиотеки обработки звука конкретных форматов, поэтому теперь всё гораздо проще — SDL_mixer обрабатывает всю логику, относящуюся к форматам.

Постоянные данные


По умолчанию все записываемые в файловую систему Emscripten данные хранятся только в памяти и теряются при обновлении страницы. Emscripten предоставляет простой интерфейс для монтирования папки, поддерживаемой IndexedDB, что решает проблему хранения, но по-прежнему остаётся множество других проблем:

  1. У игроков Zelda Classic есть файлы сохранений, которые они могут захотеть перенести в браузер
  2. Игроки захотят иметь доступ к этим файлам (или для создания резервных копий, или чтобы ими делиться), но браузеры не открывают IndexedDB пользователям, не обладающим техническими знаниями
  3. Браузеры избегают очистки данных в IndexedDB при вызове navigator.storage.persist(), однако утеря таких данных, как файлы сохранений (и особенно файл .qst автора квеста), станет катастрофой, и я не хочу надеяться на то, что всё, находящееся внутри браузера, будет храниться там постоянно


Избежать всех этих проблем поможет использование реальной файловой системы. К счастью, за последний год на этом фронте произошёл большой прогресс: Filesystem Access API предоставляет возможность пользователю передать папку странице, даже позволяя странице выполнять в неё запись. При помощи Given window.showDirectoryPicker() браузер открывает диалоговое окно выбора папки, а выбор пользователя задаётся как FileSystemDirectoryHandle.

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

© Habrahabr.ru