谷本 心 in せろ部屋

はてなダイアリーから引っ越してきました

Goで格闘ゲームのマクロを実装してみた

このところ、夜な夜な格ゲーの動画を撮ってはYoutubeにアップしては、SRK(shoryuken.com)に取り上げられて承認欲求を満たしている [twitter:@cero_t] です。SRKは世界最大の格ゲーコミュニティだよ!
http://shoryuken.com/?s=Shin+Tanimoto


このエントリーは Go2 Advent Calendar 2017 の7日目です。
https://qiita.com/advent-calendar/2017/go2

はじめに

まえがき

学生時代、僕は格闘ゲームの連続技(コンボ)の研究を嗜んでおりまして、学校に行くぐらいならゲーセンを行く、試験勉強するぐらいならコンボの研究をする、卒論を書くぐらいならコンボビデオを撮るという、クズみたいな学生生活を送っておりました。当時、いまは亡き「ゲーメスト」というアーケードゲーム雑誌で体験ライターとして編集部に泊まり込んで記事を書くような体験もしました。
その頃よくプレイしていたストリートファイター系のゲームが、なんと18年の歳月を経て新作を出すというのです。これは万難を排してやらねばならぬと相成ったのですが、当時20歳前後だった僕も先日ついに40歳を迎えたわけで、さすがにもうあの頃のようには手が動きません。
しかし、いまの僕には、あの頃の自分にはなかったプログラミング能力があります。人間が手作業で行っていることを、機械に行わせるのが仕事です。そう、格闘ゲームも機械にやらせてしまえば良いじゃないか、というのが今回のテーマになります。


※なお、ゲームのネット対戦やオンラインランキングでマクロを使うのはマナー違反です。決してやらないでください。

目的

プレステ3やプレステ4、Xboxなどで使えるマクロを作ります。実装にはGoを使いました。

用意するもの

ゲーム機とPCをつなぐ、何かしらのデバイスが必要になります。今回はConsole Tuner社のTitan Oneという機器を利用します。
https://www.consoletuner.com/products/titan-one/


これはUSBゲームコントローラーを様々なゲーム機で使えるようにするための変換器です。しかしただの変換機能だけでなく、PCと繋ぐことでマクロ機能やプログラミングを組んだりできるほか、API自体もDLLとして公開されているので、自作アプリからも利用することができます。今回の用途にはベストです。


Titan OneとDLLを使って、次のような流れで操作を流し込みます。

ゲーム機 <-(USB)- Titan One <-(USB)- Windows(DLL <- 自作アプリケーション)

なんとなくイメージが湧くでしょうか?

なぜアプリを自作するのか?

先ほど「マクロ機能やプログラミング環境が用意されている」と書いた通り、用意された環境を利用すれば、別にアプリを自作するまでもなくマクロを実行することができます。ただ、付属のプログラミング環境ではミリ秒単位の制御しかできず、格闘ゲームの60fps(framer per second)の制御をするには不便でした。格闘ゲーマーは物事をフレーム単位で考えていて、ミリ秒単位で考えるわけではないのです。
なので、フレーム単位で扱いやすくするための、簡単なアプリを自作することにしました。

なぜGoなのか?

今回のアプリは、配布が容易であることと、Mac/Windowsのいずれでも開発できるという点でGoを選びました。
僕はJavaエンジニアなので、最初はJavaでJNAを使って実装しました。Javaでも問題なく動くものができたのですが、ただちょっと配布するのが面倒だなと思い、かと言ってC#にするとMacでの開発環境が微妙なので、Goを選ぶに至りました。もちろんそんな消去法的な選択だけでなく、ちょっとGoを勉強してみたかったという背景もあります。

DLL呼び出しの実装

それではGoで実装していきます。僕はGo初心者で体系的に学んだこともないので、ここがおかしいよとか、ここもっとこうした方が良いよというのがあれば、教えてもらえると凄く嬉しいです。

DLLの読み込み

まずTitan Oneが提供しているDLL(gcdapi.dll)を読み込む部分を作ります。
goでDLLを読み込むにはsyscall.LoadDLLを使います。

dll, _err := syscall.LoadDLL("gcdapi.dll")
if _err != nil {
	log.Fatal("Error loading gcdapi.dll", _err)
}

これでDLLの読み込みができます。


ここで「Failed to load gcdapi.dll: %1 is not a valid Win32 application.」というエラーに悩まされました。
原因は「gcdapi.dllが32bit」だったことです。64bit環境でコンパイル/実行しようとするとエラーになるので、32bit環境向けにコンパイルする必要があります。

set GOARCH=386

これでOK。
ここで1時間ほどハマっていました。

DLL内の関数呼び出し

DLLにある関数を呼び出すには、読み込んだdllのFindProc関数を使います。

