Python
Arduino
LuaJIT
sfc
TAS

この記事は第2のドワンゴ Advent Calendar 2017の13日目の記事です。

最初に

電子工作の基本知識のないまま書かれる記事となっています。

TASとは

Tool-Assisted SpeedrunTool-Assisted Superplay などの頭文字を取ったアクロニムです。

ここでは Tool-Assisted SpeedrunTool-Assisted Superplay などを区別せず、TASとして説明します。

掻い摘んだ説明をすると、基本的にゲームのエミュレータの諸機能(QS/QL, 実行速度のスロー化やコマ送りなど)を使い、
毎フレームごとの入力を(リプレイファイルと呼ばれたりするファイルに)保存、[作業|完成]後にリプレイファイルの各フレームの入力を等速で行うことによって 理論上再現可能なすごいプレイ映像を作り出すことをTASと呼びます。

言葉の定義については様々な議論がありますが、その辺りについての詳しくは下記リンクを参考にしてください。

認識としては上に書いたもので大体あっているかと思います。

TASとは (タスとは) [単語記事] - ニコニコ大百科

すごいプレイの魅力

上の動画を見ていただければ少しは魅力が伝わるかと思います。人間でも練習しなければ難しい技を難なく決めていく様を見ているのはとても気持ちが良いものだと思います。

また、上のようなものもあります。こちらはゲームクリアまでに42秒と掛かっていないため、是非目を通して頂きたいです。

所謂任意コードの入力、実行をスーパーマリオワールドの上で実現し、通常では確実にありえない短さでゲームクリアしています。

これと同じことを人間がやるためには、少なくともフレームパーフェクトかつ超複雑な入力を5フレームの間は繰り返さなければいけません。

恐らく人間が正攻法でこの入力をすることは不可能かと思いますが、TASでは毎フレームごとにコマ送りをしつつ正確な入力をすることができるので、入力の難度については完全に無問題となっています。

理論上再現可能であること

TASにおいて個人的に最重要である要件に、理論上再現可能というものがあります。

上に貼った42秒足らずでクリアするようなプレイですが、もし仮に実際に全く同じ入力を実機で行うことができれば再現することができるはずです。

この記事の本題は、実機に対する入力をArduinoを通して行うことです。

TASBOT

茶番じみた文章を長々と書きましたが、実際に実機でのリプレイファイルの再現は既に行われています。

これはAGDQというRTAの大型イベントの様子ですが、TAS専用の時間が設けられており、実際のゲームハード本体に対し、TASの入力をそのまま再現しています。

このような、TASの入力をそのまま再現する専用ハードのことをどうやらTASBOTと呼ぶようで、このTASBOTによるゲームプレリがAGDQでは恒例となっています。

作りたいもののイメージとしてはまさにこれであり、全く同じようなものを作りたく思っています。

また、単純に好きだという理由とSRAMの状態管理や擬似乱数の生成などの事情も兼ねて、ここではスーパーファミコン(以下SFC)のTASBOTを制作しています。

作るもの概要

  • シリアル通信によりPCからコントローラーに入力内容を渡す
    • コントローラー側で入力内容を理解し、SFCが内容と同じ情報を受け取るように振る舞う
  • SFC本体からコントローラーに入力が催促される度にPCに信号を送る

SFCのコントローラー入力を模倣する

TASBOTを作るということは、つまり何からの手段で制御するコントローラー作りとも言い換えることができます。

SFCで動くコントローラーを作るためには、SFCからの信号を受け取り、正しい形でSFCに返してやることが必要です。

ここでは実際のSFCコントローラーの動きを見ます。

ポート

SFCのコントローラーの差込口には7つのピンがあり、このピンによってSFC本体に接続されます。

これが実際のプラグの形になります。

丸くなっていない方から順に

[ VCC, Clock, Latch, Data1, Data2, Select, GND ]

という順番で並んでいます。

名称未設定.png

Data2ピンとSelectピンに関しては、通常のコントローラーには配線されておらず、マルチタップ等に配線されています。

処理の流れ

SFC本体が基本的なコントローラーに対する入力の受け取り方は以下の流れになります。
ここより下に現れる Rising とは、LowからHighに変化することを指しています。Arduinoの attachInterrupt に渡す Mode に使われる定数に存在している言葉です。

  1. LatchClockRising する
  2. Data1Bボタン が押されていれば Low 押されていなければ High に変化
  3. ClockRising する
  4. Data1Yボタン が押されていれば Low 押されていなければ High に変化
  5. ClockRising する
  6. Data1Selectボタン が押されていれば Low 押されていなければ High に変化
    以下同じように ClockRising する度にボタンの投下状態を調べ Data1 の出力を切り替える
  7. 16回以降の ClockRising 後は Data1Low に変化する
  8. 一定回数 ClockRising した後に先頭に戻る

image.png

ClockのRising回数とボタンの対応表は以下の通りです。

ClockのRising回数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
対応するボタン B Y Select Start A X L R 0 1 2 3

最後の 0123 は識別符のようなもので、コントローラーによっては0番のみ常に投下状態にあったりするそうです。基本的なコントローラーについては0-3全て押されていない状態となります。

マルチタップ接続時の挙動

