Этот пост является частью серии под названием Обратный инжиниринг игры Gameboy Advance. Читать введение здесь. Читать предыдущий пост здесь.

Подпишитесь на меня в Twitter, чтобы получить больше компьютерных развлечений🐦

Мы глубоко погрузились в реверс-инжиниринг и лишь немного коснулись JS в последних парах глав… но этот пост будет совершенно другим: давайте пойдем по склону веб-разработки, поэтому применим все, что мы обнаружили, к нашему редактору уровней.

Далее я укажу наиболее важные решения в программировании klo-gba.js, обосновывая, почему так должно было быть, что я получил и чему научился. Я не хочу здесь никаких «не знаю, я просто знаю, что так было».
В отличие от других постов, в которых я рассказывал историю в хронологическом порядке, я не буду здесь то же самое. В каждом разделе я опишу дизайнерское решение, от самого большого до самого маленького.

Отказ от ответственности: klo-gba.js — это личный проект, то есть разработанный в свободное время и как чистое хобби! Так что на некоторые решения и обоснования влияет хобби!

Веб-приложение?

С самого начала я говорил о разработке веб-приложения… Но вы, наверное, спросите, почему я говорю о вебе? Как правило, инструменты реверс-инжиниринга — это настольные приложения. Даже No$GBA — настольное приложение. Редакторы уровней для игр Pokémon GBA также являются настольными приложениями. Самые популярные эмуляторы Switch и 3DS — десктопные приложения. Даже эта простая программа для проверки целостности файлов Switch является десктопным приложением. Почему мы делаем что-то другое?

Основная причина, по которой я делаю веб-приложение, очень проста: я люблю Интернет. Как сказал Габриэль Гимарайнш, один из технических менеджеров стартапа-единорога Brex, «Интернет — это инструмент доставки кода»: пользователю нужно только получить доступ к URL-адресу, и он может немедленно воспользоваться услугой. Это открытая среда, в которой каждый может сосуществовать и применять свои технологии.
Было бы пустой тратой времени делать настольное приложение, когда мы можем предоставить все через простой URL-адрес. Таким образом, мы не заставляем пользователя загружать программное обеспечение только для того, чтобы немного поиграть с нашим редактором карт. Это даже позволяет большему количеству пользователей перебирать альфа-версии редактора.

Последние достижения делают все более возможной разработку настоящих веб-приложений — например, было бы очень сложно, если бы мы сделали наше приложение 10 лет назад. WebGL и библиотеки, такие как React, многое облегчают и делают все это возможным.

JS?

Хорошо. Давайте сделаем веб-приложение… но… писать на JS? В 2019 году появилось так много лучших вещей… Такие языки, как TypeScript, ClojureScript, ReasonML…
Что ж, в этом отношении я должен признать: JS был ужасным выбором, и я сожалею об этом.

Я выбрал разработку на JS только потому, что уже привык к этому, и библиотеки, которые я намеревался использовать, лучше работали с JS, например, функциональная библиотека Ramda. Другим примером библиотеки является библиотека пользовательского интерфейса, которую я уже решил использовать, Former-Kit, которая не типизирована, что снижает выгоды от использования таких языков, как TS.

Еще одна вещь, которая заставила меня выбрать JS, заключалась в том, что я надеялся, что использование более популярного языка привлечет больше людей для совместной работы над проектом. Что ж, я получил несколько PR от других людей, но их было очень мало, что не компенсировало затраты на использование JS.

Даже имея дело с простым личным проектом, я часто делаю ошибки, которых могли бы избежать языки со статической типизацией, TS и ReasonML (что может получить этот объект? Каковы возможные состояния этого перечисления?).

Любопытно, что, имея дело с полностью личным проектом, у меня гораздо больше свободы для опробования различных идей и технологий. Одним из них были эксперименты с плагином babel pipe-operator. Это сделало код JS более удобоваримым.
Этот плагин очень хорошо работает с Ramda, так как все функции Ramda каррируются. Чуть позже мы увидим небольшой пример этого.

100% статическое приложение

