前回(Part1)では、CHIP-8の基本構造と7つの命令を実装しました。
今回は残りの 約28個の命令 を全部実装します。正直、最初は「残り28個とか余裕でしょ」って思ってたんですけど...
目次
CHIP-8の命令一覧
CHIP-8には全部で35個の命令があります。Part1で7つ実装したので、残り28個!
命令表(完全版)
| オペコード | ニーモニック | 説明 |
|---|---|---|
| 00E0 | CLS | 画面クリア ✅ |
| 00EE | RET | サブルーチンから戻る ✅ |
| 1NNN | JP addr | NNNにジャンプ ✅ |
| 2NNN | CALL addr | サブルーチン呼び出し ✅ |
| 3XNN | SE Vx, byte | Vx == NNならスキップ |
| 4XNN | SNE Vx, byte | Vx != NNならスキップ |
| 5XY0 | SE Vx, Vy | Vx == Vyならスキップ |
| 6XNN | LD Vx, byte | Vx = NN ✅ |
| 7XNN | ADD Vx, byte | Vx += NN ✅ |
| 8XY0 | LD Vx, Vy | Vx = Vy |
| 8XY1 | OR Vx, Vy | Vx |= Vy |
| 8XY2 | AND Vx, Vy | Vx &= Vy |
| 8XY3 | XOR Vx, Vy | Vx ^= Vy |
| 8XY4 | ADD Vx, Vy | Vx += Vy (キャリーあり) |
| 8XY5 | SUB Vx, Vy | Vx -= Vy |
| 8XY6 | SHR Vx | Vx >>= 1 |
| 8XY7 | SUBN Vx, Vy | Vx = Vy - Vx |
| 8XYE | SHL Vx | Vx <<= 1 |
| 9XY0 | SNE Vx, Vy | Vx != Vyならスキップ |
| ANNN | LD I, addr | I = NNN ✅ |
| BNNN | JP V0, addr | V0 + NNNにジャンプ |
| CXNN | RND Vx, byte | Vx = random & NN |
| DXYN | DRW Vx, Vy, n | スプライト描画 ✅ |
| EX9E | SKP Vx | キー押下でスキップ |
| EXA1 | SKNP Vx | キー非押下でスキップ |
| FX07 | LD Vx, DT | Vx = delay_timer |
| FX0A | LD Vx, K | キー入力待ち |
| FX15 | LD DT, Vx | delay_timer = Vx |
| FX18 | LD ST, Vx | sound_timer = Vx |
| FX1E | ADD I, Vx | I += Vx |
| FX29 | LD F, Vx | I = フォントアドレス |
| FX33 | LD B, Vx | BCD変換 |
| FX55 | LD [I], Vx | レジスタをメモリに保存 |
| FX65 | LD Vx, [I] | メモリからレジスタにロード |
...多いな。
Part1からの変更点
まず構造体にキー入力関連のフィールドを追加:
const KEY_COUNT: usize = 16;
struct Chip8 {
memory: [u8; MEMORY_SIZE],
display: [[bool; DISPLAY_WIDTH]; DISPLAY_HEIGHT],
v: [u8; REGISTER_COUNT], // V0-VF レジスタ
i: u16, // インデックスレジスタ
pc: u16, // プログラムカウンタ
stack: [u16; STACK_SIZE],
sp: u8, // スタックポインタ
delay_timer: u8,
sound_timer: u8,
keypad: [bool; KEY_COUNT], // キー入力状態(追加)
waiting_for_key: bool, // キー入力待ち状態(追加)
key_register: usize, // キー入力を格納するレジスタ(追加)
}
FX0A命令(キー入力待ち)は特殊で、キーが押されるまでエミュレータが停止します。この状態を管理するためにwaiting_for_keyフラグを追加しました。
算術・論理演算命令(8XYN系)
ここが一番脳が溶けたところ...
8XY0〜8XYE: レジスタ間の演算
// ========== 0x8XXX - 算術・論理演算 ==========
(0x8, _, _, 0x0) => {
// 8XY0 - LD: VX = VY
self.v[x] = self.v[y];
}
(0x8, _, _, 0x1) => {
// 8XY1 - OR: VX = VX | VY
self.v[x] |= self.v[y];
self.v[0xF] = 0; // CHIP-8のquirkとして実装
}
(0x8, _, _, 0x2) => {
// 8XY2 - AND: VX = VX & VY
self.v[x] &= self.v[y];
self.v[0xF] = 0;
}
(0x8, _, _, 0x3) => {
// 8XY3 - XOR: VX = VX ^ VY
self.v[x] ^= self.v[y];
self.v[0xF] = 0;
}
⚠️ 注意: CHIP-8 Quirks
OR、AND、XORの後にVF = 0を設定していますが、これは CHIP-8のquirk(癖) です。
オリジナルのCOSMAC VIPでは、これらの命令はVFをリセットしていました。一部のゲーム(特にSuper CHIP-8用)はこの挙動に依存しているので、互換性のために実装しています。
キャリー・ボローフラグ
(0x8, _, _, 0x4) => {
// 8XY4 - ADD: VX = VX + VY, VF = キャリー
let (result, overflow) = self.v[x].overflowing_add(self.v[y]);
self.v[x] = result;
self.v[0xF] = if overflow { 1 } else { 0 };
}
(0x8, _, _, 0x5) => {
// 8XY5 - SUB: VX = VX - VY, VF = NOT borrow
let vx = self.v[x];
let vy = self.v[y];
self.v[x] = vx.wrapping_sub(vy);
self.v[0xF] = if vx >= vy { 1 } else { 0 };
}
ポイント:VFフラグの意味
| 命令 | VFの意味 |
|---|---|
| ADD | 1 = オーバーフローした |
| SUB | 1 = ボローしなかった(VX >= VY) |
| SUBN | 1 = ボローしなかった(VY >= VX) |
| SHR | シフト前の最下位ビット |
| SHL | シフト前の最上位ビット |
SUBのVFが「NOT borrow」なのが直感に反するよね...。最初「1だったらボローした」と思い込んで3時間溶かしました。こわいですねぇ。
シフト命令
(0x8, _, _, 0x6) => {
// 8XY6 - SHR: VX = VY >> 1, VF = 最下位ビット
self.v[x] = self.v[y];
let lsb = self.v[x] & 1;
self.v[x] >>= 1;
self.v[0xF] = lsb;
}
(0x8, _, _, 0xE) => {
// 8XYE - SHL: VX = VY << 1, VF = 最上位ビット
self.v[x] = self.v[y];
let msb = (self.v[x] >> 7) & 1;
self.v[x] <<= 1;
self.v[0xF] = msb;
}
これもquirkがあって、オリジナルはVX = VYを先にやりますが、SUPER CHIP-8ではVYを無視してVXだけを使います。今回はオリジナル準拠で実装。
条件分岐命令
ゲームを作るには条件分岐が必須!
(0x3, _, _, _) => {
// 3XNN - SE: VX == NNなら次の命令をスキップ
if self.v[x] == nn {
self.pc += 2;
}
}
(0x4, _, _, _) => {
// 4XNN - SNE: VX != NNなら次の命令をスキップ
if self.v[x] != nn {
self.pc += 2;
}
}
(0x5, _, _, 0x0) => {
// 5XY0 - SE: VX == VYなら次の命令をスキップ
if self.v[x] == self.v[y] {
self.pc += 2;
}
}
(0x9, _, _, 0x0) => {
// 9XY0 - SNE: VX != VYなら次の命令をスキップ
if self.v[x] != self.v[y] {
self.pc += 2;
}
}
スキップは「次の命令を飛ばす」=「PCを2バイト進める」です。
図解:スキップの仕組み
通常の場合:
PC → [命令1] → [命令2] → [命令3]
↓ ↓ ↓
実行 実行 実行
スキップした場合:
PC → [SE V0, 5] → [スキップ] → [命令3]
↓ ✗ ↓
実行 実行
メモリ・レジスタ操作(FXNN系)
ここも曲者揃い...
FX33: BCD変換
(0xF, _, 0x3, 0x3) => {
// FX33 - LD: BCD表現をI, I+1, I+2に格納
let value = self.v[x];
self.memory[self.i as usize] = value / 100;
self.memory[self.i as usize + 1] = (value / 10) % 10;
self.memory[self.i as usize + 2] = value % 10;
}
例えば254を入れると:
- メモリ[I] = 2
- メモリ[I+1] = 5
- メモリ[I+2] = 4
スコア表示とかで使います。便利!
FX29: フォントスプライト
(0xF, _, 0x2, 0x9) => {
// FX29 - LD: I = フォントスプライトのアドレス
// 各フォントは5バイト、0x000から始まる
self.i = (self.v[x] as u16 & 0x0F) * 5;
}
Part1で用意したフォントセットを使うための命令。V[x]に0〜Fの値を入れておくと、そのフォントのアドレスがIに設定されます。
FX55/FX65: レジスタの保存・復元
(0xF, _, 0x5, 0x5) => {
// FX55 - LD: V0-VXをメモリ[I]以降に保存
for reg in 0..=x {
self.memory[self.i as usize + reg] = self.v[reg];
}
self.i += x as u16 + 1; // quirk: Iをインクリメント
}
(0xF, _, 0x6, 0x5) => {
// FX65 - LD: メモリ[I]以降をV0-VXにロード
for reg in 0..=x {
self.v[reg] = self.memory[self.i as usize + reg];
}
self.i += x as u16 + 1; // quirk: Iをインクリメント
}
これもquirkがあって、オリジナルはI をインクリメントしますが、現代の実装では変更しないものもあります。
キー入力関連
EX9E/EXA1: キー判定
(0xE, _, 0x9, 0xE) => {
// EX9E - SKP: VXのキーが押されていたらスキップ
let key = self.v[x] as usize;
if key < KEY_COUNT && self.keypad[key] {
self.pc += 2;
}
}
(0xE, _, 0xA, 0x1) => {
// EXA1 - SKNP: VXのキーが押されていなかったらスキップ
let key = self.v[x] as usize;
if key >= KEY_COUNT || !self.keypad[key] {
self.pc += 2;
}
}
FX0A: キー入力待ち
(0xF, _, 0x0, 0xA) => {
// FX0A - LD: キー入力待ち
self.waiting_for_key = true;
self.key_register = x;
}
この命令は特殊で、キーが押されるまで エミュレータが完全に停止 します。
cycle関数も修正:
fn cycle(&mut self) {
// キー入力待ち中は実行しない
if self.waiting_for_key {
return;
}
let opcode = self.fetch();
self.execute(opcode);
}
fn key_press(&mut self, key: u8) {
if key < KEY_COUNT as u8 {
self.keypad[key as usize] = true;
// キー入力待ち中だったら解除
if self.waiting_for_key {
self.v[self.key_register] = key;
self.waiting_for_key = false;
}
}
}
逆アセンブラを作る
デバッグ用に逆アセンブラも作りました。オペコードからニーモニックに変換:
fn disassemble(&self, opcode: u16) -> String {
let nibbles = (
((opcode & 0xF000) >> 12) as u8,
((opcode & 0x0F00) >> 8) as u8,
((opcode & 0x00F0) >> 4) as u8,
(opcode & 0x000F) as u8,
);
let x = nibbles.1;
let y = nibbles.2;
let n = nibbles.3;
let nn = (opcode & 0x00FF) as u8;
let nnn = opcode & 0x0FFF;
match nibbles {
(0x0, 0x0, 0xE, 0x0) => "CLS".to_string(),
(0x0, 0x0, 0xE, 0xE) => "RET".to_string(),
(0x1, _, _, _) => format!("JP 0x{:03X}", nnn),
(0x2, _, _, _) => format!("CALL 0x{:03X}", nnn),
// ... 他の命令
_ => format!("??? 0x{:04X}", opcode),
}
}
使用例
0x00E0 -> CLS
0x00EE -> RET
0x1234 -> JP 0x234
0x2456 -> CALL 0x456
0x3A55 -> SE VA, 0x55
0x6A05 -> LD VA, 0x05
0x8AB4 -> ADD VA, VB
0xDAB5 -> DRW VA, VB, 5
0xFA29 -> LD F, VA
0xFB33 -> LD B, VB
ROMを読み込んでこれを実行すれば、プログラムの内容がわかる!
テストと実行結果
全ての命令をテストするプログラムを書きました。
Test 1: 算術演算
let arithmetic_test = vec![
0x60, 0x05, // LD V0, 5
0x61, 0x03, // LD V1, 3
0x80, 0x14, // ADD V0, V1 -> V0 = 8, VF = 0
0x62, 0xFF, // LD V2, 255
0x72, 0x01, // ADD V2, 1 -> V2 = 0 (オーバーフロー)
// ...
];
出力:
LD V0, 0x05
LD V1, 0x03
ADD V0, V1 (VF=0)
LD V2, 0xFF
ADD V2, 0x01
LD V3, 0x10
LD V4, 0x05
SUB V3, V4 (VF=1)
📊 Registers:
V: [08, 03, 00, 0B, 05, 02, 0A, 00, ...]
V0 = 8になってる!ちゃんと足し算できてます。
Test 2: 条件分岐
let branch_test = vec![
0x60, 0x05, // LD V0, 5
0x30, 0x05, // SE V0, 5 -> スキップする
0x61, 0xFF, // LD V1, 255 (スキップされる)
0x61, 0x01, // LD V1, 1 (実行される)
];
出力:
LD V0, 0x05
SE V0, 0x05 (SKIP)
LD V1, 0x01
✅ V1 should be 0x01 (skip worked)
V1 = 0x01!スキップがちゃんと動いてます。
Test 3: BCD変換
let bcd_test = vec![
0x60, 0xFE, // LD V0, 254
0xA3, 0x00, // LD I, 0x300
0xF0, 0x33, // LD B, V0
];
出力:
Input: 254
BCD Output: 2 5 4 (should be 2 5 4)
完璧!
Test 4: フォント描画
let font_test = vec![
0x00, 0xE0, // CLS
0x60, 0x00, // LD V0, 0 (フォント '0')
0xF0, 0x29, // LD F, V0
0x61, 0x05, // LD V1, 5 (X座標)
0x62, 0x05, // LD V2, 5 (Y座標)
0xD1, 0x25, // DRW V1, V2, 5
// ... 'A'と'F'も描画
];
出力:
📺 Display Output (showing '0', 'A', 'F'):
==================================================================
| |
| ████ ████ ████ |
| █ █ █ █ █ |
| █ █ ████ ████ |
| █ █ █ █ █ |
| ████ █ █ █ |
| |
==================================================================
「0」「A」「F」が表示されてます!かわいい!
Test 5: サブルーチン
let subroutine_test = vec![
// メインプログラム
0x60, 0x00, // LD V0, 0
0x22, 0x0A, // CALL 0x20A (サブルーチン)
0x22, 0x0A, // CALL 0x20A (再度呼び出し)
0x22, 0x0A, // CALL 0x20A (さらに呼び出し)
0x12, 0x08, // JP 0x208 (無限ループ)
// サブルーチン (V0をインクリメント)
0x70, 0x01, // ADD V0, 1
0x00, 0xEE, // RET
];
出力:
--- Step 1 (PC: 0x200) ---
LD V0, 0x00
--- Step 2 (PC: 0x202) ---
CALL 0x20A
--- Step 3 (PC: 0x20A) ---
ADD V0, 0x01
--- Step 4 (PC: 0x20C) ---
RET -> 0x204
...
V0 = 3 (should be 3 after 3 subroutine calls)
3回サブルーチンを呼んでV0 = 3!スタックもちゃんと動いてます。
まとめ
今回実装したもの ✅
- 全35命令のデコード・実行
- 算術・論理演算(ADD, SUB, OR, AND, XOR, SHR, SHL)
- 条件分岐(SE, SNE)
- キー入力判定
- タイマー操作
- BCD変換
- フォント描画
- レジスタ保存・復元
- 逆アセンブラ
学んだこと
-
CHIP-8のquirks(癖) が多い
- OR/AND/XORでVFをリセット
- SHRでVYをVXにコピー
- FX55/FX65でIをインクリメント
- 互換性のためにどれを選ぶか考える必要がある
-
VFフラグの使い方 が命令ごとに違う
- ADDはキャリー
- SUBはNOTボロー
- SHRは最下位ビット
- ...覚えるの大変
-
Rustのパターンマッチ が神
- タプルで4ニブル分解
- matchで網羅的に処理
- 漏れがあるとコンパイラが教えてくれる
次回予告
Part3では SDL2を使ってGUIで描画 します!
ターミナルに文字で描画するのも味があるけど、やっぱり本物のウィンドウに描画したいよね。
参考資料
この記事が役に立ったら、いいね・ストックしてもらえると嬉しいです!
脳がパンクした後も、Part3でお会いしましょう!👋