diff --git a/dedicated.mjs b/dedicated.mjs index 69d0d5f..aab3645 100755 --- a/dedicated.mjs +++ b/dedicated.mjs @@ -1,8 +1,12 @@ #!/usr/bin/env node import process from 'node:process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import EngineLauncher from './source/engine/main-dedicated.mjs'; -// make sure working directory is the directory of this script -process.chdir(new URL('./', import.meta.url).pathname); +// When running from dist/dedicated.mjs, use repository root as cwd. +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const rootDir = scriptDir.endsWith('/dist') ? resolve(scriptDir, '..') : scriptDir; +process.chdir(rootDir); await EngineLauncher.Launch(); diff --git a/package.json b/package.json index d298f66..f9b380c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "dev": "vite build --watch --mode development", "build": "vite build", "build:production": "vite build --mode production", + "build:dedicated": "vite build --config vite.config.dedicated.mjs", + "build:dedicated:production": "vite build --config vite.config.dedicated.mjs --mode production", "watch": "vite build --watch", "deploy": "npm run build:production && wrangler deploy", "lint": "eslint source", diff --git a/source/engine/common/Pmove.mjs b/source/engine/common/Pmove.mjs index 9630655..ef787fc 100644 --- a/source/engine/common/Pmove.mjs +++ b/source/engine/common/Pmove.mjs @@ -1476,6 +1476,30 @@ export class PmovePlayer { // pmove_t (player state only) this._pmove_wf = new WeakRef(pmove); } + // --- Scratch vectors for hot path movement methods --- + #pitchedAngles = new Vector(); + #categorizePoint = new Vector(); + #specialFlatForward = new Vector(); + #specialSpot = new Vector(); + #specialLadderPoint = new Vector(); + #specialWaterJumpSpot = new Vector(); + #airForward = new Vector(); + #airRight = new Vector(); + #airWishvel = new Vector(); + #airWishdir = new Vector(); + #waterWishvel = new Vector(); + #waterWishdir = new Vector(); + #waterDest = new Vector(); + #waterStart = new Vector(); + #flyForward = new Vector(); + #flyRight = new Vector(); + #flyWishvel = new Vector(); + #flyWishdir = new Vector(); + #frictionStart = new Vector(); + #frictionStop = new Vector(); + #snapBase = new Vector(); + #nudgeBase = new Vector(); + /** @returns {Pmove} parent Pmove instance @private */ get _pmove() { return this._pmove_wf.deref(); @@ -1585,7 +1609,7 @@ export class PmovePlayer { // pmove_t (player state only) const divisor = this._pmove.configuration.pitchDivisor; if (divisor) { - const pitchedAngles = this.angles.copy(); + const pitchedAngles = this.#pitchedAngles.set(this.angles); let pitch = pitchedAngles[0]; if (pitch > 180) { pitch -= 360; @@ -1666,7 +1690,7 @@ export class PmovePlayer { // pmove_t (player state only) /** Determine ground entity, water type and water level. */ _categorizePosition() { // Q2: PM_CatagorizePosition // --- Ground check --- - const point = this.origin.copy(); + const point = this.#categorizePoint.set(this.origin); point[2] -= this._pmove.configuration.groundCheckDepth; if (this.velocity[2] > 180) { @@ -1760,15 +1784,21 @@ export class PmovePlayer { // pmove_t (player state only) this._ladder = false; // check for ladder - const flatforward = new Vector(this._angleVectors.forward[0], this._angleVectors.forward[1], 0); + const flatforward = this.#specialFlatForward; + flatforward[0] = this._angleVectors.forward[0]; + flatforward[1] = this._angleVectors.forward[1]; + flatforward[2] = 0; flatforward.normalize(); - const spot = this.origin.copy().add(flatforward); + const spot = this.#specialSpot.set(this.origin).add(flatforward); let trace = this._pmove.clipPlayerMove(this.origin, spot); if (trace.fraction < 1) { // Q2 checks trace.contents & CONTENTS_LADDER, we use content type - const ladderPoint = trace.endpos.copy().add(flatforward.copy().multiply(0.5)); + const ladderPoint = this.#specialLadderPoint; + ladderPoint[0] = trace.endpos[0] + flatforward[0] * 0.5; + ladderPoint[1] = trace.endpos[1] + flatforward[1] * 0.5; + ladderPoint[2] = trace.endpos[2] + flatforward[2] * 0.5; const ladderContents = this._pmove.pointContents(ladderPoint); // In Q1 BSP, there is no CONTENTS_LADDER. Ladder detection should // be implemented via trigger_ladder entities or texture flags. @@ -1788,7 +1818,10 @@ export class PmovePlayer { // pmove_t (player state only) } // probe forward for a solid wall, then check for empty space above it. - const wjspot = this.origin.copy().add(flatforward.copy().multiply(this._pmove.configuration.forwardProbe)); + const wjspot = this.#specialWaterJumpSpot; + wjspot[0] = this.origin[0] + flatforward[0] * this._pmove.configuration.forwardProbe; + wjspot[1] = this.origin[1] + flatforward[1] * this._pmove.configuration.forwardProbe; + wjspot[2] = this.origin[2] + flatforward[2] * this._pmove.configuration.forwardProbe; wjspot[2] += this._pmove.configuration.wallcheckZ; let cont = this._pmove.pointContents(wjspot); @@ -1923,12 +1956,15 @@ export class PmovePlayer { // pmove_t (player state only) // Q1: if the leading edge is over a dropoff, increase friction if (this._pmove.configuration.edgeFriction && this.onground !== null) { - const start = new Vector( - this.origin[0] + vel[0] / speed * 16, - this.origin[1] + vel[1] / speed * 16, - this.origin[2] + Pmove.PLAYER_MINS[2], - ); - const stop = new Vector(start[0], start[1], start[2] - 34); + const start = this.#frictionStart; + const stop = this.#frictionStop; + const invSpeed = 1.0 / speed; + start[0] = this.origin[0] + vel[0] * invSpeed * 16; + start[1] = this.origin[1] + vel[1] * invSpeed * 16; + start[2] = this.origin[2] + Pmove.PLAYER_MINS[2]; + stop[0] = start[0]; + stop[1] = start[1]; + stop[2] = start[2] - 34; const edgeTrace = this._pmove.clipPlayerMove(start, stop); if (edgeTrace.fraction === 1.0) { friction *= this._pmove.movevars.edgefriction; @@ -2342,27 +2378,29 @@ export class PmovePlayer { // pmove_t (player state only) // Project forward/right onto the horizontal plane and renormalize. // This prevents looking up/down from reducing horizontal move speed. - const forward = this._angleVectors.forward.copy(); - const right = this._angleVectors.right.copy(); + const forward = this.#airForward.set(this._angleVectors.forward); + const right = this.#airRight.set(this._angleVectors.right); forward[2] = 0; right[2] = 0; forward.normalize(); right.normalize(); - const wishvel = new Vector( - forward[0] * fmove + right[0] * smove, - forward[1] * fmove + right[1] * smove, - 0, - ); + const wishvel = this.#airWishvel; + wishvel[0] = forward[0] * fmove + right[0] * smove; + wishvel[1] = forward[1] * fmove + right[1] * smove; + wishvel[2] = 0; - const wishdir = wishvel.copy(); + const wishdir = this.#airWishdir.set(wishvel); let wishspeed = wishdir.normalize(); // clamp to server defined max speed const maxspeed = (this.pmFlags & PMF.DUCKED) ? this._pmove.movevars.duckspeed : this._pmove.movevars.maxspeed; if (wishspeed > maxspeed) { - wishvel.multiply(maxspeed / wishspeed); + const scale = maxspeed / wishspeed; + wishvel[0] *= scale; + wishvel[1] *= scale; + wishvel[2] *= scale; wishspeed = maxspeed; } @@ -2424,11 +2462,10 @@ export class PmovePlayer { // pmove_t (player state only) const forward = this._angleVectors.forward; const right = this._angleVectors.right; - const wishvel = new Vector( - forward[0] * this.cmd.forwardmove + right[0] * this.cmd.sidemove, - forward[1] * this.cmd.forwardmove + right[1] * this.cmd.sidemove, - forward[2] * this.cmd.forwardmove + right[2] * this.cmd.sidemove, - ); + const wishvel = this.#waterWishvel; + wishvel[0] = forward[0] * this.cmd.forwardmove + right[0] * this.cmd.sidemove; + wishvel[1] = forward[1] * this.cmd.forwardmove + right[1] * this.cmd.sidemove; + wishvel[2] = forward[2] * this.cmd.forwardmove + right[2] * this.cmd.sidemove; if (!this.cmd.forwardmove && !this.cmd.sidemove && !this.cmd.upmove) { wishvel[2] -= 60; // drift towards bottom @@ -2436,11 +2473,14 @@ export class PmovePlayer { // pmove_t (player state only) wishvel[2] += this.cmd.upmove; } - const wishdir = wishvel.copy(); + const wishdir = this.#waterWishdir.set(wishvel); let wishspeed = wishdir.normalize(); if (wishspeed > this._pmove.movevars.maxspeed) { - wishvel.multiply(this._pmove.movevars.maxspeed / wishspeed); + const scale = this._pmove.movevars.maxspeed / wishspeed; + wishvel[0] *= scale; + wishvel[1] *= scale; + wishvel[2] *= scale; wishspeed = this._pmove.movevars.maxspeed; } @@ -2452,12 +2492,11 @@ export class PmovePlayer { // pmove_t (player state only) // from STEPSIZE+1 above it straight down. If the trace succeeds the // player “steps up” onto a ledge or slope, allowing them to climb out // of the water at low edges. Only fall back to slideMove on failure. - const dest = new Vector( - this.origin[0] + this.frametime * this.velocity[0], - this.origin[1] + this.frametime * this.velocity[1], - this.origin[2] + this.frametime * this.velocity[2], - ); - const start = dest.copy(); + const dest = this.#waterDest; + dest[0] = this.origin[0] + this.frametime * this.velocity[0]; + dest[1] = this.origin[1] + this.frametime * this.velocity[1]; + dest[2] = this.origin[2] + this.frametime * this.velocity[2]; + const start = this.#waterStart.set(dest); start[2] += STEPSIZE + 1; const trace = this._pmove.clipPlayerMove(start, dest); @@ -2508,23 +2547,25 @@ export class PmovePlayer { // pmove_t (player state only) const fmove = this.cmd.forwardmove; const smove = this.cmd.sidemove; - const fwd = this._angleVectors.forward.copy(); - const rgt = this._angleVectors.right.copy(); + const fwd = this.#flyForward.set(this._angleVectors.forward); + const rgt = this.#flyRight.set(this._angleVectors.right); fwd.normalize(); rgt.normalize(); - const wishvel = new Vector( - fwd[0] * fmove + rgt[0] * smove, - fwd[1] * fmove + rgt[1] * smove, - fwd[2] * fmove + rgt[2] * smove, - ); + const wishvel = this.#flyWishvel; + wishvel[0] = fwd[0] * fmove + rgt[0] * smove; + wishvel[1] = fwd[1] * fmove + rgt[1] * smove; + wishvel[2] = fwd[2] * fmove + rgt[2] * smove; wishvel[2] += this.cmd.upmove; - const wishdir = wishvel.copy(); + const wishdir = this.#flyWishdir.set(wishvel); let wishspeed = wishdir.normalize(); if (wishspeed > this._pmove.movevars.spectatormaxspeed) { - wishvel.multiply(this._pmove.movevars.spectatormaxspeed / wishspeed); + const scale = this._pmove.movevars.spectatormaxspeed / wishspeed; + wishvel[0] *= scale; + wishvel[1] *= scale; + wishvel[2] *= scale; wishspeed = this._pmove.movevars.spectatormaxspeed; } @@ -2566,7 +2607,7 @@ export class PmovePlayer { // pmove_t (player state only) // Compute snap direction signs BEFORE rounding origin, so we know // which way to jitter when the snapped position lands in solid. const sign = [0, 0, 0]; - const base = new Vector(); + const base = this.#snapBase; for (let i = 0; i < 3; i++) { const snapped = Math.round(this.origin[i] * 8.0); base[i] = snapped * 0.125; @@ -2605,7 +2646,7 @@ export class PmovePlayer { // pmove_t (player state only) */ _nudgePosition() { // Q2: PM_InitialSnapPosition / QW: NudgePosition const offsets = [0, -1, 1]; - const base = this.origin.copy(); + const base = this.#nudgeBase.set(this.origin); for (let z = 0; z < 3; z++) { for (let y = 0; y < 3; y++) { diff --git a/source/engine/network/Network.mjs b/source/engine/network/Network.mjs index 41ba45f..bd9428b 100644 --- a/source/engine/network/Network.mjs +++ b/source/engine/network/Network.mjs @@ -187,6 +187,8 @@ NET.Init = function() { NET.delay_receive = new Cvar('net_delay_receive', '0', Cvar.FLAG.NONE, 'Delay receiving messages from the network. Useful for debugging.'); NET.delay_receive_jitter = new Cvar('net_delay_receive_jitter', '0', Cvar.FLAG.NONE, 'Jitter for the delay receiving messages from the network. Useful for debugging.'); + NET.ws_max_receive_queue = new Cvar('net_ws_max_receive_queue', '512', Cvar.FLAG.NONE, 'Maximum queued websocket messages per connection before dropping it.'); + NET.ws_max_receive_queue_bytes = new Cvar('net_ws_max_receive_queue_bytes', '4194304', Cvar.FLAG.NONE, 'Maximum queued websocket payload bytes per connection before dropping it.'); Cmd.AddCommand('maxplayers', NET.MaxPlayers_f); Cmd.AddCommand('listen', NET.Listen_f); diff --git a/source/engine/network/NetworkDrivers.mjs b/source/engine/network/NetworkDrivers.mjs index 9008a20..3bdf45f 100644 --- a/source/engine/network/NetworkDrivers.mjs +++ b/source/engine/network/NetworkDrivers.mjs @@ -326,6 +326,31 @@ export class WebSocketDriver extends BaseDriver { return /^wss?:\/\//i.test(host); } + _QueueIncomingMessage(qsocket, data) { + const packet = new Uint8Array(data); + const maxQueueMessages = Math.max(1, NET.ws_max_receive_queue.value | 0); + const maxQueueBytes = Math.max(1024, NET.ws_max_receive_queue_bytes.value | 0); + const queuedBytes = qsocket.receiveMessageLength | 0; + + if (qsocket.receiveMessage.length >= maxQueueMessages || (queuedBytes + packet.byteLength) > maxQueueBytes) { + Con.PrintWarning(`WebSocketDriver: receive queue overflow for ${qsocket.address}, disconnecting client\n`); + qsocket.state = QSocket.STATE_DISCONNECTED; + + try { + if (qsocket.driverdata && qsocket.driverdata.readyState <= 1) { + qsocket.driverdata.close(1008, 'receive queue overflow'); + } + } catch { + // socket already gone, nothing else to do + } + + return; + } + + qsocket.receiveMessage.push(packet); + qsocket.receiveMessageLength = queuedBytes + packet.byteLength; + } + Connect(host) { // Only handle ws:// and wss:// URLs if (!/^wss?:\/\//i.test(host)) { @@ -359,9 +384,9 @@ export class WebSocketDriver extends BaseDriver { // freeing up some QSocket structures sock.receiveMessage = []; - sock.receiveMessageLength = null; + sock.receiveMessageLength = 0; sock.sendMessage = []; - sock.sendMessageLength = null; + sock.sendMessageLength = 0; sock.driverdata.qsocket = sock; @@ -392,6 +417,7 @@ export class WebSocketDriver extends BaseDriver { // fetch a message const message = qsocket.receiveMessage.shift(); + qsocket.receiveMessageLength = Math.max(0, (qsocket.receiveMessageLength | 0) - message.byteLength); // parse header const ret = message[0]; @@ -492,12 +518,12 @@ export class WebSocketDriver extends BaseDriver { } if (NET.delay_receive.value === 0) { - this.qsocket.receiveMessage.push(new Uint8Array(data)); + this.qsocket.driver._QueueIncomingMessage(this.qsocket, data); return; } setTimeout(() => { - this.qsocket.receiveMessage.push(new Uint8Array(data)); + this.qsocket.driver._QueueIncomingMessage(this.qsocket, data); }, NET.delay_receive.value + (Math.random() - 0.5) * NET.delay_receive_jitter.value); } @@ -531,9 +557,9 @@ export class WebSocketDriver extends BaseDriver { // these event handlers will feed into the message buffer structures sock.receiveMessage = []; - sock.receiveMessageLength = null; + sock.receiveMessageLength = 0; sock.sendMessage = []; - sock.sendMessageLength = null; + sock.sendMessageLength = 0; sock.state = QSocket.STATE_CONNECTED; // set the last message time to now @@ -555,7 +581,7 @@ export class WebSocketDriver extends BaseDriver { }); ws.on('message', (data) => { - sock.receiveMessage.push(new Uint8Array(data)); + this._QueueIncomingMessage(sock, data); }); this.newConnections.push(sock); @@ -1969,4 +1995,3 @@ export class WebRTCDriver extends BaseDriver { return null; } }; - diff --git a/source/engine/server/Com.mjs b/source/engine/server/Com.mjs index 4eddc59..d060ddb 100644 --- a/source/engine/server/Com.mjs +++ b/source/engine/server/Com.mjs @@ -18,6 +18,92 @@ eventBus.subscribe('registry.frozen', () => { // @ts-ignore export default class NodeCOM extends COM { + /** @type {Map>} */ + static _packIndexCache = new Map(); + /** @type {Map} */ + static _packFdCache = new Map(); + /** @type {Map} */ + static _fileCache = new Map(); + static _fileCacheBytes = 0; + static _maxFileCacheEntries = 256; + static _maxFileCacheBytes = 32 * 1024 * 1024; + + static _touchCacheEntry(key, data) { + if (this._fileCache.has(key)) { + this._fileCache.delete(key); + } + this._fileCache.set(key, data); + } + + static _getCachedFile(key) { + if (!this._fileCache.has(key)) { + return null; + } + + const data = this._fileCache.get(key); + this._touchCacheEntry(key, data); + return data; + } + + static _addCachedFile(key, data) { + if (!data || data.byteLength === 0 || data.byteLength > (this._maxFileCacheBytes >> 2)) { + return; + } + + if (this._fileCache.has(key)) { + const previous = this._fileCache.get(key); + this._fileCacheBytes -= previous.byteLength; + this._fileCache.delete(key); + } + + while ( + this._fileCache.size >= this._maxFileCacheEntries || + (this._fileCacheBytes + data.byteLength) > this._maxFileCacheBytes + ) { + const firstKey = this._fileCache.keys().next().value; + if (!firstKey) { + break; + } + const evicted = this._fileCache.get(firstKey); + this._fileCache.delete(firstKey); + this._fileCacheBytes -= evicted.byteLength; + } + + this._fileCache.set(key, data); + this._fileCacheBytes += data.byteLength; + } + + static _clearFileCache() { + this._fileCache.clear(); + this._fileCacheBytes = 0; + } + + static _getPackIndex(searchPathName, packIndex, pak) { + const cacheKey = `${searchPathName}\u0000${packIndex}`; + const existing = this._packIndexCache.get(cacheKey); + if (existing) { + return existing; + } + + const index = new Map(); + for (const file of pak) { + index.set(file.name, file); + } + + this._packIndexCache.set(cacheKey, index); + return index; + } + + static async _getPackFd(packPath) { + let fd = this._packFdCache.get(packPath); + if (fd) { + return fd; + } + + fd = await fsPromises.open(packPath, 'r'); + this._packFdCache.set(packPath, fd); + return fd; + } /** * Loads a file, searching through registered search paths and packs. @@ -31,43 +117,48 @@ export default class NodeCOM extends COM { for (let i = this.searchpaths.length - 1; i >= 0; i--) { const search = this.searchpaths[i]; const netpath = search.filename ? `${search.filename}/${filename}` : filename; + const cached = this._getCachedFile(netpath); + if (cached) { + return cached; + } // 1) Search within pack files for (let j = search.pack.length - 1; j >= 0; j--) { const pak = search.pack[j]; + const packIndex = this._getPackIndex(search.filename, j, pak); + const file = packIndex.get(filename); + if (!file) { + continue; + } - for (const file of pak) { - if (file.name !== filename) { - continue; - } - - // Found a matching file in the PAK metadata - if (file.filelen === 0) { - // The file length is zero, return an empty buffer - return new ArrayBuffer(0); - } + // Found a matching file in the PAK metadata + if (file.filelen === 0) { + // The file length is zero, return an empty buffer + return new ArrayBuffer(0); + } - const packPath = `data/${search.filename !== '' ? search.filename + '/' : ''}pak${j}.pak`; - - let fd; - try { - // Open the .pak file - fd = await fsPromises.open(packPath, 'r'); - - // Read the bytes - const buffer = Buffer.alloc(file.filelen); - await fd.read(buffer, 0, file.filelen, file.filepos); - - Sys.Print(`PackFile: ${packPath} : ${filename}\n`); - return new Uint8Array(buffer).buffer; - // eslint-disable-next-line no-unused-vars - } catch (err) { - // If we can't open or read from the PAK, just continue searching - } finally { - if (fd) { - await fd.close(); - } + const packPath = `data/${search.filename !== '' ? search.filename + '/' : ''}pak${j}.pak`; + + try { + // Reuse already-open file descriptors for hot asset paths. + const fd = await this._getPackFd(packPath); + + // Read the bytes + const buffer = Buffer.alloc(file.filelen); + await fd.read(buffer, 0, file.filelen, file.filepos); + + const out = new Uint8Array(buffer).buffer; + this._addCachedFile(netpath, out); + Sys.Print(`PackFile: ${packPath} : ${filename}\n`); + return out; + // eslint-disable-next-line no-unused-vars + } catch (err) { + // If we can't open or read from the PAK, just continue searching + const staleFd = this._packFdCache.get(packPath); + if (staleFd) { + void staleFd.close().catch(() => {}); } + this._packFdCache.delete(packPath); } } @@ -80,8 +171,10 @@ export default class NodeCOM extends COM { // If we got here, the file exists—read and return its contents const buffer = await fsPromises.readFile(directPath); + const out = new Uint8Array(buffer).buffer; + this._addCachedFile(netpath, out); Sys.Print(`FindFile: ${netpath}\n`); - return new Uint8Array(buffer).buffer; + return out; // eslint-disable-next-line no-unused-vars } catch (err) { // Not accessible or doesn't exist—keep searching @@ -94,6 +187,12 @@ export default class NodeCOM extends COM { }; static Shutdown() { + for (const fd of this._packFdCache.values()) { + void fd.close().catch(() => {}); + } + this._packFdCache.clear(); + this._packIndexCache.clear(); + this._clearFileCache(); }; /** @@ -169,6 +268,7 @@ export default class NodeCOM extends COM { return false; } Sys.Print('COM.WriteFile: ' + filename + '\n'); + this._clearFileCache(); return true; } @@ -182,6 +282,7 @@ export default class NodeCOM extends COM { return false; } Sys.Print('COM.WriteTextFile: ' + filename + '\n'); + this._clearFileCache(); return true; } @@ -195,6 +296,8 @@ export default class NodeCOM extends COM { search.pack[search.pack.length] = pak; } this.searchpaths[this.searchpaths.length] = search; + this._packIndexCache.clear(); + this._clearFileCache(); } static Path_f() { diff --git a/source/engine/server/Navigation.mjs b/source/engine/server/Navigation.mjs index cce5afa..d0ff55b 100644 --- a/source/engine/server/Navigation.mjs +++ b/source/engine/server/Navigation.mjs @@ -175,7 +175,10 @@ export class Navigation { /** @type {Cvar} */ static nav_debug_path = null; /** @type {Cvar|null} NOTE: unavailable outside of dedicated server */ + static nav_auto_rebuild = null; + /** @type {Cvar|null} NOTE: unavailable outside of dedicated server */ static nav_build_process = null; + static _rebuildScheduled = false; /** maximum slope that is passable */ maxSlope = 0.7; // ~45 degrees @@ -214,6 +217,7 @@ export class Navigation { static Init() { if (registry.isDedicatedServer) { this.nav_build_process = new Cvar('nav_build_process', '0', Cvar.FLAG.NONE, 'if set to 1, it will force build the nav mesh and quit'); + this.nav_auto_rebuild = new Cvar('nav_auto_rebuild', '0', Cvar.FLAG.NONE, 'if set to 1, outdated navmesh is rebuilt automatically (may spike CPU)'); } this.nav_save_waypoints = new Cvar('nav_save_waypoints', '0', Cvar.FLAG.NONE, 'if set to 1, will save all extracted waypoints to nav file'); @@ -223,9 +227,29 @@ export class Navigation { // worker thread -> main thread: mesh probably out of date eventBus.subscribe('nav.build', () => { - if (SV.server.navigation) { - SV.server.navigation.build(); + if (!SV.server.navigation) { + return; + } + + if (this.nav_auto_rebuild && this.nav_auto_rebuild.value === 0) { + Con.PrintWarning('Navigation: nav mesh is out of date and auto rebuild is disabled. Run "nav_auto_rebuild 1" or rebuild manually via "nav".\n'); + return; + } + + if (this._rebuildScheduled) { + return; } + + this._rebuildScheduled = true; + setTimeout(() => { + this._rebuildScheduled = false; + + if (!SV.server.navigation) { + return; + } + + SV.server.navigation.build(); + }, 0); }); } diff --git a/source/engine/server/Server.mjs b/source/engine/server/Server.mjs index 74c1c9a..1e06fd2 100644 --- a/source/engine/server/Server.mjs +++ b/source/engine/server/Server.mjs @@ -227,6 +227,9 @@ export default class SV { static waterfriction = null; static rcon_password = null; static public = null; + static maxmessagesperframe = null; + static maxcommandsperframe = null; + static maxpendingcmds = null; /** Scheduled game commands */ static _scheduledGameCommands = []; @@ -256,6 +259,9 @@ export default class SV { SV.waterfriction = new Cvar('sv_waterfriction', '4'); SV.rcon_password = new Cvar('sv_rcon_password', '', Cvar.FLAG.ARCHIVE); SV.public = new Cvar('sv_public', '1', Cvar.FLAG.ARCHIVE | Cvar.FLAG.SERVER, 'Make this server publicly listed in the master server'); + SV.maxmessagesperframe = new Cvar('sv_maxmessagesperframe', '128', Cvar.FLAG.SERVER, 'Max network messages consumed per client and frame (0 = unlimited).'); + SV.maxcommandsperframe = new Cvar('sv_maxcommandsperframe', '2048', Cvar.FLAG.SERVER, 'Max client commands processed per client and frame (0 = unlimited).'); + SV.maxpendingcmds = new Cvar('sv_maxpendingcmds', '256', Cvar.FLAG.SERVER, 'Max queued move commands per client (oldest commands are dropped on overflow).'); Navigation.Init(); @@ -602,6 +608,11 @@ export default class SV { // (QW-style: each command is simulated individually so msec matches // what the client predicted, even when multiple arrive in one frame). client.pendingCmds.push(cmd); + const maxPendingCmds = SV.maxpendingcmds.value | 0; + if (maxPendingCmds > 0 && client.pendingCmds.length > maxPendingCmds) { + const overflowCount = client.pendingCmds.length - maxPendingCmds; + client.pendingCmds.splice(0, overflowCount); + } // Keep client.cmd as the latest for backwards-compat code paths client.cmd.set(cmd); @@ -645,8 +656,25 @@ export default class SV { * @returns {boolean} true if successful, false if failed */ static ReadClientMessage(client) { + const maxMessages = SV.maxmessagesperframe.value | 0; + const maxCommands = SV.maxcommandsperframe.value | 0; + const messageBudgetEnabled = maxMessages > 0; + const commandBudgetEnabled = maxCommands > 0; + let messagesProcessed = 0; + let commandsProcessed = 0; + // Process all pending network messages while (true) { + if (messageBudgetEnabled && messagesProcessed >= maxMessages) { + Con.DPrint(`SV.ReadClientMessage: message budget exceeded for ${client.name} (${client.netconnection.address})\n`); + return true; + } + + if (commandBudgetEnabled && commandsProcessed >= maxCommands) { + Con.DPrint(`SV.ReadClientMessage: command budget exceeded for ${client.name} (${client.netconnection.address})\n`); + return true; + } + const ret = NET.GetMessage(client.netconnection); if (ret === -1) { @@ -658,6 +686,7 @@ export default class SV { return true; // No more messages } + messagesProcessed++; NET.message.beginReading(); // Process all commands in this message @@ -680,6 +709,12 @@ export default class SV { break; // End of message } + commandsProcessed++; + if (commandBudgetEnabled && commandsProcessed > maxCommands) { + Con.DPrint(`SV.ReadClientMessage: command budget exceeded for ${client.name} (${client.netconnection.address})\n`); + return true; + } + if (!SV.#processClientCommand(client, cmd)) { return false; // Client should disconnect } diff --git a/source/engine/server/Sys.mjs b/source/engine/server/Sys.mjs index 978c656..3e50302 100644 --- a/source/engine/server/Sys.mjs +++ b/source/engine/server/Sys.mjs @@ -1,6 +1,6 @@ /* global Buffer */ -import { argv, stdout, exit } from 'node:process'; +import { argv, stdout, exit, cwd } from 'node:process'; import { start } from 'repl'; import express from 'express'; @@ -161,7 +161,11 @@ export default class Sys { Sys.#isRunning = true; if (Host.refreshrate.value === 0) { - Host.refreshrate.set(60); + if (registry.isDedicatedServer && Host.ticrate.value > 0) { + Host.refreshrate.set(Math.round(1.0 / Host.ticrate.value)); + } else { + Host.refreshrate.set(60); + } } // Main loop @@ -176,7 +180,12 @@ export default class Sys { Sys.Print(`Host.Frame took too long: ${dtime} ms\n`); } - await Q.sleep(Math.max(0, 1000.0 / Math.min(300, Math.max(60, Host.refreshrate.value)) - dtime)); + const minRefreshRate = registry.isDedicatedServer ? 10 : 60; + const refreshRate = Math.min(300, Math.max(minRefreshRate, Host.refreshrate.value)); + const frameBudget = 1000.0 / refreshRate; + + // Always yield at least 1ms, otherwise overload can spin at 100% CPU forever. + await Q.sleep(Math.max(1, frameBudget - dtime)); // when there are no more commands to process and no active connections, we can sleep indefinitely if (NET.activeconnections === 0 && Host._scheduledForNextFrame.length === 0 && !Cmd.HasPendingCommands()) { @@ -236,7 +245,8 @@ export default class Sys { Sys.Print(`Webserver will listen on ${listenAddress || 'all interfaces'} on port ${listenPort}\n`); - const __dirname = import.meta.dirname + '/../..'; + // Always serve relative to repository root (cwd), works for both source and Vite SSR bundle. + const rootDir = cwd(); const distHeaders = (res) => { res.set('Cross-Origin-Opener-Policy', 'same-origin'); @@ -244,13 +254,13 @@ export default class Sys { }; if (basepath !== '') { - app.use(basepath, express.static(join(__dirname + '/..', 'dist'), { setHeaders: distHeaders })); - app.use(basepath + '/data', express.static(join(__dirname + '/..', 'data'))); - app.use(basepath + '/source', express.static(join(__dirname + '/..', 'source'))); + app.use(basepath, express.static(join(rootDir, 'dist'), { setHeaders: distHeaders })); + app.use(basepath + '/data', express.static(join(rootDir, 'data'))); + app.use(basepath + '/source', express.static(join(rootDir, 'source'))); } else { - app.use(express.static(join(__dirname + '/..', 'dist'), { setHeaders: distHeaders })); - app.use('/data', express.static(join(__dirname + '/..', 'data'))); - app.use('/source', express.static(join(__dirname + '/..', 'source'))); + app.use(express.static(join(rootDir, 'dist'), { setHeaders: distHeaders })); + app.use('/data', express.static(join(rootDir, 'data'))); + app.use('/source', express.static(join(rootDir, 'source'))); } const skipChars = (basepath + '/qfs/').length; @@ -291,4 +301,3 @@ export default class Sys { }); } }; - diff --git a/source/engine/server/physics/ServerClientPhysics.mjs b/source/engine/server/physics/ServerClientPhysics.mjs index 4f953e5..69b8108 100644 --- a/source/engine/server/physics/ServerClientPhysics.mjs +++ b/source/engine/server/physics/ServerClientPhysics.mjs @@ -24,6 +24,10 @@ eventBus.subscribe('registry.frozen', () => { */ export class ServerClientPhysics { constructor() { + /** @type {import('../../common/Pmove.mjs').PmovePlayer|null} */ + this._sharedPmovePlayer = null; + this._sharedPmoveRef = null; + this._impactVelocityScratch = new Vector(); } // ========================================================================= @@ -64,6 +68,20 @@ export class ServerClientPhysics { } } + /** + * Reuse a single PmovePlayer instance to avoid per-command allocations. + * @param {import('../../common/Pmove.mjs').Pmove} pm pmove world instance + * @returns {import('../../common/Pmove.mjs').PmovePlayer} reusable mover + */ + _getSharedPmovePlayer(pm) { + if (!this._sharedPmovePlayer || this._sharedPmoveRef !== pm) { + this._sharedPmovePlayer = pm.newPlayerMove(); + this._sharedPmoveRef = pm; + } + + return this._sharedPmovePlayer; + } + /** * Runs the shared PmovePlayer for a client, copying state in and out of * the entity and client objects. This replaces the old server-side @@ -76,11 +94,8 @@ export class ServerClientPhysics { const entity = ent.entity; const pm = SV.pmove; - // --- Set up physents --- - this._setupPhysents(ent); - - // --- Create a fresh player mover --- - const pmove = pm.newPlayerMove(); + // --- Reuse the player mover --- + const pmove = this._getSharedPmovePlayer(pm); // --- Copy entity state → pmove --- pmove.origin.set(entity.origin); @@ -180,6 +195,7 @@ export class ServerClientPhysics { // Touched entities — fire touch functions via SV.physics.impact // to match the bidirectional touch semantics used by SV_FlyMove. const touchedSet = new Set(); + const impactVelocity = this._impactVelocityScratch; for (const idx of pmove.touchindices) { if (idx > 0 && idx < pm.physents.length && !touchedSet.has(idx)) { touchedSet.add(idx); @@ -187,7 +203,8 @@ export class ServerClientPhysics { if (pe.edictId !== undefined && pe.edictId < SV.server.num_edicts) { const touchEdict = SV.server.edicts[pe.edictId]; if (!touchEdict.isFree()) { - SV.physics.impact(ent, touchEdict, entity.velocity.copy()); + impactVelocity.set(entity.velocity); + SV.physics.impact(ent, touchEdict, impactVelocity); } } } @@ -347,8 +364,13 @@ export class ServerClientPhysics { // smooth and the next packet will catch up. Running with the // last known cmd would add phantom movement, making the // remote player appear to move faster than the host. - for (const cmd of client.pendingCmds) { - client.cmd.set(cmd); + if (client.pendingCmds.length > 0) { + // Build physents once per frame; commands only differ in input. + this._setupPhysents(ent); + } + + for (let i = 0; i < client.pendingCmds.length; i++) { + client.cmd.set(client.pendingCmds[i]); this._runSharedPmove(ent, client); } client.pendingCmds.length = 0; diff --git a/vite.config.dedicated.mjs b/vite.config.dedicated.mjs new file mode 100644 index 0000000..8514df0 --- /dev/null +++ b/vite.config.dedicated.mjs @@ -0,0 +1,30 @@ +import { defineConfig } from 'vite'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export default defineConfig(({ mode }) => ({ + esbuild: { + // Strip all console calls in production dedicated builds. + drop: mode === 'production' ? ['console', 'debugger'] : [], + }, + build: { + // Keep frontend build artifacts; only overwrite dedicated entry/chunks. + outDir: resolve(__dirname, 'dist'), + emptyOutDir: false, + ssr: resolve(__dirname, 'dedicated.mjs'), + sourcemap: mode !== 'production', + minify: mode === 'production' ? 'esbuild' : false, + target: 'node20', + reportCompressedSize: false, + rollupOptions: { + output: { + format: 'es', + entryFileNames: 'dedicated.mjs', + chunkFileNames: 'chunks/[name]-[hash].mjs', + }, + }, + }, +}));