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
JavaScript
(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);