新しいblogを作りました 今後こちらに書いていきます
Rustから目覚めるぼくらのゲームボーイ!
Conway氏についてですが、公式にアナウンスがでたようです。ご冥福をお祈り申し上げます。
2003年に発売された「Linuxから目覚めるぼくらのゲームボーイ!」というC言語でゲームボーイアドバンスで動作する自作ゲームを作成していく書籍があります。
ゲームボーイアドバンスはARM7TDMIというコアを使用しており、Rustで自作ゲームを作ることも可能となっています。
この記事では「Linuxから目覚めるぼくらのゲームボーイ!」のステップをRustで実施するための準備としてライフゲームが動くまでを書いてみます。
動機は今作っているWasmインタープリタをGBAで動かすことができないかの調査です。(たとえLチカレベルでも)AssemblyScriptとかでGBAのゲームかけたら面白くないですか。
成果物
I succeeded to run Conway's Game of Life written in Rust on GameBoyAdvance. #rustlang https://t.co/A7rOJg3SwV pic.twitter.com/KEiokbDCI7
— bokuweb (@bokuweb17) April 5, 2020
環境のセットアップ
RustはARMに対応しています。しかし、Coretexシリーズなど比較的新しめのアーキテクチャのみの対応となっており、古いARM7TDMIには対応していません。
幸いなことにLLVMはこのCPUに対応しているので直接LLVMにターゲット設定してやることでARM7TDMIに対応したバイナリを出力することが可能となります。
With Docker
Dockerイメージは用意したのでmakefileを書いておけば以下のように使えます
$ docker run --rm -v `pwd`:/code bokuweb/rust-gba make
Without Docker
Rust
Rust現時点での最新のnightlyを使用しています。組み込みでRustを使用する場合原則nightlyとなるようです。
rust version 1.44.0-nightly (94d346360 2020-04-09)
また、クロスビルド用にcargo-xbuildもインストールします。
$ rustup component add rust-src $ cargo install cargo-xbuild
Toolchain
ARM7TDMI用のToolchainを用意します。ターゲットはarm-none-eabi。
$ wget http://ftp.gnu.org/gnu/binutils/binutils-2.27.tar.gz $ tar -zxvf binutils-2.27.tar.gz $ cd binutils-2.27 $ ./configure --target=arm-none-eabi $ sudo make $ sudo make install
Hello World!
画面にdotを打つのがGBAプログラミングにおけるHello Worldのようです。
crt0.S
ではmain.rsから書けるかと言うとそうではありません。まずはmain関数を呼ぶためのスタートアップルーチンをアセンブラで書く必要があります。
といっても最低限でよければ以下を書くだけです。mainを呼び出しているだけです。割り込みを使用する際にはここでいくつか設定が必要になると思いますがとりあえずは不要です。詳しくは「Linuxから目覚めるぼくらのゲームボーイ!」に記載があります。
.arm __start: ldr r0, =main bx r0
linker.ld
今回のようなケースでは、どのようなメモリ構成になっていて、どこにどのセクションをロードするのか。を教えてあげる必要があります。そのためにリンカースクリプトを用意します。
ENTRY(__start)
MEMORY {
boot (w!x) : ORIGIN = 0x2000000, LENGTH = 256K
wram (w!x) : ORIGIN = 0x3000000, LENGTH = 32K
rom (rx) : ORIGIN = 0x8000000, LENGTH = 32M
}
SECTIONS {
.text : {
KEEP(target/crt0.o(.text));
*(.text .text.*);
. = ALIGN(4);
} >rom = 0xff
}
「プログラムを内蔵ROM(0x8000000)に配置する」よう記述しています
main.rs
ようやくmain.rsにとりかかります。以下は液晶ディスプレイの中央に青いドットを一つ表示するサンプルです。
#![no_std] #![feature(start)] #[panic_handler] fn panic(_info: &core::panic::PanicInfo) -> ! { loop {} } #[start] fn main(_argc: isize, _argv: *const *const u8) -> isize { unsafe { (0x400_0000 as *mut u16).write_volatile(0x0403); // BG2 Mode 3 (0x600_0000 as *mut u16) .offset((80 * 240 + 120) as isize) .write_volatile(0x001F); } 0 }
#![no_std]
Rustの標準ライブラリはOSの機能を利用しているため今回のようなベアメタルなケースでは使用できません。そのため#![no_std]をつけてstdクレートではなくcoreクレートをリンクするよう示します。(アロケータも使用できなくなるため、この時点ではVecやStringも使用不可能となります。)
#[panic_handler]
#[panic_handler]アトリビュートにて、パニック発生時の動作を定義しています
main
(0x400_0000 as *mut u16).write_volatile(0x0403); // BG2 Mode 3
0x400_0000番地はLCD制御を行うレジスタで0x0403を書き込むことで複数枚ある背景のなかから2枚目、描画モード3を指定しています。
GBAでもファミコンやゲームボーイのように8x8のスプライトを敷き詰めてゲームを描画するのが基本的な使用方法になるようなのですが、ピクセル単位で描画することができるモードがあり、モード3はそのうちの一つです。
スプライトの用意や設定をせずに直接描画できるので楽です。
write_volatileは最適化により省略されたり並べ替えられたりしないことを保証するメソッドです。
例えば組み込みなどでは「レジスタのあるビットが変化するまでloopの中で待ち続ける」といった処理を書くことがあるかと思いますが、その場合volatileがないと同じ番地にリードを繰り返す無意味な処理とみなされ省略されるケースがあると思います。xxxx_volatileを使用することでこれを防げるという認識です。
(0x600_0000 as *mut u16) .offset((80 * 240 + 120) as isize) .write_volatile(0x001F);
0x0600_0000はVRAMで80 * 240 + 120にオフセットすることで真ん中にドットを打つことができますできます。これは表示領域が240 * 160のためです。
ここでは0x001Fを書き込んでいますが、これは赤色になります。GBAでこのモードの場合各ドット色は15bitで表現されます。bit0~4が赤、bit5~9が緑、bit10~14が青となっています。そのためこのサンプルは赤いドットになります。
arm-none-eabi.json
前述したようにARM7TDMI用のバイナリを吐くにはLLVMに対してターゲットを指定してやる必要があります
{ "abi-blacklist": [ "stdcall", "fastcall", "vectorcall", "thiscall", "win64", "sysv64" ], "arch": "arm", "cpu": "arm7tdmi", "data-layout": "e-m:e-p:32:32-i64:64-v128:64:128-a:0:32-n32-S64", "executables": true, "linker": "arm-none-eabi-ld", "linker-flavor": "ld", "linker-is-gnu": true, "llvm-target": "thumbv4-none-eabi", "os": "none", "panic-strategy": "abort", "pre-link-args-crt": { "ld": ["crt0.o"] }, "pre-link-args": { "ld": ["-Tlinker.ld"] }, "relocation-model": "static", "target-c-int-width": "32", "target-endian": "little", "target-pointer-width": "32" }
llvm-targetがthumbv4-none-eabiを指定したりlinkerにarm-none-eabi-ldに指定し、pre-link-args-crtでcrt0.oをpre-links-argsでlinker.ldを指定することでGBA
用のバイナリを吐くようになります。
このjsonはいくつかのサンプルを参考にしており、その際data-layoutについて、初めて見たのでびっくりしたのですが、以下を見ると読み解くことができそうです。
どうやらターゲットのエンディアンがリトルエンディアンであることやポインタを32bitアライメントでレイアウトすること、64bit整数を64bitアライメントでレイアウトする等指定しているようです。
makefile
自分は以下のようなmakefileを用意しました。
arm-none-eabi-asでcrt0.Sからcrt.oを作成しtarget配下に格納。
cargo xbuildでビルドした後、arm-none-eabi-objcopyでelfをbinに変換しています。
build: mkdir -p target arm-none-eabi-as src/crt0.S -o target/crt0.o cargo xbuild --target arm-none-eabi.json --release arm-none-eabi-objcopy -O binary target/arm-none-eabi/release/lifegameboy game.gba
ビルド
ここまででようやくビルドできそうです。
$ docker run --rm -v `pwd`:/code bokuweb/rust-gba make
でうまくいけばgame.gbaが生成されると思います。
実行
エミュレータで動作させてみます。自分はvisualboyadvance-mを使用しました。
$ visualboyadvance-m game.gba
うまく行けば以下のように赤いドットが表示されると思います。これでようやくHello World!が終了です。

