お久しぶりです、エンジニアの宮下です。
今年2月にリリースされたGo 1.24で、WebAssembly (以下WASM) への対応が進み、WASIライブラリとしての使い道が広がりました。そもそもWASMとは、ブラウザやその他のランタイムで動作するバイナリフォーマットならびにVMの仕様で、EnvoyやOpenTelemetryなどでもプラグインとしてサポートされています。
恥ずかしながらWASMについて知識が浅かった私は、学習を兼ねて、ゲームを作ってみることにしました。
このゲームはGoで書いたコードをWASMに変換し、TIC-80という環境で動かしています。これを動かすまでの過程で、さまざまな困難にぶつかりました。
今回使う環境について : そもそもTIC-80とは?
TIC-80はファンタジーコンソール(fantasy console)の一種です。「架空のゲーム機」といった意味を持ち、レトロゲーム機のような体験を提供するエミュレータを指します。ブラウザでも動作するため、公式サイトや他のコミュニティでは共有されたゲームをすぐにプレイでき、プレイヤー、制作者問わず交流が盛んです。
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対応を犠牲にしたサイズの小さいバイナリを出力できます。
早速、以下のコマンドでビルドし、サイズを確認しました。結果、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 ターゲットを使ってみることにしました。
$ 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のインターフェイスをラッピングしたライブラリに任せることにしました。この記事ではサンプルコードをビルドして動かしてみます。
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を使いたいという物好きの方のご参考になれば幸いです。