Golang + WASMでゲームを作ろうとした話

お久しぶりです、エンジニアの宮下です。

今年2月にリリースされたGo 1.24で、WebAssembly (以下WASM) への対応が進み、WASIライブラリとしての使い道が広がりました。そもそもWASMとは、ブラウザやその他のランタイムで動作するバイナリフォーマットならびにVMの仕様で、EnvoyやOpenTelemetryなどでもプラグインとしてサポートされています。

恥ずかしながらWASMについて知識が浅かった私は、学習を兼ねて、ゲームを作ってみることにしました。

sako-0384.github.io

github.com

このゲームはGoで書いたコードをWASMに変換し、TIC-80という環境で動かしています。これを動かすまでの過程で、さまざまな困難にぶつかりました。

今回使う環境について : そもそもTIC-80とは?

TIC-80はファンタジーコンソール(fantasy console)の一種です。「架空のゲーム機」といった意味を持ち、レトロゲーム機のような体験を提供するエミュレータを指します。ブラウザでも動作するため、公式サイトや他のコミュニティでは共有されたゲームをすぐにプレイでき、プレイヤー、制作者問わず交流が盛んです。

tic80.com

TIC-80はOSSとしてGitHubで管理されており、WASM対応についての議論がIssueで行われています。現在はC, D, Rust, Zigでのビルドがテンプレートとして存在しますが、Goでの実装については議論が止まっています。今回はこれについて検証したいと思います。

動作環境

今回用いたツールのバージョンは以下のとおりです。

  • TIC-80 : 1.1.2837 Pro (be42d6f) *1
  • wasm-tools 1.243.0
  • Go : 1.25.4
  • TinyGo : 0.39.0
  • LLVM : 19.1.2

実験

1. まずは最新のGoでやってみる

早速まずは手元にあるGo 1.25にて、以下のようなコードを書いてみました。

package main

// Cls はTIC-80が提供する関数
//go:wasmimport env cls
func Cls(color int32)

// Rect はTIC-80が提供する関数
//go:wasmimport env rect
func Rect(x, y, width, height, color int32)

// BOOT はTIC-80の初期化関数。ゲーム起動時に呼び出される
//go:wasmexport BOOT
func BOOT() {}

// TIC はTIC-80の更新関数。通常1秒60フレームの頻度で呼び出される
//go:wasmexport TIC
func TIC() {
    // ディスプレイを色番号0 (黒) で埋める
    Cls(0)
    // 座標(0, 0)を左上端とし、100px四方の正方形を描く。塗りつぶし色番号は12 (白)
    Rect(0, 0, 100, 100, 12)
}

func main() {}
$ GOOS=wasip1 GOARCH=wasm go build -o main.wasm
$ du -k main.wasm
1644    main.wasm

上記のコマンドでビルドに成功しました。しかし、その次のコマンドで分かるとおり、約1.6MBのバイナリとなってしまいました。TIC-80で読み込めるWASMバイナリのサイズは256KBまでと定められており、これをこのまま使うことは現実的ではありません。

というわけでGo 1.24の機能としてのWASMの利用は断念しました。流石にここで諦めるのは悔しいので、先のIssueで挙がっていたTinyGoを使ってみます。

2. TinyGoでより小さなバイナリを出してみる

Goには公式( gc )以外の実装が存在し、WASM対応以前のフロントエンド向けのgopherjsなどが存在します。TinyGoもそのひとつで、組み込みシステムやWASM向けに、並行性やOS対応を犠牲にしたサイズの小さいバイナリを出力できます。

tinygo.org

早速、以下のコマンドでビルドし、サイズを確認しました。結果、100KBのバイナリの出力に成功しました。さらにデバッグ情報を除くと、20KBとなり、約80分の1のサイズとなりました。

$ GOOS=wasip1 GOARCH=wasm tinygo build -o main2.wasm
$ du -k main2.wasm
100     main2.wasm
$ GOOS=wasip1 GOARCH=wasm tinygo build --no-debug -o main2-nodebug.wasm
$ du -k main2-nodebug.wasm
20      main2-nodebug.wasm

早速こちらを実行してみましょう。

$ tic80 --fs . --cmd "new wasm & import binary main2-nodebug.wasm & run"

 TIC-80 tiny computer
 version 1.1.2837 Pro (be42d6f)
 https://tic80.com (C) 2017-2023

 hello! type help for help

>new wasm
new cart has been created
>import binary main2.wasm
main2.wasm imported :)
>run
>Initializing WASM3 runtime -1875443648
missing imported function