gcdapi_Load, _err := dll.FindProc("gcdapi_Load")
if _err != nil {
	log.Fatal("cannot find gcdapi_Load", _err)
}

result, _, _err := gcdapi_Load.Call()
if result == 0 {
	log.Fatal("gcdapi cannot be loaded")
}

これでgcdapi_Loadという関数を実行できます。
DLL内にある他の関数も、同じ要領で先に読み込んでおきます。


また、関数に引数を渡す必要がある場合は、uintptr型のポインタとして渡します。
次の例は、someFunction関数にarg1という値を渡す例です。

result, _, _ := someFunction.Call(uintptr(arg1))

戻り値はresultに入ります。

配列や構造体を引数に渡す

引数に配列や構造体を渡す場合は、先に参照をポインタに変換してから、さらにuintptrに変換します。
正直、参照がどうなってるのかもはや僕には理解できないのですが、こうすれば動くということだけ確認しました。

func write(inputs *[36]int8) bool {
	result, _, _ := procWrite.Call(uintptr(unsafe.Pointer(inputs)))
	// 略
	return true
}

ちなみにここで配列ではなくスライスを渡していたために上手く動かず、2時間ほどハマりました。初学者はとにかくハマって時間をロストしますね。


また構造体を引数にする場合は、DLL側に用意された構造体と同じ名前、同じ型の構造体をGo側に用意します。
次の例ではGCAPI_REPORTという構造体を引数に渡しています。

type GCAPI_REPORT struct {
	console       uint8
	controller    uint8
	led           [4]uint8
	rumble        [2]uint8
	battery_level uint8
	input         [30]GCAPI_INPUT
}

type GCAPI_INPUT struct {
	value      int8
	prev_value int8
	press_tv   uint32
}

func read() bool {
	var report GCAPI_REPORT
	result, _, _ := procRead.Call(uintptr(unsafe.Pointer(&report)))
	log.Println(report)
	// 略
	retun true
}

構造体は恐ろしいぐらいにハマらずに値が入りました。簡単です。最高です。

MacでもWindowsでも実行したい

ちなみにここまで使ってきたsyscall.LoadDLLや、その戻り値であるsyscall.DLL、syscall.ProcなどはWindows環境でしか利用できず、Mac環境でコンパイルしようとするとエラーになってしまいます。
せめてMacではDLL呼び出し部分だけモックにして、アプリ全体としては動くようにしておきたいものです。そういうのもきちんと用意されていました。


同じパッケージ内に、同名の関数を定義した別ソースファイル(モック用関数群)を作り、そのファイルの先頭にこんなコメントを入れます。

// +build !windows

これでWindowsの環境以外でビルドした場合には、モック用関数群のファイルが利用されるようになります。
・・・ちょっと何を言ってるのか分からない感じなので、実物を見て頂いたほうが早いと思います。


Windows用のソースコードがこれ。
https://github.com/cero-t/cero-macro.t1/blob/master/gcapi/gcapi_dll_windows.go


Windows以外用のソースコードはこれです。
https://github.com/cero-t/cero-macro.t1/blob/master/gcapi/gcapi_dll_other.go


いいですね、簡単です。

次に入力側を作る

DLLの呼び出しが上手く行ったので、次は呼び出し側の設計/実装です。

懐かしのコマンド

20世紀に格闘ゲームをやっていた人にとって、「236p」という文字列は波動拳コマンドにしか見えません。これはテンキー配列の「下 右下 右」に相当するもので、最後のpがパンチです。当時のパソコン通信やインターネットで頻繁に用いられた記法です。同様に、昇龍拳は「623p」、竜巻旋風脚は「214k」となります。今回はこの表記をします。
また、パンチの小中大はそれぞれl, m, hで表現し、たとえば小パンチは「lp」、中キックは「mk」、しゃがみ大キックは「2,hk」とします。これは海外の格闘ゲーマーが用いている表記方法です。


この表記法を使い、たとえば次のようなテキストを受け取ることを想定します。

2,mk 8
3 1
6,lp 1

これはレバー下と中キックを8フレーム、右下に1フレーム、右と小パンチを1フレーム、というコマンドです。中足波動拳ですね。
このようなコマンドを受け取って、上で説明した関数の呼び出しを行うようにします。

入力コマンドの変換

格闘ゲームの入力は精度が命ですから、Go側の処理に時間が掛かってコマンドが不安定になってしまっては意味がありません。そのため、上に書いたテキストをあらかじめパースして配列などに変換しておき、その後にまとめてDLL経由でコマンドを流し込む処理を行うようにします。変換とDLL呼び出しを逐次行っていると、想定しない処理遅延が発生する可能性があるためです。


テキストをパースした後、次のような構造体に変換します。

type State struct {
	buttons [36]int8
	frames  uint16
}

