Skip to content

About

取Luminol精华 为开源社区做出自己的贡献

Resources

Stars

6 stars

Watchers

0 watching

Forks

Repository files navigation

LuminolAddon

用 java agent 字节码注入,把 Folia 上失效的原版 Bukkit API 补回来。

Folia 为了多线程重写了服务端的传送、重生、调度、世界管理等路径,过程中一批原版 API 变成了 「不触发」或「直接抛异常」。本插件在这些位置注入,让原版事件和原版 API 重新工作 —— 下游插件不需要改一行代码。

所有修复默认关闭。 装上不改变服务端任何行为,需要哪个开哪个。

已在 Folia 26.1.2 上逐项实机验证(见文末「验证状态」)。


安装

  1. 把 LuminolAddon-版本号.jar 放进 plugins/
  2. 在启动命令里加 -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!
                       ^^^^^^^^^^

config.yml

# ── 稳定项 ──
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。


各项修复

teleport-event —— 传送事件

Folia 重写了整条传送路径(Entity#teleportAsync),新路径从未调用过事件 —— 源码里那行 // TODO any events that can modify go HERE 就是空着的坑位。

开启后把原版 PlayerTeleportEvent / EntityTeleportEvent 补回该位置:

  • 所有传送路径(指令、末影珍珠、传送门、观察者、API)都会触发
  • 可取消 —— 在任何移动发生之前返回,不会留下半吊子状态
  • 可改目标,含跨世界

sync-teleport —— 同步 teleport()

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(); // 拿到旧坐标

entity-ops —— 实体写操作转投

断言不在某个具体方法里,而在 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。

respawn-event —— 重生事件

Folia 把重生拆成异步两段,重生点在区块加载回调里才算出来,事件从未被调用。

开启后触发原版 PlayerRespawnEvent,位置在 Folia 把重生点写进玩家坐标之前, 所以 setRespawnLocation 完全生效,连改到另一个世界都可以。

已知不足:

  • missingRespawnBlock 恒为 false
  • isBedSpawn 由「有重生点且不是重生锚」推导,而非直接读方块(实机验证过床/非床两种情况都正确)

bukkit-scheduler —— 老调度器

Folia 上 CraftScheduler#handle(所有 runTask* 的最终汇聚点)直接抛异常, 而且方法体已被 javac 当死代码删掉,没有实现可恢复。

本插件在 runTaskTimer / runTaskTimerAsynchronously 两个汇聚点接管,把任务转交给 Folia 自己的调度器(同步 → GlobalRegionScheduler,异步 → AsyncScheduler)。 老调度器的全部公开入口都转调这两处,所以两个注入点覆盖整个 API。

同时接管了 cancelTask / cancelTasks / isQueued / isCurrentlyRunning / getPendingTasks / callSyncMethod —— 这些方法在 Folia 上实现完好,但查的是 CraftScheduler 自己的表,而我们的任务从没进过那些表。不接管的话 cancelTask(id) 就是个静默空操作:插件以为取消了,任务照跑。

这只恢复「执行」,不恢复「线程安全」。 同步任务跑在 global region 线程上, 而实体 / 区块 / 世界状态归各自的 region 线程管,任务里碰这些东西仍会撞线程断言。

command-dispatch —— 跨线程命令派发

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 表示「没有这个命令」,绝大多数调用方不检查它)。 已经在正确线程上时直接放行原逻辑,返回值仍然是真实的。

scoreboard —— 记分板注册

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 线程高频改同一个 记分板仍然可能出问题。

world-ops.delegate-create-world —— 世界创建

Folia 上 Bukkit.createWorld 直接抛异常,方法体同样在编译期被删。

装了 Worlds 插件时可以把调用转交给它执行。

两处与原版 Bukkit 的差异,开之前必须知道:

  1. 世界名会带命名空间前缀。 createWorld(new WorldCreator("foo")) 返回的世界实际叫 minecraft_foo,之后 Bukkit.getWorld("foo") 拿不到 —— 请用 createWorld 的返回值。
  2. 存档位置是 <存档>/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

bypass-folia-check:插件A,插件B    只对列出的插件绕过
bypass-folia-check                对所有插件绕过(不推荐)

Folia 会拒绝加载 plugin.yml 里没写 folia-supported: true 的插件。开启后这些插件能装上。

实现上是接管 isFoliaSupported() —— Folia 那三处门禁 (PaperPluginProviderFactory / SpigotPluginProviderFactory / CraftMagicNumbers) 都查这一个方法,所以一处就全开了。

风险:这道门禁存在的意义就是挡住会在多线程下出错的插件。绕过之后它们仍然不是线程安全的。 建议按插件名单开。

disable-thread-checks

关掉 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、还是这个插件的问题小到可以接受风险。

先开断言开关就等于把这份清单擦掉了,后面出问题连从哪查都不知道。

出问题时

出现物品复制 / 实体消失 / 区块回滚:

  1. 关掉 disable-thread-checks
  2. 复现
  3. 有异常了 → 异常栈直接指向罪魁插件
  4. 还是没异常 → 大概率跟本插件无关

做不到的

原因
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)。只有真跑才暴露。

为什么 EventBridge 只能引用 java.*

它被 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 需要)

注入点按「方法名 + 参数特征」匹配而非写死完整描述符,跨小版本有一定容忍度。 命中失败时会在启动日志里逐项报告,并保留原字节码 —— 不会让服务端起不来。

About

取Luminol精华 为开源社区做出自己的贡献

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages