mirror of
https://github.com/gamedig/node-gamedig.git
synced 2024-09-07 03:21:29 +02:00
* Add missing CRLF line ending * Add support for running using deno Prefix node imports with "node:" and gate a socket API that is not implemented in [deno](https://deno.land) so that the library can be used there. This should not break node and doesn't in my brief testing.
612 lines
19 KiB
JavaScript
612 lines
19 KiB
JavaScript
import Bzip2 from 'seek-bzip'
|
|
import Core from './core.js'
|
|
import { Buffer } from 'node:buffer'
|
|
|
|
const AppId = {
|
|
Squad: 393380,
|
|
Bat1944: 489940,
|
|
Ship: 2400,
|
|
DayZ: 221100,
|
|
Rust: 252490,
|
|
CSGO: 730,
|
|
CS_Source: 240,
|
|
EternalSilence: 17550,
|
|
Insurgency_MIC: 17700,
|
|
Source_SDK_Base_2006: 215
|
|
}
|
|
|
|
export default class valve extends Core {
|
|
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 = ''
|
|
}
|
|
|
|
async run (state) {
|
|
if (!this.options.port) this.options.port = 27015
|
|
await this.queryInfo(state)
|
|
await this.queryChallenge()
|
|
await this.queryPlayers(state)
|
|
await this.queryRules(state)
|
|
await this.cleanup(state)
|
|
}
|
|
|
|
async queryInfo (/** Results */ state) {
|
|
this.logger.debug('Requesting info ...')
|
|
const b = await this.sendPacket(
|
|
this.goldsrcInfo ? undefined : 0x54,
|
|
this.goldsrcInfo ? 'details' : 'Source Engine Query\0',
|
|
this.goldsrcInfo ? 0x6D : 0x49,
|
|
false
|
|
)
|
|
|
|
const reader = this.reader(b)
|
|
|
|
if (this.goldsrcInfo) state.raw.address = reader.string()
|
|
else state.raw.protocol = reader.uint(1)
|
|
|
|
state.name = reader.string()
|
|
state.map = reader.string()
|
|
state.raw.folder = reader.string()
|
|
state.raw.game = reader.string()
|
|
if (!this.goldsrcInfo) state.raw.appId = reader.uint(2)
|
|
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 = String.fromCharCode(reader.uint(1))
|
|
state.raw.environment = String.fromCharCode(reader.uint(1))
|
|
|
|
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)
|
|
}
|
|
} else {
|
|
state.raw.secure = reader.uint(1)
|
|
if (state.raw.appId === AppId.Ship) {
|
|
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)
|
|
if (extraFlag & 0x80) state.gamePort = reader.uint(2)
|
|
if (extraFlag & 0x10) state.raw.steamid = reader.uint(8).toString()
|
|
if (extraFlag & 0x40) {
|
|
state.raw.sourcetvport = reader.uint(2)
|
|
state.raw.sourcetvname = reader.string()
|
|
}
|
|
if (extraFlag & 0x20) state.raw.tags = reader.string().split(',')
|
|
if (extraFlag & 0x01) {
|
|
const gameId = reader.uint(8)
|
|
const betterAppId = gameId.getLowBitsUnsigned() & 0xffffff
|
|
if (betterAppId) {
|
|
state.raw.appId = betterAppId
|
|
}
|
|
}
|
|
}
|
|
|
|
const appId = state.raw.appId
|
|
|
|
// from https://developer.valvesoftware.com/wiki/Server_queries
|
|
if (
|
|
state.raw.protocol === 7 && (
|
|
state.raw.appId === AppId.Source_SDK_Base_2006 ||
|
|
state.raw.appId === AppId.EternalSilence ||
|
|
state.raw.appId === AppId.Insurgency_MIC ||
|
|
state.raw.appId === AppId.CS_Source
|
|
)
|
|
) {
|
|
this._skipSizeInSplitHeader = true
|
|
}
|
|
this.logger.debug('INFO: ', state.raw)
|
|
if (state.raw.protocol === 48) {
|
|
this.logger.debug('GOLDSRC DETECTED - USING MODIFIED SPLIT FORMAT')
|
|
this.goldsrcSplits = true
|
|
}
|
|
|
|
// DayZ embeds some of the server information inside the tags attribute
|
|
if (appId === AppId.DayZ) {
|
|
if (state.raw.tags) {
|
|
state.raw.dlcEnabled = false
|
|
state.raw.firstPerson = false
|
|
for (const tag of state.raw.tags) {
|
|
if (tag.startsWith('lqs')) {
|
|
const value = parseInt(tag.replace('lqs', ''))
|
|
if (!isNaN(value)) {
|
|
state.raw.queue = value
|
|
}
|
|
}
|
|
if (tag.includes('no3rd')) {
|
|
state.raw.firstPerson = true
|
|
}
|
|
if (tag.includes('isDLC')) {
|
|
state.raw.dlcEnabled = true
|
|
}
|
|
if (tag.includes(':')) {
|
|
state.raw.time = tag
|
|
}
|
|
if (tag.startsWith('etm')) {
|
|
const value = parseInt(tag.replace('etm', ''))
|
|
if (!isNaN(value)) {
|
|
state.raw.dayAcceleration = value
|
|
}
|
|
}
|
|
if (tag.startsWith('entm')) {
|
|
const value = parseInt(tag.replace('entm', ''))
|
|
if (!isNaN(value)) {
|
|
state.raw.nightAcceleration = value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (appId === AppId.Rust) {
|
|
if (state.raw.tags) {
|
|
for (const tag of state.raw.tags) {
|
|
if (tag.startsWith('mp')) {
|
|
const value = parseInt(tag.replace('mp', ''))
|
|
if (!isNaN(value)) {
|
|
state.maxplayers = value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async queryChallenge () {
|
|
if (this.legacyChallenge) {
|
|
// sendPacket will catch the response packet and
|
|
// save the challenge for us
|
|
this.logger.debug('Requesting legacy challenge key ...')
|
|
await this.sendPacket(
|
|
0x57,
|
|
null,
|
|
0x41,
|
|
false
|
|
)
|
|
}
|
|
}
|
|
|
|
async queryPlayers (/** Results */ state) {
|
|
state.raw.players = []
|
|
|
|
this.logger.debug('Requesting player list ...')
|
|
const b = await this.sendPacket(
|
|
this.goldsrcInfo ? undefined : 0x55,
|
|
this.goldsrcInfo ? 'players' : null,
|
|
0x44,
|
|
true
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
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()
|
|
|
|
this.logger.debug('Found player: ' + name + ' ' + score + ' ' + time)
|
|
|
|
// connecting players don't count as players.
|
|
if (!name) continue
|
|
|
|
// CSGO sometimes adds a bot named 'Max Players' if host_players_show is not 2
|
|
if (state.raw.appId === AppId.CSGO && name === 'Max Players') continue
|
|
|
|
state.raw.players.push({
|
|
name, score, time
|
|
})
|
|
}
|
|
}
|
|
|
|
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
|
|
const dayZPayload = []
|
|
|
|
this.logger.debug('Requesting rules ...')
|
|
|
|
if (this.goldsrcInfo) {
|
|
const b = await this.udpSend('\xff\xff\xff\xffrules', b => b, () => null)
|
|
if (b === null) return // timed out - the server probably has rules disabled
|
|
const reader = this.reader(b)
|
|
while (!reader.done()) {
|
|
const key = reader.string()
|
|
const value = reader.string()
|
|
rules[key] = value
|
|
}
|
|
} else {
|
|
const b = await this.sendPacket(0x56, null, 0x45, true)
|
|
if (b === null) return // timed out - the server probably has rules disabled
|
|
|
|
let dayZPayloadEnded = false
|
|
|
|
const reader = this.reader(b)
|
|
const num = reader.uint(2)
|
|
for (let i = 0; i < num; i++) {
|
|
if (appId === AppId.DayZ && !dayZPayloadEnded) {
|
|
const one = reader.uint(1)
|
|
const two = reader.uint(1)
|
|
const three = reader.uint(1)
|
|
if (one !== 0 && two !== 0 && three === 0) {
|
|
while (true) {
|
|
const byte = reader.uint(1)
|
|
if (byte === 0) break
|
|
dayZPayload.push(byte)
|
|
}
|
|
continue
|
|
} else {
|
|
reader.skip(-3)
|
|
dayZPayloadEnded = true
|
|
}
|
|
}
|
|
|
|
const key = reader.string()
|
|
const value = reader.string()
|
|
rules[key] = value
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// Squad keeps its password in a separate field
|
|
if (appId === AppId.Squad) {
|
|
if (rules.Password_b === 'true') {
|
|
state.password = true
|
|
}
|
|
}
|
|
|
|
if (appId === AppId.DayZ) {
|
|
state.raw.dayzMods = this.readDayzMods(Buffer.from(dayZPayload))
|
|
}
|
|
}
|
|
|
|
readDayzMods (/** Buffer */ buffer) {
|
|
if (!buffer.length) {
|
|
return {}
|
|
}
|
|
|
|
this.logger.debug('DAYZ BUFFER')
|
|
this.logger.debug(buffer)
|
|
|
|
const reader = this.reader(buffer)
|
|
const version = this.readDayzByte(reader)
|
|
const overflow = this.readDayzByte(reader)
|
|
const dlc1 = this.readDayzByte(reader)
|
|
const dlc2 = this.readDayzByte(reader)
|
|
this.logger.debug('version ' + version)
|
|
this.logger.debug('overflow ' + overflow)
|
|
this.logger.debug('dlc1 ' + dlc1)
|
|
this.logger.debug('dlc2 ' + dlc2)
|
|
if (dlc1) {
|
|
const unknown = this.readDayzUint(reader, 4) // ?
|
|
this.logger.debug('unknown ' + unknown)
|
|
}
|
|
if (dlc2) {
|
|
const unknown = this.readDayzUint(reader, 4) // ?
|
|
this.logger.debug('unknown ' + unknown)
|
|
}
|
|
const mods = []
|
|
mods.push(...this.readDayzModsSection(reader, true))
|
|
mods.push(...this.readDayzModsSection(reader, false))
|
|
this.logger.debug('dayz buffer rest:', reader.rest())
|
|
return mods
|
|
}
|
|
|
|
readDayzModsSection (/** Reader */ reader, withHeader) {
|
|
const out = []
|
|
const count = this.readDayzByte(reader)
|
|
this.logger.debug('dayz mod section withHeader:' + withHeader + ' count:' + count)
|
|
for (let i = 0; i < count; i++) {
|
|
if (reader.done()) break
|
|
const mod = {}
|
|
if (withHeader) {
|
|
mod.unknown = this.readDayzUint(reader, 4) // ?
|
|
|
|
// For some reason this is 4 on all of them, but doesn't exist on the last one? but only sometimes?
|
|
const offset = reader.offset()
|
|
const flag = this.readDayzByte(reader)
|
|
if (flag !== 4) reader.setOffset(offset)
|
|
|
|
mod.workshopId = this.readDayzUint(reader, 4)
|
|
}
|
|
mod.title = this.readDayzString(reader)
|
|
this.logger.debug(mod)
|
|
out.push(mod)
|
|
}
|
|
return out
|
|
}
|
|
|
|
readDayzUint (reader, bytes) {
|
|
const out = []
|
|
for (let i = 0; i < bytes; i++) {
|
|
out.push(this.readDayzByte(reader))
|
|
}
|
|
const buf = Buffer.from(out)
|
|
const r2 = this.reader(buf)
|
|
return r2.uint(bytes)
|
|
}
|
|
|
|
readDayzByte (reader) {
|
|
const byte = reader.uint(1)
|
|
if (byte === 1) {
|
|
const byte2 = reader.uint(1)
|
|
if (byte2 === 1) return 1
|
|
if (byte2 === 2) return 0
|
|
if (byte2 === 3) return 0xff
|
|
return 0 // ?
|
|
}
|
|
return byte
|
|
}
|
|
|
|
readDayzString (reader) {
|
|
const length = this.readDayzByte(reader)
|
|
const out = []
|
|
for (let i = 0; i < length; i++) {
|
|
out.push(this.readDayzByte(reader))
|
|
}
|
|
return Buffer.from(out).toString('utf8')
|
|
}
|
|
|
|
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 || 0
|
|
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({})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sends a request packet and returns only the response type expected
|
|
* @param {number} type
|
|
* @param {boolean} sendChallenge
|
|
* @param {?string|Buffer} payload
|
|
* @param {number} expect
|
|
* @param {boolean=} allowTimeout
|
|
* @returns Buffer|null
|
|
**/
|
|
async sendPacket (
|
|
type,
|
|
payload,
|
|
expect,
|
|
allowTimeout
|
|
) {
|
|
for (let keyRetry = 0; keyRetry < 3; keyRetry++) {
|
|
let receivedNewChallengeKey = false
|
|
const response = await this.sendPacketRaw(
|
|
type, payload,
|
|
(payload) => {
|
|
const reader = this.reader(payload)
|
|
const type = reader.uint(1)
|
|
this.logger.debug(() => 'Received 0x' + type.toString(16) + ' expected 0x' + expect.toString(16))
|
|
if (type === 0x41) {
|
|
const key = reader.uint(4)
|
|
if (this._challenge !== key) {
|
|
this.logger.debug('Received new challenge key: 0x' + key.toString(16))
|
|
this._challenge = key
|
|
receivedNewChallengeKey = true
|
|
}
|
|
}
|
|
if (type === expect) {
|
|
return reader.rest()
|
|
} else if (receivedNewChallengeKey) {
|
|
return null
|
|
}
|
|
},
|
|
() => {
|
|
if (allowTimeout) return null
|
|
}
|
|
)
|
|
if (!receivedNewChallengeKey) {
|
|
return response
|
|
}
|
|
}
|
|
throw new Error('Received too many challenge key responses')
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
|
|
if (typeof payload === 'string') payload = Buffer.from(payload, 'binary')
|
|
|
|
const b = Buffer.alloc(4 +
|
|
(type !== undefined ? 1 : 0) +
|
|
(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
|
|
|
|
if (type !== undefined) {
|
|
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
|
|
}
|
|
|
|
const packetStorage = {}
|
|
return await this.udpSend(
|
|
b,
|
|
(buffer) => {
|
|
const reader = this.reader(buffer)
|
|
const header = reader.int(4)
|
|
if (header === -1) {
|
|
// full package
|
|
this.logger.debug('Received full packet')
|
|
return onResponse(reader.rest())
|
|
}
|
|
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()
|
|
}
|
|
|
|
packets[packetNum] = payload
|
|
|
|
this.logger.debug(() => 'Received partial packet uid: 0x' + uid.toString(16) + ' num: ' + packetNum)
|
|
this.logger.debug(() => 'Received ' + Object.keys(packets).length + '/' + numPackets + ' packets for this UID')
|
|
|
|
if (Object.keys(packets).length !== numPackets) return
|
|
|
|
// assemble the parts
|
|
const list = []
|
|
for (let i = 0; i < numPackets; i++) {
|
|
if (!(i in packets)) {
|
|
throw new Error('Missing packet #' + i)
|
|
}
|
|
list.push(packets[i])
|
|
}
|
|
|
|
let assembled = Buffer.concat(list)
|
|
if (bzip) {
|
|
this.logger.debug('BZIP DETECTED - Extracing packet...')
|
|
try {
|
|
assembled = Bzip2.decode(assembled)
|
|
} catch (e) {
|
|
throw new Error('Invalid bzip packet')
|
|
}
|
|
}
|
|
const assembledReader = this.reader(assembled)
|
|
assembledReader.skip(4) // header
|
|
return onResponse(assembledReader.rest())
|
|
}
|
|
},
|
|
onTimeout
|
|
)
|
|
}
|
|
}
|