TypeScriptでCPUエミュレータ自作

TypeScriptでCPUエミュレータ（Chip8）を実装しました。
GitHub Pagesで公開しており、誰でもアクセスして確認できるようにしています。
https://hir-247-30.github.io/chip8.ts/

自分が実装したCPUを使って、テトリスやインベーダーゲームを遊ぶことができます。

### Chip8とは？

みんな大好きAI先生に聞いてみましょう。（丸投げ）

>CHIP-8（チップエイト） は、1970年代に登場した 簡易な仮想マシン（インタープリタ） であり、主に8ビットマイクロコンピュータ向けにゲームを実行するために設計されました。

>仮想マシン（バーチャルマシン）の一種
オリジナルのハードウェアは存在せず、エミュレーション向け
非常にシンプルな命令セット（オペコード）を持ち、ゲームの開発が容易
1970年代後半に、コスモス（COSMAC VIP）やTI-99/4Aなどのコンピュータで採用
現在では学習目的でエミュレーターを実装する題材として人気

なるほど、分かるような気もするし、分からないような気もします。

詳しく調べていくとCPUの入門にちょうどよい難易度で、仕様も公開されており、先駆者のソースコードもたくさん落ちていそうでした。

エミュレータを作成するにあたりスタックポインタやレジスタ、プログラムカウンタなども自分で実装する必要があります。
普段意識しない低レイヤの動きも理解ができるチャンスでもあります。

### Chip8の動き

CPUと聞くと難解で理解不能なものと感じますが、仕組みは意外と単純です。

```mermaid
sequenceDiagram
    participant ROM
    participant CPU
    participant メモリ
    participant プログラムカウンタ

    CPU->>ROM: 読み込み
    プログラムカウンタ->>プログラムカウンタ: 初期位置の設定
    CPU->>メモリ: フォントセットを読み込ませる
    CPU->>メモリ: ROMを読み込ませる
    CPU->>CPU: ディスプレイ表示
    loop
    CPU->>プログラムカウンタ: 位置を取得
    CPU->>メモリ: プログラムカウンタの位置から2バイトフェッチ
    CPU->>CPU: フェッチしたバイトから命令デコード
    CPU->>CPU: 命令を実行
    CPU->>CPU: ディスプレイ描画
    CPU->>CPU: タイマー更新
    CPU->>プログラムカウンタ: インクリメント
    end
```

1. ROM（ゲームのカセットのようなもの）を読みこむ
2. プログラムカウンタを初期化する（固定）
3. メモリにフォントセット（固定）を配置する
4. メモリに読み込んだROMを配置する
5. プログラムカウンタが示している番地からメモリを2バイト読み込む
6. 読んだ2バイトをCPUの命令にデコードする
7. 命令を実行する
8. プログラムカウンタを進める
9. 5〜8 を繰り返す

これがCPUの一連の流れです。合間にディスプレイを更新・描画したりもします。

プログラムカウンタが示す位置のメモリを読み込み、そこから命令にデコードして実行、プログラムカウンタを進めて同じことを繰り返す・・・と書けば意外と単純だということがわかります。

### Chip8の構成要素

詳しい仕様は下記になります。
https://yukinarit.github.io/cowgod-chip8-tech-reference-ja/2-1_memory.html

まとめると以下になります。

| 項目 | 内容 |
| --- | --- |
| メモリ  | 4KB（4096バイト）のアドレス空間 |
| 汎用レジスタ（V）  | 16個の 8ビット汎用レジスタ |
| インデックスレジスタ（I）  | メモリアドレスの格納に使用する16ビットのレジスタ |
| プログラムカウンタ（PC）  | 命令のアドレスを保持する |
| スタック  | サブルーチン用の16段スタック |
| スタックポインタ（SP）  | 8ビットでスタックの先頭のインデックスを表す |
| サウンドタイマー  | 8ビットのレジスタ |
| ディレイタイマー  | 8ビットのレジスタ |
| ディスプレイ  | 64x32ピクセルのモノクロディプレイ |
| キーボード  | 16進数のキーボード |

TypeScriptで実装する場合、16ビットと8ビットの表現がデフォルトで存在しないため 自分で作成して表現する必要がありました。

### 命令のデコード

メモリから2バイトフェッチした時、Chip8の命令にデコードする必要があります。
具体的にはフィッチした時はリトルエンディアンになっているので、これをビッグエンディアンに変換する必要があります。

このあたりは私のリポジトリにコメント付きでコミットしてあるので それを見ていただけると嬉しいです。（急な丸投げ）
https://github.com/hir-247-30/chip8.ts/blob/992d778b5e04f59ff54739c409150ceb9076ea0c/src/cpu.ts#L194

### 命令実行

デコードした命令を実行します。
命令は下記の数だけ存在しており、マッチしたものを実行します。
https://yukinarit.github.io/cowgod-chip8-tech-reference-ja/3_chip8_instructions.html

例えばビッグエンディアンに変換したものが `7ABC` だった場合、リンク先の `7xkk - ADD Vx, byte` に該当します。
「VxにVx + kkをセットする。」とのことなので、汎用レジスタV[A]にV[A] + BCを代入すればよい、となります。

TypeScriptだとmatch文のようなものが存在しないので、switchで分岐する以外になく非常にもどかしい実装となってしまいました。
※ 2025年3月時点での話

### ディスプレイ

64x32を配列で表現します。
具体的には配列の要素に0または1を入れるようにし、それぞれで色を変えて描画するようにします。

描画の方法は後述のCLIとWebとで変わります。

### CLI実行

CLI上でディスプレイを描画するケースでは `blessed` を使用しました。
https://www.npmjs.com/package/blessed/v/0.1.4

keypressを簡単に実装できるのが優れものです。
最初はconsole.logで描画していましたが、チカチカして目に悪くて仕方なかったです。blessedならそういったことにもなりません。
64x32の配列を確認し、1の位置に `█` を描画するように実装しました。

一方でblessedにはkeyupがありません。
これに関しては「300ms後にkeyupとする」という苦しい実装をしています。300msという数値に関しては完全に私の感覚値です。自分でプレイしてみて違和感が少なく感じたのがこの値でした。

また余談ですが、シングルプロセス・シングルスレッドのNode.jsがどうやってkeypressを受け付けているのかと思って調べたところ、 `process.stdin` を使って非同期で入力受付をしているみたいです。

### Web実行

Webに関してはHTML内にcanvasで描画するようにしています。
64x32の配列を確認し、0と1とで色を変えてfillRect()すればOKです。

TypeScriptだとCLIでもHTMLでも簡単に動作させることができていいですね。
HTMLで動作させたものに関しては、GitHub Pagesで公開するようにすればポートフォリオにもなります。

### 終わりに

総評して「難しそうだがやってみると案外できた」という感想です。
自分の知らない領域の知識を得られたので有意義でした。普段の業務では得ることは難しいでしょう。

リポジトリを以下に公開しています。よければどうぞ。
https://github.com/hir-247-30/chip8.ts/tree/main