👾

Rubyで書いたゲームボーイエミュレータをブラウザ上で動くようにした

2025/01/15に公開

はじめに

自作のRuby製ゲームボーイエミュレータ(Ruby Boy)を、WebAssemblyを使ってブラウザ上で動くようにしました!
https://sacckey.github.io/rubyboy/

リポジトリはこちら
https://github.com/sacckey/rubyboy

Ruby Boyの紹介記事はこちら
https://zenn.dev/sacckey/articles/05b6eb6ea89662

この記事

どのようにしてRuby Boyをブラウザ上で動かしているかを説明します。
Rubyプログラムをブラウザ上で動かしたい人も参考になると思います。

システムの概要

Ruby製ファミコンエミュレータのOptcarrotをWasmで動かしているoptcarrot.wasmの実装を参考にしています。

+----------------+       DOM Events           +----------------+
|   index.html   |     (Keyboard & ROM)       |    index.js    |
|   (Browser)    |--------------------------->| (Main Thread)  |
|                |<---------------------------|                |
|                |   Frame Data (ImageData)   |                |
+----------------+                            +----------------+
                                                     |  ^
                                                     |  |
                                            Keyboard |  |  Frame Data
                                              States |  |  (ArrayBuffer)
                                                     v  |
+----------------+                            +----------------+
|  rubyboy.wasm  |      Keyboard States       |   worker.js    |
| (GB Emulator)  |<---------------------------| (Game Thread)  |
|                |--------------------------->|                |
|                |  Frame Data (Uint8Array)   |                |
+----------------+                            +----------------+
  • index.js: メインスレッド。キーボード入力やROMファイル入力をWorkerに送り、Workerから送られてくるフレームデータでcanvasを更新する
  • worker.js: ワーカースレッド。メインスレッドからの入力イベントを処理し、Ruby Boyを実行してフレームデータをメインスレッドへ送る
  • rubyboy.wasm: Wasmパッケージ化したRuby Boy。ゲームボーイをエミュレートしてフレームデータを生成する

Ruby BoyのWasm化

Ruby ProgramのWasmパッケージ化は、以下の2つのステップで行います:

  1. CRubyをWasmにbuildしてruby.wasmを作る
  2. ruby.wasmにRuby Programをpackして専用のwasmを作る

依存しているgemのインストールはbuild時に事前に行います。図で表すと以下のとおりです。

+--------------+
|    gems      |   build
|     +        | --------->> ruby.wasm ─┐
|    CRuby     |                        |
+--------------+                        |   pack
                                        + -------->> ruby_with_program.wasm
+--------------+                        |
| Ruby Program | -----------------------┘
+--------------+

このbuildとpackにはruby_wasm gemを使います。
ruby_wasm gemやjs gem(後述)、npm packages、build済みのバイナリなどは以下のリポジトリでまとめて公開されています。
https://github.com/ruby/ruby.wasm

buildする

ruby_wasm gemを使って、依存gemを取り込んだwasmファイルを作ります。
ブラウザ上で動かしたい場合はjs gemも必要なので、合わせてインストールしておきます。

$ bundle add ruby_wasm js
$ bundle exec rbwasm build --ruby-version 3.3 -o ruby-js.wasm

Tips: 必要なgemのみbuildする

rbwasm buildを実行するとGemfile.lockにある依存しているgemも一緒にbuildされます。rspecやrubocopなどのビルドする必要がないgemを対象から外すためには、RubyWasm::Packager::EXCLUDED_GEMSに不要なgemを指定したうえで、コマンドを直接実行します。
Ruby Boyの場合はjs gem以外不要なので、以下のようにjs gem以外の全てのgemをEXCLUDED_GEMSに入れています。

require 'bundler/setup'
require 'ruby_wasm'
require 'ruby_wasm/cli'

# Exclude all gems except the 'js' gem for packaging
command = %w[build --ruby-version 3.3 -o ./docs/ruby-js.wasm]
definition = Bundler.definition
excluded_gems = definition.resolve.materialize(definition.requested_dependencies).map(&:name)
excluded_gems -= %w[js]
RubyWasm::Packager::EXCLUDED_GEMS.concat(excluded_gems)

