Closed21

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

riddle_tecriddle_tec

昨日
https://zenn.dev/gin_nazo/scraps/2c6a1fc6ba5310


https://blog.framinal.life/entry/2025/04/14/154104

100日チャレンジに感化されたので、アレンジして自分でもやってみます。

やりたいこと

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

できたもの

https://github.com/lirlia/100day_challenge_backend/tree/main/day37_chip8_emulator_go

riddle_tecriddle_tec

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

riddle_tecriddle_tec

Day 37: CHIP-8 エミュレーター (Go + ebiten) - 実装ステップ

フェーズ 1: プロジェクト設定とコア構造

  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
  2. Step 2: CHIP-8 コア構造体定義

    • pkg/chip8/chip8.goChip8 構造体(メモリ [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
  3. Step 3: ROM ローダー実装

    • pkg/chip8/chip8.goLoadROM(path string) error 関数を実装。指定されたパスの ROM ファイルを読み込み、メモリの特定アドレス (通常 0x200) 以降にロードする。
    • テスト: テスト用の小さなダミー ROM ファイルを用意し、LoadROM を呼び出してメモリに正しくロードされるか確認。
    • コミット: feat(chip8): Implement ROM loading functionality

フェーズ 2: 基本的な表示と実行サイクル

  1. Step 4: Ebiten 基本ウィンドウ設定

    • cmd/chip8/main.goebiten.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
  2. Step 5: 画面描画実装

    • cmd/chip8/main.goDraw() メソッドを実装。chip8.gfx バッファ (64x32) を参照し、ebitenDrawRectFill を使って、対応するピクセルをウィンドウ (*ebiten.Image) に描画する(1ピクセルを例えば 10x10 の矩形として描画するなど、スケールアップする)。
    • Layout() メソッドで論理サイズ (64, 32) を返すようにする。
    • テスト: chip8.gfx バッファに手動で簡単なパターン(市松模様など)を設定し、go run でそれが正しくウィンドウに描画されるか確認。
    • コミット: feat(main): Implement Chip8 screen rendering using ebiten
  3. Step 6: CPU サイクルと基本命令 (CLS, JP, LD Vx, DRW)

    • pkg/chip8/chip8.goCycle() 関数の骨組みを実装(PC からオペコードをフェッチ → デコード → 実行)。
    • pkg/chip8/opcodes.go を作成し、デコードロジックと、以下の基本的な命令の実装を追加:
      • 00E0 (CLS): gfx バッファをクリア。
      • 1nnn (JP addr): PC を nnn に設定。
      • 6xkk (LD Vx, byte): レジスタ Vxkk をロード。
      • Dxyn (DRW Vx, Vy, nibble): gfx バッファにスプライトを描画(XOR ロジック含む)。レジスタ VF の設定も行う。
    • cmd/chip8/main.goUpdate() 内で chip8.Cycle() をフレームごとに数回呼び出すようにする(仮の速度)。
    • テスト: CLSDRW を使う簡単なテスト ROM (自作または kripod/chip8-roms のテスト ROM を利用) を roms/ に配置し、go run で実行して画面クリアや描画が正しく行われるか確認。
    • コミット: feat(chip8): Implement CPU cycle and basic opcodes (CLS, JP, LD, DRW)

フェーズ 3: 入力、タイマー、その他の命令

  1. Step 7: キーボード入力実装

    • cmd/chip8/main.goUpdate() 内で 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
  2. Step 8: タイマー実装

    • pkg/chip8/chip8.goChip8 構造体に 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
  3. 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: 仕上げ

  1. Step 10: ゲーム ROM でのテストと調整

    • roms/ に配置した PONG, INVADERS, TETRIS などのゲーム ROM を実行。
    • ゲームが正しく動作するか確認。
    • CPU の実行速度 (Update 内での Cycle() の呼び出し回数) を調整して、適切なゲームスピードにする。
    • バグがあれば修正。
    • コミット: fix(chip8): Debug and adjust emulator based on game ROM testing
  2. Step 11: ドキュメント更新とコード整理

    • README.md に最終的なビルド・実行方法、キーマッピングなどを記述。
    • コード内のコメントやログを整理。
    • コミット: docs(chip8): Finalize README and code comments

このステップで進めていきましょう。まずは Step 1 のプロジェクト初期化から開始します。
準備ができたら、最初のコマンドを実行します。

riddle_tecriddle_tec

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

riddle_tecriddle_tec

性能落ちてきたので切り替え


承知いたしました。別の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 in pkg/chip8/chip8.go) と初期化関数 (New) を定義済み。フォントセットもロード済み。
  • ROM をメモリにロードする関数 (LoadROM in pkg/chip8/chip8.go) を実装済み。
  • Ebiten の基本的なウィンドウ表示とゲームループ構造 (cmd/chip8/main.go) を設定済み。ROM パスを引数で受け取れる。
  • CHIP-8 のグラフィックバッファ (gfx) を Ebiten ウィンドウに描画する処理 (Draw in cmd/chip8/main.go) を実装済み。
  • CPU の実行サイクル (Cycle in pkg/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 内に追加済み) によると、以下の命令が期待通りに実行されています。

  1. 00E0 (CLS)
  2. 6005 (LD V0, 5)
  3. 6105 (LD V1, 5)
  4. A050 (LD I, 0x050)
  5. D015 (DRW V0, V1, 5)
  6. 120A (JP 0x20A) - 以降、この命令でループ

しかし、Ebiten のウィンドウには期待される数字 '0' が表示されません(ウィンドウは表示されるが中身が描画されないか、真っ黒のまま)。

考えられる原因:

  • pkg/chip8/opcodes.godrawSprite 関数の実装に問題があり、gfx バッファが正しく更新されていない、または chip8.drawFlagtrue に設定されていない。
  • cmd/chip8/main.goDraw 関数が、gfx バッファの内容を正しく Ebiten 画面に描画できていない。

次のアクション:

  1. pkg/chip8/opcodes.godrawSprite 関数内に詳細なデバッグログを追加し、以下の点を確認する。
    • ループが期待通りに回っているか。
    • 読み込んでいるスプライトデータ (spriteByte) は正しいか。
    • gfx 配列のインデックス計算 (gfxIndex) は正しいか。
    • gfx 配列の値が XOR ロジックによって実際に変更されているか。
    • 衝突検出 (c.V[0xF] = 1) は意図せず発生していないか。
    • 関数の最後に c.drawFlag = true が実行されているか。
  2. 修正したコードで再度テスト 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))。

