Terraria Mod開発の話

暇なときのメモ書き

MonoMod

この記事初心者向けじゃないからよろしくな

tModLoaderには次のアプデでMonoModってのが追加される。 ちなみに今はBetaだからDLしてないやつはDLしとけ、さすがに必須

これが圧倒的最強で、なんと今まで手を入れられなかったバニラテラリアのprivateなあんなとこやそんなとこをいじくりまわせるのだ

まるで男優と女優と犬が超高速移動する時間停止モノ...話が逸れた

とりあえず、GithubのWikiにtML公式が用意してるチュートリアルがあるのでそれを開いて同時に読んでほしい。 画像用意するのめんどいからな、あと英語読めるやつはこのサイト見てないでWikiだけ見てればいいぞ

OK、開いた? そしたらdnspyインストールしろって書いてあるからサクッとインストールする...って言っても一番上にあるnet-472.zip的なやつをDLして解凍するだけだが。

tMLはHivePackをアップグレードして、アップグレードしたアクセサリを装備しているときに蜂が強化される代わりにBeenadeを出したいらしい。バニラの武器でも。

まあこっちはModでタイマー追加できないことにいら立ってるからModでタイマー追加できるようにコードを変えるんだが。

なんでこれができないかって言うと、Wiring.HitSwitchでタイルのID(type)を照合してバニラの一部のタイル(スイッチとかタイマーとかその他もろもろ)だけ信号を送るようになっているから。

その前に何やらリファレンス追加しないといけないらしい。 公式は不親切なことにリンク貼ってないから貼っておく

github.com

この中でDLしなきゃいけないものは結構あって、

  • Mono.Cecil.dll
  • Mono.Cecil.Pdb.dll
  • MonoMod.Utils.dll
  • TerrariaHooks.FNA.dll
  • TerrariaHooks.XNA.dll

どれが必須かはわからんが自分の環境じゃこれで動いたからまあディスクきつい人はきっちり選ぶ前にディスク整理するなり全部クラウドに置いとけ

これを全部DLしたらModCompileフォルダに入れて今から作業するプロジェクトでリファレンスに入れる。

これで環境セットアップおわり

したらdnspyでtModLoader.exeを指定して開く。 tMLチュートリアルだとplayer.beeType()を開いてる。 こっちだとWiring.HitSwitch()を開く。

で、上の方にStartとかUndo-Redoと同じ高さにC#って書いてあるやつがある。 これで表示する言語を選べる ILが中間言語。ILはスタックマシンになってる。

