LoginSignup
6
3

More than 1 year has passed since last update.

LunaChatを移植した話 (後編)

Last updated at Posted at 2021-12-04

これは前編の続きです。前編を読んだ前提で、続きから解説していきます。

Modを書く

ファイル構成

./src/main/java/以下に、maven_groupとMod IDに従って、フォルダーを作っていきます。今回は、io/github/apple502j/kanaify/以下にコードを設置します。同フォルダーには、Mixin用のコードを置くmixinフォルダーも作りましょう。

LunaChatのソースコードから、src/main/java/com/github/ucchyocean/lc3/japanize/以下のファイルを、フォルダごとこのModのsrc/main/java以下にコピーします。

イニシャライザー

Modには、「イニシャライザー」という、初期化のために実行されるコードが必要です。これは、クライアントやサーバーが起動するときに実行されますが、注意しておきたいのは、クライアント内でサーバーが実行されることがあるということです。つまり、シングルプレイヤーモードでワールドを開いたときには、サーバーが起動され、ワールドを終了してメインメニューに戻ると、サーバーが終了するというわけです。そして、サーバーはワールドを1回開くごとに再作成されるため、イニシャライザーもその回分(+クライアント起動時の1回)実行することになります。

イニシャライザーでは、まずログを記録することにします。System.out.printlnでもいいのですが、せっかくMinecraftはLog4Jを使用しているので、これを使ってみます。まずイニシャライザー用のファイル、KanaifyMod.javaを作成して、以下のように記載します。

package io.github.apple502j.kanaify;

import net.fabricmc.api.ModInitializer;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class KanaifyMod implements ModInitializer {
    public static final Logger LOGGER = LogManager.getLogger("kanaify");

    @Override
    public void onInitialize() {
        LOGGER.info("Kanaifier initialized.");
    }
}

ModInitializerは、イニシャライザーが実装すべきインターフェースで、FabricのModローダーから提供されています。staticなフィールドでLOGGERを定義し、インターフェースで定義されたonInitializeにてログを書き込むよう実装します。

最後に、イニシャライザーの場所をfabric.mod.json内で指定します。entrypointsオブジェクトのmain欄の配列を、["io.github.apple502j.kanaify.KanaifyMod"]のようにイニシャライザーのクラスの完全修飾名に変えておきます。

スレッド化

LunaChatは、パケットを受信したスレッドでかな漢字変換APIを叩きます。これを別スレッドで行うよう変更してみましょう。ここはめんどくさいので、私の実装を参考にしてみてください。このKanaifierクラスが、ローマ字漢字変換用の処理を行う形になります。これはシングルトンKanaifier.INSTANCEからアクセスできるようにしました。

イニシャライザーでは、Kanaifier.INSTANCEKanaifierの新しいインスタンスを設定しますが、ここで気を付けないといけないのは、イニシャライザーの多重実行のため、すでにKanaifier.INSTANCEが設定されている場合があるということです。この場合は、既存のものをcloseし、新しいものを作成するようにしました。もちろん、既存のものを使いまわしても問題はないはずです。

ExecutorServiceは、サーバー終了後にもきちんと終了する必要があります。これも後述します。

Mixinに突入

Mixinは、前述のとおりモンキーパッチです。既存のクラスの中に挿入(Inject)したり、乗っ取り(Redirect)や上書き(Overwrite)を行って、ゲームの動作を変更します。

まず、ExecutorServiceの終了のために、MinecraftClientMinecraftDedicatedServercloseメソッドの上部に、Kanaifier.INSTANCE.close();を呼び出す処理を追加します。これはInjectにあたります。

mixinフォルダー内にMinecraftClientMixin.javaファイルを作成し、以下のように記載します。

package io.github.apple502j.kanaify.mixin;

import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;

import net.minecraft.client.MinecraftClient;

import io.github.apple502j.kanaify.Kanaifier;
import io.github.apple502j.kanaify.KanaifyMod;

@Mixin(MinecraftClient.class)
@Environment(EnvType.CLIENT)
public class MinecraftClientMixin {
    @Inject(at = @At("HEAD"), method = "close()V")
    private void close(CallbackInfo info) {
        if (Kanaifier.INSTANCE == null) return;
        try {
            Kanaifier.INSTANCE.close();
        } catch (Exception e) {
            KanaifyMod.LOGGER.warn("Error during Kanaifier shutdown: ", e);
        }
        Kanaifier.INSTANCE = null;
    }
}

