非公式東方鬼形獣バグ修正パッチリリース

非公式ですが東方鬼形獣のバグを直して回るパッチ作ったので公開します。

th17patch.zip

直るバグは以下の通り

  • オオワシ妖夢の攻撃力上限が他より100低い
  • 実績から再生できるEDが違う
  • カワウソ妖夢1面の誤字
  • 実績38の誤字
  • replayフォルダ以下にもなぜかsnapshotフォルダがある
  • リプレイでクリア系実績が解放される

th135_ai v0.12リリース

http://wordpress.click3.org/garakuta/th135_ai.zip

キャラ別能力に関するメソッド群の追加とキャラ日紐づくobjectの取得とrequireの追加。
こいしさんだけあまりにも難解だったので妥協していて、セットされている技のIDが取れていない。

いい加減サンプルコードとドキュメントの充実頑張らないとなぁ。

東方心綺楼の解析

東方心綺楼の体験版が頒布された当時自分は、東方緋想天と同様に数々のツールが作られ、単純に対戦する以上の楽しみが待っていると疑いませんでした。
しかしそれから待てど暮らせど、ツールの一つ出てくる気配はありません。
その謎は数カ月後に明かされることになります。
Web体験版の公開、意気揚々と逆アセンブラをかけた自分の目の前に広がっていたのは、経験したことのない難解なコードでした。

というわけで、東方心綺楼がなぜ解析しづらいのか、また自分がここまで解析してきた成果をまとめておこうと思います。

心綺楼の解析はなぜ難しいのか

結論から言えば、C++などのネイティブ言語だけで作られたものではないから、という一言につきます。

通常CやC++などネイティブな言語のみで作られている場合、難読化をかけたとしても似通った処理は一箇所にまとめられることになるため、ある程度知識があれば読み解くことはそう難しいことではありません。
例えば、暗号化されたファイルを読む場合、ファイルを読むReadFileのAPIをフックして場所を特定し、そこから何度かリターンを経たあたりに復号化処理が書かれているわけです。

しかし、今回の心綺楼はSquirrelというアプリ内組み込みの言語によって大部分が実装されており、上記のようには行きません。
単純に変数から読み上げるというだけで幾つもの関数を呼び出したりリターンしたりと処理があっちこっちへと行き来し、人間がコードの意味を理解しながら本質的な部分まで追う労力は生半可なものではないからです。

つまり、逆アセンブルなど機械語に翻訳される言語用に特化した解析手法ではツール作成はかなり難しいことになります。
※綺録帖のようになんとかしたツールも存在するので、完全に不可能というわけではありません

ではどうするのか
Squirrelで書かれているならば、こちらもSquirrelと同じ土俵で戦えばいいのです。

Squirrelの入り口、HSQUIRRELVMを見つける

前の章でSquirrelと同じ土俵で戦えばいいと書きました。
ではSquirrelとは何なのかといえば、アプリ内組み込みで用いられるスタックベースの小さな言語になります。
th123_aiで使われているluaと同種の言語ですね。

軽くリファレンスを読んでみればわかりますが、SquirrelのCインターフェースは、基本HSQUIRRELVMを第一引数に受けて動作するようになっているため、HSQUIRRELVMさえ自由に出来れば乗っ取れそうです。

HSQUIRRELVMを取得するため、HSQUIRRELVMを受けていて、なおかつ解析しやすいように特徴的な文字列を扱っている関数を探します。
するとSQGenerator::Yieldがよさそうだということがわかりました。