missing imported function といったエラーが出力されました……。「WASMのマシンコードにヒントがあるかもしれない」と考え、テキスト形式(WebAssembly Text Format, WAT)に変換してみました。今回はwasm-toolsを用いましたが、ほかのツールでも同様の情報を確認できます。

$ wasm-tools print ./main2-nodebug.wasm > ./main2-nodebug.wat

結果、以下のエクスポートが確認できました。

(module $main
  (type (;0;) (func (param i32)))
  (type (;1;) (func))
  (type (;2;) (func (param i32 i32 i32 i32) (result i32)))
  (type (;3;) (func (param i32 i32)))
  (type (;4;) (func (param i32 i32) (result i32)))
  (type (;5;) (func (result i32)))
  (type (;6;) (func (param i32) (result i32)))
  (type (;7;) (func (param i32 i32 i32 i32 i32)))
  (type (;8;) (func (param i32 i32 i32 i32)))
  (import "wasi_snapshot_preview1" "fd_write" (func $runtime.fd_write (;0;) (type 2)))
  (import "wasi_snapshot_preview1" "proc_exit" (func $runtime.proc_exit (;1;) (type 0)))
  (import "env" "cls" (func $main.Cls (;2;) (type 0)))
  (import "env" "rect" (func $main.Rect (;3;) (type 7)))
  (import "wasi_snapshot_preview1" "random_get" (func $__imported_wasi_snapshot_preview1_random_get (;4;) (type 4)))
  ...

今回のビルドのターゲットにしていたWASIは、ファイルシステムやプロセスなど、OSの機能にアクセスするための標準的なインターフェイスのことです。今回ランタイムが依存している fd_write, proc_exit, random_get などの関数がWASIで規定されています。TIC-80のWASM実行環境がこれらをエクスポートしていないにも関わらず、ランタイムで呼び出そうとしたため発生したと見られます。

そこで、ビルドターゲットを改めることでこれらの関数を用いずに実行できるバイナリを出力できないか試してみます。

3. ビルドターゲットを変更してみる

TinyGoではビルドターゲットごとの設定をJSONで記述できます。実際に上記のビルドではTinyGoが提供する wasip1.json に従ってビルドされていましたが、今度はTinyGoの wasm-unknown ターゲットを使ってみることにしました。

github.com

$ tinygo build --target wasm-unknown -o main3.wasm
$ wasm-tools print main3.wasm
(module $main
  (type (;0;) (func))
  (type (;1;) (func (param i32)))
  (type (;2;) (func (param i32 i32 i32 i32 i32)))
  (import "env" "cls" (func $main.Cls (;0;) (type 1)))
  (import "env" "rect" (func $main.Rect (;1;) (type 2)))
  (memory (;0;) 2)
  (export "memory" (memory 0))
  (export "_initialize" (func $_initialize))
  (export "BOOT" (func $main.BOOT#wasmexport))
  (export "TIC" (func $main.TIC#wasmexport))
  (func $_initialize (;2;) (type 0)
    i32.const 65536
    i32.const 1
    i32.store8
  )
  (func $main.BOOT#wasmexport (;3;) (type 0)
    i32.const 65536
    i32.load8_u
    i32.eqz
    if ;; label = @1
      unreachable
    end
  )
  (func $main.TIC#wasmexport (;4;) (type 0)
    i32.const 65536
    i32.load8_u
    i32.eqz
    if ;; label = @1
      unreachable
    end
    i32.const 0
    call $main.Cls
    i32.const 0
    i32.const 0
    i32.const 100
    i32.const 100
    i32.const 12
    call $main.Rect
  )
  (@producers
    (language "C99" "")
    (processed-by "TinyGo" "0.39.0")
  )
  (@custom "target_features" (after code) "\03+\0fmutable-globals+\13nontrapping-fptoint+\08sign-ext")
)

インポート内容も cls , rectのみとなりました。初期化処理( BOOT )ならびに更新処理( TIC )のエクスポートも確認できました。早速実行してみましょう。*2

$ tic80 --fs . --cmd "new wasm & import binary main3.wasm & run"

うまく動いたようです!ようやくゲームを作るぞ、と意気込んだのも束の間、すぐに出鼻をくじかれることになります……

4. 文字列やスライスを扱ってみる

TIC-80には、文字列や配列を扱う関数が提供されています。しかし、WASMからインポートする関数では配列型がサポートされていません。メモリ内に配置された連続するデータについて、 unsafe パッケージを用いてアドレスを取得して、それらを渡す必要があります。今回その内容については、TIC-80のインターフェイスをラッピングしたライブラリに任せることにしました。この記事ではサンプルコードをビルドして動かしてみます。

github.com

package main

import (
    "github.com/sorucoder/tic80"
)

var (
    t int = 0
    x int = 96
    y int = 24
)

//go:export BOOT
func BOOT() {
    // これはWASIの `_start` を呼び出す処理なので今回は使用しない
    // tic80.Start()
}

//go:export TIC
func TIC() {
    if tic80.Btn(tic80.BUTTON_UP) {
        y--
    }
    if tic80.Btn(tic80.BUTTON_DOWN) {
        y++
    }
    if tic80.Btn(tic80.BUTTON_LEFT) {
        x--
    }
    if tic80.Btn(tic80.BUTTON_RIGHT) {
        x++
    }

    tic80.Cls(13)
    tic80.Spr(1+t%60/30*2, x, y, tic80.NewSpriteOptions().AddTransparentColor(14).SetScale(3).SetSize(2, 2))
    tic80.Print("HELLO WORLD FROM GO!", 65, 84, nil)
    t++
}
$ tinygo build --target wasm-unknown --no-debug -o main4.wasm
$ tic80 --fs . --cmd "new wasm & import binary main4.wasm & run"

動きました*3。しかし画面に不穏なノイズが載っています……。

5. メモリマップをやってみる

TIC-80は擬似的なメモリマップトI/Oを採用しており、WASMのメモリ領域の0番地からVRAM, TILES, SPRITES…と続いています。先述のノイズはVRAMにヒープまたはスタック領域が割り当てられており、意図せず作業中のデータが書き込まれているのが原因でした。ヒープやスタック領域を別の場所に割り当てることで、メモリマップトI/Oの領域を避けることで解決しそうです。

今回の実験ではリンカとして wasm-ld というlldのWASM対応版を用いており、オプションを設定することでマッピングが可能です。 wasm-unknown ターゲットをもとに、以下の画像のように配置変更を行いました。

  • --global-base=98304 : ベースとなるアドレスの変更。TIC-80が自由領域として 0x18000 からの領域を確保しているので、その位置を10進数で指定
  • --initial-memory=196608 : 初期メモリ領域。IOを含んだ領域のサイズである 256KB 以下を採用しているが、256KBでも動くはず
  • --stack-first 消去

その他も設定を行い、最終的に以下のようなJSONができました。

{
...
  "libc": "wasmbuiltins",
-  "gc": "leaking",
+  "gc": "conservative",
  "default-stack-size": 4096,
...
  "ldflags": [
-       "--stack-first",
    "--no-demangle",
    "--no-entry",
+    "--global-base=98304",
+    "--initial-memory=196608"
  ],
...
}

残念ながらこのままではうまく動きません。上に示したヒープ領域を初期化するために、ランタイムが自動生成する _initialize 関数を呼び出す必要があります。しかし、この関数は公開されていないため、上記ライブラリの Start() と同様にリンカをごまかして Initialize() を用意します。

$ go mod vendor

tic80.go に以下を追加して……

//go:linkname Initialize _initialize
func Initialize()

BOOT 関数で以下のように呼び出します。

//go:export BOOT
func BOOT() {
    tic80.Initialize()
}

ビルド、実行してみましょう。

 $ tinygo build --target target_final.json --no-debug -o main_final.wasm
 $ tic80 --fs . --cmd "new wasm & import binary main5.wasm & run"

Hello, world! 画面のノイズや文字の崩れもなく、正しく動作することが確認できました。

最後に

インターフェイスを用意したりライブラリを使えばすぐできるだろう、といった軽い気持ちで始めた一連の実験で、WASM/WASIの仕組みや、TinyGoを用いたビルド、リンクと向き合いました。ここまで苦戦するとは思いませんでした……。

TIC-80に限らず、プラグインとしてWASMに対応しているツールがちらほら存在します。こういったツールと対峙するときによくわからない状態で始めるのも嫌だったので、勉強がてら実験を進めましたが、大変有意義な遠回りになりました。

この記事がWASMについて知りたい方、またはTIC-80でGoを使いたいという物好きの方のご参考になれば幸いです。

*1:今回のようなファイルのロードやセーブはPro版が必須となります。購入またはソースコードからのビルドで利用ができます。

*2:映像では tic80 --fs . で起動し、コマンドをTIC-80内で入力しています。いずれの方法でも問題ありません。

*3:映像では main5.wasm をインポートしていますが、実験時の誤りです。