PPU-2/DMA

前回は CVDisplayLink からの呼び出しで Nes が 1画面を作る流れを見てみました。
今回は PPU の中の動作について見てみます。

前回同様、PPU の仕様や仕組みについては参考にしたページが詳しいので、そちらを参照してください。

PPU のフレームタイミング(cycle と line)

PPU の動作は ppu.run(_ cycle: Int) の呼び出しで行われます。引数の cycle は PPU が何サイクル動作するかを指定します。これは CPU の動作に合わせて PPU がどれだけ動作するかの指定になります。
PPU の内部でも cycle プロパティがあり、これが画面の横方向の位置(0-340)を表します。cycle は 340を超えると 0に戻り、line が 1増えます。
この cycle と line が PPU の内部動作のタイミングになります。1フレーム(画面)分のデータ作成には 340cycle x 261line かかります。

どの cycle で何をしてどの line で何をするということがわかる詳しい図が NesDev の Frame timing diagram にあります。
簡単に説明すると、画面左端から 1cycle 休んでその後 2cycle 毎に NameTable, Attribute, Background Tile Low, Background Tile High と VRAM からの読み込みを行います。これで 1Tile の 1Line 分(8ドット)になります。
これを 1-256cycle で行いますが、最後の 1Tile 分はダミーで使われません。そして最後の 320-340cycle で次の line の 2Tile 分行います。(337-340は使われません)。
これを 0-239line で行い 1画面分作成終了で、この後は VBlank の時間です。
line 241の cycle 1で VBlank に入り、line 261の cycle 1で VBlank 終了です。
また line 261では次の画面の line のデータを読み始めます。

これが PPU 内部での画像作成の流れです。

エミュレータでの PPU の動作

次に実際のエミュレータのコードを見ていきます。
エミュレータではかなり簡略した形で画像作成を行っています。

以下が PPU.swift の該当部分のコードです。

    var cycle: Int = 0
    var line: Int = 0

    func run(_ cycle: Int) -> RenderingData? {
        self.cycle += cycle
        if line == 0 {
            background.removeAll()
            sprites.removeAll()
            buildSprites()
        }
        
        //
        //  finish 1 line
        //
        if self.cycle >= 341 {
            self.cycle -= 341
            line += 1
            
            if hasSpriteHit() {
                setSpriteHit()
            }
            
            //
            //  each 8 line, prepare 1 row of tile data (background tile)
            //
            if line <= 241 && (line - 1) % 8 == 0 && scrollY <= 240 {
                buildBackground()
            }
            
            //
            //  start VBlank
            //
            if line == 241 {
                setVBlank()
                if hasVBlankIRQEnabled {
                    interrupts.assertNMI()
                }
            }
            
            //
            //  end VBlank and return 1 screen rendering data
            //
            if line == 262 {
                clearVBlank()
                clearSpriteHit()
                line = 0
                interrupts.deassertNMI()
                return RenderingData(palette: getPalette(),
                                     background: isBackgroundEnable ? background : nil,
                                     sprites: isSpriteEnable ? sprites : nil)
            }
        }
        
        return nil
    }

run() は呼び出される毎に +1され、341以上になると 341をマイナスされ line が +1されています。
この時 cycle は 0から 341になるまで特に何もしません。341になって 1行分の cycle が終わったときに (line – 1)の 8の剰余が 0 かどうか確認します。0の場合はその Tile(8×8ドット)を一気に 33個作成して background に加えます。PPU では、横 8ドットづつ 1line 毎に行なっていたのを、エミュレータでは 1Tile 分(8line分)一気に行なっているのです。

これを line == 262 となるまで繰り返し、Tile が 33 x 31作成できたところで、line を 0に戻して 1画面分のデータ RenderingData として background を返しています。

VBlank

line 241から VBlank が始まります。VBlank のフラグをセットして PPU の割り込みが許可されている場合、Interrupts の NMI をセットします。
そして line 261で VBlank と Interrupts の NMI がクリアされます。

画面を乱さないで PPU にアクセスする場合、CPU は基本的にこの VBlank の間に PPU へのアクセスを行います。

sprites の作成

