我在 Luminol 上适配插件踩过的坑
配套阅读:Folia / Luminol 插件适配完全指南。那篇是工具书,这篇是过程。
一、起因:我想要一个不卡的服务端
事情是从搭私服开始的。
我想给自己搞一个能长期跑下去的 Minecraft 服务端。要求不高的:人多一点不卡,能装我常用的插件,能挂假人保区块。
Paper 用了很多年,单线程那套瓶颈我也认了。直到听说 Folia——PaperMC 官方做的区域化多线程分支,把世界切成区域,每个区域独立线程并行 tick。
听起来是正解。上了之后也确实快。
然后插件全炸了。
我才意识到一个很基本的事实:Folia 没有主线程了。
传统 Bukkit 插件的世界里,"主线程"是个默认存在的东西。Bukkit.getScheduler().runTask(plugin, ...) 的意思是"丢到主线程去跑"。但 Folia 里每个区域就是自己的主线程——那么问题来了:这个任务该去哪个区域?
调度器答不上来,所以它直接抛异常。
这一句话,就是我后面所有工作的根源。
二、坑一:那 17 个 BukkitRunnable
第一个撞上的是 TrollPlus(一个恶作剧插件,冻结、击飞、刷怪、TNT 追踪这些整蛊功能)。它在 Luminol 上要么直接报错,要么行为诡异。
翻代码,一眼就看到了问题:
new BukkitRunnable() {
public void run() { ... }
}.runTaskTimer(plugin, 0, 20);
17 处。 分布在 InventoryClickListener(11 处)、ProjectileLaunchListener(5 处)、ControlHelper(1 处)。
怎么改
Folia 提供四个调度器,关键是选对:
| 调度器 | 什么时候用 |
|---|---|
GlobalRegionScheduler | 全局操作(配置、广播) |
RegionScheduler | 按位置执行(方块操作) |
EntityScheduler | 按实体执行(玩家效果) |
AsyncScheduler | 纯异步(HTTP、IO) |
TrollPlus 那些定时任务几乎都是"对某个玩家持续施加效果",所以全绑到了 EntityScheduler。
// 改之前
new BukkitRunnable() {
public void run() { target.addPotionEffect(...); }
}.runTaskTimer(plugin, 0, 20);
// 改之后(注意多出来的 null)
target.getScheduler().runAtFixedRate(plugin, task -> {
target.addPotionEffect(...);
}, null, 0L, 20L);
这里有个当时让我卡了十分钟的细节:EntityScheduler 的所有方法都比别的调度器多一个 retired 参数,实体失效时回调,不需要就传 null。少写这个参数编译不过,但报错信息不会告诉你"你少了一个参数",只会说找不到匹配的方法。
选 EntityScheduler 而不是 RegionScheduler 还有个额外好处:任务绑定在实体上,玩家跨区域移动时任务会自动跟着走。如果用 RegionScheduler 绑位置,玩家跑远了任务就留在原地对着空气施法了。
顺手修的上游 bug
改的过程中翻出来五个上游的小毛病,一并修了:
SLOWLY_KILL_PERIOD的配置路径写成了trol.slowly-kill-period(少了一个 l),导致这个配置项从来没生效过,永远走默认值- Inventory Shuffle 功能名叫"洗牌",实现是数组反转——那是翻转不是洗牌
target.getPlayer()被重复调用- 中文 locale 的 key 和英文对不上(
configuration-outdatedvsconfig-outdated、lighting-boltvslightning-bolt),导致部分消息直接显示 "Message not found" serverVersion被解析成 double,没法区分 1.20 和 1.20.6
最后一条特别典型:用 double 存版本号,1.20 和 1.20.6 会变成同一个数。
三、坑二:编译过了,运行时报 NoClassDefFoundError
TrollPlus 改完之后我以为摸到门道了,接着去做另一个项目——一个把客户端 CAD 编辑器 Mod 接到服务端的桥接插件。
代码写完了,编译通过,丢进服务器。
NoClassDefFoundError。
查了半天才搞明白,这是个非常隐蔽的坑:
Paper API 编译出来的 JAR,在 Luminol 上可能直接加载失败。
原因是 Luminol 用的是 Folia 定制的 PluginClassLoader,配合 Paper 的反射重写器和区域化多线程的类加载机制。用 Paper API 编译出的字节码,在 Luminol 的类加载器里解析不了。
解法是把编译依赖换掉:
// ❌ 会炸
compileOnly "io.papermc.paper:paper-api:26.2.build.48-alpha"
// ✅
compileOnly "me.earthme.luminol:luminol-api:1.21.11-R0.1-SNAPSHOT"
并且不能同时声明 spigot-api——luminol-api 已经包含全部 API,两个一起声明会触发 Gradle capability 冲突。
这条经验后来被我写进了那个项目的 README 开头,加了个 ⚠️。
这个坑最难受的地方在于:它在编译期完全不报错。 你会以为一切正常,直到把 JAR 丢进服务器。
四、坑三:假人被登录插件拦在了门外
接下来是假人插件。
服务器上跑着假人(用来保区块加载、维持刷怪),同时跑着 AuthMe(登录认证插件)。结果假人一出生就被 AuthMe 拦下来要求登录——可它没有客户端,登不了。
更要命的是 AuthMe 会尝试把未登录的玩家传送到出生点,而这一步在 Folia 上会出问题。
关键是找到一个可靠的识别方式
我一开始想靠玩家名判断,但假人的名字是可以配的,不可靠。
翻了假人插件的源码之后发现了一个更好的东西:FPP 给假人分配的 UUID 是确定性的,高 32 位固定是 0xFB070000。
/**
* FPP assigns deterministic fb07-prefixed UUIDs to all bots — the high 32 bits are
* always 0xFB070000. This is the single source of truth used by every AuthMe path
* that needs to skip a player.
*/
这比看名字靠谱得多——UUID 是跨核心、跨配置都稳定的。
于是给 ValidationService 加了一个 isUnrestricted(name, uuid),在原有的白名单之外,多认这一条:
public boolean isUnrestricted(String name, UUID uuid) {
return UNRESTRICTED_NAMES.contains(name.toLowerCase())
|| isFppBot(uuid); // 高 32 位 == 0xFB070000
}
但只认 UUID 还不够。服务器上其实跑着两个不同作者写的假人插件,另一套用的是 metadata 方案(fakeplayer:spawned_at)加 PersistentDataContainer。所以最终判定是双通道的:UUID 段认一套,metadata 认另一套。
这两条线最后串到了 PlayerListener、AsynchronousJoin、ListenerService、AsynchronousQuit 等一大串地方——凡是"需要跳过假人"的路径,都得过同一个判定函数。
教训是:判定逻辑必须收敛到一个地方。 如果我在七八个文件里各写各的 startsWith("fb07"),改一次要改八处,漏一处就是一个玄学 bug。
五、坑四:Folia 上的传送是"发了就不管"
给假人开完路之后,真玩家那边又出问题了:偶尔有人在登录后卡在原地,不动,也不报错。
查下去发现是 Folia 的传送语义变了。
在 Bukkit 上,teleport() 是同步的——调用返回时人已经在新位置了。而 Folia 上必须用 teleportAsync(),它返回一个 CompletableFuture,传送是异步完成的。
问题出在这中间的空窗期:玩家已经被移出当前世界,但还没加入目标世界。如果这时候有代码去操作他,会静默失败。
具体到 AuthMe,是 performJoin 在传送中途执行了。修法很直接——把传送后的操作延后 10 tick:
// 等 teleportAsync 真正落地再执行
scheduleSyncDelayedTask(() -> teleportNewPlayerToFirstSpawn(player), 10);
10 tick 是半秒。这个数字不优雅,但它是在异步边界上做同步假设的代价——Folia 没有给你"传送完成了"的回调,你只能等一个够长的时间。
我至今不确定 10 是不是最优值。但在一个玩家数不多的服上,它够用了。
六、坑五:编译时和运行时的 API 不是同一个版本
还有一个错得很典型的:某个插件编译时依赖的 CraftEngine 是 0.0.67,而服务器上跑的是 26.5.1。API 签名变了,运行时就 NoSuchMethodError。
第一版我试着在 pom 里排除掉几个编译不通过的文件——结果引入了更严重的问题:被排除的文件里有一个是经济桥接的 provider,它没了之后 EconomyBridge.api 变成 null,所有涉及经济的操作全部 NPE。
一个 bug 换一个更隐蔽的 bug。
第二版改成了正确的做法:用纯反射,编译期一个 CraftEngine 类都不引用。
// 类加载时一次性解析,编译期零依赖
private static final Method BY_ID_METHOD = resolve("...CustomItem", "byId");
private static final Method BUILD_METHOD = resolve("...CustomItem", "buildBukkitItem");
这样做的额外好处是,它同时兼容了新旧两代 API——buildBukkitItem() 和 buildItemStack() 都做探测,两个版本都能跑。
改完之后我做了件自己觉得挺重要的事:用 javap 去验证常量池。
常量池中 CraftEngine 类引用:无 ✅
Class.forName 调用:2 处 ✅
buildBukkitItem / buildItemStack:均有探测 ✅
"我改好了"和"我证明我改好了"是两件事。 尤其在字节码这个层面,肉眼是看不出来的。
七、然后我写了个探针
坑踩到第四个的时候,我意识到一个更根本的问题:我根本不知道当前这个核心有哪些 API。
Folia 的文档不全,Luminol 是社区分支文档更少。每次写代码我都在猜"这个方法存在吗",然后靠编译报错来验证——效率极低。
所以我写了个插件,就叫 ServerAPIProbe,干一件事:用反射把当前服务端全部 API 类和方法签名 dump 成一份 markdown。
思路不复杂:
- 显式类清单:硬编码 30 多个关键类(四大调度器、
RegionizedServer、TickRegionScheduler等) - 整包扫描:反射枚举
io.papermc.paper.threadedregions包下的所有类——这样能发现清单里没写的新 API - 分级失败标注:区分
ClassNotFoundException(类不存在)和NoClassDefFoundError(类存在但依赖缺失) - Folia 专项:把
Bukkit上名字含Scheduler/Region/Owned的静态方法单独列一节
有个细节挺有意思:探针插件自己必须先适配 Folia,才能探测 Folia。 所以它的 onEnable 里第一件事就是判平台:
if (isFolia()) {
Bukkit.getGlobalRegionScheduler().run(plugin, task -> dump());
} else {
Bukkit.getScheduler().runTaskAsynchronously(plugin, this::dump);
}
写探针的过程本身,就是一次 Folia 适配的实战演练。
前面那篇适配指南里所有的"实测签名",都是它采出来的。
八、还有两个不那么"技术"的坑
CoreProtect 的中文化。 这个插件默认语言是英文,方块名全英文。我加了一份 1500 多条的中英映射,并且把它做成了四级回退加载:
① 磁盘外部文件 plugins/CoreProtect/lang/blocknames_zh-cn.yml(最可靠,可现场改)
② 线程上下文类加载器读 JAR 内 lang/blocknames_zh-cn.yml
③ 插件类加载器
④ MaterialUtils.class.getResourceAsStream("/lang/...")
四级全部静默降级。为什么要这么麻烦?因为运维现场经常是没法重新打包的——把外部文件放在第一位,意味着服务器管理员可以随时覆盖翻译,不用等我发版。
FPP 的国内构建。 有个插件(FPP,一个功能很完整的假人插件)在国内根本构建不起来,因为它的构建链要拉 paperweight 的反编译产物。我加了阿里云 Maven 镜像 + 本地预热仓库,还得手写补丁解决 paperweight 2.0.0-beta.21 没把 paper-api 挂到 compile classpath 的问题。
这类坑不上台面,但它决定你能不能开工。
九、回头总结
整理一下,这轮适配真正有价值的三条经验:
第一,先判断你的代码属于哪一类。
不是所有插件都需要改。命令执行、配置读取、GUI 操作、权限检查、bStats 统计——这些完全不用动。真正要改的只有两件事:调度和传送。搜 Bukkit.getScheduler、BukkitRunnable、.teleport( 这三个关键词,五分钟就能估出工作量。
第二,编译时的 API 必须和目标核心对齐。
这条是血泪。用 Paper API 编译给 Luminol 用,代码写对了也白搭。
第三,判定逻辑要收敛。
假人识别那件事,如果我允许自己在八个文件里各写一遍,这个项目现在已经是不可维护的了。
最后说句实话:这些坑没有一个是"难"的。
它们全都是"不知道"。知道了之后,每一处的修法都不超过十行代码。
真正花掉时间的,是从报错信息倒推回原因的那段路——NoClassDefFoundError 不会告诉你"你该换编译依赖",IllegalArgumentException: Delay ticks may not be <= 0 也不会告诉你"Folia 里 delay=0 要用 run()"。
所以我把它们写下来了。希望下一个人的那段路能短一点。
评论
本站已关闭评论。