riddle_tecriddle_tec

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

riddle_tecriddle_tec

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

riddle_tecriddle_tec

o3 にレビューしてもらう


chip8のエミュレーターを作ろうとしています。

この内容を見て、
https://en.wikipedia.org/wiki/CHIP-8
https://pkg.go.dev/github.com/hajimehoshi/ebiten/v2

以下の手順についてレビューをし改善点をだして

はい、承知いたしました。省略せずに、Ebiten基本動作確認ステップを含めたCHIP-8エミュレータ開発の全ステップを以下に記載します。

Day 37: CHIP-8 エミュレーター (Go) - 再改訂実装ステップ (完全版)

フェーズ 1: CHIP-8 コアロジックの実装とテスト

  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 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)"
  2. 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, clearScreenRequestedfalse に設定します。
      • メモリの特定領域 (例: 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"
  3. Step 3: ROM ローダー実装

    • pkg/chip8/chip8.goChip8 型に LoadROM(path string) error メソッドを実装します。
    • このメソッドは、指定されたファイルパスからROMファイルを読み込みます。
    • ROMデータのサイズがメモリの搭載可能領域(0xFFF - 0x200 + 1)を超えていないかチェックします。
    • 問題なければ、ROMデータをメモリの 0x200 以降にコピーします。
    • ロード成功または失敗のログメッセージを出力します。
    • テスト: chip8_test.goTestLoadROM 関数を追加します。
      • テスト用に数バイトのダミーROMファイル(例: test_roms/dummy.ch8)を作成します。
      • LoadROM を呼び出し、エラーが発生しないこと、メモリの 0x200 以降にダミー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"
  4. Step 4: CPU サイクルとコア命令実装 (CLS, JP, LD Vx,byte, ADD Vx,byte, DRW)

    • pkg/chip8/chip8.goCycle() 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 += 2false を返します。
      • 7xkk (ADD Vx, byte): レジスタ Vx の値に kk を加算し、結果を Vx に格納します(キャリーフラグ VF は変更しません)。PC += 2false を返します。
      • Dxyn (DRW Vx, Vy, nibble):
        • レジスタ VxVy から描画開始座標 (x, y) を取得します (画面サイズで剰余を取る)。
        • I レジスタが指すメモリアドレスから始まる n バイトのスプライトデータを読み込みます。
        • 各バイトをビット列とみなし、画面の対応するピクセルとXOR演算します。
        • いずれかのピクセルが1から0に変わった(衝突した)場合、VF レジスタを1に設定します。それ以外の場合は0に設定します。
        • 実際に画面のピクセルが変更された場合は pixelChangedのような内部フラグを立てます。
        • PC += 2
        • pixelChangedtrue なら 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"
  5. Step 5: タイマー実装

    • pkg/chip8/chip8.goChip8 構造体の DT (遅延タイマー) と ST (サウンドタイマー) フィールドを使用します。
    • Cycle() メソッドの最後(または適切なタイミング)で、DTST が0より大きい場合にそれぞれ1ずつデクリメントする処理を追加します。CHIP-8のタイマーは60Hzでデクリメントされるのが一般的ですが、Cycle() の呼び出し頻度に依存するため、ここでは単純に Cycle() ごとにデクリメントします(速度調整は後段)。
    • pkg/chip8/opcodes.go (または chip8.go) に以下のタイマー関連命令を実装します:
      • Fx07 (LD Vx, DT): レジスタ VxDT の値をロードします。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"
  6. Step 6: キー入力関連命令と状態管理 (表示なし)

    • pkg/chip8/chip8.goChip8 構造体の 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 に設定します。
        • キーを格納する対象のレジスタ番号 xkeyReg に保存します。
        • この命令が実行された時点では PC を進めません (キー入力待ちのため)。
        • Cycle() メソッドの先頭で waitingForKeytrue の場合、いずれかのキーが押されるまで処理を中断(または何もせずに return false)します。キーが押されたら、そのキー番号を V[keyReg] に格納し、waitingForKeyfalse にし、PC += 2 して次の命令に進みます。
    • テスト: chip8_test.go にキー入力関連命令のテストケース (TestOpcodeSKP, TestOpcodeSKNP, TestOpcodeLDVxK) を追加します。
      • SetKey でキー状態を様々に設定し、SKP/SKNP命令がPCを正しくスキップするか確認します。
      • Fx0A命令を実行後、waitingForKeytrue になることを確認。その後、任意のキーを押した状態を SetKey で作り、次の Cycle 呼び出しで waitingForKeyfalse になり、指定したレジスタにキー番号が格納され、PCが進むことを確認します。
    • コミット: git add ., git commit -m "feat(chip8): Implement keyboard state and related opcodes with unit tests"
  7. 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 をインクリメント。PCnnn に設定。
        • 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 レジスタが指すアドレスから順に格納。格納後 II + x + 1 になるか、変わらないかは仕様による(確認が必要)。
        • Fx65 (LD Vx, [I]): メモリの I レジスタが指すアドレスから順に値を読み出し、レジスタ V0 から Vx (両端含む) までに格納。読み出し後 II + x + 1 になるか、変わらないかは仕様による。
      • 乱数 (Cxkk):
        • Cxkk (RND Vx, byte): 0から255までの乱数を生成し、kk とAND演算した結果をレジスタ Vx に格納。Goの math/rand パッケージを利用。
    • テスト: 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)
  8. 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.Printlnlog.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: 表示の準備と実装

  1. 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エミュレータ本体とは別管理にするか、学習用として後でリポジトリから削除しても問題ありません)
  2. Step 10: 表示方法の検討と決定

    • この時点で、CHIP-8エミュレータのコアロジックは単体でほぼ正しく動作し、Ebitenライブラリの基本的な使い方も理解できている状態です。
    • ここで、CHIP-8エミュレータのグラフィックとインタラクションをどのようにユーザーに提供するか、最終的な表示方法を検討し決定します。
      1. Ebiten を使用:
        • メリット: リッチなグラフィック、スムーズなアニメーション、サウンド再生(STタイマーと連携可能)、標準的なゲーム開発体験。
        • デメリット: ちらつき問題への再対処が必要になる可能性、依存ライブラリが増える。
      2. ターミナル表示:
        • メリット: 依存が少ない、シンプル、デバッグが容易な場合がある。
        • デメリット: 視覚的魅力に欠ける(アスキーアート)、インタラクションの制約、サウンド不可。
    • ユーザー(あなた)と相談し、プロジェクトの目的や学習目標に合わせて、どちらの方法で進めるか、あるいは両方試してみるかを決定します。
  3. 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) を更新します。
    • Game 構造体の Draw() メソッド:
      • まず chip8Instance.WasClearScreenRequestedAndReset() を呼び出し、CLS命令が実行されていたか確認します。もし true なら screen.Fill(backgroundColor) で画面全体をクリアします。
      • Update で設定された needsRedraw フラグ、または WasClearScreenRequestedAndResettrue だった場合にのみ描画処理を行います(最適化)。
      • 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"
  4. Step 11-B: ターミナルでのCHIP-8表示実装 (ターミナル表示を選択した場合)

    • cmd/chip8_terminal/main.go を作成します。
    • Goのターミナル操作ライブラリ(例: github.com/nsf/termbox-gogithub.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: 仕上げ

  1. 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)
  2. 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"