С самого начала разработки у меня было желание разработать 100% статическое приложение. У меня уже был опыт работы с моим личным блогом, первая версия которого требовала от меня обслуживания сервера, и это вызывало только проблемы с обслуживанием. Когда я переключился на использование только страниц GitHub и аутсорсинговых частей, для которых требовался сервер (например, комментарии), мне больше не приходилось об этом беспокоиться. Это просто работает.

Поэтому зачем сервер? Все операции, которые нам понадобятся (загрузить ПЗУ, нарисовать карту, отредактировать ее и сохранить настроенное ПЗУ), могут быть выполнены непосредственно клиентом. Другими словами: нам не нужен сервер. Так зачем он нужен? Это было бы еще одной абстракцией, о поддержке которой нужно беспокоиться.
Например, нам пришлось бы больше беспокоиться о безопасности при общении между клиентом и сервером… В конечном счете, отсутствие сервер выгоден для этого приложения!

Поскольку я уже использовал GitHub для хранения своего кода и уже имел хороший опыт работы с GitHub Pages, я решил также использовать его для размещения сайта. Конечно, я мог бы использовать что-то еще, например Now, с которым у меня тоже был хороший опыт.
Развертывание GitHub Pages было очень тривиальным, поскольку уже есть пакет NPM, который делает все за меня, gh-pages. Это PR (pull request), который это реализует (примечание: изучая мои PR, смотрите на коммит за коммитом! Я всегда стараюсь создавать атомарные коммиты, и прошу всех делать то же самое).

Реагировать

Некоторое время я профессионально разрабатывал React, и мне это очень нравится. Представление о внешнем интерфейсе как о наборе компонентов, каждый из которых имеет набор возможных состояний и написание декларативного кода в пользовательском интерфейсе, всегда имело для меня большой смысл.
Вот почему я не составило труда выбрать React. Небольшой спойлер: позже мы увидим, что не всегда хорошо использовать React в определенных частях интерфейса.

В качестве библиотеки компонентов я выбрал Former-Kit. Это библиотека компонентов от компании, в которой я работал раньше, и она полностью с открытым исходным кодом. Использование его в проекте дает мне большую гибкость (поскольку у меня уже есть опыт в этом, и мои друзья, которые его разработали, могут мне помочь) и гибкость (поскольку я знаю, как возиться с библиотекой, чтобы настроить ее и открыть PR, которые случалось несколько раз в ходе проекта).
Кроме того, эта библиотека очень хорошо работает с пользовательскими интерфейсами, основанными на карточках, и мне кажется, что интерфейс klo-gba.js состоит из различные карты. Одна карта для отображения карты тайлов, другая карта для отображения деталей объекта…
Наконец, есть и более личная причина: сотрудничество с Former-Kit, его разработка и продвижение делают меня счастливым и мотивированным как программиста, так как я питаю особую нежность к своим друзьям, которые его разработали.

монорепо

Интересным моментом проекта является архитектура монорепозитория. Другими словами, один репозиторий, в котором хранится более одного проекта. Это хороший способ более четко разделить ответственность между частями приложения, поскольку это разделение четко прописано в коде.
Но откуда два проекта? Разве мы не делаем только одно веб-приложение? Сохраняйте спокойствие… скоро станет яснее.

klo-gba.js изначально состоял всего из двух проектов: 🖌 кисть и ✂️ ножницы. Первый отвечает за весь UI, то есть за то, с чем пользователь взаимодействует непосредственно в браузере, с тем, что пользователь видит. Вторым будет «бэкэнд», а точнее то, что отвечает за манипулирование данными ПЗУ, хранение информации об уровнях…
Например, кисть — это то, что сопоставляет каждый тип плитки с цветом, а ножницы — это то, что сопоставляет ID тайла к его типу. scissors даже не знает, что такое React, а brush не знает, как манипулировать буфером ROM.
scissors хранит JSON-подобные файлы с данными для каждого уровня (например, адрес тайловой карты, размер уровня и т. д.) scissors обрабатывает эти данные, чтобы отвечать на запросы кисти.

