Skip to content

Commit 5d18e41

Browse files
committed
support UTF-8 encoding in Query responses for Minecraft versions starting from 1.21.11
1 parent 5939d61 commit 5d18e41

8 files changed

Lines changed: 262 additions & 61 deletions

File tree

README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Note that only servers with the `server.properties` option `enable-query` set to
1616
The main advantage of the Query protocol over the ping protocol ist that it returns the
1717
full list of players on the server, not just a small sample. It does, however, also have a few disadvantages,
1818
like the fact that servers will return broken response packets if the MOTD (or any other string) [contains null bytes](https://bugs.mojang.com/browse/MC-221987)
19-
or [some other special characters](https://bugs.mojang.com/browse/MC-231035).
19+
or [some other special characters](https://bugs.mojang.com/browse/MC-231035) in versions before 1.21.11.
2020
This library makes an effort to interpret these broken response packets correctly, but it is not always possible to do so.
2121

2222
```js
@@ -34,6 +34,23 @@ and [`FullStatResponse`](src/Packet/Query/FullStatResponse.js) object respective
3434

3535
Note that the query client needs to be closed manually, since it keeps its UDP socket open to reuse it for future queries.
3636

37+
#### Query string encoding
38+
39+
Since string encoding in query responses switched to UTF-8 in Minecraft 1.21.11, this library makes an effort to detect
40+
the correct encoding automatically.
41+
- In full stat responses, this works pretty reliably based on the version field.
42+
- Basic stat responses do not include the server version, so the library has to make a guess based on the content of the string.
43+
If the whole string is valid UTF-8, it will be decoded as UTF-8. If it contains invalid UTF-8 sequences, it will be decoded as ISO-8859-1 instead.
44+
45+
You can force the string encoding using the `useLegacyStringEncoding` option, where `true` will force ISO-8859-1 encoding and `false` will force UTF-8 encoding.
46+
By default, this option is set to `null`, which means that the library will try to detect the correct encoding automatically.
47+
48+
```js
49+
let useLegacyStringEncoding = true; // true, false, or null
50+
let basic = await client.queryBasic('localhost', 25565, AbortSignal.timeout(5000), useLegacyStringEncoding);
51+
let full = await client.queryFull('localhost', 25565, AbortSignal.timeout(5000), useLegacyStringEncoding);
52+
```
53+
3754
### Java Edition Ping
3855
The [Server List Ping protocol](https://wiki.vg/Server_List_Ping) is what the Minecraft client uses to show the server status in the in-game server list.
3956
This protocol changed multiple times over the years, so you'd ideally want to know the version of the server you are pinging to use the correct protocol version.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "craftping",
33
"type": "module",
4-
"version": "3.0.0",
4+
"version": "3.1.0",
55
"main": "index.js",
66
"repository": "github:aternosorg/craftping",
77
"scripts": {

src/Packet/Query/BasicStatResponse.js

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@ import ProtocolError from "../../Error/ProtocolError.js";
22
import StatResponse from "./StatResponse.js";
33

44
export default class BasicStatResponse extends StatResponse {
5+
/** @type {?boolean} */ useLegacyEncoding;
6+
7+
/**
8+
* @param {?boolean} useLegacyEncoding - Whether to use the legacy encoding method for strings.
9+
* If null, it will be determined automatically.
10+
*/
11+
constructor(useLegacyEncoding = null) {
12+
super();
13+
this.useLegacyEncoding = useLegacyEncoding;
14+
}
15+
516
/**
617
* @inheritDoc
718
*/
@@ -29,17 +40,17 @@ export default class BasicStatResponse extends StatResponse {
2940
[hostip, offset] = this.readStringNT(data, offset);
3041
} while (offset < data.length);
3142

32-
this.hostname = motd;
33-
this.gametype = gametype;
34-
this.map = map;
35-
this.numplayers = parseInt(numplayers);
36-
this.maxplayers = parseInt(maxplayers);
43+
this.hostname = this.decodeString(motd, this.useLegacyEncoding);
44+
this.gametype = this.decodeString(gametype, this.useLegacyEncoding);
45+
this.map = this.decodeString(map, this.useLegacyEncoding);
46+
this.numplayers = parseInt(this.decodeString(numplayers, this.useLegacyEncoding));
47+
this.maxplayers = parseInt(this.decodeString(maxplayers, this.useLegacyEncoding));
3748
if (isNaN(this.numplayers) || isNaN(this.maxplayers)) {
3849
throw new ProtocolError("Player count is not a number");
3950
}
4051

4152
this.hostport = hostport;
42-
this.hostip = hostip;
53+
this.hostip = this.decodeString(hostip, this.useLegacyEncoding);
4354
return this;
4455
}
4556

@@ -59,13 +70,13 @@ export default class BasicStatResponse extends StatResponse {
5970
portBuffer.writeUInt16LE(hostport, 0);
6071

6172
return Buffer.concat([
62-
this.createStringNT(motd),
63-
this.createStringNT(gametype),
64-
this.createStringNT(map),
65-
this.createStringNT(numplayers),
66-
this.createStringNT(maxplayers),
73+
this.createStringNT(motd, this.useLegacyEncoding ?? true),
74+
this.createStringNT(gametype, this.useLegacyEncoding ?? true),
75+
this.createStringNT(map, this.useLegacyEncoding ?? true),
76+
this.createStringNT(numplayers, this.useLegacyEncoding ?? true),
77+
this.createStringNT(maxplayers, this.useLegacyEncoding ?? true),
6778
portBuffer,
68-
this.createStringNT(hostip)
79+
this.createStringNT(hostip, this.useLegacyEncoding ?? true)
6980
]);
7081
}
7182
}

src/Packet/Query/FullStatResponse.js

Lines changed: 82 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ export default class FullStatResponse extends StatResponse {
2626
/** @type {string} */ version;
2727
/** @type {string} */ plugins;
2828
/** @type {string[]} */ players;
29+
/** @type {?boolean} */ useLegacyEncoding;
30+
31+
/**
32+
* @param {?boolean} useLegacyEncoding - Whether to use the legacy encoding method for strings.
33+
* If null, it will be determined automatically.
34+
*/
35+
constructor(useLegacyEncoding = null) {
36+
super();
37+
this.useLegacyEncoding = useLegacyEncoding;
38+
}
2939

3040
/**
3141
* @return {string[]}
@@ -101,7 +111,7 @@ export default class FullStatResponse extends StatResponse {
101111
/**
102112
* @param {Buffer} data
103113
* @param {number} offset
104-
* @return {[Map<string, string>, number]}
114+
* @return {[Map<string, Buffer>, number]}
105115
*/
106116
readKeyValueSection(data, offset) {
107117
let values = new Map();
@@ -114,7 +124,7 @@ export default class FullStatResponse extends StatResponse {
114124

115125
[value, offset] = this.readStringNTFollowedBy(data, offset, FullStatResponse.VALID_KEYS_WITH_PLAYERS);
116126

117-
values.set(key, value);
127+
values.set(key.toString("ascii"), value);
118128
}
119129

120130
return [values, offset];
@@ -123,7 +133,7 @@ export default class FullStatResponse extends StatResponse {
123133
/**
124134
* @param {Buffer} data
125135
* @param {number} offset
126-
* @return {[string[], number]}
136+
* @return {[Buffer[], number]}
127137
*/
128138
readPlayers(data, offset) {
129139
let players = [];
@@ -140,6 +150,42 @@ export default class FullStatResponse extends StatResponse {
140150
return [players, offset];
141151
}
142152

153+
/**
154+
* Minecraft switched to UTF-8 encoding in version 1.21.11
155+
* @param {Buffer|string} version
156+
* @return {boolean}
157+
*/
158+
shouldUseLegacyStringEncoding(version) {
159+
if (typeof version !== "string") {
160+
version = version.toString("ascii");
161+
}
162+
let parts = version.split(".")
163+
.map(s => parseInt(s))
164+
.map(n => isNaN(n) ? 0 : n);
165+
166+
let modernVersion = [1, 21, 11];
167+
return this.compareVersions(parts, modernVersion) < 0;
168+
}
169+
170+
/**
171+
*
172+
* @param {number[]} a
173+
* @param {number[]} b
174+
* @return {number}
175+
*/
176+
compareVersions(a, b) {
177+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
178+
let partA = i < a.length ? a[i] : 0;
179+
let partB = i < b.length ? b[i] : 0;
180+
if (partA < partB) {
181+
return -1;
182+
} else if (partA > partB) {
183+
return 1;
184+
}
185+
}
186+
return 0;
187+
}
188+
143189
/**
144190
* @inheritDoc
145191
*/
@@ -150,6 +196,12 @@ export default class FullStatResponse extends StatResponse {
150196
}
151197

152198
let [values, offset] = this.readKeyValueSection(data, this.constructor.START_PADDING.length);
199+
let legacyEncoding = null;
200+
if (this.useLegacyEncoding !== null) {
201+
legacyEncoding = this.useLegacyEncoding;
202+
} else if (values.has('version')) {
203+
legacyEncoding = this.shouldUseLegacyStringEncoding(values.get('version'));
204+
}
153205

154206
let playerPadding = Buffer.from(data.buffer, data.byteOffset + offset, this.constructor.PLAYERS_PADDING.length);
155207
if (!playerPadding.equals(this.constructor.PLAYERS_PADDING)) {
@@ -164,46 +216,48 @@ export default class FullStatResponse extends StatResponse {
164216
throw new Error("Unexpected data after full stat response");
165217
}
166218

167-
this.gametype = values.get('gametype');
168-
this.game_id = values.get('game_id');
169-
this.version = values.get('version');
170-
this.plugins = values.get('plugins');
171-
this.map = values.get('map');
172-
this.numplayers = parseInt(values.get('numplayers'));
173-
this.maxplayers = parseInt(values.get('maxplayers'));
219+
this.gametype = this.decodeString(values.get('gametype'), legacyEncoding);
220+
this.game_id = this.decodeString(values.get('game_id'), legacyEncoding);
221+
this.version = this.decodeString(values.get('version'), legacyEncoding);
222+
this.plugins = this.decodeString(values.get('plugins'), legacyEncoding);
223+
this.map = this.decodeString(values.get('map'), legacyEncoding);
224+
this.numplayers = parseInt(this.decodeString(values.get('numplayers'), legacyEncoding));
225+
this.maxplayers = parseInt(this.decodeString(values.get('maxplayers'), legacyEncoding));
174226
if (isNaN(this.numplayers) || isNaN(this.maxplayers)) {
175227
throw new ProtocolError("Player count is not a number");
176228
}
177-
this.hostport = parseInt(values.get('hostport'));
178-
this.hostip = values.get('hostip');
179-
this.hostname = values.get('hostname');
180-
this.players = players;
229+
this.hostport = parseInt(this.decodeString(values.get('hostport'), legacyEncoding));
230+
this.hostip = this.decodeString(values.get('hostip'), legacyEncoding);
231+
this.hostname = this.decodeString(values.get('hostname'), legacyEncoding);
232+
this.players = players.map(p => this.decodeString(p, legacyEncoding));
181233

182234
return this;
183235
}
184236

185237
/**
186238
* @param {Map<string, string>} values
239+
* @param {boolean} legacyEncoding
187240
* @return {Buffer}
188241
*/
189-
writeKeyValueSection(values) {
242+
writeKeyValueSection(values, legacyEncoding) {
190243
let parts = [];
191244
for (let [key, value] of values) {
192-
parts.push(this.createStringNT(key));
193-
parts.push(this.createStringNT(value));
245+
parts.push(this.createStringNT(key, legacyEncoding));
246+
parts.push(this.createStringNT(value, legacyEncoding));
194247
}
195248
parts.push(Buffer.alloc(1));
196249
return Buffer.concat(parts);
197250
}
198251

199252
/**
200253
* @param {string[]} players
254+
* @param {boolean} legacyEncoding
201255
* @return {Buffer}
202256
*/
203-
writePlayers(players) {
257+
writePlayers(players, legacyEncoding) {
204258
let parts = [];
205259
for (let player of players) {
206-
parts.push(this.createStringNT(player));
260+
parts.push(this.createStringNT(player, legacyEncoding));
207261
}
208262
parts.push(Buffer.alloc(1));
209263
return Buffer.concat(parts);
@@ -213,6 +267,13 @@ export default class FullStatResponse extends StatResponse {
213267
* @inheritDoc
214268
*/
215269
writePayload() {
270+
let legacyEncoding = true;
271+
if (this.useLegacyEncoding !== null) {
272+
legacyEncoding = this.useLegacyEncoding;
273+
} else if (this.version) {
274+
legacyEncoding = this.shouldUseLegacyStringEncoding(this.version);
275+
}
276+
216277
let values = new Map([
217278
['hostname', this.hostname || ''],
218279
['gametype', this.gametype || ''],
@@ -228,9 +289,9 @@ export default class FullStatResponse extends StatResponse {
228289

229290
return Buffer.concat([
230291
this.constructor.START_PADDING,
231-
this.writeKeyValueSection(values),
292+
this.writeKeyValueSection(values, legacyEncoding),
232293
this.constructor.PLAYERS_PADDING,
233-
this.writePlayers(this.players)
294+
this.writePlayers(this.players, legacyEncoding)
234295
]);
235296
}
236297
}

0 commit comments

Comments
 (0)