Conway's Game of Life
もう少し動きのあるものを動かしてみたいので以前Wasmの勉強用に作ったライフゲームを移植してみたいと思います。
Allocator
前述したように現時点ではVecやStringが使用できません。が、上記のサンプルはVecを使用しています。組み込みなどでは動的メモリ確保を禁止するルールなども多く、Vecを利用しないよう書き換えることも可能ですがせっかくなのでVecを使えるようにします。
linnker.ldの修正
linker.ldを修正し、data,bssのセクションを追加し、各開始と終了アドレス、またwramの終了アドレスを取れるようにしました。
SECTIONS {
.text : {
KEEP(target/crt0.o(.text));
*(.text .text.*);
. = ALIGN(4);
} >rom = 0xff
.rodata : {
*(.rodata .rodata.*);
. = ALIGN(4);
} >rom = 0xff
.data : {
__data_start = ABSOLUTE(.);
*(.data .data.*);
. = ALIGN(4);
__data_end = ABSOLUTE(.);
} >wram AT>rom = 0xff
.bss : {
__bss_start = ABSOLUTE(.);
*(.bss .bss.*);
. = ALIGN(4);
__bss_end = ABSOLUTE(.);
} >wram
__sidata = LOADADDR(.data);
__wram_end = ORIGIN(wram) + LENGTH(wram) -1 ;
}
main.rsの修正
main.rsを修正します。まずはMutexとHeapを作成します。これはCoretex-M用のアロケータであるrust-embedded/alloc-cortex-mから移植してきています。
Heap / Mutexの作成
pub struct Heap { heap: Mutex<linked_list_allocator::Heap>, } impl Heap { pub const fn empty() -> Heap { Heap { heap: Mutex::new(linked_list_allocator::Heap::empty()), } } pub unsafe fn init(&self, start_addr: usize, size: usize) { self.heap.lock(|heap| heap.init(start_addr, size)); } } pub struct Mutex<T> { inner: UnsafeCell<T>, } impl<T> Mutex<T> { pub const fn new(value: T) -> Self { Mutex { inner: UnsafeCell::new(value), } } } impl<T> Mutex<T> { pub fn lock<F, R>(&self, f: F) -> R where F: FnOnce(&mut T) -> R, { unsafe { let ie = core::ptr::read_volatile(REG_IE as *const u16); (REG_IE as *mut u16).write_volatile(0x0000); let ret = f(&mut *self.inner.get()); (REG_IE as *mut u16).write_volatile(ie); ret } } } unsafe impl<T> Sync for Mutex<T> {}
rust-embedded/alloc-cortex-mの中身を見たところ、linked_list_allocatorクレートのHeapをMutexで包んだものを使用していました。no_stdなのでMutexも用意する必要がありますが、リソース取得前に割り込みを禁止、取得後に割り込みを再設定をしてやれば良さそうです。以下の箇所が該当の箇所です。
let ie = core::ptr::read_volatile(REG_IE as *const u16); (REG_IE as *mut u16).write_volatile(0x0000); let ret = f(&mut *self.inner.get()); (REG_IE as *mut u16).write_volatile(ie);
global_allocatorの設定
上記で作成したHeapをglobal_allocatorとして設定し初期化することでヒープが使用できるようになりVecが使用可能となります。
#[global_allocator] static ALLOCATOR: Heap = Heap::empty(); unsafe impl GlobalAlloc for Heap { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { self.heap .lock(|heap| heap.allocate_first_fit(layout)) .ok() .map_or(0 as *mut u8, |allocation| allocation.as_ptr()) } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { self.heap.lock(|heap| heap.deallocate(NonNull::new_unchecked(ptr), layout)); } }
Allocatorの初期化
以下がヒープの初期化コードです。liner.ldの設定を参照し、ヒープの開始アドレスとサイズを求め設定しています。
extern "C" { static mut __bss_start: u8; static mut __bss_end: u8; static mut __data_start: u8; static mut __data_end: u8; static __sidata: u8; static __wram_end: u8; } fn init_heap() { unsafe { let heap_start = &__bss_end as *const u8 as usize; let heap_end = &__wram_end as *const u8 as usize; let heap_size = heap_end - heap_start; ALLOCATOR.init(heap_start, heap_size); } }
Vecを使用したmain
あとはinit_heapを呼べば、ヒープを使用できるようになります。
#[macro_use] extern crate alloc; #[start] fn main(_argc: isize, _argv: *const *const u8) -> isize { init_heap(); let mut v = vec!(0); v.push(1); 0 }
ここで以下のようなリンクエラーがでてハマっていたのですが、lto = trueにすることで成功するようになりました。詳細は不明なのですが、リンクする必要のないものを探しにいき失敗していたところ、最適化により回避できるようになったように見えます。
error: linking with `arm-none-eabi-ld` failed: exit code: 1 | = note: "arm-none-eabi-ld" "-Tlinker.ld" "-L" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/sysroot/lib/rustlib/arm-none-eabi/lib" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/arm-none-eabi/release/deps/gba_sandbox-6d2b8af1d4d70cc2.gba_sandbox.dymocybm-cgu.0.rcgu.o" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/arm-none-eabi/release/deps/gba_sandbox-6d2b8af1d4d70cc2.gba_sandbox.dymocybm-cgu.1.rcgu.o" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/arm-none-eabi/release/deps/gba_sandbox-6d2b8af1d4d70cc2.gba_sandbox.dymocybm-cgu.2.rcgu.o" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/arm-none-eabi/release/deps/gba_sandbox-6d2b8af1d4d70cc2.gba_sandbox.dymocybm-cgu.3.rcgu.o" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/arm-none-eabi/release/deps/gba_sandbox-6d2b8af1d4d70cc2.gba_sandbox.dymocybm-cgu.4.rcgu.o" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/arm-none-eabi/release/deps/gba_sandbox-6d2b8af1d4d70cc2.gba_sandbox.dymocybm-cgu.5.rcgu.o" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/arm-none-eabi/release/deps/gba_sandbox-6d2b8af1d4d70cc2.gba_sandbox.dymocybm-cgu.6.rcgu.o" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/arm-none-eabi/release/deps/gba_sandbox-6d2b8af1d4d70cc2.gba_sandbox.dymocybm-cgu.7.rcgu.o" "-o" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/arm-none-eabi/release/deps/gba_sandbox-6d2b8af1d4d70cc2" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/arm-none-eabi/release/deps/gba_sandbox-6d2b8af1d4d70cc2.ivk3z60kvi15yiz.rcgu.o" "--gc-sections" "-O1" "-L" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/arm-none-eabi/release/deps" "-L" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/release/deps" "-L" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/sysroot/lib/rustlib/arm-none-eabi/lib" "-Bstatic" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/arm-none-eabi/release/deps/liblinked_list_allocator-50c4d42fee3618a7.rlib" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/sysroot/lib/rustlib/arm-none-eabi/lib/liballoc-65305f99407f1a06.rlib" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/sysroot/lib/rustlib/arm-none-eabi/lib/librustc_std_workspace_core-3a5b75d78f842520.rlib" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/sysroot/lib/rustlib/arm-none-eabi/lib/libcore-f024f2af9aa61b13.rlib" "/home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/sysroot/lib/rustlib/arm-none-eabi/lib/libcompiler_builtins-fc44ec67faac1c70.rlib" "-Bdynamic" = note: arm-none-eabi-ld: warning: cannot find entry symbol __start; defaulting to 0000000008000000 /home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/arm-none-eabi/release/deps/gba_sandbox-6d2b8af1d4d70cc2.ivk3z60kvi15yiz.rcgu.o:(.ARM.exidx.text.__rust_alloc+0x0): undefined reference to `__aeabi_unwind_cpp_pr0' /home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/arm-none-eabi/release/deps/gba_sandbox-6d2b8af1d4d70cc2.ivk3z60kvi15yiz.rcgu.o:(.ARM.exidx.text.__rust_dealloc+0x0): undefined reference to `__aeabi_unwind_cpp_pr0' /home/bokuweb/ghq/github.com/bokuweb/gba-sandbox/target/arm-none-eabi/release/deps/gba_sandbox-6d2b8af1d4d70cc2.ivk3z60kvi15yiz.rcgu.o:(.ARM.exidx.text.__rust_realloc+0x0): undefined reference to `__aeabi_unwind_cpp_pr0' error: aborting due to previous error
ゲームの描画
今回は4x4を1lifeとして作成しました。当初1pixel/lifeで実装しようとしたのですが、あっという間にヒープが枯渇したためです。Working RAM領域は32KiBなので当たり前感がありますね。メモリを節約するよう書き換えれば1pixel/lifeでもいけそうでしたが、あまりゲーム側に手を加えたくなかったので今回はこのようにしています。
wait_for_vsyncはVBlankまでブロックし待つ関数です。VRAMを更新するのは基本的にはVBlankの間に行うことになっています。VBlankというのは描画を行っていない期間です。これは描画中にVRAMを変更すると意図しない描画になる可能性があるためです。
VBlank中であるかどうかは割り込みにて知ることも可能ですが、REG_VCOUNTすなわち0x0400_0006をリードすることで知ることもできます。REG_VCOUNTを読むことで現在描画中のy座標を知ることができるのでこの値が160以上であればVBlankということになります。
あとは特に特筆することはなく、lifeを計算し描画していくだけなので割愛します。
static REG_VCOUNT: usize = 0x0400_0006; fn wait_for_vsync() { unsafe { while core::ptr::read_volatile(REG_VCOUNT as *const u32) >= 160 {} while core::ptr::read_volatile(REG_VCOUNT as *const u32) < 160 {} } } #[start] fn main(_argc: isize, _argv: *const *const u8) -> isize { // ... ommited ... init_heap(); let mut game: Game = Game::new(40, 60); unsafe { (0x400_0000 as *mut u16).write_volatile(0x0403); // BG2 Mode 3 loop { let field = game.next(); wait_for_vsync(); for (i, cell) in field.iter().enumerate() { let col = i % 60; let row = i / 60; let color = if *cell { 0x7FFF } else { 0x0000 }; for j in 0..4 { (0x600_0000 as *mut u16) .offset(((row * 4 + j) * 240 + col * 4) as isize) .write_volatile(color); (0x600_0000 as *mut u16) .offset(((row * 4 + j) * 240 + col * 4 + 1) as isize) .write_volatile(color); (0x600_0000 as *mut u16) .offset(((row * 4 + j) * 240 + col * 4 + 2) as isize) .write_volatile(color); (0x600_0000 as *mut u16) .offset(((row * 4 + j) * 240 + col * 4 + 3) as isize) .write_volatile(color); } } } } }
正しく動作すれば以下のようにライフゲームが動作します。

実機への転送
「Linuxから目覚めるぼくらのゲームボーイ!」にはブートケーブルが付属しており、これを使うことで実機で動作させることが可能になります。
linker.ldの修正
ブートケーブル経由で動作させる場合リンカースクリプトの修正が必要になります。これはブートモード時はROM領域ではなく0x0200_0000の外部RAM領域から起動するためです。
romの箇所をbootに変更します。
SECTIONS {
.text : {
KEEP(target/crt0.o(.text));
*(.text .text.*);
. = ALIGN(4);
} >boot = 0xff
.rodata : {
*(.rodata .rodata.*);
. = ALIGN(4);
} >boot = 0xff
.data : {
__data_start = ABSOLUTE(.);
*(.data .data.*);
. = ALIGN(4);
__data_end = ABSOLUTE(.);
} >wram AT>boot = 0xff
.bss : {
__bss_start = ABSOLUTE(.);
*(.bss .bss.*);
. = ALIGN(4);
__bss_end = ABSOLUTE(.);
} >wram
__sidata = LOADADDR(.data);
__wram_end = ORIGIN(wram) + LENGTH(wram) -1 ;
}
optusbのセットアップ
ブートケーブル経由で転送するためにoptusbというツールを使います。optusbに必要なlibusbをセットアップするのですが、optusbが使用しているAPIが古いためlibusb-compatが必要となります。macであればbrewで入るようです。
ちなみに記事ではUbuntu19.10、ThinkPad X1 Carbon gen 6thで試しています。
$ wget http://downloads.sourceforge.net/libusb/libusb-compat-0.1.5.tar.bz2 $ tar -jxvf libusb-compat-0.1.5.tar.bz2 $ sudo make $ sudo make install $ sudo mount --bind /dev/bus /proc/bus $ sudo ln -s /sys/kernel/debug/usb/devices /proc/bus/usb/devices
次にoptusbのコードをダウンロード、展開します。
$ wget http://www.skyfree.org/jpn/unixuser/optusb-1.01.tar.gz $ tar -zxvf optusb-1.01.tar.gz
その後Makefileを書き換えビルドすることでセットアップは完了です。
optusb: optusb.c usb.h libusb.a gcc -I `brew --prefix libusb-compat`/include/usb.h -L `brew --prefix libusb-compat`/lib -lusb -Wall -o optusb optusb.c clean: rm -rf optusb *.o
make
以下のエントリが参考になりました。
データの転送
本体の電源を投入後、ケーブルを接続、以下のコマンドで転送ができます。
sudo optusb/optusb game.gba
動作すれば冒頭のtweetように動作します。
I succeeded to run Conway's Game of Life written in Rust on GameBoyAdvance. #rustlang https://t.co/A7rOJg3SwV pic.twitter.com/KEiokbDCI7
— bokuweb (@bokuweb17) April 5, 2020
結果
動機であったWasmインタプリタを動かせるか、ですが、インタプリタ側をno_stdに対応させれば動くのではないかと思っていますが、あとはメモリ容量との勝負になりそう。できたとしてもドットをちょっと動かす程度のものにはなると思いますが、もし成功したらまた記事にします。
Rustで書いたWebAssemblyインタプリタ上でGoで書いたゲームボーイエミュレータを動かした
概要
最近はWebAssemblyに興味があり、勉強していたんだけど仕様を読み始めても頭に入らないのでインタプリタを作ってみることにした。よくわからないものは作ってみるのが一番よい。
まだ残された課題は多いのだけれども、一つ目標にしていた「Goで書いたゲームボーイエミュレータを動かす」を達成できたのでここに書いておく。
うおーついにRustで書いた自作wasm interpreter上でgoで書いた自作gameboy emulatorが動いたー!けど1FPSくらいしかでない... pic.twitter.com/I5B2XL5E0W
— bokuweb (@bokuweb17) 2020年2月24日
こツイートに貼られているのは残念ながら、静止画ではなく、動画でありパフォーマンスが悲しいことになっていることを示している。あまりに遅くてプレイ画面まで到達できない。今後これは改善していきたい。
Goでゲームボーイエミュレータを書いた話は以下の記事を参照のこと。
インタプリタを作る
仕様書を読みながら実装していくのだけど、日本語でさくっと全体を知るのに以下のリポジトリがとても参考になった。
Hello wasm
開発は最小限のバイナリを実行できるようにし、そこに命令を足していく方針で進めた。これにはWebAssembly Studioがとても役立った
これでEmpty Wat Projectを開くとプロジェクトが作成されいくつかのファイルが生成される。この中のmain.watを書き換えて実行したり、バイナリを覗いたりすることができる。*.watというのはWebAssemblyのテキストフォーマットでWebAssembly/wabtで*.wasmに変換したり、ランタイムの一つであるwasmtimeで実行できたりする。
例えば一番シンプルな42を返すだけの関数を定義してみるとこうなる。
- main.wat
(module (func $hello (result i32) i32.const 42) (export "hello" (func $hello)) )
呼び出し側のJSは以下のようになる
fetch('../out/main.wasm').then(response => response.arrayBuffer() ).then(bytes => WebAssembly.instantiate(bytes)).then(results => { instance = results.instance; document.getElementById("container").textContent = instance.exports.hello(); }).catch(console.error);
これを実行すると42が返ってくることがわかる。
インタプリタを作る上で便利なのはBinary Explorerという機能でプロジェクトの成果物である*.wasmを解析し表示してくれる。例えば今回のサンプルであれば以下のようなものが表示される。mousehoverで詳細も表示してくれる。

先頭の橙色はマジックナンバー。重要なのは(このサンプルでは)2Byteの赤色の箇所、具体的には01 05のような箇所だ。これはセクション番号とそのサイズを表している。
セクションの番号と概要は以下のようなになっている
| 番号 | セクション名 | 概要 |
|---|---|---|
| 0x1 | Type | 関数シグネチャー宣言 |
| 0x2 | Import | インポート宣言 |
| 0x3 | Function | 関数宣言 |
| 0x4 | Table | テーブル宣言 |
| 0x5 | Memory | メモリー属性 |
| 0x6 | Global | グローバル宣言 |
| 0x7 | Export | エクスポート |
| 0x8 | Start | 開始関数宣言 |
| 0x9 | Element | 要素セクション |
| 0xA | Code | 関数実体 |
| 0xB | Data | データセグメント |
0x00はカスタムセクションだが今回は不要なため無視するとして、0x01 Typeセクション、0x03 Functionセクション、0x07 Exportセクション、0x0A Codeセクションが含まれていることがわかる。
ここでは端折って0x07 Exportセクション、0x0A Codeセクションについて見ていく。
0x07 Exportセクション
Exportは以下のようなデータで表現されており、先頭の0x07はセクション番号、次の0x09はセクションのデータサイズを示す。

紫部分の先頭の0x01はExportするエントリ数。今回はhelloという関数のみをExportしているので0x01。次の0x05はExport名の長さを示す。

Export名の長さが0x05であることがわかったので、次の5Byte0x68 0x65 0x6c 0x6c 0x6fを見るとこれがhelloであることがわかる。