Выполнение этого с монорепозиторием вместо двух отдельных репозиториев помогает обеспечить здоровый уровень инкапсуляции. Например, это облегчает, когда я открываю PR или анализирую историю коммитов репозитория, что дает прирост гибкости. Если PR влияет на оба проекта, нет необходимости открывать два PR в разных репозиториях, достаточно открыть один PR, который редактирует оба.

Поскольку приложение продолжает расти, у нас может быть больше проектов. Например, если мы собираемся разработать новый сложный компонент пользовательского интерфейса, вместо того, чтобы разрабатывать его внутри кисти, мы можем разработать его как отдельный проект и импортировать как зависимость в кисть. Или мы можем отделить существующий компонент от кисти, создав новый проект в монорепозитории.
На самом деле это произошло после нескольких месяцев разработки. Когда я решил добавить эмулятор GBA на страницу, я создал новый проект в монорепозитории, 🕹 react-gbajs, что помогло упростить работу с кистью.
Другой случай: Я хочу отделить компонент Tilemap от кисти, чтобы он был изолированным компонентом React, чтобы сделать его доступным для сообщества. Однако промежуточным шагом для достижения этого результата будет создание нового проекта внутри klo-gba.js, и только после того, как компонент Tilemap станет стабильным, его можно будет переместить в новый репозиторий. Этот промежуточный шаг был бы полезен для повышения гибкости во время разработки.

Ах да, и чтобы не потерять историю коммитов при переходе в новый репозиторий, мы можем использовать команду git subtree.

Одним из недостатков монорепозитория является то, что он увеличивает сложность процесса сборки, так как теперь нам нужно скомпилировать два проекта и связать их «каким-то образом». Есть разные способы сделать это. Тот, который я изначально применил в klo-gba.js, является одним из самых простых из возможных: я просто импортировал ножницы в кисть, используя ее относительный путь:

"dependencies": {
  ...
  "scissors": "file:../scissors"
},

Другая крайность сложности заключается в другом подходе к монорепозиторию для многих проектов — использовать Lerna, который представляет собой инструмент для создания зависимостей между проектами монорепозитория, включая создание зависимостей внутри каждого проекта.
Один из способов что он делает, например, если многим проектам требуется одна и та же зависимость, вместо того, чтобы загружать ее несколько раз, она загружает ее один раз и создает на нее символическую ссылку.
Очевидно, что klo-gba.js очень просто, я не видел необходимости использовать Lerna.

Когда было всего два проекта, использование NPM, связывающее зависимость с file:, было удовлетворительным, однако, когда я пошел добавлять третий проект в монорепозиторий, стремясь упростить и ускорить добавление зависимостей, я перешел на использование Yarn's Workspaces. , а изменение было сделано в этом PR.
Панель инструментов компании, в которой я работал, также использует подход монорепозитория через Yarn’s Workspace. Поскольку это открытый исходный код, вы можете изучить его код здесь.

Тайлмап

Первым разработанным компонентом был Tilemap, еще до того, как у klo-gba.js появилось имя. Это, без сомнения, самый сложный компонент приложения. Любопытно, что он составляет около 35% кодовой базы кисти. У этого компонента две большие обязанности: построение тайловой карты с объектами и предоставление пользователю возможности настраивать ее.

Помните те проверки концепций, которые мы разработали, чтобы нарисовать уровень в файле изображения? Ну, оттуда я начал думать о том, как разработать Tilemap. Самое ясное видение, которое у меня было, помимо кода, который рисовал BMP, заключалось в том, чтобы просто рисовать в браузере с помощью холста, и поэтому я начал его разрабатывать. Для этого я решил использовать библиотеку konva и оболочку react-konva.
Таким образом, я сделал каждую плитку точкой на холсте, и каждая точка была компонентом React. Это значительно облегчило реализацию взаимодействия с мышью.