1, 2, 3, 4番の4つのコントローラーが接続されているマルチタップの場合は、上記挙動に次のような挙動が加わります。

  • SelectHigh の場合 Clock の信号が1, 2番のコントローラーに伝わる
    • 1番コントローラーと2番コントローラーが処理される
      • 1番の出力は Data1
      • 2番の出力は Data2
  • SelectLow の場合 Clock の信号が3, 4番のコントローラーに伝わる
    • 3番コントローラーと4番コントローラーが処理される
      • 3番の出力は Data1
      • 4番の出力は Data2
  • LatchHigh の場合、以下の通りとなる
    • Data1Low
    • Data2High

image.png

実装する

あとはお手持ちのArduino等でこの動きを模すことで、コントローラーとして認識されるようになります。

実装する際に、digitakWriteを使うとおそらく間に合わないとおもうので、直接ポート操作をすることになると思います。

PORTD |= (1 << PINDAT);
PORTD &= ~(1 << PINDAT);

最初は手元にあり、使われていなかったRaspberry Piを使おうかと思っていましたが、上記の処理を実行するためにはシビアな時間内で処理を終わらせる必要があり、コンピューターであるRaspberry Piでは不可能(?)な気がしたため、マイコンであるArduinoを選択しています。

エミュレーター上のTASの入力を出力する

SFCコンと同じ出力をする装置が完成したところで、実際に受け渡す入力の情報をこちらで準備します。

ラグフレームを取り除く

どう準備するかの話の前に、SFC本体が LatchRising させるタイミングについての話をします。

Latchが常に毎フレームで Rising しているかというとそうではなく、ラグフレームという状態である時には LatchRising しません。

1フレーム内で実行すべき処理が全て終わらなかった場合にラグフレームの状態となり、次のフレームにも処理が継続されます。

そのため、ラグフレームのある状態(各データ読み込み時, 画面上のスプライトが多い時等)ではコントローラーの入力は受け付けないようになっており、そのような状態では LatchRising の回数が 秒間60回を下回ることになります。

今回、入力のタイミングを 入力が催促される度にPCに信号を送る としたのは60fpsの間隔のタイミング保持の役割をSFC側に持たせるためで、 LatchRising することでこの要項を満たしていますが、上記のラグフレームを考慮することが必要になります。

具体的には、ラグフレーム時に行われた入力を取り除くことが必要になります。

TASの生成物であるリプレイファイルは、基本的にそのフレームがラグフレームであるという趣旨の情報は保持せず、単純に毎フレームの入力状態のみを保持していることが多いため、ラグフレームの入力を取り除くには実際にエミュレーターで動かしながらラグフレームかどうかを判断する必要があります。

これはエミュレーター上のLuaJIで動くluaスクリプトを書き、 io.openwriteによってファイルに書き出すことによって実現できます。

エミュレーターの再現度の話

実際にSFCのエミュレーターであるBizHawkで書いたluaスクリプトを実行したのですが、どうも暗転処理を挟む度にdesyncしてしまうようです。

原因は単純で、実機のラグフレームの数とBizHawkのラグフレームの数が違うことでした。実機より高効率(?)に処理をしているのが見てわかりますが、ここではより完璧なエミュレーションが必要なため、1フレームで処理できる命令数に関しても左右されてしまうようでした。

SFCのエミュレーターの中に、bsnesというオープンソースのエミュレーターが存在しており、そのbsnesをベースとした、主にTAS制作に使われるlsnesというエミュレータがあり、こちらは実機とサイクル数までも一致させようというコンセプトもあるエミュレーターになっています。

lsnes上で実行することで、その再現度の高さからラグフレームの数も実機と同じになり、上記のラグフレーム数の問題も解決されました。

フレーム数調整

ただ、BizHawkで同じように作ったファイルであっても、ラグフレームの長さを考慮して入力と入力の間に新しく入力を挟み調整することでdesyncが回避できたことも確認しましたが、多くのラグフレームが挟まれる度に調整をしなければならないので、フレーム数調整は見送ることにしました。

完成(仮)

これで、実際に再生されるラグフレームのないリプレイファイル、それをSFCに入力するTASBOTが揃いました。

今回は時間の都合で1P1コンのみのTASBOTになってしまいましたが、任意コードの実行等でなければTASの動きをエミュレーター上と同じく再現することが出来ました。

↓こちらが元の動画です。

↓こちらは最初にコンソールと、SFCに接続している様子を映していますが、コントローラー側の処理落ちのため最後でdesyncしています。

良くない点

処理落ちによるdesync

シリアル通信によってコントローラーの入力を試みているため、1/60秒の猶予があるとしてもシリアル信号出力側の遅延によってdesyncしてしまうことが多々あります。

SDカードによる読み込みだとか、数フレーム単位のバルクにして送信するなど考える事はありそうですが、検討中です。

また、今回はCOMS4021を2つ使って作られる回路をArduinoで模してしまいましたが、これが大きくクロック数を消費しているのは明らかなので、実際に4021を2つ使うのも検討されるべきかと思います。

遅刻

本当は1P側2P側、合わせて8つのコントローラーで任意コード実行させるまで仕上げたかったのですが、遅刻をしてまでも間に合いませんでした…

制作に時間がかかっているのが、単に自分の電子工作に対する知識不足から来ているものかと思いますが、着実に完成には向かっているため、完成次第追記する予定だと思います。

終わりに

電子工作に手を付ける前、自分の目に映る60fpsの世界はとてもではないが追いつけるものではないという認識でしたが、自分の使っているMacbook Proよりはるかに非力なArduinoでさえも60fpsの世界に追いつくことができるというのは当たり前であるべきですが、少し不思議な間隔もありました。

普段はこんなに高級でパワーのあるマシンを使っているのだから、早いコードは書けて当然なのだ