From e72a16788e897eada4101507bdda836b0619b06b Mon Sep 17 00:00:00 2001 From: RattleSN4K3 Date: Wed, 11 Sep 2024 18:03:51 +0200 Subject: [PATCH 1/3] Implement sending broadcast in and receive multi-response in Core and UDP socket --- lib/GlobalUdpSocket.js | 7 ++++++- protocols/core.js | 24 ++++++++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/lib/GlobalUdpSocket.js b/lib/GlobalUdpSocket.js index 98c29412..8cdf8227 100644 --- a/lib/GlobalUdpSocket.js +++ b/lib/GlobalUdpSocket.js @@ -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 + ')-->') diff --git a/protocols/core.js b/protocols/core.js index c03d5d90..44a8e347 100644 --- a/protocols/core.js +++ b/protocols/core.js @@ -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) @@ -253,7 +254,7 @@ 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 @@ -261,18 +262,27 @@ export default class Core extends EventEmitter { 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) @@ -282,6 +292,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() From 2ce6690bd917187023c236344726ea5826e7682f Mon Sep 17 00:00:00 2001 From: RattleSN4K3 Date: Thu, 12 Sep 2024 00:18:15 +0200 Subject: [PATCH 2/3] Add LAN protocol for UT3 (unrealtournament3), including UnrealEngine3 protocol for generic logic Core protocol now has three methods run, prepareRun and finishRun (+ createState and populateState) for custom handling of multi responses on a single (broadcast) query. Changes: ======== Query/Core - User-Options are prioritized over any default/game/protocol options - New method core:updateOptionsDefaults(..), passed with final options to core/protocol to handle given options before the protocol runs - Added core:getOptionsDefaults(outOptions) to provide default options per protocol - Added core:getOptionsOverrides(outOptions) to provide overriding options per protocol - New method core:prepareRun() to run before core:run(state) - New method core:finishRun() to run after core:run(state) - New method core:createState(), for custom handling of creating initial state - New method core:finishState(), for custom handling of finishing the state - New method core:populateState(), for populate the state with queried data (moved from core:run() - Method "setupOptions" to handle build up options list ( New Core protocol for LAN-protocols - Defaults address to 255.255.255.255 - Defaults givenPortOnly to true - Enables broadcast mode based on resolved/given address UnrealEngine3/LAN protocol: Provides basic functionality of parsing UE3 LAN packets send over UDP broadcast - LAN query data does not contain player data - UE3 UDP packet definition is compatible to most UE3 based games, some newer engines support Steam which adds an additional 4-byte value (see packetVersion) - Games can be added by providing packetVersion and gameUniqueId (+ port, see config for [IpDrv.OnlineGameInterfaceImpl] and property LanAnnouncePort) - Some games are using the same LAN port and unique game id (such as UDK SDK or Toxikk), these games need to be differentiated on consuming client) UT3 LAN protocol: - Added query port to be 14001, and sets specific query options for a proper LAN (broadcast) query - Sets packetVersion to 5, and gameUniqueId to 0x4D5707DB Reader: - Added method to read 8-bit double values - Added peek parameter for reader:remaining(..) to check if the reader can read the additional data --- lib/QueryRunner.js | 7 +- lib/games.js | 11 ++ lib/reader.js | 35 +++- protocols/core.js | 104 ++++++++++- protocols/index.js | 3 +- protocols/unrealengine3.js | 317 ++++++++++++++++++++++++++++++++++ protocols/unrealengine3lan.js | 295 +++++++++++++++++++++++++++++++ protocols/ut3.js | 58 ++++--- protocols/ut3lan.js | 12 ++ 9 files changed, 803 insertions(+), 39 deletions(-) create mode 100644 protocols/unrealengine3.js create mode 100644 protocols/unrealengine3lan.js create mode 100644 protocols/ut3lan.js diff --git a/lib/QueryRunner.js b/lib/QueryRunner.js index 2cfe79d8..4504f11e 100644 --- a/lib/QueryRunner.js +++ b/lib/QueryRunner.js @@ -19,6 +19,7 @@ export default class QueryRunner { port: runnerOpts.listenUdpPort }) this.portCache = {} + this.userOptions = {} } async run (userOptions) { @@ -29,6 +30,9 @@ export default class QueryRunner { } } + // cache user options + this.userOptions = userOptions + const { port_query: gameQueryPort, port_query_offset: gameQueryPortOffset, @@ -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() } } diff --git a/lib/games.js b/lib/games.js index 54e82f4c..e85bc6e7 100644 --- a/lib/games.js +++ b/lib/games.js @@ -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, diff --git a/lib/reader.js b/lib/reader.js index 04033fb0..ad0d585c 100644 --- a/lib/reader.js +++ b/lib/reader.js @@ -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) { @@ -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 + * + * Note: remaining can be negative when the current position was exceed due to reading a given sized property + * + * @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 + */ 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 } diff --git a/protocols/core.js b/protocols/core.js index 44a8e347..e2d58809 100644 --- a/protocols/core.js +++ b/protocols/core.js @@ -30,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 @@ -79,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}` @@ -95,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) { @@ -350,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 + } +} diff --git a/protocols/index.js b/protocols/index.js index fa91dc5b..a6683433 100644 --- a/protocols/index.js +++ b/protocols/index.js @@ -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' @@ -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 } diff --git a/protocols/unrealengine3.js b/protocols/unrealengine3.js new file mode 100644 index 00000000..83f749f0 --- /dev/null +++ b/protocols/unrealengine3.js @@ -0,0 +1,317 @@ +import Core from './core.js' + +export const PlatformType = { + Unknown: 0, + Windows: 1, + Xenon: 4, + PS3: 8, + Linux: 16, + MacOSX: 32 +} + +/** The types of advertisement of settings to use */ +// eslint-disable-next-line no-unused-vars +export const EOnlineDataAdvertisementType = { + /** Don't advertise via the online service or QoS data */ + ODAT_DontAdvertise: 0, + /** Advertise via the online service only */ + ODAT_OnlineService: 1, + /** Advertise via the QoS data only */ + ODAT_QoS: 2 +} + +/** The supported data types that can be stored in the union */ +export const ESettingsDataType = { + /** Means the data in the OnlineData value fields should be ignored */ + SDT_Empty: 0, + /** 32 bit integer goes in Value1 only */ + SDT_Int32: 1, + /** 64 bit integer stored in both value fields */ + SDT_Int64: 2, + /** Double (8 byte) stored in both value fields */ + SDT_Double: 3, + /** Unicode string pointer in Value2 with length in Value1 */ + SDT_String: 4, + /** Float (4 byte) stored in Value1 fields */ + SDT_Float: 5, + /** Binary data with count in Value1 and pointer in Value2 */ + SDT_Blob: 6, + /** Date/time structure. Date in Value1 and time Value2 */ + SDT_DateTime: 7 +} + +/** + * @typedef {Object|Uint8Array} UniqueNetId Struct that holds a transient, unique identifier for a player + * @property {number[8]} Uid - The id used by the network to uniquely identify a player + */ + +/** + * @typedef {Object} OnlineGameSettings Holds the base configuration settings for an online game + * @property {number} NumPublicConnections - The number of publicly available connections advertised + * @property {number} NumPrivateConnections - The number of connections that are private (invite/password) only + * @property {number} NumOpenPublicConnections - The number of publicly available connections that are available + * @property {number} NumOpenPrivateConnections - The number of private connections that are available + * + * @property {boolean} bShouldAdvertise - Whether this match is publicly advertised on the online service + * @property {boolean} bIsLanMatch - This game will be lan only and not be visible to external players + * @property {boolean} bUsesStats - Whether the match should gather stats or not + * @property {boolean} bAllowJoinInProgress - Whether joining in progress is allowed or not + * @property {boolean} bAllowInvites - Whether the match allows invitations for this session or not + * @property {boolean} bUsesPresence - Whether to display user presence information or not + * @property {boolean} bAllowJoinViaPresence - Whether joining via player presence is allowed or not + * @property {boolean} bUsesArbitration - Whether the session should use arbitration or not + * + * @property {string} OwningPlayerName - The owner of the game + * @property {UniqueNetId} OwningPlayerId - The unique net id of the player that owns this game +*/ + +/** + * Structure used to represent a string setting that has a restricted and + * localized set of value strings. For instance: + * + * GameType (id) Values = (0) Death Match, (1) Team Death Match, etc. + * + * This allows strings to be transmitted using only 8 bytes and each string + * is correct for the destination language irrespective of sender's language + * @typedef {Object} LocalizedStringSetting + * @property {int} Id - The unique identifier for this localized string + * @property {int} ValueIndex - The unique identifier for this localized string + * @property EOnlineDataAdvertisementType AdvertisementType - How this setting should be presented to requesting clients: online or QoS + */ + +/** + * Structure to hold arbitrary data of a given type. + * @typedef {Object} SettingsData + * @property ESettingsDataType Type - Enum (byte) indicating the type of data held in the value fields + * @property {int} Value1 - This is a union of value types and should never be used in script + * @property {int} Value2 - This is a union of value types and should never be used in script. NOTE: It's declared as a pointer for 64bit systems + * @property {*} ValueRaw - A raw value of the setting + */ + +/** + * Structure used to hold non-localized string data. Properties can be arbitrary types. + * @typedef {Object} SettingsProperty + * @property {int} PropertyId - The unique id for this property + * @property {SettingsData} Data - The data stored for the type + * @property EOnlineDataAdvertisementType AdvertisementType - How this setting should be presented to requesting clients: online or QoS + */ + +/** + * Holds the base properties for the quried data + * @type {OnlineGameSettings} + */ +export const EmptyPayloadData = Object.freeze({ + NumOpenPublicConnections: 0, + NumOpenPrivateConnections: 0, + NumPublicConnections: 0, + NumPrivateConnections: 0, + + bShouldAdvertise: undefined, + bIsLanMatch: undefined, + bUsesStats: undefined, + bAllowJoinInProgress: undefined, + bAllowInvites: undefined, + bUsesPresence: undefined, + bAllowJoinViaPresence: undefined, + bUsesArbitration: undefined, + bAntiCheatProtected: undefined, // in newer packets + + OwningPlayerName: undefined, + OwningPlayerId: undefined +}) + +/** The size of the header for validation */ +export const LAN_BEACON_PACKET_HEADER_SIZE = 16 + +// Offsets for various fields +/* eslint-disable */ +const LAN_BEACON_VER_OFFSET = 0 +const LAN_BEACON_PLATFORM_OFFSET = 1 +const LAN_BEACON_GAMEID_OFFSET = 2 +const LAN_BEACON_PACKETTYPE1_OFFSET = 6 +const LAN_BEACON_PACKETTYPE2_OFFSET = 7 +const LAN_BEACON_NONCE_OFFSET = 8 +/* eslint-enable */ + +/** + * Implements the protocol for UnrealEngine3 based games (UE3) + */ +export default class unrealengine3 extends Core { + /** + * Translates raw properties into known properties + * @param {Object} state Current state data including raw values/properties + */ + static staticPopulateProperties (state) { + const split = (a) => { + let s = a.split('\x1c') + s = s.filter((e) => { return e }) + return s + } + if ('custom_mutators' in state.raw) state.raw.custom_mutators = split(state.raw.custom_mutators) + if ('stock_mutators' in state.raw) state.raw.stock_mutators = split(state.raw.stock_mutators) + if ('map' in state.raw) state.map = state.raw.map + + if ('hostname' in state.raw) state.name = state.raw.hostname + else if ('servername' in state.raw) state.name = state.raw.servername + if ('mapname' in state.raw) state.map = state.raw.mapname + if (state.raw.password === '1') state.password = true + if ('maxplayers' in state.raw) state.maxplayers = parseInt(state.raw.maxplayers) + if ('hostport' in state.raw) state.gamePort = parseInt(state.raw.hostport) + if ('gamever' in state.raw) state.version = state.raw.gamever + } + + /** + * Generates a random client + * @param {number} length The length of the random Id + * @returns a length-byte-sized unique client Id + */ + static generateNonce (length) { + const nonce = new Uint8Array(length) + for (let i = 0; i < length; i++) { + nonce[i] = Math.floor(Math.random() * 256) + } + return nonce + } + + /** + * Parses/reads a UE3 string at the current position of the reader + * @param {*} reader the reader to read data from + * @returns a string of unlimited size + */ + static readString (reader) { + const stringLen = reader.uint(4) + const stringContent = reader.string(stringLen) + return stringContent + } + + /** + * Parses/reads a UE3 string at the current position of the reader + * @param {*} reader the reader to read data from + * @returns {UniqueNetId} unique identifier + */ + static readUniqueNetId (reader) { + const Uids = reader.part(8) + const netId = new Uint8Array(Uids) + return netId + } + + /** + * Parses/reads a UE3 localized setting at the current position of the reader + * @param {*} reader the reader to read data from + * @returns {LocalizedStringSetting} localized setting + */ + static readLocalizedStringSetting (reader) { + const fId = reader.int(4) + const fValueIndex = reader.int(4) + const fAdvertisementType = reader.uint(1) + return { + Id: fId, + ValueIndex: fValueIndex, + AdvertisementType: fAdvertisementType + } + } + + /** + * Parses/reads a UE3 non-localized property setting at the current position of the reader + * @param {*} reader the reader to read data from + * @returns {SettingsProperty} non-localized property setting + */ + static readSettingsProperty (reader) { + const fPropertyId = reader.uint(4) + const fData = unrealengine3.readSettingsData(reader) + const fAdvertisementType = reader.uint(1) + // build object + return { + PropertyId: fPropertyId, + Data: fData, + AdvertisementType: fAdvertisementType + } + } + + /** + * Parses/reads a UE3 non-localized settings data at the current position of the reader + * @param {*} reader the reader to read data from + * @returns {SettingsData} non-localized settings data + */ + static readSettingsData (reader) { + let val + const data = { + Type: ESettingsDataType.SDT_Empty, + ValueRaw: 0, + Value1: 0, + Value2: 0, + + cleanup () { + // Strings are copied so make sure to delete them + if (this.Type === ESettingsDataType.SDT_String) { + delete this.Value2 + } else if (this.Type === ESettingsDataType.SDT_Blob) { + delete this.Value2 + } + this.Type = ESettingsDataType.SDT_Empty + this.Value1 = 0 + this.Value2 = null + this.ValueRaw = this.Value1 + }, + + setDataDouble (InData) { + this.cleanup() + + // Convert DOUBLE to a 64-bit integer representation + const buffer = new ArrayBuffer(8) + const view = new DataView(buffer) + view.setFloat64(0, InData, true) + + // Get high/low parts + const FullData = view.getBigUint64(0, true) + this.Value1 = Number((FullData >> 32n) & 0xFFFFFFFFn) + this.Value2 = Number(FullData & 0xFFFFFFFFn) + }, + + setDataFloat (InData) { + this.cleanup() + + // Convert FLOAT to a 32-bit integer representation + const buffer = new ArrayBuffer(4) + const view = new DataView(buffer) + view.setFloat32(0, InData, true) + + // Get the 32-bit integer representation + this.Value1 = view.getInt32(0, true) + } + } + + // Read the type + const type = reader.uint(1) + data.Type = type + + // Now read the held data + switch (type) { + case ESettingsDataType.SDT_Float: + val = reader.float() + data.setDataFloat(val) + break + case ESettingsDataType.SDT_Int32: + val = reader.int(4) + // Data.SetData(valInt) + break + case ESettingsDataType.SDT_Int64: + val = reader.int(8) + // todo Data.Set... + break + case ESettingsDataType.SDT_Double: + val = reader.double() + data.SetData(val) + break + case ESettingsDataType.SDT_Blob: + // TODO: Add parsing blob data + throw new Error('Reading blob data is currently not supported') + case ESettingsDataType.SDT_String: + val = unrealengine3.readString(reader) + // Data.SetData(Val); + break + } + data.ValueRaw = val + return data + } +} diff --git a/protocols/unrealengine3lan.js b/protocols/unrealengine3lan.js new file mode 100644 index 00000000..665180fa --- /dev/null +++ b/protocols/unrealengine3lan.js @@ -0,0 +1,295 @@ +import { CoreLAN } from './core.js' +import unrealengine3, * as UE3 from './unrealengine3.js' + +function parseNumber (str) { + const number = +str + return number +} + +/** + * Implements the LAN protocol for UnrealEngine3 based games (UE3) + */ +export default class unrealengine3lan extends CoreLAN { + constructor () { + super() + this.sessionId = 1 + this.encoding = 'latin1' + this.byteorder = 'be' + this.useOnlySingleSplit = false + this.isJc2mp = false + + this.translateMap = {} + + this.packetVersion = 1 + this.gameUniqueId = 0x00000000 + this.platform = UE3.PlatformType.Windows + + this.packetTypesQuery1 = 'S' + this.packetTypesQuery2 = 'Q' + this.packetTypesResponse1 = 'S' + this.packetTypesResponse2 = 'R' + + // generate unique client id + this.clientNonce = unrealengine3.generateNonce(8) + } + + /** @override */ + getOptionsOverrides (outOptions) { + super.getOptionsOverrides(outOptions) + const defaults = { + port: 14001 + } + Object.assign(outOptions, defaults) + } + + /** @override */ + updateOptionsDefaults () { + super.updateOptionsDefaults() + + // update constructor values from options manually + const { packetVersion, lanGameUniqueId, lanPacketPlatformMask } = this.options + this.packetVersion = packetVersion ?? this.packetVersion + this.gameUniqueId = lanGameUniqueId ? parseNumber(lanGameUniqueId) : this.gameUniqueId + this.platform = lanPacketPlatformMask ?? this.platform + } + + async run (state) { + const { outputAsArray = false } = this.options + + // send single request and handle multi response + const buffer = await this.sendPacket(this.packetVersion, this.gameUniqueId, false, false) + const bufferList = Array.isArray(buffer) ? buffer : [buffer] + const packets = bufferList.map((packet) => this.parsePacket(packet)) + + // build response objects using Core logic's population + const resultStates = packets.map(packet => { + const packetState = { ...packet } + this.populateState(packetState) + return packetState + }) + + // either return as array, or linked list (defaults to linked list) + if (outputAsArray && Array.isArray(state)) { + state.push(...resultStates) + } else { + const firstPacket = resultStates.length ? { ...resultStates[0], $next: resultStates.slice(1).reduceRight((next, value) => ({ ...value, $next: next }), null) } : null + Object.assign(state, firstPacket) + } + } + + /** @override */ + prepareRun () { + // initially create an array as response as broadcast may result into multiple respones + const { outputAsArray = false } = this.options + return outputAsArray ? [] : {} + } + + /** @override */ + finishRun (state) { + // do nothing, state is already populated + } + + /** + * Sends initial query packet to receive a server response from any machine in the current subnet + * @param {byte} type Packet version + * @param {*} gameUniqueId Game Id typically unique for every game, some games share the same id (see LanGameUniqueId) + * @returns list of valid responses + */ + async sendPacket (type, gameUniqueId) { + const b = Buffer.alloc(16) + let offset = 0 + offset = b.writeUint8(type, offset) + offset = b.writeUint8(this.platform, offset) + offset = b.writeInt32BE(gameUniqueId, offset) + offset += b.write(this.packetTypesQuery1, offset, 1, 'ascii') + offset += b.write(this.packetTypesQuery2, offset, 1, 'ascii') + Buffer.from(this.clientNonce.buffer).copy(b, offset); offset += this.clientNonce.length + + return await this.udpSend(b, (buffer) => { + if (this.isValidLanResponsePacket(buffer)) { + return buffer + } + }) + } + + /** + * Parses the packet from given buffer + * @param {Buffer} buffer the current buffer to parse the packet data from + * @returns Parsed and translated server response object + */ + parsePacket (buffer) { + // create default empty state + const state = this.createState() + Object.assign(state, UE3.EmptyPayloadData) + + const fullReader = this.reader(buffer) + const packetVersion = fullReader.uint(1) + fullReader.skip(UE3.LAN_BEACON_PACKET_HEADER_SIZE - 1) + const payload = fullReader.rest() + + const reader = this.reader(payload) + + // read session info + const ip = reader.uint(4) + const port = reader.uint(4) + const ipStr = (ip >> 24 & 255) + '.' + (ip >> 16 & 255) + '.' + (ip >> 8 & 255) + '.' + (ip & 255) + state.raw.hostaddress = ipStr + state.raw.hostport = port + + state.raw.NumOpenPublicConnections = reader.uint(4) + state.raw.NumOpenPrivateConnections = reader.uint(4) + state.raw.NumPublicConnections = reader.uint(4) + state.raw.NumPrivateConnections = reader.uint(4) + + // new packets seem to have an additional bool/byte field, + // flags generally consist of 8 1-byte/bool values + state.raw.bShouldAdvertise = reader.uint(1) === 1 + state.raw.bIsLanMatch = reader.uint(1) === 1 + state.raw.bUsesStats = reader.uint(1) === 1 + state.raw.bAllowJoinInProgress = reader.uint(1) === 1 + state.raw.bAllowInvites = reader.uint(1) === 1 + state.raw.bUsesPresence = reader.uint(1) === 1 + state.raw.bAllowJoinViaPresence = reader.uint(1) === 1 + state.raw.bUsesArbitration = reader.uint(1) === 1 + if (packetVersion >= 5) { + // read additional flag for newer packets + state.raw.bAntiCheatProtected = reader.uint(1) === 1 + } + + // Read the owning player id + state.raw.OwningPlayerId = unrealengine3.readUniqueNetId(reader) + // Read the owning player name + state.raw.OwningPlayerName = unrealengine3.readString(reader) + + // properties from the advertised settings + const localizedProperties = [] + state.raw.LocalizedProperties = localizedProperties + const NumAdvertisedProperties = reader.uint(4) + if (reader.remaining() > 0) { // check if overflown + for (let index = 0; index < NumAdvertisedProperties && reader.remaining() > 0; index++) { + // parse and add property to array + const property = unrealengine3.readLocalizedStringSetting(reader) + localizedProperties.push(property) + } + } + + // Now read the contexts and properties from the settings class + const properties = [] + state.raw.Properties = properties + const NumProperties = reader.uint(4) + if (reader.remaining() > 0) { // check if overflown + for (let index = 0; index < NumProperties && reader.remaining() > 0; index++) { + // parse and add property to array + const property = unrealengine3.readSettingsProperty(reader) + properties.push(property) + } + } + + // if data could not be processed properly, meaning some specific properties cannot be read + // the current position might exceed the buffer, remaining() will be negative for such case + if (reader.remaining() < 0) { + // clear array + properties.length = 0 + localizedProperties.length = 0 + } + + // Turn all that raw state into something useful + this.populateProperties(state) + // DEBUG: delete state.raw + + return state + } + + /** + * Translates raw properties into known properties + * @param {Object} state Parsed data + */ + populateProperties (state) { + // pass raw data + state.gameHost = state.raw.hostaddress + state.gamePort = state.raw.hostport + + state.name = state.raw.OwningPlayerName + state.maxplayers = state.raw.NumOpenPublicConnections + + state.NumOpenPublicConnections = state.raw.NumOpenPublicConnections + state.NumOpenPrivateConnections = state.raw.NumOpenPrivateConnections + state.NumPublicConnections = state.raw.NumPublicConnections + state.NumPrivateConnections = state.raw.NumPrivateConnections + + state.bShouldAdvertise = state.raw.bShouldAdvertise + state.bIsLanMatch = state.raw.bIsLanMatch + state.bUsesStats = state.raw.bUsesStats + state.bAllowJoinInProgress = state.raw.bAllowJoinInProgress + state.bAllowInvites = state.raw.bAllowInvites + state.bUsesPresence = state.raw.bUsesPresence + state.bAllowJoinViaPresence = state.raw.bAllowJoinViaPresence + state.bUsesArbitration = state.raw.bUsesArbitration + state.bAntiCheatProtected = state.raw.bAntiCheatProtected + + state.OwningPlayerId = Buffer.from(state.raw.OwningPlayerId).toString('hex') + state.OwningPlayerName = state.raw.OwningPlayerName + + // manually transform serialized properties into known structure + const props = state.raw.Properties?.reduce((acc, prop) => { + acc[`p${prop.PropertyId}`] = prop.Data.ValueRaw + return acc + }, {}) + + // manually transform serialized localized properties into known structure + const propsLocalized = state.raw.LocalizedProperties?.reduce((acc, prop) => { + acc[`s${prop.Id}`] = prop.ValueIndex // TOOD: find actual value + return acc + }, {}) + + // translate properties + state.raw = { ...state.raw, ...props, ...propsLocalized } + this.translate(state.raw, this.translateMap) + + // Turn all that raw state into something useful + unrealengine3.staticPopulateProperties(state) + + if (!this.debug) { + delete state.raw.LocalizedProperties + delete state.raw.Properties + } + } + + /** + * Checks if the given packet is a valid response packet for the current client + * @param {Buffer} buffer the current buffer to parse the packet data from + * @returns true if the packet is valid and can be parsed + */ + isValidLanResponsePacket (buffer) { + let bIsValid = false + const bufferLength = (buffer ? buffer.length : null) ?? 0 + + // Serialize out the data if the packet is the right size + if (bufferLength > UE3.LAN_BEACON_PACKET_HEADER_SIZE) { + const reader = this.reader(buffer) + + // version mismatch? + const iVersion = reader.uint(1) + if (iVersion === this.packetVersion) { + // same platform? + const iPlatform = reader.uint(1) + if (iPlatform === this.platform) { + // is response from same game? + const iGameId = reader.int(4) + if (iGameId === this.gameUniqueId) { + const cServerResponse1 = reader.string(1) + const cServerResponse2 = reader.string(1) + if (cServerResponse1 === this.packetTypesResponse1 && cServerResponse2 === this.packetTypesResponse2) { + // is response from same client? + const nonceRaw = reader.part(8) + const nonceHex = nonceRaw.toString('hex') + const clientNonceHex = Buffer.from(this.clientNonce).toString('hex') + bIsValid = (nonceHex === clientNonceHex) + } + } + } + } + } + return bIsValid + } +} diff --git a/protocols/ut3.js b/protocols/ut3.js index 852f832d..6bca9fec 100644 --- a/protocols/ut3.js +++ b/protocols/ut3.js @@ -1,37 +1,39 @@ import gamespy3 from './gamespy3.js' +export const TranslateMapUT3 = Object.freeze({ + mapname: false, + p1073741825: 'map', + p1073741826: 'gametype', + p1073741827: 'servername', + p1073741828: 'custom_mutators', + gamemode: 'joininprogress', + s32779: 'gamemode', + s0: 'bot_skill', + s6: 'pure_server', + s7: 'password', + s8: 'vs_bots', + s10: 'force_respawn', + p268435704: 'frag_limit', + p268435705: 'time_limit', + p268435703: 'numbots', + p268435717: 'stock_mutators', + p1073741829: 'stock_mutators', + s1: false, + s9: false, + s11: false, + s12: false, + s13: false, + s14: false, + p268435706: false, + p268435968: false, + p268435969: false +}) + export default class ut3 extends gamespy3 { async run (state) { await super.run(state) - this.translate(state.raw, { - mapname: false, - p1073741825: 'map', - p1073741826: 'gametype', - p1073741827: 'servername', - p1073741828: 'custom_mutators', - gamemode: 'joininprogress', - s32779: 'gamemode', - s0: 'bot_skill', - s6: 'pure_server', - s7: 'password', - s8: 'vs_bots', - s10: 'force_respawn', - p268435704: 'frag_limit', - p268435705: 'time_limit', - p268435703: 'numbots', - p268435717: 'stock_mutators', - p1073741829: 'stock_mutators', - s1: false, - s9: false, - s11: false, - s12: false, - s13: false, - s14: false, - p268435706: false, - p268435968: false, - p268435969: false - }) + this.translate(state.raw, TranslateMapUT3) const split = (a) => { let s = a.split('\x1c') diff --git a/protocols/ut3lan.js b/protocols/ut3lan.js new file mode 100644 index 00000000..156b1d06 --- /dev/null +++ b/protocols/ut3lan.js @@ -0,0 +1,12 @@ +import unrealengine3lan from './unrealengine3lan.js' +import { TranslateMapUT3 } from './ut3.js' + +/** + * Implements the LAN protocol for UT3 + */ +export default class ut3lan extends unrealengine3lan { + constructor () { + super() + this.translateMap = { ...TranslateMapUT3 } + } +} From 1365535ed6bd7102d5509ba1b846a994b6d6bdfd Mon Sep 17 00:00:00 2001 From: RattleSN4K3 Date: Tue, 17 Sep 2024 21:24:05 +0200 Subject: [PATCH 3/3] Pass online settings into raw state, state compliant to standard query --- protocols/unrealengine3.js | 29 ++++++++++ protocols/unrealengine3lan.js | 101 +++++++++++++++++++++------------- 2 files changed, 91 insertions(+), 39 deletions(-) diff --git a/protocols/unrealengine3.js b/protocols/unrealengine3.js index 83f749f0..f6c7428d 100644 --- a/protocols/unrealengine3.js +++ b/protocols/unrealengine3.js @@ -158,6 +158,35 @@ export default class unrealengine3 extends Core { if ('maxplayers' in state.raw) state.maxplayers = parseInt(state.raw.maxplayers) if ('hostport' in state.raw) state.gamePort = parseInt(state.raw.hostport) if ('gamever' in state.raw) state.version = state.raw.gamever + + if (state.raw.playerTeamInfo && '' in state.raw.playerTeamInfo) { + for (const playerInfo of state.raw.playerTeamInfo['']) { + const player = {} + for (const from of Object.keys(playerInfo)) { + let key = from + let value = playerInfo[from] + + if (key === 'player') key = 'name' + if (key === 'score' || key === 'ping' || key === 'team' || key === 'deaths' || key === 'pid') value = parseInt(value) + player[key] = value + } + state.players.push(player) + } + } + + if ('numplayers' in state.raw) state.numplayers = parseInt(state.raw.numplayers) + else state.numplayers = state.players.length + } + + /** + * Converts a UE3 unique id to a string + * @param {UniqueNetId} reader the unique net idenitifer + * @returns {string} a converted unique identifier + */ + static UniqueNetIdToString (uniqueNetId) { + const bytes = ([...uniqueNetId]).reverse() + const value = bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3] + return String(value) } /** diff --git a/protocols/unrealengine3lan.js b/protocols/unrealengine3lan.js index 665180fa..b2a91e65 100644 --- a/protocols/unrealengine3lan.js +++ b/protocols/unrealengine3lan.js @@ -6,6 +6,13 @@ function parseNumber (str) { return number } +function anyToString (obj) { + if (typeof obj === 'boolean') { + return obj ? 'True' : 'False' + } + return String(obj) +} + /** * Implements the LAN protocol for UnrealEngine3 based games (UE3) */ @@ -72,7 +79,21 @@ export default class unrealengine3lan extends CoreLAN { if (outputAsArray && Array.isArray(state)) { state.push(...resultStates) } else { - const firstPacket = resultStates.length ? { ...resultStates[0], $next: resultStates.slice(1).reduceRight((next, value) => ({ ...value, $next: next }), null) } : null + // add linker as state root + // const firstPacket = resultStates.length ? { ...resultStates[0], $next: resultStates.slice(1).reduceRight((next, value) => ({ ...value, $next: next }), null) } : null + + // add linker into state.raw + const firstPacket = resultStates?.[0] + if (resultStates.length) { + const nextPacket = resultStates.slice(1).reduceRight((next, value) => { + // return { ...value, $next: next } + Object.assign(value.raw, { $next: next }) + return value + }, null) + + Object.assign(firstPacket.raw, { $next: nextPacket }) + } + Object.assign(state, firstPacket) } } @@ -120,7 +141,9 @@ export default class unrealengine3lan extends CoreLAN { parsePacket (buffer) { // create default empty state const state = this.createState() - Object.assign(state, UE3.EmptyPayloadData) + + // create empty game settings + const OnlineGameSettings = { ...UE3.EmptyPayloadData } const fullReader = this.reader(buffer) const packetVersion = fullReader.uint(1) @@ -136,30 +159,31 @@ export default class unrealengine3lan extends CoreLAN { state.raw.hostaddress = ipStr state.raw.hostport = port - state.raw.NumOpenPublicConnections = reader.uint(4) - state.raw.NumOpenPrivateConnections = reader.uint(4) - state.raw.NumPublicConnections = reader.uint(4) - state.raw.NumPrivateConnections = reader.uint(4) + OnlineGameSettings.NumOpenPrivateConnections = 1 + OnlineGameSettings.NumOpenPublicConnections = reader.uint(4) + OnlineGameSettings.NumOpenPrivateConnections = reader.uint(4) + OnlineGameSettings.NumPublicConnections = reader.uint(4) + OnlineGameSettings.NumPrivateConnections = reader.uint(4) // new packets seem to have an additional bool/byte field, // flags generally consist of 8 1-byte/bool values - state.raw.bShouldAdvertise = reader.uint(1) === 1 - state.raw.bIsLanMatch = reader.uint(1) === 1 - state.raw.bUsesStats = reader.uint(1) === 1 - state.raw.bAllowJoinInProgress = reader.uint(1) === 1 - state.raw.bAllowInvites = reader.uint(1) === 1 - state.raw.bUsesPresence = reader.uint(1) === 1 - state.raw.bAllowJoinViaPresence = reader.uint(1) === 1 - state.raw.bUsesArbitration = reader.uint(1) === 1 + OnlineGameSettings.bShouldAdvertise = reader.uint(1) === 1 + OnlineGameSettings.bIsLanMatch = reader.uint(1) === 1 + OnlineGameSettings.bUsesStats = reader.uint(1) === 1 + OnlineGameSettings.bAllowJoinInProgress = reader.uint(1) === 1 + OnlineGameSettings.bAllowInvites = reader.uint(1) === 1 + OnlineGameSettings.bUsesPresence = reader.uint(1) === 1 + OnlineGameSettings.bAllowJoinViaPresence = reader.uint(1) === 1 + OnlineGameSettings.bUsesArbitration = reader.uint(1) === 1 if (packetVersion >= 5) { // read additional flag for newer packets - state.raw.bAntiCheatProtected = reader.uint(1) === 1 + OnlineGameSettings.bAntiCheatProtected = reader.uint(1) === 1 } // Read the owning player id - state.raw.OwningPlayerId = unrealengine3.readUniqueNetId(reader) + OnlineGameSettings.OwningPlayerId = unrealengine3.readUniqueNetId(reader) // Read the owning player name - state.raw.OwningPlayerName = unrealengine3.readString(reader) + OnlineGameSettings.OwningPlayerName = unrealengine3.readString(reader) // properties from the advertised settings const localizedProperties = [] @@ -194,8 +218,8 @@ export default class unrealengine3lan extends CoreLAN { } // Turn all that raw state into something useful + state.raw.session = OnlineGameSettings this.populateProperties(state) - // DEBUG: delete state.raw return state } @@ -205,46 +229,45 @@ export default class unrealengine3lan extends CoreLAN { * @param {Object} state Parsed data */ populateProperties (state) { + const { session } = state.raw + // pass raw data state.gameHost = state.raw.hostaddress state.gamePort = state.raw.hostport - state.name = state.raw.OwningPlayerName - state.maxplayers = state.raw.NumOpenPublicConnections + session.TotalOpenConnections = (session?.NumOpenPublicConnections || 0) + (session?.NumOpenPrivateConnections || 0) + session.TotalConnections = (session?.NumPublicConnections || 0) + (session?.NumPrivateConnections || 0) - state.NumOpenPublicConnections = state.raw.NumOpenPublicConnections - state.NumOpenPrivateConnections = state.raw.NumOpenPrivateConnections - state.NumPublicConnections = state.raw.NumPublicConnections - state.NumPrivateConnections = state.raw.NumPrivateConnections + state.name = session?.OwningPlayerName + state.raw.maxplayers = (session.TotalConnections).toString() + state.raw.numplayers = (session.TotalConnections - session.TotalOpenConnections).toString() - state.bShouldAdvertise = state.raw.bShouldAdvertise - state.bIsLanMatch = state.raw.bIsLanMatch - state.bUsesStats = state.raw.bUsesStats - state.bAllowJoinInProgress = state.raw.bAllowJoinInProgress - state.bAllowInvites = state.raw.bAllowInvites - state.bUsesPresence = state.raw.bUsesPresence - state.bAllowJoinViaPresence = state.raw.bAllowJoinViaPresence - state.bUsesArbitration = state.raw.bUsesArbitration - state.bAntiCheatProtected = state.raw.bAntiCheatProtected + // pass specific session fields - state.OwningPlayerId = Buffer.from(state.raw.OwningPlayerId).toString('hex') - state.OwningPlayerName = state.raw.OwningPlayerName + // replace uniqueid with stringified id + session.OwningPlayerId = unrealengine3.UniqueNetIdToString(session?.OwningPlayerId) + state.raw.OwningPlayerId = session.OwningPlayerId + state.raw.OwningPlayerName = session.OwningPlayerName + state.raw.bUsesStats = anyToString(session.bUsesStats || false) + state.raw.bIsDedicated = anyToString(session.bIsDedicated || false) + state.raw.NumPublicConnections = anyToString(session.NumPublicConnections) // manually transform serialized properties into known structure const props = state.raw.Properties?.reduce((acc, prop) => { - acc[`p${prop.PropertyId}`] = prop.Data.ValueRaw + acc[`p${prop.PropertyId}`] = String(prop.Data.ValueRaw) // force string return acc }, {}) // manually transform serialized localized properties into known structure const propsLocalized = state.raw.LocalizedProperties?.reduce((acc, prop) => { - acc[`s${prop.Id}`] = prop.ValueIndex // TOOD: find actual value + acc[`s${prop.Id}`] = String(prop.ValueIndex) // force string return acc }, {}) // translate properties - state.raw = { ...state.raw, ...props, ...propsLocalized } - this.translate(state.raw, this.translateMap) + const stateRaw = { ...state.raw, ...props, ...propsLocalized } + Object.assign(state.raw, stateRaw) + this.translate(state.raw, this.translateMap) // Note: Legacy handling of query values // Turn all that raw state into something useful unrealengine3.staticPopulateProperties(state)