bool SQGenerator::Yield(SQVM *v,SQInteger target)
{
	if(_state==eSuspended) { v->Raise_Error(_SC("internal vm error, yielding dead generator"));  return false;}
	if(_state==eDead) { v->Raise_Error(_SC("internal vm error, yielding a dead generator")); return false; }
	SQInteger size = v->_top-v->_stackbase;
……以下略……

※SQVM *とHSQUIRRELVMはtypedefされているだけで同じ型

では実際に、OllyDbgを使って一行目の文字列で検索し、使用している関数先頭にbreakpointを設置。
その状態で心綺楼を実行し、breakpointに引っかかった時点でスタックからHSQUIRRELVMの値を取得。
さらにメモリーエディタを用いてその値を格納しているメモリーを検索すると、どうやらスレッドローカル領域に格納しているようでth135.exeの.tlsセクション内がヒットしました。
これでSquirrelの世界へ攻め入ることができます。

まずはHelloWorldから

解析してツールを作ろうというのにHelloWorldというのも変かと思われるかもしれませんが、まだ前の章で取得した値が本当にHSQUIRRELVMなのか確認できていません。
急がば回れ、ここは慎重に動作確認から行きましょう。

C++サイド

  sq_setprintfunc(v, printfunc, printfunc); // printfuncの実装については省略
  sq_pushroottable(v);
  sqstd_dofile(v, "main.nut", 0, true);
  sq_pop(v,1);

main.nut

print("HelloWorld");

ひとまずこんなコードをdllインジェクションして実行してみました、クラッシュしました。
……やはり確認は重要ですね。

なぜクラッシュしたのか

VisualStudioでスタックトレースをみてみるとsq_dofile内のreallocでランタイムチェックに失敗してクラッシュしているようです。

なぜreallocでクラッシュするのかというと、メモリーの効率化のためランタイムライブラリの内部でmallocやfreeやreallocする対象のメモリーについて管理を行なっており
にも関わらず自作コード内と心綺楼本体という別々に管理されているものを混ぜてしまったことが原因となります。

ではどうするのか

話は簡単、自作コード内と心綺楼内部に存在するsq_dofileのうち心綺楼内部の方を使えばいいのです。

というわけでHSQUIRRELVMを探してきた時と同じように、ユニークな文字列を探し、そこからサーチすることにします。
sqstd_loadfileにユニークな文字列があったので、そこを呼び出している関数一覧を出し、それらの逆アセンブラコードを軽く読んでsqstd_dofileを特定しました。

ではsqstd_dofileをそのアドレスに置き換えてみて実行してみましょう。

マルチスレッド対応でもないのにメインではないスレッドから使用しているためたまにクラッシュしますが、一応HelloWorldと表示されることもあるはずです。
ちょっと不安になる結果ではありますが、HSQUIRRELVMはこの値で正しいようです。

クラッシュ対策

前の章でとりあえずHelloWorldは出来ました。
しかしたまにクラッシュしたり、表示文字列が化けるようなのはなんとも消化不良な感が漂います。
そこで、解析を次のステップに進める前に、動作を安定化させようと思います。

動作が不安定なのは別スレッドから内部のスタックを触っているからにほかなりません。
ではメインスレッドで自分を実行させる方法はないのか、リファレンスをひっくり返して探して来ました。
その結果、sq_setnativedebughookで関数ポインターを設定すれば、Squirrelの関数が実行されたタイミングで自作コードを走らせることが可能らしいとの記述が。
念のためsq_setnativedebughookの実装も読んできましたが、特にマルチスレッドで操作して落ちる恐れも無さそうです。

というわけで現状のコードはこちら

#include <stdio.h>
#include <algorithm>
#include <boost/version.hpp>
#include <boost/static_assert.hpp>
#include <boost/filesystem.hpp>
#include <Windows.h>
#include <org/click3/dll_hack_lib.h>
#include "squirrel.h"

unsigned int baseAddr;
SQRESULT (*dofile)(HSQUIRRELVM, const SQChar *, SQBool, SQBool);

void printfunc(HSQUIRRELVM v, const SQChar* s, ...) {
  va_list arglist;
  va_start(arglist, s);
  vprintf(s, arglist);
  va_end(arglist);
}


void hook(HSQUIRRELVM v, SQInteger type, const SQChar * const sourcename, SQInteger line, const SQChar * const funcname) {
  static bool first = true;
  if (!first) {
    return;
  }
  first = false;
  sq_pushroottable(v);
  dofile(v, "main.nut", 0, true);
  sq_pop(v,1);
}

void main() {
  org::click3::DllHackLib::SetupConsole();
  baseAddr = reinterpret_cast<unsigned int>(::GetModuleHandleW(NULL));
  dofile = reinterpret_cast<SQRESULT (*)(HSQUIRRELVM, const SQChar *, SQBool, SQBool)>(baseAddr + 0x0034AAF0);
  unsigned int addr = 0;
  while(addr == 0) {
    Sleep(1000);
    addr = *reinterpret_cast<const unsigned int *>(baseAddr + 0x004D7984);
  }
  HSQUIRRELVM v = reinterpret_cast<HSQUIRRELVM>(addr);
  sq_setprintfunc(v, printfunc, printfunc);
  sq_setnativedebughook(v, hook);
}

※インジェクション部分は省いています

こころさんとの遭遇

さて、前の章でSquirrelの中にコードをインジェクションしたり、内部構造を自由に操作することができるようになりました。
では次はキャラのxy座標の取得を目標に、Squirrelの内部データサーチを行……う前にちょっと脇道にそれましょう。

まずSquirrelのグローバルな名前空間のものを一覧表示してみます。
色々面白いものは見つかりますが、キャラ情報らしきものは見つかりません。
そこでテーブル要素に限り1階層だけ下まで中身を表示するようにしてみます。

おや、save_dataの下にenable_kokoroという好奇心を刺激されるものがありますね。
ちょっと有効にしてみましょう。
※2013/08/03現在「秦こころ」は未実装であり、ストーリー以外で使用することはできません。

void hookEnableKokoro(HSQUIRRELVM v, SQInteger type, const SQChar * const sourcename, SQInteger line, const SQChar * const funcname) {
  static bool first = true;
  if (!first) {
    return;
  }
  first = false;
  sq_pushroottable(v);
  sq_pushstring(v, "save_data", -1);
  sq_get(v, -2);
  sq_pushstring(v, "enable_kokoro", -1);
  sq_pushbool(v, true);
  sq_set(v, -3);
  sq_pop(v, 2);
}

さて、この状態で心綺楼を起動してみるとどうなるのでしょうか……

キター

はい、ここまで本筋と関係のない脱線でした。
ちなみにこの状態で設定をいじったりプロファイルをいじったりするとセーブされてしまい、enable_kokoroへの操作をやめてもこころさんは有効になったままになってしまいます。
試す人は注意。

xy情報へのアクセス

とりあえず対戦中にしてから前述のグローバル名前空間内の物を列挙してみます。
すると、actor以下にplayer1といういかにもなものが増えていました。

試しにplayer1以下を表示させてみると、lifeやら何やら色々出てきて、もうこれに間違いないという気分になりますが、なぜかxyだけ見当たりません。
どういうことかと思い調べてみると__gettable以下にxとyという関数が。
これは何らかの抽象化の臭いがします。
なのでとりあえず以下の様なコードで取得を試みてみました。

void hookPrintPos(HSQUIRRELVM v, SQInteger hookType, const SQChar * const sourcename, SQInteger line, const SQChar * const funcname) {
  sq_pushroottable(v);
  sq_pushstring(v, "actor", -1);
  sq_get(v, -2);
  const unsigned int size = sq_getsize(v, -1);
  if (size < 2) {
    sq_pop(v, 2);
    return;
  }
  sq_pushstring(v, "player1", -1);
  sq_get(v, -2);
  sq_pushstring(v, "x", -1);
  if (SQ_FAILED(sq_get(v, -2))) {
    printf("error\n");
    sq_pop(v, 3);
  }
  int x;
  sq_getinteger(v, -1, &x);
  sq_pop(v, 1);
  sq_pushstring(v, "y", -1);
  if (SQ_FAILED(sq_get(v, -2))) {
    printf("error\n");
    sq_pop(v, 3);
  }
  int y;
  sq_getinteger(v, -1, &y);
  sq_pop(v, 2);
  sq_pushstring(v, "player2", -1);
  sq_get(v, -2);
  sq_pushstring(v, "x", -1);
  if (SQ_FAILED(sq_get(v, -2))) {
    printf("error\n");
    sq_pop(v, 3);
  }
  int ex;
  sq_getinteger(v, -1, &ex);
  sq_pop(v, 1);
  sq_pushstring(v, "y", -1);
  if (SQ_FAILED(sq_get(v, -2))) {
    printf("error\n");
    sq_pop(v, 3);
  }
  int ey;
  sq_getinteger(v, -1, &ey);
  printf("%d:%d  %d:%d\n", x, y, ex, ey);
  sq_pop(v, 4);
}

すると、予想通りXY情報が取得出来ました。
後から知ったことなのですが、__gettableなどはSqratというバインダーを使っていると増えるメンバーで、メソッドが見つからなかった場合の処理をフックしてC側の実態にひもづける何かなのだとか。

とにもかくにもxy情報も無事取得できました、めでたしめでたし。

終わりに

如何だったでしょうか?
主にSquirrelレイヤーに乗り入れる方法がメインで、そのあとは大体蛇足だった感じがします。

最後に上記のコードを利用して、ひたすら近づいてA連するだけのAIをおいておきます。
DLLインジェクション方法まで含んでいるので、試しに動かしてみたい方はどうぞ。
http://wordpress.click3.org/garakuta/th135hack.zip

非想天測1.10aパッチについて

気付いた人は気付いたかもしれませんが、th123d.datというファイルが追加されています。
リプレイの互換性があるのに追加ファイルとはどういうことなのか?
気になったので調べてみました。

とりあえずtouhouSEで展開してみると、中には文のモーション定義ファイルが一個だけ入っていました。
手元のツール(非公開品)にて解析してみると、天狗の太鼓から喰らい判定が一個削除されていることがわかりました。
天狗の太鼓と言えば、意味不明なところに喰らい判定があるバグが有名ですが、もしやこっそり修正されたのか?
と思い、検証用のリプを1.10でとり、それを1.10aで再生してみたのですが、普通に喰らいました。

調べてみると、どうやらth123d.datは追加されただけで読み込む処理は入っていないようです。
そこでth123d.datの読み込み処理を追加するパッチを作って試してみたところ、天狗の太鼓バグが発生しなくなりました。
どうやら推測は当たっていたようです。

どうしてファイル入れるだけで有効化していないのかはわかりませんが(リプレイの互換性対策?)、一応天狗の太鼓バグなどを直すつもりではいるようです。
いつになるかはわかりませんがまったり待っていましょう。

非想天則の早苗さんの二柱ゲージについて

ゲージは使用した後回復までにかかる時間が指定される方式であり。
見た目が同じゲージ半分でも、実際に回復にかかる時間は消費した技によって異なる。
具体例を出すとLv0突ゲージ半分では待ち150Fで、Lv4突ゲージ半分では待ち90Fとなる。
(ゲージ最大値が0で、技を使うたびに-300や-180になっているイメージ)

また、ゲージには以下の3つの状態が存在する。
1:ゲージを消費してから回復をはじめるまでの待ち時間、設置技なら消えるまではここに当たる。
2:上記状態中に被弾やキャンセルなどを行った場合に課せられる回復停止時間、1の残量に関わらず常に26F。
3:個々に決まっている猶予F分だけ待たされる、ゲージ回復時間。

2のキャンセルはあくまでキャンセルした場合であり、スペルを使っても消えない物(罠など)は回復を始めるわけでは無い。
上記フレームにヒットストップは一切影響が無い、つまり多段ヒット技で2の被弾キャンセルになろうと回復停止は26Fである。
回復中であれば暗転や時間停止中であっても関係なく回復するが、回復停止中は暗転や時間停止中はカウントされない。
(例:回復開始直後咲夜の世界で終了時ゲージは全快、回復停止中咲夜の世界で解除後も変わらず)
正確に言うと、停止中であっても回復はしているが停止解除と同時に停止前の状態に戻る。
なので、暗転直後にゲージをよく見ていると一瞬だけ伸びてすぐ元に戻る様が見れる事がある。

さらに豆知識として、突などいくつかのスキルはなぜか内部的にはゲージ回復時間が+30Fされており
回復ディレイ中に30Fだけ回復してからゲージ回復を始める。
(突Lv0は内部的には330であり、回復ディレイ50F目に30F分回復している)
なので、よくゲージを見ていると回復を始める一瞬前に少量だが回復しているのを見て取れる。

以下の一覧表はゲージ回復に関したフレーム情報である。
回復ディレイと召喚終了待ちと召喚後ディレイは全て上記でいう1に該当する。
ゲージ回復は3に該当し、2は全てで26Fだったので省略した。
ゲージ消費の項は技モーション開始から実際にゲージ消費が行われるまでのフレーム数で
このフレーム数以内に被弾した場合はゲージ消費が行われない。

・乾神招来 突
ゲージ消費:6F
回復ディレイ:76F
ゲージ回復:
・Lv0:300F
・Lv1:270F
・Lv2:240F
・Lv3:210F
・Lv4:180F

・坤神招来 盾
ゲージ消費:14F
召喚終了待ち:258F(自然消滅時)
召喚後ディレイ:26F
・Lv0:180F
・Lv1:165F
・Lv2:150F
・Lv3:135F
・Lv4:120F

・乾神招来 風
ゲージ消費:14F
回復ディレイ:169F
ゲージ回復:
・Lv1:180F
・Lv2:165F
・Lv3:150F
・Lv4:135F

・坤神招来 鉄輪
ゲージ消費:14F
召喚終了待ち:54+Lv*8F
召喚後ディレイ:48F
ゲージ回復:
・Lv1:210F
・Lv2:190F
・Lv3:170F
・Lv4:150F
なぜか被弾時Lvに関係なくゲージ回復が180Fになる。

・乾神招来 御柱
ゲージ消費:3F
回復ディレイ:264F
ゲージ回復:
・Lv1:180F
・Lv2:170F
・Lv3:160F
・Lv4:150F

・坤神招来 罠
ゲージ消費:15F
召喚終了待ち:324+Lv*61F
召喚後ディレイ:26F
ゲージ回復:
・Lv1:180F
・Lv2:150F
・Lv3:120F
・Lv4:90F
なぜか被弾時Lvに関係なくゲージ回復が180Fになる。

ちなみに、上記の情報は俗に言うムダ知識だ。
あくまで知的好奇心を満たすためだけの物であり、実戦においてはなんら役に立たない事を明記しておく。
さらにいうと、上記情報の大部分はツールサポートを得た上でとはいえ目視による実測であり、多少間違いがあるだろう物でもある。
exeファイルを解析し直に抜きだした物では無いため情報の信憑性は保障できない、利用する場合はその事を承知の上でお願いしたい。

非想天則の霊力に関して

非想天則の霊力球一つを200とした場合、1Fごとに
6回復する。
さらにグリモワールを使った個数×1だけ回復する。
さらに天候雹なら6回復する。

霊力回復開始タイミングは霊力消費からきっかり60F後である。
だが、霊力消費タイミングが異なるために行動ごとに回復速度差が存在する。
(と思っているけど、全て調べたわけでは無いので一部例外があるかも)
※2010/01/14追記:普通に違った、詳細は霊力回復開始フレームについてを参照

これらの情報から霊力回収期待値のような物を算出できる。
例えばアリスのCの場合
・霊力消費タイミングは8F
・全体Fは40F
なので、通常時はモーション終了後28F後に回復を始め、その34F後には霊力が射撃前に戻る。
つまり、40+28+34で102Fの猶予があればCを撃っても霊力の採算は付く計算になる。

コレだけでは面白くもなんとも無いので、ためしにグリモワールを4つ使用した場合と比較しどの程度変わるのか計算してみる事にする。
グリモワ4積みで回復に必要な時間が20Fに減るので、40+28+20で88Fで採算が付く。
なので14F得した事になり、1射撃ごとに回復を待つ立ち回りでも1.16倍の量の射撃を撒ける計算になる。

仮に霊力尽きるまで撃ち続けてから回復したと仮定すると
・通常時40*5+28+167=395F
・グリモワ4積み40*5+28+100=328F
となり、1.2倍撒ける計算。

結論として、グリモワ4積み自体は回復速度1.67倍であるが、実際に撒ける射撃の量になおせばアリスC射では1.16~1.2倍程度であり、回復速度に騙されて1.67倍も撒くと息切れてしまうことがわかった。

ちなみに、こんな事がわかっても何も得しない。
根拠は、今これを書いている自分が1年やっていて未だにレート1200台のプレイヤーだということ。
頭でっかちってやーね。

東方新作用ツールの進行度合いとか

色々経験値積んだので効率化を図るために環境をいじいじしていたり
諸々の知識を忘却してしまったので手探りで解析始めてみたり
そんなこんなでようやくスタート地点につけた所ですが、もう日曜が終わろうとしているというね。

とりあえずpn2cは更新しました。
がんばって緋想天と非想天則どちらでも動くようにしたよ!やったね!
まぁ、ツールスレの動き見ている限りではtskにこの機能乗りそうなのでいらないかなーとは思いつつも周作ということで
なので告知はしない、するにしても他のとまとめてって事に。

さて、touhouSEは特に何かするまでもなく動いちゃってる感じだし
(palファイルの扱いでエラーしてるけど)
次はth105_aiにするべきか、それともその他小物を弄ってもう少し経験値溜めてからにするか……

皆準備が終わったようです

東方系ツール作者さんたちの、ね。

ツールスレがここ二日ぐらいにぎやかで、Tenco!の人や緋行跡の人やらが動き出したようです。
あぁ皆楽しそうだなぁ……

とりあえず「ProfileName to Clipboard」とth105_aiとtouhouSEあたりは対応させたいけど
そんなに時間があるか、それ以前に東方新作入手できるか怪しいところ。
あぁ、早く土日こい……

必要は発明の母

だっけ? うろ覚えだからちょっと違うかもしれない。

framedisplayswrでは限界を感じる今日この頃
てかpatファイルの中身を切り捨てすぎなんだよ、これ。
せめてID-1と画像の横幅&縦幅には対応してくれ……

ざっと動作見るには十分だけど、きちんとした動作見るにはバイナリエディタで読むか実機で動かしてみるしかないしなぁ
時間が出来たら自力で代替物作ってみたいけど、うーむ