И это сработало! Однако... он работал очень плохо, так как должен был отображать много компонентов. Просто для понимания, второй уровень содержит 18000 тайлов, то есть создается около 18000 компонентов React!
Первый рендер уровня занял ~3 секунды, а переход на другой уровень занял ~13 секунд!! Эта значительная задержка при переключении уровня вероятно происходит из-за того, что React пытается использовать компоненты, уже находящиеся на экране, однако, поскольку он имеет дело с переключением всего уровня, все его попытки повторного использования плиток потерпят неудачу, и поэтому на обновление виртуального DOM и последующую отрисовку экрана ушло гораздо больше времени.

Как я уже сказал, помимо рендеринга уровня также необходимо обновлять цвет тайла при его изменении, и, очевидно, ждать несколько секунд для каждого клика по карте было бы не очень приятно. Итак, я написал какой-то чрезвычайно странный код, используя refs, чтобы избежать повторного рендеринга Tilemap при изменении цвета одной точки. Несмотря на это, рендеринг карты тайлов продолжал занимать несколько секунд.

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

Реализация этого алгоритма была непростой не только из-за сложности расширить компонент до максимально возможного размера, но и из-за усложнения рисования плитки. Вот PR, реализующий упрощенную версию этого алгоритма. После этой оптимизации мне удалось сократить время первого рендера до 1,8 секунды, а следующего — до 3,6 секунды. Несмотря на то, что это все еще немного медленно, это хороший прогресс, верно? Но теперь код намного сложнее…

Стремясь получить обратную связь и готовясь выступить с лекцией об этом проекте на конференции, я впервые выступил с докладом о klo-gba.js на митапе ReactSP#35. Одну часть выступления я посвятил объяснению сложности компонента Tilemap, а потом все настоятельно рекомендовали мне отказаться от Canvas и заменить его на WebGL.

Хорошо. Перечислив отзывы, я решил провести рефакторинг всего для использования WebGL, на этот раз используя библиотеку @inlet/react-pixi, которая использует pixi.js под капотом. Просто заменив Canvas на WebGL и сохранив ту же архитектуру, я уже получил прирост производительности. Первый рендер теперь занимал 1,6 секунды, а следующие — 2,8 секунды! Это пиар, который сделал это изменение.

Затем в качестве следующего шага, следуя отзывам, я решил уменьшить абсурдное количество компонентов на экране. Вместо того, чтобы каждый тайл был компонентом React, я решил визуализировать только один компонент и внутри него заставить WebGL рисовать все тайлы. С таким подходом я получил гораздо более читаемый и оптимизированный код! Первый рендеринг теперь занимает всего 0,625 секунды, а следующие рендеры занимают 0,536 секунды. Это пиар с реализацией.

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

Кроме того, я надеюсь, что в будущем у меня будет время отделить компонент Tilemap от нового компонента React, потому что я думаю, что это будет полезно для других проектов сообщества.

Шаблон поставщика

Что-то очень популярное при разработке с React — это использование Redux-что-то для управления глобальным состоянием приложения. Хорошо, я разрабатывал новый проект с нуля, однако у меня были некоторые сомнения, нужно ли мне применять Redux или нет.

Обсуждая с людьми с большим опытом работы с интерфейсом, мы пришли к выводу, что Redux нам не нужен. Интерфейс klo-gba.js слишком прост, чтобы нуждаться в сложности, которую добавляет Redux.
У нас очень мало операций, связанных с вводом-выводом. У нас даже нет ни одного сервера для связи. Кроме того, у нас есть несколько компонентов, которые влияют на глобальное состояние приложения.

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

С момента запуска приложения я уже знал, что компонент для выбора уровня (SelectVision) повлияет на глобальное состояние приложения, так как все остальные компоненты должны обновляться в соответствии с уровнем, выбранным пользователем.
Применяя шаблон провайдера, SelectVision необходимо связаться с провайдером, ответственным за хранение информации о выбранном уровне (VisionProvider). Вся логика обновления информации об уровне остается в провайдере, не в компоненте!
Таким образом, когда VisionProvider обновляется, все компоненты, которые слушают этого провайдера, также обновляются. -рендеринг. С этим нужно быть осторожным, так как если у нас плохой план, может произойти нежелательный повторный рендеринг!

