新しい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
とRAM
2個くらいじゃないでしょうか。ファミコンの場合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に依存したものはやはり移植する必要があるので、そのあたりから貢献してみるのは勉強にもなるし良さそうだと思った。