Minecraft Forge 1.15.2環境でのcoremodの書き方を簡単に解説します。
ASMって何? とかバイトコード何? という説明はここではしません。すいません。
あとJavascriptについても説明しません。すいません。
ただ、coremodを使うレベルのmodderの方々なら、フィーリングで書けると思います。
Forgeの1.13以降ならたぶん同じ書き方で動くと思いますが検証はしていません。
このページとサンプルコードを非常に参考にさせていただきました。先達に深く感謝します。
99.99 - Coremod
coremodって? ASMって?
それでも一応簡単に。"coremod"というのは、Forgeが用意している仕組みのひとつで、これを使うとMinecraftのJavaコードそのものを書き換えることができます(多少表現に語弊があります)。
そのために使うライブラリがASMです。また、コードそのものと言っても、普段書いている人間用のコードではなくて、コンパイルされたあとのバイトコードを書き替えます。
いずれにしても、ASMが書ければ割となんでもできるようになります。ただ他のmodとの競合は増えてくるかもしれません。
ASMについての勉強はこのpdfが非常にお勧めです(が長いです)。
ASM 4.0 A Java bytecode engineering library
検証環境
Minecraft: 1.15.2
Minecraft Forge: 1.15.2-31.2.0
1.13より前からの変更点
以前はJavaのコードでcoremodを記述していましたが、1.13からはJavascriptで記述します。
ASM自体の書き方は、おおむね前と同じと思ってもらって大丈夫です(たぶん)。
ファイル構成
必要なファイルは最低ふたつです。
- coremods.json
- 実際にASMを書いたJavascriptファイル
coremods.json
main/java/resources/META-INF/フォルダ直下に置きます。
ファイル名は、たぶん"coremods.json"じゃなきゃダメです。
中身は下のような感じです。
{
"TestMod Transformer": "testmod-transformer.js"
}
配列内のキー("TestMod Transformer")はたぶんなんでもいいです。大文字小文字も何でもいいと思います。
値は、これから書くことになるjsファイルの場所を書いてください。
上のように単にファイル名だけ書くと、main/java/resources/直下にあるファイルを指定していることになります。絶対パスとか相対パスが機能するかは試してないですが相対パスはいけそうな気がします。
{
"TestMod Transformer": "testmod-transformer.js",
"TestMod Transformer2": "testmod-transformer2.js"
}
一度に複数のjavascriptファイルを指定することもできます。
ASMを書くJavascriptファイル
分かりやすさのために、main/java/resources/直下 に置くことにしましょう。場所を変える場合は上のcoremods.jsonの中身の値も変えてください。
実際にASMを書くjsは以下のような感じです。
function initializeCoreMod() {
return {
'coremodmethod': {
'target': {
'type': 'METHOD',
'class': 'your.target.class',
'methodName': 'targetMethodName',
'methodDesc': 'targetMethodDescriptor'
},
'transformer': function(method) {
//ここにASMの処理を書く。
return method;
}
}
}
}
jsファイルの中には、"initializeCoreMod()"という名前で、jsonを返り値にする関数を書いておく必要があります。ぶっちゃけ上をコピペすればOKです。
上の「ここにASMの処理を書く」というところにいろいろ書いていけば完成です。
以上、お疲れ様でした。
とすると、はぁ、おちょくるのもたいがいしにしろよ? と言われそうなので、もう少し書いておきます。上でわかる人はもうあとは自由にやってください。
transformerの種類
targetとして、"FIELD"、"METHOD"、"CLASS"の3種類を指定できます。それぞれ、フィールド、メソッド、クラスに干渉します。つまり、以下の3つの書き方ができます。
function initializeCoreMod() {
return {
'coremodmethod': {
'target': {
'type': 'FIELD',
'class': 'your.target.class',
'fieldName': 'targetFieldName'
},
'transformer': function(field) {
//ここにASMの処理を書く。
return field;
}
}
}
}
function initializeCoreMod() {
return {
'coremodmethod': {
'target': {
'type': 'METHOD',
'class': 'your.target.class',
'methodName': 'targetMethodName',
'methodDesc': 'targetMethodDescriptor'
},
'transformer': function(method) {
//ここにASMの処理を書く。
return method;
}
}
}
}
function initializeCoreMod() {
return {
'coremodmethod': {
'target': {
'type': 'CLASS',
'class': 'your.target.class'
},
'transformer': function(class) {
//ここにASMの処理を書く。
return class;
}
}
}
}
"target"内に必要な値は上記のとおりです。
ただ、フィールドはリフレクションヘルパーでJavaから干渉できますし、クラス自体に干渉しなくても結局メソッドへの干渉で事足りることが多い気がする、という個人的な理由で、以下FIELDとCLASSの改変には触れません。誰かお願いします。
具体的な書き方
function initializeCoreMod() {
var ASMAPI = Java.type("net.minecraftforge.coremod.api.ASMAPI");
var Opcodes = Java.type('org.objectweb.asm.Opcodes');
var InsnList = Java.type("org.objectweb.asm.tree.InsnList");
var InsnNode = Java.type("org.objectweb.asm.tree.InsnNode");
var mappedMethodName = ASMAPI.mapMethod("target_srg_func_name");
return {
'coremodmethod': {
'target': {
'type': 'METHOD',
'class': 'your.target.class',
'methodName': mappedMethodName,
'methodDesc':'targetMethodDescriptor'
},
'transformer': function(method) {
print("Enter transformer.");
var arrayLength = method.instructions.size();
var target_instruction = null;
//search the target instruction from the entire instructions.
for(var i = 0; i < arrayLength; ++i) {
var instruction = method.instructions.get(i);
if(instruction.name === "targetMethodName") {
target_instruction = instruction;
break;
}
}
if(target_instruction == null) {
print("Failed to detect target.");
return method;
}
var toInject = new InsnList();
// toInject.add(new InsnNode(Opcodes.POP));
toInject.add(new InsnNode(Opcodes.RETURN));
// Inject new instructions just after the target.
method.instructions.insert(target_instruction, toInject);
return method;
}
}
}
}
だいぶ見通しが良くなったかと思います。順に解説します。
Javaクラスの呼び出し
var ASMAPI = Java.type("net.minecraftforge.coremod.api.ASMAPI");
var Opcodes = Java.type('org.objectweb.asm.Opcodes');
var InsnList = Java.type("org.objectweb.asm.tree.InsnList");
var InsnNode = Java.type("org.objectweb.asm.tree.InsnNode");
どんな魔法かわかりませんが、"Java.type()"の引数にいつものimport先のクラス名を入れると、あたかもJavaみたいに使えます(説明雑)。Opcodesなんかが来ればもう百人力な気がします。でもコード補完が効かないのが困ります。補完のやり方知っている人がいたら教えてください。
ASMAPIとOpcodesはどんな改変でも確実に使う気がしますが、他のクラスを呼び出すかどうかはお好みだと思います。いまのcoremodはいわゆるtree APIを使っているので、どんなクラスがあるかは別途そっちで調べてください。
干渉するメソッドの指定
var mappedMethodName = ASMAPI.mapMethod("target_srg_func_name");
return {
'coremodmethod': {
'target': {
'type': 'METHOD',
'class': 'your.target.class',
'methodName': mappedMethodName,
'methodDesc':'targetMethodDescriptor'
},
'transformer': function(method) {
return method;
}
}
}
"coremodmethod"と名付けていますが、これは任意の文字列でいいみたいです。他の"target"とか"transformer"とかはこれじゃないとだめです。
ASMAPIにはmapMethod()というメソッドが定義されていて、難読化後のメソッド名から、マップされたメソッド名を返してくれます。
例えば"net.minecraft.util.ScreenShotHelper"クラスの"saveScreenshotRaw"メソッドが、"func_228051_b_"であるとかそういうやつです。このmapMethod()を使ってメソッド名を取得しておかないと、開発環境では動くのに実環境では動かない(またはその逆)が起こります。
実際に書く時は、"ASMAPI.mapMethod("func_228051_b_")"などと、引数に難読化後のメソッド名を入れます。
難読化後のメソッド名は、いつものMCP Botから、開発環境にあったマッピングを選んで探してください。
ちなみにMCPのマッピングで探しても見つからないやつがたまにいます。そういうやつらは、開発環境でも実環境でもすでにそのわかりやすい方の名前で動いてるので、mapMethod()でメソッド名を取ってくる必要はないです。
"methodDesc"はいつものdescriptorです。
void hogehoge(int i, float f)
っていうメソッドだったらdescriptorは"(IF)V"になるっていういつものあれです。
IDEAでもEclipseでもJavaコードをバイトコードで表示するプラグインがありますので、それを使うとコピペが捗ると思います。
実際の改編
'transformer': function(method) {
print("Enter transformer.");
var arrayLength = method.instructions.size();
var target_instruction = null;
//search the target instruction from the entire instructions.
for(var i = 0; i < arrayLength; ++i) {
var instruction = method.instructions.get(i);
if(instruction.name === "targetMethodName") {
target_instruction = instruction;
break;
}
}
if(target_instruction == null) {
print("Failed to detect target.");
return method;
}
var toInject = new InsnList();
// toInject.add(new InsnNode(Opcodes.POP));
toInject.add(new InsnNode(Opcodes.RETURN));
// Inject new instructions just after the target.
method.instructions.insert(target_instruction, toInject);
return method;
}
実際に改変する部分は、個人的には以前より書きやすくなったと思います。
引数に渡ってくるmethodにはinstructionsというフィールドがあり、ここにバイトコードが配列で一行ずつ入ってます。メソッドのバイトコードそのものがです。
あとは、そのバイトコードの好きな位置を、削除したり、変えたり、returnを入れたりすることになります。
上では、「あるメソッドが呼び出されたらその場でreturnする」という改変を施しています。
原始的に、instructionsをひとつずつforで回して、対象のメソッドが呼ばれてる行(つまりinstruction)を探します。
おめあてのinstructionが見つかったら、新しいinstructionを挿入してやります。
InsnList()は、一連のinstructionの組を作るためのクラスで、これにそれぞれのinstructionをadd()してやります。
ここでは、"new InsnNode(Opcodes.RETURN)"として、"return"を意味するinstructionだけを、InsnListに追加しています。
最後に、instructionsにはinsert(引数1, 引数2)というメソッドが用意されていて、引数1に指定したinstructionの直後に、引数2に指定したinsnListの中身のinstruction達を追加します。
ここでは、特定のメソッドの直後に"return"を追加する処理となります。
注意点ですが、coremodを触ったことのある方ならわかるかと思いますが、好き放題にreturnすると、フレームの整合性が取れなくなってたぶんいまのバージョンでもクラッシュすると思います。
フレームに積んであるけどもう使わない値は、正しい回数だけPOPしないとまずいと思います。
その他
- 理由はわかりませんが、変数宣言時にletとかconstは使えません。エラーになります。varを使ってください。
- print()は開発環境でマイクラをデバッグ起動すると、コンソールに出力してくれますが、実環境ではどこにも出てこないみたいです。
- 一枚のjavascriptファイルで複数のtransformerを返せるみたいですがやり方はよくわかりません。
- ForgeのASMAPIクラス(これは普通にJavaです)には、あるメソッドが一番最初にコールされる場所を探すとかそういうメソッドも用意されてるみたいなので、一度中身を見てみると良いと思います。
終わりに
例示とか書こうと思いましたが力付きました。また今度余力があったら書きます。
いろいろ書き間違いとか勘違いとかある気がするので気が付いた方は教えてください。
お付き合いいただきありがとうございました。