RubyWasm::CLI.new(stdout: $stdout, stderr: $stderr).run(command)

参考:
https://speakerdeck.com/lnit/matrk11-ruby-wasm-msw?slide=73

packする

上で作ったruby-js.wasmにRuby Boyのコードをpackします。

# ./lib配下にあるRuby Boyのコードをpackする
$ bundle exec rbwasm pack ruby-js.wasm --dir ./lib::/lib -o rubyboy.wasm

rubyboy.wasmが完成したので、ブラウザ上で動かします。

ブラウザ上で動かす

システム構成図(再掲)

+----------------+       DOM Events           +----------------+
|   index.html   |     (Keyboard & ROM)       |    index.js    |
|   (Browser)    |--------------------------->| (Main Thread)  |
|                |<---------------------------|                |
|                |   Frame Data (ImageData)   |                |
+----------------+                            +----------------+
                                                     |  ^
                                                     |  |
                                            Keyboard |  |  Frame Data
                                              States |  |  (ArrayBuffer)
                                                     v  |
+----------------+                            +----------------+
|  rubyboy.wasm  |      Keyboard States       |   worker.js    |
| (GB Emulator)  |<---------------------------| (Game Thread)  |
|                |--------------------------->|                |
|                |  Frame Data (Uint8Array)   |                |
+----------------+                            +----------------+

下部のworker.jsとrubyboy.wasmの処理内容について説明します。

VMの初期化

worker.js
import { DefaultRubyVM } from 'https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.7.0/dist/browser/+esm';

const response = await fetch('./rubyboy.wasm');
const module = await WebAssembly.compileStreaming(response);
const { vm, wasi } = await DefaultRubyVM(module);
vm.eval(`
  require 'js'
  require_relative 'lib/executor'

  $executor = Executor.new
`);

this.vm = vm;
this.rootDir = wasi.fds[3].dir;

Worker内でrubyboy.wasmをDefaultRubyVMメソッドに渡してVMを作ります。このVM上でRuby Boyのコードを実行します。

wasi.fds[3].dirはDefaultRubyVMがpreopenしてくれているルートディレクトリを表すMapです。
このMapを使って、VMへのROMデータの送信及びVMからのフレームデータの受信を行います。

ゲーム画面を描画する

worker.js
sendPixelData() {
  this.vm.eval(`$executor.exec(${this.directionKey}, ${this.actionKey})`);
  const file = this.rootDir.contents.get('video.data');
  const bytes = file.data;

  postMessage({ type: 'pixelData', data: bytes.buffer }, [bytes.buffer]);
}

WorkerがVM上でRuby BoyのExecutor#execを実行し、/video.dataに書き込まれたフレームデータを読み込んでindex.jsにpostします。index.jsは受信したフレームデータでcanvasを更新します。これを繰り返すことでゲーム画面を描画できます。

Ruby BoyのExecutor#execの実装は以下のとおりです。

executor.rb
def exec(direction_key = 0b1111, action_key = 0b1111)
  bin = @emulator.step(direction_key, action_key).pack('V*')
  File.binwrite('/video.data', bin)
end

execメソッドは現在のボタン入力(方向キー, A, B, Start, Select)を受け取ってゲームボーイをエミュレートし、フレームデータを/video.dataに書き込みます。
Ruby Boyではフレームデータを[0xff555555]のようなABGR形式のUint32配列で管理しており、これを.pack('V*')することで[0x55, 0x55, 0x55, 0xff]のようなcanvas用のRGBA形式のUint8配列に変換しています。

おわりに

自作のRuby製ゲームボーイエミュレータ(Ruby Boy)を、WebAssemblyを使ってブラウザ上で動くようにしました!
ruby.wasmは日々進化しており、Mastodonがブラウザ上で動作するなど熱いプロジェクトです。Rubyプログラムをブラウザ上で動かすのは楽しいので皆様もぜひ。

参考資料

https://techracho.bpsinc.jp/hachi8833/2024_03_25/139952

https://github.com/kateinoigakukun/optcarrot.wasm

https://speakerdeck.com/lnit/matrk11-ruby-wasm-msw

https://aligach.net/diary/2024/0525/pack-ruby-script-with-wasm/

Discussion