はじめに
おはこんばんちは!!尾藤 a.k.a BTO です。
この記事は Rust2 Advent Calendar 2020 の14日目です。
タイトルで全部言い切ってますが、Rust で書いた CHIP-8 エミュレータを wasm で出力して、フロント(?)を React で書いてブラウザで動かした話です。
書いてたら長くなってしまったので、ここでは Rust で書いたエミュレータ側の処理について書きます。
デモ
とりあえず動いているものを見てもらえれば。全部ブラウザで動いているので、github pages に静的サイトとしてディプロイしてます。
コンソールで動かす版もあります。こちらはターミナルで動くようになっていて、100% Rust で書かれています。
simple-chip8
CHIP-8とは
1970年代に実装された小さな仮想マシンで、主にゲームを動かす目的で実装されました。コンピュータの歴史のかなり初期に実装されたものなので仕様が小さく、public rom もあって動作確認もしやすいところからエミュレータ実装の入門用として非常に人気があります。
技術的な詳細は CHIP-8 Technical Reference
にあります。
CHIP-8 の仕様
CHIP-8の仕様は非常にコンパクトで、リストにするとこのようになります。
- メモリ: 4KB
- レジスタ
- 汎用レジスタ: 8bit * 16(V0 - VF)
- インデックスレジスタ(16bit)
- スタックポインタ(16bit)
- プログラムカウンタ(16bit)
- スタックメモリ: 16bit * 16
- キーボード: 16個の入力キー
- ディスプレイ: モノクロ 64x32ピクセル
- サウンド: ビープ音
- タイマー
- ディレイタイマー
- サウンドタイマー
命令セット
全部で35個の命令セットになってて非常に少ないので実装は楽です。命令長は16bitの固定長。各命令の細かい仕様はテクニカルリファレンスをご確認ください。
さあ、そしたら実装行ってみよう!!
エミュレータの構造体
エミュレータの構造体を定義します。上記仕様に従って、それぞれを構造体のメンバとして持つだけです。構造体はこんな感じになります。
pub struct Chip8 {
// 4KB of RAM
ram: [u8; 4096],
// General purpose 8-bit registers
v: [u8; 16],
// Index register
pub i: usize,
// Stack
stack: [usize; 16],
// Stack pointer
pub sp: usize,
// Program counter
pub pc: usize,
}
初期化処理
構造体が定義できたら初期化処理です。初期化処理は次のような処理になります。
- フォントセットを読み込む
- ROMを読み込む
フォントセットの読み込み
フォントセットは最初から決まっています。それをメモリの 0x000 - 0x1FF に書き込むだけです。CHIP-8 では 0x000 - 0x1FF の領域は予約領域として確保されています。
const FONT_SET: [u8; 80] = [
0xF0, 0x90, 0x90, 0x90, 0xF0, // 0
0x20, 0x60, 0x20, 0x20, 0x70, // 1
0xF0, 0x10, 0xF0, 0x80, 0xF0, // 2
0xF0, 0x10, 0xF0, 0x10, 0xF0, // 3
0x90, 0x90, 0xF0, 0x10, 0x10, // 4
0xF0, 0x80, 0xF0, 0x10, 0xF0, // 5
0xF0, 0x80, 0xF0, 0x90, 0xF0, // 6
0xF0, 0x10, 0x20, 0x40, 0x40, // 7
0xF0, 0x90, 0xF0, 0x90, 0xF0, // 8
0xF0, 0x90, 0xF0, 0x10, 0xF0, // 9
0xF0, 0x90, 0xF0, 0x90, 0x90, // A
0xE0, 0x90, 0xE0, 0x90, 0xE0, // B
0xF0, 0x80, 0x80, 0x80, 0xF0, // C
0xE0, 0x90, 0x90, 0x90, 0xE0, // D
0xF0, 0x80, 0xF0, 0x80, 0xF0, // E
0xF0, 0x80, 0xF0, 0x80, 0x80, // F
];
fn load_fontset(&mut self) {
for (i, font) in FONT_SET.iter().enumerate() {
self.ram[i] = *font;
}
}
ROMの読み込み
CHIP-8 では ROM データは 0x200 移行のメモリに書き込みます。今回は JS 側で ROM データを読み込むので、wasm のメモリ領域に直接書き込んでいるので、Rust の実装はなしです。やっていることは簡単で、こんな実装になるはずです。
fn load_rom(&mut self) {
for (i, byte) in ROM.iter().enumerate() {
self.ram[i + 0x200] = *byte;
}
}
無限ループ
そもそも仮想マシンというかコンピュータの処理というのは
- メモリから命令を読み込む
- 命令を実行する
- 1に戻る
という無限ループをずっと実行しているだけです。そこに各デバイスとの入出力処理がイベントとして入ってきます。なので、ループ内での処理を書けば、あとはそれを無限ループ内で実行すれば良いだけです。
今回の実装では、無限ループは JS 側でやります。
1stepの実装
無限ループ内の1step内の実装は次のような流れになります。
- JS: キー入力
- JS: サウンドタイマーの処理(未実装)
- Rust: ディレイタイマーの処理
- Rust: メモリからオペコード(CPU命令)の読み込み
- Rust: オペコードの実行
- JS: ディスプレイの描画処理
Rust で実装するのは、ディレクタイマーとCPU命令実行部分になります。
ディレイタイマー
CHIP-8 にはスリープ命令がなく、一定時間待つための処理としてディレイタイマーが用意されています。8bit レジスタで値が管理されていて、1/60秒でデクリメント(-1)されます。0
になったらタイマー終了です。なので実装はループの中でデクリメントするだけです。
fn dec_delay_timer(&mut self) {
if self.delay_timer > 0 {
self.delay_timer -= 1;
}
}
オペコードの読み込み
オペコードは16bitの固定長になっています。プログラムカウンタに設定されている値が今実行しているメモリアドレスになっています。なので何も考えずにメモリからそのアドレスのデータを読み込むだけです。
fn fetch(&self) -> u16 {
(self.ram[self.pc] as u16) << 8 | self.ram[self.pc + 1] as u16;
}
オペコードの解析
命令長は16bitですが、命令数は65536個ではなく35個です。命令によっていくつかのパターンがあり、オペランド(引数)を取ります。CHIP-8 のオペコードはオペランドも含んでいます。
CHIP-8 のオペコードは次のオペランドを持ち、どこのbitに格納されるかも決まっています。
- nnn: 12bit整数(1-12bit)
- kk: 8bit整数(1-8bit)
- x: 4bitで汎用レジスタを指定(9-12bit)
- y: 4bitで汎用レジスタを指定(5-8bit)
- n: 4bit整数(1-4bit)
オペランドのパターンは次のようになります。o
には4bitの数字が入る。
- oooo: 固定命令
- onnn
- oxkk
- oxyo
- oxyn
上記のような仕様なので16bitのオペコードを4bitずつに分けてやり、その組み合わせで適切な処理を呼び出すようにします。
fn run_opcode(&mut self, opcode: u16) -> Pc {
let nibbles = (
(opcode & 0xF000) >> 12 as usize,
(opcode & 0x0F00) >> 8 as usize,
(opcode & 0x00F0) >> 4 as usize,
(opcode & 0x000F) as usize,
);
let nnn = (opcode & 0xFFF) as usize;
let kk = (opcode & 0xFF) as u8;
let x = nibbles.1 as usize;
let y = nibbles.2 as usize;
let n = nibbles.3 as usize;
match nibbles {
(0x00, 0x00, 0x0E, 0x00) => self.op_00e0(),
(0x00, 0x00, 0x0E, 0x0E) => self.op_00ee(),
(0x00, _, _, _) => self.op_0nnn(nnn),
(0x01, _, _, _) => self.op_1nnn(nnn),
(0x02, _, _, _) => self.op_2nnn(nnn),
(0x03, _, _, _) => self.op_3xkk(x, kk),
(0x04, _, _, _) => self.op_4xkk(x, kk),
(0x05, _, _, 0x00) => self.op_5xy0(x, y),
(0x06, _, _, _) => self.op_6xkk(x, kk),
(0x07, _, _, _) => self.op_7xkk(x, kk),
(0x08, _, _, 0x00) => self.op_8xy0(x, y),
(0x08, _, _, 0x01) => self.op_8xy1(x, y),
(0x08, _, _, 0x02) => self.op_8xy2(x, y),
(0x08, _, _, 0x03) => self.op_8xy3(x, y),
(0x08, _, _, 0x04) => self.op_8xy4(x, y),
(0x08, _, _, 0x05) => self.op_8xy5(x, y),
(0x08, _, _, 0x06) => self.op_8x06(x),
(0x08, _, _, 0x07) => self.op_8xy7(x, y),
(0x08, _, _, 0x0e) => self.op_8x0e(x),
(0x09, _, _, 0x00) => self.op_9xy0(x, y),
(0x0A, _, _, _) => self.op_annn(nnn),
(0x0B, _, _, _) => self.op_bnnn(nnn),
(0x0C, _, _, _) => self.op_cxkk(x, kk),
(0x0D, _, _, _) => self.op_dxyn(x, y, n),
(0x0E, _, 0x09, 0x0E) => self.op_ex9e(x),
(0x0E, _, 0x0A, 0x01) => self.op_exa1(x),
(0x0F, _, 0x00, 0x07) => self.op_fx07(x),
(0x0F, _, 0x00, 0x0A) => self.op_fx0a(x),
(0x0F, _, 0x01, 0x05) => self.op_fx15(x),
(0x0F, _, 0x01, 0x08) => self.op_fx18(x),
(0x0F, _, 0x01, 0x0E) => self.op_fx1e(x),
(0x0F, _, 0x02, 0x09) => self.op_fx29(x),
(0x0F, _, 0x03, 0x03) => self.op_fx33(x),
(0x0F, _, 0x05, 0x05) => self.op_fx55(x),
(0x0F, _, 0x06, 0x05) => self.op_fx65(x),
_ => panic!("{:04X}: {:04X} is invalid opcode", self.pc, opcode),
}
}
オペコードの実行
各オペコードの呼び出しはできるようになったので、どんどんオペコードの処理を実装していきます。基本的には CHIP-8 Technical Reference に仕様がのっているので、仕様通りに実装していきます。
例えば 7xkk
という Vx レジスタに kk
の値を足して Vx に格納する命令は次のように実装します。
// ADD Vx, byte: Set Vx = Vx + kk
fn op_7xkk(&mut self, x: usize, kk: u8) -> Pc {
self.v[x] = (self.v[x] as u16 + kk as u16) as u8;
}
オペコードの実装する時はテストコードを書くことを強くおすすめします。仕様が最初から決まっていて、細かい仕様の違いでうまく動作しなくなることがあるので、テストコードがしっかりと書かれていると安心度がかなり上がります。
プログラムカウンタの変更
オペコードを実行したらプログラムカウンタを変更します。プログラムカウンタを変更しないと毎回同じオペコードを実行してしまいますよね。通常は次のオペコードを実行すれば良くて、命令調は16bitなので、値を +2
すれば大丈夫です。
オペコードの中には次の命令をスキップするものがあります。その場合には +4
します。
ジャンプ命令は指定されたアドレスの値をプログラムカウンタに入れるだけです。
僕の場合は次のような enum を定義して処理するようにしました。
enum Pc {
Inc,
Skip,
Jump(usize),
}
キー入力
キー入力は JS 側でやりますが、JS からどのキーが入力されたかを Rust のエミュレータ側で受け取らないといけません。メンバ変数に keycode
を追加してそこに値を格納する処理を書くだけです。
実際はキー入力のオペコードの処理も絡んでくるので次のような実装になります。オペコードの処理は実際のコードを見てもらえればと思います。
pub fn set_key(&mut self, keycode: u8) {
if self.key_waiting {
self.v[self.key_register] = keycode;
self.key_waiting = false;
} else {
self.keycode = keycode;
}
}
vram
CHIP-8 の仕様には vram はありませんが、JS のビュー側とのやり取りのために vram を使用します。64x32ピクセルのモノクロディスプレイなので、64x32の二次元の bool 配列をメンバー変数に追加します。
struct Chip8 {
vram: [[bool; DISPLAY_WIDTH]; DISPLAY_HEIGHT],
}
あとは表示を制御するオペコードの中で、適切に値を書き換えるだけです。例えば CLS
というディスプレイをクリアするオペコードの実装は次のようになります。
// CLS: Clear the display
fn op_00e0(&mut self) -> Pc {
trace!("CLS");
for y in 0..DISPLAY_HEIGHT {
for x in 0..DISPLAY_WIDTH {
self.vram[y][x] = false;
}
}
self.vram_changed = true;
Pc::Inc
}
まとめ
Rust で書いたエミュレータ側の処理は、このような流れになります。次は wasm で JS 側とのつなぎこみや実装をどのようにやったのかを書きたいと思います。