1画面分として返される RenderingData の中の sprites ですが、これは 1画面に含まれる全ての Sprite 情報が入っています。
Sprite の情報は 256byte の SpriteRAM に入っているのでこれを 1画面作成の一番初め(line == 0の時)に全部確認して、x, y 座標が画面内に収まるものを sprites に入れて返しています。

Nes のアプリによっては SpriteRAM の先頭に自機のデータ、最後に敵機のデータという具合にデータを入れるものもあるので、SpriteRAM の領域を最初から確認していって途中でデータがなくなったら終了とすると、必要なデータが表示されなくなります。最初、これで一部の敵が表示されなくて困ってました…。

OAM DMA

画面に表示する Sprite データは PPU の SpriteRAM に入っていますが、これは通常の PPU への読み書きによる方法と OAM DMA と呼ばれる DMA を使って送る方法があります。
1つづつコマンドで送る方法は通常の PPU 操作と同じ要領で読み書きが行えます。

    func read(_ addr: Word) -> Byte {
        switch 0x2000 + addr {
        case .ppuStatus:
            isHorizontalScroll = true
            let data = registers[Int(addr)]
            clearVBlank()
            return data
        case .oamData:
            return spriteRAM.read(Word(spriteRAMAddr))
        case .ppuData:
            return readVRam()
        default:
            return 0
        }
    }
    
    func write(_ addr: Word, _ data: Byte) {
        switch 0x2000 + addr {
        case .oamAddress:
            writeSpriteRAMAddr(data)
        case .oamData:
            writeSpriteRAMData(data)
        case .ppuScroll:
            writeScrollData(data)
        case .ppuAddress:
            writeVRAMAddr(data)
        case .ppuData:
            writeVRAMData(data)
        default:
            registers[Int(addr)] = data
        }
    }

.oamAddress, .oamData がそれぞれ SpriteRam のアドレスとデータを読み書きするアドレス(I/Oポート)になります。

OAM DMA は DMA を使って 256byte の Sprite データを一気に転送する方法で、これは 0x4014番地へ転送する Sprite 情報の置かれたアドレスの上位バイトを書き込むことで行います。PPU_BUS.swift では以下のように実装されています。

        else if address == .oamDMA {
            dma.write(data)
        }

後は DMA.swift で dma が要求されたことをセットして

    func write(_ data: Byte) {
        ramAddr = Word(data) << 8
        isProcessing = true
    }

NES.swift で step毎に dma のチェックをして OAM DMA を実行させます。

            if dma.isProcessing {
                dma.runDMA()
                cycle = ppu.cycle % 2 == 0 ? 513 : 514
            }

ちなみに、OAM DMA は PPU の cycle のタイミングによってかかる cycle 数(CPU が休む時間)が 513 か 514 になります。

まとめ

今回は PPU の動作の流れを見てみました。
実際には 1line 毎にデータをフェッチしているのをエミュレータでは 1Tile 行毎にデータをフェッチ&作成しています。PPU 動作中にタイミングを合わせて VRAM にアクセスしてわざとノイズ?を出すようなアプリでない限りはこれでも問題はないのかな?
ただ、個人的にはこの PPU の動作はキチンと理解できていないのが辛いです…。
例えばオリジナルのコードでは run() の cycle が 341 以上になった時、buildBackground() にいく時の条件が line % 8 === 0 です。

    if (this.cycle >= 341) {
      this.cycle -= 341;
      this.line++;

      if (this.hasSpriteHit()) {
        this.setSpriteHit();
      }

      if (this.line <= 240 && this.line % 8 === 0 && this.scrollY <= 240) {
        this.buildBackground();
      }

Swift で何にも考えずにそのまま書くと line が 1以上でしかこの if 文に来ないので 1行目の Tile が抜けてしまいます。
たぶん自分が JavaScript をちゃんと理解してないからわからないんだと思うのですが。

とりあえず、理解できないなりに Swift ではちょこちょこっと修正してみてなんとか動くようにはなっています。

というわけで、次回は PPU-3 です。PPU の基本的な部分、レジスタやそのアクセス方法などについて実装を見ていきます。