При разработке с использованием Provider Pattern важно попытаться разбить поставщиков и изолировать ветки компонентов, на которые влияет тот или иной поставщик, чтобы уменьшить количество ненужных повторных рендерингов, как объяснил сам Дэн Абрамов. . Поэтому klo-gba.js в настоящее время имеет только около 3 провайдеров:

  • ROMProvider, в котором хранится буфер ПЗУ (залито ПЗУ? Функция обновления ПЗУ…);
  • VisionProvider, в котором хранится информация о текущем выбранном уровне (тайловая карта и буферы объектов, функции обновления уровня…);
  • DisplayMapOptionsProvider, в котором хранятся параметры отображения карты (показать сетку? скрыть объекты?)

Досадная проблема, с которой я столкнулся, заключалась в том, что в react-konva, как и в @inlet/react-pixi, невозможно передавать контексты, созданные извне. Этот комментарий лучше объясняет эту ошибку.
Решение, которое у меня было, было в Tilemap просто извлечь все данные, необходимые в контекстах, и передать их в качестве реквизита каждому из его дочерних элементов.

Веб-сборка

Итак, давайте закончим эту главу золотым ключиком! Это моя любимая часть и один из самых больших хайпов в проекте.
Вы помните, что в четвертой главе Где находится тайловая карта в ПЗУ? мы использовали старый проект, написанный на C, для распаковки тайловой карты. извлечено из ПЗУ? Что ж, это отлично работает, когда процесс распаковки выполняется вручную… но как мы можем автоматизировать распаковку, да еще и запускать ее в браузере? В конце концов, веб-страница не может запускать код C!

Переписывание всего этого C-кода в JS заняло бы много времени, и я уже был уверен, что C-код отлично работает с файлами карты тайлов в игре.
Так что насчет компиляции C-кода в WebAssembly и последующей загрузки? это в браузере?

Я никогда раньше не использовал WebAssembly, я знал только суть. Так что мне пришлось узнать немного больше, прежде чем запачкать руки. В этих исследованиях я нашел популярный проект Emscripten. Он уже очень зрелый и старый; он существовал с тех пор, когда была популярна компиляция для asm.js, который в основном является подмножеством JS, ориентированным на гораздо большую оптимизацию для обработки.

Хотя это и не было нашей целью, еще одним преимуществом использования WebAssembly является повышение производительности, поскольку WebAssembly очень хорошо справляется с операциями, связанными с ЦП, что как раз и происходит, когда мы распаковываем тайловую карту.

Чтобы скомпилировать с помощью Emscripten, просто запустите emcc -s WASM=1 mySource.c. При компиляции, помимо создания файла .wasm, он также генерирует оболочку, написанную на JS, что значительно упрощает интеграцию WebAssembly с остальной кодовой базой.
Оболочка уже загружает .wasm и предоставляет свои функции для использования.

Еще одно чудо Emscripten заключается в том, что ему удается эмулировать файловую систему. Зачем нам это нужно? Что ж… исходный код на C использует файловую систему для чтения и записи буфера данных, подлежащего сжатию и распаковке. Чтобы использовать его, нам нужно передать путь к файлу. Поскольку я действительно не хотел возиться с кодом C, так как он мог быть подвержен ошибкам, я стремился сохранить то же самое поведение.
Изучая Emscripten, я увидел, что можно передать флаг компилятора в это для эмуляции файловой системы, и он даже добавляет функции в оболочку для управления этой эмулируемой файловой системой! При этом удалось сохранить максимально возможное количество исходного кода.
Используемый флаг — -s EXTRA_EXPORTED_RUNTIME_METHODS=["FS"].

