100日チャレンジ day37 (CHIP-8を使ったゲームエミュレータ)

昨日
100日チャレンジに感化されたので、アレンジして自分でもやってみます。
やりたいこと
- 世の中のさまざまなドメインの簡易実装をつくり、バックエンドの実装に慣れる(dbスキーマ設計や、関数の分割、使いやすいインターフェイスの切り方に慣れる
- 設計力(これはシステムのオーバービューを先に自分で作ってaiに依頼できるようにする
- 生成aiをつかったバイブコーティングになれる
- 実際にやったことはzennのスクラップにまとめ、成果はzennのブログにまとめる(アプリ自体の公開は必須ではないかコードはgithubにおく)
できたもの

ゲームのエミュレーター を作る
CHIP-8 という昔のPCがあるらしいのでこれを使う

Day 37: CHIP-8 エミュレーター (Go + ebiten) - 実装ステップ
フェーズ 1: プロジェクト設定とコア構造
-
Step 1: プロジェクト初期化
-
day37_chip8_emulator_go
ディレクトリ作成。 cd day37_chip8_emulator_go
-
go mod init github.com/lirlia/100day_challenge_backend/day37_chip8_emulator_go
実行。 -
go get github.com/hajimehoshi/ebiten/v2
で ebiten 依存関係追加。 -
cmd/chip8
,pkg/chip8
,roms
ディレクトリ作成。 - README.md を作成し、基本情報を記述。
- テスト: コマンドがエラーなく完了することを確認。
-
コミット:
init: Initialize project structure and dependencies for day37_chip8_emulator_go
-
-
Step 2: CHIP-8 コア構造体定義
-
pkg/chip8/chip8.go
にChip8
構造体(メモリ[4096]byte
, レジスタV[16]byte
, インデックスI
, PC, スタックstack[16]uint16
, SP, グラフィックバッファgfx[64*32]byte
など)を定義。 -
New() *Chip8
関数で構造体を初期化(メモリクリア、フォントセットロードなど)する処理を実装。 -
テスト:
New()
を呼び出して初期状態が期待通りか確認する簡単なテストコード (_test.go
またはmain.go
で一時的に) を書くか、デバッガで確認。 -
コミット:
feat(chip8): Define core Chip8 struct and initialization logic
-
-
Step 3: ROM ローダー実装
-
pkg/chip8/chip8.go
にLoadROM(path string) error
関数を実装。指定されたパスの ROM ファイルを読み込み、メモリの特定アドレス (通常 0x200) 以降にロードする。 -
テスト: テスト用の小さなダミー ROM ファイルを用意し、
LoadROM
を呼び出してメモリに正しくロードされるか確認。 -
コミット:
feat(chip8): Implement ROM loading functionality
-
フェーズ 2: 基本的な表示と実行サイクル
-
Step 4: Ebiten 基本ウィンドウ設定
-
cmd/chip8/main.go
にebiten.Game
インターフェースを実装するGame
構造体を作成。*chip8.Chip8
を保持するフィールドを追加。 -
main
関数でコマンドライン引数から ROM パスを取得し、chip8.New()
,chip8.LoadROM()
を呼び出す。 - 空の
Update()
,Draw()
,Layout()
メソッドを実装。 -
ebiten.RunGame()
を呼び出してウィンドウを表示させる。 -
テスト:
go run ./cmd/chip8 path/to/dummy.rom
で空のウィンドウが表示されることを確認。 -
コミット:
feat(main): Set up basic ebiten window and game loop structure
-
-
Step 5: 画面描画実装
-
cmd/chip8/main.go
のDraw()
メソッドを実装。chip8.gfx
バッファ (64x32) を参照し、ebiten
のDrawRect
やFill
を使って、対応するピクセルをウィンドウ (*ebiten.Image
) に描画する(1ピクセルを例えば 10x10 の矩形として描画するなど、スケールアップする)。 -
Layout()
メソッドで論理サイズ (64, 32) を返すようにする。 -
テスト:
chip8.gfx
バッファに手動で簡単なパターン(市松模様など)を設定し、go run
でそれが正しくウィンドウに描画されるか確認。 -
コミット:
feat(main): Implement Chip8 screen rendering using ebiten
-
-
Step 6: CPU サイクルと基本命令 (CLS, JP, LD Vx, DRW)
-
pkg/chip8/chip8.go
にCycle()
関数の骨組みを実装(PC からオペコードをフェッチ → デコード → 実行)。 -
pkg/chip8/opcodes.go
を作成し、デコードロジックと、以下の基本的な命令の実装を追加:-
00E0
(CLS):gfx
バッファをクリア。 -
1nnn
(JP addr): PC をnnn
に設定。 -
6xkk
(LD Vx, byte): レジスタVx
にkk
をロード。 -
Dxyn
(DRW Vx, Vy, nibble):gfx
バッファにスプライトを描画(XOR ロジック含む)。レジスタVF
の設定も行う。
-
-
cmd/chip8/main.go
のUpdate()
内でchip8.Cycle()
をフレームごとに数回呼び出すようにする(仮の速度)。 -
テスト:
CLS
やDRW
を使う簡単なテスト ROM (自作または kripod/chip8-roms のテスト ROM を利用) をroms/
に配置し、go run
で実行して画面クリアや描画が正しく行われるか確認。 -
コミット:
feat(chip8): Implement CPU cycle and basic opcodes (CLS, JP, LD, DRW)
-
フェーズ 3: 入力、タイマー、その他の命令
-
Step 7: キーボード入力実装
-
cmd/chip8/main.go
のUpdate()
内でebiten.IsKeyPressed()
などを使用し、物理キーボードのキー(例: 1, 2, 3, Q, W, E など)を CHIP-8 の 16 キー (chip8.keys[16]byte
) にマッピングして状態を更新する処理を実装。 -
pkg/chip8/opcodes.go
にキー入力関連の命令を実装:-
Ex9E
(SKP Vx):Vx
のキーが押されていたら PC をスキップ。 -
ExA1
(SKNP Vx):Vx
のキーが押されていなかったら PC をスキップ。 -
Fx0A
(LD Vx, K): キー入力を待ち、押されたキーをVx
に格納 (ブロッキング処理)。
-
-
テスト: キー入力に反応する ROM (例:
KEYPAD_TEST.ch8
や簡単なゲームPONG
) でテスト。 -
コミット:
feat(chip8): Implement keyboard input handling and related opcodes
-
-
Step 8: タイマー実装
-
pkg/chip8/chip8.go
のChip8
構造体にDT
(遅延タイマー) とST
(サウンドタイマー) フィールドを追加。 -
Cycle()
内で、タイマーが 0 より大きい場合にデクリメントする処理を追加 (60Hz でデクリメントするのが一般的)。 -
pkg/chip8/opcodes.go
にタイマー関連の命令を実装:-
Fx07
(LD Vx, DT) -
Fx15
(LD DT, Vx) -
Fx18
(LD ST, Vx)
-
- テスト: タイマーを使用するテスト ROM や、タイマーが影響するゲーム (遅延やサウンドキュー) で確認。デバッグログでタイマー値の変化を追う。サウンドの再生は実装しない。
-
コミット:
feat(chip8): Implement delay and sound timers and related opcodes
-
-
Step 9: 残りの主要命令実装
-
pkg/chip8/opcodes.go
に未実装の命令カテゴリを実装していく。- 算術/論理演算 (8xy0-8xy7, 8xyE)
- 条件分岐 (3xkk, 4xkk, 5xy0, 9xy0)
- サブルーチン呼び出し/リターン (2nnn, 00EE)
- インデックスレジスタ操作 (Annn, Fx1E)
- メモリ操作 (Fx29, Fx33, Fx55, Fx65)
- 乱数 (Cxkk)
-
テスト: kripod/chip8-roms/test_roms にある
BC_test.ch8
,test_opcode.ch8
などを実行し、期待される結果になるか確認。必要に応じてデバッグと修正を繰り返す。 -
コミット:
feat(chip8): Implement remaining arithmetic, logic, flow control, and memory opcodes
(複数回に分けても良い)
-
フェーズ 4: 仕上げ
-
Step 10: ゲーム ROM でのテストと調整
-
roms/
に配置したPONG
,INVADERS
,TETRIS
などのゲーム ROM を実行。 - ゲームが正しく動作するか確認。
- CPU の実行速度 (
Update
内でのCycle()
の呼び出し回数) を調整して、適切なゲームスピードにする。 - バグがあれば修正。
-
コミット:
fix(chip8): Debug and adjust emulator based on game ROM testing
-
-
Step 11: ドキュメント更新とコード整理
- README.md に最終的なビルド・実行方法、キーマッピングなどを記述。
- コード内のコメントやログを整理。
-
コミット:
docs(chip8): Finalize README and code comments
このステップで進めていきましょう。まずは Step 1 のプロジェクト初期化から開始します。
準備ができたら、最初のコマンドを実行します。

ebiten っていうゲームエンジン用のフレームワークと、それで呼び出される CHIP-8 の opcode の実装をしている

性能落ちてきたので切り替え
承知いたしました。別のLLMに引き継ぐためのプロンプトを作成します。以下をコピーして利用してください。
引き継ぎプロンプト
プロジェクト: Day 37: CHIP-8 エミュレーター (Go + ebiten)
目標: Go言語とEbitenライブラリを使用してCHIP-8エミュレーターを実装する。
現在の状況:
Step 6「CPU サイクルと基本命令 (CLS, JP, LD Vx, DRW)」の実装途中です。
これまでの進捗:
- プロジェクトのディレクトリ構造 (
day37_chip8_emulator_go
) と Go モジュール、Ebiten の依存関係を設定済み。 - CHIP-8 のコア構造体 (
Chip8
inpkg/chip8/chip8.go
) と初期化関数 (New
) を定義済み。フォントセットもロード済み。 - ROM をメモリにロードする関数 (
LoadROM
inpkg/chip8/chip8.go
) を実装済み。 - Ebiten の基本的なウィンドウ表示とゲームループ構造 (
cmd/chip8/main.go
) を設定済み。ROM パスを引数で受け取れる。 - CHIP-8 のグラフィックバッファ (
gfx
) を Ebiten ウィンドウに描画する処理 (Draw
incmd/chip8/main.go
) を実装済み。 - CPU の実行サイクル (
Cycle
inpkg/chip8/chip8.go
) の基本的な骨組みを実装済み。オペコードのフェッチ、実行呼び出し、基本的な PC 更新ロジックを含む。 - 基本的なオペコード (
00E0
,1nnn
,6xkk
,A000
,Dxy0
) のデコードと実行ロジックをpkg/chip8/opcodes.go
に実装済み。特にdrawSprite
関数でgfx
バッファの更新とdrawFlag
の設定を行う。
現在の問題:
画面クリア (CLS) 後、数字 '0' のスプライトを描画 (DRW) し、無限ループ (JP) するテスト ROM (roms/test_cls_drw.ch8
) を作成して実行しています。
デバッグログ (Cycle
および executeOpcode
内に追加済み) によると、以下の命令が期待通りに実行されています。
-
00E0
(CLS) -
6005
(LD V0, 5) -
6105
(LD V1, 5) -
A050
(LD I, 0x050) -
D015
(DRW V0, V1, 5) -
120A
(JP 0x20A) - 以降、この命令でループ
しかし、Ebiten のウィンドウには期待される数字 '0' が表示されません(ウィンドウは表示されるが中身が描画されないか、真っ黒のまま)。
考えられる原因:
-
pkg/chip8/opcodes.go
のdrawSprite
関数の実装に問題があり、gfx
バッファが正しく更新されていない、またはchip8.drawFlag
がtrue
に設定されていない。 -
cmd/chip8/main.go
のDraw
関数が、gfx
バッファの内容を正しく Ebiten 画面に描画できていない。
次のアクション:
-
pkg/chip8/opcodes.go
のdrawSprite
関数内に詳細なデバッグログを追加し、以下の点を確認する。- ループが期待通りに回っているか。
- 読み込んでいるスプライトデータ (
spriteByte
) は正しいか。 -
gfx
配列のインデックス計算 (gfxIndex
) は正しいか。 -
gfx
配列の値が XOR ロジックによって実際に変更されているか。 - 衝突検出 (
c.V[0xF] = 1
) は意図せず発生していないか。 - 関数の最後に
c.drawFlag = true
が実行されているか。
- 修正したコードで再度テスト ROM (
go run ./cmd/chip8 -rom roms/test_cls_drw.ch8
) を実行し、追加したデバッグログと画面表示を確認して、問題箇所を特定・修正する。
関連ファイル:
day37_chip8_emulator_go/pkg/chip8/chip8.go
day37_chip8_emulator_go/pkg/chip8/opcodes.go
day37_chip8_emulator_go/cmd/chip8/main.go
-
day37_chip8_emulator_go/roms/test_cls_drw.ch8
(内容:\x00\xE0\x60\x05\x61\x05\xA0\x50\xD0\x15\x12\x0A
)
作業ディレクトリ:
/Users/noname/Cording/100day_challenge_backend/day37_chip8_emulator_go
その他:
- プロジェクト全体の指示(使用技術、コーディングスタイル、日本語での応答など)は別途確認してください。
- 最終的に Step 6 が完了したら(テスト ROM で '0' が表示されたら)、デバッグログを削除し、変更をコミットしてください (コミットメッセージ:
feat(chip8): Implement CPU cycle and basic opcodes (CLS, JP, LD Vx, DRW)
)。

やっと 0 だせた

なかなか次のキーボードのところがうまくいかない

これを参考にしてもらう

全くうまくいかないので、最初からやり直す。
また ebiten を途中から入れるのではなく chip8 を作り切ってからにする

o3 にレビューしてもらう
chip8のエミュレーターを作ろうとしています。
この内容を見て、
以下の手順についてレビューをし改善点をだして
はい、承知いたしました。省略せずに、Ebiten基本動作確認ステップを含めたCHIP-8エミュレータ開発の全ステップを以下に記載します。
Day 37: CHIP-8 エミュレーター (Go) - 再改訂実装ステップ (完全版)
フェーズ 1: CHIP-8 コアロジックの実装とテスト
-
Step 1: プロジェクト初期化
- ワークスペースルートに
day37_chip8_emulator_go
ディレクトリを作成します。 - ターミナルで
cd day37_chip8_emulator_go
を実行し、作成したディレクトリに移動します。 -
go mod init github.com/lirlia/100day_challenge_backend/day37_chip8_emulator_go
を実行して Go Modules を初期化します。 -
cmd/chip8_tester
,pkg/chip8
,roms
ディレクトリを作成します。(chip8_tester
はEbitenを使わないコアロジックテスト用です) -
day37_chip8_emulator_go
ディレクトリ直下にREADME.md
を作成し、プロジェクトの目的、基本的な使い方などを記述します。 - テスト: 上記コマンドがエラーなく完了し、期待されるディレクトリとファイルが作成されていることを確認します。
-
コミット:
git add .
(または個別のファイル指定)、git commit -m "init: Initialize project structure for day37_chip8_emulator_go (core logic first)"
- ワークスペースルートに
-
Step 2: CHIP-8 コア構造体定義
-
pkg/chip8/chip8.go
ファイルを作成します。 -
Chip8
構造体を定義します。これには以下のフィールドが含まれます:-
memory [4096]byte
: メモリ -
V [16]byte
: 汎用レジスタ V0-VF -
I uint16
: インデックスレジスタ I -
PC uint16
: プログラムカウンタ PC -
stack [16]uint16
: スタック -
SP uint8
: スタックポインタ SP -
gfx [64 * 32]byte
: グラフィックバッファ (1ピクセル1バイト、0か1) -
drawnGfx [64 * 32]byte
: 前回の描画状態を保持するバッファ(差分描画用) -
clearScreenRequested bool
: CLS命令が実行されたかを示すフラグ -
DT byte
: 遅延タイマー -
ST byte
: サウンドタイマー -
keys [16]bool
: キー入力状態 (押されていれば true) -
waitingForKey bool
: Fx0A命令でキー入力を待っているか -
keyReg byte
: Fx0A命令でキーを格納するレジスタ番号
-
-
fontSet
配列を定義し、0-Fまでのフォントデータを格納します。 -
New() *Chip8
関数を実装します。この関数は以下の初期化処理を行います:-
Chip8
構造体のインスタンスを作成します。 - メモリ全体を0でクリアします。
- レジスタ V0-VF を0でクリアします。
- スタックを0でクリアします。
-
gfx
およびdrawnGfx
バッファを0でクリアします。 -
keys
配列を全てfalse
で初期化します。 -
I
,PC
(通常0x200
),SP
を初期値に設定します。 -
DT
,ST
を0に設定します。 -
waitingForKey
,clearScreenRequested
をfalse
に設定します。 - メモリの特定領域 (例:
0x050
から) にfontSet
をロードします。 - 初期化完了のログメッセージを出力します。
-
-
テスト:
pkg/chip8/chip8_test.go
を作成します。TestNewChip8
関数を定義し、New()
で作成されたChip8
インスタンスの各フィールドが期待通りの初期値(メモリのフォント部分が正しくコピーされているか、PCが0x200
か、SPが0か、レジスタがゼロクリアされているかなど)になっていることをアサーションを使って確認します。 -
コミット:
git add pkg/chip8/chip8.go pkg/chip8/chip8_test.go
,git commit -m "feat(chip8): Define core Chip8 struct and initialization logic with unit tests"
-
-
Step 3: ROM ローダー実装
-
pkg/chip8/chip8.go
のChip8
型にLoadROM(path string) error
メソッドを実装します。 - このメソッドは、指定されたファイルパスからROMファイルを読み込みます。
- ROMデータのサイズがメモリの搭載可能領域(
0xFFF - 0x200 + 1
)を超えていないかチェックします。 - 問題なければ、ROMデータをメモリの
0x200
以降にコピーします。 - ロード成功または失敗のログメッセージを出力します。
-
テスト:
chip8_test.go
にTestLoadROM
関数を追加します。- テスト用に数バイトのダミーROMファイル(例:
test_roms/dummy.ch8
)を作成します。 -
LoadROM
を呼び出し、エラーが発生しないこと、メモリの0x200
以降にダミーROMの内容が正しくコピーされていることを確認します。 - 大きすぎるROMファイルをロードしようとした場合にエラーが返ることを確認します。
- 存在しないファイルを指定した場合にエラーが返ることを確認します。
- テスト用に数バイトのダミーROMファイル(例:
-
コミット:
git add pkg/chip8/chip8.go pkg/chip8/chip8_test.go roms/dummy.ch8
(もしテストROMを追加したら),git commit -m "feat(chip8): Implement ROM loading functionality with unit tests"
-
-
Step 4: CPU サイクルとコア命令実装 (CLS, JP, LD Vx,byte, ADD Vx,byte, DRW)
-
pkg/chip8/chip8.go
にCycle() bool
メソッドを実装します。このメソッドは1サイクルのCPU動作をエミュレートし、描画が必要な操作(CLSまたはDRW)が行われた場合にtrue
を返します。-
fetchOpcode() uint16
ヘルパーメソッドを実装し、PC
から2バイトのオペコードをフェッチします。 -
executeOpcode(opcode uint16) bool
ヘルパーメソッドを実装し、オペコードをデコード・実行し、描画が必要だったか否かを返します。 -
Cycle
メソッド内で、fetchOpcode
でオペコードを取得し、executeOpcode
を呼び出します。 -
重要:
executeOpcode
実行後、次の命令のためにPC
を2バイト進めます。ただし、ジャンプやコール命令など、PC
を直接変更する命令の場合は、その命令内でPC
が設定されるため、二重に進めないように注意が必要です。このPC管理戦略を明確にします(例:executeOpcode
はPCを進めず、Cycle
の末尾で、PCが命令によって変更されなかった場合のみPC += 2
する、など)。
-
-
pkg/chip8/opcodes.go
ファイルを作成します (またはchip8.go
内に実装を続けることも可能です)。 -
executeOpcode
の中でswitch
文を使い、以下のオペコードのデコードと実行ロジックを実装します:-
00E0
(CLS):gfx
バッファ全体を0で埋めます。clearScreenRequested
フラグをtrue
に設定します。true
を返します。 -
1nnn
(JP addr):PC
をアドレスnnn
に設定します。false
を返します。 -
6xkk
(LD Vx, byte): レジスタVx
に値kk
をロードします。PC += 2
。false
を返します。 -
7xkk
(ADD Vx, byte): レジスタVx
の値にkk
を加算し、結果をVx
に格納します(キャリーフラグVF
は変更しません)。PC += 2
。false
を返します。 -
Dxyn
(DRW Vx, Vy, nibble):- レジスタ
Vx
とVy
から描画開始座標 (x, y) を取得します (画面サイズで剰余を取る)。 -
I
レジスタが指すメモリアドレスから始まるn
バイトのスプライトデータを読み込みます。 - 各バイトをビット列とみなし、画面の対応するピクセルとXOR演算します。
- いずれかのピクセルが1から0に変わった(衝突した)場合、
VF
レジスタを1に設定します。それ以外の場合は0に設定します。 - 実際に画面のピクセルが変更された場合は
pixelChanged
のような内部フラグを立てます。 -
PC += 2
。 -
pixelChanged
がtrue
ならtrue
を返し、そうでなければfalse
を返します。
- レジスタ
-
-
テスト:
chip8_test.go
に各命令のテストケースを追加します (TestOpcodeCLS
,TestOpcodeJP
,TestOpcodeLD6xkk
,TestOpcodeADD7xkk
,TestOpcodeDRWDxyn
)。- 初期状態を設定し、オペコードを実行(
Cycle
または直接executeOpcode
を呼び出す)、結果のレジスタ値、PC、gfx
バッファ、VF
レジスタなどが期待通りか確認します。 - DRW命令では、衝突あり・なし、画面外への描画(クリッピングされるか、あるいはエラー処理が適切かなど、仕様による)のケースをテストします。
-
Cycle
メソッドがCLSやDRW(ピクセル変更あり)の場合にtrue
を返し、それ以外の場合にfalse
を返すことを確認します。
- 初期状態を設定し、オペコードを実行(
-
コミット:
git add .
,git commit -m "feat(chip8): Implement CPU cycle and core opcodes (CLS, JP, LD, ADD, DRW) with unit tests"
-
-
Step 5: タイマー実装
-
pkg/chip8/chip8.go
のChip8
構造体のDT
(遅延タイマー) とST
(サウンドタイマー) フィールドを使用します。 -
Cycle()
メソッドの最後(または適切なタイミング)で、DT
とST
が0より大きい場合にそれぞれ1ずつデクリメントする処理を追加します。CHIP-8のタイマーは60Hzでデクリメントされるのが一般的ですが、Cycle()
の呼び出し頻度に依存するため、ここでは単純にCycle()
ごとにデクリメントします(速度調整は後段)。 -
pkg/chip8/opcodes.go
(またはchip8.go
) に以下のタイマー関連命令を実装します:-
Fx07
(LD Vx, DT): レジスタVx
にDT
の値をロードします。PC += 2
。 -
Fx15
(LD DT, Vx):DT
にレジスタVx
の値をロードします。PC += 2
。 -
Fx18
(LD ST, Vx):ST
にレジスタVx
の値をロードします。PC += 2
。
-
-
テスト:
chip8_test.go
にタイマー関連命令のテストケース (TestOpcodeLDVxDT
,TestOpcodeLDDTVx
,TestOpcodeLDSTVx
) を追加します。- タイマー値をセットし、数サイクル実行後にタイマー値が正しくデクリメントされていることを確認します。
- レジスタとタイマー間で値が正しくロードされることを確認します。
-
コミット:
git add .
,git commit -m "feat(chip8): Implement delay and sound timers and related opcodes with unit tests"
-
-
Step 6: キー入力関連命令と状態管理 (表示なし)
-
pkg/chip8/chip8.go
のChip8
構造体のkeys [16]bool
,waitingForKey bool
,keyReg byte
フィールドを使用します。 -
SetKey(keyIndex int, pressed bool)
メソッドをChip8
に追加し、外部から特定のキーの押下状態を設定できるようにします。 -
pkg/chip8/opcodes.go
(またはchip8.go
) に以下のキー入力関連命令を実装します:-
Ex9E
(SKP Vx): レジスタVx
に格納されているキー番号に対応するキーが押されていれば(keys
配列でtrue
ならば)、PC
をさらに2バイト進めます(合計4バイトスキップ)。そうでなければPC += 2
。 -
ExA1
(SKNP Vx): レジスタVx
に格納されているキー番号に対応するキーが押されていなければ、PC
をさらに2バイト進めます。そうでなければPC += 2
。 -
Fx0A
(LD Vx, K):-
waitingForKey
フラグをtrue
に設定します。 - キーを格納する対象のレジスタ番号
x
をkeyReg
に保存します。 - この命令が実行された時点では
PC
を進めません (キー入力待ちのため)。 -
Cycle()
メソッドの先頭でwaitingForKey
がtrue
の場合、いずれかのキーが押されるまで処理を中断(または何もせずにreturn false
)します。キーが押されたら、そのキー番号をV[keyReg]
に格納し、waitingForKey
をfalse
にし、PC += 2
して次の命令に進みます。
-
-
-
テスト:
chip8_test.go
にキー入力関連命令のテストケース (TestOpcodeSKP
,TestOpcodeSKNP
,TestOpcodeLDVxK
) を追加します。-
SetKey
でキー状態を様々に設定し、SKP/SKNP命令がPCを正しくスキップするか確認します。 - Fx0A命令を実行後、
waitingForKey
がtrue
になることを確認。その後、任意のキーを押した状態をSetKey
で作り、次のCycle
呼び出しでwaitingForKey
がfalse
になり、指定したレジスタにキー番号が格納され、PCが進むことを確認します。
-
-
コミット:
git add .
,git commit -m "feat(chip8): Implement keyboard state and related opcodes with unit tests"
-
-
Step 7: 残りの主要命令実装 (カテゴリごと、テスト重視)
-
pkg/chip8/opcodes.go
(またはchip8.go
) に、これまで未実装だった残りのCHIP-8命令をカテゴリごとに実装していきます。各命令の詳細はCHIP-8の仕様書を参照してください。-
算術/論理演算 (8xy0-8xy7, 8xyEなど):
-
8xy0
(LD Vx, Vy) -
8xy1
(OR Vx, Vy) -
8xy2
(AND Vx, Vy) -
8xy3
(XOR Vx, Vy) -
8xy4
(ADD Vx, Vy):VF
にキャリーを設定。 -
8xy5
(SUB Vx, Vy):VF
にボローなし(Vx > Vy)の場合1を設定。 -
8xy6
(SHR Vx {, Vy}):Vx
を右シフト。最下位ビットをVF
に。 -
8xy7
(SUBN Vx, Vy):VF
にボローなし(Vy > Vx)の場合1を設定。 -
8xyE
(SHL Vx {, Vy}):Vx
を左シフト。最上位ビットをVF
に。
-
-
条件分岐 (3xkk, 4xkk, 5xy0, 9xy0):
-
3xkk
(SE Vx, byte) -
4xkk
(SNE Vx, byte) -
5xy0
(SE Vx, Vy) -
9xy0
(SNE Vx, Vy)
-
-
サブルーチン呼び出し/リターン (2nnn, 00EE):
-
2nnn
(CALL addr): 現在のPC
をスタックに積み、SP
をインクリメント。PC
をnnn
に設定。 -
00EE
(RET):SP
をデクリメントし、スタックからアドレスを取り出してPC
に設定。
-
-
インデックスレジスタ操作 (Annn, Fx1E):
-
Annn
(LD I, addr):I
レジスタにアドレスnnn
をロード。 -
Fx1E
(ADD I, Vx):I
レジスタの値にVx
の値を加算し、結果をI
に格納。
-
-
メモリ操作 (Fx29, Fx33, Fx55, Fx65):
-
Fx29
(LD F, Vx):I
レジスタに、レジスタVx
の値(0-F)に対応するフォント文字のスプライトデータが格納されているメモリアドレスを設定(フォントはメモリの先頭付近にロード済み)。 -
Fx33
(LD B, Vx): レジスタVx
の値をBCD(二進化十進表現)に変換し、M[I]
に百の位、M[I+1]
に十の位、M[I+2]
に一の位を格納。 -
Fx55
(LD [I], Vx): レジスタV0
からVx
(両端含む) までの値を、メモリのI
レジスタが指すアドレスから順に格納。格納後I
はI + x + 1
になるか、変わらないかは仕様による(確認が必要)。 -
Fx65
(LD Vx, [I]): メモリのI
レジスタが指すアドレスから順に値を読み出し、レジスタV0
からVx
(両端含む) までに格納。読み出し後I
はI + x + 1
になるか、変わらないかは仕様による。
-
-
乱数 (Cxkk):
-
Cxkk
(RND Vx, byte): 0から255までの乱数を生成し、kk
とAND演算した結果をレジスタVx
に格納。Goのmath/rand
パッケージを利用。
-
-
算術/論理演算 (8xy0-8xy7, 8xyEなど):
-
テスト:
chip8_test.go
に、実装した各命令カテゴリまたは個々の命令に対する詳細なテストケースを作成します。レジスタ、メモリ、PC、I、SP、VFなどの状態変化を細かく確認します。特にスタック操作やメモリ操作、フラグの扱いは注意深くテストします。 -
コミット: カテゴリごとに
git add .
,git commit -m "feat(chip8): Implement <カテゴリ名> opcodes with unit tests"
のようにコミットを分けます。 (例:feat(chip8): Implement arithmetic, logic, and flow control opcodes with unit tests
)
-
-
Step 8: コアロジックの統合テスト (テストROM実行、
chip8_tester
)-
cmd/chip8_tester/main.go
を作成します。これはEbitenを使わない、シンプルなコマンドラインベースのテストランナーです。 -
main
関数で、コマンドライン引数からテストROMのパスを受け取ります。 -
chip8.New()
でエミュレータを初期化し、LoadROM()
でROMをロードします。 - メインループを作成し、その中で
chip8.Cycle()
を呼び出します。ループは一定回数(例: 1000サイクル)、または特定のROMが終了を示す状態(例: 特定の無限ループに入った)になるまで実行します。 - オプションで、各サイクルの実行後に
Chip8
の状態(PC, I, SP, Vレジスタ, DT, ST, スタックの一部、gfx
バッファのハッシュ値や一部ダンプなど)をfmt.Println
やlog.Println
で出力するようにします。 -
roms/
ディレクトリに、kripod/chip8-roms (John Lin's chip8-test-rom) や Timendus/chip8-test-suite (SUPER-CHIPのテストも含むので注意) から、基本的なテストROM (BC_test.ch8
,test_opcode.ch8
,IBM Logo.ch8
,Corax+.ch8
など) をダウンロードして配置します。 -
chip8_tester
を使ってこれらのテストROMを実行し、期待される動作をするか(特定の手順でログに出力されるレジスタ値やメモリ内容が正しいか、期待されるグラフィックパターンがgfx
バッファに形成されるかなど)を確認します。期待値は、他のエミュレータの実装やテストROMのドキュメントを参考にします。 -
テスト:
go run ./cmd/chip8_tester/main.go roms/test_opcode.ch8
のように実行し、出力ログや(もし実装すれば)gfx
バッファの簡易表示を検証します。バグがあればpkg/chip8
のコードを修正し、再度テストします。 -
コミット:
git add .
,git commit -m "test(chip8): Perform integration testing with standard test ROMs using chip8_tester"
-
フェーズ 2: 表示の準備と実装
-
Step 9: Ebiten 基本動作確認
-
cmd/ebiten_sandbox/main.go
という一時的なファイル(またはディレクトリ)を作成します。 -
go get github.com/hajimehoshi/ebiten/v2
を実行してEbitenライブラリを取得します (Step 1でgo.mod
に追加していれば不要な場合も)。 - Ebitenの公式ドキュメント (https://pkg.go.dev/github.com/hajimehoshi/ebiten/v2) のサンプルやAPIリファレンスを参照しながら、以下の基本的なEbitenの機能を試す簡単なプログラムを作成します。
-
ebiten.Game
インターフェース(Update
,Draw
,Layout
メソッド)を実装したSandboxGame
構造体を作成します。 -
main
関数でebiten.SetWindowTitle
,ebiten.SetWindowSize
を試し、ebiten.RunGame(&SandboxGame{})
を呼び出してウィンドウを表示します。 -
Draw
関数内でscreen.Fill(color.RGBA{...})
を使って画面全体を特定の色で塗りつぶします。 -
ebitenutil.DrawRect
(デバッグ用) やvector.DrawFilledRect
を使って、画面に単純な矩形を描画します。座標系を確認します。 -
Update
関数内でebiten.IsKeyPressed(ebiten.KeyA)
やinpututil.IsKeyJustPressed(ebiten.KeySpace)
を使ってキーボードからの入力を検知し、その結果をコンソールにfmt.Println
で出力します。 -
ebiten.CursorPosition()
でマウスカーソルの座標を取得し、コンソールに出力します。 -
ebitenutil.DebugPrint(screen, "Hello Ebiten!")
やtext.Draw
(フォントのロードが必要な場合あり) を使って、画面に簡単な文字を表示します。
-
- 目的: Ebitenの基本的なゲームループ、描画API、入力処理、座標系、ウィンドウ管理について実践的に理解します。これはCHIP-8エミュレータのコアロジックとは独立して行い、Ebitenライブラリ自体の使い方に習熟することを目的とします。
-
テスト: 作成したサンドボックスプログラム (
go run ./cmd/ebiten_sandbox/main.go
) を実行し、ウィンドウが表示され、描画や入力が期待通りに動作することを確認します。 -
コミット:
git add cmd/ebiten_sandbox/main.go go.mod go.sum
,git commit -m "feat(sandbox): Verify basic Ebiten functionalities (window, draw, input, text)"
- (このサンドボックスコードは、CHIP-8エミュレータ本体とは別管理にするか、学習用として後でリポジトリから削除しても問題ありません)
-
-
Step 10: 表示方法の検討と決定
- この時点で、CHIP-8エミュレータのコアロジックは単体でほぼ正しく動作し、Ebitenライブラリの基本的な使い方も理解できている状態です。
- ここで、CHIP-8エミュレータのグラフィックとインタラクションをどのようにユーザーに提供するか、最終的な表示方法を検討し決定します。
-
Ebiten を使用:
- メリット: リッチなグラフィック、スムーズなアニメーション、サウンド再生(STタイマーと連携可能)、標準的なゲーム開発体験。
- デメリット: ちらつき問題への再対処が必要になる可能性、依存ライブラリが増える。
-
ターミナル表示:
- メリット: 依存が少ない、シンプル、デバッグが容易な場合がある。
- デメリット: 視覚的魅力に欠ける(アスキーアート)、インタラクションの制約、サウンド不可。
-
Ebiten を使用:
- ユーザー(あなた)と相談し、プロジェクトの目的や学習目標に合わせて、どちらの方法で進めるか、あるいは両方試してみるかを決定します。
-
Step 11-A: Ebiten でのCHIP-8表示実装 (Ebitenを選択した場合)
-
cmd/chip8_ebiten/main.go
を作成します(またはcmd/chip8/main.go
として)。 -
ebiten.Game
インターフェースを実装するGame
構造体を定義します。この構造体は*chip8.Chip8
のインスタンスをフィールドとして保持します。 -
main
関数:- コマンドライン引数からROMファイルのパスを取得します。
-
chip8.New()
でCHIP-8インスタンスを作成し、LoadROM()
でROMをロードします。 -
ebiten.SetWindowTitle
,ebiten.SetWindowSize
(CHIP-8の64x32画面をスケールアップしたサイズ) などを設定します。 -
ebiten.RunGame()
にGame
インスタンスを渡してゲームループを開始します。
-
Game
構造体のUpdate()
メソッド:- Ebitenのキー入力関数 (
ebiten.IsKeyPressed
など) を使用して、物理キーボードの入力をCHIP-8の16キーにマッピングし、chip8Instance.SetKey()
を呼び出してCHIP-8側のキー状態を更新します。 - CHIP-8のCPU速度を調整するため、1フレームあたりに
chip8Instance.Cycle()
を呼び出す回数を設定します(例: 1秒間に500サイクルなら、TPSが60の場合、1フレームあたり約8回)。 -
chip8Instance.Cycle()
の戻り値(描画が必要か否か)を受け取り、Game
構造体内のフラグ (needsRedraw
) を更新します。
- Ebitenのキー入力関数 (
-
Game
構造体のDraw()
メソッド:- まず
chip8Instance.WasClearScreenRequestedAndReset()
を呼び出し、CLS命令が実行されていたか確認します。もしtrue
ならscreen.Fill(backgroundColor)
で画面全体をクリアします。 -
Update
で設定されたneedsRedraw
フラグ、またはWasClearScreenRequestedAndReset
がtrue
だった場合にのみ描画処理を行います(最適化)。 -
chip8Instance.Gfx()
を呼び出して現在のグラフィックバッファを取得します。 - 取得した64x32の
gfx
バッファを走査し、ON (1) になっているピクセルをscreen
(Ebitenの描画ターゲット) の対応する位置に描画します。ピクセルはスケールアップして表示(例: CHIP-8の1ピクセルを画面上の10x10の矩形としてvector.DrawFilledRect
で描画)。 - (オプション)差分描画を実装する場合:
chip8Instance.DrawnGfx()
も取得し、現在のgfx
と比較して変更があったピクセルのみを描画します。 - 描画が完了したら
chip8Instance.UpdateDrawnGfx()
を呼び出して、drawnGfx
バッファを現在の状態に更新します。
- まず
-
Game
構造体のLayout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int)
メソッド:- CHIP-8の論理的な画面サイズ (64, 32) を返します。Ebitenがこれを元に実際のウィンドウサイズに合わせてスケーリングなどを処理します。
-
テスト: まずは
roms
にある単純な描画テストROM (例:IBM Logo.ch8
) で正しく表示されるか確認します。次に、PONG
,INVADERS
などのゲームROMを実行し、描画、キー入力、ゲームスピードが適切か確認します。ちらつきが発生する場合は、SetScreenClearedEveryFrame(false)
の設定や差分描画ロジックを見直します。 -
コミット:
git add .
,git commit -m "feat(ebiten): Implement Chip8 display, input, and game loop using Ebiten"
-
-
Step 11-B: ターミナルでのCHIP-8表示実装 (ターミナル表示を選択した場合)
-
cmd/chip8_terminal/main.go
を作成します。 - Goのターミナル操作ライブラリ(例:
github.com/nsf/termbox-go
やgithub.com/gdamore/tcell/v2
)を選定し、go get
で依存関係に追加します。あるいは、標準のfmt.Print
とエスケープシーケンスで頑張ることも可能です。 -
main
関数:- コマンドライン引数からROMファイルのパスを取得。
-
chip8.New()
とLoadROM()
でCHIP-8を初期化。 - ターミナルライブラリを初期化。
- ターミナル描画関数を作成:
-
chip8.Gfx()
を取得。 - ターミナル画面をクリア(またはカーソルを左上に移動)。
-
gfx
バッファを走査し、ONのピクセルを#
や*
、OFFのピクセルをスペースなどでターミナルに出力。
-
- ターミナル入力関数を作成:
- ターミナルライブラリの機能を使って非同期または同期でキー入力を取得。
- 取得したキーをCHIP-8の16キーにマッピングし、
chip8Instance.SetKey()
を呼び出す。
- メインループ:
- 一定間隔(
time.Sleep
などで調整)でループ。 - 入力処理関数を呼び出す。
-
chip8Instance.Cycle()
を適切な回数呼び出す。 - 描画関数を呼び出してターミナル画面を更新。
- 一定間隔(
- プログラム終了時にターミナルライブラリを正しく終了処理します。
-
テスト: 簡単なテストROMから始め、
gfx
バッファの内容がターミナルに期待通りにアスキーアートとして表示されるか、キー入力が認識されるかを確認します。 -
コミット:
git add .
,git commit -m "feat(terminal): Implement Chip8 display and interaction in terminal"
-
フェーズ 3: 仕上げ
-
Step 12: ゲーム ROM での最終テストと調整 (選択した表示方法で)
-
roms/
ディレクトリに、より複雑なゲームROM(例:PONG (1 player).ch8
,INVADERS.ch8
,TETRIS.ch8
,BRIX.ch8
など)を配置します。 - 選択した表示方法(Ebitenまたはターミナル)でこれらのゲームROMを実行します。
- ゲームが正しくプレイできるか、描画に大きな問題がないか、キー入力は適切に反応するか、ゲームスピードは快適かなどを確認します。
- 問題点があれば、
pkg/chip8
のコアロジック、またはcmd/
配下の表示・入力処理コードをデバッグ・修正します。 - CPUの実行速度(
Cycle()
の呼び出し頻度)や、Ebitenの場合の画面更新設定などを微調整します。 -
コミット:
git add .
,git commit -m "fix: Debug and adjust emulator based on game ROM testing for <display_method>"
(例:fix: Debug and adjust emulator based on game ROM testing for Ebiten
)
-
-
Step 13: ドキュメント更新とコード整理
-
README.md
を最終更新します。含める情報:- プロジェクトの概要。
- ビルド方法 (
go build ./cmd/...
)。 - 実行方法(コマンドライン引数、ROMの配置場所など)。
- 使用するキーボードのキーとCHIP-8のキーのマッピング。
- 既知の問題点や今後の改善点(もしあれば)。
- コード全体のコメントを見直し、不要なデバッグログ (
fmt.Println
や詳細すぎたlog.Println
) を削除またはコメントアウトします。 - コードのフォーマットを
go fmt ./...
で統一します。 - (オプション)
go vet ./...
やgolangci-lint run
などで静的解析を行い、潜在的な問題を修正します。 -
コミット:
git add .
,git commit -m "docs: Finalize README, clean up code, and prepare for v1.0"
-
これで、より詳細で網羅的なステップリストになったかと思います。この計画で進めていきましょう。

全体的なフィードバック — いまの手順は網羅的で非常に良いです
とくに コアと UI を分離 し、単体テスト→統合テスト→UI 組み込みという順序は理想的です。
一方で、Go 1.22 以降の言語機能や Ebiten の挙動(Update が既定で 60 TPS など)を活かすと、さらにシンプルかつ安全になります。以下では 共通改善ポイント → ステップ別コメント の順にまとめました。
⸻
共通改善ポイント
項目 改善提案
パッケージ構成 pkg/ は OSS 配布用に外部公開 API を示す慣例ですが、今回は CLI 2 本だけが使い手です。internal/chip8 としておくと 誤用をコンパイル時に防げる のでおすすめです。
エミュレータの時間管理 CHIP‑8 の DT/ST は 60 Hz で減算 。Ebiten は Update を既定で 60 TPS で呼ぶ ので、Update で 1 Cycle + タイマー処理を行えば実装が自然 になります(Step 5 の「Cycle ごとにデクリメント」は Ebiten 版では不要)。CLI テスター側は time.Ticker(1/60 s) を使って同じロジックを共有しましょう。
CPU 周波数 実機は 500 ~ 700 Hz 程度と言われますが、ROM により前提がまちまちです。cyclesPerFrame := uint(8) のように 可変にしてコマンドラインフラグで上書きできるようにしておくとデバッグが楽です。
PC のインクリメント方針 命令実行側で PC を 必ず 2 増分し、変更が必要な命令だけ上書き する形(≒公式インタープリタ方式)が Go では実装が簡潔です。今の案(Cycle 末尾で増分判定)はテストが複雑になりがちです。
表現型 画面バッファは []uint8 より []bool / []byte でも性能差はほぼ無いので、差分描画ありなら [64*32]bool の方が読みやすいです。
テスト 1 命令 1 テストよりも 表形式 (table‑driven) テスト にすると 1 ファイルで網羅できます。失敗時にどの命令で壊れたかがすぐ分かります。
CI / Lint GitHub Actions で go test ./..., go vet ./..., golangci-lint run を回しておくと後半で UI を触り始めても壊れにくいです。
ROM / フォントの配布 Go 1.16+ の //go:embed で標準フォントセットやテスト ROM を埋め込めば、roms/ が無くてもテストが動く ようになります。
⸻
ステップ別コメント
Step 1 – プロジェクト初期化
• go mod init <module> のあとに go work init && go work use ./... を付けておくと、将来ほかの 100day プロジェクトを同一ワークスペースで管理しやすくなります。
Step 2 – 構造体定義
• fontSet は var fontSet = [...]byte{} ではなく const fontSet = [...]byte{} にすると ミスで書き換えられません。
• drawnGfx までコアに置くと構造体が肥大化します。差分描画は UI 側の責務にして、**「画面バッファは常に真値」**とした方が役割分離が明確です。
Step 3 – ROM ローダ
• io.ReadAll より os.ReadFile の方が短く書け、stat でサイズチェックするより len(data) で実測した方が安全です。
Step 4 – CPU サイクル実装
• 命令デコードは switch opcode & 0xF000 だけだと 0x00E0 / 0x00EE の区別が面倒なので、下位 12 ビットまでマスクしたキーを作ると読みやすいです。
switch key := opcode & 0xF000; key {
case 0x0000:
switch opcode {
case 0x00E0: … // CLS
case 0x00EE: … // RET
}
case 0x1000: … // JP
}
• DRW は 衝突検出 (VF) と描画変更フラグ を分けるとテストしやすくなります(衝突は発生しても、実際のピクセルは変わらないケースがあるため)。
Step 5 – タイマー
• Ebiten 版では Update() 内で
for i := 0; i < cyclesPerFrame; i++ { c.Cycle() }
if c.DT > 0 { c.DT-- }
if c.ST > 0 { c.ST-- }
とし、CLI テスターは time.NewTicker(time.Second/60) で同じ関数を呼び出すだけで済みます。
Step 6 – キー入力
• waitingForKey でポーリングを止めると音も止まります。実機では停止中でもタイマーは止まらないので、キー待ちに入ってもタイマー処理だけは走らせるようにしましょう(ゲームによっては重要です)。
Step 7 – 残り命令
• SHL/SHR の「仕様が 2 系統ある」(原仕様 vs. VIP) 問題に注意してください。テスト ROM に合わせて -variant vip|schip フラグで切り替えられる実装が無難です。
• Fx55/Fx65 も同様に I レジスタの Auto‑Increment の有無が実装差分ポイントです。
Step 8 – 統合テスト
• John Lindsey の chip8-test-rom は スプライトラップアラウンドをテストするものがあります。UI 無しだと失敗を検出しづらいので hash(gfx) 出力ではなく PNG にスナップショットを書き出すと人間が目視できます(image/png と os.Create で簡単に可視化できます)。
Step 9 – Ebiten サンドボックス
• Ebiten で「ピクセルを 10×10 で描画」は動きますが、1 フレームごとに 2048 個の矩形を都度生成すると GC 圧が増します。ebiten.NewImage(64,32) を作って img.Set(i,j,color) で書き換え → ドラッグコピーでスケール描画 の方が軽量です。
• ちらつきが出た場合は ebiten.SetScreenClearedEveryFrame(false) を使うより 差分描画 + Offscreen 画像にフル描画→screen.DrawImage がシンプルです。
Step 10 – 表示方法の検討
• 迷ったら Ebiten一択で OK です。ターミナル表示はデバッグには便利ですが入力が 16 キー固定の CHIP‑8 では非実用的です。テスト用に残す場合は -headless フラグで CLI を起動する程度で十分でしょう。
Step 11‑A – Ebiten 実装
• Ebiten 2.7+ では InputChars() が UTF‑8 rune を返すので、数字キー→CHIP‑8 キー番号のマッピングは switch r { case '1': … } の方が OS 非依存です。
• 音は ebiten/v2/audio と audio/wav を使って 440 Hz 正弦波 0.1 s の []byte を事前生成し、ST>0 の間ループ再生すると簡単に「ビーッ」という音が出せます。
Step 12 – ゲーム ROM テスト
• スピードが合わない場合は Delay Timer に依存しない ROM かを確認してください。INVADERS は DT=0 でも自前で wait ループするので、実装不具合をあぶり出しやすいです。
Step 13 – ドキュメント
• README のキーマップは US 配列/JIS 配列の両方を例示しておくと親切です(JIS だと 2 6 8 A がズレるため)。
⸻
まとめ
大枠の流れは完璧なので、上記細部を調整するだけで 保守しやすく実機互換度の高いエミュレータ になります。とくに 60 Hz ≠ CPU Hz の切り分けと PC 増分戦略 を早めに確定しておくと後工程のバグが激減します。がんばってください!

**Day 37: CHIP-8 エミュレーター (Go) - レビュー反映版・実装ステップ **
フェーズ 1: CHIP-8 コアロジックの実装とテスト
-
Step 1: プロジェクト初期化
- ワークスペースルートに
day37_chip8_emulator_go
ディレクトリを作成します。 - ターミナルで
cd day37_chip8_emulator_go
を実行し、作成したディレクトリに移動します。 -
go mod init github.com/lirlia/100day_challenge_backend/day37_chip8_emulator_go
を実行して Go Modules を初期化します。 -
cmd/chip8_tester
,internal/chip8
,roms
,assets
(フォントやテストROM埋め込み用) ディレクトリを作成します。 -
day37_chip8_emulator_go
ディレクトリ直下にREADME.md
を作成し、基本情報を記述します。 - テスト: 上記コマンドがエラーなく完了し、期待されるディレクトリとファイルが作成されていることを確認します。
-
コミット:
init: Initialize project structure and Go modules for day37_chip8_emulator_go
- ワークスペースルートに
-
Step 2: CHIP-8 コア構造体定義
-
internal/chip8/chip8.go
ファイルを作成します。 -
const fontSet = [...]byte{...}
としてフォントデータを定義します(書き換え不可にするため)。 -
//go:embed assets/fonts/chip8_font.bin
(仮のパス) のようにして、フォントセットをバイナリに埋め込む準備をします (assets
ディレクトリに実際のフォントデータを配置)。 -
Chip8
構造体を定義します。フィールド:memory [4096]byte
V [16]byte
I uint16
PC uint16
stack [16]uint16
SP uint8
-
gfx [64 * 32]byte
(またはbool
、可読性を考慮。ここでは一旦byte
とします) -
DT byte
(Delay Timer) -
ST byte
(Sound Timer) keys [16]bool
waitingForKey bool
keyReg byte
-
rng *rand.Rand
(乱数生成器、math/rand
を使用) -
cyclesPerFrame uint
(CPU速度調整用、デフォルト値設定) -
variantSCHIP bool
(SHL/SHR, Fx55/Fx65の挙動切り替え用フラグ、オプション)
-
New(cyclesPerFrame uint, variantSCHIP bool) *Chip8
関数を実装します。-
Chip8
インスタンスを作成し、引数で受け取った値をフィールドに設定。 -
rng
を初期化 (rand.New(rand.NewSource(time.Now().UnixNano()))
)。 - メモリクリア、レジスタクリア、スタッククリア、
gfx
クリア、キー初期化。 -
I
,PC
(0x200
),SP
初期化。 -
DT
,ST
,waitingForKey
初期化。 - 埋め込んだフォントセットをメモリの
fontOffset
(例:0x050
) にロード。
-
-
テスト:
internal/chip8/chip8_test.go
を作成します。Table-drivenテスト ([]struct{ name string; setup func(*Chip8); expected func(*testing.T, *Chip8) }
) の形式で、New()
の初期状態をテストします (メモリのフォント部分、PC, SP, Vレジスタ、タイマー、フラグなど)。 -
コミット:
feat(chip8): Define core Chip8 struct, initialization, font embedding, and basic unit tests
-
-
Step 3: ROM ローダー実装
-
internal/chip8/chip8.go
のChip8
型にLoadROM(romPath string) error
メソッドを実装します。-
os.ReadFile(romPath)
を使用してROMデータを読み込みます。 -
len(data)
で実測したROMサイズが搭載可能メモリを超えていないかチェックします。 - 問題なければ、ROMデータをメモリの
romOffset
(0x200
) 以降にコピーします。
-
- (オプション)テスト用にいくつかの小さなROMも
//go:embed assets/roms_test/...
で埋め込み、LoadROMFromEmbed(embedID string) error
のようなメソッドも用意することを検討します。 -
テスト:
chip8_test.go
のTable-drivenテストにLoadROM
のテストケースを追加。- 正常なROMロード、大きすぎるROM、存在しないROMのケースをテスト。
- 埋め込みROMからのロードもテスト(実装した場合)。
-
コミット:
feat(chip8): Implement ROM loading from file (and optionally embed) with tests
-
-
Step 4: CPU サイクルとコア命令実装 (CLS, JP, LD Vx,byte, ADD Vx,byte, DRW)
-
internal/chip8/chip8.go
にCycle() (redraw bool, collision bool, halted bool)
メソッドを実装します。redraw
は画面更新の要否、collision
はDRW命令の衝突、halted
はFx0Aでキー待ち状態かを示します。 -
internal/chip8/opcodes.go
ファイルを作成し、Chip8
のメソッドとしてexecuteOpcode(opcode uint16) (redraw bool, collision bool)
を実装します。-
PCインクリメント方針:
executeOpcode
内の各命令処理の最後に 原則としてc.PC += 2
を実行 します。ジャンプやコール命令など、PCを直接変更する命令の場合は、その命令のロジック内でc.PC
を上書き設定し、その後のc.PC += 2
は行わないか、あるいは行った後に正しい値で再度上書きします(後者の方が分岐が減りシンプルになる場合あり)。 - 命令デコードの改善(レビューフィードバックより):
switch opcode & 0xF000 { case 0x0000: switch opcode & 0x00FF { // より具体的なマスクを使用 case 0x00E0: // CLS // ... CLSの実装 ... // c.PC += 2 return true, false // redraw = true, collision = false case 0x00EE: // RET // ... RETの実装 (PCはスタックから設定、ここではPC+=2しない) ... return false, false default: // log.Printf("Unknown opcode: 0x%X", opcode) // c.PC += 2 return false, false } case 0x1000: // JP addr // ... JPの実装 (PCはnnnに設定、ここではPC+=2しない) ... return false, false // ... 他のケース ... case 0xD000: // DRW Vx, Vy, nibble // ... DRWの実装 ... // c.PC += 2 // return pixelChanged, collisionDetected default: // log.Printf("Unknown opcode prefix: 0x%X", opcode & 0xF000) // c.PC += 2 return false, false }
-
00E0
(CLS):gfx
バッファをクリア。 -
1nnn
(JP addr):PC
をnnn
に設定。 -
6xkk
(LD Vx, byte):Vx = kk
。 -
7xkk
(ADD Vx, byte):Vx = Vx + kk
(VFは変更しない)。 -
Dxyn
(DRW Vx, Vy, nibble):- (x, y)座標、スプライトデータを取得。
- XOR描画。
VF
に衝突を設定。 - 戻り値で実際にピクセルが変更されたか(
redraw
)と衝突があったか(collision
)を返す。
-
PCインクリメント方針:
-
Cycle
メソッドの実装:-
waitingForKey
がtrue
なら、キー入力処理。押されていればV[keyReg]
に格納、waitingForKey = false
、PC += 2
。return false, false, true
(halted)。 -
fetchOpcode()
(PCから2バイト読み込み、ただしPCはここでは進めない)。 -
redraw, collision := c.executeOpcode(opcode)
を呼び出し。 - (executeOpcode内でPC管理しない場合) PCを進める共通処理をここに入れるか検討。
- タイマー更新処理(後述Step 5で詳細化)。
-
return redraw, collision, false
。
-
-
テスト:
chip8_test.go
のTable-drivenテストに各命令のテストケースを追加。PCの挙動、レジスタ、メモリ、gfx
バッファ、VF、Cycle
の戻り値を確認。DRWではredraw
とcollision
が正しく分離して返るかテスト。 -
コミット:
feat(chip8): Implement CPU cycle, PC management, and core opcodes (CLS, JP, LD, ADD, DRW) with tests
-
-
Step 5: タイマー実装
-
internal/chip8/chip8.go
のChip8
構造体のDT
とST
を使用。 -
Chip8
にUpdateTimers()
メソッドを追加します。このメソッドはDT
とST
が0より大きい場合にそれぞれ1ずつデクリメントします。これはCHIP-8の60Hzタイマーを模擬します。 -
internal/chip8/opcodes.go
にタイマー関連命令を実装:-
Fx07
(LD Vx, DT) -
Fx15
(LD DT, Vx) -
Fx18
(LD ST, Vx) - これらの命令実行後
c.PC += 2
。
-
-
テスト:
chip8_test.go
にタイマー関連命令のテスト。UpdateTimers
を別途呼び出してタイマーがデクリメントされることを確認。 -
コミット:
feat(chip8): Implement delay/sound timers logic and related opcodes with tests
-
-
Step 6: キー入力関連命令と状態管理
-
internal/chip8/chip8.go
のkeys
,waitingForKey
,keyReg
を使用。 -
SetKey(keyIndex int, pressed bool)
メソッドをChip8
に実装。 -
IsKeyPressed(keyIndex int) bool
メソッドをChip8
に実装。 -
internal/chip8/opcodes.go
にキー入力関連命令を実装:-
Ex9E
(SKP Vx):if c.IsKeyPressed(c.V[x]) { c.PC += 2 }
の後、c.PC += 2
。 -
ExA1
(SKNP Vx):if !c.IsKeyPressed(c.V[x]) { c.PC += 2 }
の後、c.PC += 2
。 -
Fx0A
(LD Vx, K):c.waitingForKey = true; c.keyReg = x
。この命令ではPC
を進めない。
-
-
Cycle
メソッドの先頭でのwaitingForKey
時の処理を調整:if c.waitingForKey { for i, pressed := range c.keys { if pressed { c.V[c.keyReg] = byte(i) c.waitingForKey = false c.PC += 2 // Fx0A命令の完了としてPCを進める // c.keys[i] = false // キーイベントを消費するかは設計次第 log.Printf("Key %X pressed, V%X = %d", i, c.keyReg, c.V[c.keyReg]) c.UpdateTimers() // キー待ち中でもタイマーは進める return false, false, false // キーが押されたのでhaltedではない } } c.UpdateTimers() // キー待ち中でもタイマーは進める return false, false, true // まだキー待ち中 (halted) }
-
テスト:
chip8_test.go
にテストケース追加。SetKey
で状態を設定し、SKP/SKNP、Fx0Aの動作を確認。Fx0A実行後、キーを押してCycle
を再度呼び出すとwaitingForKey
が解除され、レジスタに値が入り、PCが進むことを確認。キー待ち中もタイマーが進む(UpdateTimers
が呼ばれる想定)ことをテスト設計で考慮。 -
コミット:
feat(chip8): Implement keyboard state, related opcodes, and timer update during key wait, with tests
-
-
Step 7: 残りの主要命令実装 (カテゴリごと、テスト重視)
-
internal/chip8/opcodes.go
に未実装の命令を実装。各命令後、適切にc.PC += 2
(または分岐命令等で直接PCを設定)。-
算術/論理演算 (8xy0-8xy7, 8xyE):
SHL
/SHR
はvariantSCHIP
フラグに応じて挙動を切り替える (オプションだが推奨)。 - 条件分岐 (3xkk, 4xkk, 5xy0, 9xy0)
- サブルーチン (2nnn, 00EE)
- インデックスレジスタ (Annn, Fx1E)
-
メモリ操作 (Fx29, Fx33, Fx55, Fx65):
Fx55
/Fx65
はvariantSCHIP
または別のフラグでI
の Auto-Increment有無を切り替える(オプションだが推奨)。 - 乱数 (Cxkk)
-
算術/論理演算 (8xy0-8xy7, 8xyE):
-
テスト:
chip8_test.go
で各命令カテゴリまたは個々の命令のTable-drivenテストを作成。 -
コミット: カテゴリごとにコミット (例:
feat(chip8): Implement arithmetic and logic opcodes with tests
)。
-
-
Step 8: コアロジックの統合テスト (
chip8_tester
)-
cmd/chip8_tester/main.go
を作成。- コマンドラインフラグでROMパス、
cyclesPerFrame
、variantSCHIP
を受け取れるようにする。 -
chip8.New()
で初期化、LoadROM()
。 - メインループ:
time.NewTicker(time.Second / 60)
を使用。Tickerが発火するたびに以下を実行:for i := 0; i < int(chip8Instance.CyclesPerFrame()); i++ { redraw, collision, halted = chip8Instance.Cycle() ... }
-
chip8Instance.UpdateTimers()
を呼び出す。 - (オプション)
gfx
バッファのハッシュ値や、特定のテストROMの期待出力をコンソールに出力。 - (推奨)
gfx
バッファをPNGファイルとして定期的に、またはテスト終了時に書き出す機能を追加 (image/png
とos.Create
)。
- コマンドラインフラグでROMパス、
-
roms/
にテストROM (BC_test, test_opcode, IBM Logo, Corax+, スプライト折り返しテストなど) を配置。//go:embed
で埋め込むことも検討。 -
テスト:
go run ./cmd/chip8_tester/main.go -rom roms/test_opcode.ch8 -cycles 8
などで実行。出力ログ、PNGスナップショットを目視確認。 -
コミット:
test(chip8): Implement chip8_tester for integration testing with test ROMs and PNG snapshot output
-
フェーズ 2: 表示の準備と実装
-
Step 9: Ebiten 基本動作確認
-
cmd/ebiten_sandbox/main.go
を作成。 -
go get github.com/hajimehoshi/ebiten/v2
を実行。 - Ebitenドキュメント (https://pkg.go.dev/github.com/hajimehoshi/ebiten/v2) を参照し、基本機能(ウィンドウ表示、タイトル・サイズ変更、Fill、矩形描画、キー入力検知・コンソール出力、マウス座標取得・コンソール出力、文字表示)を試す。
-
描画効率の考慮:
ebiten.NewImage(64, 32)
でオフスクリーンイメージを作成し、img.Set(x, y, color)
でピクセルデータを書き込み、DrawImageOptions
でスケールを指定してscreen.DrawImage(offscreenImage, opts)
で描画する方式を試す。 - テスト: サンドボックスプログラムを実行し、各機能が期待通り動作することを確認。
-
コミット:
feat(sandbox): Verify basic Ebiten functionalities and efficient drawing techniques
-
-
Step 10: 表示方法の検討と決定
- CHIP-8コアとEbiten基本動作が確認できたので、表示方法を決定。レビューの推奨に従い、Ebitenを主とし、
chip8_tester
はデバッグ/テスト用ヘッドレスモードとして残す方針を検討。
- CHIP-8コアとEbiten基本動作が確認できたので、表示方法を決定。レビューの推奨に従い、Ebitenを主とし、
-
Step 11: Ebiten でのCHIP-8表示実装
-
cmd/chip8_ebiten/main.go
を作成 (または既存のcmd/chip8
を流用)。 -
ebiten.Game
インターフェースを実装するGame
構造体を作成 (*chip8.Chip8
を保持)。 -
main
関数: ROMパス、cyclesPerFrame
、variantSCHIP
をフラグで受け付け。chip8.New
、LoadROM
。Ebitenウィンドウ設定 (ebiten.SetMaxTPS(60)
も確認)。 -
Game
のUpdate()
メソッド:- Ebitenのキー入力 (
ebiten.InputChars()
やebiten.IsKeyPressed
) を取得し、CHIP-8キーへマッピング (switch r { case '1': chip8Instance.SetKey(0x1, true) ... }
)。キー離上も処理。 -
chip8Instance.UpdateTimers()
を呼び出す (60Hzで呼ばれるUpdateと同期)。 -
for i := 0; i < int(chip8Instance.CyclesPerFrame()); i++ { redraw, _, halted = chip8Instance.Cycle(); if redraw { g.needsScreenUpdate = true }; if halted { break } }
。
- Ebitenのキー入力 (
-
Game
のDraw()
メソッド:-
g.needsScreenUpdate
またはchip8Instance.WasClearScreenRequestedAndReset()
(コア側にこのフラグ確認メソッドが必要) がtrue
の場合のみ描画。 - オフスクリーンイメージ (
*ebiten.Image
, サイズ64x32) を準備。 -
chip8Instance.Gfx()
を取得し、オフスクリーンイメージにピクセルをSet
で書き込む。 -
DrawImageOptions
でスケールを設定し、オフスクリーンイメージを画面にDrawImage
。 - (ちらつき対策)
ebiten.SetScreenClearedEveryFrame(false)
を設定し、このDraw
が画面全体を常に上書きするようにする。あるいは、差分描画ロジックをUI側に持つ(コアのgfx
とUI側の前回gfx
を比較)。レビューでは「Offscreen 画像にフル描画→screen.DrawImage」がシンプルとされている。
-
-
Layout()
メソッド: CHIP-8の論理サイズ (64, 32) を返す。 - テスト: 簡単な描画ROMからゲームROMへ。ちらつき、速度、キー反応を調整。
-
コミット:
feat(ebiten): Implement Chip8 display, input, and game loop using Ebiten with offscreen rendering
-
フェーズ 3: 仕上げ
-
Step 12: ゲーム ROM での最終テストと調整
-
PONG
,INVADERS
,TETRIS
等を実行。動作確認。 - (レビューより)
INVADERS
はDT=0でも動作するので、実装不具合のあぶり出しに有用。 - CPU速度 (
cyclesPerFrame
)、キーマッピングなどを最終調整。 -
コミット:
fix: Debug and adjust emulator based on game ROM testing (Ebiten)
-
-
Step 13: ドキュメント更新とコード整理
- README.md: ビルド・実行方法、キーマッピング(US/JIS配列例示)、フラグ説明。
- コード整理、不要なログ削除、
go fmt
,go vet
。 -
コミット:
docs: Finalize README, clean up code, and prepare for release
これで、レビューフィードバックを可能な限り取り込み、オプションも明確にした上で、より堅牢な開発計画になったかと思います。
この計画で進めてよろしいでしょうか?

おおお
❯ go run ./cmd/chip8_tester/main.go -rom ./roms/keyboard.ch8 -duration 2s -output test_opcode_snapshot.png
2025/05/06 23:27:50 Loaded ROM: ./roms/keyboard.ch8
2025/05/06 23:27:50 Running emulation for 2s...
2025/05/06 23:27:52 Emulation finished after 120 frames.
2025/05/06 23:27:52 Snapshot saved to test_opcode_snapshot.png
やっと表示できた

続いて ebiten

ebiten 自体も動いた

おおお、うごいてる

ピンポン、インベーダー、テトリス、あとなんか

学び
- gemini2.5 でまずは成功率の高いステップを出してもらう
- 今回でいえばまずは chip8 のエミュレータの作成を行う
- 続いて ebiten を最小限で動かしてもらい、動かし方を学んでもらう
- その上で ebiten + chip8 で動くようにする
- かつ上記でつくったステップを GPT-o3 でレビューしてもらう
- その結果を gemini2.5に投げてステップを修正してもらう
この方法でやったら一発でうまくいった
今回の学びは以下のとおりです
- エミュレーターというのは今回でいえば chip8 が定義している ope code を解釈し、それをルール通りに実行する機械
- 指定の場所からコードを見ていき次々に処理していくことで、chip8 で動かす rom が初めて動作するようになる
- また llm への依頼として成功率の高い作業手順の確率と、reasoning model による手順のレビューを行うことで成功できた
- プロンプトエンジニアリングの高度なものって感じ