プログラミングや低レイヤで遊ぶ人

基本的には遊んだことを記事にしていく

ファミコンエミュレータ作り おわり

FC emulator作り with golang

完成までのマラソン記事。がんばってやり切るぞい。

また、注意点としてこの記事はコードを更新しながら作成していっていくので記事で書いてある内容と実際のコードに差が生まれる可能性はあります。

コード本体    

お問い合わせは私のツイッターまでお願いします。@jumdtw

CPUからVRAMへの書き込み


これまでにCPUとPPUを作成したのでそれを連携させてPPUから画面に描画させます。

今回で目標は達成します!!

 CPUとPPUの連携


PPUの画面描画をEbitenというgolangで使えるゲームエンジンで実装しましたが、そのUpdate関数の中でCPUを動作させていきます。

ここでまずお伝えしたいこととしてCPUは直接的にVRAMに書き込みができません。0x2006番地と0x2007番地のメモリマップトioを使用してVRAMに書き込んで行きます。
そのためCPUのstore命令の実装を少し変えます。

// FC_CPU/emuCPU.go
type CpuEmu struct {

    //各割り込みを行うための情報
    Irqaddr uint16
    Nmiaddr uint16
    Resetaddr uint16
    InterruptFlag bool

    // vram へ書き込みを行うための内部情報 
    // vram addr
    VramAddr uint16
    // vram write flag
    VramWriteFlag bool
    // vram write value
    VramWriteValue uint8


    // A X Y S P
    Regi map[string]uint8
    // PC
    RegPc uint16
    // Memory
    Memory [memCap]uint8
}


// FC_CPU/opcmd.go
func (fcEmu *CpuEmu) staAbs() {
    var absposhigh uint16 = uint16(fcEmu.Memory[fcEmu.RegPc+1])
    var absposlow uint16 = uint16(fcEmu.Memory[fcEmu.RegPc])
    var pos uint16 = (absposhigh << 8) + absposlow
    // 0x2006だったらppuへのアクセス
    if pos == 0x2006 {
        fcEmu.VramAddr = fcEmu.VramAddr << 8
        fcEmu.VramAddr += uint16(fcEmu.Regi["A"])
    }
    if pos == 0x2007 {
        fcEmu.VramWriteFlag = true
        fcEmu.VramWriteValue = fcEmu.Regi["A"]
    }else {
        fcEmu.VramWriteFlag = false
    }
    fcEmu.Memory[pos] = fcEmu.Regi["A"]
    fcEmu.RegPc = fcEmu.RegPc + 2
}


まず、CPUの構造体に現在の状態を管理できるフラッグや変数を設置します。そしてstore命令でio制御用にマッピングされている領域に書き込みが行われた場合それ専用の処理を記述しています。今回はVRAMアクセス用のものしか実装していません。

これを実装し、画像データの入ったROMの中身を画面に描画するプログラムをCPUエミュレータに実行させていきます。(使っている画像データがデータだけにそのプログラムの配布ができません。申し訳ありません。)

CPUの実行タイミング


以下は前回作成したUpdate関数を改良したものです。

// FcEmulator.go
func (g *Game) Update(screen *ebiten.Image) error {
    // Generate the noise with random RGB values.
    var numblock int
    var numtile int
    var vv int
    for i :=0; i<30; i++ {
        for k :=0; k < 32; k++ {
            // vv は n枚目のタイルの一番左上のピクセルの最初の配列番号
            // k は一増えるごとに8pixl×ピクセル倍率分増える
            // i は一増えるごとに256×ピクセル倍率に8pixl×ピクセル倍率を掛ける
            // 最後に4を掛けることにより配列数を出す。
            vv = (k*8*Pixlsize+i*256*Pixlsize*8*Pixlsize)*4
            // patternNum がどのブロックにあるか
            // tileは32x30の半分なのでブロックは16x15.ナンバリングは0スタート
            numblock = Numblockreturn(i,k)
            numtile = k+i*32
            DrawTile(g,vv,numblock,numtile)
            // ppu はcpuの3倍のクロックを持っているのでppuの方が動作的には早いが
            // さすがにここで実行すると遅すぎるので将来的には修正が必要
            cpuexecute(g)
        }

    }

    return nil
}

cpuexecute(g)を実行することで一命令実行していきます。ただし、この位置での実行は実際のPPUとCPUの実行速度にかなりの差が出てしまうため将来的には修正が必要担ってきます。今回はこれでも目標に達成できるだけの実行速度には達しているのでこれで妥協します。

