跳到主要内容

Incision 字节码织入

Incision 是 TabooLib 的运行时织入模块,为 Bukkit/Paper/NMS 场景提供一套可控、可诊断、可回滚的手术式织入能力。

它不是通用 AOP 框架,也不是编译期 Mixin 系统。它更像一套给 Minecraft 服务端插件准备的手术工具:下刀点要准,生命周期要能控,出了问题要能查,必要时还得能撤。

先把它理解对

最容易把 Incision 想歪的地方有三个:

  1. 它不是动态代理。不会新建 $Proxy 类,不需要接口,连目标类自己内部的调用、final 方法、NMS 那种根本不走代理链的路径,它也能碰到。
  2. 它不是编译期 Mixin。Mixin 在程序启动前改好目标类;Incision 在类已经加载到 JVM 后做运行时织入。
  3. 它不是拿来把业务逻辑全都写成 patch 的。能正常写接口、事件、服务层的地方,还是正常写。

适用场景

适合:

  • 在 Bukkit/Paper 插件里做方法入口、出口、调用点级别的轻量织入
  • 对 NMS/Bukkit 方法做版本门控、remap 后再织入
  • 对 Kotlin objectcompanion@JvmStatic 目标做双路径覆盖
  • 做临时 patch、范围 patch、线程局部 patch、批量 patch
  • 对运行时问题提供可回滚的诊断性织入
  • 读写目标类的 private/final/static 字段,或调用 private 方法

不适合:

  • 把整套业务逻辑长期建立在大规模字节码改写之上
  • 期望它替代完整字节码框架或编译期 Mixin 系统
  • 在完全不了解目标字节码形态时直接依赖复杂 InsnPattern

两套入口

Incision 同时支持两套入口:

入口写法推荐场景
注解模式@Surgeon + @Lead/@Trail/@Splice/...长期、稳定、声明式的 patch(默认首选)
DSL 模式Scalpel { ... } / Scalpel.transient { ... }临时、作用域、线程局部、事件驱动的 patch
选型建议

能写成 @Surgeon 的长期 patch,就别先写成 DSL。只有 patch 的生命周期本身要动态控制时,再上 DSL。

Advice 类型总表

类型DSL/注解典型用途是否替换原逻辑关键约束
Leadlead / @Lead入口探针、计数、参数观察只能表达入口语义
Trailtrail / @Trail正常出口或异常出口收尾onThrow=false 时不覆盖异常出口
Splicesplice / @Splice环绕、短路、改参与放行可选必须显式 proceed/skip/override
Graftgraft / @Graft在锚点前后追加逻辑原指令仍会执行
Bypassbypass / @Bypass把单个调用点重定向到 handler是,替换单点只替换目标位点,不替换整个方法
Trimtrim / @Trim改写参数、返回值、局部变量改值,不改流程必须保证值类型兼容
Exciseexcise / @Excise整段方法覆写是,替换整个方法同一目标只能有一个 Excise

如果你从 Mixin 体系过来,可以这样对照:

MixinIncision
@Inject(at = @At("HEAD"))@Lead
@Inject(at = @At("RETURN"))@Trail
around + cancel / proceed@Splice
@Redirect@Bypass
@ModifyArg / @ModifyVariable@Trim
@Overwrite@Excise

注解模式(推荐)

基础结构

import taboolib.module.incision.annotation.Lead
import taboolib.module.incision.annotation.Operation
import taboolib.module.incision.annotation.Splice
import taboolib.module.incision.annotation.Surgeon
import taboolib.module.incision.annotation.Trail
import taboolib.module.incision.api.Theatre

@Surgeon(priority = 50)
object DemoSurgeon {

@Lead(scope = "method:top.example.Target#greet(java.lang.String)java.lang.String")
fun beforeGreet(theatre: Theatre) {
println("before: ${theatre.arg<String>(0)}")
}

@Splice(scope = "method:top.example.Target#greet(java.lang.String)java.lang.String")
@Operation(id = "rewrite-name", priority = 100)
fun aroundGreet(theatre: Theatre): Any? {
val name = theatre.arg<String>(0) ?: return theatre.resume.proceed()
if (name == "Admin") {
return theatre.override("blocked")
}
return theatre.resume.proceed(name.uppercase())
}

@Trail(
scope = "method:top.example.Target#greet(java.lang.String)java.lang.String",
onThrow = true
)
fun afterGreet(theatre: Theatre) {
if (theatre.throwable != null) {
println("throw: ${theatre.throwable?.message}")
} else {
println("after greet")
}
}
}

代码说明:

  • @Surgeon 只能标在 Kotlin object 上,声明"这个对象里放的是注解式 patch"
  • @Surgeon(priority = 50) 设置类级默认优先级
  • @Operation 可以覆盖类级默认优先级和启用状态
  • 扫描期会把方法翻译成 AdviceEntry,注册进 dispatcher,再触发织入

@Lead:方法入口

最适合做前置探针、参数观察、记日志、轻量前置判断。

@Lead(scope = "method:top.example.Target#greet(java.lang.String)java.lang.String")
fun beforeGreet(theatre: Theatre) {
println("before: ${theatre.arg<String>(0)}")
}
注意

别拿 @Lead 做整段控制流接管。想"放不放行",请直接用 @Splice

@Trail:方法出口

适合正常返回前收尾、异常抛出前补日志、做统计。

@Trail(
scope = "method:top.example.Target#greet(java.lang.String)java.lang.String",
onThrow = true
)
fun afterGreet(theatre: Theatre) {
if (theatre.throwable != null) {
println("异常: ${theatre.throwable?.message}")
} else {
println("正常返回")
}
}

onThrow = true 时同时覆盖异常出口,默认只覆盖正常返回路径。

@Splice:环绕控制

控制力最强的 advice 类型,也最容易写出坑。命中后你必须明确表态:

@Splice(scope = "method:top.example.Target#greet(java.lang.String)java.lang.String")
fun aroundGreet(theatre: Theatre): Any? {
val name = theatre.arg<String>(0) ?: return theatre.resume.proceed()
if (name == "Admin") {
// 短路:不执行原方法,直接返回
return theatre.override("blocked")
}
// 改参后放行
return theatre.resume.proceed(name.uppercase())
}

Resume 操作:

操作含义
theatre.resume.proceed()放行,继续执行原方法
theatre.resume.proceed(newArgs...)改参后放行
theatre.resume.proceedResult(value)带着一个新结果继续往下传
theatre.override(value) / resume.skip(value)直接短路,不执行原方法
重要

如果 @Splice 的 handler 什么都不干(既不 proceed 也不 skip),会触发 ResumeMissing。这不是温柔提示,而是明确告诉你:这段环绕逻辑没把路走完。

@Graft:锚点前后追加逻辑

在某个调用点、字段访问、构造指令等锚点前后追加逻辑,但原指令照跑。适合做探针、补日志、埋点。

import taboolib.module.incision.annotation.Graft
import taboolib.module.incision.annotation.Site
import taboolib.module.incision.api.Anchor
import taboolib.module.incision.api.Shift

@Graft(
method = "top.example.Target#greet(java.lang.String)java.lang.String",
site = Site(
anchor = Anchor.INVOKE,
target = "top.example.Logger#print(java.lang.String)void",
shift = Shift.BEFORE
)
)
fun beforePrint(theatre: Theatre) {
println("logger is about to run")
}

@Bypass:调用点替换

把某个调用点直接换掉,类似 Mixin 的 @Redirect。原来的那次调用不会再执行。

import taboolib.module.incision.annotation.Bypass
import taboolib.module.incision.annotation.Site
import taboolib.module.incision.api.Anchor

@Bypass(
method = "top.example.Target#greet(java.lang.String)java.lang.String",
site = Site(
anchor = Anchor.INVOKE,
target = "top.example.Service#load(java.lang.String)java.lang.String"
)
)
fun replaceLoad(theatre: Theatre): Any? {
return "mocked"
}

@Trim:值改写

不接管流程,只改参数、返回值或局部变量的值。

import taboolib.module.incision.annotation.Trim

@Trim(
method = "top.example.Target#greet(java.lang.String,int)java.lang.String",
kind = Trim.Kind.ARG,
index = 0
)
fun rewriteName(theatre: Theatre): Any? {
return "patched"
}

