
这个模组项目一开始就是只支持 Fabric 的,且特意没有为 Forge 做预留。但这几天考虑到现阶段 NeoForge 和 Fabric 几乎已是平分秋色,许多大型的整合包都是基于 NeoForge 的,而 Sinytra Connector 对一些非使用 API 对 Minecraft 进行修改的模组不一定兼容,因此这几天一直在对一个模组项目进行改造,使之由 Fabric 项目转化为支持 NeoForge 和 Fabric 的多平台项目。
在这过程中,历经了无数坎坷,也积累了不少经验,在此做记录,希望对其他模组开发者也能有帮助。
在转为基于 Architectury Plugin 的多平台项目后,遇到的第一个难解的问题是,accessor 的编译出了问题。例如,如下所示的这段代码:

ExcuteCommandAccessor 类
看似没有问题,但实际上编译出现了问题。经查,原因在于,两个方法所实际调用的方法是相同的名称但是不同的签名。模组原先是用 Yarn 映射编写的,Mojang 官方映射中的这两个 Execute.storeValue 方法在 Yarn 中的名称分别是 Execute.executeStoreScore 和 Execute.executeStoreBossbar,但转换为 Mojang 官方映射后,方法名称相同,只是参数不同。不知道为什么在 Architectury Plugin 的项目中无法编译。最后我的解决办法是,将其分割为两个不同的 accessor,这是生效的。
Fabric 和 NeoForge 的事件注册模式完全不同。Fabric 的各个事件类型会对应一个函数式接口,这个接口的参数就是事件的各个参数,接口可以返回 void,也可以返回一个值,例如 boolean 或 InteractionResult,并且每个事件接口都会指定如何将该接口的多个实现合并起来(Function<T[],T>)。这样做的好处是,事件在调用时不需要每次都创建对象,且多种事件可以共用同一接口类型。以 Fabric 的 WorldRendererEvents.BEFORE_BLOCK_OUTLINE(1.21.10 之前)为例,每次注册事件需要提供一个 BeforeOutline 接口的实现,该函数式接口的唯一方法接收两个参数并返回一个 boolean。在事件调用时,注册的各个 BeforeOutline 接口的实现会被依次调用,且只要有其中一个返回 false,那么整个事件调用就返回 false。
而 NeoForge(和 Forge)的事件是一个 Event 的子类的对象,其事件的各个参数作为此对象的字段,注册事件时会提供此类的一个 consumer,将此类的对象作为参数,不返回值,但可对此对象调用一些方法,例如 setCancellationResult() 以修改原版行为会返回的 InteractionResult 的值,或者调用 setCancelled() 以中止事件的执行。每次调用事件都会创建一个事件对象。以 NeoForge 的 PlayerInteractEvent.RightClickBlock 为例,此事件类将玩家、手、坐标等作为字段,各字段有 getter 方法,注册类时提供一个 Consumer<PlayerInteractEvent.RightClickBlock> 的 lambda,将这个 PlayerInteractEvent.RightClickBlock 对象作为方法参数,可通过调用 setCancellationResult() 来影响结果。当每次有玩家右键点击方块时,就创建一个 PlayerInteractEvent.RightClickBlock 对象,并调用我注册的 Consumer<PlayerInteractEvent.RightClickBlock>。
两种截然不同的事件注册方式,导致我在其间做一个抽象层会比较费劲,而且我并没有使用 Architectury API。不过我还是成功地通过几种接口间的转化,为 Fabric 和 NeoForge 的事件搭建起一座“桥梁”。这个桥梁的结构是类似 Fabric 的,因此在 Fabric 中使用较为方便,而在 NeoForge 中使用则需要指定转化的方式。

事件桥梁在 Fabric 中的底层

事件桥梁在 NeoForge 中的底层

事件桥梁在 Fabric 中的使用(以 UseBlockCallback.EVENT 为例)

