改 Minecraft 的代码这件事,我从 1.12 时代就开始折腾了。那会儿想给原版的弓箭加个追踪效果,第一反应是去反编译 minecraft.jar,把源码抽出来改完再编译回去。结果折腾一下午,客户端能启动,一进服务器就各种校验不过,换个版本全部重来。后来才知道,正经做法从来不是改源文件,而是靠 Mixin 这套字节码注入框架加 AccessWidener 这个访问权限扩展工具,在类加载阶段"半路拦截"目标类,把我们的逻辑塞进去。这篇内容我打算把这两样东西从头到尾讲透:它们解决的到底是什么问题、配置文件怎么写、注入点怎么挑、打包上线为什么会崩、和别的模组冲突了怎么办。写法上我尽量说人话,哪怕你刚会写 Java 的 Hello World,跟着走也能跑出第一个自己的 Mixin 补丁;如果你已经写过几个模组,中间那些注入点选择和冲突排查的经验应该也能帮你省不少时间。
1. 先搞清楚:为什么改 MC 代码要靠 Mixin 和 AccessWidener
1.1 反编译改源码这条路为什么彻底走不通
很多人第一次想动原版代码,脑子里冒出来的方案就是"把源码抠出来改掉再打回去"。我当年也是这么想的,而且确实成功了——在我的单机环境里跑得好好的。问题出在分发环节:Minecraft 的客户端和服务端在启动时会校验自身文件的完整性,你改过的 class 一旦和官方签名对不上,联机就进不去。更致命的是版本迭代,官方每次小更新都会重新混淆一遍类名和方法名,你上午改好的a.class到下午就变成b.class,昨天的方法偏移量今天全部作废。这种维护成本没有任何个人或团队扛得住。
Mixin 换了个思路:我不碰你的源文件,我在类加载器把目标类读进内存、还没交给 JVM 定义之前,动态地往它的字节码里插指令。目标类本身在磁盘上原封不动,校验自然没问题;而注入的代码由我们自己的模组提供,跟着模组一起分发。Fabric、Forge 1.16 以后、NeoForge 这些主流加载器都把 Mixin 内置进了启动流程,等于官方给你留了一个"合法的后门"。这就是为什么现在几乎所有功能性模组都在用 Mixin,而不是改源码。
顺带说一句,Mixin 的注入是"编译期写好、运行期生效"。你在 Java 源码里写注解,构建工具在打包阶段计算出这些注入点对应到生产环境中的真实位置,生成一份映射表塞进 jar 里,游戏启动时再按这份表去执行注入。理解这条链路,后面排查"开发环境能跑、打包后崩"的问题时你会轻松很多。
1.2 两者的分工:一个改行为,一个改可见性
刚接触的人容易把 Mixin 和 AccessWidener 混为一谈,觉得都是"改原版代码的工具"。其实它们管的是完全不同的两件事,分工非常清晰。
Mixin 管的是行为。它能在方法执行的某个节点插一段自己的代码,能替换某个方法调用的返回值,能改写某个局部变量,甚至能整个覆盖掉一个方法。它解决的是"我想让原版某个逻辑不一样"。
AccessWidener 管的是可见性。原版里大量字段和方法是private或protected的,你在自己的代码里根本点不出来,编译器直接报错。AccessWidener 做的事就是把这些成员的访问修饰符放宽——private变public,final变可写,protected的类变可继承。它一点行为都不改,纯粹是给你开权限。
举个具体的场景你就懂了。我想读玩家当前的速度向量并对它做点操作,Entity里的velocity字段是private的,代码里写player.getVelocity()拿到的是个副本,改它没用。这时候两条路:一是用 Mixin 写个@Accessor接口去代理这个字段,二是用 AccessWidener 一行mutable field把它放开,然后像访问普通 public 字段一样直接读写。两种做法效果一样,但 AW 那一行明显更省事,还少一个接口文件。
1.3 改代码的三层需求对照表
我把实际开发中遇到的改动需求归成三类,方便你判断该掏哪个工具。
| 需求类型 | 典型场景 | 推荐工具 | 备注 |
|---|---|---|---|
| 改方法内部逻辑 | 让跳跃更高、让伤害减半、给方块加右键行为 | Mixin 的@Inject/@ModifyVariable | 最常用,兼容性最好 |
| 替换某次方法调用 | 把isSneaking()的返回值强行改成 false | Mixin 的@Redirect或 MixinExtras 的@WrapOperation | 粒度细,易受版本影响 |
| 访问或修改非公开成员 | 读私有字段、调私有方法、继承 protected 类 | AccessWidener 优先,@Accessor/@Invoker兜底 | AW 写一行就够 |
判断顺序我一般是这样的:能用 AccessWidener 解决的可见性问题,绝不写成 Mixin;能用一个@Inject在HEAD处搞定的,绝不去抠INVOKE注入点。工具的"重"和"轻"差异很大,轻的方案在版本升级时活下来的概率高得多。
注意:不要为了图快直接把原版方法整个
@Overwrite掉。这是新手最容易犯的错,也是和其他模组冲突的头号来源。后面第 6 章会专门讲为什么。
2. 工程骨架搭建:从零到能跑起第一个 Mixin
2.1 工具链选型与版本对照
现在主流的模组开发工具链就两套:Fabric 生态的 Fabric Loom,和 Forge/NeoForge 生态的 NeoGradle。它们对 Mixin 的支持都很完整,差别主要在构建脚本的写法、映射表的选择,以及 AccessWidener 的替代方案上——Forge 系传统上用 AccessTransformer(AT),Fabric 系用 AccessWidener(AW),两者概念一模一样,只是文件语法不同。
我个人的建议是:如果你主要想学 Mixin 本身,从 Fabric 起步最省心。Loom 会自动帮你处理 refmap 生成、开发环境映射、genSources反编译等一堆琐事,配置文件也短。等你把机制吃透了,再迁移到 NeoForge 只是换几个 Gradle 块的写法。
版本匹配这块非常关键,我踩过太多次坑。Java 版本、Gradle 版本、Loom 版本、Minecraft 版本,四者必须对上。参考下面这张表,具体版本号请以你下载时的官方模板为准,因为 Minecraft 更新很快。
| 组件 | 1.20.1 时代 | 1.21.x 时代 | 说明 |
|---|---|---|---|
| JDK | 17 | 21 | 编译和运行都要一致 |
| Gradle | 8.x | 8.6+ | 用 wrapper,别用系统自带的 |
| Fabric Loom | 1.4~1.6 | 1.7+ | 版本不匹配会直接构建失败 |
| Mixin | 0.8.5 | 0.8.5 / 0.8.7 | 由 Loom 传递依赖,通常不用手写 |
| MixinExtras | 0.3.x | 0.4.x | 选配,但强烈建议加 |
最省力的起步方式是用官方模板仓库生成骨架——Fabric 有fabric-template-generator,NeoForge 也有对应的 MDK。自己从空目录手搓 Gradle 配置,光是搞对插件版本就要花掉半天,不值得。
2.2 build.gradle 与 Loom 的关键配置
生成骨架之后,你要动的地方其实就几处。首先是gradle.properties里的版本变量,minecraft_version、yarn_mappings、loader_version、fabric_version这几个必须填对。yarn_mappings那串东西长这样:1.21.1+build.3,不同 build 号之间 Yarn 名字可能有细微差异,遇到方法找不到时可以先换 build 号试试。
然后是build.gradle里开启 AccessWidener。这一行不加,你的.accesswidener文件就是个废纸。
loom { // 指向你的 AW 文件 accessWidenerPath = file("src/main/resources/mymod.accesswidener") } dependencies { // MixinExtras:强烈建议加,后面 @Local 和 @WrapOperation 都靠它 include(implementation(annotationProcessor("io.github.llamalad7:mixinextras-fabric:0.4.1"))) }include(...)这层包装很关键。它的意思不是只把 MixinExtras 加进编译路径,而是把整个库的 jar 内联进你的模组包里,这样玩家不需要额外装它。很多新手忘了这层,结果自己电脑上跑得好好的,别人一装就提示缺依赖。
fabric.mod.json里要声明三件事:模组基本信息、Mixin 配置文件名、AccessWidener 文件名。
{ "schemaVersion": 1, "id": "mymod", "version": "1.0.0", "name": "My Mod", "environment": "*", "entrypoints": { "main": ["com.example.mymod.MyMod"] }, "mixins": ["mymod.mixins.json"], "accessWidener": "mymod.accesswidener", "depends": { "fabricloader": ">=0.15.11", "minecraft": "~1.21" } }提示:
environment写成"*"表示客户端和服务端都加载。如果你的模组只改客户端画面,比如 HUD 渲染,一定改成"client",否则服务端会去加载客户端专属类然后直接崩。
2.3 三种映射表:Yarn、Mojmap、Intermediary
这是新手最容易被绕晕的地方,我用生活化的说法捋一遍。
Minecraft 发布时类名方法名全被混淆成了a、b、a()这种鬼东西。为了让模组作者有名字可写,社区和官方各自维护了一套"翻译词典":Yarn 是 Fabric 社区维护的,把a翻译成PlayerEntity;Mojmap 是 Mojang 官方发布的,把它翻译成Player。你在开发时写代码,用的就是词典里的名字。
但生产环境的玩家电脑上,类名仍然是混淆状态。怎么办?Fabric 引入了一个中间层叫 Intermediary,把每个混淆名和每个 Yarn 名之间建立固定对应关系,比如PlayerEntity对应class_1657。模组打包时,构建工具会把所有引用从 Yarn 名 remap 到 Intermediary 名,同时生成一份 refmap 记录每个 Mixin 注入点在 Intermediary 下的坐标。游戏启动时,加载器按 Intermediary 名找到目标类,按 refmap 找到目标方法,完成注入。
理解了这条链路,"开发能跑、打包崩溃"就很好解释了:开发环境用 Yarn 名,打包后用 Intermediary 名,refmap 就是这两者之间的桥。桥断了,注入自然失败。
// 开发时你会这样写(Yarn 名) @Mixin(PlayerEntity.class) public class PlayerMixin { @Inject(method = "tick", at = @At("HEAD")) private void onTick(CallbackInfo ci) { } }打包后,PlayerEntity会变成class_1657,tick会变成method_7350(具体编号以版本为准),这些都是构建工具自动完成的,你不需要手写。
2.4 mixins.json 逐字段说明
这个配置文件是最容易出细节问题的地方,我逐字段讲一遍。
{ "required": true, "minVersion": "0.8", "package": "com.example.mymod.mixin", "compatibilityLevel": "JAVA_21", "mixins": ["PlayerTickMixin", "BlockUseMixin"], "client": ["HudRenderMixin"], "server": ["ServerTickMixin"], "injectors": { "defaultRequire": 1 } }required: true表示这份 Mixin 配置是刚需,加载失败就报错退出,方便你在开发阶段第一时间发现问题。发布正式版时,可以考虑把它设为 false,避免某些极端环境下因为注入失败导致玩家完全进不去游戏。
package是 Mixin 类的根包名。Mixin 类必须放在这个包的子包下,最终的全限定名是package加上数组里的字符串。比如package是com.example.mymod.mixin,数组里写"PlayerTickMixin",那这个类就必须在com.example.mymod.mixin.PlayerTickMixin,写成com.example.mymod.mixin.player.PlayerTickMixin然后数组里写"player.PlayerTickMixin"也合法。
compatibilityLevel一般跟你的 JDK 版本走。写成低版本而代码里用了高版本的语法特性,编译期就炸了。
mixins、client、server三个数组的分组是最要命的细节。mixins是双端都会加载的,client只在客户端加载,server只在服务端加载。你把一个引用了渲染类的 Mixin 放进mixins数组,单机没事,一上专用服务端立刻NoClassDefFoundError。
injectors.defaultRequire: 1的含义是"每个注入点至少要成功命中一次"。如果你写了个@Inject,但注入点上一次都没匹配上,加载时会直接抛异常告诉你。这个值设成 0 会静默失败,非常不建议——注入悄悄失效比直接崩掉难查十倍。
3. Mixin 核心机制拆解
3.1 @At 注入点选择逻辑
@At决定你的代码插在目标方法的哪个位置,选错了要么注入不上,要么注入上了但执行时机完全不对。常见的几类:
HEAD:方法体最开始。最稳,受版本影响最小。RETURN/TAIL:前者在每次return前,后者在最后一个return前。想改返回值优先用RETURN。INVOKE:某个方法调用处。粒度精准,但要写完整的调用目标描述符,版本一变就容易失效。FIELD:某个字段读写处。用法和INVOKE类似。CONSTANT:某个常量出现处。改数值很好用,但要小心它的匹配顺序。
选注入点的原则,我的经验是"能往前后靠就不往中间钻"。HEAD和RETURN是两条护城河,官方就算重写方法内部逻辑,只要方法签名不变,你的注入基本不受影响。而INVOKE依赖的是方法内部的调用序列,官方随便调整一下语句顺序你就得重找。
举个真实的例子。我想改玩家的跳跃初速度。翻源码发现LivingEntity里有个getJumpVelocity()方法专门算这个值,那么最稳的做法就是在这个方法返回时改掉它的返回值,而不是去jump()方法内部找那个调用点。前者只要getJumpVelocity这个方法还在,就永远有效;后者官方把setVelocity的调用拆成两句,你就得重来。
3.2 五个高频注解
@Inject是最常用的。它在指定位置插一段代码,配合cancellable = true还能中途拦截方法执行,把返回值直接塞回去。
@Mixin(PlayerEntity.class) public abstract class PlayerTickMixin { @Inject(method = "tick", at = @At("HEAD")) private void mymod$onTick(CallbackInfo ci) { // 每 tick 都会跑,注意别在这里做重活 } }如果目标方法有返回值,回调参数要用CallbackInfoReturnable<T>,T 是返回类型:
@Inject(method = "damage", at = @At("HEAD"), cancellable = true) private void mymod$onDamage(DamageSource source, float amount, CallbackInfoReturnable<Boolean> cir) { if (amount > 100F) { cir.setReturnValue(false); // 直接判定没受伤,跳过原逻辑 } }方法名前面的mymod$前缀是我个人的习惯。虽然不强制,但强烈建议加上,因为 Mixin 注入后是在同一个类里运行,和原版方法重名的话排查起来非常痛苦。
@Redirect用来替换某一次方法调用。它比@Inject更暴力,直接把那次调用的结果换成你的。签名规则要记牢:如果目标方法是实例方法,你的回调第一个参数是调用者实例,后面按原参数顺序排。
@Redirect(method = "tick", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/player/PlayerEntity;isSneaking()Z")) private boolean mymod$redirectSneak(PlayerEntity self) { return false; // 假装玩家没在潜行 }@ModifyVariable改局部变量或方法参数。argsOnly = true表示只管参数,ordinal指定第几个同类型的目标。
@ModifyVariable(method = "damage", at = @At("HEAD"), argsOnly = true, ordinal = 0) private float mymod$halveDamage(float amount) { return amount * 0.5F; }@ModifyArg改某次方法调用的实参,和@Redirect的差别是它只改参数不改调用本身。
@Overwrite整个覆盖方法。这个我放在最后讲,因为在能不用的时候绝对不要用。它会和任何其他也想覆盖同一个方法的模组直接冲突,加载器会抛Mixin apply failed然后游戏崩掉。
实操心得:
@Inject加ci.cancel()能实现和@Overwrite类似的效果,但冲突概率低得多。因为@Inject是在原方法基础上插代码,多个模组可以在不同位置各自插入互不干扰;而@Overwrite是把整个方法体换掉,一山不容二虎。
3.3 @Shadow 与 @Unique 的边界
@Shadow用来引用目标类里已存在的成员。你在 Mixin 类里声明一个字段或方法,加上@Shadow,Mixin 就知道"这个不是我新加的,是原来就有的,帮我接上"。
@Mixin(PlayerEntity.class) public abstract class PlayerShadowMixin { @Shadow @Final private static int field_1234; // 引用一个私有静态字段 @Shadow public abstract float getHealth(); // 引用一个公开方法 @Shadow protected abstract void dropInventory(); // 引用一个受保护方法 }这里有个大坑:@Shadow声明的成员必须真实存在于目标类,而且访问修饰符可以放宽(比如原版是protected,你写public也能接上),但类型和名字必须完全一致。声明错了编译不报错,运行时报NoSuchFieldError或NoSuchMethodError。所以每次版本升级,@Shadow的地方都要重新核对一遍。
@Unique正好相反,它标记"这个字段或方法是我新加的,别去目标类里找,也别和别人冲突"。
@Mixin(PlayerEntity.class) public abstract class PlayerCounterMixin { @Unique private int mymod$jumpCount = 0; // 全新字段,注入到目标类里 }用@Unique加字段是有代价的:它会真的往目标类的字节码里塞一个新字段。如果你加了几个@Unique字段,而这个 Mixin 作用在一个会被大量实例化的类上(比如每个实体),内存占用会实打实涨上去。所以能放自己模组的静态 Map 里就别往目标类塞字段。
3.4 MixinExtras:让注入更稳的现代写法
原生 Mixin 有个让我难受很久的问题:@Redirect对同一个调用点只能被一个模组生效,两个模组都想改同一个调用就直接冲突。MixinExtras 这个库专门解决这类问题,它提供了一批"可叠加"的注入器,是现在写模组的标配。
@ModifyExpressionValue改一个表达式的值,多个模组可以叠加应用,比@Redirect友好太多。
@ModifyExpressionValue(method = "tick", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/player/PlayerEntity;isSneaking()Z")) private boolean mymod$alwaysNotSneaking(boolean original) { return false; }@WrapOperation是@Redirect的升级版,它给你一个Operation对象代表原始调用,你决定要不要执行它、执行完再对结果做手脚。
@WrapOperation(method = "tick", at = @At(value = "INVOKE", target = "...getJumpVelocity()F")) private float mymod$boostJump(LivingEntity instance, Operation<Float> original) { return original.call(instance) * 1.5F; }@Local解决的是抓局部变量这个老大难。原生 Mixin 要抓局部变量得靠LocalCapture.CAPTURE_FAILHARD加一长串参数声明,一旦官方在旁边插了个新变量,整个注入就错位。@Local直接在回调参数上标注类型和序号,清晰得多。
@Inject(method = "someMethod", at = @At(value = "INVOKE", target = "...")) private void mymod$grabLocal(@Local(ordinal = 0) int someValue, CallbackInfo ci) { // someValue 就是那个局部变量的值 }提示:用 MixinExtras 时记得把它的注解处理器加上(
annotationProcessor),否则@Local这类注解不会生成对应的注入元数据,运行时会报注入失败。
4. AccessWidener 全语法拆解
4.1 文件声明与加载方式
AW 文件是个纯文本,第一行是声明头,格式固定:
accessWidener v2 namedv2是当前推荐的版本,v1太老不要用。named表示文件里写的是 Yarn 名(Fabric 开发环境的命名),如果写intermediary则是生产环境的中间名,一般你在开发时不需要后者。
文件放在src/main/resources/下,名字随意但建议和模组 id 一致,方便识别。然后在两处引用它:build.gradle的loom块里写accessWidenerPath,fabric.mod.json里写"accessWidener": "mymod.accesswidener"。两处缺一不可,少一个文件就是废纸。
改完 AW 文件后必须重新构建才会生效。这东西是在编译期把权限信息写进元数据的,不是运行期动态加载的,改完直接启动游戏看不到效果。
4.2 accessible / mutable / extendable 三类修饰
AW 的指令一共就三种,理解它们就理解了这个文件。
accessible是放宽访问。字段从private变public,方法从protected变public,类从包级私有变公开。
accessible class net/minecraft/entity/Entity accessible field net/minecraft/entity/Entity pos Lnet/minecraft/util/math/Vec3d; accessible method net/minecraft/entity/Entity setFlag (IZ)Vmutable用于字段,效果是去掉final。原版很多字段是final的,你光用accessible只能读不能写,必须mutable才能赋值。
mutable field net/minecraft/entity/Entity velocity Lnet/minecraft/util/math/Vec3d;注意:mutable只对字段有意义,用在方法上是非法的。而且mutable只去掉final标记,不改变访问级别,如果字段原本是private final,你想既能读又能写,得两行都写。
extendable用于类,效果是让final类可以被继承,或者让只有包级访问的类能被外部继承。
extendable class net/minecraft/block/Block完整地写一个 AW 文件大概是这样:
accessWidener v2 named # 让 Entity 的坐标字段可以直接读写 accessible field net/minecraft/entity/Entity pos Lnet/minecraft/util/math/Vec3d; # 去掉 velocity 的 final 限制 mutable field net/minecraft/entity/Entity velocity Lnet/minecraft/util/math/Vec3d; # 放开一个受保护的方法 accessible method net/minecraft/entity/Entity setFlag (IZ)V # 让某个类可以被继承 extendable class net/minecraft/entity/Entity以#开头的行是注释,加载时会忽略。建议每条都写上用途,几个月后回头看你自己都记不住当初为什么开这条。
4.3 描述符怎么写
写 AW 最劝退的就是方法那一行末尾的(IZ)V这种鬼东西。这叫做方法描述符,用一套固定编码表示参数和返回值类型。规则不难,记住下面这张表就行。
| 类型 | 编码 | 示例 |
|---|---|---|
| int | I | (I)V= 参数 int,返回 void |
| long | J | (J)J |
| float | F | (F)F |
| double | D | ()D= 无参返回 double |
| boolean | Z | (Z)V |
| void | V | 只出现在返回值位置 |
| 引用类型 | L包名/类名; | Lnet/minecraft/util/math/Vec3d; |
| 数组 | 前面加[ | [I= int 数组 |
组合起来看几个例子就懂了。setVelocity(double, double, double)的描述符是(DDD)V。damage(DamageSource, float)返回 boolean,描述符是(Lnet/minecraft/entity/damage/DamageSource;F)Z。字段的描述符更简单,就是它自己的类型,比如 Vec3d 类型的字段写成Lnet/minecraft/util/math/Vec3d;。
犯错的成本很高:描述符写错,编辑器不会提示你,构建也不报错,但运行时这条 AW 规则会被静默跳过,然后你的代码在访问那个字段时抛IllegalAccessError。排查这种问题特别费时间,所以我的习惯是每次写完 AW 都去游戏里跑一遍相关功能,确认生效了再往下写。
实操心得:不知道怎么查描述符的话,在 IDE 里把光标放到目标方法上,按
Ctrl+Alt+Shift+C(IntelliJ 的复制引用),它会给你一份带完整签名的字符串,把它转成描述符比手算靠谱得多。或者干脆打开genSources生成的反编译源码,对着方法签名数一遍参数类型。
4.4 AW 和 @Accessor 该怎么选
同样是让私有字段可访问,两条路各有适用场景。
AW 的优势是"一劳永逸"——写一行,整个模组任何地方都能直接访问,代码风格统一,没有额外接口文件。劣势是它修改的是全局可见性,会影响同环境下所有代码,而且改动是编译期的,改完必须重新构建,热重载不生效。
@Accessor的优势是"局部且精确"——只在你声明的接口里暴露你想暴露的那部分,其他成员保持私有,封装性更好。劣势是要多写接口文件,访问方式变成了方法调用,比如accessor.getVelocity()而不是直接entity.velocity。
我的选择标准是:如果这个字段在整个模组里到处都要用、访问频率高,用 AW;如果只是某个类里临时读一下,用@Accessor更干净。还有一类情况必须用@Accessor——你想访问的成员在目标类里没有稳定的 Yarn 名字(比如某些字段在 Yarn 里压根没被命名,还是原始混淆名),AW 写起来很别扭,@Accessor反而更灵活。
5. 三个可复现实战案例
5.1 案例一:让玩家跳得更高
需求很简单:玩家跳跃高度提升百分之三十五。前面分析过,不要去改jump()方法内部,而是改getJumpVelocity()的返回值。
package com.example.mymod.mixin; import com.llamalad7.mixinextras.injector.ModifyReturnValue; import net.minecraft.entity.LivingEntity; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; @Mixin(LivingEntity.class) public class LivingEntityJumpMixin { @ModifyReturnValue(method = "getJumpVelocity()F", at = @At("RETURN")) private float mymod$boostJump(float original) { return original * 1.35F; } }这里method = "getJumpVelocity()F"把描述符一起写进去了。其实只写方法名也能匹配,但把描述符写上能避免同名重载带来的歧义,是个好习惯。
需要提醒的是,这个方法的返回类型在不同版本里变过,1.21 附近是float,更早的版本可能是double。如果你照着写编译报错,八成是返回值类型对不上,去genSources生成的源码里确认一下改成对应的类型即可。
配置更新:把这个类加进mymod.mixins.json的mixins数组(因为实体逻辑双端都有,不能用client)。然后跑runClient,进游戏按空格,如果跳跃明显变高就说明注入成功了。
如果没生效,先去日志里搜mymod关键字,看看有没有Mixin apply failed之类的报错。没有报错但也没效果,那基本就是类没被加进mixins.json,或者包路径没对上。
5.2 案例二:调用原版私有方法 + 读取私有字段
假设我要写一个功能:把玩家当前的速度向量乘以 0.9 做减速,同时调用Entity里一个受保护的方法。代码里直接写player.velocity编译不过,因为它是私有的。
先改 AW 文件:
accessWidener v2 named # 速度向量需要可读可写 mutable field net/minecraft/entity/Entity velocity Lnet/minecraft/util/math/Vec3d; # setFlag 需要外部调用 accessible method net/minecraft/entity/Entity setFlag (IZ)V然后在正常代码里就可以直接访问了:
package com.example.mymod; import net.minecraft.entity.player.PlayerEntity; import net.minecraft.util.math.Vec3d; public final class SpeedHelper { public static void dampen(PlayerEntity player) { Vec3d v = player.velocity; // 私有字段现在能读了 player.velocity = new Vec3d(v.x * 0.9, v.y, v.z * 0.9); // 也能写了 player.setFlag(0, true); // 受保护方法现在能调了 } }注意player.velocity这种直接字段访问在 Yarn 里并不是所有版本都有——有些版本velocity是 private,有些版本通过 getter 暴露。如果 AW 写了之后编译器还是报错,说明字段名或者描述符和当前映射对不上,去源码里核对。
注意事项:AW 放开的权限是对整个开发环境生效的。如果你放在一个团队项目里,别人写代码时也能随手访问这些"本该私有"的成员,容易写出耦合很深的代码。建议在 AW 文件里写清楚注释,说明为什么必须放开,让后来人有个心理预期。
5.3 案例三:给原版方块加右键交互
目标:右键点击钻石块时,把它变成绿宝石块,并且消耗掉这次交互。
package com.example.mymod.mixin; import net.minecraft.block.Block; import net.minecraft.block.BlockState; import net.minecraft.block.Blocks; import net.minecraft.entity.player.PlayerEntity; import net.minecraft.util.ActionResult; import net.minecraft.util.Hand; import net.minecraft.util.hit.BlockHitResult; import net.minecraft.util.math.BlockPos; import net.minecraft.world.World; 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.CallbackInfoReturnable; @Mixin(Block.class) public class BlockUseMixin { @Inject(method = "onUse", at = @At("HEAD"), cancellable = true) private void mymod$onDiamondUse(BlockState state, World world, BlockPos pos, PlayerEntity player, BlockHitResult hit, CallbackInfoReturnable<ActionResult> cir) { if (state.isOf(Blocks.DIAMOND_BLOCK)) { if (!world.isClient) { world.setBlockState(pos, Blocks.EMERALD_BLOCK.getDefaultState()); } cir.setReturnValue(ActionResult.SUCCESS); } } }几个关键点。@Inject的参数列表必须和onUse的签名完全一致,顺序、类型一个都不能错,最后再加上CallbackInfoReturnable。签名错了会在构建或者运行时报错提示方法不匹配。
world.isClient这个判断不能省。方块修改是服务端逻辑,客户端去改只在本地生效,会和服务器不同步,玩家看到的现象是"方块变了但服务器不认"。这是新手做方块交互最常见的 bug。
cir.setReturnValue配合cancellable = true实现提前返回。原版onUse发现方块是钻石块时本来会返回ActionResult.PASS,我们直接把它拦下来返回SUCCESS,这样玩家手部会有挥动动画。
onUse的签名在不同版本有差异,有返回ActionResult的,也有返回TypedActionResult的,具体以你的版本源码为准。
5.4 案例四:改伤害数值并记录来源
最后一个案例演示@ModifyVariable的用法,顺便体会一下argsOnly的意义。
package com.example.mymod.mixin; import net.minecraft.entity.LivingEntity; import net.minecraft.entity.damage.DamageSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.ModifyVariable; @Mixin(LivingEntity.class) public class DamageReduceMixin { private static final Logger LOGGER = LoggerFactory.getLogger("mymod"); @ModifyVariable(method = "damage", at = @At("HEAD"), argsOnly = true, ordinal = 0) private float mymod$reduceDamage(float amount, DamageSource source) { float reduced = amount * 0.8F; LOGGER.info("伤害 {} -> {},来源 {}", amount, reduced, source.getName()); return reduced; } }ordinal = 0表示抓第一个 float 类型的参数。damage方法的参数里可能不止一个 float,ordinal就是用来消歧的。写错了可能会改了错误的变量,所以建议一开始就打开日志把改动前后的值打出来验证。
回调方法里除了要改的值,还可以往后追加目标方法的其他参数(这里是DamageSource source)。这个能力叫参数捕获,MixinExtras 让它的写法清爽很多。但要注意追加的参数必须从目标方法参数列表里按顺序取,不能跳过。
实操心得:
@ModifyVariable加日志是个很好的调试手段。很多注入问题不是没生效,而是生效了但改错了变量。把值打出来一看就知道。日志级别别用 debug,默认不开你根本看不到,用 info 更直接。
6. 踩坑实录与排查速查表
6.1 开发环境正常、打包后崩溃
这是新人最常见、也最容易心态崩的问题。现象是runClient一切正常,打成 jar 丢进正式环境一启动就Mixin apply failed。
原因基本锁定在 refmap。开发环境用的是 Yarn 名,目标方法叫tick;打包后要用 Intermediary 名,叫method_xxxx。refmap 就是记录这个映射的文件,正常情况下 Loom 会在build目录里自动生成并打进 jar。
排查顺序是这样的:先解压你的 jar,确认里面有mymod-refmap.json这个文件。没有的话,检查build.gradle里是不是漏了 Loom 的配置,或者你是不是手工把refmap字段写进了mixins.json(Fabric 下通常不需要手写,让 Loom 接管)。
有 refmap 但内容可疑,就打开它看看里面记录的方法名是不是你期望的那几个。如果 refmap 里是空的,说明 Loom 没扫到你的 Mixin 注解,多半是mixins.json里的package字段和实际类路径不匹配。
还有一个隐蔽的坑:你在mixins.json里写了refmap字段,但文件名和 Loom 生成的对不上,加载器找不到文件就当成空表处理,注入全部失效但不一定报错。
6.2 Mixin apply failed 的六种原因
我在日志里见过太多次这个报错,把原因整理成一张表,按出现频率排序。
| 报错关键词 | 大概率原因 | 排查方向 |
|---|---|---|
NoSuchMethodError | 方法名或描述符写错,或者版本已变 | 核对genSources生成的源码签名 |
NoSuchFieldError | 字段名或类型和实际不符 | 核对映射,确认字段是否被官方移除 |
target ... was not found | 类名写错,或该类不在当前侧(客户端/服务端) | 确认类是否属于客户端专属包 |
Critical injection failure | @At注入点匹配不到 | 检查INVOKE的 target 描述符 |
Mixin apply failed ... is already overwritten | 两个模组都想@Overwrite同一方法 | 至少有一方要改成@Inject |
IllegalAccessError | AW 规则没生效或描述符写错 | 重新构建,检查 AW 描述符 |
Critical injection failure我最常遇到。它的意思是你指定的注入点一个都没匹配上。@Inject配HEAD很少出这问题,@At(value = "INVOKE", ...)才容易踩。排查方法是把那个 target 描述符抄下来,去反编译源码里逐字符对比,包括包名、类名、方法名、参数和返回值。错一个字母都不行,描述符里没有容错空间。
6.3 和其他模组冲突怎么办
冲突的根源是多个模组想改同一处。缓解手段有这么几层。
第一层,优先用@Inject而不是@Overwrite。@Inject天然支持多方共存,每个模组的代码按优先级顺序插入,互不覆盖。
第二层,用 MixinExtras 的@ModifyExpressionValue和@WrapOperation替代原生的@Redirect。原生@Redirect对同一个调用点是互斥的,混入器的作者当时设计它就没考虑多模组协同。MixinExtras 的这两个注入器支持链式叠加,多个模组的改动会依次作用,这是目前最推荐的方案。
第三层,给 Mixin 设置priority。数值越大越晚执行,但它解决不了互斥问题,只能调整顺序。真正互斥的场景,优先级再高也是一方生效一方失效。
第四层,如果你就是要改一个热门方法,去看看是否有官方的扩展点。比如 Forge/NeoForge 有事件总线,Fabric 有各种 Callback API,能用官方提供的钩子就别动字节码。改字节码是最后手段,不是第一选择。
6.4 调试环境变量
最后分享几个我常开的调试参数,加在 IDE 的运行配置的 VM options 里就行。
-Dmixin.debug=true打开 Mixin 的调试模式,日志会详细很多,注入成功失败都有记录。
-Dmixin.debug.verbose=true更啰嗦,会打印每个注入点的匹配过程,找注入失败原因时很有用。
-Dmixin.debug.export=true会把注入后的类导出到.mixin.out目录,你能直接反编译看最终生成的字节码长什么样。这个功能我第一次用的时候挺震撼的——你能亲眼看到自己写的注入代码被塞进了哪个位置,对理解 Mixin 的运行机制帮助巨大。
还有-Dmixin.dumpTargetOnFailure=true,注入失败时把目标类的字节码 dump 出来,配合上一个参数一起用。
提示:这些参数只在开发环境用,千万别写进正式发布的配置里。日志刷太多会拖慢启动,导出字节码更是纯浪费磁盘。
我个人的体会是,Mixin 这玩意儿的学习曲线前面陡后面平。刚开始你连描述符都写不对,跟着教程抄还会各种报错;但只要你独立解决过三次"注入失败",把注入点选择和描述符这两件事吃透,后面就是查文档加仿写的重复劳动了。AccessWidener 更简单,语法就那么几条,真正的门槛在于你得知道该开哪个字段——这就得靠读源码和调试参数了。至于那些"代码大全""指令大全"之类的东西,看看就行,真到自己动手的时候,你还是得打开反编译源码,一行一行对着签名核对。这是我折腾了这么多年最实在的一条经验:工具会更新,语法会变,但"看清目标、精准注入、保留退路"这个思路是不变的。