rl-replay
Version:
RocketLeague replay parser for Node.JS
856 lines (717 loc) • 23.4 kB
JavaScript
"use strict";
const Q = require("q");
const fs = require("fs");
const BitStream = require('./bit-buffer/bit-buffer.js').BitStream;
const PropertyMapper = require("./netstream_property.js");
const ActorMapper = require("./netstream_actor.js");
const request = require("request");
const ReplayParser = require("./parser.js");
const TeamRed = 0;
const TeamBlue = 1;
class NetStreamReader
{
constructor(buffer)
{
this.internal = new BitStream(buffer);
}
readFloat()
{
return this.internal.readFloat32();
}
readInt32()
{
return this.internal.readBits(32, true);
}
readByte()
{
return this.internal.readBits(8, false);
}
readBool()
{
return this.readBit() == 1;
}
readBit()
{
var bit = this.internal._view._getBit(this.internal._index);
this.internal._index++;
return bit;
}
readBits(num, signed)
{
return this.internal.readBits(num, signed);
}
readSerializedInt(max_value)
{
if(max_value === undefined)
{
throw new Error("Undefined max value when calling readSerializedInt in NetStreamReader.");
}
var max_bits = Math.floor(Math.log(max_value) / Math.log(2)) + 1;
var value = 0;
for(var i = 0; i < max_bits && (value + (1<< i)) < max_value; ++i)
{
value += (this.readBool() ? 1: 0) << i;
}
return value;
}
readSerializedFloat(max_value, numbits)
{
var max_bit_value = (1 << (numbits - 1)) - 1;
var bias = (1 << (numbits - 1));
var ser_int_max = (1 << (numbits - 0));
var delta = this.readSerializedInt(ser_int_max);
var unscaled_value = delta - bias;
var value = 0.0;
if(max_value > max_bit_value)
{
var inv_scale = max_value / max_bit_value;
value = unscaled_value * inv_scale;
}else{
var scale = max_bit_value / max_value
var inv_scale = 1.0/scale
value = unscaled_value * inv_scale
}
return value;
}
readVector()
{
var numBits = this.readSerializedInt(20);
var bias = 1 << (numBits + 1);
var maxBits = numBits + 2;
var dx = this.readBits(maxBits, true);
var dy = this.readBits(maxBits, true);
var dz = this.readBits(maxBits, true);
var ret = {
x: dx - bias,
y: dy - bias,
z: dz - bias
}
return ret;
}
readByteVector()
{
var x, y, z;
x = y = z = 0;
if(this.readBool())
x = this.readByte();
if(this.readBool())
y = this.readByte();
if(this.readBool())
z = this.readByte();
return {
x: x,
y: y,
z: z
}
}
readFloatVector()
{
return {
x: this.readSerializedFloat(1, 16),
y: this.readSerializedFloat(1, 16),
z: this.readSerializedFloat(1, 16)
};
}
readStringUTF8(length)
{
var bytes = [];
for(var i = 0;i<length;i++)
bytes.push(this.readByte());
var str = "";
for(var i = 0;i<bytes.length;i++)
{
str += String.fromCharCode(bytes[i]);
}
return str;
}
readStringUTF16(length)
{
var bytes = [];
for(var i = 0;i<length;i+=2)
bytes.push(this.readBits(16));
var str = "";
for(var i = 0;i<bytes.length;i++)
{
str += String.fromCharCode(bytes[i]);
}
return str;
}
peakBits(num, format, formatType)
{
var a = [];
var str = "";
if(!format || (format && formatType == "binary"))
{
for(var i = 0;i<num;i++)
{
var bit = this.internal._view._getBit(this.internal._index + i);
a.push(bit);
if(format && formatType == "binary")
{
str += bit;
if((i + 1) % 8 == 0)
str += " ";
}
}
}
if(format)
{
if(formatType == "hex")
{
var str = "";
for(var i = 0;i<num;i+=8)
{
var byte = this.internal._view.getBitsUnsigned(this.internal._index + i, 8);
var hex = byteToHex(byte);
str += hex + " ";
}
}
return str;
}
return a;
}
skip(num)
{
this.internal._index += num;
}
}
class ActorState
{
constructor(replay, stream, alive_buffer)
{
this.replay = replay;
this.stream = stream;
this.alive_buffer = alive_buffer;
this.state = ActorState.UNKNOWN;
this.id = null;
this.flag = null;
this.type_id = null;
this.type_name = null;
this.class_name = null;
this.start_position = null;
this.start_rotation = null;
this.properties = null;
this.parsed = null;
}
parse()
{
this.id = this.stream.readBits(10);
var alive = this.stream.readBool();
if(alive)
{
var is_new = this.stream.readBool();
if(is_new)
{
this.state = ActorState.NEW;
this.flag = this.stream.readBool();
this.type_id = this.stream.readInt32();
this.type_name = this.replay.object[this.type_id];
this.class_name = this.replay.convert_arch_type_to_class_name(this.type_name);
this.properties = {};
this.alive_buffer[this.id] = this;
if(this.type_name.indexOf("TheWorld") != -1)
{
//not sure if this if is even needed
return;
}
this.start_position = this.stream.readVector();
if(this.type_name.indexOf("Archetypes.Ball") != -1 || this.type_name.indexOf("Car_Default") != -1)
this.start_rotation = this.stream.readByteVector();
this.create_parsed();
}else{
this.state = ActorState.EXISTING;
this.copy(this.alive_buffer[this.id]);
var cache = this.replay.get_cache_for_type(this.class_name);
while(this.stream.readBool())
{
var prop_id = this.stream.readSerializedInt(cache.get_max_property_id() + 1);
var prop_name = this.replay.object[cache.get_property(prop_id)];
if(PropertyMapper[prop_name] === undefined)
{
throw new Error("No mapping available for property '" + prop_name + "'.");
}
this.properties[prop_name] = PropertyMapper[prop_name](this.stream);
}
//extremely inefficient
this.create_parsed();
this.alive_buffer[this.id] = this;
}
}else{
this.state = ActorState.DEAD;
this.parsed = null;
delete this.alive_buffer[this.id];
}
}
copy(other)
{
this.flag = other.flag;
this.type_id = other.type_id;
this.type_name = other.type_name;
this.class_name = other.class_name;
this.start_position = other.start_position;
this.start_rotation = other.start_rotation;
this.properties = JSON.parse(JSON.stringify(other.properties)); //deep-clone
this.parsed = null;
}
create_parsed()
{
for(var key in ActorMapper)
{
if(this.class_name.indexOf(key) != -1)
{
this.parsed = new ActorMapper[key](this.id);
break;
}
}
}
//workaround because javascript doesn't allow static constant properties.
static get UNKNOWN()
{
return 0;
}
static get NEW()
{
return 1;
}
static get EXISTING()
{
return 2;
}
static get DEAD()
{
return 3;
}
}
class NetStreamFrame
{
constructor(replay, stream, alive_buffer)
{
this.replay = replay;
this.stream = stream;
this.alive_buffer = alive_buffer;
this.id = 0;
this.time = null;
this.delta = null;
this.actor = null;
this.parsed_actor = null;
}
get_actor(id)
{
if(this.actor[id] === undefined)
return null;
return this.actor[id];
}
get_parsed_actor(id)
{
if(this.parsed_actor[id] === undefined)
return null;
return this.parsed_actor[id].parsed;
}
get_player_by_name(name)
{
for(var j in this.parsed_actor)
{
if(this.parsed_actor[j].parsed.type == "Player")
{
if(this.parsed_actor[j].parsed.name == name)
{
return this.parsed_actor[j].parsed;
}
}
}
return null;
}
add_actor(state)
{
if(this.actor[state.id] !== undefined)
return;
if(state.parsed)
{
this.parsed_actor[state.id] = state;
}else if(this.parsed_actor[state.id] !== undefined)
{
delete this.parsed_actor[state.id];
}
this.actor[state.id] = state;
}
parse()
{
this.time = this.stream.readFloat();
this.delta = this.stream.readFloat();
this.actor = {};
this.parsed_actor = {};
while(true)
{
var actorPresent = this.stream.readBool();
if(!actorPresent)
break;
var state = new ActorState(this.replay, this.stream, this.alive_buffer);
state.parse();
if(state.state == ActorState.DEAD)
this.add_actor(state);
}
for(var key in this.alive_buffer)
{
this.add_actor(this.alive_buffer[key]);
}
for(var key in this.parsed_actor)
{
this.parsed_actor[key].parsed.update(this, this.parsed_actor[key]);
}
}
}
class NetCache
{
constructor(cache)
{
this.class_id = cache.object_index;
this.parent_id = cache.parent_id;
this.id = cache.id;
this.mapping = {};
this.parent = null;
this.children = [];
this.max_property_id = 0;
for(var i = 0;i<cache.properties.length;i++)
{
if(cache.properties[i].id > this.max_property_id)
this.max_property_id = cache.properties[i].id;
this.mapping[cache.properties[i].id] = cache.properties[i].index;
}
}
add_child(child)
{
child.parent = this;
child.set_max_property_id(this.max_property_id);
this.children.push(child);
}
get_property(id)
{
if(this.mapping[id] !== undefined) return this.mapping[id];
if(this.parent != null) return this.parent.get_property(id);
return null;
}
set_max_property_id(id)
{
if(id <= this.max_property_id)
return;
this.max_property_id = id;
for(var i = 0;i<this.children.length;i++)
{
this.children[i].set_max_property_id(id);
}
}
get_max_property_id()
{
return this.max_property_id;
}
}
class Replay
{
constructor(file)
{
this.file = file;
this.data = null;
this.header = null;
this.properties = null;
this.sfx = null;
this.object = null;
this.name = null;
this.package = null;
this.frame = null;
this.goal = null;
this.loaded = false;
this.loadInProgress = null;
}
load(options)
{
if(this.loaded)
return;
if(this.loadInProgress !== null)
return this.loadInProgress;
options = options || {};
var defer = Q.defer();
this.loadInProgress = defer;
this.asyncCall(function(){
if(typeof this.file === "string")
{
var callback = function(err, buffer){
if(err)
{
return defer.reject(err);
}
this.parse(buffer, options).then(function(){
this.loaded = true;
this.loadInProgress = null;
defer.resolve();
}.bind(this), function(err){
this.loadInProgress = null;
defer.reject(err);
}.bind(this), function(progress){
this.loadInProgress = null;
defer.notify(progress);
}.bind(this));
}.bind(this);
//check if its an url
if(/^http(?:s)?\:\/\//i.exec(this.file) !== null)
{
Replay.download(this.file, callback);
}else{
fs.readFile(this.file, callback);
}
}else{
this.parse(this.file, options).then(function(){
this.loaded = true;
this.loadInProgress = null;
defer.resolve();
}.bind(this), function(err){
this.loadInProgress = null;
defer.reject(err);
}.bind(this), function(progress){
this.loadInProgress = null;
defer.notify(progress);
}.bind(this));
}
});
return defer.promise;
}
parse(buffer, options)
{
var defer = Q.defer();
this.asyncCall(function(){
var data = ReplayParser.parse(buffer);
this.data = data;
this.header = data.header;
this.properties = {};
this.sfx = [];
this.object = [];
this.name = [];
this.package = [];
this.frame = [];
this.goal = [];
for(var i = 0;i<data.sfx.length;i++) this.sfx.push(data.sfx[i].name);
for(var i = 0;i<data.object.length;i++) this.object.push(data.object[i].string);
for(var i = 0;i<data.name.length;i++) this.name.push(data.name[i].string);
for(var i = 0;i<data.package.length;i++) this.package.push(data.package[i].string);
for(var i = 0;i<data.tickmark.length;i++)
{
var mark = data.tickmark[i];
if(mark.type == "Team0Goal")
{
this.goal.push({
frame: mark.frame,
type: "blue"
});
}
else if(mark.type == "Team1Goal")
{
this.goal.push({
frame: mark.frame,
type: "red"
});
}else{
//unknown tickmark, might be useful in the future
//console.log("Tickmark: " + mark.type);
}
}
this.parse_properties();
if(options.parse_netcache === undefined || options.parse_netcache)
{
this.parse_netcache();
if(options.parse_frame === undefined || options.parse_frame)
this.parse_frames();
}
defer.resolve();
});
return defer.promise;
}
static download(url, callback)
{
request({url: url, encoding: null}, function(error, response, body){
if(error)
return callback(error, null);
return callback(null, body);
});
}
parse_property(property)
{
if(property.more.type == "ArrayProperty")
{
var data = [];
for(var j = 0;j<property.more.details.array.length;j++)
{
var sub = {};
for(var k = 0;k<property.more.details.array[j].part.length;k++)
{
var sub_property = property.more.details.array[j].part[k];
if(sub_property.name == "None")
continue;
sub[sub_property.name] = this.parse_property(sub_property);
}
data.push(sub);
}
return data;
}
if(property.more.type == "StrProperty")
{
var str = null;
if(property.more.details.value_length < 0)
str = property.more.details.value.toString("ucs2");
else
str = property.more.details.value.toString("utf8");
return str;
}
if(property.more.details.value === undefined)
{
return [property.more.details.value1, property.more.details.value2];
}
return property.more.details.value;
}
parse_properties()
{
for(var i = 0;i<this.data.properties.length;i++)
{
var property = this.data.properties[i];
if(property.name == "None")
continue;
this.properties[property.name] = this.parse_property(property);
}
}
parse_netcache()
{
var temp_cache = [];
var pri_netcache = null;
var prix_netcache = null;
var prita_netcache = null;
for(var i = 0;i<this.data.class_netcache.length;i++)
{
var cache = new NetCache(this.data.class_netcache[i]);
var typename = this.object[cache.class_id];
if(typename == "Engine.PlayerReplicationInfo") pri_netcache = cache;
if(typename == "ProjectX.PRI_X") prix_netcache = cache;
if(typename == "TAGame.PRI_TA") prita_netcache = cache;
temp_cache.push(cache);
}
temp_cache.reverse();
for(var i = 0;i<temp_cache.length - 1;i++)
{
var cache = temp_cache[i];
var j = i + 1;
while(j < temp_cache.length)
{
var item = temp_cache[j];
if(item.id == cache.parent_id)
{
item.add_child(cache);
break;
}else{
j++;
}
}
}
// 2016/02/10 patch replays have TAGame.PRI_TA classes with no parent, this should be a temporary fix till we figure out why they have no parent.
if(prix_netcache.parent == null)
{
pri_netcache.add_child(prix_netcache);
}
if(prita_netcache.parent == null)
{
prix_netcache.add_child(prita_netcache);
}
this.netcache = temp_cache.slice(0, -1);
}
parse_frames()
{
var stream = new NetStreamReader(this.data.networkstream);
var alive_buffer = {};
for(var i = 0;i<this.properties["NumFrames"];i++)
{
this.frame.push(this.parse_frame(i, stream, alive_buffer));
this.loadInProgress.notify(i / this.properties["NumFrames"]);
}
}
parse_frame(id, stream, alive_buffer)
{
var frame = new NetStreamFrame(this, stream, alive_buffer);
frame.id = id;
frame.parse();
return frame;
}
convert_arch_type_to_class_name(arch_type)
{
if(arch_type == "GameInfo_Soccar.GameInfo.GameInfo_Soccar:GameReplicationInfoArchetype")
return "TAGame.GRI_TA";
else if(arch_type == "GameInfo_Season.GameInfo.GameInfo_Season:GameReplicationInfoArchetype")
return "TAGame.GRI_TA";
else if(arch_type == "Archetypes.GameEvent.GameEvent_Season:CarArchetype")
return "TAGame.Car_Season_TA";
else if(arch_type.indexOf("Archetypes.Ball") != -1)
return "TAGame.Ball_TA";
else
{
var name = arch_type.replace(/_\d+/, "").split(".").slice(-1)[0].split(":").slice(-1)[0];
name = name.replace("_Default", "_TA");
name = name.replace("Archetype", "");
name = name.replace("_0", "");
name = name.replace("0", "_TA");
name = name.replace("1", "_TA");
name = name.replace("Default__", "");
name = "." + name;
return name;
}
}
get_cache_for_type(type)
{
for(var i = 0;i<this.netcache.length;i++)
{
if(this.object[this.netcache[i].class_id].indexOf(type) != -1)
{
return this.netcache[i];
}
}
return null;
}
asyncCall(func)
{
setTimeout(func.bind(this), 0);
}
}
module.exports = Replay;
if(require.main === module)
{
function getFiles (dir, files_){
files_ = files_ || [];
var files = fs.readdirSync(dir);
for (var i in files){
var name = dir + '\\' + files[i];
if (fs.statSync(name).isDirectory()){
getFiles(name, files_);
} else {
files_.push(name);
}
}
return files_;
}
//var replay = new Replay("https://rocketleaguereplays-media.s3-eu-west-1.amazonaws.com/uploads/replay_files/862E5E0F40CF0DC42AE62FB32FD9D7C3.replay");
var samples = getFiles(process.env["USERPROFILE"] + "\\Documents\\My games\\Rocket League\\TAGame\\Demos");
var test = function(i, done){
if(i >= samples.length)
return done();
try
{
var replay = new Replay(samples[i]);
replay.load().then(function(){
console.log("Test succeeded, file '" + samples[i] + "'.");
test(i + 1, done);
}, function(e){
console.log("Test failed, file '" + samples[i] + "', error: " + e);
test(i + 1, done);
});
}
catch(e)
{
console.log("Test failed, file '" + samples[i] + "', error: " + e);
test(i + 1, done);
}
};
test(0);
}