事件桥梁在 NeoForge 中的使用(以 PlayerInteractEvent.RightClickBlock 为例)
在 Fabric 中注册内容的方式,是直接在初始化阶段调用 Registry.register,我早已习惯。但 Forge 和 NeoForge 不同,因此所有与注册表的注册相关的代码都要更改。而我这个模组,不仅注册了一些内容,还创建了自己的注册表,都需要进行改造。
在这过程中有不少坎坷。首先我想直接注册到 RegisterEvent 和 NewRegistryEvent,结果发现不行,一查才发现,NeoForge 的事件总线分别 NeoForge 事件总线和模组事件总线,实现了 IModBusEvent 的事件需要注册到模组事件总线,而非 NeoForge.EVENT_BUS。此时我才想起,以前开发 Forge 模组也是如此。
对模组的内容我考虑的是使用 DeferredRegister,这一过程在编译时没有出问题,结果一运行就出问题了,调试时发现 DeferredRegister 有内容但是实际注册表是空的,然后才发现DeferredRegister 并非创建了就行,还需要调用 register 才能注册到事件总线中,并且也得是模组的事件总线。

将 DeferredRegister 注册到模组的事件总线中
模组的事件总线是模组在 NeoForge 中的主类的构造函数的参数,因此需要在模组主类的构造函数中注册。我不太记得如何在其他地方获取到模组的事件总线了,应该是可以的,但是我没有查到。
之前在 Fabric 中,我一直是通过 EntityDataSerializers.registerSerializer(Yarn 映射中称为 TrackedDataHandlerRegistry.register)注册的实体数据,但是这一数据的序列化存储方式以及同步机制需要自己 mixin,我没在 Fabric API 中找到对应的方法。在 NeoForge 中不能使用此方法,得在 NeoForgeRegistries.ENTITY_DATA_SERIALIZERS 中注册,但即使是注册之后,也出现了各种各样的问题。
不过不久前刚好了解到,Fabric API 和 NeoForge 都提供数据附加(data attachment)机制,且都能搞定数据附加的序列化以及同步。数据附加在 Fabric API 中还只是“近期推出的一项实验性功能”,经查,最早在 1.20.1 就可以使用了,而正是我预期让模组兼容的最低 Minecraft 版本。(也许不少模组都是因为 Fabric API 之前还没有数据附加机制,所以没有支持 Fabric 吧。)
改用数据附加机制的整个过程比较顺利,还省掉了不少 mixin,效果也不错,符合预期。Fabric 和 NeoForge 都支持数据附加,接口和实现方式大同小异,都支持持久化存储(需要指定 Codec)以及向客户端同步(需要指定 StreamCodec,即 Yarn 映射所称的 PacketCodec)。

Fabric 中的数据附加调用,标黄线是因为在此版本的 Fabric API 中,数据附加仍是实验性功能