@Excise:整段方法覆写

重型武器。原方法体不跑了,全部交给你。

import taboolib.module.incision.annotation.Excise

@Excise(scope = "method:top.example.Target#greet(java.lang.String)java.lang.String")
fun overwrite(theatre: Theatre): Any? {
return "direct result"
}
慎用

@Excise 风险最大,和别人冲突的概率最高,同一 target 只允许一个 Excise。能用 @Lead@Trail@Splice 解决的,尽量别直接 @Excise

DSL 模式

DSL 的入口是 Scalpel,必须放在 @SurgeryDesk object 内。

持久 patch

import taboolib.module.incision.annotation.SurgeryDesk
import taboolib.module.incision.api.Suture
import taboolib.module.incision.dsl.Scalpel

@SurgeryDesk
object DemoDesk {

val greetPatch: Suture by Scalpel {
lead("top.example.Target#greet(java.lang.String)java.lang.String") { theatre ->
println("before greet: ${theatre.args[0]}")
}
}
}

临时 patch

@SurgeryDesk
object DemoDesk {

fun patchOnce() {
Scalpel.transient {
splice("top.example.Target#greet(java.lang.String)java.lang.String") { theatre ->
println("args = ${theatre.args.contentToString()}")
theatre.resume.proceed()
}
}.use {
// 只在这个作用域里生效
}
}
}

DSL 模式一览

模式作用说明
Scalpel {}持久 patch通常作为属性委托,返回 Suture
Scalpel.deferred 惰性 patch延迟到首次访问或目标类加载后 arm
Scalpel.transient {}一次性 patch需手动 healuse
Scalpel.scoped {}作用域 patch块内生效,块外自动回收
Scalpel.threadLocal {}线程局部 patch默认不启用,按线程激活
Scalpel.armOn/disarmOn事件驱动 patch返回 ArmTrigger,由调用方决定何时 arm/disarm
Scalpel.exclusive互斥 patch块内挂起同 target 的其他 ARMED patch

Theatre:Handler 的工作台

Theatre 是 advice 执行时看到的上下文,几乎所有 handler 都围着它转。

属性/方法说明
self当前实例,静态方法时为 null
args参数数组,能读也能改
target当前命中的方法坐标
throwable异常出口时能看到异常
arg<T>(index)按类型取参数,越界返回 null
argOrThrow<T>(index)按类型取参数,越界抛异常
selfAs<T>()self 安全转型
resume只有 @Splice 最依赖,控制后续流程

Suture 生命周期

不管是 DSL 还是注解扫描出来的 patch,最终都会有自己的 Suture

状态含义
ARMED已织入并启用
TRIGGERED已触发过一次以上
SUSPENDED字节码仍在,但 dispatcher 跳过 handler
HEALED已卸载或回滚
INACTIVE_UNRESOLVED声明未成功解析

控制接口:

  • heal():永久卸载
  • suspend():临时停用,但不回滚织入点
  • resume():恢复已挂起的 patch
  • close():等价于 heal()

访问 private 字段与方法

Handler 内可以读写目标类(或任意其他类)的 private/final/static 字段,以及调用 private 方法。底层走 JVMTI JNI,完全绕过 Java 访问控制,不依赖 setAccessible,不受 JDK 17+ 模块封装影响。

Lambda 工厂(推荐)

类级声明,解析一次,处处复用:

import taboolib.module.incision.api.*
import taboolib.module.incision.annotation.Surgeon
import taboolib.module.incision.annotation.Lead

@Surgeon
object AccessDemo {

private val privateFinalName = field<String>("privateFinalName")
private val protectedValue = field<Double>("protectedValue")
private val setMutableCount = fieldSet<Int>("privateMutableCount")
private val getMutableCount = field<Int>("privateMutableCount")
private val staticSecret = staticField<String>(SomeClass::class.java, "STATIC_SECRET")
private val setStaticCounter = staticFieldSet<Int>(SomeClass::class.java, "staticCounter")
private val getStaticCounter = staticField<Int>(SomeClass::class.java, "staticCounter")
private val privateAdd = method<Int>("privateAdd", "(II)I")
private val privateGreet = method<String>("privateGreet")

@Lead(scope = "method:top.example.Target#run()void")
fun beforeRun(theatre: Theatre) {
val name = privateFinalName(theatre)
val value = protectedValue(theatre)
setMutableCount(theatre, 42)
val count = getMutableCount(theatre)
val secret = staticSecret()
setStaticCounter(99)
val counter = getStaticCounter()
val sum = privateAdd(theatre, 10, 20)
val greeting = privateGreet(theatre, "world")
}
}

