Flutterを使ったファミコンエミュレータの開発 (HelloWorldまで)

概要

Flutter(Dart)の勉強をしようと考え、どうせなら以前から作りたいと思っていたファミコンエミュレータを書くことにした。 まだROMとしてはHello Worldを表示するテストROMしか動かせてはいないが、60FPSで描画出来るようになったので記事を書いた。

github.com

Hello World ROMが動いている様子

ファミコンエミュレータについて

先人の皆様がファミコンエミュレータを様々な言語や環境に移植しつつ、それに伴う知見をブログなどに残してくださっているので、それらの情報に従ったら基本的に作成できる。私の場合は、下記の記事を一番参考にさせてもらった。

qiita.com

CPU/PPUのそれぞれの仕組みから、HelloWorldを表示するまで必要な内容をわかりやすく説明してくださっている。 まずは記事に従ってROMに含まれるスプライトを表示してみるのが良い。実際に目に見えるものが出てくるとテンションが上がるのもある。

記事の最後にサンプルROMのアセンブラが乗っているので、自分のCPUが吐き出したオペコードと比較して何が間違っているかを確認してすすめることができた。

詳細なCPUの命令ごとの動作については下記を参考にさせてもらった。

NES on FPGA CPU

レジスタのステータスフラグの意味や、各命令でしなければならないこと、アドレッシングモードの種類やサイクル数など、ここを見れば基本的に問題ないはず。 私の場合はオペコード毎の情報を格納したMAPを作るにあたって、このサイトに加えて、下記の2サイトも参考にした。

6502 Opcodes - NES Hacker Wiki

6502 instructions - Nesdev wiki

ただ、Hello Worldを動かすにあたっては全ての命令とアドレッシングモードを実装する必要はまったくなかったので、下記の命令とアドレッシングモードを実装するところまでで止めている。アドレッシングモード毎にFetchしなければならない数が決まっているのでそこと、Reset割り込みにおけるPCの初期値セットだけを実装し、頭からFetchしていくことで、必要な命令とアドレッシングモードを抜き出すことができた。(最終的にはJMPで無限ループになるので、そこまで抜き出す。)

# 命令
BNE
DEY
INX
JMP
LDA
LDX
LDY
SEI
STA
TXS
# アドレッシングモード
Implied
Accumulator
Immediate
Relative
Absolute
AbsolutePageX

Flutterにおけるエミュレータ

エミュレータ実行スレッドを分ける

エミュレータ自体の実行はそれなりに処理負荷がかかるので別のIsolateで実行するようにした。 ゲーム実行中はずっと動かし続けるので compute 関数を使うのではなく、Isolate.spawn によってIsolateを立ち上げる。

emulatorReceivePort = ReceivePort();
Isolate.spawn(emulatorIsolateMain, emulatorReceivePort.sendPort);

emulatorIsolateMain 側でも ReceivePort を生成し、それをParent側に送信することで双方向の通信が出来るようにしている。 エミュレータ実行Isolate側では下記のコードのように常にParentからのイベントを待機しながら、エミュレータの1フレームごとの処理を行っている。

Timer.periodic(Duration(milliseconds: 16), _executeFrame);

await for (var message in childReceivePort) {
  // メッセージが来たら処理を行う
}

1フレーム分の処理が完了したら、Isolate作成時に渡された sendPort を使って、メインIsolateに向けてフレームのピクセル情報を送信する。

画面への描画

エミュレータ側で RGBA 形式の Uint8List を作成し、それを ui.decodeImageFromPixels によって ui.Image 形式に変換させる。 (256x240はファミコンの画面サイズ)

Future<ui.Image> _convertFrameToImage(Uint8List pixels) {
  final c = Completer<ui.Image>();
  ui.decodeImageFromPixels(
    pixels,
    256,
    240,
    ui.PixelFormat.rgba8888,
    c.complete,
  );
  return c.future;
}

その後 ui.ImageCustomPaint におけるCanvasに対して canvas.drawImage している。 当初は RawImage に直接 ui.Image をつっこんで、毎回 setState() {}により描画処理を発火させていたが、おそらく CustomPaint を使いrepaint させるほうがインスタンス化のコストが減り、低負荷なのではないかと考えてこのような形にした。(未検証なので実は RawImage でも軽かったりするかもしれない。)

class _EmulatorPageState extends State<EmulatorPageWidget> {
  _EmulatorController controller;

  @override
  void initState() {
    super.initState();
    controller = _EmulatorController();
  }

  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(
        children: <Widget>[
          Expanded(
            child: Container(
              width: double.infinity,
              height: double.infinity,
              color: Colors.black38,
              child: CustomPaint(
                painter: _EmulatorDrawPainter(controller),
              )
            )
          ),
        ]
      ),
    );
  }
}

class _EmulatorController extends ChangeNotifier {
  ui.Image currentFrame;

  _EmulatorController();

  // Isolate周りの処理は省略

  /** 
   * Isolateからフレーム更新のメッセージが届いた場合に発火する
   */
  void _onRecvUpdateFrame(Uint8List framePixels) {
    _convertFrameToImage(framePixels).then((ui.Image image) {
      currentFrame = image;
      notifyListeners();
    });
  }
}

class _EmulatorDrawPainter extends CustomPainter {
  _EmulatorController controller;
  Paint paintObject;

  _EmulatorDrawPainter(this.controller): super(repaint: controller) {
    paintObject = Paint();
  }

  @override
  void paint(Canvas canvas, Size size) {
    canvas.save();
    if (controller.currentFrame != null) {
      canvas.drawImage(controller.currentFrame, Offset.zero, paintObject);
    }
    canvas.restore();
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}

CustomPaint において描画をし直す = paint を再度呼び出すためには repaint オプションに ChangeNotifierValueNotifier を渡す必要がある。 これらのNotifierを使ってデータの更新があることを CustomPaint に通知すると、paint が再実行される。(ValueNotifier の場合はそのままデータを設定すると setter の働きによりそのまま通知が行われる。 ChangeNotifier の場合は notifyListeners() を実行すれば良い。

手を抜いた点と、今後の予定

PPUについてかなり手を抜いた。 パレット情報を使っていないし、1フレーム分のサイクルが走ったら、そのタイミングで全部描画するという実装にしている。 今後、実際のゲームROMを実行する段になって問題が出ると思うので、そのタイミングで実装する。

最終的にはMapper4のROMが動作するところまで実装したい。 その後、GameBoyColorのエミュレータ作成にもチャレンジしてみたい気持ちである。 (とりあえず吸い出し機とゲームは購入した)