開いたら、右のAssembly ExplorerでHitSwitchの上で右クリックしてEdit Method(C#)

f:id:kodamamiyabi:20190615162458p:plain
今回追加するコード(カーソルの位置から下)

ここで自分がコード書いてこうなってくれたらいいなっていう未来予想図を書く。 このステップは飛ばしてもいいんだが。 今回追加するのはコード末尾のカーソルのあるreturnから下

公式のチュートリアルに書いてあるから一応コンパイルしてみる。 右下のCompileってボタンを押す...がこのままだと通らない。 TileObjectData見たことねえぞって言われるので、

using Terraria.ObjectData;

とかやってやる。まあコンパイルは通る。 コンパイルしたら最後のelseの代わりにreturnが入ったが。まあいい

そしたら表示言語をILにしてみる。やたら長くてどの部分が変わったかぱっと見じゃわからないが、 今回はコード末尾に追加してあるのと、(結果的に)Returnが2つ追加されてるから、 下から見ていってretを2個探せばいい。 と思うじゃん。関数の最後にretが勝手に追加されるので3つです。

IL表示の時のピンクの文字にマウスカーソルを合わせるとその記号がどういうやつか英語で教えてくれる。 クリックするとMSのリファレンスWebページに飛ぶ

見慣れない記号列ばっかりだがretはreturnの略だったりcallは普通に関数呼び出しだったり、 英単語の略が多いから普通に読める、抵抗感なくしていけ

ピンク文字の左についているIL_0000みたいなやつはラベルって言う。 if文とかfor文とかその他もろもろ諸星きらりはこのラベルを使って指定位置に飛ぶ (正確には違うっぽい挙動してたが何を基に飛んでるか調べるのがめんどくさかった)

ifコードブロックの終わりに文字通り飛んでるってこと

いろいろわかったところで、 公式はコード変えるのに3つのアプローチを書いているけど実質2つ

1,2はC#を書いて、 3はILを書いてる。

んで、ぶっちゃけ言うと3でしかさっきのCompileは必要ない。 1,2はILは見るけどCompileはイメトレ程度

そしたら実際にコード書いていく

まず、ModTileのAutoLoad()に

public override bool Autoload(ref string name)
{
    IL.Terraria.Wiring.HitSwitch += HitSwitchHook;
    return base.Autoload(ref name);
}

って書いてやる。AutoLoad()でやる理由は不明。1回きりでいいならModのコンストラクタでやってもよさそうだけどわからないのでAutoLoad()使いましょう(脳死)。tMLではModItemのAutoLoad()に追加している。 IL.Terrariaの行がHitSwitchって関数にHitSwitchHookで変更加えますよ的な感じ。批判受けそう。 HitSwitchHookって関数を定義とついでに操作できるやつを追加

using MonoMod.Cil;

//(略)

private void HitSwitchHook(ILContext il)
{
    var c = new ILCursor(il);
}

んで、基本的にこの操作をするときは、

  1. コードを追加したい場所の特徴を把握、移動
  2. コードを追加

の2ステップが必要になる。 とりあえず公式の1,2のバージョンだけ書く。正直IL書きたくないでしょ?編み物みたいで楽しい

まずは末尾(のretの前)に入れたいので、

c.Index = c.Body.Instructions.Count - 1;

でretの一つ前に移動。コードを追加する。

c.EmitDelegate<Func<int, int, bool>>((i, j) =>
{
    Tile tile = Main.tile[i, j];
    Main.PlaySound(28, i * 16, j * 16, 0, 1f, 0f);
    TileObjectData data = TileObjectData.GetTileData(tile);
    if (data != null)
    {
        int left = i - (int)(tile.frameX / 18);
        int top = j - (int)(tile.frameY / 18);
        Wiring.TripWire(left, top, data.Width, data.Height);
    }
    else
    {
        Wiring.TripWire(i, j, 1, 1);
    }
    return true;
});
c.Emit(Pop);

EmitでILのコード1個追加するみたいな。

Emit(コード, 引数);

EmitDelegateはdelegateしてあげる必要があるのでFuncを使う。 Funcは戻り値一つ(末尾)要求するので、何でもよかったけどboolで。 で、このboolが残っていると末尾のretでboolがスタックから読まれて返されてしまう(エラーを吐く)ので、末尾のPopで消す。

後は通常のC#コードなのでわかると思う。わからなかったらコード読め

できました!!!とはいかない

問題点その1

iとjはどこから引っ張ってくるの?

c.Emit(Ldarg_0);
c.Emit(Ldarg_1);

その2

さっき既存のif文の末尾にreturn追加してたよね?

c.Emit(Ret);

完成!!でもない。

WHY???????完成ジャナーイ???????

(Retを消してから)Main.newText()でなんか出せばわかるんだけど、なぜかLeverを押したときは反応するけど、ほかのタイルでは反応しない。

ってことはその手前にあるレバーのif文の中に追加されてしまっていることが考えられる。 コード追加していないHitSwitchのILコードを見てみる。

レバーのif文は411と132が入っているif文なので、411と132を手掛かりに探す。

f:id:kodamamiyabi:20190615174240p:plain
ILの画面

132の行の次(312行目)と411の行の次(320行目)に両方ともラベルが引数になっていることがわかる。

132の次は322行目へのラベルだったので(カーソルを合わせるとハイライト表示される)、if文の中に入っていそうだ。 411の次は...末尾のretに飛んでいた。

つまり、

  1. ifの中に入る→コードを順番に実行→新たに追加されたreturn呼び出し→終了
  2. ifの外→追加されたコードを実行→末尾のreturnを呼び出して終了

という動作を期待していたのだが、

  1. ifの中に入る→コードを順番に実行→追加されたコードを実行→終了
  2. ifの外→ラベルを基に末尾のreturnまでジャンプ(追加コードはスキップ)→終了

となっていた。 ラベルを基に末尾まで飛んでしまうので、ラベルを追加コードの手前に設定することで修正する。

まずは改変する場所を探す。 ここでID決め打ちすると他のModと被った時に簡単に競合してしまうので、ほかの情報を使う。

今回はbne.unとラベルが飛ぶ先がretであることを利用する。手前で411がスタックに積まれていることを利用してもいい。

var label = c.DefineLabel();
 ILLabel target = null;
while(c.TryGotoNext(i => i.MatchBneUn(out target)))
{
    if (target.Target.Match(Ret))
    {
        c.Remove();
        c.Emit(Bne_Un, label);
        break;
    }
}

var label = c.DefineLabel(); でラベル使いますよ辞書に追加しといてねってやる。批判受けそう。

ILLabel target = null; これもラベルだけど、これはbne.unに一致したやつをいれておく用

while(c.TryGotoNext(i => i.MatchBneUn(out target))) これでbne.unに一致するものがある間はbne.unのとこに移動するループをする。

if (target.Target.Match(Ret)) これでラベルの飛び先がRetであることを確認する

c.Remove(); で今まであった行を消して、

c.Emit(Bne_Un, label); で代わりに自分で設定できるラベルに書き換えたものにすり替えておく

そしたら、ラベルの飛び先はまだ設定できてないので、設定する

i,jを読むところにラベルを設定する。

c.MarkLabel(label);
c.Emit(Ldarg_0);
c.Emit(Ldarg_1);

今度こそ完成!!! お疲れ様でした!!!!

ふるこーど

using System;
using Terraria;
using Terraria.ModLoader;
using Terraria.ObjectData;
using MonoMod.Cil;

namespace BlogMod
{
    class blogSwitch : ModTile
    {
        public override bool Autoload(ref string name)
        {
            IL.Terraria.Wiring.HitSwitch += HitSwitchHook;
            return base.Autoload(ref name);
        }

        private void HitSwitchHook(ILContext il)
        {
            var c = new ILCursor(il);

            var label = c.DefineLabel();
            ILLabel target = null;
            while(c.TryGotoNext(i => i.MatchBneUn(out target)))
            {
                if (target.Target.Match(Ret))
                {
                    c.Remove();
                    c.Emit(Bne_Un, label);
                    break;
                }
            }

            c.Index = c.Body.Instructions.Count - 1;
            c.Emit(Ret);

            c.MarkLabel(label);
            c.Emit(Ldarg_0);
            c.Emit(Ldarg_1);
            
            c.EmitDelegate<Func<int, int, bool>>((i, j) =>
            {
                Tile tile = Main.tile[i, j];
                Main.PlaySound(28, i * 16, j * 16, 0, 1f, 0f);
                TileObjectData data = TileObjectData.GetTileData(tile);
                if (data != null)
                {
                    int left = i - (int)(tile.frameX / 18);
                    int top = j - (int)(tile.frameY / 18);
                    Wiring.TripWire(left, top, data.Width, data.Height);
                }
                else
                {
                    Wiring.TripWire(i, j, 1, 1);
                }
                return true;
            });
            c.Emit(Pop);
        }
    }
}

ここまで書くのにだいぶ疲れたから3は適当。 基本的にc.Emit()をコンパイル結果見ながら連打していくんだけど、どうやって関数呼ぶんや系が発生しがち。 おそらくこっちの方がパフォーマンスに優れている

var type = typeof(System.Int32);
var concat = typeof(System.String).GetMethod("Concat", new Type[] { typeof(object), typeof(object), typeof(object) });
c.Emit(Ldarg_0); //i
c.Emit(Box, type); //type(System.Int32)でキャスト
c.Emit(Ldstr, " Hook "); //文字をスタックに
c.Emit(Ldarg_1); //j
c.Emit(Box, type); //type(System.Int32)でキャスト
c.Emit(Call, concat); //concat関数(引数はobjectの長さ3配列)を呼ぶ、結果はスタックへ
c.Emit(Ldc_I4, 255); //コンパイルしたら設定しろって言われた(デフォルト値), 255をスタックへ
c.Emit(Ldc_I4, 255); //同
c.Emit(Ldc_I4, 255); //同
c.Emit(Ldc_I4_0); //同, falseをスタックへ
var newText = typeof(Main).GetMethod("NewText", new Type[] { typeof(string), typeof(byte), typeof(byte), typeof(byte), typeof(bool) });
c.Emit(Call, newText); //newText関数を呼ぶ