Однако это еще не все: солнце и радуга...
Мне удалось успешно скомпилировать код декомпрессии C, и он работает в примере проекта, который Emscripten генерирует без проблем, но при попытке использовать его в klo-gba.js, это не сработало! Я застрял на некоторое время, не понимая, что происходит, пока не понял, что проблема в Webpack. Что!? Вебпак?? да. То же самое.
Одна из настроек Webpack — переименовывать файлы, чтобы избежать проблем с кешем в браузере. Таким образом, файл, который раньше назывался lzss.wasm, в конечном итоге будет называться примерно как 0b549596b.wasm. При переименовании этих активов Webpack также обновляет импорт в коде, однако это не работает для оболочки, созданной Emscripten, поэтому он не может найти файл .wasm. Эта же ошибка уже затрагивала других людей, и найденное ими решение заключалось в том, чтобы использовать загрузчик файлов и исправить функцию locateFile оболочки. Итак, у нас есть:

const huffmanWasm = require('./wasm/huffman.wasm')
const huffmanModule = require('./wasm/huffman.js')({
  locateFile (path) {
    if (path.endsWith('.wasm')) {
      return `node_modules/scissors/dist/${huffmanWasm}`
    }
return path
  },
})
...

Причина добавления node_modules/scissors/dist/ перед именем файла заключается в том, что ножницы — это проект, который зависит от кисти, и именно здесь файлы .wasm будут с точки зрения кисти.
PR, который добавляет эти сумасшедшие функции с WebAssembly прямо здесь.

В тот момент мне казалось, что все работает отлично, однако я понял, что у меня возникла проблема при развертывании.
Причина в том, что при выполнении производственной сборки и развертывания отсутствует путь с именем node_modules/scissors/dist/. Поэтому нам нужно использовать загрузчик copy-webpack-plugin, чтобы скопировать файлы .wasm в ножницы и переместить их в кисть. Этот коммит — вот что это исправляет. (См. PR для этого коммита, чтобы иметь лучший контекст.)
Честно говоря, мне не очень понравилось это исправление, так как оно создавало инкапсуляцию в сборке между кистью и ножницами. Одна из моих идей по лучшему устранению этой ошибки — отключить переименование файлов, которое делает Webpack, но я не тестировал этот подход (принимаю участие в проекте!).

После создания небольшого модуля, инкапсулирующего логику взаимодействия с WebAssembly, функция извлечения уровня очень чистая:

const extractTilemap = (romBuffer, [addressStart, addressEnd]) =>
  romBuffer.slice(addressStart, addressEnd)
  |> huffmanDecode
  |> lzssDecode

Обратите внимание, что функция извлечения уровня состоит всего из трех строк! А, и обратите внимание, как элегантен оператор канала =^-^=

С этого момента основа уже работала, в итоге удалось распаковать уровень из игры в браузере. Но как насчет того, чтобы еще немного улучшить его?
Процесс компиляции с использованием Emscripten подразумевает, что человек имеет его на своем компьютере и выполняет некоторые команды… тогда как насчет автоматизации этого с помощью шеллскрипта и Docker? Итак, я нашел образ Emscripten, созданный сообществом (официального образа не существует), и написал следующий шелл-скрипт:

function docker_run_emscripten {
  local filename="$1"
echo "Compiling $filename..."
docker run \
    --rm -it \
    -v $(pwd)/scissors/src/wasm:/src \
    trzeci/emscripten \
    emcc -s WASM=1 -s ALLOW_MEMORY_GROWTH=1 -s MODULARIZE=1 -s EXTRA_EXPORTED_RUNTIME_METHODS=[\"FS\"] -s EXPORT_NAME=\"$filename\" -o ./$filename.js $filename.c
}
docker_run_emscripten huffman
docker_run_emscripten lzss

Благодаря этому стало намного проще установить среду для запуска klo-gba.js!

В заключении…

Хорошо, мы видели различные решения, принятые во время разработки klo-gba.js, и с этого момента у нас заработало наше веб-приложение для настройки уровней из игры Klonoa! Как круто!

Что-то еще отсутствует? Ну допустим да…

На данный момент нам удалось настроить уровень и сохранить его в ПЗУ, однако со следующим ограничением: уровень, следующий за настроенным уровнем, больше не работает! То есть, если мы модифицируем уровень N, мы можем играть в свой пользовательский уровень N, но игра вылетает при загрузке уровня N+1.

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

Следующая запись: Сохранение пользовательского уровня!