用 java agent 字节码注入,把 Folia 上失效的原版 Bukkit API 补回来。
Folia 为了多线程重写了服务端的传送、重生、调度、世界管理等路径,过程中一批原版 API 变成了 「不触发」或「直接抛异常」。本插件在这些位置注入,让原版事件和原版 API 重新工作 —— 下游插件不需要改一行代码。
所有修复默认关闭。 装上不改变服务端任何行为,需要哪个开哪个。
已在 Folia 26.1.2 上逐项实机验证(见文末「验证状态」)。
- 把
LuminolAddon-版本号.jar放进plugins/ - 在启动命令里加
-javaagent,指向同一个 jar
java -Xmx4G -javaagent:plugins/LuminolAddon-版本号.jar -jar folia.jar nogui第二步不能省。这个 jar 既是插件也是 agent —— 注入必须在服务端类被加载之前完成, 只当普通插件放进去不会有任何效果。
没加时启动日志会明确告警:
[LuminolAddon] 未检测到 java agent,所有修复均不可用。
java -Xmx4G \
"-javaagent:plugins/LuminolAddon-版本号.jar=bypass-folia-check:插件A,插件B" \
-jar folia.jar nogui注意引号。 参数里的
;和,会被 bash / PowerShell 吃掉, 整个-javaagent:...必须用引号包住。
插件名从 Folia 的报错里抄:
Could not load plugin 'LegacyTest v1.0' as it is not marked as supporting Folia!
^^^^^^^^^^
# ── 稳定项 ──
teleport-event:
enabled: true # 原版传送事件
sync-teleport:
enabled: true # 老插件的 entity.teleport(loc)
cross-region-fallback: false
respawn-event:
enabled: true # 原版重生事件
# ── 实验性 ──
bukkit-scheduler:
enabled: true # 老调度器 —— 兼容性的大头,但改动面也最大
entity-ops:
enabled: true # 非属主线程的实体写操作转投属主线程(bukkit-scheduler 的配套)
methods: [] # 留空 = 内置默认名单
command-dispatch:
enabled: true # 跨线程 Bukkit.dispatchCommand
scoreboard:
enabled: true # registerNewTeam / registerNewObjective
# ── 不推荐,需要 Worlds 前置 ──
world-ops:
delegate-create-world: false上面三项(传送 / 同步传送 / 重生)改动面小、语义清晰,可以放心开。
bukkit-scheduler 是实验性的:它接管了整个 BukkitScheduler API 的行为,
影响面比前三项大得多。老插件兼容主要靠它,但也最可能出意外 —— 建议单独开一段时间观察。
delegate-create-world 不推荐开,除非你确实需要运行时建世界,并且接受
世界名和存档位置的差异。
| 老插件在做 | 结果 |
|---|---|
| 定时存档、经济、权限、聊天、数据库同步 | ✅ |
runTask / runTaskLater / runTaskTimer / 异步任务 |
✅ |
cancelTask / callSyncMethod / getPendingTasks |
✅ |
监听 PlayerTeleportEvent / PlayerRespawnEvent |
✅ |
registerNewTeam / registerNewObjective(发光、称号、计分板插件) |
✅ |
同世界近距离 player.teleport(loc) |
✅ 真同步 |
跨世界 teleport() |
false(按 Bukkit 契约,不撒谎) |
| 调度任务里改实体 / 方块 | ❌ 抛异常 |
最后一行是这一档的天花板,也是绝大多数「老插件在 Folia 上不能用」的真正原因。 能不能突破见 disable-thread-checks。
Folia 重写了整条传送路径(Entity#teleportAsync),新路径从未调用过事件 ——
源码里那行 // TODO any events that can modify go HERE 就是空着的坑位。
开启后把原版 PlayerTeleportEvent / EntityTeleportEvent 补回该位置:
- 所有传送路径(指令、末影珍珠、传送门、观察者、API)都会触发
- 可取消 —— 在任何移动发生之前返回,不会留下半吊子状态
- 可改目标,含跨世界
Folia 上 entity.teleport(loc) 直接抛 UnsupportedOperationException。
但并非全都无解:Folia 自己的 teleportAsync 里有一条 same-region 快路径本来就是同步做完的。
开启后「同世界 + 同 region + 目标区块已加载」时 teleport() 真正同步完成,
语义与 Paper 一致(方法返回时传送已发生,事件取消时返回 false)。
cross-region-fallback:跨 region / 跨世界时的行为。
| 值 | 行为 |
|---|---|
false(默认) |
返回 false,传送不发生。插件按契约检查返回值就知道失败 |
true |
退化为异步并返回 true。老插件「看起来能用」,但返回时传送尚未发生 |
true 的坑:
player.teleport(loc);
player.setHealth(20); // 此时玩家可能还在旧位置
Location now = player.getLocation(); // 拿到旧坐标断言不在某个具体方法里,而在 CraftEntity#getHandle:
public Entity getHandle() {
TickThread.ensureTickThread(this.entity, "Accessing entity state off owning region's thread");
return this.entity;
}CraftEntity 内部 113 处调用它 —— 所以几乎每个方法在非属主线程上都会炸,不只是 remove()。
Folia 已经做过一轮分类:getLocation() 之类它认为可容忍的读刻意绕开 getHandle 直接读字段,
走 getHandle 的才是它判定为危险的。本项跟着这个标记走,不重新判断哪些安全。
⚠ 只有 void 方法能转投。 转投意味着立刻返回、活儿下 tick 才干;有返回值的方法要么阻塞等待
(跨 region 死锁)要么编个返回值,两条都不能接受。所以:
entity.setCustomName("x"); // 转投,下 tick 生效
entity.getCustomName(); // 走 getHandle → 照样抛异常崩溃会显著变少,但不会归零。 别期待错。
实测注入 CraftEntity 23 个方法(+CraftPlayer 的覆盖版本),默认转投名单 21 个 ——
排除了 update / readBukkitValues / setPose0 三个服务端内部方法,插件碰不到它们,
纳进来是纯风险。名单可在 entity-ops.methods 里改,不需要重编译。
这是 bukkit-scheduler 的配套项。 触发它的插件代码在原版 Folia 上根本跑不起来 ——
典型如 MythicLib 的全息 Hologram#flyOut:
new BukkitRunnable() {
public void run() {
despawn(); // → Entity::remove ← 崩在这
updateLocation(loc); // → entity.teleport() ← 已被 sync-teleport 覆盖
}
}.runTaskTimer(MythicLib.plugin, 0, period); // 原版 Folia 上直接抛异常是 bukkit-scheduler.enabled 把这个任务恢复成能跑的,它跑在 global tick 线程上却去动
region 的实体。开了老调度器的服务器基本都需要这一项。
开启后:非属主线程的写操作排到实体所属 region 的线程,下 tick 真正执行。实参在注入点装箱带过去, handler 在属主线程上反射重放 —— 不会静默丢弃操作。
代价四条:
| 写不再同步生效 | 方法返回时旧值还在,下 tick 才写入 |
| 写后读不到自己写的值 | 见上面 setCustomName / getCustomName 的例子 |
| 关服 / 卸载时可能漏做 | EntityScheduler 待执行任务会被取消。转投任务挂在 LuminolAddon 名下(而非调用方插件)以缩小窗口 |
| 实体已 retire 时静默丢弃 | 那时本来也没什么可做的 |
同一实体的多次转投共用一个 FIFO 队列,相互之间顺序不变;与非转投调用之间会重排。
热路径代价:注入点拆成两段,判定段零分配(服务端自己 tick 实体时走的就是它), 只有真要转投才装箱实参。
与 disable-thread-checks 的区别:那个是把断言删掉让越界写照样发生(LightingLuminol 的做法),
本项是把操作搬到正确的线程上真正执行。前者静默数据竞争,后者只是延迟一 tick。
Folia 把重生拆成异步两段,重生点在区块加载回调里才算出来,事件从未被调用。
开启后触发原版 PlayerRespawnEvent,位置在 Folia 把重生点写进玩家坐标之前,
所以 setRespawnLocation 完全生效,连改到另一个世界都可以。
已知不足:
missingRespawnBlock恒为falseisBedSpawn由「有重生点且不是重生锚」推导,而非直接读方块(实机验证过床/非床两种情况都正确)
Folia 上 CraftScheduler#handle(所有 runTask* 的最终汇聚点)直接抛异常,
而且方法体已被 javac 当死代码删掉,没有实现可恢复。
本插件在 runTaskTimer / runTaskTimerAsynchronously 两个汇聚点接管,把任务转交给
Folia 自己的调度器(同步 → GlobalRegionScheduler,异步 → AsyncScheduler)。
老调度器的全部公开入口都转调这两处,所以两个注入点覆盖整个 API。
同时接管了 cancelTask / cancelTasks / isQueued / isCurrentlyRunning /
getPendingTasks / callSyncMethod —— 这些方法在 Folia 上实现完好,但查的是
CraftScheduler 自己的表,而我们的任务从没进过那些表。不接管的话 cancelTask(id)
就是个静默空操作:插件以为取消了,任务照跑。
这只恢复「执行」,不恢复「线程安全」。 同步任务跑在 global region 线程上, 而实体 / 区块 / 世界状态归各自的 region 线程管,任务里碰这些东西仍会撞线程断言。
Folia 对 Bukkit.dispatchCommand 有线程要求:发送者是实体时必须在该实体所属的 region 线程,
否则必须在 global 线程。老插件不知道这回事 —— 典型的是在玩家 region 线程里用 console sender
发一条 tellraw(很多本地化库这么干):
Caused by: java.lang.IllegalStateException: Dispatching command async
at RegionizedServer.ensureGlobalTickThread(RegionizedServer.java:141)
at CraftServer.dispatchCommand(CraftServer.java:952)
at Bukkit.dispatchCommand(Bukkit.java:1106)
at ...LocaleManager.sendMessage(LocaleManager.java:259)
开启后不拆那道检查,而是按发送者类型把调用重新投递到正确的线程上 —— 实体走它自己的 region 线程,其它走 global,与 Folia 内部那两道检查一一对应。 重新投递后再次进入 hook 时线程已经对了,直接放行原逻辑,不会无限循环。
disable-thread-checks拦不到这个。RegionizedServer.ensureGlobalTickThread与TickThread.ensureTickThread是两个不同的东西,本插件只动后者。这项要单独开。
代价:需要转发时该调用变为异步,且乐观返回 true —— 那一刻还不知道命令是否存在
(Bukkit 里 false 表示「没有这个命令」,绝大多数调用方不检查它)。
已经在正确线程上时直接放行原逻辑,返回值仍然是真实的。
Folia 把 CraftScoreboard#registerNewTeam 和 #registerNewObjective 换成了直接抛
UnsupportedOperationException —— 记分板是全服共享状态,跨 region 线程访问不安全。
后果是一批插件在 onEnable 里就崩:
java.lang.UnsupportedOperationException
at org.bukkit.craftbukkit.scoreboard.CraftScoreboard.registerNewTeam(CraftScoreboard.java:181)
at io.lumine.mythic.lib.glow.provided.MythicGlowModule.enable(MythicGlowModule.java:39)
开启后这两个方法按 CraftBukkit 原本的语义重新实现,并在 CraftScoreboard 实例上加锁。
读操作(getTeam / getObjective 等)Folia 没禁,保持原样。
思路和 LightingLuminol 一致(加锁而不是禁用),但实现不同:它直接改源码,删掉那句
throw 再给方法加 synchronized;我们改不了源码,而 javac 已经把 if (true) throw 之后
的方法体当死代码删光了(编译产物里各自只剩 4 条指令,没有可恢复的实现)。所以字节码只
负责在方法开头短路转发,实现在插件侧重写,ACC_SYNCHRONIZED 由注入时补上。
这只让注册不再抛异常,不代表记分板在多线程下就完全安全。 NMS 侧的记分板结构本身 没有为并发设计,本插件只把最常见的注册路径串行化了。从多个 region 线程高频改同一个 记分板仍然可能出问题。
Folia 上 Bukkit.createWorld 直接抛异常,方法体同样在编译期被删。
装了 Worlds 插件时可以把调用转交给它执行。
两处与原版 Bukkit 的差异,开之前必须知道:
- 世界名会带命名空间前缀。
createWorld(new WorldCreator("foo"))返回的世界实际叫minecraft_foo,之后Bukkit.getWorld("foo")拿不到 —— 请用createWorld的返回值。 - 存档位置是
<存档>/dimensions/<命名空间>/<名称>,不是./<名称>。
固定行为(不可配置):
- 带自定义
ChunkGenerator/BiomeProvider的WorldCreator会被拒绝而不是降级 —— 悄悄丢掉生成器会造出地形完全不对的世界,比直接失败糟糕得多 - 绝不触发 Worlds 的 legacy 文件夹迁移,
createWorld不会搬动任何已有存档
必须在 global region 线程调用:
Bukkit.getGlobalRegionScheduler().execute(plugin, () -> Bukkit.createWorld(creator));有两项不在 config.yml 里,只能写在启动参数上 —— 它们要在插件加载期 / 类加载期
就生效,那时本插件的 onEnable 还没跑。多条指令用 ; 分隔:
"-javaagent:plugins/LuminolAddon-版本号.jar=bypass-folia-check:插件A,插件B;disable-thread-checks"bypass-folia-check:插件A,插件B 只对列出的插件绕过
bypass-folia-check 对所有插件绕过(不推荐)
Folia 会拒绝加载 plugin.yml 里没写 folia-supported: true 的插件。开启后这些插件能装上。
实现上是接管 isFoliaSupported() —— Folia 那三处门禁
(PaperPluginProviderFactory / SpigotPluginProviderFactory / CraftMagicNumbers)
都查这一个方法,所以一处就全开了。
风险:这道门禁存在的意义就是挡住会在多线程下出错的插件。绕过之后它们仍然不是线程安全的。 建议按插件名单开。
关掉 Folia 的线程断言(TickThread#ensureTickThread 的全部 8 个重载)。
同一个老插件从错误线程改实体的实测对照:
不开: zombie.setCustomName("x") -> 抛 IllegalStateException
开了: zombie.setCustomName("x") -> 「成功」
两次执行的操作完全一样 —— 都是从非属主线程写 region 私有的数据结构。 区别只在于第二次没人告诉你。Folia 的实体表和区块表是按 region 分的、不是并发容器, 从别的线程写进去就是真损坏,表现为物品复制 / 实体消失 / 区块回滚,而且没有日志。
另一组实测更能说明问题 —— 开启后各操作的实际结果:
[通过] 改实体状态 setCustomName()
[通过] 读实体 getLocation()
[拦住] 生成新实体 spawnEntity() -> NullPointerException ← 注意异常类型
[通过] 移除实体 remove()
spawnEntity 挂在 NPE 而不是 Folia 那句清楚的 "not under this region's control" ——
说明断言拿掉之后,代码被带进了 Folia 自己都没预期的状态。失败模式从「明确拒绝」
退化成了「随机空指针」。
还要知道:即使不开这个开关,「读」操作大多也不触发断言。 实测从错误线程读一个
removed=UNLOADED_TO_CHUNK 的实体,getLocation() 照样返回了看起来正常的坐标。
静默出错的路径本来就存在,断言是仅剩的、能让你发现这件事的东西。
实现上只动 ensureTickThread(纯断言),不动 isTickThreadFor ——
后者参与 Folia 自己的逻辑判断,比如传送的 same-region 快路径:
if (destination == this.level() && isTickThreadFor(destination, chunkX, chunkZ, 8)) {
entity.root.teleportSyncSameRegion(...); // 恒为 true = 跨 region 直接同步改状态,必坏
}先不开 disable-thread-checks 跑一周。
把日志里的 IllegalStateException: Thread failed main thread check 收集起来 ——
那些就是真正不兼容的插件和具体位置。拿到这份清单之后才有依据判断:
是找替代品、是提 issue、还是这个插件的问题小到可以接受风险。
先开断言开关就等于把这份清单擦掉了,后面出问题连从哪查都不知道。
出现物品复制 / 实体消失 / 区块回滚:
- 关掉
disable-thread-checks - 复现
- 有异常了 → 异常栈直接指向罪魁插件
- 还是没异常 → 大概率跟本插件无关
| 原因 | |
|---|---|
unloadWorld |
Bukkit 契约是同步返回 boolean,与 Worlds 的异步实现对不上。用 WorldsAccess#unload |
createWorld 带自定义生成器 |
WorldCreator 携带的是 ChunkGenerator 实例,反推不出 Worlds 需要的「插件:id」形式 |
| 让碰世界状态的老插件真正线程安全 | 只有插件自己知道它要碰哪个实体,而且是碰的那一刻才知道 —— 调度时推导不出正确线程。这只能改插件源码 |
WorldLoadEvent 在 Folia 上本来就没坏,不需要本插件做任何事。
全部在真实 Folia 上验证,不是仅靠单元测试。
| 修复 | 注入点 | 字节码自检 | 实机验证 |
|---|---|---|---|
| 传送事件 | Entity#teleportAsync |
✅ | ✅ 10/10(触发 / 取消 / 改目标) |
| 同步 teleport | CraftEntity + CraftPlayer#teleport0 |
✅ | ✅ 含「返回时已在新坐标」 |
| 重生事件 | ServerPlayer#respawn + waiter lambda |
✅ | ✅ 真玩家,床 / 非床两种路径 |
| 世界创建 | CraftServer#createWorld |
✅ | ✅ 4/4 |
| 老调度器 | CraftScheduler#handle + RegionizedServer#globalTick |
— | ✅ 9/9 + cancelTask / callSyncMethod |
| 实体写操作转投 | CraftEntity / CraftPlayer 各 void 写操作 ×23 |
✅ + 真实字节码 verifier 575/575 | 待实机 |
| 命令派发 | CraftServer#dispatchCommand |
✅ | ✅ 开关前后对照,命令确实执行 |
| 记分板注册 | CraftScoreboard ×2 |
✅ | ✅ 队伍 / 目标 / 重名报错 / 非 global 线程 |
| folia-supported 绕过 | isFoliaSupported ×2 |
✅ | ✅ 真实老插件加载成功 |
| 线程断言开关 | ensureTickThread ×8 |
✅ | ✅ 开关前后行为对照 |
| 版本 | 结果 |
|---|---|
| Folia 26.1.2 | 11 处注入点全命中,端到端 10/10 + 调度器 4/4,含真玩家重生验证 |
| Folia 1.21.11 | 11 处注入点全命中,端到端 10/10 + 调度器 4/4 |
| Canvas 26.1.2 | 注入点全命中(Canvas 基于 Luminol,注入点与 Folia 一致) |
两个版本的子计数完全一致(调度器汇聚点 2 / 查询 6 / ensureTickThread 重载 8),
说明这两条线上核心结构没有分叉。
仓库自带 tools/VerifyAll.java,可对任意 Folia jar 一次性检查全部注入点,不需要起服务器:
java -cp LuminolAddon-版本号.jar:. VerifyAll <server.jar> <api.jar>注入代码里手写了 stack map frame 和跳转,写错的表现是服务端启动时 VerifyError 直接崩。
自检把注入跑在桩类上真实 define 并调用:
java -cp build/libs/LuminolAddon-版本号.jar com.dirges.LuminolAddon.agent.SelfCheck系统属性开启,平时零开销:
-Dluminoladdon.selftest=true # 传送事件端到端(用僵尸,不需要客户端)
-Dluminoladdon.schedtest=true # 老调度器
-Dluminoladdon.worldtest=true # createWorld 转交
-Dluminoladdon.respawntest=true # 重生事件(需要真玩家,进服 5 秒后自动击杀)这套东西建议保留。 字节码自检、JVM 校验、对真 jar 验证注入命中 —— 这三层都绿的情况下,
本插件曾经在真实服务器上完全没有工作(两个 classloader bug:handler 注册进了错的副本;
注入点根本看不见 EventBridge)。只有真跑才暴露。
它被 agent 用 appendToBootstrapClassLoaderSearch 装在 bootstrap classloader 上。
没有这一步,注入进 NMS 的 INVOKESTATIC EventBridge.onTeleport 在运行时会
NoClassDefFoundError —— Paper 的服务端 classloader 是个只覆盖服务端 jar 与 libraries 的
URLClassLoader,不会向 app classloader 委派我们的包。
bootstrap 看不见 NMS / Bukkit,所以 EventBridge 的 handler 字段必须用
java.util.function.Function 而不是自定义接口 —— 自定义接口的 Class 身份绑在定义它的
classloader 上,跨过去赋值会 IllegalArgumentException。
插件侧通过 AgentLink 反射访问,绝不能直接写 EventBridge.xxx = ... ——
那会写进插件 classloader 里的另一份副本,编译得过、跑起来也不报错,但注入完全空转。
- 服务端:Folia(26.1.2 与 1.21.11 均已实测,两者结构一致)
- 下游分支(Luminol / DeerFolia 等)理论可用,但未实测 —— 用
VerifyAll自查 - 编译目标:Java 21 / paper-api 1.21.11
- 可选软依赖:Worlds 4.3.0-pre1+(仅
delegate-create-world需要)
注入点按「方法名 + 参数特征」匹配而非写死完整描述符,跨小版本有一定容忍度。 命中失败时会在启动日志里逐项报告,并保留原字节码 —— 不会让服务端起不来。