3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

前回(Part1)では、CHIP-8の基本構造と7つの命令を実装しました。

今回は残りの 約28個の命令 を全部実装します。正直、最初は「残り28個とか余裕でしょ」って思ってたんですけど...


目次

  1. CHIP-8の命令一覧
  2. Part1からの変更点
  3. 算術・論理演算命令(8XYN系)
  4. 条件分岐命令
  5. メモリ・レジスタ操作(FXNN系)
  6. キー入力関連
  7. 逆アセンブラを作る
  8. テストと実行結果

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変換
  • フォント描画
  • レジスタ保存・復元
  • 逆アセンブラ

学んだこと

  1. CHIP-8のquirks(癖) が多い

    • OR/AND/XORでVFをリセット
    • SHRでVYをVXにコピー
    • FX55/FX65でIをインクリメント
    • 互換性のためにどれを選ぶか考える必要がある
  2. VFフラグの使い方 が命令ごとに違う

    • ADDはキャリー
    • SUBはNOTボロー
    • SHRは最下位ビット
    • ...覚えるの大変
  3. Rustのパターンマッチ が神

    • タプルで4ニブル分解
    • matchで網羅的に処理
    • 漏れがあるとコンパイラが教えてくれる

次回予告

Part3では SDL2を使ってGUIで描画 します!

ターミナルに文字で描画するのも味があるけど、やっぱり本物のウィンドウに描画したいよね。


参考資料


この記事が役に立ったら、いいね・ストックしてもらえると嬉しいです!

脳がパンクした後も、Part3でお会いしましょう!👋

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?