在服务端使用插件修改 MC 源码
写 Minecraft 插件久了,你一定遇到过这种场景:
比如你想改锋利附魔的伤害公式,想在某个 NMS 方法执行前后插一段逻辑,想把某个内部调用替换成你自己的实现。这些需求在 Bukkit 事件体系里根本找不到入口。
传统做法无非几种:反射硬调、ProtocolLib 拦包、编译期 Mixin、甚至直接改服务端 JAR。每种都有各自的痛点。
今天聊一个不太一样的思路 —— 用 TabooLib 的 Incision 模块,在运行时直接对已加载的类做字节码织入。
https://github.com/TabooLib/taboolib/tree/dev/6.3.0/module/incision
先说说传统方案的问题
反射
反射是最常见的"万能钥匙"。拿到 Field、Method,setAccessible(true),然后硬调。
能用,但问题也很明显:
反射适合偶尔读个字段、调个方法,但如果你想"在某个方法执行前插一段逻辑"或者"把方法里的某个调用替换掉",反射就无能为力了。
编译期 Mixin
Mixin 是 Fabric/Sponge 生态里的标配方案。在编译期把你的代码织入目标类,功能强大,生态成熟。
但在 Bukkit/Paper 插件场景下,Mixin 有几个绕不开的问题:
直接改服务端
最暴力的方案。改完打包,替换服务端 JAR。不用多说,维护成本爆炸,每次服务端更新都要重新来一遍。
Incision:运行时的手术刀
Incision 是 TabooLib 6.3 新增的字节码织入模块。它的定位很明确:在类已经加载到 JVM 之后,对目标方法做运行时织入。
和 Mixin 的核心区别在于时机 —— Mixin 在类加载前改字节码,Incision 在类加载后通过 JVMTI 做 retransform。这意味着:
安装
在 build.gradle.kts 里加一行:
从最简单的场景开始
假设你有一个类,里面有个方法你想在执行前后做点事情:
方法入口探针:@Lead
最轻量的织入方式。在目标方法执行前插一段逻辑,不影响原方法的执行。
就这么多。@Surgeon 标记这个 object 是一个"施术者",@Lead 声明在目标方法入口处插入逻辑。Theatre 是织入执行时的上下文,能拿到参数、实例、返回值等信息。
方法出口收尾:@Trail
在方法正常返回后做点事情,比如记日志、做统计:
@Lead 和 @Trail 是最安全的两种 advice,它们不会改变原方法的执行流程,只是在前后"旁听"。
进阶:接管方法执行
环绕控制:@Splice
当你需要完全控制一个方法的执行流程时 —— 比如改参数、改返回值、甚至直接短路不让原方法执行 —— 就该用 @Splice 了。
来看一个实际的例子:
theatre.resume 是 @Splice 的核心。你必须明确告诉它接下来怎么走:
注意:@Splice 的 handler 必须显式调用 proceed 或 skip。如果什么都不干,会触发 ResumeMissing 异常。这不是 bug,是设计上的强制约束 —— 环绕逻辑必须把路走完。
实战:Mixin 风格的方法覆写
如果你从 Sponge/Fabric 的 Mixin 体系过来,Incision 提供了对应的注解:
对照表:
精确到字节码指令的锚点定位
Incision 不只能在方法入口和出口做文章。通过 @site 注解,你可以精确定位到方法内部的任意字节码位置:
支持的锚点类型:
读写 private 字段、调用 private 方法
在 handler 里,你可以自由访问目标类的任何字段和方法,不管它是 private、final 还是 static。底层走 JVMTI JNI,完全绕过 Java 访问控制,不受 JDK 17+ 模块封装影响。
也可以在 Theatre 上直接调用,适合一次性场景:
真实案例:修改锋利附魔的伤害公式
来看一个贴近实战的例子。假设你想把锋利附魔的伤害计算换成自定义公式:
这段代码做了什么:
版本门控
NMS 方法在不同 Minecraft 版本里可能有不同的签名。Incision 提供了 @version 注解来做版本过滤:
扫描期就会根据当前服务端版本决定哪些 advice 注册、哪些跳过,不会有运行时开销。
Kotlin 目标的双路径问题
如果你要 patch 的目标是 Kotlin 代码,有个容易踩的坑:Kotlin 的 companion 实例方法和 @JvmStatic 静态桥接方法是两条不同的调用路径。
@KotlinTarget 帮你处理这个问题,不用自己去猜字节码里到底生成了几条路径。
Patch 的生命周期
每个 patch 都有自己的 Suture(缝合线),可以在运行时控制:
这是 Incision 和编译期 Mixin 最大的区别之一:patch 不是一锤子买卖,你可以在运行时随时启停。
什么时候该用,什么时候不该用
适合用 Incision 的场景:
不适合的场景:
学习路径建议
如果你之前一直在用反射硬调 NMS,或者被 Mixin 的配置折腾得够呛,不妨试试 Incision。它不会替代所有方案,但在 Bukkit/Paper 插件的场景下,它提供了一个更轻量、更可控的选择。
写 Minecraft 插件久了,你一定遇到过这种场景:
"这个功能 Bukkit API 没暴露出来,事件也没有,只能去改 NMS 的逻辑。"
比如你想改锋利附魔的伤害公式,想在某个 NMS 方法执行前后插一段逻辑,想把某个内部调用替换成你自己的实现。这些需求在 Bukkit 事件体系里根本找不到入口。
传统做法无非几种:反射硬调、ProtocolLib 拦包、编译期 Mixin、甚至直接改服务端 JAR。每种都有各自的痛点。
今天聊一个不太一样的思路 —— 用 TabooLib 的 Incision 模块,在运行时直接对已加载的类做字节码织入。
https://github.com/TabooLib/taboolib/tree/dev/6.3.0/module/incision
先说说传统方案的问题
反射
反射是最常见的"万能钥匙"。拿到 Field、Method,setAccessible(true),然后硬调。
代码:
val method = MinecraftServer::class.java.getDeclaredMethod("getPlayerCount")
method.isAccessible = true
val count = method.invoke(server) as Int
能用,但问题也很明显:
- 你只能"调用"和"读写字段",没法改方法内部的执行逻辑
- JDK 17+ 的模块封装越来越严,setAccessible 经常翻车
- 性能不算好,高频调用场景下反射开销不可忽视
反射适合偶尔读个字段、调个方法,但如果你想"在某个方法执行前插一段逻辑"或者"把方法里的某个调用替换掉",反射就无能为力了。
编译期 Mixin
Mixin 是 Fabric/Sponge 生态里的标配方案。在编译期把你的代码织入目标类,功能强大,生态成熟。
但在 Bukkit/Paper 插件场景下,Mixin 有几个绕不开的问题:
- 需要在类加载前完成织入,插件的加载时机往往不够早
- 和 Paper 的类加载机制有冲突,配置复杂
- 织入是一次性的,运行时没法动态启停
- 多个插件同时 Mixin 同一个类容易打架
直接改服务端
最暴力的方案。改完打包,替换服务端 JAR。不用多说,维护成本爆炸,每次服务端更新都要重新来一遍。
Incision:运行时的手术刀
Incision 是 TabooLib 6.3 新增的字节码织入模块。它的定位很明确:在类已经加载到 JVM 之后,对目标方法做运行时织入。
和 Mixin 的核心区别在于时机 —— Mixin 在类加载前改字节码,Incision 在类加载后通过 JVMTI 做 retransform。这意味着:
- 不需要特殊的类加载器配置
- 可以在运行时动态启停 patch
- 出了问题可以回滚
- 作为 TabooLib 模块,安装就是一行配置的事
安装
在 build.gradle.kts 里加一行:
代码:
taboolib {
env {
install(Basic, Bukkit, BukkitNMS)
install("incision") // 就这一行
}
}
从最简单的场景开始
假设你有一个类,里面有个方法你想在执行前后做点事情:
代码:
class SomeService {
fun greet(name: String): String {
return "hello, $name"
}
}
方法入口探针:@Lead
最轻量的织入方式。在目标方法执行前插一段逻辑,不影响原方法的执行。
代码:
@Surgeon
object GreetMonitor {
@Lead(scope = "method:com.example.SomeService#greet(java.lang.String)java.lang.String")
fun beforeGreet(theatre: Theatre) {
println("有人调用了 greet,参数是: ${theatre.arg<String>(0)}")
}
}
就这么多。@Surgeon 标记这个 object 是一个"施术者",@Lead 声明在目标方法入口处插入逻辑。Theatre 是织入执行时的上下文,能拿到参数、实例、返回值等信息。
方法出口收尾:@Trail
在方法正常返回后做点事情,比如记日志、做统计:
代码:
@Surgeon
object GreetMonitor {
@Trail(scope = "method:com.example.SomeService#greet(java.lang.String)java.lang.String")
fun afterGreet(theatre: Theatre) {
println("greet 执行完毕")
}
// 如果还想捕获异常出口
@Trail(
scope = "method:com.example.SomeService#greet(java.lang.String)java.lang.String",
onThrow = true
)
fun afterGreetWithException(theatre: Theatre) {
if (theatre.throwable != null) {
println("greet 抛了异常: ${theatre.throwable?.message}")
}
}
}
@Lead 和 @Trail 是最安全的两种 advice,它们不会改变原方法的执行流程,只是在前后"旁听"。
进阶:接管方法执行
环绕控制:@Splice
当你需要完全控制一个方法的执行流程时 —— 比如改参数、改返回值、甚至直接短路不让原方法执行 —— 就该用 @Splice 了。
来看一个实际的例子:
代码:
// 目标类
class SurgeonAopTargetFixture {
var fieldHits: Int = 0
fun greet(name: String): String {
fieldHits++
return "hello, $name"
}
fun rewriteTarget(x: Int): Int = x * 10
}
代码:
@Surgeon
object SurgeonAopCases {
// 改参后放行:把 name 参数加个前缀
@Splice(scope = "method:...SurgeonAopTargetFixture#greet(java.lang.String)java.lang.String")
fun aroundModifyArgs(theatre: Theatre): Any? {
val newArgs = theatre.args.copyOf()
newArgs[0] = "patched-${newArgs[0]}"
return theatre.resume.proceed(*newArgs)
}
// 改写返回值:在原结果基础上 +100
@Splice(scope = "method:...SurgeonAopTargetFixture#rewriteTarget(int)int")
fun aroundRewriteResult(theatre: Theatre): Any? {
val result = theatre.resume.proceed()
return theatre.resume.proceedResult((result as Int) + 100)
}
}
theatre.resume 是 @Splice 的核心。你必须明确告诉它接下来怎么走:
| 操作 | 含义 |
|---|---|
| resume.proceed() | 放行,继续执行原方法 |
| resume.proceed(newArgs...) | 改参后放行 |
| resume.proceedResult(value) | 带着新结果继续往下传 |
| resume.skip(value) | 直接短路,不执行原方法 |
注意:@Splice 的 handler 必须显式调用 proceed 或 skip。如果什么都不干,会触发 ResumeMissing 异常。这不是 bug,是设计上的强制约束 —— 环绕逻辑必须把路走完。
实战:Mixin 风格的方法覆写
如果你从 Sponge/Fabric 的 Mixin 体系过来,Incision 提供了对应的注解:
代码:
// 目标类:原方法会抛异常
class SurgeonMixinTargetFixture {
fun mayThrow(throwIt: Boolean): String {
if (throwIt) error("boom")
return "ok"
}
fun helper(x: Int): Int = x * 10
}
代码:
@Surgeon(priority = 10)
object SurgeonMixinCases {
// @Excise = @Overwrite:整段替换方法体
@Excise(scope = "method:...SurgeonMixinTargetFixture#mayThrow(boolean)java.lang.String")
fun overwriteMayThrow(theatre: Theatre): Any? {
return "mixin-safe" // 永远不会抛异常了
}
// @Bypass = @Redirect:替换方法内的某个调用点
@Bypass(
method = "...SurgeonMixinTargetFixture#helper(int)int",
site = Site(anchor = Anchor.HEAD)
)
fun redirectHelper(theatre: Theatre): Any? {
return 888 // 原来返回 x*10,现在固定返回 888
}
}
对照表:
| Mixin | Incision | 说明 |
|---|---|---|
| @Inject(at = @At("HEAD")) | @Lead | 方法入口 |
| @Inject(at = @At("RETURN")) | @Trail | 方法出口 |
| @Redirect | @Bypass | 替换调用点 |
| @ModifyArg / @ModifyVariable | @Trim | 改写参数或变量 |
| @Overwrite | @Excise | 整段方法覆写 |
精确到字节码指令的锚点定位
Incision 不只能在方法入口和出口做文章。通过 @site 注解,你可以精确定位到方法内部的任意字节码位置:
代码:
@Surgeon
object SurgeonSiteCases {
private const val FIX = "top.maplex.incisiontest.fixture.SurgeonSiteTargetFixture"
// 在调用 helper() 之前插入逻辑
@Graft(
method = "$FIX#invokeHelper(int)int",
site = Site(
anchor = Anchor.INVOKE,
target = "$FIX#helper(int)int",
shift = Shift.BEFORE,
),
)
fun graftBeforeHelperInvoke(theatre: Theatre) {
println("helper 即将被调用")
}
// 把 plainReturn() 的返回值改成 999
@Trim(
method = "$FIX#plainReturn()int",
kind = Trim.Kind.RETURN,
)
fun trimReturnPlain(theatre: Theatre): Any? {
return 999
}
// 把 echo() 的第一个参数改掉
@Trim(
method = "$FIX#echo(java.lang.String)java.lang.String",
kind = Trim.Kind.ARG,
index = 0,
)
fun trimEchoArg(theatre: Theatre): Any? {
return "trimmed"
}
}
支持的锚点类型:
| Anchor | 含义 | 典型用途 |
|---|---|---|
| HEAD | 方法入口 | Lead、参数 Trim |
| TAIL | 正常出口前 | Trail 收尾 |
| RETURN | return 指令前 | 返回值 Trim |
| INVOKE | 方法调用处 | Graft/Bypass 调用点 |
| FIELD_GET | 字段读 | 字段读取探针 |
| FIELD_PUT | 字段写 | 字段写入探针 |
| NEW | new 指令 | 构造前后探针 |
| THROW | 抛异常处 | 异常路径观察 |
读写 private 字段、调用 private 方法
在 handler 里,你可以自由访问目标类的任何字段和方法,不管它是 private、final 还是 static。底层走 JVMTI JNI,完全绕过 Java 访问控制,不受 JDK 17+ 模块封装影响。
代码:
@Surgeon
object AccessDemo {
// Lambda 工厂方式(推荐,解析一次,处处复用)
private val getSecret = field<String>("secretField")
private val setCounter = fieldSet<Int>("counter")
private val callPrivate = method<String>("privateMethod")
@Lead(scope = "method:com.example.Target#run()void")
fun beforeRun(theatre: Theatre) {
val secret = getSecret(theatre)
setCounter(theatre, 42)
val result = callPrivate(theatre, "arg1")
}
}
也可以在 Theatre 上直接调用,适合一次性场景:
代码:
@Lead(scope = "...")
fun handler(theatre: Theatre) {
val name: String? = theatre.field("playerName")
theatre.setField("enabled", false)
theatre.invoke<Unit>("notifyAll")
}
真实案例:修改锋利附魔的伤害公式
来看一个贴近实战的例子。假设你想把锋利附魔的伤害计算换成自定义公式:
代码:
@Surgeon
object SharpnessModifier {
var customFormula: (level: Int) -> Float = { level ->
2.0f + 1.5f * (level - 1)
}
@Splice(scope = "method:net.minecraft.world.item.enchantment.EnchantmentHelper#modifyDamage(net.minecraft.server.level.ServerLevel,net.minecraft.world.item.ItemStack,net.minecraft.world.entity.Entity,net.minecraft.world.damagesource.DamageSource,float)float")
@Operation(id = "sharpness-modifier", enabled = true)
fun modifySharpness(theatre: Theatre): Any? {
val serverLevel = theatre.arg<ServerLevel>(0) ?: return theatre.resume.proceed()
val itemStack = theatre.arg<ItemStack>(1) ?: return theatre.resume.proceed()
val baseDamage = theatre.arg<Float>(4) ?: return theatre.resume.proceed()
val enchantments = itemStack.getOrDefault(
DataComponents.ENCHANTMENTS, ItemEnchantments.EMPTY
)
if (enchantments.isEmpty) return theatre.resume.proceed()
val result = MutableFloat(baseDamage)
for (entry in enchantments.entrySet()) {
val holder = entry.key as Holder<Enchantment>
val level = entry.intValue
if (holder.`is`(Enchantments.SHARPNESS)) {
result.add(customFormula(level))
} else {
holder.value().modifyDamage(serverLevel, level, itemStack, victim, damageSource, result)
}
}
return result.toFloat()
}
}
这段代码做了什么:
- 用 @Splice 接管了 EnchantmentHelper.modifyDamage 的整个执行流程
- 遍历物品上的附魔,遇到锋利就用自定义公式
- 其他附魔原样调用原版的 modifyDamage
- @Operation(id = "sharpness-modifier") 给这个 patch 起了个名字,方便运行时管理
版本门控
NMS 方法在不同 Minecraft 版本里可能有不同的签名。Incision 提供了 @version 注解来做版本过滤:
代码:
@Surgeon
object VersionAwarePatch {
@Lead(scope = "method:...SomeTarget#versionedMethod()java.lang.String")
@Version(start = "1.17", end = "1.21")
fun onModernVersions(theatre: Theatre) {
// 只在 1.17 ~ 1.21 版本生效
}
@Lead(scope = "method:...SomeTarget#versionedMethod()java.lang.String")
@Version(start = "26.1")
fun onFutureVersions(theatre: Theatre) {
// 26.1+ 版本生效
}
}
扫描期就会根据当前服务端版本决定哪些 advice 注册、哪些跳过,不会有运行时开销。
Kotlin 目标的双路径问题
如果你要 patch 的目标是 Kotlin 代码,有个容易踩的坑:Kotlin 的 companion 实例方法和 @JvmStatic 静态桥接方法是两条不同的调用路径。
代码:
@Surgeon
object KotlinAwarePatch {
@Lead(scope = "method:...SomeTarget#staticEcho(java.lang.String)java.lang.String")
@KotlinTarget(jvmStaticBridge = true)
fun onStaticEcho(theatre: Theatre) {
// 同时覆盖 @JvmStatic 桥接方法
}
@Lead(scope = "method:...SomeTarget#companionEcho(java.lang.String)java.lang.String")
@KotlinTarget(companionInstance = true)
fun onCompanionEcho(theatre: Theatre) {
// 覆盖 companion 实例方法
}
}
@KotlinTarget 帮你处理这个问题,不用自己去猜字节码里到底生成了几条路径。
Patch 的生命周期
每个 patch 都有自己的 Suture(缝合线),可以在运行时控制:
| 状态 | 含义 |
|---|---|
| ARMED | 已织入并启用 |
| SUSPENDED | 字节码仍在,但 handler 被跳过 |
| HEALED | 已卸载或回滚 |
代码:
// 临时停用
suture.suspend()
// 恢复
suture.resume()
// 永久卸载
suture.heal()
这是 Incision 和编译期 Mixin 最大的区别之一:patch 不是一锤子买卖,你可以在运行时随时启停。
什么时候该用,什么时候不该用
适合用 Incision 的场景:
- Bukkit API 和事件体系覆盖不到的 NMS 逻辑修改
- 需要在方法内部的特定位置插入逻辑(不只是入口出口)
- 需要运行时动态启停的 patch
- 跨版本的 NMS 适配(配合 @version 和 remap)
- 对第三方插件的方法做钩子
不适合的场景:
- Bukkit 事件能解决的,别用字节码织入
- 能正常写接口、服务层的业务逻辑,别写成 patch
- 不了解目标方法的字节码结构就直接上复杂的 InsnPattern
学习路径建议
- 先学 @Surgeon + @Lead + @Trail,这是最安全的起点
- 再学 @Splice,把 Resume 的语义搞明白
- 然后看 @Graft、@Bypass、@Trim
- 最后再碰 @Excise、复杂 Site、InsnPattern
- 需要动态启停时,再去学 DSL 模式的 Scalpel
如果你之前一直在用反射硬调 NMS,或者被 Mixin 的配置折腾得够呛,不妨试试 Incision。它不会替代所有方案,但在 Bukkit/Paper 插件的场景下,它提供了一个更轻量、更可控的选择。
- 内容版权许可
- 作者保留一切权利,禁止转载
领取红包用户