工厂函数一览:

工厂函数返回类型调用形式
field<T>(name)FieldAccessor<T>accessor(theatre)accessor(receiver)
field<T>(ownerClass, name)FieldAccessor<T>同上,指定声明类
staticField<T>(ownerClass, name)StaticFieldAccessor<T>accessor()
fieldSet<T>(name)FieldSetter<T>setter(theatre, value)
fieldSet<T>(ownerClass, name)FieldSetter<T>同上
staticFieldSet<T>(ownerClass, name)StaticFieldSetter<T>setter(value)
method<T>(name, descriptor?)MethodAccessor<T>accessor(theatre, arg1, arg2)
staticMethod<T>(ownerClass, name, descriptor?)StaticMethodAccessor<T>accessor(arg1, arg2)

Theatre 直接调用

适用于一次性、不值得声明 val 的场景:

@Lead(scope = "...")
fun handler(t: Theatre) {
val name: String? = t.field("playerName")
val count: Int? = t.staticField(SomeClass::class.java, "MAX_COUNT")
t.setField("enabled", false)
t.invoke<Unit>("notifyAll")
}

通用工具扩展

以下顶层扩展函数可在任何地方使用,不限于 Theatre 作用域:

import taboolib.module.incision.api.*

val greetable: Greetable? = someObject.cast<Greetable>()
val str: String = someObject.castOrThrow<String>()
val secret: String? = someObject.readField<String>("secret")
someObject.writeField("secret", "modified")
val result: String? = someObject.callMethod<String>("greet")
注意事项
  • 修改 static final 原始类型或 String 字段可能不会对已 JIT 过的调用点生效(常量折叠)
  • JVMTI 不可用时自动降级到反射 + Unsafe,但 JDK 17+ 非开放模块的 private 字段可能降级失败
  • 方法重载场景下,如果按参数类型匹配到多个候选,需要显式传入 descriptor 参数

锚点与落点

锚点类型

Anchor含义常见用途
HEAD方法入口Lead、参数 Trim
TAIL正常出口前Trail 收尾
RETURNreturn 指令前返回值 Trim
INVOKE方法调用处Graft/Bypass 调用点
FIELD_GET字段读字段读取探针
FIELD_PUT字段写字段写入探针
NEWnew 指令构造前后探针
THROW抛异常处异常路径观察

Site 参数

字段说明
anchor锚点类型
target锚点目标,例如 owner#name(desc)ret
shiftBEFORE / AFTER
ordinal第几个命中,-1 表示全部
offset相对锚点再移动几条指令

方法描述符

Incision 内部统一使用以下格式:

owner#method(arg1,arg2,...)returnType

示例:

写法含义
org.bukkit.entity.Player#kickPlayer(java.lang.String)void实例方法
top.example.Target$Companion#echo(java.lang.String)java.lang.StringKotlin companion 实例方法
net.minecraft.server.MinecraftServer#getPlayerCount()intNMS 方法
注意

描述符错误通常会落到 Trauma.Declaration.BadDescriptor,请仔细检查 owner、方法名、参数类型和返回类型。

版本门控与 Remap

@Version

在扫描期决定某条 advice 是否注册。默认 matcher 走 Minecraft/NMS 版本,支持自定义 matcher FQCN。

Remap

用户声明可以写逻辑上的 NMS owner/name/desc,运行时交给 RemapRouter / TabooLibNmsResolver 解析到实际类名。安装 weaver 时会先做 owner 级映射,再对已加载类做 retransform。

@KotlinTarget

解决 Kotlin companion 实例方法与 @JvmStatic 静态桥接方法是两条调用路径的问题。可分别扩展到 companionInstancejvmStaticBridge

@Operation 元信息

