Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] feat: UE3 LAN query support #638

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion lib/GlobalUdpSocket.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,14 @@ export default class GlobalUdpSocket {
return this.socket
}

async send (buffer, address, port, debug) {
async send (buffer, address, port, debug, enableBroadcast = undefined) {
const socket = await this._getSocket()

// if broadcast is enabled, pass it to the socket
if (enableBroadcast) {
socket.setBroadcast(true)
}

if (debug) {
this.logger._print(log => {
log(address + ':' + port + ' UDP(' + this.port + ')-->')
Expand Down
7 changes: 6 additions & 1 deletion lib/QueryRunner.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default class QueryRunner {
port: runnerOpts.listenUdpPort
})
this.portCache = {}
this.userOptions = {}
}

async run (userOptions) {
Expand All @@ -29,6 +30,9 @@ export default class QueryRunner {
}
}

// cache user options
this.userOptions = userOptions

const {
port_query: gameQueryPort,
port_query_offset: gameQueryPortOffset,
Expand Down Expand Up @@ -114,8 +118,9 @@ export default class QueryRunner {

async _attempt (options) {
const core = getProtocol(options.protocol)
core.options = options
core.options = core.setupOptions(options, this.userOptions)
core.udpSocket = this.udpSocket
core.updateOptionsDefaults()
return await core.runOnceSafe()
}
}
11 changes: 11 additions & 0 deletions lib/games.js
Original file line number Diff line number Diff line change
Expand Up @@ -3152,6 +3152,17 @@ export const games = {
old_id: 'ut3'
}
},
unrealtournament3lan: {
name: 'Unreal Tournament 3',
release_year: 2007,
options: {
port: 14001,
port_query_offset: null,
protocol: 'ut3lan',
packetVersion: 5,
lanGameUniqueId: 0x4D5707DB
}
},
urbanterror: {
name: 'Urban Terror',
release_year: 2000,
Expand Down
35 changes: 32 additions & 3 deletions lib/reader.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,27 @@ export default class Reader {
return r
}

double () {
let r = 0
if (this.remaining() >= 8) {
if (this.defaultByteOrder === 'be') r = this.buffer.readDoubleBE(this.i)
else r = this.buffer.readDoubleLE(this.i)
}

this.i += 8
return r
}

varint () {
const out = Varint.decode(this.buffer, this.i)
this.i += Varint.decode.bytes
return out
}

/** @returns Buffer */
/**
* Return the upcoming uffer of given size
* @param bytes the amount of bytes to read
* @returns Buffer of given size */
part (bytes) {
let r
if (this.remaining() >= bytes) {
Expand All @@ -158,14 +172,29 @@ export default class Reader {
return r
}

remaining () {
return this.buffer.length - this.i
/** Returns the remaining bytes in the current buffer
* <b>
* Note: remaining can be negative when the current position was exceed due to reading a given sized property
* </b>
* @param peek? additional bytes to read from the current position and returns the remaining count of bytes (defaults to 0)
* @return the remaining byte count
*/
remaining (peek = 0) {
return this.buffer.length - this.i - (+peek)
}

/**
* Returns the remaining byte array from the current position
* @returns the remaining bytes
*/
Comment on lines +186 to +189
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate a lot the addition of docs comments, but they should come in a separate PR to this one.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate a lot the addition of docs comments, but they should come in a separate PR to this one.

Right. I was just using that logic specifically and just added the JSDoc comment.

rest () {
return this.buffer.slice(this.i)
}

/**
* Checks if the buffer has reached its end.
* @returns true if the buffer is at the end, otherwise the current position is before the end.
*/
done () {
return this.i >= this.buffer.length
}
Expand Down
128 changes: 118 additions & 10 deletions protocols/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default class Core extends EventEmitter {
this.delimiter = '\0'
this.srvRecord = null
this.abortedPromise = null
this.enabledBroadcast = null
this.logger = new Logger()
this.dnsResolver = new DnsResolver(this.logger)

Expand All @@ -29,6 +30,44 @@ export default class Core extends EventEmitter {
this.usedTcp = false
}

// TODO: Consider adjusting the function being a field ("setupOptions = func") for _preventing_ overloading (ECMA 2022 required)
// // define as field to prevent overriding
// setupOptions = (options, userOptions = undefined) => {
setupOptions (options, userOptions = undefined) {
// safety check
userOptions ||= {}

// retrieve default options from subclasses
const defaultOptions = {}
this.getOptionsDefaults(defaultOptions)
Object.keys(defaultOptions).forEach(key => {
options[key] ??= defaultOptions[key]
})

// retrieve overriding options from subclasses
const overrideOptions = {}
this.getOptionsOverrides(overrideOptions)
Object.keys(overrideOptions).forEach(key => {
// prioritize user options, allow null parameters
options[key] = userOptions[key] ?? overrideOptions[key] ?? options[key]
})

return options
}

/**
* Update options. Can be used to add/change/override/remove protocol-specific options
*/
updateOptionsDefaults () { }
/**
* Build/add list of options to override. Can be used to override protocol options
*/
getOptionsDefaults (outOptions) { }
/**
* Build/add list of options to override. Can be used to override protocol options
*/
getOptionsOverrides (outOptions) { }

// Runs a single attempt with a timeout and cleans up afterward
async runOnceSafe () {
const { debug, attemptTimeout } = this.options
Expand Down Expand Up @@ -78,10 +117,39 @@ export default class Core extends EventEmitter {
options.port ||= resolved.port
}

const state = new Results()
await this.run(state)
// create initial state, run protocol and let specifiic protocol implementations handle populdate a given state
const initialState = this.prepareRun()
await this.run(initialState)
this.finishRun(initialState)

return initialState
}

/** main process executing the query */
async run (/** Results */ state) {}

/** Setup/prepare run, will provide how to build up a state. Used in subclasses */
prepareRun () { return this.createState() }

/** finish run, will provide how to generate final state. Used in subclasses */
finishRun (state) { this.populateState(state) }

/**
* Creates a default state object
* @returns {Results} A state for populate queried values into
*/
createState () {
return new Results()
}

/**
* Populates and sets up the given state with with basic properties based on given raw values
* @param {Results} state The initial created state for returning as query result
*/
populateState (state) {
const { options } = this
state.queryPort = options.port

// because lots of servers prefix with spaces to try to appear first
state.name = (state.name || '').trim()
state.connect = state.connect || `${state.gameHost || options.host || options.address}:${state.gamePort || options.port}`
Expand All @@ -94,12 +162,8 @@ export default class Core extends EventEmitter {
log('Size of players array:', state.players.length)
log('Size of bots array:', state.bots.length)
})

return state
}

async run (/** Results */ state) {}

/** Param can be a time in ms, or a promise (which will be timed) */
registerRtt (param) {
if (param instanceof Promise) {
Expand Down Expand Up @@ -253,26 +317,35 @@ export default class Core extends EventEmitter {
if (typeof buffer === 'string') buffer = Buffer.from(buffer, 'binary')

const socket = this.udpSocket
await socket.send(buffer, address, port, this.options.debug)
await socket.send(buffer, address, port, this.options.debug, this.enabledBroadcast)

if (!onPacket && !onTimeout) {
return null
}

let socketCallback
let timeout
const results = []
const isBroadcast = !!this.enabledBroadcast && (address?.includes('255') ?? false)

try {
const promise = new Promise((resolve, reject) => {
const start = Date.now()
socketCallback = (fromAddress, fromPort, buffer) => {
try {
if (fromAddress !== address || fromPort !== port) return
// in case of a configured broadcast address, the received response might come from the same "address"
// e.g. responses for UE3 lan queries might be sent as broadcast therefore the address can be the same
if ((fromAddress !== address && !isBroadcast) || fromPort !== port) return

this.registerRtt(Date.now() - start)
const result = onPacket(buffer)
if (result !== undefined) {
this.logger.debug('UDP send finished by callback')
resolve(result)
// broadcasts may expect multiple respones, store packet and keep waiting for additional responses
results.push(result)
if (!isBroadcast) {
this.logger.debug('UDP send finished by callback')
resolve(result)
}
}
} catch (e) {
reject(e)
Expand All @@ -282,6 +355,12 @@ export default class Core extends EventEmitter {
})
timeout = Promises.createTimeout(socketTimeout, 'UDP')
const wrappedTimeout = Promise.resolve(timeout).catch((e) => {
// in case of broadcast query and received responses, don't return as timeout-error.
// consider the timeout out with at least one received response as valid
if (isBroadcast && !!results.length) {
return results
}

this.logger.debug('UDP timeout detected')
if (onTimeout) {
const result = onTimeout()
Expand Down Expand Up @@ -334,3 +413,32 @@ export default class Core extends EventEmitter {
}
}
}

/**
* Implements the core LAN protocol
*/
export class CoreLAN extends Core {
constructor () {
super()
this.enabledBroadcast = false
this.outputAsArray = null
}

/** @override */
getOptionsDefaults (outOptions) {
super.getOptionsDefaults(outOptions)
const defaults = {
address: '255.255.255.255',
givenPortOnly: true
}
Object.assign(outOptions, defaults)
}

/** @override */
updateOptionsDefaults () {
super.updateOptionsDefaults()

// update enabledBroadcast value manually based on provided address
this.enabledBroadcast = this.options.address?.includes('255') ?? this.enabledBroadcast
}
}
3 changes: 2 additions & 1 deletion protocols/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import tribes1 from './tribes1.js'
import tribes1master from './tribes1master.js'
import unreal2 from './unreal2.js'
import ut3 from './ut3.js'
import ut3lan from './ut3lan.js'
import valve from './valve.js'
import vcmp from './vcmp.js'
import ventrilo from './ventrilo.js'
Expand All @@ -67,7 +68,7 @@ export {
armagetron, ase, asa, assettocorsa, battlefield, buildandshoot, cs2d, discord, doom3, eco, epic, factorio, farmingsimulator, ffow,
fivem, gamespy1, gamespy2, gamespy3, geneshift, goldsrc, gtasao, hexen2, jc2mp, kspdmp, mafia2mp, mafia2online, minecraft,
minecraftbedrock, minecraftvanilla, minetest, mumble, mumbleping, nadeo, openttd, palworld, quake1, quake2, quake3, rfactor, ragemp, samp,
savage2, starmade, starsiege, teamspeak2, teamspeak3, terraria, tribes1, tribes1master, unreal2, ut3, valve,
savage2, starmade, starsiege, teamspeak2, teamspeak3, terraria, tribes1, tribes1master, unreal2, ut3, ut3lan, valve,
vcmp, ventrilo, warsow, eldewrito, beammpmaster, beammp, dayz, theisleevrima, xonotic, altvmp, vintagestorymaster,
vintagestory
}
Loading
Loading