Server List Ping(SLP)

服务器列表查询 (Server List Ping) 是 Minecraft 客户端和服务器之间的一种简单的通信协议。它用于查询服务器的状态,例如在线玩家数量、最大玩家数量、服务器版本等。

Server List Ping(SLP)

身为 Minecraft 爱好者, 看了市面有很多 Minecraft 服务器状态查询功能, 我就想着能不能自己做个, 刚开始以为只有 Java 或者 Python 才能写出这样的后端业务, 在我查阅了一些关于这种功能的资料后, 我发现了mcsrvstat这个网站,它提供了一个简单的 API,可以查询 Minecraft 服务器的状态,包括在线玩家数量、最大玩家数量、服务器版本等。
我就参照了这个 API 的文档,自己手撸了一个简单的查询 Minecraft 服务器状态的工具.

1. 什么是 SLP

SLP 是Server List Ping的缩写,是 Minecraft 客户端和服务器之间的一种简单的通信协议。它用于查询服务器的状态,例如在线玩家数量、最大玩家数量、服务器版本等。SLP 共有三个阶段,分别是握手阶段(Handshake)、状态查询阶段(Status Request)、状态查询响应阶段(Status Response)。

握手阶段(Handshake)

在握手阶段,客户端向服务器发送一个状态为 1 的握手包,

Packet ID Field Name Field Type Notes
0x00 Protocol Version VarInt 协议版本号
0x00 Server Address String 服务器地址
0x00 Server Port String 服务器端口
0x00 Next state VarInt 下一个状态 1: 状态 2: 登录

实现 VarInt 编码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function packVarint(num) {
const buffers = [];

while (true) {
// 如果大于 0x7f 说明此数字不能用只用当前一个字节表示,需要在高位变 1
if (num > 0x7f) {
// 0x7f 的二进制编码为 01111111,用 & 操作即可取出后 7 位
// 0x80 的二进制编码为 11111111,用 | 操作可以把最高位变为 1
buffers.push(0x80 | (num & 0x7f));
// 完成操作将数字右移 7 位
num >>= 7;
} else {
// 如果不大于 0x7f 说明一字节就行了,直接添加到结果里
buffers.push(num);
break;
}
}

return Uint8Array.from(buffers);
}

实现 VarInt 解码

有编码, 就有解码, 为了方便后续的使用, 我们也需要实现 VarInt 的解码方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function unPackVarint(arr) {
let result = 0;

for (let idx = 0; idx < 5; idx++) {
let bufferVal = arr[idx];

// 获取蒂7位的有效数据,并根据结果左移进行还原
result |= (bufferVal & 0x7f) << (idx * 7);

// 读取最高位是否为 0,如果为 0 表示这是最后一个数据,直接返回即可
if ((bufferVal & 0x80) === 0) return result;
}

return result;
}

构建 handshake 数据包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 这里以 hypixel 为例
const host = "mc.hypixel.net";
const port = 25565;

// 主机地址
const hostUnit8Arr = Uint8Array.from(Buffer.from(host));
const dataBody = concatData([
// 第一个 buffer 为数据包 ID,后一个便是协议版本,这里没有使用 packVarInt 是因为可以直接看出来结果(-1 或空)。
Uint8Array.from([0x00, 0x00]),
// 主机地址长度,使用VarInt编码进行处理
packVarint(hostUnit8Arr.length),
// 主机地址,类型为string,不需要进行编码
hostUnit8Arr,
// 端口,同样不需要编码,而 1 经过编码后很容易看出来为 0x01,没有大于0x7f
// 由于端口需要两个字节表示,所以需要转换一下
concatData([
Uint8Array.from(Buffer.from(Uint16Array.from([port]).buffer)),
Uint8Array.from([0x01]),
]),
]);

// 将包长度信息进行编码
const data = concatData([packVarint(dataBody.length), dataBody]);

function concatData(arrays) {
const totalLength = arrays.reduce((acc, value) => acc + value.length, 0);
let result = new Uint8Array(totalLength);

arrays.reduce((offset, arr) => {
result.set(arr, offset);
return offset + arr.length;
}, 0);

return result;
}

状态查询阶段(Status Request)

同上面一样,数据长度进行了 VarInt 编码处理,但因为太小还是和未编码一样(一个字节)

Packet ID Field Name Field Type Notes
0x00 no fieds no fieds no fieds
1
const data = Uint8Array.from([0x01, 0x00]);

状态查询响应阶段(Status Response)

在状态查询响应阶段,服务器向客户端发送一个包含服务器状态信息的 JSON 字符串。

Packet ID Field Name Field Type Notes
0x00 JSON Response String 服务器状态信息

获取数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function getResponseData() {
const socket = new Socket().connect({ host: "mc.hypixel.mc", port: 25565 });
let packageDataLen,
responseBuffers = Buffer.from("");
// Send handshake Packet
// Send server request packet

return new Promise((resolve) => {
socket.on("readable", () => {
// 如果字节数小于 2,则无法读取数据部分的总大小,需要等到下一次可读取状态触发
if (socket.readableLength >= 5) {
if (packageDataLen === undefined) {
// 读取数据部分大小
const packageLen = unPackVarint(socket);
// 读取数据包 ID,这里可以不用解码
const packageId = unPackVarint(socket);
// 读取信息部分大小
const packageDataLength = unPackVarint(socket);

packageDataLen = packageDataLength;
}

responseBuffers = Buffer.concat([responseBuffers, socket.read()]);

if (responseBuffers.length >= packageDataLen) {
// 数据大小达到期望值,可以中断连接了
socket.destroy();
// 返回数据
resolve(responseBuffers);
}
}
});
});
}