UNPKG

demofile

Version:

A node.js library for parsing Counter-Strike Global Offensive (CSGO) demo files. The library is also Browserify-able, and a standalone bundle that you can `<script src="...">` is available in [browser/bundle.js](browser/bundle.js).

1,278 lines (1,270 loc) 6.42 MB
(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){ window.Buffer = require("buffer").Buffer; window.demofile = require("../dist/demo"); },{"../dist/demo":6,"buffer":81}],2:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); function assertExists(value, message) { if (value == null) throw new Error(message || "expected non-null value"); return value; } exports.default = assertExists; },{}],3:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SND_STOP = exports.INVALID_NETWORKED_EHANDLE_VALUE = exports.MAX_USERDATA_BITS = exports.SUBSTRING_BITS = exports.MAX_CUSTOM_FILES = exports.SIGNED_GUID_LEN = exports.MAX_PLAYER_NAME_LENGTH = exports.MAX_SPLITSCREEN_CLIENTS = exports.MAX_OSPATH = exports.NUM_NETWORKED_EHANDLE_BITS = exports.NUM_NETWORKED_EHANDLE_SERIAL_NUMBER_BITS = exports.NETWORKED_EHANDLE_ENT_ENTRY_MASK = exports.MAX_EDICTS = exports.MAX_EDICT_BITS = void 0; exports.MAX_EDICT_BITS = 11; exports.MAX_EDICTS = 1 << exports.MAX_EDICT_BITS; exports.NETWORKED_EHANDLE_ENT_ENTRY_MASK = exports.MAX_EDICTS - 1; exports.NUM_NETWORKED_EHANDLE_SERIAL_NUMBER_BITS = 10; exports.NUM_NETWORKED_EHANDLE_BITS = exports.MAX_EDICT_BITS + exports.NUM_NETWORKED_EHANDLE_SERIAL_NUMBER_BITS; exports.MAX_OSPATH = 260; exports.MAX_SPLITSCREEN_CLIENTS = 260; exports.MAX_PLAYER_NAME_LENGTH = 128; exports.SIGNED_GUID_LEN = 32; exports.MAX_CUSTOM_FILES = 4; exports.SUBSTRING_BITS = 5; exports.MAX_USERDATA_BITS = 14; exports.INVALID_NETWORKED_EHANDLE_VALUE = (1 << exports.NUM_NETWORKED_EHANDLE_BITS) - 1; exports.SND_STOP = 1 << 2; },{}],4:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ConVars = void 0; const events_1 = require("events"); /** * Manages console variables. */ class ConVars extends events_1.EventEmitter { constructor() { super(...arguments); this.vars = new Map(); } listen(demo) { demo.on("net_SetConVar", (msg) => { const convars = msg.convars; if (!convars) return; for (const cvar of convars.cvars) { const oldValue = this.vars.get(cvar.name); this.vars.set(cvar.name, cvar.value); const args = { name: cvar.name, value: cvar.value, oldValue }; this.emit(cvar.name, args); this.emit("change", args); } }); } } exports.ConVars = ConVars; },{"events":84}],5:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.decodeCrosshairCode = exports.CrosshairStyle = void 0; const sharecode_1 = require("./sharecode"); var CrosshairStyle; (function (CrosshairStyle) { CrosshairStyle[CrosshairStyle["Default"] = 0] = "Default"; CrosshairStyle[CrosshairStyle["DefaultStatic"] = 1] = "DefaultStatic"; CrosshairStyle[CrosshairStyle["Classic"] = 2] = "Classic"; CrosshairStyle[CrosshairStyle["ClassicDynamic"] = 3] = "ClassicDynamic"; CrosshairStyle[CrosshairStyle["ClassicStatic"] = 4] = "ClassicStatic"; })(CrosshairStyle = exports.CrosshairStyle || (exports.CrosshairStyle = {})); function decodeCrosshairCode(shareCode) { function signed(byte) { return byte > 127 ? -(~byte & 0xff) - 1 : byte; } const bytes = (0, sharecode_1.decodeShareCode)(shareCode); if (bytes[1] !== bytes.slice(2).reduce((x, y) => x + y) % 256) { throw new Error("invalid crosshair code"); } return { outline: bytes[4] / 2, red: bytes[5], green: bytes[6], blue: bytes[7], alpha: bytes[8], splitDistance: bytes[9], innerSplitAlpha: (bytes[11] >> 4) / 10, hasOutline: !!(bytes[11] & 8), outerSplitAlpha: (bytes[12] & 0xf) / 10, splitSizeRatio: (bytes[12] >> 4) / 10, thickness: bytes[13] / 10, length: bytes[15] / 10, gap: signed(bytes[3]) / 10, hasCenterDot: !!((bytes[14] >> 4) & 1), hasAlpha: !!((bytes[14] >> 4) & 4), isTStyle: !!((bytes[14] >> 4) & 8), style: CrosshairStyle[(bytes[14] & 0xf) >> 1] }; } exports.decodeCrosshairCode = decodeCrosshairCode; },{"./sharecode":28}],6:[function(require,module,exports){ (function (Buffer){(function (){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.DemoFile = exports.parseHeader = void 0; const events_1 = require("events"); const timers = require("timers"); const url_1 = require("url"); const https = require("https"); const ByteBuffer = require("bytebuffer"); const bitbuffer_1 = require("./ext/bitbuffer"); const assert = require("assert"); const consts_1 = require("./consts"); const convars_1 = require("./convars"); const entities_1 = require("./entities"); const gameevents_1 = require("./gameevents"); const icekey_1 = require("./icekey"); const net = require("./net"); const stringtables_1 = require("./stringtables"); const usermessages_1 = require("./usermessages"); const assert_exists_1 = require("./assert-exists"); const grenadetrajectory_1 = require("./supplements/grenadetrajectory"); const molotovdetonate_1 = require("./supplements/molotovdetonate"); const itempurchase_1 = require("./supplements/itempurchase"); function httpGet(url) { return new Promise((resolve, reject) => { https .request(url, res => { const chunks = []; res.on("data", chunk => { chunks.push(chunk); }); res.on("end", () => { if (res.statusCode == 200) { resolve(Buffer.concat(chunks)); } else { reject(`request '${url}' failed: ${res.statusCode} (${res.statusMessage})`); } }); }) .end(); }); } function parseHeaderBytebuf(bytebuf) { return { magic: bytebuf.readString(8, ByteBuffer.METRICS_BYTES).split("\0", 2)[0], protocol: bytebuf.readInt32(), networkProtocol: bytebuf.readInt32(), serverName: bytebuf .readString(consts_1.MAX_OSPATH, ByteBuffer.METRICS_BYTES) .split("\0", 2)[0], clientName: bytebuf .readString(consts_1.MAX_OSPATH, ByteBuffer.METRICS_BYTES) .split("\0", 2)[0], mapName: bytebuf .readString(consts_1.MAX_OSPATH, ByteBuffer.METRICS_BYTES) .split("\0", 2)[0], gameDirectory: bytebuf .readString(consts_1.MAX_OSPATH, ByteBuffer.METRICS_BYTES) .split("\0", 2)[0], playbackTime: bytebuf.readFloat(), playbackTicks: bytebuf.readInt32(), playbackFrames: bytebuf.readInt32(), signonLength: bytebuf.readInt32() }; } /** * Parses a demo file header from the buffer. * @param {ArrayBuffer} buffer - Buffer of the demo header * @returns {IDemoHeader} Header object */ function parseHeader(buffer) { const bytebuf = ByteBuffer.wrap(buffer, true); return parseHeaderBytebuf(bytebuf); } exports.parseHeader = parseHeader; /** * Represents a demo file for parsing. */ class DemoFile extends events_1.EventEmitter { /** * Starts parsing buffer as a demo file. * * @fires DemoFile#tickstart * @fires DemoFile#tickend * @fires DemoFile#end * * @param {ArrayBuffer} buffer - Buffer pointing to start of demo header */ constructor() { super(); /** * When parsing, set to current tick. */ this.currentTick = -1; /** * Number of seconds per tick */ this.tickInterval = NaN; /** * When parsing, set to the splitscreen slot for the current command. * @deprecated Splitscreen slot is unused for PC games. */ this.playerSlot = 0; /** * Set to the client slot of the recording player. * Always null for GOTV demos. */ this.recordingClientSlot = null; this._chunks = []; this._lastThreadYieldTime = 0; this._immediateTimerToken = null; this._timeoutTimerToken = null; this._encryptionKey = null; this._hasEnded = false; this._isBroadcastFragment = false; this._supplementEvents = [ grenadetrajectory_1.default, molotovdetonate_1.default, itempurchase_1.default ]; this._supplementCleanupFns = new Map(); this.entities = new entities_1.Entities(); this.gameEvents = new gameevents_1.GameEvents(); this.stringTables = new stringtables_1.StringTables(); this.userMessages = new usermessages_1.UserMessages(); this.conVars = new convars_1.ConVars(); this.gameEvents.listen(this); // It is important that entities listens after game events, as they both listen on // tickend. this.entities.listen(this); this.stringTables.listen(this); this.userMessages.listen(this); this.conVars.listen(this); // #65: Some demos are missing playbackTicks from the header // Pull the tick interval from ServerInfo this.on("svc_ServerInfo", msg => { this.tickInterval = msg.tickInterval; }); this.on("svc_EncryptedData", msg => { if (!this._handleEncryptedData(msg)) { // Some demos appear to have the encryption key recorded // incorrectly in the .dem.info file. Don't throw an error // if we can't decode it correctly. // The game client silently skips bad encrypted messages. // See https://github.com/saul/demofile/issues/322#issuecomment-1085776379 this.emit("warning", { message: "Unable to read encrypted message" }); } }); this.on("newListener", (event) => { // If we already have listeners for this event, nothing to do if (this.listenerCount(event) > 0) return; const supplement = this._findSupplement(event); if (supplement == null) return; const cleanupFn = supplement.setup(this); this._supplementCleanupFns.set(supplement, cleanupFn); }); this.on("removeListener", (event) => { // If there are still listeners for this event, early out if (this.listenerCount(event) > 0) return; const supplement = this._findSupplement(event); if (supplement == null) return; // Don't cleanup if there are listeners on other emits that this supplement emits const existingListenerCount = supplement.emits.reduce((prev, name) => prev + this.listenerCount(name), 0); if (existingListenerCount > 0) return; const cleanupFn = (0, assert_exists_1.default)(this._supplementCleanupFns.get(supplement)); cleanupFn(); this._supplementCleanupFns.delete(supplement); }); } /** * @returns Number of ticks per second */ get tickRate() { return 1.0 / this.tickInterval; } /** * @returns Number of seconds elapsed */ get currentTime() { return this.currentTick * this.tickInterval; } /** * Shortcut for `this.entities.players` * @returns All connected player entities */ get players() { return this.entities.players; } /** * Shortcut for `this.entities.teams` * @returns All team entities */ get teams() { return this.entities.teams; } /** * Shortcut for `this.entities.gameRules` * @returns GameRules entity */ get gameRules() { return this.entities.gameRules; } _findSupplement(eventName) { for (const supplement of this._supplementEvents) { if (supplement.emits.indexOf(eventName) >= 0) return supplement; } return null; } /** * Start streaming a GOTV broadcast over HTTP. * Will keep streaming until the broadcast finishes. * * @param url URL to the GOTV broadcast. * @returns Promise that resolves then the broadcast finishes. */ async parseBroadcast(url) { if (!url.endsWith("/")) url += "/"; // Some packets are formatted slightly differently in // broadcast fragments compared to a normal demo file. this._isBroadcastFragment = true; const syncUrl = new url_1.URL("sync", url).toString(); const syncResponse = await httpGet(syncUrl); const syncDto = JSON.parse(syncResponse.toString()); if (syncDto.protocol !== 4) { throw new Error(`expected protocol version 4, got: ${syncDto.protocol}`); } if (syncDto.token_redirect != null) { url = new url_1.URL(syncDto.token_redirect, url).toString(); if (!url.endsWith("/")) url += "/"; } this.header = { magic: "HL2DEMO", protocol: syncDto.protocol, networkProtocol: 0, serverName: `GOTV Broadcast: ${url}`, clientName: "GOTV Demo", mapName: syncDto.map, gameDirectory: "csgo", playbackTime: 0, playbackTicks: 0, playbackFrames: 0, signonLength: 0 }; this.tickInterval = 1 / syncDto.tps; let cancelled = false; this.emit("start", { cancel() { cancelled = true; } }); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (cancelled) { return; } const startUrl = new url_1.URL(`${syncDto.signup_fragment | 0}/start`, url).toString(); const startResponse = await httpGet(startUrl); // Read the signon fragment this._bytebuf = ByteBuffer.wrap(startResponse, true); while (this._bytebuf.remaining() > 0) { this._readCommand(); } // Keep reading fragments until we run out let fragment = syncDto.fragment | 0; let fragmentType = "full"; while (!this._hasEnded) { const fragmentUrl = new url_1.URL(`${fragment}/${fragmentType}`, url).toString(); let fragmentResponse; try { fragmentResponse = await httpGet(fragmentUrl); } catch { // HTTP 404 errors are expected - each fragment only lasts for a few seconds. // Wait for 1-2 secs before retrying to avoid spamming the relay. await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 1000)); continue; } // We need to request {fragment}/full + {fragment}/delta if (fragmentType == "full") { fragmentType = "delta"; } else { fragment += 1; } this._bytebuf = ByteBuffer.wrap(fragmentResponse, true); while (this._bytebuf.remaining() > 0) { this._readCommand(); } } } parseStream(stream) { this._hasEnded = false; const onReceiveChunk = (chunk) => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (this._bytebuf == null) { this._bytebuf = ByteBuffer.wrap(chunk, true); } else { this._chunks.push(chunk); } }; const readPacketChunk = () => { try { // Keep reading until we can't read any more while (this._bytebuf.remaining() > 0 || this._chunks.length > 0) { this._bytebuf.mark(); this._readCommand(); } } catch (e) { if (e instanceof RangeError) { // Reset the byte buffer to the start of the last command this._bytebuf.offset = Math.max(0, this._bytebuf.markedOffset); } else { stream.off("data", onReceiveChunk); const error = e instanceof Error ? e : new Error(`Exception during parsing: ${e}`); this._emitEnd({ error, incomplete: false }); } } }; const readHeaderChunk = () => { // Wait for enough bytes for us to read the header if (!this._tryEnsureRemaining(1072)) return; // Once we've read the header, remove this handler stream.off("data", readHeaderChunk); const cancelled = this._parseHeader(); if (!cancelled) stream.on("data", readPacketChunk); }; stream.on("data", onReceiveChunk); stream.on("data", readHeaderChunk); stream.on("error", e => { stream.off("data", onReceiveChunk); this._emitEnd({ error: e, incomplete: false }); }); stream.on("end", () => { var _a; const fullyConsumed = // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition ((_a = this._bytebuf) === null || _a === void 0 ? void 0 : _a.remaining()) === 0 && this._chunks.length === 0; if (fullyConsumed) return; this._emitEnd({ incomplete: true }); }); } parse(buffer) { this._hasEnded = false; this._bytebuf = ByteBuffer.wrap(buffer, true); const cancelled = this._parseHeader(); if (!cancelled) timers.setTimeout(this._parseRecurse.bind(this), 0); } /** * Cancel the current parse operation. */ cancel() { if (this._immediateTimerToken) { timers.clearImmediate(this._immediateTimerToken); this._immediateTimerToken = null; } if (this._timeoutTimerToken) { timers.clearTimeout(this._timeoutTimerToken); this._timeoutTimerToken = null; } } /** * Set encryption key for decrypting `svc_EncryptedData` packets. * This allows decryption of messages from public matchmaking, like * chat messages and caster voice data. * * The key can be extracted from `match730_*.dem.info` files with `extractPublicEncryptionKey`. * * @param publicKey Public encryption key. */ setEncryptionKey(publicKey) { if (publicKey != null && publicKey.length !== 16) { throw new Error(`Public key must be 16 bytes long, got ${publicKey.length} bytes instead`); } this._encryptionKey = publicKey; } _emitEnd(e) { if (this._hasEnded) return; if (e.error) { this.emit("error", e.error); } this.emit("end", e); this._hasEnded = true; } _parseHeader() { this.header = parseHeaderBytebuf(this._bytebuf); // #65: Some demos are missing playbackTicks from the header if (this.header.playbackTicks > 0) { this.tickInterval = this.header.playbackTime / this.header.playbackTicks; } // If this is a POV demo, try to figure out who the recording player is if (this.header.clientName !== "GOTV Demo") { this.stringTables.on("update", this._handleStringTableUpdate.bind(this)); } let cancelled = false; this.emit("start", { cancel: () => { cancelled = true; } }); return cancelled; } _readIBytes() { this._ensureRemaining(4); const length = this._bytebuf.readInt32(); this._ensureRemaining(length); return this._bytebuf.readBytes(length); } _handleEncryptedData(msg) { if (msg.keyType !== 2 || this._encryptionKey == null) return true; const key = new icekey_1.IceKey(2); key.set(this._encryptionKey); assert(msg.encrypted.length % key.blockSize() === 0); const plainText = new Uint8Array(msg.encrypted.length); key.decryptUint8Array(msg.encrypted, plainText); // Create a ByteBuffer skipped past the padding const buf = ByteBuffer.wrap(plainText, true); const paddingBytes = buf.readUint8(); if (paddingBytes + 4 > buf.remaining()) return false; buf.skip(paddingBytes); // For some reason, the size is encoded as an int32, then as a varint32 buf.BE(); const bytesWritten = buf.readInt32(); buf.LE(); if (buf.remaining() !== bytesWritten) return false; const cmd = buf.readVarint32(); const size = buf.readVarint32(); if (buf.remaining() !== size) return false; const message = net.findByType(cmd); assert(message != null, `No message handler for ${cmd}`); if (this.listenerCount(message.name)) { const msgInst = message.class.decode(new Uint8Array(buf.toBuffer())); this.emit(message.name, msgInst); } return true; } _handleStringTableUpdate(update) { if (this.recordingClientSlot != null) return; if (update.table.name === "userinfo" && update.userData != null) { const playerInfo = update.userData; if (playerInfo.name === this.header.clientName) { this.recordingClientSlot = update.entryIndex; } } } /** * Fired when a packet of this type is hit. `svc_MessageName` events are also fired. * @public * @event DemoFile#net_MessageName */ _handleDemoPacket() { if (!this._isBroadcastFragment) { this._ensureRemaining(160); // skip cmd info this._bytebuf.skip(152); // skip over sequence info this._bytebuf.readInt32(); this._bytebuf.readInt32(); } const chunk = this._readIBytes(); while (chunk.remaining()) { const cmd = chunk.readVarint32(); const size = chunk.readVarint32(); const message = net.findByType(cmd); assert(message != null, `No message handler for ${cmd}`); if (this.listenerCount(message.name)) { const messageBuffer = chunk.readBytes(size); const msgInst = message.class.decode(new Uint8Array(messageBuffer.toBuffer())); this.emit(message.name, msgInst); } else { chunk.skip(size); } } } _handleDataChunk() { this._readIBytes(); } _handleDataTables() { const chunk = this._isBroadcastFragment ? this._bytebuf : this._readIBytes(); this.entities.handleDataTables(chunk); } _handleUserCmd() { this._ensureRemaining(4); this._bytebuf.readInt32(); // outgoing sequence const chunk = this._readIBytes(); // If nobody's listening, don't waste cycles decoding it if (!this.listenerCount("usercmd")) return; const bitbuf = bitbuffer_1.BitStream.from(chunk.buffer.slice(chunk.offset, chunk.limit)); const move = { commandNumber: 0, tickCount: 0, viewAngles: { x: 0, y: 0, z: 0 }, aimDirection: { x: 0, y: 0, z: 0 }, forwardMove: 0, sideMove: 0, upMove: 0, buttons: new Array(), impulse: 0, weaponSelect: 0, weaponSubType: 0, randomSeed: 0, mouseDeltaX: 0, mouseDeltaY: 0 }; if (bitbuf.readOneBit()) { move.commandNumber = bitbuf.readUInt32(); } else { move.commandNumber = 1; } if (bitbuf.readOneBit()) { move.tickCount = bitbuf.readUInt32(); } else { move.tickCount = 1; } // Read direction if (bitbuf.readOneBit()) move.viewAngles.x = bitbuf.readFloat32(); if (bitbuf.readOneBit()) move.viewAngles.y = bitbuf.readFloat32(); if (bitbuf.readOneBit()) move.viewAngles.z = bitbuf.readFloat32(); // Read aim direction if (bitbuf.readOneBit()) move.aimDirection.x = bitbuf.readFloat32(); if (bitbuf.readOneBit()) move.aimDirection.y = bitbuf.readFloat32(); if (bitbuf.readOneBit()) move.aimDirection.z = bitbuf.readFloat32(); // Read movement if (bitbuf.readOneBit()) move.forwardMove = bitbuf.readFloat32(); if (bitbuf.readOneBit()) move.sideMove = bitbuf.readFloat32(); if (bitbuf.readOneBit()) move.upMove = bitbuf.readFloat32(); if (bitbuf.readOneBit()) { const buttons = bitbuf.readUInt32(); if (buttons & (1 << 0)) move.buttons.push("attack"); if (buttons & (1 << 1)) move.buttons.push("jump"); if (buttons & (1 << 2)) move.buttons.push("duck"); if (buttons & (1 << 3)) move.buttons.push("forward"); if (buttons & (1 << 4)) move.buttons.push("back"); if (buttons & (1 << 5)) move.buttons.push("use"); if (buttons & (1 << 6)) move.buttons.push("cancel"); if (buttons & (1 << 7)) move.buttons.push("left"); if (buttons & (1 << 8)) move.buttons.push("right"); if (buttons & (1 << 9)) move.buttons.push("moveleft"); if (buttons & (1 << 10)) move.buttons.push("moveright"); if (buttons & (1 << 11)) move.buttons.push("attack2"); if (buttons & (1 << 12)) move.buttons.push("run"); if (buttons & (1 << 13)) move.buttons.push("reload"); if (buttons & (1 << 14)) move.buttons.push("alt1"); if (buttons & (1 << 15)) move.buttons.push("alt2"); if (buttons & (1 << 16)) move.buttons.push("score"); if (buttons & (1 << 17)) move.buttons.push("speed"); if (buttons & (1 << 18)) move.buttons.push("walk"); if (buttons & (1 << 19)) move.buttons.push("zoom"); if (buttons & (1 << 20)) move.buttons.push("weapon1"); if (buttons & (1 << 21)) move.buttons.push("weapon2"); if (buttons & (1 << 22)) move.buttons.push("bullrush"); if (buttons & (1 << 23)) move.buttons.push("grenade1"); if (buttons & (1 << 24)) move.buttons.push("grenade2"); if (buttons & (1 << 25)) move.buttons.push("lookspin"); } if (bitbuf.readOneBit()) move.impulse = bitbuf.readUInt8(); if (bitbuf.readOneBit()) { move.weaponSelect = bitbuf.readUBits(consts_1.MAX_EDICT_BITS); if (bitbuf.readOneBit()) move.weaponSubType = bitbuf.readUBits(6); } if (bitbuf.readOneBit()) move.mouseDeltaX = bitbuf.readInt16(); if (bitbuf.readOneBit()) move.mouseDeltaY = bitbuf.readInt16(); this.emit("usercmd", move); } _handleStringTables() { const chunk = this._readIBytes(); const bitbuf = bitbuffer_1.BitStream.from(chunk.buffer.slice(chunk.offset, chunk.limit)); this.stringTables.handleStringTables(bitbuf); } _tryEnsureRemaining(bytes) { const remaining = this._bytebuf.remaining(); if (remaining >= bytes) return true; let left = bytes - remaining; for (let i = 0; i < this._chunks.length && left > 0; ++i) left -= this._chunks[i].length; // We don't have enough bytes with what we have buffered up if (left > 0) return false; const mark = Math.max(0, this._bytebuf.markedOffset); const newOffset = this._bytebuf.offset - mark; // Reset to the marked offset. We're never going to need the bytes preceding it this._bytebuf.offset = mark; this._bytebuf = ByteBuffer.wrap(Buffer.concat([ new Uint8Array(this._bytebuf.toBuffer()), ...this._chunks ]), true); this._chunks = []; // Advance to the point we'd already read up to this._bytebuf.offset = newOffset; return true; } _ensureRemaining(bytes) { if (!this._tryEnsureRemaining(bytes)) { throw new RangeError(`Not enough data to continue parsing. ${bytes} bytes needed`); } } _readCommand() { this._ensureRemaining(6); const command = this._bytebuf.readUint8(); const tick = this._bytebuf.readInt32(); this.playerSlot = this._bytebuf.readUint8(); if (tick !== this.currentTick) { this.emit("tickend", this.currentTick); this.currentTick = tick; this.emit("tickstart", this.currentTick); } switch (command) { case 2 /* DemoCommands.Packet */: case 1 /* DemoCommands.Signon */: this._handleDemoPacket(); break; case 6 /* DemoCommands.DataTables */: this._handleDataTables(); break; case 9 /* DemoCommands.StringTables */: this._handleStringTables(); break; case 4 /* DemoCommands.ConsoleCmd */: // TODO this._handleDataChunk(); break; case 5 /* DemoCommands.UserCmd */: this._handleUserCmd(); break; case 7 /* DemoCommands.Stop */: this.cancel(); this.emit("tickend", this.currentTick); this._emitEnd({ incomplete: false }); return; case 8 /* DemoCommands.CustomData */: throw new Error("Custom data not supported"); case 3 /* DemoCommands.SyncTick */: break; default: throw new Error(`Unrecognised command: ${command}`); } } _parseRecurse() { const now = Date.now(); // Schedule another round of parsing if (now - this._lastThreadYieldTime < 32) { this._immediateTimerToken = timers.setImmediate(this._parseRecurse.bind(this)); } else { this._lastThreadYieldTime = now; this._timeoutTimerToken = timers.setTimeout(this._parseRecurse.bind(this), 0); } try { this.emit("progress", this._bytebuf.offset / this._bytebuf.limit); this._readCommand(); } catch (e) { // Always cancel if we have an error - we've already scheduled the next tick this.cancel(); // #11, #172: Some demos have been written incompletely. // Don't throw an error when we run out of bytes to read. if (e instanceof RangeError && this.header.playbackTicks === 0 && this.header.playbackTime === 0 && this.header.playbackFrames === 0) { this._emitEnd({ incomplete: true }); } else { const error = e instanceof Error ? e : new Error(`Exception during parsing: ${e}`); this._emitEnd({ error, incomplete: false }); } } } } exports.DemoFile = DemoFile; }).call(this)}).call(this,require("buffer").Buffer) },{"./assert-exists":2,"./consts":3,"./convars":4,"./entities":7,"./ext/bitbuffer":18,"./gameevents":20,"./icekey":21,"./net":22,"./stringtables":29,"./supplements/grenadetrajectory":30,"./supplements/itempurchase":31,"./supplements/molotovdetonate":32,"./usermessages":33,"assert":73,"buffer":81,"bytebuffer":83,"events":84,"https":85,"timers":752,"url":753}],7:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Entities = void 0; const assert = require("assert"); const events_1 = require("events"); const assert_exists_1 = require("./assert-exists"); const bitbuffer_1 = require("./ext/bitbuffer"); const consts = require("./consts"); const net = require("./net"); const immutable = require("immutable"); const iter_tools_1 = require("iter-tools"); const baseentity_1 = require("./entities/baseentity"); const gamerules_1 = require("./entities/gamerules"); const networkable_1 = require("./entities/networkable"); const player_1 = require("./entities/player"); const team_1 = require("./entities/team"); const weapon_1 = require("./entities/weapon"); const props_1 = require("./props"); const Long = require("long"); const projectile_1 = require("./entities/projectile"); function isPropExcluded(excludes, table, prop) { return excludes.find(excluded => table.netTableName === excluded.dtName && prop.varName === excluded.varName); } function readFieldIndex(entityBitBuffer, lastIndex, newWay) { if (newWay && entityBitBuffer.readOneBit()) { return lastIndex + 1; } let ret = 0; if (newWay && entityBitBuffer.readOneBit()) { ret = entityBitBuffer.readUBits(3); } else { ret = entityBitBuffer.readUBits(7); switch (ret & (32 | 64)) { case 32: ret = (ret & ~96) | (entityBitBuffer.readUBits(2) << 5); assert(ret >= 32); break; case 64: ret = (ret & ~96) | (entityBitBuffer.readUBits(4) << 5); assert(ret >= 128); break; case 96: ret = (ret & ~96) | (entityBitBuffer.readUBits(7) << 5); assert(ret >= 512); break; } } if (ret === 0xfff) { // end marker is 4095 for CS:GO return -1; } return lastIndex + 1 + ret; } function cloneProps(props) { const result = {}; // eslint-disable-next-line no-restricted-syntax for (const tableName in props) { const oldTable = props[tableName]; const newTable = {}; // eslint-disable-next-line no-restricted-syntax for (const prop in oldTable) { newTable[prop] = oldTable[prop]; } result[tableName] = newTable; } return result; } /** * Represents entities and networked properties within a demo. */ class Entities extends events_1.EventEmitter { constructor() { super(...arguments); this.dataTables = []; this.serverClasses = []; /** * Map of entity index => networkable instance */ this.entities = new Map(); this.markedForDeletion = []; this.staticBaselines = {}; this.pendingBaselines = {}; this.serverClassBits = 0; this.tableClassMap = { DT_CSPlayer: player_1.Player, DT_Team: team_1.Team, DT_CSGameRules: gamerules_1.GameRules, DT_BaseCSGrenadeProjectile: projectile_1.Projectile, DT_WeaponCSBase: weapon_1.Weapon, DT_BaseEntity: baseentity_1.BaseEntity }; this._serverClassConstructor = new Map(); /** * Set of which entities were active in the most recent tick. */ this.transmitEntities = null; this._entityBaselines = [ new Map(), new Map() ]; this._frames = immutable.Map(); this._demo = null; this._singletonEnts = {}; this._currentServerTick = -1; this._maxPlayers = 0; this._userInfoTable = null; this._userIdToEntity = new Map(); this._steam64IdToEntity = new Map(); this._accountNumberToEntity = new Map(); } get playerResource() { return this._demo.entities.getSingleton("CCSPlayerResource"); } get gameRules() { return this.getSingleton("CCSGameRulesProxy"); } get teams() { return Array.from(this.findAllWithClass(team_1.Team)); } get players() { const players = []; for (let i = 1; i <= this._maxPlayers; ++i) { const entity = this.entities.get(i); // Only return players that are fully connected if ((entity === null || entity === void 0 ? void 0 : entity.userInfo) != null) { players.push(entity); } } return players; } get weapons() { return Array.from(this.findAllWithClass(weapon_1.Weapon)); } listen(demo) { this._demo = demo; demo.on("svc_PacketEntities", e => this._handlePacketEntities(e)); demo.on("svc_TempEntities", e => this._handleTempEntities(e)); demo.on("svc_ServerInfo", e => { this._maxPlayers = e.maxClients; }); demo.on("net_Tick", e => { this._currentServerTick = e.tick; }); demo.stringTables.on("create", table => { if (table.name === "userinfo") this._userInfoTable = table; }); demo.stringTables.on("update", e => this._handleStringTableUpdate(e)); demo.on("tickend", () => { if (this.markedForDeletion.length > 0) { for (const index of this.markedForDeletion) { this.entities.delete(index); this.emit("remove", { index }); } this.markedForDeletion.length = 0; } }); } /** * Determines whether handle is set. * This function does not determine whether the handle points to a valid entity. * @param {number} handle - Networked entity handle value * @returns {boolean} true if handle is set */ isHandleSet(handle) { return handle !== consts.INVALID_NETWORKED_EHANDLE_VALUE; } /** * Returns the entity specified by a particular handle. * @param {number} handle - Networked entity handle value * @returns {Entity|null} Entity referenced by the handle. `null` if no matching entity. */ getByHandle(handle) { if (!handle.isValid) { return null; } const ent = this.entities.get(handle.index); if (ent == null || ent.serialNum !== handle.serialNum) { return null; } return ent; } /** * Returns the entity that belongs to the player with a given user ID. * @param {number} userId - Player user ID * @returns {Player|null} Entity referenced by the user ID. `null` if no matching player. */ getByUserId(userId) { const entityIndex = this._userIdToEntity.get(userId); if (entityIndex === undefined) return null; return this.entities.get(entityIndex); } /** * Returns the entity that belongs to the player with a given Steam account ID. * @param {number} accountId - Steam account ID * @returns {Player|null} Entity referenced by the account ID. `null` if no matching player. */ getByAccountId(accountId) { const entityIndex = this._accountNumberToEntity.get(accountId); if (entityIndex === undefined) return null; return this.entities.get(entityIndex); } /** * Returns the entity that belongs to the player with a given 64-bit Steam ID. * @param {Long|string} steam64Id - 64-bit Steam ID * @returns {Player|null} Entity referenced by the Steam ID. `null` if no matching player. */ getBySteam64Id(steam64Id) { const idString = steam64Id instanceof Long ? steam64Id.toString() : steam64Id; const entityIndex = this._steam64IdToEntity.get(idString); if (entityIndex === undefined) return null; return this.entities.get(entityIndex); } getSingleton(serverClass) { const existing = this._singletonEnts[serverClass]; if (existing) { return existing; } const result = (0, iter_tools_1.find)(([, ent]) => ent.serverClass.name === serverClass, this.entities); if (!result) { throw new Error(`Missing singleton ${serverClass}`); } const [, entity] = result; this._singletonEnts[serverClass] = entity; return entity; } *findAllWithTable(table) { for (const ent of this.entities.values()) { if (table in ent.props) { yield ent; } } } *findAllWithClass(klass) { for (const ent of this.entities.values()) { if (ent instanceof klass) { yield ent; } } } handleDataTables(chunk) { // eslint-disable-next-line no-constant-condition while (true) { const descriptor = net.findByType(chunk.readVarint32()); assert((descriptor === null || descriptor === void 0 ? void 0 : descriptor.name) === "svc_SendTable", "expected SendTable message"); const length = chunk.readVarint32(); const msg = descriptor.class.decode(new Uint8Array(chunk.readBytes(length).toBuffer())); if (msg.isEnd) { break; } this.dataTables.push(msg); } const serverClasses = chunk.readShort(); this.serverClassBits = Math.ceil(Math.log2(serverClasses)); for (let i = 0; i < serverClasses; ++i) { const classId = chunk.readShort(); assert(classId === i, "server class entry for invalid class ID"); const name = chunk.readCString(); const dtName = chunk.readCString(); const dataTable = (0, assert_exists_1.default)(this._findTableByName(dtName), "no data table for server class"); const serverClass = { name, dtName, dataTable, flattenedProps: this._flattenDataTable(dataTable) }; this.serverClasses.push(serverClass); // Find the constructor for this server class const tablesInClass = new Set(serverClass.flattenedProps.map(prop => prop.table.netTableName)); // eslint-disable-next-line no-restricted-syntax for (const tableName in this.tableClassMap) { if (tablesInClass.has(tableName)) { this._serverClassConstructor.set(classId, this.tableClassMap[tableName]); break; } } // parse any pending baseline const pendingBaseline = this.pendingBaselines[classId]; if (pendingBaseline) { this.staticBaselines[classId] = this._parseInstanceBaseline(pendingBaseline, classId); this.emit("baselineupdate", { classId, serverClass, baseline: this.staticBaselines[classId] }); delete this.pendingBaselines[classId]; } } this.emit("datatablesready"); } _gatherExcludes(table) { const excludes = []; for (const prop of table.props) { if ((prop.flags & props_1.SPROP_EXCLUDE) !== 0) { excludes.push(prop); } if (prop.type === 6 /* PropType.DataTable */) { const subTable = (0, assert_exists_1.default)(this._findTableByName(prop.dtName)); excludes.push(...this._gatherExcludes(subTable)); } } return excludes; } _gatherProps(table, excludes) { const flattened = []; for (let index = 0; index < table.props.length; ++index) { const prop = table.props[index]; if ((prop.flags & props_1.SPROP_INSIDEARRAY) !== 0 || (prop.flags & props_1.SPROP_EXCLUDE) !== 0 || isPropExcluded(excludes, table, prop)) { continue; } if (prop.type === 6 /* PropType.DataTable */) { const subTable = (0, assert_exists_1.default)(this._findTableByName(prop.dtName)); const childProps = this._gatherProps(subTable, excludes); if ((prop.flags & props_1.SPROP_COLLAPSIBLE) === 0) { for (const fp of childProps) { fp.collapsible = false; } } flattened.push(...childProps); } else { flattened.push({ prop, table, decode: (0, props_1.makeDecoder)(prop, prop.type === 5 /* PropType.Array */ ? table.props[index - 1] : undefined), collapsible: true }); } } // collapsible props should come after non-collapsible return flattened.sort(({ collapsible: a }, { collapsible: b }) => (a ? 1 : 0) - (b ? 1 : 0)); } _flattenDataTable(table) { const flattenedProps = this._gatherProps(table, this._gatherExcludes(table)); const prioritySet = new Set(flattenedProps.map(fp => fp.prop.priority)); prioritySet.add(64); const priorities = Array.from(prioritySet).sort((a, b) => a - b); let start = 0; // On this surface this looks like a sort by priority (or min(64, priority) // for CHANGES_OFTEN props). It's not a stable sort so it can't just be // replaced with JS Array#sort for (const priority of priorities) { // eslint-disable-next-line no-constant-condition while (true) { let currentProp; for (currentProp = start; currentProp < flattenedProps.length; ++currentProp) { const prop = flattenedProps[currentProp].prop; if (prop.priority === priority || (priority === 64 && (prop.flags & props_1.SPROP_CHANGES_OFTEN) !== 0)) { if (start !== currentProp) { const temp = flattenedProps[start]; flattenedProps[start] = flattenedProps[currentProp]; flattenedProps[currentProp] = temp; } start++; break; } } if (currentProp === flattenedProps.length) { break; } } } return flattenedProps; } _findTableByName(name) { return this.dataTables.find(table => table.netTableName === name); } _addEntity(index, classId, serialNum, existingEntity, entityBaseline) { const baseline = entityBaseline || this.staticBaselines[classId]; if (!baseline) { throw new Error(`no baseline for entity ${index} (class ID ${classId})`); } const props = cloneProps(baseline); // If we already have this entity, start fresh with baseline props if ((existingEntity === null || existingEntity === void 0 ? void 0 : existingEntity.serialNum) === serialNum) { existingEntity.props = props; return existingEntity; } // Delete the entity if the serial numbers mismatch if (existingEntity) { this._removeEntity(index, true); } const klass = this._serverClassConstructor.get(classId) || networkable_1.Networkable; const entity = new klass(this._demo, index, classId, serialNum, props); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument this.entities.set(index, entity); return entity; } _removeEntity(index, immediate) { const entity = this.entities.get(index); if (!entity) { return; } // It's possible that the entity is already marked for deletion. // This is because entities are deleted at the end of the dem_packet, // after the game events fire. if (!immediate && entity.deleting) { return; } this.emit("beforeremove", { entity, immediate }); if (immediate) { this.entities.delete(index); this.emit("remove", { index }); } else { entity.deleting = true; this.markedForDeletion.push(index); } } _parseEntityUpdate(entityBitBuffer, classId) { const serverClass = this.serverClasses[classId]; const newWay = entityBitBuffer.readOneBit(); let lastIndex = -1; const fieldIndices = []; // eslint-disable-next-line no-constant-condition while (true) { lastIndex = readFieldIndex(entityBitBuffer, lastIndex, newWay);