@Mixin(MinecraftClient.class)とは、MinecraftClientクラスにMixinを行うことを、@Environment(EnvType.CLIENT)は、クライアント側のみでMixinをすべきことを示します。(ただし、これは便宜上のもので、付けなくても問題はありません。) Mixinといっても、アノテーションがあることを除けば通常のクラスです。private voidなメソッドcloseを定義します。このメソッドは、Mixin先のメソッドの引数すべてに加え、CallbackInfoという返り値などの設定に使うインスタンスを受け取ります。MinecraftClient#closeは引数を受け取らないため、MixinのメソッドはCallbackInfoのみを受け取ります。

この上にある@Injectアノテーションが、挿入場所を決めています。at = @At("HEAD")は、メソッドの最上部という意味で、method = "close()V"は、メソッド名を示しています。Javaでは、異なる引数を持つ複数のメソッドが同じ名前を持つことが許されているため(オーバーロード)、ここでは引数の型を指定したclose()V (closeメソッド、引数なし、返り値はvoid(V))を指定します。IntelliJ IDEAを使用していて、Minecraft Developmentプラグインをインストールした場合は、メソッドを右クリックしてCopyからMixin Target Referenceを選ぶとコピーできます。

中のコードは簡単です。

  1. 何らかの理由で二重終了した場合は何もしない
  2. Kanaifier.INSTANCE.close();を呼び出す(念のため例外キャッチも)
  3. Kanaifier.INSTANCEnullにする

同じようなコードを、MinecraftDedicatedServerに対しても書きます。このときは、@Environment(EnvType.SERVER)アノテーションを使用します。

PlayerManager#broadcastを乗っ取る

前回、チャットメッセージを送信している部分が、ServerPlayNetworkHandler#handleMessage内のPlayerManager#broadcastということがわかりました。これを乗っ取るには、まずServerPlayNetworkHandler内にMixinします。ここでは既存のロジックを乗っ取るため、Redirectを使用します。この場合は、

@Redirect(method = "乗っ取るメソッドが呼び出されているメソッドの名前", at = @At(value = "INVOKE", target = "乗っ取るメソッド"))

のように記述します。返り値は、乗っ取るメソッドと同じです。また、PlayerManagerへの参照を、第一引数として受け取る以外は、引数も乗っ取るメソッドと同じです。

メソッド名の指定は、IntelliJ IDEAでコピーしてもいいのですが、せっかくの機会ですから覚えてみましょう。クラス名を指定するときは、Lの後に完全修飾名のドットをスラッシュで置き換えたものを書き、セミコロンで終えます。その後メソッド名、そしてカッコ内に引数を指定し、最後に返り値を指定します。引数は、intの場合はIbooleanの場合はZ、クラスのインスタンスの場合は前述の方法でクラスを指定したものとなります。返り値も同様です。また、返り値がvoidの場合はVを指定します。そのため、以下のようになります:

@Mixin(ServerPlayNetworkHandler.class)
public class ServerPlayNetworkHandlerMixin {
    @Redirect(method = "handleMessage(Lnet/minecraft/server/filter/TextStream$Message;)V",
            at = @At(value = "INVOKE", target = "Lnet/minecraft/server/PlayerManager;broadcast(Lnet/minecraft/text/Text;Ljava/util/function/Function;Lnet/minecraft/network/MessageType;Ljava/util/UUID;)V"))
    private void broadcastKanaified(PlayerManager playerManager, Text serverMessage, Function<ServerPlayerEntity, Text> playerMessageFactory, MessageType playerMessageType, UUID sender) {
        /* 処理 */
    }
}

実際に送信されるメッセージは、serverMessageです。しかし、これは型がStringではなく、Textとなっています。これは、Minecraft上で表示される文字列を示すクラスで、サブクラスに文字列をそのまま表示するLiteralTextや、全部や一部が翻訳できるTranslatableTextがあります。チャットメッセージは、実は後者なのです。チャットメッセージを送信すると、画面には<ユーザー名> メッセージと表示されますが、これはリソースパックを用いて括弧の種類などを変更できるため、TranslatableTextになります。また、ユーザー名が第一引数、送信されたメッセージが第二引数です。ユーザー名を翻訳する意味はないので、この第二引数を取り出す必要があります。

引数の取り出し

KanaifyMod.javaと同階層にKanaifyUtil.javaを作り、引数の取り出しに使うユーティリティーメソッドを置きましょう。まず、引数Text textTranslatableTextかどうかを確認し、次にその翻訳キーがチャットメッセージのもの("chat.type.text")であるかを確認する必要があります。Minecraft 1.17.1以降はJava 16が必須なので、Java 14で導入されたisinstanceのパターンマッチングが使えます。

    public static String getChatMessage(Text text) {
        if (text == null) return "";
        if (text instanceof TranslatableText chatText) {
            if (CHAT_TEXT_KEY.equals(chatText.getKey())) {
                /* 第二引数を返す */
            }
        }
        /* 他のModが干渉してきた場合など */
        KanaifyMod.LOGGER.warn("getChatMessage received unexpected input " + text);
        return text.toString();
    }

