• 欢迎加入MineBBS QQ讨论群:点击查看所有的官方讨论群
  • 我们将于近期对服务器进行迁移,服务可能中断至多2日。请各位安排好自己的访问计划,造成不便敬请谅解!
  • MineBBS入站考试已经上线!想要成为【正式会员】解锁更多功能吗?快来参与吧!【点我去看】

教程 在服务端使用插件修改MC源码

在服务端使用插件修改 MC 源码

写 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
    }
}

对照表:

MixinIncision说明
@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 收尾
RETURNreturn 指令前返回值 Trim
INVOKE方法调用处Graft/Bypass 调用点
FIELD_GET字段读字段读取探针
FIELD_PUT字段写字段写入探针
NEWnew 指令构造前后探针
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()
    }
}

这段代码做了什么:
  1. 用 @Splice 接管了 EnchantmentHelper.modifyDamage 的整个执行流程
  2. 遍历物品上的附魔,遇到锋利就用自定义公式
  3. 其他附魔原样调用原版的 modifyDamage
  4. @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




学习路径建议

  1. 先学 @Surgeon + @Lead + @Trail,这是最安全的起点
  2. 再学 @Splice,把 Resume 的语义搞明白
  3. 然后看 @Graft、@Bypass、@Trim
  4. 最后再碰 @Excise、复杂 Site、InsnPattern
  5. 需要动态启停时,再去学 DSL 模式的 Scalpel




如果你之前一直在用反射硬调 NMS,或者被 Mixin 的配置折腾得够呛,不妨试试 Incision。它不会替代所有方案,但在 Bukkit/Paper 插件的场景下,它提供了一个更轻量、更可控的选择。
 
内容版权许可
作者保留一切权利,禁止转载
枫溪

快来试试看!

还可以输入20字数。
领取红包用户
JOHNNY1yu31112 jsy0517 极 克 MC_MR BrAeRd 实力划水 卖饼翁 ewsk ljm278 Kazemar_sama

在线会员

  • Ashsk
  • kilang
  • oYuyio
  • zerolin
  • 黄本本
后退
顶部 底部