これで、より詳細で網羅的なステップリストになったかと思います。この計画で進めていきましょう。

riddle_tecriddle_tec

全体的なフィードバック — いまの手順は網羅的で非常に良いです

とくに コアと 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 増分戦略 を早めに確定しておくと後工程のバグが激減します。がんばってください!

riddle_tecriddle_tec

**Day 37: CHIP-8 エミュレーター (Go) - レビュー反映版・実装ステップ **

フェーズ 1: CHIP-8 コアロジックの実装とテスト

  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 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
  2. 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
  3. Step 3: ROM ローダー実装

    • internal/chip8/chip8.goChip8 型に 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
  4. Step 4: CPU サイクルとコア命令実装 (CLS, JP, LD Vx,byte, ADD Vx,byte, DRW)

    • internal/chip8/chip8.goCycle() (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): PCnnn に設定。
      • 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)を返す。
    • Cycle メソッドの実装:
      • waitingForKeytrue なら、キー入力処理。押されていれば V[keyReg] に格納、waitingForKey = falsePC += 2return 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では redrawcollision が正しく分離して返るかテスト。
    • コミット: feat(chip8): Implement CPU cycle, PC management, and core opcodes (CLS, JP, LD, ADD, DRW) with tests
  5. Step 5: タイマー実装

    • internal/chip8/chip8.goChip8構造体の DTST を使用。
    • Chip8UpdateTimers() メソッドを追加します。このメソッドは DTST が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
  6. Step 6: キー入力関連命令と状態管理

    • internal/chip8/chip8.gokeys, 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
  7. Step 7: 残りの主要命令実装 (カテゴリごと、テスト重視)

    • internal/chip8/opcodes.go に未実装の命令を実装。各命令後、適切に c.PC += 2 (または分岐命令等で直接PCを設定)。
      • 算術/論理演算 (8xy0-8xy7, 8xyE): SHL/SHRvariantSCHIP フラグに応じて挙動を切り替える (オプションだが推奨)。
      • 条件分岐 (3xkk, 4xkk, 5xy0, 9xy0)
      • サブルーチン (2nnn, 00EE)
      • インデックスレジスタ (Annn, Fx1E)
      • メモリ操作 (Fx29, Fx33, Fx55, Fx65): Fx55/Fx65variantSCHIP または別のフラグで I の Auto-Increment有無を切り替える(オプションだが推奨)。
      • 乱数 (Cxkk)
    • テスト: chip8_test.go で各命令カテゴリまたは個々の命令のTable-drivenテストを作成。
    • コミット: カテゴリごとにコミット (例: feat(chip8): Implement arithmetic and logic opcodes with tests)。
  8. Step 8: コアロジックの統合テスト (chip8_tester)

    • cmd/chip8_tester/main.go を作成。
      • コマンドラインフラグでROMパス、cyclesPerFramevariantSCHIP を受け取れるようにする。
      • 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/pngos.Create)。
    • 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: 表示の準備と実装

  1. 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
  2. Step 10: 表示方法の検討と決定

    • CHIP-8コアとEbiten基本動作が確認できたので、表示方法を決定。レビューの推奨に従い、Ebitenを主とし、chip8_tester はデバッグ/テスト用ヘッドレスモードとして残す方針を検討。
  3. Step 11: Ebiten でのCHIP-8表示実装

    • cmd/chip8_ebiten/main.go を作成 (または既存の cmd/chip8 を流用)。
    • ebiten.Game インターフェースを実装する Game 構造体を作成 (*chip8.Chip8 を保持)。
    • main 関数: ROMパス、cyclesPerFramevariantSCHIP をフラグで受け付け。chip8.NewLoadROM。Ebitenウィンドウ設定 (ebiten.SetMaxTPS(60) も確認)。
    • GameUpdate() メソッド:
      • 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 } }
    • GameDraw() メソッド:
      • 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: 仕上げ

  1. Step 12: ゲーム ROM での最終テストと調整

    • PONG, INVADERS, TETRIS 等を実行。動作確認。
    • (レビューより)INVADERS はDT=0でも動作するので、実装不具合のあぶり出しに有用。
    • CPU速度 (cyclesPerFrame)、キーマッピングなどを最終調整。
    • コミット: fix: Debug and adjust emulator based on game ROM testing (Ebiten)
  2. Step 13: ドキュメント更新とコード整理

    • README.md: ビルド・実行方法、キーマッピング(US/JIS配列例示)、フラグ説明。
    • コード整理、不要なログ削除、go fmt, go vet
    • コミット: docs: Finalize README, clean up code, and prepare for release