ここで問題が生じました。引数を取り出すメソッド、TranslatableText#getArgprivateアクセスが掛かっています。これを呼び出したいのですが、現状はできません。

Access Widener

といっても、先人たちの知恵によりこうした問題は手早く解決できます。Fabric Modローダーには、アクセス制限レベルを強制的にpublicに上書きしたり、クラスのfinalを取り除いたりする機能「Access Widener」が存在します。これを設定してみましょう。

src/main/resources/以下に、kanaify.accesswidenerという名前のファイルを作ります。書式は以下の通りです:

accessWidener   v1  named

# #で始まる行はコメント
accessible  method  クラス メソッド名 引数と返り値

クラスや引数と返り値の指定は、Mixinと同様です。そのため、クラスnet.minecraft.text.TranslatableText内に定義された、intを受け取ってnet.minecraft.text.StringVisitableを返すgetArgメソッドは、以下のように指定します:

accessible  method  net/minecraft/text/TranslatableText getArg (I)Lnet/minecraft/text/StringVisitable;

最後に、このファイルへのパスを、fabric.mod.jsonに書く必要があります。このファイルに"accessWidener" : "kanaify.accesswidener",を追加すれば完了です。

Mixinの登録

同じフォルダーには、kanaify.mixins.jsonというファイルがあります。ここに、作成したMixinを登録します。配列"mixins"には、サーバーとクライアントの両方で実行すべきMixinを、"client"では、クライアント側のみで実行すべきMixinを、両方とも単純なクラス名で指定します。また、配列"server"で、サーバー側のみで実行すべきMixinを指定できます。MinecraftClientMixinはクライアント側のみで、MinecraftDedicatedServerMixinはサーバー側のみで実行したいので、そのように指定します。

ServerPlayNetworkHandlerはどうしましょうか。ここで、Mixinの指定時は、クライアント側に内蔵されたサーバーは、クライアント側とみなされることに注意が必要です。つまり、このMixinはサーバーとクライアントの両方で実行することになります。最後に、"package"の値をMixinが含まれるパッケージに修正すると、結果は次のようになります:

{
  "required": true,
  "minVersion": "0.8",
  "package": "io.github.apple502j.kanaify.mixin",
  "compatibilityLevel": "JAVA_16",
  "mixins": [
    "ServerPlayNetworkHandlerMixin"
  ],
  "client": [
    "MinecraftClientMixin"
  ],
  "server": [
    "MinecraftDedicatedServerMixin"
  ],
  "injectors": {
    "defaultRequire": 1
  }
}

チャットメッセージの作成

表示用には、プレイヤー名と変換前、変換後のメッセージを組み合わせて、新しいTranslatedTextを作らないといけません。まずユーザー名を元のText、sourceから取り出し、変換前と変換後のメッセージを組み合わせたメッセージを、新しいTranslatableTextの引数とします。nullチェックなども加えた結果は、以下のようになります。

    public static Text createChatMessage(Text source, String original, String message) {
        if (source == null) return new LiteralText("");
        if (source instanceof TranslatableText chatText) {
            if ("chat.type.text".equals(chatText.getKey())) {
                try {
                    StringVisitable username = chatText.getArg(0);
                    return new TranslatableText("chat.type.text", username.getString(), String.format("%s (%s)", message, original));
                } catch (TranslationException exc) {
                }
            }
        }
        KanaifyMod.LOGGER.warn("createChatMessage received unexpected input " + source);
        return source;
    }

かな漢字変換コードを一般化する

Google翻訳API以外にも、かな漢字変換APIは存在します。これらを使いやすいよう、コードを抽象化させましょう。といっても、これはメインの話題ではないため、実装を確認してコピーで大丈夫です。

組み合わせる

これまで、MixinといったFabric系のMod開発に必要な部分は詳しく、それ以外は実装をコピーという形でコードを準備してきました。これらを組み合わせる時が来ました。

ServerPlayNetworkHandlerMixinに戻ります。このMixin内では、次の処理を行う必要があります:

  1. メッセージ内容を、KanaifyUtil#getChatMessageで取得する。
  2. コピーしたKanaifier.INSTANCE.convert(String)で、ローマ字から漢字に変換する。これはCompletableFuture<String>を返す。
  3. 変換後の文字列を用いて、送信するチャットメッセージを作成する。
  4. PlayerManager#broadcastを実行しなおす。