以下はcpuexecute(g)のソースコードです。

// FcEmulator.go
func cpuexecute(g *Game){

    // NMI割り込み
    // 多分本来のハードウェア動作だと次にこの動作処理が来るまでにCPU側で処理が終わっている。
    // でもこのエミュレータだと実質同期処理しているような状態なので処理が終わる前に再び
    // nmiaddrで割り込みが入ってしまう。そのため割り込み処理が終わるまでフラッグで判断して処理する。
    nmiflag := g.Cpuemu.Memory[0x2000] & 0b10000000
    if nmiflag == 0b10000000 && g.Cpuemu.InterruptFlag == false{
        // ステータスレジスタの変化
        g.Cpuemu.Regi["P"] = g.Cpuemu.Regi["P"] & 0b11101011
        g.Cpuemu.Regi["P"] = g.Cpuemu.Regi["P"] + 0b00010100

        // 割り込みフラッグ 割り込み中はtrue
        // 割り込み処理に入ったのでtrueにする
        g.Cpuemu.InterruptFlag = true

        // スタックに現在のPCを積む
        var sppos uint16 = 0x1000 + uint16(g.Cpuemu.Regi["S"])
        // 0x4050 だったら 50 40 の順でスタックに積む。個々の動作は等で確認が取れていないためこのエミュレータだけの可能性あり。
        lowaddr := g.Cpuemu.RegPc & 0x00ff
        highaddr := g.Cpuemu.RegPc & 0xff00
        highaddr = highaddr >> 8
        g.Cpuemu.Memory[sppos] = uint8(lowaddr)
        g.Cpuemu.Memory[sppos] = uint8(highaddr)
        g.Cpuemu.Regi["S"] = g.Cpuemu.Regi["S"] - 2

        // 割り込みアドレスの代入
        g.Cpuemu.RegPc = g.Cpuemu.Nmiaddr
    }

    g.Cpuemu.Execute()
    
    if g.Cpuemu.VramWriteFlag {
        g.Ppuemu.Memory[g.Cpuemu.VramAddr] = g.Cpuemu.VramWriteValue
        g.Cpuemu.VramAddr++
        g.Cpuemu.VramWriteFlag = false
    }
}

NMI割り込みとはハードウェアから入る強制的な割り込みです。ファミコンでは一定周期でPPUからCPUへこの割り込みが入ります。(CPU側の設定で遮断可能)
割り込みがあったら割り込み用のアドレスに書き換えます。
もし割り込みがなかったらそのまま命令を実行します。

実行結果


こんな感じになりました。前回と違ってちゃんとCPUから色情報も書き込んでいるので色塗りもいい感じになっています。
f:id:jumdtw:20200612221530j:plain
プログラムの内容自体はROMの中身をただただ描画するだけなのでこれ以上の動きは特にありません。

衝撃の事実


無事目標を達成してファミコンエミュレータの完成です!!!!!!!!!!!!

というのは幻想です

なぜならこのエミュレータは多大な問題を抱えているからです。


・そもそもマッパー0(拡張機能なしの純粋なファミコンのこと。つまりドラクエは無理)しか動かない
・スクロール機能がなくマリオのようなアクション系のゲームが動かない
・そもそもスプライト機能がなくマリオの描画ができない
・コントローラー?知らない子ですね

軽くあげるだけでこれだけありますね。特にスプライトとコントローラーが致命的です。
いやまぁ、実装しようと思えばできるのですが今回の目標は美少女の描画ということにしてしまったので。

やるとしたら番外編ということでまた記事自体は書くかもしれません。

まとめ


今回のエミュレータ作りを通して学べたことは以下です。

golangの標準パッケージのソースが具体的にどのように動いているのか
・並行処理の重要性とその実装
ファミコンのすごさ

個人的には並行処理が学べて楽しかったです。オライリーから書籍も出ているのでぜひ。

参考 

YY-CHR @wiki
電子書籍「ファミコンゲーム製作入門」(おまけ付き)
ギコ猫でもわかるファミコンプログラミング
Memory-Map of NES
ファミコンのグラフィックスの省メモリ化テクニックとは?
Go言語 - 複数ファイルのコンパイル
Golangで自分自身で定義したパッケージをインポートする方法あれこれ
配列と Slice
Go言語でバイナリファイルを読み込む