これで、レビューフィードバックを可能な限り取り込み、オプションも明確にした上で、より堅牢な開発計画になったかと思います。
この計画で進めてよろしいでしょうか?

riddle_tecriddle_tec

おおお


❯ 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

やっと表示できた

riddle_tecriddle_tec

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

riddle_tecriddle_tec

学び

  1. gemini2.5 でまずは成功率の高いステップを出してもらう
    • 今回でいえばまずは chip8 のエミュレータの作成を行う
    • 続いて ebiten を最小限で動かしてもらい、動かし方を学んでもらう
    • その上で ebiten + chip8 で動くようにする
  2. かつ上記でつくったステップを GPT-o3 でレビューしてもらう
  3. その結果を gemini2.5に投げてステップを修正してもらう

この方法でやったら一発でうまくいった


今回の学びは以下のとおりです

  • エミュレーターというのは今回でいえば chip8 が定義している ope code を解釈し、それをルール通りに実行する機械
  • 指定の場所からコードを見ていき次々に処理していくことで、chip8 で動かす rom が初めて動作するようになる
  • また llm への依頼として成功率の高い作業手順の確率と、reasoning model による手順のレビューを行うことで成功できた
    • プロンプトエンジニアリングの高度なものって感じ
このスクラップは2ヶ月前にクローズされました