node-gamedig/protocols/valve.js

436 lines
15 KiB
JavaScript
Raw Normal View History

2019-01-07 07:52:29 +01:00
const Bzip2 = require('compressjs').Bzip2,
Core = require('./core'),
Results = require('../lib/Results');
const AppId = {
Squad: 393380,
Bat1944: 489940,
Ship: 2400
};
2014-10-29 08:02:03 +01:00
class Valve extends Core {
2017-08-09 12:32:09 +02:00
constructor() {
super();
// legacy goldsrc info response -- basically not used by ANYTHING now,
// as most (all?) goldsrc servers respond with the source info reponse
// delete in a few years if nothing ends up using it anymore
this.goldsrcInfo = false;
// unfortunately, the split format from goldsrc is still around, but we
// can detect that during the query
this.goldsrcSplits = false;
// some mods require a challenge, but don't provide them in the new format
// at all, use the old dedicated challenge query if needed
this.legacyChallenge = false;
// 2006 engines don't pass packet switching size in split packet header
// while all others do, this need is detected automatically
this._skipSizeInSplitHeader = false;
this._challenge = '';
}
2019-01-07 07:52:29 +01:00
async run(state) {
2019-01-12 12:45:09 +01:00
if (!this.options.port) this.options.port = 27015;
2019-01-07 07:52:29 +01:00
await this.queryInfo(state);
await this.queryChallenge();
await this.queryPlayers(state);
await this.queryRules(state);
await this.cleanup(state);
2017-08-09 12:32:09 +02:00
}
async queryInfo(/** Results */ state) {
2019-01-09 12:50:30 +01:00
this.debugLog("Requesting info ...");
2019-01-07 07:52:29 +01:00
const b = await this.sendPacket(
0x54,
'Source Engine Query\0',
2017-08-09 12:32:09 +02:00
this.goldsrcInfo ? 0x6D : 0x49,
2019-01-07 07:52:29 +01:00
false
);
2017-08-09 12:32:09 +02:00
2019-01-07 07:52:29 +01:00
const reader = this.reader(b);
2017-08-09 12:32:09 +02:00
2019-01-07 07:52:29 +01:00
if(this.goldsrcInfo) state.raw.address = reader.string();
else state.raw.protocol = reader.uint(1);
2017-08-09 12:32:09 +02:00
2019-01-07 07:52:29 +01:00
state.name = reader.string();
state.map = reader.string();
state.raw.folder = reader.string();
state.raw.game = reader.string();
state.raw.appId = reader.uint(2);
2019-01-07 07:52:29 +01:00
state.raw.numplayers = reader.uint(1);
state.maxplayers = reader.uint(1);
if(this.goldsrcInfo) state.raw.protocol = reader.uint(1);
else state.raw.numbots = reader.uint(1);
state.raw.listentype = reader.uint(1);
state.raw.environment = reader.uint(1);
if(!this.goldsrcInfo) {
state.raw.listentype = String.fromCharCode(state.raw.listentype);
state.raw.environment = String.fromCharCode(state.raw.environment);
}
state.password = !!reader.uint(1);
if(this.goldsrcInfo) {
state.raw.ismod = reader.uint(1);
if(state.raw.ismod) {
state.raw.modlink = reader.string();
state.raw.moddownload = reader.string();
reader.skip(1);
state.raw.modversion = reader.uint(4);
state.raw.modsize = reader.uint(4);
state.raw.modtype = reader.uint(1);
state.raw.moddll = reader.uint(1);
2017-08-09 12:32:09 +02:00
}
2019-01-07 07:52:29 +01:00
}
state.raw.secure = reader.uint(1);
if(this.goldsrcInfo) {
state.raw.numbots = reader.uint(1);
} else {
if(state.raw.appId === AppId.Ship) {
2019-01-07 07:52:29 +01:00
state.raw.shipmode = reader.uint(1);
state.raw.shipwitnesses = reader.uint(1);
state.raw.shipduration = reader.uint(1);
}
state.raw.version = reader.string();
const extraFlag = reader.uint(1);
2019-01-12 12:45:09 +01:00
if(extraFlag & 0x80) state.gamePort = reader.uint(2);
if(extraFlag & 0x10) state.raw.steamid = reader.uint(8).toString();
2019-01-07 07:52:29 +01:00
if(extraFlag & 0x40) {
state.raw.sourcetvport = reader.uint(2);
state.raw.sourcetvname = reader.string();
}
if(extraFlag & 0x20) state.raw.tags = reader.string();
if(extraFlag & 0x01) {
const gameId = reader.uint(8);
const betterAppId = gameId.getLowBitsUnsigned() & 0xffffff;
if (betterAppId) {
state.raw.appId = betterAppId;
}
}
2019-01-07 07:52:29 +01:00
}
// from https://developer.valvesoftware.com/wiki/Server_queries
if(
state.raw.protocol === 7 && (
state.raw.appId === 215
|| state.raw.appId === 17550
|| state.raw.appId === 17700
|| state.raw.appId === 240
2019-01-07 07:52:29 +01:00
)
) {
this._skipSizeInSplitHeader = true;
}
this.logger.debug("INFO: ", state.raw);
2019-01-07 07:52:29 +01:00
if(state.raw.protocol === 48) {
this.logger.debug("GOLDSRC DETECTED - USING MODIFIED SPLIT FORMAT");
2019-01-07 07:52:29 +01:00
this.goldsrcSplits = true;
}
2017-08-09 12:32:09 +02:00
}
2019-01-07 07:52:29 +01:00
async queryChallenge() {
2017-08-09 12:32:09 +02:00
if(this.legacyChallenge) {
2019-01-07 07:52:29 +01:00
// sendPacket will catch the response packet and
// save the challenge for us
2019-01-09 12:50:30 +01:00
this.debugLog("Requesting legacy challenge key ...");
2019-01-07 07:52:29 +01:00
await this.sendPacket(
0x57,
null,
0x41,
false
);
2017-08-09 12:32:09 +02:00
}
}
async queryPlayers(/** Results */ state) {
state.raw.players = [];
2014-10-29 08:02:03 +01:00
2019-01-09 12:50:30 +01:00
this.debugLog("Requesting player list ...");
2019-01-07 07:52:29 +01:00
const b = await this.sendPacket(
0x55,
null,
0x44,
true
2019-01-07 07:52:29 +01:00
);
if (b === null) {
// Player query timed out
// CSGO doesn't respond to player query if host_players_show is not 2
// Conan Exiles never responds to player query
// Just skip it, and we'll fill with dummy objects in cleanup()
return;
}
2017-08-09 12:32:09 +02:00
2019-01-07 07:52:29 +01:00
const reader = this.reader(b);
const num = reader.uint(1);
for(let i = 0; i < num; i++) {
reader.skip(1);
const name = reader.string();
const score = reader.int(4);
const time = reader.float();
2017-08-09 12:32:09 +02:00
2019-01-09 12:50:30 +01:00
this.debugLog("Found player: "+name+" "+score+" "+time);
2019-01-07 07:52:29 +01:00
// connecting players don't count as players.
if(!name) continue;
2017-08-09 12:32:09 +02:00
2019-01-07 07:52:29 +01:00
// CSGO sometimes adds a bot named 'Max Players' if host_players_show is not 2
if (state.raw.steamappid === 730 && name === 'Max Players') continue;
state.raw.players.push({
name:name, score:score, time:time
});
}
2017-08-09 12:32:09 +02:00
}
async queryRules(/** Results */ state) {
const appId = state.raw.appId;
if (appId === AppId.Squad
|| appId === AppId.Bat1944
|| this.options.requestRules) {
// let's get 'em
} else {
return;
}
const rules = {};
state.raw.rules = rules;
2019-01-09 12:50:30 +01:00
this.debugLog("Requesting rules ...");
const b = await this.sendPacket(0x56,null,0x45,true);
if (b === null) return; // timed out - the server probably has rules disabled
2019-01-07 07:52:29 +01:00
const reader = this.reader(b);
const num = reader.uint(2);
for(let i = 0; i < num; i++) {
const key = reader.string();
const value = reader.string();
rules[key] = value;
2019-01-07 07:52:29 +01:00
}
2017-08-09 12:32:09 +02:00
2018-05-05 10:00:54 +02:00
// Battalion 1944 puts its info into rules fields for some reason
if (appId === AppId.Bat1944) {
if ('bat_name_s' in rules) {
state.name = rules.bat_name_s;
delete rules.bat_name_s;
if ('bat_player_count_s' in rules) {
state.raw.numplayers = parseInt(rules.bat_player_count_s);
delete rules.bat_player_count_s;
}
if ('bat_max_players_i' in rules) {
state.maxplayers = parseInt(rules.bat_max_players_i);
delete rules.bat_max_players_i;
}
if ('bat_has_password_s' in rules) {
state.password = rules.bat_has_password_s === 'Y';
delete rules.bat_has_password_s;
}
// apparently map is already right, and this var is often wrong
delete rules.bat_map_s;
2018-05-05 10:00:54 +02:00
}
}
// Squad keeps its password in a separate field
if (appId === AppId.Squad) {
if (rules.Password_b === "true") {
state.password = true;
2018-05-05 10:00:54 +02:00
}
}
}
2018-05-05 10:00:54 +02:00
async cleanup(/** Results */ state) {
// Organize players / hidden players into player / bot arrays
const botProbability = (p) => {
if (p.time === -1) return Number.MAX_VALUE;
return p.time;
};
const sortedPlayers = state.raw.players.sort((a,b) => {
return botProbability(a) - botProbability(b);
});
delete state.raw.players;
const numBots = state.raw.numbots;
const numPlayers = state.raw.numplayers - numBots;
while(state.bots.length < numBots) {
if (sortedPlayers.length) state.bots.push(sortedPlayers.pop());
else state.bots.push({});
}
while(state.players.length < numPlayers || sortedPlayers.length) {
if (sortedPlayers.length) state.players.push(sortedPlayers.pop());
else state.players.push({});
}
2018-05-05 10:00:54 +02:00
}
/**
2019-01-07 07:52:29 +01:00
* Sends a request packet and returns only the response type expected
* @param {number} type
* @param {boolean} sendChallenge
* @param {?string|Buffer} payload
* @param {number} expect
2019-01-07 07:52:29 +01:00
* @param {boolean=} allowTimeout
* @returns Buffer|null
**/
2019-01-07 07:52:29 +01:00
async sendPacket(
type,
payload,
expect,
2019-01-07 07:52:29 +01:00
allowTimeout
) {
2019-01-07 07:52:29 +01:00
for (let keyRetry = 0; keyRetry < 3; keyRetry++) {
let receivedNewChallengeKey = false;
2019-01-07 07:52:29 +01:00
const response = await this.sendPacketRaw(
type, payload,
2019-01-07 07:52:29 +01:00
(payload) => {
const reader = this.reader(payload);
const type = reader.uint(1);
this.debugLog(() => "Received 0x" + type.toString(16) + " expected 0x" + expect.toString(16));
2019-01-07 07:52:29 +01:00
if (type === 0x41) {
const key = reader.uint(4);
if (this._challenge !== key) {
this.debugLog('Received new challenge key: 0x' + key.toString(16));
2019-01-07 07:52:29 +01:00
this._challenge = key;
receivedNewChallengeKey = true;
2019-01-07 07:52:29 +01:00
}
}
if (type === expect) {
return reader.rest();
} else if (receivedNewChallengeKey) {
2019-01-09 12:35:11 +01:00
return null;
}
2019-01-07 07:52:29 +01:00
},
() => {
if (allowTimeout) return null;
}
2019-01-07 07:52:29 +01:00
);
if (!receivedNewChallengeKey) {
2019-01-09 12:35:11 +01:00
return response;
}
2019-01-07 07:52:29 +01:00
}
throw new Error('Received too many challenge key responses');
}
2019-01-07 07:52:29 +01:00
/**
* Sends a request packet and assembles partial responses
* @param {number} type
* @param {boolean} sendChallenge
* @param {?string|Buffer} payload
* @param {function(Buffer)} onResponse
* @param {function()} onTimeout
**/
async sendPacketRaw(
type,
payload,
onResponse,
onTimeout
) {
const challengeAtBeginning = type === 0x55 || type === 0x56;
const challengeAtEnd = type === 0x54 && !!this._challenge;
2019-01-07 07:52:29 +01:00
if (typeof payload === 'string') payload = Buffer.from(payload, 'binary');
const b = Buffer.alloc(5
+ (challengeAtBeginning ? 4 : 0)
+ (challengeAtEnd ? 4 : 0)
+ (payload ? payload.length : 0)
);
let offset = 0;
let challenge = this._challenge;
if (!challenge) challenge = 0xffffffff;
b.writeInt32LE(-1, offset);
offset += 4;
b.writeUInt8(type, offset);
offset += 1;
if (challengeAtBeginning) {
if (this.byteorder === 'le') b.writeUInt32LE(challenge, offset);
else b.writeUInt32BE(challenge, offset);
offset += 4;
}
if (payload) {
payload.copy(b, offset);
offset += payload.length;
}
if (challengeAtEnd) {
if (this.byteorder === 'le') b.writeUInt32LE(challenge, offset);
else b.writeUInt32BE(challenge, offset);
offset += 4;
2019-01-07 07:52:29 +01:00
}
2014-10-29 08:02:03 +01:00
2019-01-07 07:52:29 +01:00
const packetStorage = {};
return await this.udpSend(
b,
(buffer) => {
const reader = this.reader(buffer);
const header = reader.int(4);
if(header === -1) {
// full package
2019-01-09 12:50:30 +01:00
this.debugLog("Received full packet");
2019-01-07 07:52:29 +01:00
return onResponse(reader.rest());
}
2019-01-07 07:52:29 +01:00
if(header === -2) {
// partial package
const uid = reader.uint(4);
if(!(uid in packetStorage)) packetStorage[uid] = {};
const packets = packetStorage[uid];
let bzip = false;
if(!this.goldsrcSplits && uid & 0x80000000) bzip = true;
let packetNum,payload,numPackets;
if(this.goldsrcSplits) {
packetNum = reader.uint(1);
numPackets = packetNum & 0x0f;
packetNum = (packetNum & 0xf0) >> 4;
payload = reader.rest();
} else {
numPackets = reader.uint(1);
packetNum = reader.uint(1);
if(!this._skipSizeInSplitHeader) reader.skip(2);
if(packetNum === 0 && bzip) reader.skip(8);
payload = reader.rest();
}
2019-01-07 07:52:29 +01:00
packets[packetNum] = payload;
this.debugLog(() => "Received partial packet uid: 0x"+uid.toString(16)+" num: "+packetNum);
2019-01-09 12:50:30 +01:00
this.debugLog(() => "Received "+Object.keys(packets).length+'/'+numPackets+" packets for this UID");
2019-01-07 07:52:29 +01:00
if(Object.keys(packets).length !== numPackets) return;
2019-01-07 07:52:29 +01:00
// assemble the parts
const list = [];
for(let i = 0; i < numPackets; i++) {
if(!(i in packets)) {
2019-01-09 12:50:30 +01:00
throw new Error('Missing packet #'+i);
2019-01-07 07:52:29 +01:00
}
list.push(packets[i]);
}
2019-01-07 07:52:29 +01:00
let assembled = Buffer.concat(list);
if(bzip) {
2019-01-09 12:50:30 +01:00
this.debugLog("BZIP DETECTED - Extracing packet...");
2019-01-07 07:52:29 +01:00
try {
assembled = Buffer.from(Bzip2.decompressFile(assembled));
} catch(e) {
2019-01-09 12:50:30 +01:00
throw new Error('Invalid bzip packet');
2019-01-07 07:52:29 +01:00
}
}
2019-01-07 07:52:29 +01:00
const assembledReader = this.reader(assembled);
assembledReader.skip(4); // header
return onResponse(assembledReader.rest());
}
2019-01-07 07:52:29 +01:00
},
onTimeout
);
2017-08-09 12:32:09 +02:00
}
}
module.exports = Valve;