はじめに
MinecraftのModをいじる上で、立ちはだかるMojanの罠仕様の壁。これを回避するにはCoreModを使って直接バイトコードを書き換える必要がありますが、asmを使って書き換えるのはなかなか厳しいものがあります。
そこで、Javassistを使って楽をしてしまいましょう
ごめんなさい
この記事はJavassistの使用方法や、CoreMod自体の作り方を解説するものではありません。そのあたりはJavassist公式ページ、CoreModはModding wiki、あるいは他の方が作られたModのコードをgithubで探してみてください。
また、コードは筆者の趣味でScala、Minecraftのバージョンは1.12.2となっています。ただ、そこまでScalaScalaしてないはずなので、Javaに置き換えるのは簡単... なはずです。
また、使い方自体はMinecraftのバージョンにも依存しないはずなので、利用したいバージョンに合わせてコードを読み替えてください。
Javassistって?
Javaのソースコードを実行時にコンパイル・バイトコードを生成、既存のクラスを書き換えたりすることができるライブラリです
公式ページは英語ですが、日本語のチュートリアルがあるので、簡単に使えると思います。
ライセンスはLGPLとMPL、Apache Licenseのトリプルライセンスなので、必要なものを選択しましょう。
お題
今回は火打ち石を使ったときの処理に色々差し込んでみましょう。コードとしてはこのあたりに差し込む感じです
// 46行目あたり
if (player instanceof EntityPlayerMP) // ←ここのifの判定のあたりを書き換える
{
CriteriaTriggers.PLACED_BLOCK.trigger((EntityPlayerMP)player, pos, itemstack);
}
使い方
build.gradle
dependenciesにjavassistを加えましょう
dependencies {
compile group: 'org.javassist', name: 'javassist', version: '3.22.0-GA'
}
他の設定はCoreModを作るときと同じで問題ありません
IFMLLoadingPlugin
TransformerExclusionsにjavassistを追加しておきます
@IFMLLoadingPlugin.TransformerExclusions(Array("java", "scala", "javassist", "com.yukiaji.javassistsample.asm"))
@IFMLLoadingPlugin.MCVersion("1.12.2")
class JavassistSampleCore extends IFMLLoadingPlugin with IFMLCallHook {
// snip
}
IClassTransformer
前準備
対象のクラスを見つけたら、CtClassを作ります
val pool = ClassPool.getDefault
val ctClass = pool.makeClass(new ByteArrayInputStream(basicClass))
ctClass.defrost()
byte配列からCtClassを作った場合、できたCtClassは変更不可な状態となっているため、defrostを呼び出して変更できるようにします。
次に、読み込んだCtClassに定義されているメソッドの中で、目的のものを探します。今回はOnItemUse
です
// TargetMethodNameはOnItemUse、TargetMethodSrgNameはOnItemUseのsrg名、TargetMethodSigは(Lnet.minecraft.entity.player.EntityPlayer;L...)L...みたいな引数と返値の型を表す文字列
val method = ctClass.getDeclaredMethods.find(method => {
val methodName = FMLDeobfuscatingRemapper.INSTANCE.mapMethodName(ctClass.getName, method.getName, method.getSignature)
val methodSig = FMLDeobfuscatingRemapper.INSTANCE.mapMethodDesc(method.getSignature)
// srg名でない場合もシグネチャの比較はすべきだがオーバーロードされていない前提で省略
methodName == TargetMethodName || (methodName == TargetMethodSrgName && methodSig == TargetMethodSig)
})
ここまでが前準備です。次は実際にコードを書き換えてみます
コードの書き換え1
見つけたメソッドの目的の箇所を検索、コードを注入します
method match {
case Some(m) => // メソッドが見つかった場合
// コードの注入
m.instrument(new ExprEditor() {
val TargetInstanceOfClassName = FMLDeobfuscatingRemapper.INSTANCE.unmap("net/minecraft/entity/player/EntityPlayerMP").replaceAll("\\/", ".")
override def edit(i: Instanceof): Unit = {
// edit内で取れるクラス名やメソッド名は難読化後の名前で取れるため注意
if (i.getType.getName == TargetInstanceOfClassName) {
// コードの書き換え
i.replace(
"""
|System.out.println("success fire"); // 注入するコード
|$_ = $proceed($$); // 元のコード
""".stripMargin
)
}
}
})
case _ =>
}
これで実行すると、火打ち石を使ったときにコンソールにsuccess fire
と出力されると思います。おそらく2回出力されると思いますが、ClientとServer両方で実行されるので正常な動作です
コードの書き換え2
このままだとちょっと見にくいので、ExprEditorをくくりだしてしまいましょう
method match {
case Some(m) => // メソッドが見つかった場合
// コードの注入
m.instrument(ExprEditorPrintSuccess)
case _ =>
}
// objectとは: https://dwango.github.io/scala_text/object.html
object ExprEditorPrintSuccess extends ExprEditor {
val TargetInstanceOfClassName = ClassName("net.minecraft.entity.player.EntityPlayerMP") // ClassNameは元のクラス名から難読化済みのクラスを作ったりするための独自case class
override def edit(i: Instanceof): Unit = {
// edit内で取れるクラス名やメソッド名は難読化後の名前で取れるため、比較前に適宜map/unmapする
if (i.getType.getName == TargetInstanceOfClassName.unmappedName) {
i.replace(
"""
|System.out.println("success fire");
|$_ = $proceed($$);
""".stripMargin
)
}
}
}
これにもう1処理、今度は火打ち石を使ったときにチャットにテキストを表示するようにしてみましょう。
チャットに表示するためのメソッドは下のように切り出しておきます
object StatusBoneFireLit {
def send(player: EntityPlayer): Unit = {
if (player.isInstanceOf[EntityPlayerMP]) {
player.sendStatusMessage(new TextComponentString("§eBONEFIRE LIT"), true)
}
}
}
では、これを先ほどと同じ位置で呼び出すようにコードを書き換えてみます
method match {
case Some(m) =>
// コードの注入
m.instrument(ExprEditorStatusMessage)
case _ =>
}
object ExprEditorStatusMessage extends ExprEditor {
val TargetInstanceOfClassName = ClassName("net.minecraft.entity.player.EntityPlayerMP")
val PlayerClassName = ClassName("net.minecraft.entity.player.EntityPlayer")
override def edit(i: Instanceof): Unit = {
// edit内で取れるクラス名やメソッド名は難読化後の名前で取れるため、比較前に適宜map/unmapする
if (i.getType.getName == TargetInstanceOfClassName.unmappedName) {
definePlaceholder("com.yukiaji.javassistsample.main", "StatusBoneFireLit", "send", List(ClassName("net.minecraft.entity.player.EntityPlayer")), ClassName.Void, Modifier.PUBLIC | Modifier.STATIC)
// ↑ ?
i.replace(
"""
|com.yukiaji.javassistsample.main.StatusBoneFireLit.send((%s)$1);
|$_ = $proceed($$);
""".stripMargin.format(PlayerClassName.unmappedName)
)
}
}
}
さて、サンプルコードに謎のメソッドdefinePlaceholder
が現れました。
scalaだけなのか、読み込み順序の問題か、はたまたおま環なのかはわかりませんが、開発環境での実行はここをコメントアウトしても問題なく動作するものの、実際にjarに固めて本番環境で実行すると、「com.yukiaji.javassistsample.main.StatusBoneFireLit
なんてないぞ!」とJavassistに怒られてしまいます。これは、JavassistからStatusBoneFireLit
が参照できないために起きるエラーで、コードの書き換え前にJavassistにこのクラス、あるいはメソッドの存在を教えないといけません。なので、ダミーでクラスとメソッドを定義し、Javassistから参照可能にしてあげます
private def definePlaceholder(packageName: String, className: String, methodName: String, argTypes: List[ClassName], returnType: ClassName, modifiers: Int): Unit = {
val pool = ClassPool.getDefault
pool.makePackage(getClass.getClassLoader, packageName)
val placeholder = pool.makeClass(packageName + "." + className)
// 生成するメソッドで参照するクラスはすべてmapされる前のクラス名を使用する
var argString = argTypes.zipWithIndex.map({ case (t, i) => t.unmappedName + " arg" + i }).mkString(", ") // type arg1, type arg2, ...
val placeholderMethod = CtMethod.make(
s"""
|${returnType.unmappedName} $methodName($argString) {
| System.out.println("WARNING: call placeholder method");
| ${if (returnType != ClassName.Void) "return null;" else ""}
|}""".stripMargin,
placeholder
)
placeholderMethod.setModifiers(modifiers)
placeholder.addMethod(placeholderMethod)
}
ここで生成するダミーのクラス/メソッドは実際にバイトコードとして出力はされないので、メソッドの中身などは構文的に問題なければ適当で大丈夫です