方法级 @Operation 可以覆盖类级 @Surgeon 的默认优先级和启用状态:

@Surgeon(priority = 50)
object DemoSurgeon {

@Lead(scope = "method:top.example.Target#run()void")
@Operation(id = "my-lead", priority = 100, enabled = true)
fun beforeRun(theatre: Theatre) {
// priority 100 覆盖类级的 50
}

@Trail(scope = "method:top.example.Target#run()void")
@Operation(id = "disabled-trail", enabled = false)
fun afterRun(theatre: Theatre) {
// 默认禁用,可通过 Suture.resume() 运行时启用
}
}

排序规则:

  • priority 降序
  • 同优先级保持注册顺序
  • 类级 @Surgeon(priority) 是默认值
  • 方法级 @Operation(priority) 可覆盖类级默认值

实战示例:锋利附魔伤害拦截

以下示例展示如何用 @Splice 精确拦截 NMS 的附魔伤害结算方法,观察锋利附魔的真实伤害贡献:

import net.minecraft.core.Holder
import net.minecraft.core.component.DataComponents
import net.minecraft.server.level.ServerLevel
import net.minecraft.world.damagesource.DamageSource
import net.minecraft.world.entity.Entity
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.enchantment.Enchantment
import net.minecraft.world.item.enchantment.Enchantments
import net.minecraft.world.item.enchantment.ItemEnchantments
import org.apache.commons.lang3.mutable.MutableFloat
import taboolib.module.incision.annotation.Operation
import taboolib.module.incision.annotation.Splice
import taboolib.module.incision.annotation.Surgeon
import taboolib.module.incision.api.Theatre

private const val MODIFY_DAMAGE =
"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"

@Surgeon
object SharpnessModifier {

var customFormula: (level: Int) -> Float = { level ->
2.0f + 1.5f * (level - 1)
}

@Splice(scope = MODIFY_DAMAGE)
@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 victim = theatre.arg<Entity>(2) ?: return theatre.resume.proceed()
val damageSource = theatre.arg<DamageSource>(3) ?: return theatre.resume.proceed()
val baseDamage = theatre.arg<Float>(4) ?: return theatre.resume.proceed()

val enchantments: ItemEnchantments =
itemStack.getOrDefault(DataComponents.ENCHANTMENTS, ItemEnchantments.EMPTY)
if (enchantments.isEmpty) return theatre.resume.proceed()

val result = MutableFloat(baseDamage)
for (entry in enchantments.entrySet()) {
@Suppress("UNCHECKED_CAST")
val holder = entry.key as Holder<Enchantment>
val level = entry.intValue

if (holder.`is`(Enchantments.SHARPNESS)) {
val customBonus = customFormula(level)
result.add(customBonus)
} else {
holder.value().modifyDamage(serverLevel, level, itemStack, victim, damageSource, result)
}
}
return result.toFloat()
}
}

代码说明:

  • 使用 @Splice 完全接管 EnchantmentHelper.modifyDamage 的逻辑
  • 对锋利附魔使用自定义公式替换原版计算
  • 其他附魔原样调用 Enchantment.modifyDamage()
  • @Operation(id = "sharpness-modifier") 方便运行时通过 id 查找和管理

实战示例:Essentials /list 命令钩子

import org.bukkit.command.CommandSender
import taboolib.module.incision.annotation.Lead
import taboolib.module.incision.annotation.Surgeon
import taboolib.module.incision.annotation.Trail
import taboolib.module.incision.api.Theatre
import taboolib.module.incision.api.callMethod

@Surgeon
object EssentialsListHook {

private const val TARGET = "method:com.earth2me.essentials.commands.Commandlist#run(*)"

private fun getSender(theatre: Theatre): CommandSender? {
val source = theatre.arg<Any>(1) ?: return null
return source.callMethod<CommandSender>("getSender")
}

@Lead(scope = TARGET)
fun beforeList(theatre: Theatre) {
getSender(theatre)?.sendMessage("§a[Incision] 即将执行 /list 命令...")
}

@Trail(scope = TARGET)
fun afterList(theatre: Theatre) {
getSender(theatre)?.sendMessage("§a[Incision] /list 命令执行完毕!")
}
}