NeoForge 中的数据附加调用
NeoForge 当然也支持 mixin,不过有个问题,NeoForge 的 neoforge.mods.toml 文件中似乎只能指定一个 mixin 配置,而 Forge 和 Fabric 都可以指定多个 mixin 配置。我之所以使用多个 mixin 配置,是因为这个模组的 mixin 实在太多了,于是用不同的目录分好类。为了让 NeoForge 能使用,我只能将多个 mixin 配置合并成一个。不过好在我不需要将不同目录的 mixin 类放到一个目录中,只要这几个都是 mixins 目录的子目录就可以了,因为 mixin 配置是支持子目录的。例如,如果某 mixin 配置指定的 mixin 目录是 mod.example.mixins,那么你把 mixin 类放在 mod.example.mixins.somepart 也是没有问题的。
不过有点让我头疼的一点是,NeoForge 是直接修改了 Minecraft 代码,导致一些原本在 Fabric 中可用的 mixin,在 NeoForge 就不可用了。例如,在原版 Minecraft 中,如果在命令中输入了不存在的实体选择器变量,其报错代码是这个样子:
throw ERROR_UNKNOWN_SELECTOR_TYPE.createWithContext(this.reader, "@" + String.valueOf(c)); 而在 NeoForge 中,不知道是不是因为编译的问题,代码变成了这个样子:
throw ERROR_UNKNOWN_SELECTOR_TYPE.createWithContext(this.reader, "@" + c0) 我之前是将 mixin 打在 String.valueOf 方法的调用上,于是在 NeoForge 上出错了,我还特地去看了字节码,确实没有调用 String.valueOf。我只能修改 mixin 的实现方式。
另一个例子是,在命令中提供 id 的建议时,原版代码是这样:
if (matchesSubStr(input, id.getNamespace()) || id.getNamespace().equals("minecraft") && matchesSubStr(input, id.getPath())) {
consumer.accept(object);
} NeoForge 把它改成了这样:
if (matchesSubStr(input, id.getNamespace()) || matchesSubStr(input, id.getPath())) {
consumer.accept(object);
} 不难理解,NeoForge 让 Minecraft 直接就能提供非原版的命名空间的 id 的建议了(似乎新版本 Minecraft 现在也是如此了)。这个 mixin 的问题好解决,只需要给 @ModifyExpressionValue 注解添加参数 require=0 就可以了。
在运行时还发现了一个问题,在 NeoForge 中我给 CommandSyntaxException 模组写的 mixin 没有生效,我通过 mixin 让它实现我自己的 CommandSyntaxExceptionExtension 接口,但它没有实现,CommandSyntaxException.class.getInterfaces() 返回的是空数组。我尝试用 mixin 注入该类的 <init> 方法使之输出一行文字,结果也没有效果,尝试使用 @Implements 注解或者导出 mixin 也没有效果。而在 Fabric 中,这个类正常实现了我指定的接口,注入 <init> 方法也能正常生效。
考虑到 CommandSyntaxException 属于 com.mojang.brigadier 中的类,而不是 Minecraft 本体,没有经过重映射,可能是 NeoForge 本就不支持对它的 mixin,但我查找资料并没有了解到 NeoForge 是否支持对 Brigadier 类的 mixin 的问题。不过我在 NeoForge 的 Discord 频道提问后得到了热心网友解答,NeoForge 确实不支持对库的 mixin,因此只能改用其他实现方式。
类似地,CommandContextAccessor 也不生效了,我使用这个 accessor 是为了获取其私有的 arguments 字段。于是,我修改了实现方式,在 Fabric 中仍是使用 accessor,而 NeoForge 中使用反射来实现。

使用反射替代 accessor
或许 NeoForge 和 Forge 给大家的印象就是,API 非常丰富,而 Fabric API 提供的 API 不多,很多功能都得 mixin,但我这次在 NeoForge 中要用 mixin 来实现 Fabric 中的功能。
具体来说,是 Fabric API 中的 WorldRenderEvents.BEFORE_DEBUG_RENDER,允许在渲染世界调试信息(如区块边界、实体判定箱)前渲染一些其他信息。不过我在 NeoForge 中没有找到类似的替代 API。从 NeoForge 修改过的 LevelRenderer(Yarn 中称为 WorldRenderer)中可看出,可以使用 NeoForge 提供的 RenderLevelStageEvent,但是该事件并没有提供我在渲染轮廓时所需要的 MultiBufferSource.BufferSource(Yarn 中称为 VertexConsumerProvider.Immediate),而拥有此值的 RenderHighlightEvent.Block 和 RenderHighlightEvent.Entity 只会在对准到方块或者实体时渲染。
于是我别无选择,只好自己写个 mixin,当然我自己写 mixin 也不难,因为做了这么久的 Fabric 模组开发,早就习惯了写各种各样的 mixin 了。

LevelRendererMixin

EventBridgeImpl.hookBeforeRender(其中的 BeforeDebugRenderEvent 是我自己做的)
另外我也补充一点,这次开发过程中,我也首次使用了 MixinExtras 提供的 @Expression 功能,非常强大,不过这并不是因为移植到 NeoForge 而使用的,所以这里不作阐述。