結果は、以下のようになります。

    private void broadcastKanaified(PlayerManager playerManager, Text serverMessage, Function<ServerPlayerEntity, Text> playerMessageFactory, MessageType playerMessageType, UUID sender) {
        String m = KanaifiyUtil.getChatMessage(serverMessage);
        CompletableFuture<String> future = Kanaifier.INSTANCE == null ? CompletableFuture.completedFuture(m) : Kanaifier.INSTANCE.convert(m);
        future.thenAcceptAsync((kanaified) -> {
            Text kanaText = KanaifiyUtil.createChatMessage(serverMessage, m, kanaified);
            playerManager.broadcast(kanaText, (_player) -> kanaText, MessageType.CHAT, sender);
        });
    }

ビルド

RAMが8GiB以下の場合は、ビルド前にIDE、ブラウザーなどのRAMを消費するプログラムをすべて閉じることをお勧めします。ビルドを実行するには、build.gradleなどがあるフォルダー内にて、gradlew buildを実行します。

ビルドが成功した場合は、./build/libs/内にkanaify-1.0.0.jarといったファイルができたはずです。ただし、ビルドが成功したといっても、読み込んだらクラッシュ、ということはあり得ます。なので、実際に読み込まないといけません。

MultiMCでテスト

前回設定したMultiMCを開き、インスタンスのアイコンを右クリックし「インスタンスの編集」を押します。「Loader mods」タブに、ドラッグ&ドロップでkanaify-1.0.0.jarを追加します。

Fabric Modローダーはパフォーマンス改善等のパッチを一切しないため、プレイ中のRAMやCPU使用率を改善したい場合は、Modを導入する必要があります。これは、互換性テストにもなります。LithiumSodiumは著名なFabric系のパフォーマンス改善Modです。jarファイルをダウンロードし、同様にして追加します。チャットModのテストにシェーダーは不要なので、OptifineといったModは追加しなくて大丈夫です。

起動して、まずクラッシュしないか、ワールドを作成してチャットに書き込めるか、ローマ字で書き込んだ内容は変換されるかをテストしましょう。起動時直後にクラッシュした場合は、多くの場合はMixinの指定に問題があります。MultiMCでクラッシュ時に自動表示されるクラッシュログの「Mixin transformation failed」などの行を読むと、大体理解できるはずです。

Modの公開

GitHub上でModを公開することもできますし、Mod公開用のウェブサイトを使うこともできます。fabric-example-modテンプレートを使用した場合は、GitHubにpushすると同時に、GitHub側でも(Actionsを用いて)ビルドされます。

Mod公開用のウェブサイトには、多数のModが掲載されているCurseforgeや、比較的新しく、Fabricのmodが多くそろっているModrinthがあります。私はModrinthで公開しました。

最後に

Fabric系のmod開発は、初心者にとっては難しいものですが、慣れれば「Mixinを使ってコードを直接触る」といったほうがAPIの文書を読むより簡単になってきます。そして、Minecraftのソースコードの読み方をある程度理解すれば、複雑な仕組みがどのように実装されているかがすぐ理解できるようになります。

このModは機能は単純ですが、作る過程でFabric系の開発の基礎であるMixin、Access Widener、そしてテスト方法についてすべて触れることができました。これを理解すれば、他のModの開発にも非常に役立つはずです。

質疑応答

1.18対応はどうすればいいですか?

まず、(すでにしていない場合は)Java 17をインストールします。fabric-example-modからbuild.gradleを上書きコピーし、次のコマンドを実行します:

$ gradlew migrateMappings --mappings "1.18+build.1"

その後、remappedSrc内のコードをsrc/main/java内に移動し、最後にfabric.mod.jsongradle.propertiesを編集します。gradle.propertiesの内容は、公式ウェブサイトに例が記載されています。

MultiMC側では、新しいインスタンスを作成することをおすすめします。既存のものを使用する場合は、インスタンス編集画面で、MinecraftとIntermediary Mappingsのバージョンを1.18に変更し、またFabric Loaderのバージョンを0.12.8以降にします。

ただし、バージョンの変更により、Mixin先のコードが変更されている可能性があるので、再確認は必須です。

PlayerManager#broadcastは別スレッドで実行してもいいのですか?

いい質問ですね。たしかに、このメソッドは本来メインスレッドで実行すべきです。ただ、実際ModされていないMinecraftでもメインスレッド外で実行されているため、問題はないでしょう。

チャットメッセージを送信中、ちょうどいいタイミングでプレイヤーが参加退出したときなどに、ConcurrentModificationExceptionが送出される可能性があります。ただ、これはいずれ捕捉されるし、またメインスレッド外での例外はサーバークラッシュには至らないため、大した問題にはなりません。

Invokerを使ったほうが早いのではないですか?

Fabric Modder現る。たしかにこの場合は@Invoker MixinでTranslatableText.getArgを呼び出せますが、Access Widenerのほうが万能なのでこっちを紹介しました。

なぜそこまで詳しいのですか? 専門家ですか?

開発者のひとりです(?)。よくスナップショット版の解読速度を競っています

6
3
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3