代码说明:

  • 使用通配描述符 run(*) 匹配方法
  • 通过 callMethod 调用目标对象的 private 方法获取 CommandSender
  • @Lead 在命令执行前发送提示,@Trail 在执行后发送提示

技术原理概览

工作流程

点击放大

关键设计

  1. 字节码里写的是桥调用(IncisionBridge.dispatch),不是业务 handler 本体。这样 patch 可以统一启停,handler 可以热插拔。

  2. 桥放在不被 TabooLib Gradle 插件 relocate 的 io.izzel.* 包下,所有插件的织入字节码都指向同一个桥类,解决跨插件 ClassLoader 问题。

  3. 同一个 owner 上的 advice 会先聚合后再织入,避免重复 retransform。

  4. 织入前会通过 FrameVerifier 预检帧一致性,失败就回退原字节码,不把坏 class 喂给 JVM。

两个后端

后端原理生产环境可用性
InstrumentationBackendself-attach → Instrumentation.retransformClasses受限(JDK 21+ 需开关,Paper 常禁)
JvmtiBackend预编译 native agent → JVMTI RetransformClasses最稳(不依赖 attach 权限)

JvmtiBackend 是生产环境主力,额外提供原字节码缓存、任意 ClassLoader 的 defineClass、无访问控制的字段/方法访问等独家能力。

生命周期

@Surgeon 扫描和物理织入尽量前推到 LifeCycle.CONST,这是 TabooLib 给用户代码开放的最早窗口。INITLOADENABLE 阶段的宿主逻辑都可以被更早注册的 patch 命中。

仍然不能拦截的:

  • 插件 main class 的静态初始化块(比 CONST 更早)
  • TabooLib 自身启动阶段的代码
  • bootstrap/system ClassLoader 上某些核心类

诊断与排错

优先看三类信息:Forensics.debug/warnTrauma.*、对应分类测试用例。

现象常见原因先看哪里
advice 未命中描述符错、scope 过宽或过窄、pattern 不匹配DescriptorCodecScopeInsnPattern
ResumeMissingSplice 没有显式放行或短路handler 本身
只命中 Java,不命中 Kotlin漏了 companion / @JvmStatic 扩展@KotlinTarget
某些 NMS 版本不生效版本过滤或 remap 结果不一致@VersionRemapRouter
同 target 顺序不对优先级或注册顺序认知错误AdviceChain 排序规则

常见问题

能用 @Surgeon 就别先上 DSL

注解式 patch 更稳定,也更容易统一管理。DSL 的重点不是"语法更帅",而是"生命周期能不能动态管理"。

@Splice 一定要明确 proceed 还是 override

忘了不是"默认继续",而是直接触发 ResumeMissing

先把 target 写准,再谈 pattern

更稳的顺序:先把 descriptor/scope/site 写准 → 真不够用再加 InsnPattern → 再复杂的条件用 where 做二次筛选。

Kotlin companion 和 @JvmStatic 不是一条路

你以为自己 patch 到了一个 Kotlin "静态方法",实际命中的可能只是一条路径。这时要记得 @KotlinTarget

@Excise 别当常规手段

LeadTrailSplice 解决的,尽量别直接 Excise

学习顺序建议

  1. 先学 @Surgeon@Lead@Trail
  2. 再学 @Splice,把 Resume 的语义搞明白
  3. 再去看 @Graft@Bypass@Trim
  4. 最后再碰 @ExciseInsnPattern、复杂 Sitewhere
  5. 需要动态启停时,再去学 DSL 的 Scalpel

术语对照表

术语对外理解代码对应
手术 / patch一组对目标方法生效的织入声明Suture
施术者持有注解式 advice 的 object@Surgeon
工作台持有 DSL patch 的 object@SurgeryDesk
现场advice 执行时看到的上下文Theatre
放行继续执行原方法或原指令resume.proceed()
短路不再执行原方法,直接给结果resume.skip() / override()
锚点要插入或替换的字节码位置Anchor / Site
某个 target 下的 advice 顺序集合AdviceChain
调度器运行时按 target 分发 advice 的中心TheatreDispatcher
织入器把 dispatcher 调用写回字节码的组件Scalpel.installWeaver / SiteWeaver