NMS调用
尽管有点像“祝福”,但 NMS 是 net.minecraft.server 包的简称
新手教程写起来麻烦,毕竟要考虑新手都掌握了什么知识,这点比较麻烦。先写点高级教程,讲解一些东西的做法。
对于已经写 Bukkit 有一段时间的人编写的高级教程,按照以下步骤,了解并使用来自 Minecraft 原版的功能,从更底层的方面来实现更高级的功能。
# 什么是 NMS
NMS 全称 net.minecraft.server
包,是早在 1.17 以前(其实要更久,非常久)就形成的概念。当时 Minecraft 原版服务端的类都被混淆,给 remap 并 relocate 平铺到了类似 net.minecraft.server.v1_xx_Rx
格式的包中,为了方便,大伙们约定俗成地将其简称为 NMS。从 1.17 开始官方放出了 mojang mapping 混淆表,并且把 relocate 给取消了。上述的那些类,现在不在 net.minecraft.server
里面了,而是在 net.minecraft
里面,有层次地排列。它们本质上都是一样的东西,所以我们依然将它叫作 NMS。
不是所有插件都需要自己调用 NMS,亲爱的 CraftBukkit (opens new window) 已经帮你包装过 NMS 了,它将原版混淆过的服务端包装成 Bukkit 接口,其它衍生服务端(Spigot、Paper、Folia 等)再基于它,包装更多的原版接口,甚至修改原版服务端逻辑。所以你平常写的插件,都是通过 Bukkit 接口去访问 NMS 的。有 SpigotMC 社区在给你兜底,你几乎不需要去考虑不同游戏版本的接口变动问题,他们会尽自己所能保证 Bukkit 的接口不会改变。
正如之前所说,CraftBukkit 已经帮我们包装过 NMS 了,那么插件主动去调用 NMS 的动机就非常明显了,那就是 CraftBukkit、Spigot、Paper、Folia 做得都不够全面,也不可能做得全面。总有一些功能是仅使用包装过的接口无法实现的,比如获取或设置一个数值(最常见的需求)、重新实现一个接口用来骗过服务端(NPC插件),等等。这些没有被服务端核心探索过的东西,就有可能成为一些令人惊叹的插件的实现方法。
# 准备依赖
在十几年前,引入 NMS 依赖是非常简单的一件事,直到服务端核心圈子里出现了一件大事 (opens new window),就再也不存在比较官方的依赖分发了。
直到目前,有以下几种常见的获取 NMS 依赖方法
- 通过 BuildTools (opens new window) 构建,自动发布到本地仓库
- 添加本地 jar 依赖(从服务端核心的
META-INF/versions/spigot-xxx.jar
获得,或者用 BuildTools 构建后在本地仓库获得) - 使用 paperweight (opens new window),在开发者侧自动构建服务端核心并反混淆,在编译插件时自动重新混淆 NMS 部分
- 使用网络依赖(见后文)
四种方法都有缺点
- BuildTools 需要开发者另外下载,而且构建时间不短,构建一次就要了老命,因为这个特点,并不适合在 Github Actions 之类的自动构建系统使用(虽然 Actions 也能配置保存和读取缓存,但是麻烦)。
- 添加本地 jar 作为依赖的缺点显而易见,对于现代的包管理器(Gradle、Maven 等)来说,这是最不优雅的依赖引用方法,而且还要传一个几十兆大小的文件到仓库。方便,但非常不优雅,也占项目地方。
- 使用 paperweight 其实就相当于自动运行 BuildTools,它用起来非常方便,也非常优雅。但你要知道,虽然它不是 BuildTools,但也要跟 BuildTools 一样跑下载服务端、拉取仓库、反编译、应用 patch 等等流程的,这跑起来就非常地慢。更别说一个插件很有可能要跨非常多的版本做支持,我通常都是做 1.8 到最新版支持的,这起码十几二十个版本(1.8-1.19 的最新子版本 12 个,还有 1.20 的 R1 到 R4,1.21 的 R1 到 R4 一共 8 个版本,总共 20 个版本)。都要跑一遍 paperweight 我都能写一个新插件了,太慢了,所以不推荐使用。
- 使用网络依赖似乎全都是优点,速度只跟网络挂钩,所有内容都预构建好了只需要下载引用就行了。但是你猜为什么在十几年前引入 NMS 依赖是非常简单的事,就是因为发到了自家的 maven 仓库。所有分发了 NMS 依赖的仓库都是第三方仓库,没有吃 DMCA 的风险也有停止运营风险。
综上,暂时比较推荐的方案是,通过网络仓库来获取 NMS 依赖,如果真的出事了,就使用 BuildTools 构建。
目前我收集到以下几个可用性比较高的 maven 仓库
- RoseWoodDev (opens new window) 接手 PlayerPoints 那个组织的公开仓库,国内速度稍快,只有 1.8+ 的依赖
- CodeMC (opens new window) 一个开源社区的公益仓库,国内速度时快时慢,有最低 1.7.2 的依赖
- lumine (opens new window) 开发 MythicMobs 那个组织的公开仓库,速度还行,有最低 1.6.4 的依赖,但是命名有点怪怪的,依赖也没那么全,只能凑合着用
NMS 依赖通常会在以下两个包里,任选一个即可
- 1.8 开始基本都用
org.spigotmc:spigot
(注意不是 spigot-api,spigot-api 不带 NMS) - 比较旧的版本会用
org.bukkit:craftbukkit
,当然,现在到最新的版本也有分发 craftbukkit 依赖
# 配置项目
核心思想是,做一个通用的接口(interface
),然后针对每一个 Minecraft 服务端版本都做一个子模块,去实现这个接口,在这些实现中调用 NMS 包的方法和字段,与原版内容交互。
然后在插件加载时,根据服务端版本去反射寻找实现,找得到就继续正常加载,找不到就打印警告并卸载插件。
好,“根据服务端版本”这么小小的一句话,因为各方势力的割据,在插件开发者这里造成了不小的麻烦。Spigot 的 Bukkit.getServer()
实现始终都是 org.bukkit.craftbukkit.v1_xx_Rx.CraftServer
,所以我们只要获取它的包名,就能获取当前服务器的 NMS 版本,即 v1_xx_Rx
,比如 v1_19_R3
就是 1.19.4
(要注意的是 NMS 版本和游戏版本没有必然关系,它们的对应关系也没文档可以查,只能靠实际经验)。
但是在 Paper 高版本的某一个版本起,他们的 Bukkit.getServer()
实现在 org.bukkit.craftbukkit.CraftServer
!你要是用这个方法获取,是包获取不到 NMS 版本的。当然,在这个版本起,Paper 也会自动处理要加载插件,把一些 NMS 访问给自动修正,也就是把调用包名的 v1_xx_Rx
给删掉。如果你是直接调用而没有使用反射调用,不需要担心它的修改。
所以,使用 Bukkit.getServer()
方法获取不到版本(报错)的时候,要换成 Bukkit.getVersion()
的方法,获取 Bukkit 版本最前面出现的游戏版本即可。
这里就不多讲代码了,展开讲要写很长,感兴趣的话可以看我的仓库 (opens new window)。
为了方便起见,以下教学使用我编写的 NMS 项目模板。
# 1.复制模板
克隆仓库
git clone https://github.com/MrXiaoM/nms-template.git
将仓库的 buildSrc
文件夹复制到项目目录,但如果你的项目已经有 buildSrc
了,只需复制其中的 ExtraHelper.kt
即可。有它之后,可以减少很多工作量。
将仓库的 nms
文件夹复制到项目目录。
# 2.添加子项目
编辑 settings.gradle.kts
,在结尾添加以下代码
// 如果在子项目中,比如 ./bukkit/nms,请使用 :bukkit:nms
include(":nms")
// 如果在子项目中,比如 ./bukkit/nms,请使用 bukkit/nms
File("nms").listFiles()?.forEach { dir ->
// 这里已经隐含了 dir 必须是一个文件夹,因此无需额外判断
if (File(dir, "build.gradle.kts").exists()) {
// 同上
include(":nms:${dir.name}")
}
}
2
3
4
5
6
7
8
9
10
# 3.配置依赖
在需要引用 nms
的项目构建脚本 build.gradle.kts
中添加以下内容
// 新建 shadowLink 配置
configurations.create("shadowLink")
dependencies{
// ... 其它依赖
// NMS 接口以及实现
for (nms in project.project(":nms").also {
implementation(it)
}.subprojects) {
if (nms.name == "shared") implementation(nms)
if (nms.name.startsWith("v")) add("shadowLink", nms)
}
}
tasks {
shadowJar {
archiveClassifier.set("") // 个人习惯
// 添加 shadowLink 配置到打包任务,不在代码进行依赖引用,单纯打包 NMS 实现进去,即可杂交编译目标
configurations.add(project.configurations.getByName("shadowLink"))
// 将 top.mrxiaom.example 换成你自己的包
relocate("nms.impl", "top.mrxiaom.example.nms")
}
build { dependsOn(shadowJar) } // 个人习惯
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 4.进行设置
打开 nms/build.gradle.kts
,文件开头有一点配置项
val targetJavaVersion = 8
val source = NMSSource.RoseWoodDev
val sharedSpigotAPI = "1.21"
2
3
比较重要的是 NMSSource
,即 NMS 依赖来源。目前可以使用 RoseWoodDev
或者 CodeMC
,如果你想的话,在文件结尾还可以添加更多依赖源。
# 5.最终使用
以下代码写进插件主类
@Override
public void onEnable() {
// 初始化 NMS,失败时卸载插件
if (!Versions.init(getLogger())) {
Bukkit.getPluginManager().disablePlugin(this);
return;
}
// ...你自己的插件加载逻辑
}
2
3
4
5
6
7
8
9
public void foo() {
// 需要使用的时候,通过 Versions 来获取自己放出的 NMS 接口
ILivingEntity nms = Versions.getLivingEntity();
}
2
3
4
需要添加版本支持,复制 nms 子项目中 v1_xx_Rx
命名格式的任意一个项目,并且重新命名(这里 (VERSION_TO_REVISION) (opens new window)是规则),修改构建脚本中 setupNMS
函数的参数即可。
如果需要添加类,先在 shared
模块添加接口,然后再在所有 v1_xx_Rx
子项目中添加实现,最后在 Versions.java
中添加版本对应关系、反射初始化实现即可。
有时可能会遇到一些 NMS 方法需要引用 authlib、brigadier、datafixerupper 之类的(这三个比较常见),不要图方便全用一样的版本,要 Minecraft 版本对应相同的依赖版本才行。否则即使编译成功了,之后执行的时候可能会因为方法签名不一样导致报错。
先下载 https://bmclapi2.bangbang93.com/version/版本/json
然后打开(你有客户端的话,打开 .minecraft/versions/版本/版本.json
也行),格式化,并且搜索相关依赖。
这里以 1.21 为例,搜索 authlib
会得到下面这段
{
"downloads": {
// ... downloads 没啥用,省略
},
"name": "com.mojang:authlib:6.0.54"
}
2
3
4
5
6
复制 name
的值,添加到相应版本的 dependencies
里
setupJava(21)
dependencies {
setupNMS("1.21")
compileOnly("com.mojang:authlib:6.0.54")
}
2
3
4
5
因为 1.17+
的依赖基本都要 Java 17 才能引用,1.20.5+
要 Java 21,所以还要加个 setupJava
设定子项目的编译目标版本。
# 1.7.x
以上配置方法只适用于 1.8+
的版本。如果你需要 1.7.x
,不能使用 setupNMS
方法,应该手动添加 CodeMC
的仓库,并添加 craftbukkit
依赖。
repositories {
maven("https://repo.codemc.io/repository/nms/")
}
dependencies {
compileOnly("org.bukkit.bukkit:1.7.10-R0.1-SHAPSHOT")
compileOnly("org.bukkit.craftbukkit:1.7.10-R0.1-SHAPSHOT")
}
2
3
4
5
6
7
# 调用NMS
配置好了依赖才能调用,上面我们说了那么多,都是为了调用作准备。
核心思想是通过 Bukkit接口 -> CraftBukkit -> 原版接口
这样的路径来访问原版对象的实例。
就以模板项目中的 LivingEntity
为例,它在 CraftBukkit 对应的实现是 CraftLivingEntity
,只要强制转换就行了。要是你实在不放心就加个判断,但是没必要,大多数情况下不会有问题,出问题的时候通常是插件冲突,到时再具体情况具体分析。
CraftLivingEntity craft = (CraftLivingEntity) entity;
好,我们拿到 CraftBukkit 那一层的实例了。CraftBukkit 这一层可能会有比 Bukkit 更多的内部方法,但通常不会有很多。一般来说,只需要访问 handle
字段,或者调用 getHandle()
方法,就能拿到 Minecraft 原版对象。不同版本的获取方法不同,有的版本还可能不会把 handle
字段给设置成公开的,要反射去取。
EntityLiving nms = craft.getHandle();
接下来就是你的活了,拿到了 Minecraft 原版这一层的实例,你想怎么改就怎么改。
当然改起来还是很憋屈的,毕竟名称都是混淆的,需要去查 MCP (1.7-1.12) (opens new window)、 yarn (1.13+) (opens new window) 之类的混淆表。因为我不常写大项目,命名基本都能猜得出来,没查过几次混淆表(而且查的基本都是高版本的),就不介绍用法了。
改起来是真的憋屈,混淆刚刚说过,除此之外,你只能读取或修改一些值(还有 private
、protected
修饰符限制),调用一些没包装到 Bukkit 接口的方法,最多重写某个抽象类或者接口,而不能修改服务端原有的代码。相比,修改服务端源码的自由度要高很多,但兼容性非常低。看你的需求来选择调用 NMS 还是直接改服务端,对于私人服务器,直接改服务端源码可以实现一些纯插件完全无法实现的功能,体现服务器的独特性、创新性。