末尾の0x00 0x00だが、前者はExport種別を表す。今回は関数をExportしているので0x00だが、MemoryやTableなどもExportできる。
最後尾はインデックスで今回の場合はモジュールが持っている関数一つの中から先頭の関数をExportしているので0x00になる。

詳細は省いているが、Rustで書くと以下のようになった。
impl Decoder for ExportSection { type Error = DecodeError; fn decode<R: Read>(reader: &mut R) -> Result<Self, Self::Error> { let count: u32 = VarUint32::decode(reader)?.into(); let mut entries: Vec<ExportEntry> = vec![]; for _ in 0..count { let field_len = VarUint32::decode(reader)?.into(); let name = read_bytes(reader, field_len)?; let name = String::from_utf8(name)?; let kind = read_next(reader)?; let kind = ExternalKind::from_u8(kind)?; let index: u32 = VarUint32::decode(reader)?.into(); entries.push(ExportEntry { name, kind, index }); } Ok(ExportSection { count, entries }) } }
LEB128
順番が前後するが、例えばExport数を表すcountや最後尾のExportするエントリのインデックスの型はなにかと言うとu8やu32ではなくvaruint32になっている。これはLEB128という可変長の整数エンコード形式で表現された型で、値の大きさと表現にバイト数が比例する。
具体的にはMSBがデータの継続を表すフラグとして使用されており、実データ部は8bitのうち7bitになる。つまり100であれば1byteで表現できるし、255であれば表現に2byte必要になる。u32において4byteで表現されていたデータを表現するのに5byte必要になることもあるが、多くの場合で固定で4byte確保するよりバイナリを小さくできる。のだと思う。
もともとはデバッグ用ファイルフォーマットのDWARFのために設計されたものらしい。知らなかった。
もし、wasmファイルをごにょごにょするものを作るなら早い段階でLEB128を読めるようにする必要がある。
実装は愚直にMSBをチェックしながら結合していく実装になっている。より効率的な読み方があったら教えて下さい。。。
impl Decoder for VarUint32 { type Error = DecodeError; fn decode<R: io::Read>(reader: &mut R) -> Result<Self, Self::Error> { let mut value = 0; let mut i = 0; loop { let b = u32::from(read_next(reader)?); value |= (b & 0x7f) .checked_shl(i * 7) .ok_or(DecodeError::InvalidVarUint32Error)?; i += 1; if i > 5 { return Err(DecodeError::InvalidVarUint32Error); } if b & 0x80 == 0 { if i == 5 && b >= 0x10 { return Err(DecodeError::InvalidVarUint32Error); } break; } } Ok(VarUint32(value)) } }
Exportはこれで完了で、つまり外部からhelloが呼ばれたら関数インデックス0の関数を実行すればいいということがわかる。
0x0A Codeセクション
じゃあ関数がどのようになっているかとCodeセクションを見れば良い。
先頭の0x0A 0x06はExportのときと同様セクション番号とサイズだ。

0x01 0x04 0x00の0x01はエントリ数、0x04はエントリのBodyサイズ、0x00はローカル変数の数を表す。

0x41 0x2A 0x0Bはいよいよバイトコード部、すなわちi32.const 42に該当する部分になる。0x41はi32.const、0x2Aは42を表しており、0x0Bはendを表している。

ちょっと複雑だけれども、やっていることはExportセクションのデコードと変わらず愚直に読んでいくことになると思う。
impl Decoder for CodeSection { type Error = DecodeError; fn decode<R: Read>(reader: &mut R) -> Result<Self, Self::Error> { let count: u32 = VarUint32::decode(reader)?.into(); let mut bodies: Vec<FunctionBody> = vec![]; for _ in 0..count { let body_size: usize = VarUint32::decode(reader)?.into(); let mut body = Cursor::new(read_bytes(reader, body_size)?); let local_count: u32 = VarUint32::decode(&mut body)?.into(); let mut locals: Vec<LocalEntry> = vec![]; for _ in 0..local_count { let count = VarUint32::decode(&mut body)?.into(); let value_type = ValueType::from_u8(read_next(&mut body)?) .ok_or(DecodeError::InvalidValueTypeError)?; locals.push(LocalEntry { count, value_type }); } let mut code: Vec<u8> = vec![]; body.read_to_end(&mut code)?; code.pop(); bodies.push(FunctionBody { locals, decoded: decode_function_body(&code)?, }) } Ok(CodeSection { count, bodies }) } }
今回の例で取り扱う命令はi32.constのみだが、他にどれくらいあるかと言うとMVPで以下くらい、これからBulkMemoryとかSIMDとかの命令に対応していくことになると思う
実行
ここまでで外部からhelloが呼ばれたときに0x41 0x2A 0x0B(i32.const 42)を実行すれば、なんとかなりそうなのがわかった。
かなり簡素化して書くと以下のようなイメージ。
// (1) 名前を指定して関数を呼び出す pub fn invoke(&self, name: &str) -> Result<Vec<RuntimeValue>, YawError> { // (2) 名前から関数indexを解決する let index = self.exports.resolve(name)?; // (3) indexからバイトコードを引いてくる let func = self.functions.get_ref(index as usize)?; let mut stack = vec![]; // (4) バイトコードを読んで命令を判別 let opcode = get_op(&func.code); let arg = get_arg(&func.code); match opcode { // (5) 実行 Opcode::I32const => { stack.push(arg); } _ => { ... } } Ok(stack) }
wasmの実行はスタックマシンとして定義されているので、値用のスタック(この例ではstackという変数)を用意し、そこに値を出し入れするようにしている。i32.constはもらった値をスタックに積むだけの命令なのでstack.push(arg)して終了。stackに残った値(この場合42)が返り値となる。
かなり端折ったけど、ミニマムなものはだいたい、これくらいでできると思う。あとは命令やセクションを拡充しながらテストを通していくことになると思う。
テスト
wasmにはtestsuiteが用意されているのでそれを使用するのがよい。
ここでは以下のようなテストが定義されており、これを実行していくことでテストができる。
(assert_return (invoke "add" (i32.const 1) (i32.const 1)) (i32.const 2))
assert_returnなどはテスト用のテキスト表現でwasm自体のこの、命令が備わっているわけではない。のでテストを実行するのはこのテキストフォーマットもparseして実行してやる必要がある。
そのあたりを自分で実装するのはさすがに嫌だったので今回はwabtのRustバインディングを使った。これを使うと以下のようにテストが書ける。
let s = String::from_utf8(buf).unwrap(); let mut parser = ScriptParser::from_str(&s).unwrap(); while let Some(Command { kind, .. }) = parser.next().unwrap() { match kind { CommandKind::AssertReturn { action, expected } => { if let Action::Invoke { field, args, module, .. } = action { ... omitted ... let ret = ins.invoke(&field.to_string(), &args)?; match ret[ret.len() - 1] { RuntimeValue::I32(v) => assert_eq!(expected, vec![Value::I32(v)]), _ => { ... omitted ...} } } } _ => {} } }
テストが実行できるようになったらあとはテストが落ちたところを直していけばよい。ただ、このテストを走らすこと自体結構たいへんだったので、もしこの記事を見て試してみる人がいたら序盤からテストの実行について考慮することをおすすめしたい。
ゲームボーイエミュレータ
テストが通るようになり、Goで書いたゲームボーイエミュレータを動かそうと試みた。方針としてはGo側には手を加えずブラウザで動くwasmファイルをそのまま動かそう、という方針。
これは今からGo側を変更するのが面倒だったし、手を加えたことで、問題が発生した場合の切り分けをしたくなかったからなんだけど、これはこれであまりいい方針ではなかったような気がしてる。。。
wasmを吐く場合、たとえばGoから以下のようにブラウザのwindowに対して値をセットできる。
js.Global().Get("window").Set("hello", "world")
これを実行するとwindowに値がセットされていることが確認できる。
console.log(window.hello) // world
wasm-JS間は数値でしかやり取りできないのにこのようなことができるのか調べる必要があった。これはグルーコードとして提供されているwasm_exec.jsを見るとだいたい分かる。
JS側では以下のように値を保持しておき、配列のindexをリニアメモリ経由で指定することで値のやりとりをしているよう。
this._values = [ NaN, 0, null, true, false, global, this ];
js.Global().Get("window").Set("hello", "world")を例にとると、まずsyscall/js.stringValを呼んで"world"をthis._valuesに登録する。
簡素化すると以下のようになっていて、"world"をthis._valuesに登録したあとthis._valuesの最後尾のindexをメモリに書き戻しているためGo側で"world"がthis._values格納されたindexを知ることができる。
go: { "syscall/js.stringVal": sp => { storeValue(sp + 24, loadString(sp + 8)); // "world" }, } const storeValue = (addr, v) => { this._values.push(v); // ... ommitted ... mem().setUint32(addr, this._values.length - 1, true); };
次に、リニアメモリに"hello"を書き込みつつ、先に得られた"world"格納先のindexとwindowが格納されたindexをリニアメモリに書き込みyscall/js.valueSetを呼び出す。
そうするとRefrect.setによりwindow.helloに"world"が代入されるという仕組みだ。
"syscall/js.valueSet": sp => { Reflect.set( loadValue(sp + 8), // window loadString(sp + 16), // "hello" loadValue(sp + 32) // "world" ); }
これは一番簡単な例だが、関数呼び出しなどもこの応用でできている。
あとは、Rustでなんとかwasm_exec.jsに擬態することにした。
うすうす分かってはいたがwasm_exec.jsはReflectを多用しており、Rustとの相性はよくない。あまりいい方針ではなかったと前述したのはこれが理由。
かなりアドホックでひどい作りな自覚があるのだけれども、なんとか擬態することはでき、syscall/js.copyBytesToJS経由でVRAMの内容を受け取りSDL2で描画することに成功した。たとえば"syscall/js.valueSet"は以下のようになった。(ひどい。)
"syscall/js.valueSet" => { let sp: u32 = args[0].into(); let _value = self.load_value(sp + 8, &self.values.borrow())?; let name: &str = &self.load_string(sp + 16).unwrap(); let value = &self.load_value(sp + 32, &self.values.borrow())?; match name { "GB" => { if let BridgeValue::WrappedFunc(f) = value.clone() { *self.gb.borrow_mut() = Some(f); } } "result" => {} "next" => { if let BridgeValue::WrappedFunc(f) = value.clone() { *self.next.borrow_mut() = Some(f); } } _ => {} } Ok(vec![]) }
全体が気になる方はこちら
他のランタイムはどうしているのかと見てみたら、wasmerは専用のGoライブラリを用意しているようだった。正しい。。。。
パフォーマンスはまだひどいけど、ひとまず目標の一つであるゲームボーイエミュレータの起動は達成できた。
これから
まずはWASIに対応したい。WASIに対応すればpythonが動かせるはずなのでこれはこれで面白い。
合わせてパフォーマンスの改善。インタプリタではかなり速いと言われるwasm3の実装を見ていいところを取り込みたい。
また、[no_std]に対応してベアメタル、強いて言うならARM7で動かしたい。JITやAOTの機構の検討もしたいが、ベアメタルでの動作を考慮にいれるとこのリポジトリでは採用することはなさそう。
今回実装した機能はMVPで、あとにはGCやThreadなども控えているので勉強の題材としてはよさそう。以上。
AWS CDKで Rust + AppSyncの構成をつくるメモ
構成としては認証をCognito UserPoolで行い、AppSyncからLambdaを呼び出してJSONを返す構成とする。
UserPool
UserPoolを用意する。this.node.tryGetContextでコンテキストが渡せるのでここに環境名、例えばprodなどを与えてSuffixとするようにした。
import * as path from "path"; import * as cdk from "@aws-cdk/core"; import * as cognito from "@aws-cdk/aws-cognito"; export class MyStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const env = this.node.tryGetContext("env"); const userPool = new cognito.UserPool(this, "MyUserPool" + env, { userPoolName: "MyUserPool-" + env }); } }
cognito.UserPoolはHigh-level constructsというものっぽくて細かい設定が隠蔽されてて触れない気がする。
正しいかわからないけど
const userPoolCfn = userPool.node.defaultChild as cognito.CfnUserPool;
とすると細かい設定ができるように見える
Lambda
Lambdaとロールを作る。ひとまずCloudWatchLogsのみアクセス可能とする。必要に応じてDynamoやRDS、S3への権限をつける。
Rustの場合はruntimeはlambda.Runtime.PROVIDEDとする。PROVIDEDの場合handlerの設定は無視されるっぽい。
lambda.Code.fromAssetにバイナリのパスを設定しておくとcdk deployでバイナリをS3にあげてセットしてくれる。
import * as path from "path"; import * as cdk from "@aws-cdk/core"; import * as appsync from "@aws-cdk/aws-appsync"; import * as cognito from "@aws-cdk/aws-cognito"; import * as lambda from "@aws-cdk/aws-lambda"; import * as iam from "@aws-cdk/aws-iam"; import { definition } from "./schema"; export class MyStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { ...省略... const lambdaRole = new iam.Role(this, "MyLambdaRole", { assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"), path: "/service-role/", inlinePolicies: { CloudWatchWritePolicy: new iam.PolicyDocument({ statements: [ new iam.PolicyStatement({ actions: [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ], resources: ["*"] }) ] }) } }); const MyLambda = new lambda.Function(this, "MyLambda", { functionName: `my-function-${env}`, runtime: lambda.Runtime.PROVIDED, handler: "index.handler", code: lambda.Code.fromAsset( path.join( __dirname, "../../target/x86_64-unknown-linux-musl/release" ) ), role: lambdaRole }); } }
AppSync
AppSync作成時にauthenticationType: "AMAZON_COGNITO_USER_POOLS"にしてUserPoolIdを渡す。
new appsync.CfnGraphQLSchemaにSchemaを渡す。
schemaは別ファイルに以下のように書いといてこれを読ます。
export const definition = ` type Hello { world: String! } type Query { getHello: Hello! } type Schema { query: Query } `;
あとはDataSourceとResolverを追加する。 DataSource用のRoleにはLambda呼び出し権限を忘れないように。
import * as path from "path"; import * as cdk from "@aws-cdk/core"; import * as appsync from "@aws-cdk/aws-appsync"; import * as cognito from "@aws-cdk/aws-cognito"; import * as lambda from "@aws-cdk/aws-lambda"; import * as iam from "@aws-cdk/aws-iam"; import { definition } from "./schema"; export class MyStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { ...省略... const gql = new appsync.CfnGraphQLApi(this, "AppSyncAPI", { name: `MyGQL-${env}`, authenticationType: "AMAZON_COGNITO_USER_POOLS", userPoolConfig: { awsRegion: "ap-northeast-1", defaultAction: "ALLOW", userPoolId: userPool.userPoolId } }); const schema = new appsync.CfnGraphQLSchema(this, "GqlSchema", { apiId: gql.attrApiId, definition }); const dataSourceIamRole = new iam.Role(this, "dataSourceIamRole", { assumedBy: new iam.ServicePrincipal("appsync.amazonaws.com"), inlinePolicies: { InvokeLambdaFunction: new iam.PolicyDocument({ statements: [ new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ["lambda:invokeFunction"], resources: ["*"] }) ] }) } }); const dataSource = new appsync.CfnDataSource(this, "DataSource", { apiId: gql.attrApiId, name: "LambdaDataSource", serviceRoleArn: dataSourceIamRole.roleArn, type: "AWS_LAMBDA", lambdaConfig: { lambdaFunctionArn: MyLambda.functionArn } }); const addItemResolver = new appsync.CfnResolver(this, "HelloResolver", { apiId: gql.attrApiId, typeName: "Query", fieldName: "getHello", dataSourceName: dataSource.name, requestMappingTemplate: `{ "version": "2018-05-29", "operation": "Invoke", "payload": { "now": $util.toJson($util.time.nowISO8601()), } }`, responseMappingTemplate: `$util.toJson($ctx.result)` }); addItemResolver.addDependsOn(schema); } }
Rust
main.rsを以下のように用意。 基本的にはここに書いてある通り。
実行バイナリの名前をbootstrapにするのを忘れないこと。
#[macro_use] extern crate lambda_runtime as lambda; #[macro_use] extern crate serde_derive; #[macro_use] extern crate log; use lambda::error::HandlerError; use std::error::Error; #[derive(Deserialize, Clone)] struct CustomEvent {} #[derive(Serialize, Clone)] struct CustomOutput { world: String, } fn main() -> Result<(), Box<dyn Error>> { simple_logger::init_with_level(log::Level::Info)?; lambda!(my_handler); Ok(()) } fn my_handler(e: CustomEvent, c: lambda::Context) -> Result<CustomOutput, HandlerError> { Ok(CustomOutput { world: format!("Dekita!"), }) }
あとはビルド。今回はmuslで。
cargo build --release --target x86_64-unknown-linux-musl
あとはCDKでデプロイすればよい。
cdk deploy -c env="dev"
ローカルでLambdaを実行
ローカルで動かすにはSAMを使えばいいっぽい。
以下のようなtemplate.yamlを用意し
AWSTemplateFormatVersion: "2010-09-09" Transform: AWS::Serverless-2016-10-31 Resources: TestRustFunc: Type: "AWS::Serverless::Function" Properties: Handler: sam_local_test Runtime: provided CodeUri: ./rust.zip
zipしてから
zip -j rust.zip ./target/x86_64-unknown-linux-musl/release/bootstrap
以下で一応動作は確認できた
sam local invoke "TestRustFunc" -e event.json
2020-01-29 19:34:57 Mounting /tmp/tmp6efof_c2 as /var/task:ro,delegated inside runtime container START RequestId: 136f6ca2-6a4f-121d-5ece-30d4999340b1 Version: $LATEST 2020-01-29 10:34:58,203 INFO [lambda_runtime::runtime] Received new event with AWS request id: 136f6ca2-6a4f-121d-5ece-30d4999340b1 2020-01-29 10:34:58,205 INFO [lambda_runtime::runtime] Response for 136f6ca2-6a4f-121d-5ece-30d4999340b1 accepted by Runtime API END RequestId: 136f6ca2-6a4f-121d-5ece-30d4999340b1 REPORT RequestId: 136f6ca2-6a4f-121d-5ece-30d4999340b1 Init Duration: 277.65 ms Duration: 3.08 ms Billed Duration: 100 ms Memory Size: 128 MB Max Memory Used: 9 MB {"world":"Dekita!"}
今やってるお仕事、これに置き換えたい...
Go Conference 2019 Autumn「GoでつくるGameBoyエミュレータ」を発表してきた
表題の通りGo Conference 2019 Autumnで発表させていただきました。運営・スタッフの方々、スピーカーの方々、スポンサーの方々、発表を聞きに来てくださった方々、懇親会でお話させていただいた方々ありがとうございました。非常に楽しかったです。
今回は発表資料の補足や、質問いただいた内容の回答、補足などを記事としてまとめておこうと思い記事にしてみることにしました。
発表資料
発表資料は以下です。
補足
発表がかなり駆け足になってしまったのと、資料だけではよくわからない箇所があると思うので補足を以下にあげていきます。
DEMO
このページから遊ぶことができると思います。
さぼってページに載せていないんですがキーマップは以下です。
| keyboard | game pad |
|---|---|
| ← | ← button |
| ↑ | ↑ button |
| ↓ | ↓ button |
| → | → button |
| Z | A button |
| X | B button |
| Enter | Start button |
| Backspace | Select button |
スマートフォンの場合は画面上のGameBoyの各ボタンがタッチできるようになっています。
デフォルトでプレイできるのはtobutobugirlという2017年製のオープンソースGameBoyソフトです。
スペック

ブロック図
LR35902に色々詰まっているのが印象的です。なので基板上で目につく大物部品はLR35902とRAM2個くらいじゃないでしょうか。ファミコンの場合CPUとPPU(ピクチャープロセッシングユニット)が別パッケージになっていたため、CPUからPPUの持つVRAMにアクセスするのがとても面倒でしたが、その点が解消されており、物理的にもソフト的にもシンプルになっています。

その辺は以下の記事でも触れているので参照してみてください。
レジスタ
Fはフラグを管理する特殊なレジスタです。
また基本8bitレジスタですがBとCをくっつけてBCの16bitレジスタとして扱うことが可能です。


CPUの基本動作
乱暴な言い方をするとCPUはずっとこのステップを繰り返しているだけです。実際には割り込みの処理などがありますが、割り込み処理も各ステップ実行時に割り込みフラグをチェックして割り込みがかかっていたら指定番地へジャンプするただけなので、難しい処理ではありません。


フェッチ
CPUは次に実行すべき命令が何かを知る必要があります。なので次に実行すべき命令の番地を記録しておくプログラムカウンタ(以下PC)の指し先からリードを行います。これはカートリッジ内のROMかもしれませんし、どこかのRAMかもしれません。

リードを行った後はPCをインクリメントし、次の命令orデータを指すようにします。
この例はPCが0x1000番地を指しており、LD B, 0xA5というBレジスタに即値0xA5を格納する命令が入っている様子です。この命令のコードは0x06なのでPCの指し先には0x06がその次の番地にはデータとして0xA5が格納されています。

デコード
CPUは読んできたコードが何か調べる必要があります。今回の例で言うと0x06が何者かを調べる必要があります。自分は命令コードを渡すと命令の情報が引き出せる配列を用意しました。


今回は情報として、オペランドのサイズ、実行サイクル数、命令実行関数を引き出せるようにしています。
オペランドのサイズはこのCPUの場合0~2です。オペランドのサイズに応じてフェッチするようにしています。

発表時点でforでいいところ、なぜかswitchで書かれており、@Linda_ppが指摘してくれました。ありがとうございました。
実行
あとは実行するだけです。この例はレジスタBに0xA5に格納するだけなので一行で済んでしまいます。「なんだ簡単じゃないか」と思われた方もいるかと思いますが、まさにそのとおりでこのCPUは乗算・除算命令もなくデータの移動、加算、減算、ジャンプなどの単純な命令をちまちま実装していくだけで完成します。

ただ、数が多いのでその点はちょっと大変です。このCPUでいうと命令数は500弱くらいでしょうか。最初の数命令は動き始めると楽しいんですが、加算や減算をひたすら追加していくルーチンワークになると非常にだるくなります。
CPUを実行した際の返り値としてCPUの実行サイクル数を返しています。この値を元にGPUをどれだけ稼働させるかを決定するので、この値はCPU-GPU間の同期をとるための重要な値となります。

メモリマップ
色がついている箇所がカセット内のメモリ領域ですね。swichableと書いてあるのは、この領域はバンク方式をとっているので、ある特定の処理を行うことでその領域がごそっと切り替わることになります。ある特定の処理というのはROM領域バンク番号を書き込むなどの気持ち悪い処理なんですが、よく取られていた方法のようです。

省略されていますが上位のほうには割り込み、LCD、タイマーなどのペリフェラルに関する領域が取られています。
あと個人的に自分が気をつけていることですが、メモリマップの情報はCPUが知ることがないように気をつけています。CPU自身はデバイスの詳細を知る必要がないはずで、CPU自身がメモリマップの情報を知ってしまうとCPUのてスタビリティが下がるので別のモジュールに切り出してCPUにインジェクトできるような構成を取っています。
自分は単体テストは以下のように書いてみました。
func setupCPU(offset types.Word, data []byte) (*CPU, *mocks.MockBus) { b := mocks.MockBus{} b.SetMemory(offset, data) irq := interrupt.NewInterrupt() l := logger.NewLogger(logger.LogLevel("Debug")) return NewCPU(l, &b, irq), &b } func TestLDn_nn(t *testing.T) { assert := assert.New(t) cpu, _ := setupCPU(0, []byte{0x01, 0xDE, 0xAD}) cpu.PC = 0x00 cpu.Step() assert.Equal(byte(0xAD)) assert.Equal(byte(0xDE)) }
この部分をコードに落とすには基本的にはアドレスの範囲に応じてアクセス先のデバイスを変更する単純な処理になります。たとえばリードであれば以下のような感じです。

GPUの描画タイミング
画面の右端まで描画を行ったら次ライン描画までのブランク期間HBlankという期間があります。この期間を含めると1ラインあたり456クロックとなります。

また、表示領域を描画し終わってから次の描画を始めるまでのブランク期間VBlankという期間が10ライン分あります。

これらを考慮すると1フレームあたり70,224クロックで完了する計算になります。
この数字は重要な数字でCPUと同期をとるために必要となります。
GPUの基本動作
GPUは稼働するクロック数を入力し、それらをカウントアップ。クロックが1ライン分の456クロック積算されるごとに描画処理を行うような作りとしました。

具体的には以下のタイミングで各処理を行うようにしました。
- 表示領域内
- 表示領域描画完了
- VBlank完了
表示領域内
表示領域内、すなわちg.ly < 144の場合。g.lyというのは0xFF44番地に配置されているLCD Y座標レジスタです。CPUはこのレジスタを読むことで現在GPUが液晶の何ライン目を描画しているのかを知ることができます。
表示領域内であれば背景を1ライン分組み立てるようにしています。

表示領域描画完了
表示領域描画完了時にはスプライトをまとめて描画しています。スプライトというのは所謂マリオやクリボーなどのキャラクター画像で、背景の上にキャラクターを最大40個配置することが可能です。

文章だとわかりにくいですが、以下のGPUデータの可視化デモを見てもらえると分かりやすいかもしれません。

本来スプライトも各ライン毎に描画していくべきなのですが、エミュレータの場合はさぼって表示領域描画完了のタイミングにまとめて全てのスプライトを描画してしまっています。
「ではなぜ背景も同じようにまとめて描画しないのか」という疑問があると思うのですがライン毎にスクロール量が変更される可能性があるためです。
具体例として、スーパーマリオランドでマリオが画面右側へ進んでいくとどんどん画面がスクロールしていくと思うのですが、スコアやタイマを表示しているヘッダ(と勝手に呼びます)はスクロールせずに固定したままになっています。これはヘッダ部分をスクロール量0で描画した後にスクロール量を変更しているためです。
よって、表示領域描画完了時にまとめて背景を描画するような処理にするとこのような挙動が再現できなくなってしまいます。
また資料では割愛していますが、このタイミングでVBlank期間に入ることを通知するための割り込み処理が必要となります。この割り込みが何故必要かと言うと、描画中にVRAMを変更すると画像が乱れる可能性があるためで、基本的にはVRAMはVBkank中に触ることになているためです。
VBlank完了
g.lyを先頭の0に戻したり、VBlank割り込みフラグを解除したりします。

背景の描画
8Byteで8x8サイズのスプライトが1枚分作れます。16Byteで8x8サイズのスプライトを2枚作り足し合わせることで、色情報が3bitのタイルが表現できます。

タイルデータ
タイルデータを格納する領域は決められており、VRAM内の0x8000~0x97FF番地に格納されることになっています。格納された順にタイル番号がふられます。

タイルマップ
背景を作るにはタイルマップと呼ばれる領域、具体的には0x9800~0x9BFFに先のタイル番号を敷き詰めていくことになります。タイルマップは32x32タイルすなわち256X256px分の領域が確保されており、その一部が表示領域として切り取られLCDに表示されます。

なぜ余分な領域があるかというと、スムーズなスクロールに対応するためだと思います。当時のスペックだとVRAMへのアクセスはかなり遅い上に、先述したように書き込みできるタイミングは限られてしまいます。
VBlank中に多くの背景を書き換えることは不可能なため、256X256pxの領域から表示分160X144pxを切り取って表示しているものと思われます。
これは以下のツイートのタイルマップを見ると分かりやすいかもしれません。
マリオ、スクロールで背景が書き換えられるのがわかる pic.twitter.com/5sV6S9rWAl
— bokuweb (@bokuweb17) October 10, 2019
また、発表では割愛しましたがタイルマップは二面分の領域が確保されており、どちらを選択することはレジスタを編集することで切り替えることが可能です。
スクロール
表示領域を256x256pxの領域から切り出せるということを説明しましたが、どの領域を切り出すかをScrollX(0xFF43番地)、ScrollY(0xFF42番地)で設定することができます。

図は一番シンプルな例ですが、前述したようにスクロール量は描画の途中でも編集が可能なため、この図でいうところの青枠の切り取り領域はかならずしも1つの四角形になるとは限りません。
背景の描画
背景の描画データの組み立ては泥臭くやるしかなくて基本的には以下の手順です。
- 現在の描画座標から、タイルの座標を割り出す
- タイルの座標から該当するタイルマップのアドレスを算出する
- タイルマップからタイル番号を読み出す
- タイル番号からタイルデータを引いてくる
- 画像データを組み立ててバッファに格納

エミュレータの基本動作
ここまででCPUとGPUの基礎はできているので、これらの同期をとってエミュレータとして動作させる必要があります。

基本的なステップは以下です。
- CPUを1命令実行する
- CPUでの命令実行にかかったクロック分GPUを稼働する
- 積算クロック数が1フレーム分に達したら画像データを取得して描画する
です。注意すべきはCPUの動作クロックとGPUの動作クロックに差異がある点です。LR35902には4.19MHzが入力されていますが、CPUの動作クロックはそれを4分周したものになっています。つまりCPUで2クロックかかる命令を実行した場合、GPUは8クロック分稼働させる必要があります。x4しているのはそのためです。
今回は画像データを返すところまでをNextという関数にまとめておきました。後述しますが、ここで描画処理までをここで行ってしまうと、WasmとNativeで動作を切り分け必要がでてくるのと、テストするのが面倒になってしまうのでここではバッファを返すようにしています。

あとは60FPSすなわち16ms周期でNextを実行して画像データを取得・描画してやればエミュレータとしては完成です。イメージは以下のような感じです。

もちろん、これは実機のタイミングとは大きく異なりますが、エミュレータとしては入力が反映された画像データが16ms周期で取得できれば、辻褄は合うので問題ないです。この方式で不具合がでるゲームはそうそう無い気はしています。
あとはこの画像データを描画するだけです。特に面白いとこはないので発表でもスキップ気味に話ましたが、今回はfaiface/pixelというglfwライブラリを使用しました。
余談ですが、現在では治っているかもしれませんが、glfwを使用した場合macでは一度windowを移動しないと描画されない問題があり以下のような謎Hackが入っています。この問題のせいで数時間溶けました。
pos := win.GetPos() win.SetPos(pixel.ZV) win.SetPos(pos)
Testing
単体テストは前述したようにこつこつ書いていけばいいのですが、正直効率はあまりよくありません。CPUの作りが極めて単純なこともあり、加算命令やデータ移動のテストを書いていると辛くなってきます。
大抵どのようなレトロゲームにも先人たちがテストROMを作ってくれているのでこれを使うのがいいでしょう。
こんな感じで結果を表示してくれます。

たとえばCPU命令の実行結果が正しいかを検証するのは上記のcpu_instrsを使用します。このテストは落ちた場所を通知してくれますし、11パターンあるテストケースを個別に実行することもできます。また親切なことにシリアル出力に結果を吐いてくれるのでGPU周りが未実装でもこのROMは使用できます。
ある程度までCPUが動いたらまずはこのテストをパスすることを目標にするといいかもしれません。
このようにテストROMを使用して検証していくのは便利なのですが、CIでどう回すか。という別問題が出てきます。今回は以下のように指定したROMの指定フレームをpngとして保存するようにし、VisualRegressionTest`を行うようにしました。

これには以前作成したreg-viz/reg-cliというツールを使っています。最近@wadackelと@Quramyがレポート画面を刷新してくれて使いやすくなっていますのでぜひ使ってみてください。
本当は発表当日にgo版を用意して「どやっ」って言うつもりだったんですが資料作成に時間を吸われ全然間に合いませんでした。このツールはWebフロントエンドの開発などで使用することを念頭において作っているのでgo版を用意しても喜ぶのは僕ぐらいかもしれませんが...。
閑話休題。前回実行時に生成された画像をcommitしておき、見本画像とすることでただしく描画されているかが検証できます。
開発中はトライ&エラー的にごにょごにょいじることがあると思います。その際に以前は動いていた箇所を意図せず壊してしまったりしてしまいますが、この仕組みによりこれは防げます。
たとえば意図せずキャラクターの描画部分をコメントアウトしてしまったとしましょう。
if g.ly == constants.ScreenHeight { // g.buildSprites() ウワ-マチガッテコメントアウトシチャッタヨ }
そうすると以下のようにテストに失敗するようにしました。

変更部分もレポートを見ることですぐに分かるようになっています。

Wasmの初期化
goをWasm化してブラウザで動かす場合には、JS↔go間でグルーコードが必要となります。wasm_exec.jsというのがそれで、これは公式に用意されています。なのでまずこいつを読みこみ必要があります。

Wasmを読んでinstantiateする必要があります。注意点としてはwasm_exec.js経由で用意されているimportObjectを食わせる必要がある点です。これによりWasm側からwasm_exec.jsで用意されている関数を呼ぶことができます。

wasmのfetchとinstantiateを行ってくれるinstantiateStreamingというAPIがあるんですが、safariが対応していないのでここではfetchとinstantiate個別で行っています。
ブラウザで動作させる
初期化時に渡したimportObject経由でgo側の関数をJSから呼べるようにします。このときgo側でsyscall/jsをimportする必要があります。
import syscall/js

ここではブラウザ側のglobalにGBというオブジェクトを生やしています。実態はnewGBという関数になっていて、JSからは以下のように使用できます。
const rom = await fetch("./tobu.gb");
const buf = await rom.arrayBuffer();
const gb = new GB(new Uint8Array(buf));
ここではROMをfetchしてGBに渡しています。
go側ではもらったROMデータをもとに各種データを初期化し、ゲームを開始するための準備を行います。
また、ゲームの描画はcanvasで行うためGPUで生成されるデータをブラウザに引き渡す必要があります。これは以下のようにnextという関数を生やしてバッファを返すようにしました。
* js.typedArrayOfは後述しますがgo1.13では使用できません。
これで以下のようにgb.next()を呼ぶことでJS側でバッファが取得できるようになります。
const gb = new GB(new Uint8Array(buf)); const image = gb.next();
あとはJS側で16ms周期でgb.next()を呼び、返ってくるデータをcanvasに書き込めばブラウザでゲームが動作します。具体的には以下のような感じです。ディスプレイのリフレッシュレートでコールバックを発火してくれるrequestAnimationFrameを使用しています。(ここはサボっていてディスプレイのリフレッシュレートが30Hzだったり120Hzだと良くないことがおこります)

ビルド
ビルドは-tags=wasmとすることでnativeと切り分けています。

サイズ
サイズは予想通りかなり大きく、さらにはグルーコードもついてきます。

パフォーマンス

パフォーマンスは発表後に再測定しています。
Rustで書いたファミコンのエミュレータをwasmにした際には1フレーム当たり3ms程度だったので6~7msくらいは出てほしいと思っていましたが、届きませんでした。もちろんファミコンとは1フレームあたりの命令実行数は異なるはずなので参考値でしかありませんが。
もちろん、まだまだ最適化の余地はあるとは思いますが、それでも現状の書き方で6~7msくらいは出てほしいなーというのが率直な感想です。
FrameGraphをざっと見た感じ、分かりやすいボトルネックはなく、全体的に遅いという印象でした。試しにいくつかの関数がどのようにwasmに変換されるのかを見てみましたが、やはりruntimeの分コードが膨らみじわじわ効いて来ているような印象です。
たとえば以下のようなコードでもwasmに変換した際に大きく膨らんでしまうのである程度の速度低下はさけられないと思います。ただ、wasmにはGCをサポートするプロポーザルも出ているので、これにより改善するかもしれませんし、現時点であればtinyGoを試してみるのもいいかもしれません。

↑のコードをwasmにすると↓のようなコードになりました。

質疑応答
質問いただいた内容と回答を記載しておきます。 漏れや意図が汲み取れていないところがありましたらご指摘ください。
実装時に参考すべき資料はあるか
基本的にはこのpdfを見ればなんとかなると思います。
http://marc.rawer.de/Gameboy/Docs/GBCPUman.pdf
Go1.13は試したか
発表は1.12を前提に発表しました。すんなり動かなかったのと、資料作成が間に合ってなかったので後回しにしていましたが、発表後検証し、速度の比較を行ってみました。
| browser | go1.12.11 | go1.13.4 |
|---|---|---|
| Chrome77 | 10.43ms | 9.61ms |
| Firefox69 | 10.38ms | 10.48ms |
| Safari | 11.27ms | 8.80ms |
1フレームにおける平均処理時間なので小さいほうが良いです。Firefoxではあまり変化がありませんが、Chrome, Safariでは良くなっていると言って良さそうです。
| go1.12.11 | go1.13.4 | |
|---|---|---|
| サイズ(MB) | 3.4MB | 2.8MB |
サイズもかなり小さくなっていました。
注意点としてはgo1.13では資料内で紹介したjs.typedArrayOfが削除され、js.CopyBytesToJSを使用するよう変更されていました。
js.typedArrayOfはバッファを返して来ましたが、js.CopyBytesToJSは引数で渡したバッファにセットするIFになっています。
なのでnextは以下のように変更しました。
this.Set("next", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
img := emu.Next()
return js.CopyBytesToJS(args[0], img)
}))
ただ、ここでも問題があって、js.CopyBytesToJSは第一引数がUint8Arrayのinstanceかチェックしているんですが、canvasのImageDataはUint8ClampedArrayなので直接セットできず、今回はwasm_exec.jsをちょっと修正し、本体側にもパッチを投げてみました。
既存のツールを使用してwasmのサイズは小さくできるか
発表時は「そもそもruntimeがでかいわけだし、劇的には削れないだろう」と思い試しておらず、発表後にwasm-stripとwasm-optをかけてみましたが、サイズはほぼかわりませんでした。
global変数を使ったり、ヒープを使用しないような記述をすることで速くなるか
速度は改善する方向に向かいそうですが、どんなwasmが吐かれるかみてみないとなんとも言えなそうですし、まだ試せていないです。
同Conference@DQNEOさんの以下の発表を聞いて(とても楽しみにしてたし、楽しかった)自分もやりたくなったので、速度やサイズを改善したwasm用goコンパイラ`作れないかなーなど考えるなどしていた。
60FPSは出せるか
ネイティブであれば全く問題なく出るのと、wasmでもJS側でもgo側でも工夫できそうなところはまだまだあるので、新しめのデスクトップPCであれば比較的安定して出せそうだと思います。ただ、スマートフォンだとちょっと苦しそうな気もします。
ファミコンより作りやすいと言ったが具体的にどのようなところか
一例ですが、ゲームボーイは256x256pxのから160x144pxを切り出して表示しますが、ファミコンの場合は以下のように表示領域4面分のメモリ領域から1画面分を切り出すんですが、境界面がメモリ的にはまったく連続しておらずバグりやすいという話をしました。

また、冒頭で話たようにゲームボーイはGPUとCPUが同じパッケージにいるのでCPUから直接VRAMがアクセスできる。という点もハードウェア・ソフトウェアの両面をシンプルにしており、わかりやすくなっている点だと思います。
スプライトと背景の違い
発表では時間の都合上スプライトについては省略しました。軽くここで言及しておきます。 スプライトは背景の上に以下のようにキャラクターなどを描画する機能で最大40個配置することができます。

背景の場合はタイルマップと言われる領域に8x8のタイルを32x32タイル分敷き詰めるという話をしました。
なので(x , y) = (0, 0 )に2番タイル、(x , y) = (0, 8)に8番タイル... (x , y) = (8, 8)に1番タイル、のように8の倍数の座標にタイルを埋めていくことになります。
スプライトの場合は背景の上を縦横無尽に動ける必要があるため(x, y) = (17, 23) のような座標にも配置できなければなりません。スプライトは以下のような4バイトのデータで表現されます。
| Byte | 詳細 |
|---|---|
| 0 | Y座標 - 16 |
| 1 | X座標 - 8 |
| 2 | タイル番号 |
| 3 | オプション |
オプションの詳細は割愛しますが、水平、垂直反転したり、背景との表示優先順位を設定できたりします。
このデータをOAM RAMというRAMに格納するとGPUが画面にスプライトを展開します。
以上まとめになります。ありがとうございました。
ゲームボーイエミュレータをGo言語で書いた
概要
Goはこれまで量を書いたことがなかったので入門にゲームボーイエミュレータを書いてみることにした。ゲームボーイである理由はたまたまよくできたゲームボーイの資料(http://marc.rawer.de/Gameboy/Docs/GBCPUman.pdf)を見つけてしまったため。
成果物
まだ基本的なカートリッジタイプしか実装できていないがそこそこ動き始めたので公開することにした。直近は対応カートリッジを増やしながらWebAssemblyを吐けるようにしたい。
ゲームボーイの基本仕様
| 項目 | 概要 |
|---|---|
| CPU | LR35902 4.19MHz 8bit |
| RAM | 8kB |
| VRAM | 8KB |
| ROM | 256k~32MBit |
| Display | 4階調モノクロ、160×144ドット |
| スプライト | 8×8 最大40個表示 / 1ライン上に 最大10個表示 |
| 背景 | 256×256ドット |
| ウィンドウ機能 | 後述 |
| サウンド | 矩形波2ch+波形メモリ音源1ch+ノイズ1ch |
| 通信ポート | シリアル通信ポート搭載 |
| 割込み機能 | パッド入力割込み、シリアル通信割込み、タイマー割込み、LCDC割込み、Vblank割込み |
CPUはシャープ製のLR35902でこの中には画像処理や音声の機能も含まれている。コアはカスタムZ80と聞くことが多いが、Intel8080とZ80のハイブリッドとも聞いたことがあって、もうちょっと詳しく知りたいと思い調べていたら以下の記事に辿り着いた。
推測も含んでいるようなので実際のところはわからないが技術面では 8080 カスタムと呼ぶべきで、政治面では Z80 カスタムと呼ぶべきらしく面白かった。
前述したように画像処理(ファミコンで言うところのPPU)はLR35902に含まれているため部品点数がとても少ない。大きな部品はメモリ2つとLCDだけだ。あのスペースに押し込めるのに苦労したんだろうなと思う。
ファミコンとの違い
ゲームボーイ、ファミコンのイケてない点がいろいろ改善されているように見えて、そういう観点で見ると面白かった
— bokuweb (@bokuweb17) July 20, 2019
こんなツイートしたところ反応があったので書いてみることにする。ただ、ファミコン開発時の技術面やコスト面での限界もあっただろうし改善というとすこし大げさな気もするので気になった違いを挙げてみたいと思う。いざまとめてみるとそんなに量も無い気がするけど。
タイマーペリフェラルがある
逆にファミコンに無いということに驚くかもしれませんがファミコンにはタイマーがなかった。ので1秒待つような処理が必要になった場合、各命令がどのくらい時間を食うのか計算してwhile文などで待つ必要あったと思う。辛い。 *1
*1 Vblank割り込みをカウントアップすれば簡易タイマーになるのでは。というコメントをいただきました。
ゲームボーイには簡素なものながらタイマーがついており周波数は4種類から選べるし、もちろん割り込みもついている。
タイマーは指定周期経過するごとにカウンタをインクリメントしていき1byteのレジスタがオーバーフローする際に割り込みがかかるようになっている。なのでこのカウンタを読むことでどのくらい時間が経過したか測定することができる。
この機能を使うことによりゲームボーイではエミュレータのCPUの実行タイミングが正しいか計測できる。そのためファミコンではなかったタイミングが正しいかどうかテストするROMがたくさんあった。(タイミングまで正確にエミュレートするのは難しくてこの手のテストROMは全然PASSできていない)
エミュレータとしては多少タイミングがずれていても動くのでどこまで頑張るかは実装者のやる気次第。
シリアル通信ができる
これもタイマー同様ファミコンに無いということに驚くが、ゲームボーイではシリアル通信ができる。とても原始的な作りになっていて制御すべきレジスタは2個だけ。0xFF01に送信データを書いたあと0xFF02に書くと送信されるっぽい。ぽい、というのはあまり真面目に実装していなくて0xFF01を標準出力に接続するだけでエミュレータとしては十分だからだ。
テストROMによってはテスト結果をシリアルに吐いてくれるので描画の実装がまだできていなくてもCPUの命令テストなどが行える。これはエミュレータを作る側としては非常に助かる。ただ自分は最後の最後までシリアルに出力される文字が化けていてこの恩恵に預かれなかったが。。。
ここまで書いて気づいたんだが、このシリアルポートはゲームボーイ同士の通信に使われているポートらしい。ゲームボーイで通信ケーブルを使った覚えがないのですっかり頭から抜け落ちていた。
なぜかテトリスは0x55をDr.マリオは0x60を連続して出力してくるのでバグっているのかと思っていたんだけど、多分通信相手を探してるんだそうな。
ここのプロトコルがわからないが解析してWebSocketにでもつなげばネット対戦ができるかもしれない。
Hblank割り込みや指定ラインでの割り込みがある
Hblankとはあるラインを描画してから次のラインの描画が開始するまでのブランク期間で、ゲームボーイはHblankでの割り込みや指定したラインが描画された際(正確にはラインバッファに展開された際かもしれない)に割り込みをかけることができる。
このタイミングを知ることで様々なことが可能になるが、代表的なものはやはりラスタスクロールじゃないかと思う。ラスタスクロールは画面描画の途中でスクロール量を調整することで部分的なスクロールなど様々な表現が可能となる。
まだおかしいがだいたい動いた pic.twitter.com/Mk5Z00j0Cu
— bokuweb (@bokuweb17) August 1, 2019
たとえばこのようにスコアやタイムの表記だけ固定してゲーム部分のみスクロールさせることができる。
実際にこのカートリッジがどうやってるかまでは見てないが恐らく指定ラインで割り込みをかけてスクロール値を変更するなどすれば実現できると思う。
じゃあそれらのタイミングを取れないファミコンはどのようにラスタスクロールを実現しているかというと0爆弾という謎仕様がある。これはスプライト用RAMの先頭に格納されたスプライトがラインバッファ上に展開された際にある特定のレジスタにフラグが立つというものだ。
ちくちょう。。。スコアがスクロールしやがる。。。。 pic.twitter.com/LgJ80Bpmnd
— bokuweb (@bokuweb17) January 15, 2018
たとえばこれ。これは失敗例で意図しないとこまでスクロールしてるんだけど、そのおかげ(?)で0爆弾であるスプライトを目視することができる。本来コインが表示される位置にあるコインの影のような黒いスプライトだ。バグによりコインが流れていってしまってわかりにくいが。
このスプライト描画完了を検出してからスクロールを開始することによりスコアやタイムは画面上部に固定したままゲーム部分をスクロールすることができている。
このあたりのスクロールに関しては以下の記事も面白い。
ゼルダの伝説ではヘッダを固定したまま縦スクロールがありそれをどのように実装しているかという話。
0爆弾というトリッキーな仕様をシンプルな割り込みで解決できるようになったのは改善といっても良さそうだ。
ウィンドウという機能がある
これは最初説明を見てもなんのことかわからなかったが以下の記事を読んで氷解した。
簡単に言うと背景の上にもう一枚背景をかぶせるようなことができる機能だ。ただ、透過処理ができるわけではないので8x8ピクセルの単位で完全上書きになってしまう。
使用例としては以下のようなものが挙げられる。
GAME OVERの帯が下から上がってくるのはwindowという機能らしい。なんでもない機能に見えるけどファミコンでこれは実現できない気がする pic.twitter.com/KmsbmX99sx
— bokuweb (@bokuweb17) August 4, 2019
下から上がってくるGAME OVER の帯はまさにウィンドウ機能が使用されている。大した機能ではないように見えるが、ファミコンではこの挙動を実現できない*2んじゃないかと思っている。ファミコンではスプライトを並べて表現するか、背景を書き換えるかどちらかの手法になるが、スプライトは横方向最大8個しか並べられないし、背景をこのような速度で書き換えることはできないからだ。
*2 id:u_mid さんの指摘で GAME OVERの帯も不可能でない との指摘をいただきました。確かにタイミングの制御はかなりシビアだけどhblankのタイミングをうまく捉えてscrollを駆使したら行けるのかなーという気がしてきました。ただ少なからずグリッチが出るんじゃないかな...
で、話は戻ってゼルダの伝説のヘッダ固定上下スクロールもひょっとしてこのウィンドウ機能があればシュッと解決できるんじゃないかと思ったりしてる。なので地味だけど画期的な機能だと思う。
画像処理機能がCPUと同じパッケージに入ってる
これは半導体の集積度の向上やゲームボーイの筐体のサイズの都合上自然とこうなるべきという感じではあるが、ゲームボーイでは画像処理機能がCPUと同じパッケージに入ってる。
エミュレータ作成者から見て、何が嬉しいかと言うとCPUからVRAMに直接アクセスできることだろう。
ファミコンではVRAMはPPU(画像処理IC)に接続されていたためCPUからは直接アクセスすることができない。(VRAMに画像を配置するのはCPUの仕事であるにも関わらず。)
どうするかと言うとPPU内のアドレスレジスタにアクセスするVRAMのアドレスを書いてからデータレジスタにアクセスすることでようやくVRAMを読んだり書いたりできる。
ここで重要な点はPPU内のデータレジスタは初回ゴミデータが読めるので読み捨てる必要がある点だ。これはファミコン開発サイトNESDEVにもハマりポイントして紹介されており幾多のエミュレータ作者を陥れた仕様だろう。これをちゃんと実装しないと漏れなくスーパーマリオブラザーズの空が黒くなる。
これはCPU側のバスとPPU側のバスが非同期だからで、非同期のバス間でやりとりするにはFIFOをつけたりDual port RAMを使ったりすることが多いと思う。が、当時Dual port RAMなんてものは無かったかもしれないし仕様面、コスト面からも使う必然性もないのでFIFOが入ったんだろう。なので初回はゴミデータになる。
ファミコンにはこんな事情があったのでやはり、VRAMへのアクセスがシンプルになるのは嬉しい。
実装過程
完全な理解
ゲームボーイ完全に理解した #bokuwebnes pic.twitter.com/X0idXw7rze
— bokuweb (@bokuweb17) June 21, 2019
エミュレータ実装の第一歩はHello Worldまたはそれに相当するROMを探しコードを読むことだと思う。今回は以下のものを使用した。
ブートROM
これもファミコンとの違いの一つではあるのだけど、ゲームボーイはブートROMを持っている。0x0000~0x0100がブートROMの領域なんだけど一度起動後は0x000~0x0100はカートリッジのROM領域に再マッピングされるという仕様らしい。そういう挙動不安になる。
ロゴおかしい #bokuwebnes pic.twitter.com/BWSgSRq20r
— bokuweb (@bokuweb17) June 22, 2019
ロゴはでたけどなんかぎざぎざしてるのと上から落ちてこない... #bokuwebnes pic.twitter.com/XqjBmSiTaE
— bokuweb (@bokuweb17) June 22, 2019
表示はできたもののスクロールが実装できていないので中央に居座っている。
降ってきた!けどなんかホラーっぽい.... #bokuwebnes pic.twitter.com/noSVVbJv5s
— bokuweb (@bokuweb17) June 22, 2019
スクロールが絡む座標計算は何度実装しても難しくてすんなりいった試しがない。y方向の座標計算をミスっていたためホラーっぽい仕上がりに。
直った。色をLCDっぽく修正。 pic.twitter.com/2F1PNr1lKo
— bokuweb (@bokuweb17) June 24, 2019
完成。自分にとってゲームボーイは緑っぽいLCDの色のイメージなのでわざわざこの色に修正した。
CPUテスト
CPUテストROMはここにある。こいつはシリアルにも結果を出力してくれる便利なやつ。
CPU test romがようやく動くようになった pic.twitter.com/Xxzfz4iFoX
— bokuweb (@bokuweb17) July 1, 2019
ようやく全部通った pic.twitter.com/qOkR7kuGXe
— bokuweb (@bokuweb17) July 13, 2019
動かすには苦労した。デフォルトのカートリッジタイプではなくRAMを持ったカートリッジタイプでRAMにプログラムをコピーしてから実行するような作りになっていたためすんなりとはいかなかった。
ただ、このROMは個別実行できたりかなり重宝した。難点としてはアセンブラが結構複雑で読んでもどこで落ちているのかわからないこともしばしば。
Opus5
謎のシューティング風ゲーム。敵もいなければ攻撃もできない主にスクロールとキー入力確認用ROMと言う感じ。またはじめてスプライトが登場したのでここで実装した。たしかスプライト用DMAも使用していてそれも合わせて実装した気がする。
シューティングっぽいなにか pic.twitter.com/Uw09pSlehY
— bokuweb (@bokuweb17) July 16, 2019
ugoita pic.twitter.com/MwRY6rZQen
— bokuweb (@bokuweb17) July 19, 2019
ゲームボーイの解像度は160×144なので4kディスプレイで遊ぶとこうなる。早くスケール機能をつけないといけない。
4kで遊ぶとこうなる pic.twitter.com/FsxTS8bWx4
— bokuweb (@bokuweb17) July 19, 2019
テトリス
テトリスはなぜかすんなり動いて完成した気になってた。
テトリス動いた。大体完成では。wasmにするぞ! pic.twitter.com/eHtxRWKmlt
— bokuweb (@bokuweb17) July 19, 2019
スーパーマリオランド
これが全然だめだった。一番のミスはタイルIDの取り違い。昔のゲームはメモリ容量が少ないためVRAMにピクセルデータを直接持たせるのではなくスプライトデータを指し示すタイルIDを敷き詰めることになる。が、ゲームボーイはこれが負の値になる場合があるようでこれにハマッた。結局この値の持ち方にどのような利点があるのかさっぱりわからず。タイルIDがずれた分不思議な世界が描画されてた。
本来マリオであるべき場所がハエだしG反転しながら襲い掛かってくるすばらしい世界観 pic.twitter.com/Ri1smO7Ya0
— bokuweb (@bokuweb17) July 30, 2019
マリオがハエだしGが反転しながら襲ってくる。
すごい pic.twitter.com/8h9g7HlQw0
— bokuweb (@bokuweb17) August 5, 2019
マリオがたくさん。
謎すぎるの撮れた pic.twitter.com/lwC0HN3is6
— bokuweb (@bokuweb17) July 30, 2019
反転しながら襲ってくるGを避け3を手にするとやっぱりハエになる。
できたと思ったけど死ぬ瞬間2つに割れる pic.twitter.com/ujQD1iTxjD
— bokuweb (@bokuweb17) July 30, 2019
2つに割れる。
まだおかしいがだいたい動いた pic.twitter.com/Mk5Z00j0Cu
— bokuweb (@bokuweb17) August 1, 2019
これから
先にも書いたとおり、ひとまずはWebAssembly対応して遊んでみる。
あともう少し技術的詳細を書いた記事はどこかのタイミングで書こうかとは思ってる。けど腰は重そう。
そういえば以前ファミコンエミュレータを書くのをおすすめしたけど、ゲームボーイのほうがハマりポイントが少なくてもっとおすすめ。気になる方はぜひ。
Denoを読む(1)
正月にDenoを読んでたメモです。いろいろ間違ってる可能性が高いのでご注意ください。
Denoとは
Node.jsの作者Ryan Dahl氏による新しいTypeSciprtのランタイム。Node.jsの反省点を生かして作られてる。 おおきく分けてTypeScript、V8、Rustの三層で構成されていてTypeScriptとRust間はFlatBuffersでやり取りされ、仲介としてC++で書かれたlibdenoが存在する。
参考資料
yosuke-furukawa.hatenablog.com
読んでいく
前提
実装は日に日に変化しているのでひとまず以下のバージョンについてのメモとする
Cargo.toml
まずはCargo.tomlを眺めてみる。package.jsonみたいなやつです。dependenciesは以下のような感じ。特段目を引くようなものは見当たらないようにみえる。
[dependencies] atty = "=0.2.11" dirs = "=1.0.4" flatbuffers = "=0.5.0" futures = "=0.1.25" getopts = "=0.2.18" http = "=0.1.14" hyper = "=0.12.19" hyper-rustls = "=0.15.0" kernel32-sys = "=0.2.2" lazy_static = "=1.2.0" libc = "=0.2.46" log = "=0.4.6" rand = "=0.6.3" remove_dir_all = "=0.5.1" ring = "=0.13.5" rustyline = "=2.1.0" serde_json = "1.0.34" source-map-mappings = "0.5.0" tempfile = "=3.0.5" tokio = "=0.1.13" tokio-executor = "=0.1.5" tokio-fs = "=0.1.4" tokio-io = "=0.1.10" tokio-process = "=0.2.3" tokio-threadpool = "=0.1.9" url = "=1.7.2" winapi = "=0.3.6"
Rust側を見てく
エントリポイントはsrc/main.rsぽいのでここから読んでいく。
- src/main.rs
fn main() { // ... ommited ... 基本的にはロガーの設定 let state = Arc::new(isolate::IsolateState::new(flags, rest_argv, None)); let snapshot = snapshot::deno_snapshot(); let isolate = isolate::Isolate::new(snapshot, state, ops::dispatch); tokio_util::init(|| { isolate .execute("denoMain();") .unwrap_or_else(print_err_and_exit); isolate.event_loop().unwrap_or_else(print_err_and_exit); }); }
前半はロガーの設定などをぼちぼちやる感じ。
isolate::IsolateStateはisolate用のフラグやworker用channelsの保持用ぽい。まずこいつを作る。そもそもisolateは何かというとコンテキストが隔離されたJS実行環境と思えばいいのだろうか。chromeでのタブやworkerをイメージすれば良さそう(多分)。実際、最近入ったworker対応でもやはりworker作成時にisolateを作成している。
let snapshot = snapshot::deno_snapshot()ではv8のsnapshotを作成している。deno_snapshot()は以下。
- src/snapshot.rs
pub fn deno_snapshot() -> deno_buf { #[cfg(not(feature = "check-only"))] let data = include_bytes!(concat!(env!("GN_OUT_DIR"), "/gen/snapshot_deno.bin")); // ... ommited .../ unsafe { deno_buf::from_raw_parts(data.as_ptr(), data.len()) } }
deno_snapshotはこれだけでinclude_bytes!でファイルをごそっと読んでそのポインタと長さを返しているだけの様子。snapshotはなんぞやという話は以下を読むと良さそう。
コンテキスト作成時にV8のヒープにロードするのには時間がかかるので、ロード後のsnapshotを撮っておいてそれを使用することで起動を速くする仕組みっぽい。上の記事でもまさにTypeScriptのコンパイラの話をしている。Denoではtools/build.py実行時にdeno/js配下のファイルがトランスパイルかつV8のヒープロードされた状態でスナップショットにされるぽい。なのでjs/*.tsを変更した場合は再ビルドしないと反映されない。ちなみにnew Date()やMath.random()は値が焼き付くようなことが書いてある。
あとはtokioの中でdenoMainを実行して、isolate.event_loop()でタスクがなくなるまで待つことになっているぽい。タスクがなくなったらループを抜けて終了する。
tokio_util::init(|| { isolate .execute("denoMain();") .unwrap_or_else(print_err_and_exit); isolate.event_loop().unwrap_or_else(print_err_and_exit); });
tokioの初期化は以下のようになっている。tokioのチュートリアルもやったがこの辺何をやってるのかまだちゃんとわかってない。宿題。
pub fn init<F>(f: F) where F: FnOnce(), { let rt = tokio::runtime::Runtime::new().unwrap(); let mut executor = rt.executor(); let mut enter = tokio_executor::enter().expect("Multiple executors at once"); tokio_executor::with_default(&mut executor, &mut enter, move |_enter| f()); }
そもそもtokioってなにかというとRustの非同期I/Oライブラリで、イベントループを作ってLinuxであればepoll、BSDであればkqueueを使ってディスクリプタを監視し適宜処理を行うやつでNode.jsでいうところのlibuvの役割を果たしているようにみえる。違ったら指摘いただけると。。。
denoを読み始めたんだけど、結局tokioを学ばなければならないとなって正月はほぼ以下を読んでいた。以下はtokioの学習用の簡易実装でいろいろ勉強になる。ひとまずこれを読めばどんなことをやっているかはわかる。(tokioのおもちゃ実装ということで昔はtoykioという名前だった)
fahrenheitでは簡素化とポータビリティのためepollではなくselectを使用している。と書いてある。
ブログ記事もある。
ただ、まだ理解できていないのでもう少し勉強して理解できたら別途まとめたい。
isolate.event_loop()がどうなってるかというと以下のようになっていて、self.is_idle()が真になるまでループを抜けてこない。self.is_idle()は非同期タスクが0かつ設定されたtimeoutがなくなると真となる。なので非同期タスクがない(たとえば、console.log("hello");などを実行した)場合は待ちタスクがないのですぐアイドルと判定されループを抜けて終了する。
pub fn event_loop(&self) -> Result<(), JSError> { while !self.is_idle() { match recv_deadline(&self.rx, self.get_timeout_due()) { Ok((req_id, buf)) => self.complete_op(req_id, buf), Err(mpsc::RecvTimeoutError::Timeout) => self.timeout(), Err(e) => panic!("recv_deadline() failed: {:?}", e), } // ommited... promise error check } // ommited... promise error check Ok(()) }
ループ内では、recv_deadline(&self.rx, self.get_timeout_due())で非同期タスク完了のメッセージを待ち続けることになる。
では送信元はどこかというとdeno/src/isolate.rsのextern "C" fn pre_dispatchの以下の箇所っぽい。タスクを登録して、その完了時にsender.sendでメッセージを送信している。
let task = op .and_then(move |buf| { let sender = tx; // tx is moved to new thread sender.send((req_id, buf)).expect("tx.send error"); Ok(()) }).map_err(|_| ()); tokio::spawn(task);
extern "C"がついていることからもC++で書かれたlibsenoから叩かれる箇所だと推測できる。追ってみるとIsorate::newでlibdeno::configに受信コールバックとして渡されている。
let config = libdeno::deno_config { will_snapshot: 0, load_snapshot: snapshot, shared: libdeno::deno_buf::empty(), // TODO Use for message passing. recv_cb: pre_dispatch, resolve_cb, };
let task = op.and_then(...)のopは何かというと、以下のようなシグネチャになってる。
pub type Op = Future<Item = Buf, Error = DenoError> + Send;
deno/src/ops.rsのdispatchの返り値となっており、dispatchでメッセージのデシリアライズ後matchでファイルの読み書きやフェッチなどの処理に振り分けられる。例えばメッセージの種別がReadFileであれば以下のようにop_read_fileに振り分けられる。
pub fn dispatch( isolate: &Isolate, control: libdeno::deno_buf, data: libdeno::deno_buf, ) -> (bool, Box<Op>) { let base = msg::get_root_as_base(&control); let is_sync = base.sync(); let inner_type = base.inner_type(); let cmd_id = base.cmd_id(); let op: Box<Op> = if inner_type == msg::Any::SetTimeout { // ... ommited ... } else { // Handle regular ops. let op_creator: OpCreator = match inner_type { msg::Any::ReadFile => op_read_file, // ... 他の実処理に分岐される ...
たとえば一番シンプルな処理っぽいchdirであれば以下のような感じ。該当する処理を行ってBox<Op>を返すという感じ。
fn op_chdir( _state: &Arc<IsolateState>, base: &msg::Base, data: libdeno::deno_buf, ) -> Box<Op> { assert_eq!(data.len(), 0); let inner = base.inner_as_chdir().unwrap(); let directory = inner.directory().unwrap(); Box::new(futures::future::result(|| -> OpResult { std::env::set_current_dir(&directory)?; Ok(empty_buf()) }()))
ここでの結果がpre_dispatchにis_syncフラグと一緒に戻されて、非同期/同期で処理が分岐される。
例えば同期モードであれば(https://github.com/denoland/deno/blob/6f79ad721a9f8c9d66d79f21ea479286f3ca5374/src/isolate.rs#L416-L425) のようにbloking_onで処理の完了を待ってからレスポンスメッセージが送られる。
let buf = tokio_util::block_on(op).unwrap(); let buf_size = buf.len(); if buf_size == 0 { // FIXME isolate.state.metrics_op_completed(buf.len()); } else { // Set the synchronous response, the value returned from isolate.send(). isolate.respond(req_id, buf);
非同期の場合は先に記載したように処理の完了を待って完了後、完了が通知される。この通知は先のisolate.event_loop()内で受信されて非同期タスクの完了処理が実行される。完了処理は現在待機中のタスク数のデクリメント(tokio側のAPIを使いたい旨のコメントがあったが、問題があるのか現在は手動で行っている。)とV8側へのレスポンス。
let tx = isolate.tx.clone(); isolate.ntasks_increment(); let task = op .and_then(move |buf| { let sender = tx; // tx is moved to new thread sender.send((req_id, buf)).expect("tx.send error"); Ok(()) }).map_err(|_| ()); tokio::spawn(task);
TypeScript側を見てく
Rust側の大枠の流れはわかったのでTypeScript側を見てみる
エントリポイントはjs/main.ts。ここにRust側から呼ばれていたdenoMainがある。
export default function denoMain() { libdeno.recv(handleAsyncMsgFromRust); const startResMsg = sendStart(); // ... ommited ... os.setPid(startResMsg.pid()); const cwd = startResMsg.cwd(); log("cwd", cwd); for (let i = 1; i < startResMsg.argvLength(); i++) { args.push(startResMsg.argv(i)); } log("args", args); Object.freeze(args); const inputFn = args[0]; compiler.recompile = startResMsg.recompileFlag(); if (inputFn) { compiler.run(inputFn, `${cwd}/`); } else { replLoop(); } }
まずはlibdeno.recv(handleAsyncMsgFromRust);でRust側からの受信コールバックを設定する。
const promiseTable = new Map<number, util.Resolvable<msg.Base>>(); export function handleAsyncMsgFromRust(ui8: Uint8Array) { // If a the buffer is empty, recv() on the native side timed out and we // did not receive a message. if (ui8.length) { const bb = new flatbuffers.ByteBuffer(ui8); const base = msg.Base.getRootAsBase(bb); const cmdId = base.cmdId(); const promise = promiseTable.get(cmdId); util.assert(promise != null, `Expecting promise in table. ${cmdId}`); promiseTable.delete(cmdId); const err = errors.maybeError(base); if (err != null) { promise!.reject(err); } else { promise!.resolve(base); } } // Fire timers that have become runnable. fireTimers(); }
基本的にここに到達するのはTypeScript側から非同期処理を呼んでその応答がRust側から返ってきたケース(だと思う)。非同期処理開始メッセージを送る祭にcommandIdをキーにPromiseをpromiseTableに登録しておいて、返ってきたメッセージのcommandIdをキーにそれを回収、resolve/rejectを実行してるっぽい。
ちょうど下にsendAsyncというのがいた。promiseTable.set(cmdId, promise);してる。
export function sendAsync( builder: flatbuffers.Builder, innerType: msg.Any, inner: flatbuffers.Offset, data?: ArrayBufferView ): Promise<msg.Base> { const [cmdId, resBuf] = sendInternal(builder, innerType, inner, data, false); util.assert(resBuf == null); const promise = util.createResolvable<msg.Base>(); promiseTable.set(cmdId, promise); return promise; }
次にconst startResMsg = sendStart(); でスタートメッセージを同期モードで送信している。Rust側で各メッセージに対して何をやっているかはops.rsを見ればいいのがわかったいるので覗いてみる。
let inner = msg::StartRes::create( &mut builder, &msg::StartResArgs { cwd: Some(cwd_off), pid: std::process::id(), argv: Some(argv_off), debug_flag: state.flags.log_debug, recompile_flag: state.flags.recompile, types_flag: state.flags.types, version_flag: state.flags.version, v8_version: Some(v8_version_off), deno_version: Some(deno_version_off), ..Default::default() }, );
基本的にはRust側でもている基本的情報を返信しているだけっぽい。返してるメッセージは上記のようなものでフラグとか引数、プロセスIDやバージョンなどを詰めて返している模様。 あとは返ってきたメッセージの引数やフラグなどを処理して以下のようにファイルが指定されていればコンパイルして実行。なければREPLモードに入るっぽい。
if (inputFn) { compiler.run(inputFn, `${cwd}/`); } else { replLoop(); }
compiler.runの先はどうなってるかまだちゃんとみてないけど、CodeFetchというメッセージが同期が飛んでるのでRust側で該当ファイルを読んで返却後トランスパイルしてどこかにキャッシュしてるのかな。今度みる。
FlatBuffers
メッセージのやり取りにはFlatBuffersが使用されているが、定義はsrc/msg.fbsにいる。
tools/build.pyを実行するとTypeScriptとRustのコードがtarget/debug/gen/配下にmsg_generated.rs、msg_generated.tsとして生成される。
たとえば先のstartメッセージのレスポンスであれば以下のように定義されている。
table StartRes { cwd: string; argv: [string]; debug_flag: bool; deps_flag: bool; recompile_flag: bool; types_flag: bool; version_flag: bool; deno_version: string; v8_version: string; }
FlatBuffersはロード時にパースせず値が必要なときまで後回しするなどオーバーヘッドが少なく速いらしい。
このへんもまた今度詳しく調べてみる。
setTimeoutを実行してみる
だいたいの流れはわかったのでひとまず何か非同期処理を実行してみる。まずはsetTimeoutを試してみる。あとこの辺試してて気づいたんですが、microtaskのqueueはV8側で面倒見てくれるっぽい。知らなかった。
setTimeout(() => console.log("hello"), 1000);
を実行してみてその流れをみてみる。
setTimeoutはjs/timer.tsに定義されている。
export function setTimeout( cb: (...args: Args) => void, delay: number, ...args: Args ): number { return setTimer(cb, delay, args, false); }
これをたどっていくとsetGlobalTimeoutでメッセージを作って送信しているのがわかる。ただし、sendSync で送られている。timeout周りは非同期ながら若干特別扱いされてるっぽい。
function setGlobalTimeout(due: number | null, now: number) { // ... ommitted... // Send message to the backend. const builder = flatbuffers.createBuilder(); msg.SetTimeout.startSetTimeout(builder); msg.SetTimeout.addTimeout(builder, timeout); const inner = msg.SetTimeout.endSetTimeout(builder); const res = sendSync(builder, msg.Any.SetTimeout, inner); globalTimeoutDue = due; }
これはsrc/ops.rsのdispatchのメッセージから各処理への分岐部分に書いてあった。例外的に同期処理として扱われメインスレッドで更新されるとのこと。
let op: Box<Op> = if inner_type == msg::Any::SetTimeout { // SetTimeout is an exceptional op: the global timeout field is part of the // Isolate state (not the IsolateState state) and it must be updated on the // main thread. assert_eq!(is_sync, true); op_set_timeout(isolate, &base, data) }
op_set_timeoutを見るとどうもisolate側にtimeout値を設定しているだけのよう。そして同期モードのメッセージなのでdummyの空bufferをひとまず返してTypeScript側がブロックしないようにしてるっぽい。
fn op_set_timeout( isolate: &Isolate, base: &msg::Base, data: libdeno::deno_buf, ) -> Box<Op> { let inner = base.inner_as_set_timeout().unwrap(); let val = inner.timeout() as i64; let timeout_due = if val >= 0 { Some(Instant::now() + Duration::from_millis(val as u64)) } else { None }; isolate.set_timeout_due(timeout_due); ok_future(empty_buf()) }
timeout_dueがセットされると最初の方で記載したisolate.eventloopのself.is_idleが偽になってrecv_deadlineで受信待ちになる。
pub fn event_loop(&self) -> Result<(), JSError> { while !self.is_idle() { match recv_deadline(&self.rx, self.get_timeout_due()) { Ok((req_id, buf)) => self.complete_op(req_id, buf), Err(mpsc::RecvTimeoutError::Timeout) => self.timeout(), Err(e) => panic!("recv_deadline() failed: {:?}", e), } // ... ommited ... }
recv_deadlineは以下のようになっている。dueが設定されていれば、rx.recv_timeout(timeout)でタイムアウトを待つ。が、その後非同期メッセージを受信した場合は一旦rx.recv_timeoutから抜けてきてしまうので、後続の非同期タスクを登録したあと、次ループで再度rx.recv_timeoutで待つんだと思う。
fn recv_deadline<T>( rx: &mpsc::Receiver<T>, maybe_due: Option<Instant>, ) -> Result<T, mpsc::RecvTimeoutError> { match maybe_due { None => rx.recv().map_err(|e| e.into()), Some(due) => { let now = Instant::now(); let timeout = if due > now { due - now } else { Duration::new(0, 0) }; rx.recv_timeout(timeout) } } }
let now = Instant::now(); let timeout = if due > now { due - now } else { Duration::new(0, 0) };
とやっているのは一度ループから抜けた際に経過してしまった時間を吸収してるっぽい。なので、常に設定されるタイマーは一個になる気がする。 なので以下を実行した場合も1,000msと2,000msのタイマーが設定されるわけではなく1,000msのタイマーを待ったあと再度差分の1,000ms(実際には997とか微妙に減った値だと思う)が設定されるぽい。
setTimeout(() => {...}, 1000);
setTimeout(() => {...}, 2000);
そうするとTypeScript側も工夫が必要で複数のタイマーはひとまずconst dueMap: { [due: number]: Timer[] } = Object.create(null); に管理されるっぽい。稼働中のタイマーのみglobalTimeoutDueにセットされて管理される。現在のタイマーが完了前に次のタイマー設定が来た場合はglobalTimeoutDueが未設定、もしくは
globalTimeoutDueより期限が近いタイマーが新たに設定されるっぽい。そのへんをやってるのが以下。
function schedule(timer: Timer, now: number) { assert(!timer.scheduled); assert(now <= timer.due); let list = dueMap[timer.due]; if (list === undefined) { list = dueMap[timer.due] = []; } list.push(timer); timer.scheduled = true; if (globalTimeoutDue === null || globalTimeoutDue > timer.due) { setGlobalTimeout(timer.due, now); } }
なのでタイマーがセットされると同時にタイムアウト完了コールバックも同時に設定され、コールバック内で次に設定すべきタイムアウトがあれば経過時間を調整して設定、なければ完了メッセージ(timeout = -1)を送っている。Rust側では完了メッセージを受けたら、timeout_dueにNoneを設定して(他にまちタスクがなければ)isolate.eventloopを抜けて終了という流れっぽい。
なのでhandleAsyncMsgFromRustのfireTimersはまさにそれようなんですね。バッファが空の場合はタイムアウトという取り決めのもと次のタイマーをセットしにいってるんだと思う。
export function handleAsyncMsgFromRust(ui8: Uint8Array) { if (ui8.length) { // ... ommitted ... } // Fire timers that have become runnable. fireTimers(); }
かなり昔にtokio_timerを使ってタイマーを実装するというissueを見かけた気がしたけど、そのような作りではなく、どのような議論を経てこの実装になっているのかはちょっとわからん。
非同期処理を確認したかったんだけどsetTimeoutはちょっと特殊だったっぽい。次はreadFileとかcodeFetch周りを読めたら読みたい。
ひとまずここまで。
Deno用のpretty_assertを作った
あけましておめでとうございます。
tl;dr
Denoの入門に以下を作った

Deno?
概要
Ryan DahlがRustでDenoというものを作っていると聞いたとき貢献したいなーと思っていたけど、忙しさを言い訳に長い間ビルドすらできずにいた。
そんな中最近になってTLでDenoを楽しそうに触ってる方々がでてきて、みんなあまりに楽しそうなので触発されて自分の始めることにした。特にhashrockさんの記事をみて自分もやるぞ!となった。
Denoを読んで見る
最近なかなかまとまった時間が取れてなかったけど正月は時間がとれそうだったのでdenoを読むというのをハイプライオリティなタスクとしてスケジュールし、12/31〜1/1はDenoとその周辺を読んでた。
まだまだ理解が怪しいがどのように動いているかは把握できた気がするのでもうちょっとアップデートしてできれば記事にしたい。
Denoのテスト周り
Denoをきりのいいところまで読んだあとまずは人間に見やすいアサートを書いてみるか。ってことになった。 Denoにはテスト用のモジュールがあり以下のように書ける。
import { test, assertEqual } from 'https://deno.land/x/testing/testing.ts'; test({ name: 'example', fn() { assertEqual(10, 10); }, });
が、テスト失敗時の出力が見にくかった為だ。
実装
モジュールを作りはじめると「あれもない、これもない」となる。具体的にはjestのもっているpretty-formatを使いたかったのだが、直接は使えないのでひとまず export { default } from 'pretty-format'; を書いてrollupでバンドル後@ts-ignore を付加する方法をとった。anyになるしあまりいい方法ではないので今後何かしらいい方法が提案されるんじゃないかな。
これで一応import prettyFormat from './pretty-format/dist/index.js';として使用できる。
あと自分はたまたまno dependenciesのdiffライブラリを作っていたのでこれらを使用してassert結果を色付してやった。(こっちはまったく手をいれずimport diff, { DiffType } from 'https://denopkg.com/bokuweb/wu-diff-js@0.1.6/lib/index.ts';として使用できた。便利。)
denolandにはregistoryが用意してあって、PRを送ってマージされるとhttps://deno.land/x/pretty_assert@0.1.1/index.tsのようなURLで使用できるようになる。
多くのnpmモジュールは何かしらの方法で使用することはできるけど、NodeのAPIに依存したものはやはり移植する必要があるので、そのあたりから貢献してみるのは勉強にもなるし良さそうだと思った。