buttonsが押すべきボタンの一覧です。配列のそれぞれがボタンやレバーに対応しており、押さない時は0、押した時は100という値が入ります。


変換は地味なコードなので割愛します。ここでやってます。
https://github.com/cero-t/cero-macro.t1/blob/master/processor/converter.go

正確な60fpsのコマンド実行

続いて、コマンド実行の処理です。State構造体の配列をきっちりと指定したフレームで呼び出す必要があります。
ちなみに格闘ゲームではよく「1/60秒」という表現が使われて、「光速は遅すぎる」など話題になることがありますが、少なくとも手元環境のプレステ3で確認したところ、1フレームは「1/60秒」ではなく「1/59.94秒」でした。


そのあたりも踏まえて、次のようなコードを書きました。ボタンを押した後に、そのフレーム数分(たとえば5フレームなら、約 5 * 16.6ms = 83ms)だけ待つというコードです。
ただしこの処理自体の実行時間も考慮する必要があるため、現在時刻など利用して、フレームレートを保つようにしています。

// フレームレート (frame per 1000 seconds)
const frameRate uint64 = 59940

func Process(states *[]State) {
	var totalFrames uint64
	start := time.Now().UnixNano()

	for _, state := range *states {
		// ボタンを押す
		gcapi.Push(&state.buttons)

		// フレーム数分だけsleepする
		totalFrames += uint64(state.frames)
		sleepTime := start + int64(totalFrames*1000*1000*1000*1000/frameRate) - time.Now().UnixNano()
		time.Sleep(time.Duration(sleepTime))
	}
}

正確に59.94fpsを保つためにわざわざアプリを自作したので、この処理が一番のキモと言えますね。


この関数を含む処理全体はここにあります。
https://github.com/cero-t/cero-macro.t1/blob/master/processor/processor.go

UIはどうする?

最後にUIです。
GoのUIをどうすべきかは議論があるようですが、「クロスプラットフォームで使えて、皆が慣れ親しんでいるUI」と言えば、コマンドラインCUI)とHTMLの2つでしょう。

まずはCUI

テキストファイルを読み込んでその中に書かれた操作コマンドを実行するCUIを作ります。
こんな感じで、実行時の引数にファイル名を指定します。

$ go run macro.go combo.txt

これでcombo.txtに書かれた内容が、ゲーム機に送られるのです。


このmacro.txtという渋い名前のファイルの中身は、こんな感じです。

9 34
hp 20
2,mk 8
6 1
2 1
3,hp 1

ジャンプ大パンチ 中足 昇龍拳、という感じです。

続いてGUI

もう一つのUIが、HTTPサーバとして起動し、画面で入力されたコマンドを実行するGUIです。実行時の引数にファイル名を指定しなければHTTPサーバとして立ち上がります。

$ go run macro.go
Server started at [::]:8080

それでlocalhostの(あるいは別のPCからIPアドレスを指定して)8080ポートにアクセスすれば、こんな画面が出てきます。

テキストボックスにコマンドを入力して「run」を押せば実行できる形です。
マクロで動かす以外にも簡単な操作をリモートでできるよう、他のボタンも置いておきました。「LP」ボタンを押すと、「lp 2」というマクロ(小パンチを2フレーム押す)が送られます。


なおHTMLファイルの使い方ですが、Goでリソースをバンドルするのがちょっと面倒だったので、ソースコード内にヒアドキュメントで書いてしまいました。

func formHTML() string {
	return `
<!DOCTYPE html>
<html>
<head>
(略)
</body>
</html>
`
}

なおViewフレームワークには超軽量で有名な「Vanilla JS」を用いているため、この1ファイルだけでViewのレンダリングやサーバとの通信処理なども実装できています。
http://vanilla-js.com/
はい。


HTTPクライアント、サーバ関連のソース全体はこちらです。
https://github.com/cero-t/cero-macro.t1/blob/master/http/form-html.go
https://github.com/cero-t/cero-macro.t1/blob/master/http/http.go

実際、試してみたところ・・・!!

これで画面からDLLまですべて繋がったので、いざ実機で挑戦!


おぉ、動く動く! キャラが動くよ!


・・・あれ、コンボが途切れるぞ?
なんか、コンボが不安定だぞ?


そうなんです、どうやらTitan Oneは処理遅延が10ms程度あり、その遅延も安定しないため、格闘ゲームの1フレームを安定させて動かすのが難しいようなのです。
ここまで書いてきてこのオチはなかなか酷いのですが、別にこれで終わりというわけではありません。
より遅延が少ないデバイスが出てきて、それをAPI経由で叩けるようになれば、また再挑戦したいと思います。


あれ、この手元にあるデバイスは、何だろう??


来年には、API経由で叩けるようになるのかなぁ。