434 lines
15 KiB
C#
434 lines
15 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace AnotherReplayReader
|
|
{
|
|
internal sealed class Player
|
|
{
|
|
public static readonly IReadOnlyDictionary<string, string> ComputerNames = new Dictionary<string, string>
|
|
{
|
|
{ "E", "简单" },
|
|
{ "M", "中等" },
|
|
{ "H", "困难" },
|
|
{ "B", "凶残" },
|
|
};
|
|
|
|
public string PlayerName { get; private set; }
|
|
public uint PlayerIP { get; private set; }
|
|
public int FactionID { get; private set; }
|
|
public int Team { get; private set; }
|
|
|
|
public Player(string[] playerEntry)
|
|
{
|
|
var isComputer = playerEntry[0][0] == 'C';
|
|
|
|
PlayerName = playerEntry[0].Substring(1);
|
|
|
|
if (isComputer)
|
|
{
|
|
PlayerName = ComputerNames[PlayerName];
|
|
PlayerIP = 0;
|
|
FactionID = int.Parse(playerEntry[2]);
|
|
Team = int.Parse(playerEntry[4]);
|
|
}
|
|
else
|
|
{
|
|
PlayerIP = uint.Parse(playerEntry[1], System.Globalization.NumberStyles.HexNumber);
|
|
FactionID = int.Parse(playerEntry[5]);
|
|
Team = int.Parse(playerEntry[7]);
|
|
}
|
|
|
|
|
|
}
|
|
}
|
|
|
|
internal enum ReplayType
|
|
{
|
|
Skirmish,
|
|
Lan,
|
|
Online
|
|
}
|
|
|
|
internal sealed class ReplayChunk
|
|
{
|
|
public uint TimeCode { get; private set; }
|
|
public byte Type { get; private set; }
|
|
public byte[] Data { get; private set; }
|
|
|
|
public ReplayChunk(uint timeCode, BinaryReader reader)
|
|
{
|
|
TimeCode = timeCode; // reader.ReadUInt32();
|
|
Type = reader.ReadByte();
|
|
var chunkSize = reader.ReadInt32();
|
|
Data = reader.ReadBytes(chunkSize);
|
|
if(reader.ReadInt32() != 0)
|
|
{
|
|
throw new InvalidDataException("Replay Chunk not ended with zero");
|
|
}
|
|
}
|
|
}
|
|
|
|
internal enum ReplayFooterOption
|
|
{
|
|
SeekToFooter,
|
|
CurrentlyAtFooter,
|
|
}
|
|
|
|
internal sealed class ReplayFooter
|
|
{
|
|
public const uint Terminator = 0x7FFFFFFF;
|
|
public static readonly byte[] FooterString = Encoding.ASCII.GetBytes("RA3 REPLAY FOOTER");
|
|
public uint FinalTimeCode { get; private set; }
|
|
public byte[] Data { get; private set; }
|
|
|
|
public ReplayFooter(BinaryReader reader, ReplayFooterOption option)
|
|
{
|
|
var currentPosition = reader.BaseStream.Position;
|
|
reader.BaseStream.Seek(-4, SeekOrigin.End);
|
|
var footerLength = reader.ReadInt32();
|
|
|
|
if (option == ReplayFooterOption.SeekToFooter)
|
|
{
|
|
currentPosition = reader.BaseStream.Length - footerLength;
|
|
}
|
|
|
|
reader.BaseStream.Seek(currentPosition, SeekOrigin.Begin);
|
|
var footer = reader.ReadBytes(footerLength);
|
|
|
|
if(footer.Length != footerLength || reader.BaseStream.Position != reader.BaseStream.Length)
|
|
{
|
|
throw new InvalidDataException("Invalid footer");
|
|
}
|
|
|
|
using (var footerStream = new MemoryStream(footer))
|
|
using (var footerReader = new BinaryReader(footerStream))
|
|
{
|
|
var footerString = footerReader.ReadBytes(17);
|
|
if(!footerString.SequenceEqual(FooterString))
|
|
{
|
|
throw new InvalidDataException("Invalid footer, no footer string");
|
|
}
|
|
FinalTimeCode = footerReader.ReadUInt32();
|
|
Data = footerReader.ReadBytes(footer.Length - 25);
|
|
if(footerReader.ReadInt32() != footerLength)
|
|
{
|
|
throw new InvalidDataException();
|
|
}
|
|
}
|
|
}
|
|
|
|
public ReplayFooter(uint finalTimeCode)
|
|
{
|
|
FinalTimeCode = finalTimeCode;
|
|
Data = new byte[] { 0x02, 0x1A, 0x00, 0x00, 0x00 };
|
|
}
|
|
|
|
List<float> TryGetKillDeathRatio()
|
|
{
|
|
if(Data.Length < 24)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var ratios = new List<float>();
|
|
using (var stream = new MemoryStream(Data, Data.Length - 24, 24))
|
|
using (var reader = new BinaryReader(stream))
|
|
{
|
|
ratios.Add(reader.ReadSingle());
|
|
}
|
|
|
|
return ratios;
|
|
}
|
|
}
|
|
|
|
internal static class ReplayExtensions
|
|
{
|
|
public static string ReadUTF16String(this BinaryReader reader)
|
|
{
|
|
var currentBytes = new List<byte>();
|
|
byte[] lastTwoBytes = null;
|
|
while(true)
|
|
{
|
|
lastTwoBytes = reader.ReadBytes(2);
|
|
if (lastTwoBytes.Length != 2)
|
|
{
|
|
throw new InvalidDataException();
|
|
}
|
|
if(lastTwoBytes.All(x => x == 0))
|
|
{
|
|
break;
|
|
}
|
|
currentBytes.AddRange(lastTwoBytes);
|
|
}
|
|
|
|
return Encoding.Unicode.GetString(currentBytes.ToArray());
|
|
}
|
|
|
|
public static void WriteChunk(this BinaryWriter writer, ReplayChunk chunk)
|
|
{
|
|
writer.Write(chunk.TimeCode);
|
|
writer.Write(chunk.Type);
|
|
writer.Write(chunk.Data.Length);
|
|
writer.Write(chunk.Data);
|
|
writer.Write(0);
|
|
}
|
|
|
|
public static void WriteFooter(this BinaryWriter writer, ReplayFooter footer)
|
|
{
|
|
writer.Write(ReplayFooter.Terminator);
|
|
writer.Write(ReplayFooter.FooterString);
|
|
writer.Write(footer.FinalTimeCode);
|
|
writer.Write(footer.Data);
|
|
writer.Write(ReplayFooter.FooterString.Length + footer.Data.Length + 8);
|
|
}
|
|
|
|
public static void WriteReplay(this BinaryWriter writer, Replay replay)
|
|
{
|
|
writer.Write(replay.RawHeader);
|
|
|
|
var lastTimeCode = (uint)0;
|
|
foreach(var chunk in replay.Body)
|
|
{
|
|
lastTimeCode = chunk.TimeCode;
|
|
writer.WriteChunk(chunk);
|
|
}
|
|
|
|
if (replay.HasFooter)
|
|
{
|
|
writer.WriteFooter(replay.Footer);
|
|
}
|
|
else
|
|
{
|
|
writer.WriteFooter(new ReplayFooter(lastTimeCode));
|
|
}
|
|
}
|
|
}
|
|
|
|
internal sealed class Replay
|
|
{
|
|
public static readonly byte[] HeaderMagic = Encoding.ASCII.GetBytes("RA3 REPLAY HEADER");
|
|
public static readonly Dictionary<ReplayType, string> TypeStrings = new Dictionary<ReplayType, string>()
|
|
{
|
|
{ ReplayType.Skirmish, "遭遇战录像" },
|
|
{ ReplayType.Lan, "局域网录像" },
|
|
{ ReplayType.Online, "官网录像" },
|
|
};
|
|
|
|
public string Path { get; private set; }
|
|
public string FileName => System.IO.Path.GetFileNameWithoutExtension(Path);
|
|
public DateTime Date { get; private set; }
|
|
public bool HasFooter => Footer != null;
|
|
public TimeSpan? Length { get; private set; }
|
|
public string MapName { get; private set; }
|
|
public string MapPath { get; private set; }
|
|
public IReadOnlyList<Player> Players => _players;
|
|
public int NumberOfPlayingPlayers { get; private set; }
|
|
public long Size { get; private set; }
|
|
public Mod Mod { get; private set; }
|
|
public ReplayType Type { get; private set; }
|
|
public string TypeString => TypeStrings[Type];
|
|
public bool HasCommentator { get; private set; }
|
|
public Player ReplaySaver => Players[_replaySaverIndex];
|
|
|
|
public byte[] RawHeader { get; private set; }
|
|
public IReadOnlyList<ReplayChunk> Body => _body;
|
|
public ReplayFooter Footer { get; private set; }
|
|
|
|
private List<Player> _players;
|
|
private byte _replaySaverIndex;
|
|
private long _headerSize;
|
|
private List<ReplayChunk> _body;
|
|
|
|
public Replay(string path)
|
|
{
|
|
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
|
|
{
|
|
Parse(path, stream);
|
|
}
|
|
}
|
|
|
|
public Replay(string path, Stream stream)
|
|
{
|
|
Parse(path, stream);
|
|
}
|
|
|
|
private void Parse(string path, Stream stream)
|
|
{
|
|
Path = path;
|
|
|
|
using (var reader = new BinaryReader(stream))
|
|
{
|
|
Size = reader.BaseStream.Length;
|
|
var headerMagic = reader.ReadBytes(HeaderMagic.Length);
|
|
if(!headerMagic.SequenceEqual(HeaderMagic))
|
|
{
|
|
throw new InvalidDataException($"{Path} is not a replay, header is {BitConverter.ToString(headerMagic)}");
|
|
}
|
|
|
|
var isSkirmish = reader.ReadByte() == 0x04;
|
|
reader.ReadBytes(4 * 4); // version and builds
|
|
reader.ReadBytes(2); // commentary flag, and padding zero byte
|
|
|
|
reader.ReadUTF16String(); // title
|
|
reader.ReadUTF16String(); // description
|
|
MapName = reader.ReadUTF16String(); // map name
|
|
reader.ReadUTF16String(); // map id
|
|
|
|
NumberOfPlayingPlayers = reader.ReadByte();
|
|
|
|
for(var i = 0; i <= NumberOfPlayingPlayers; ++i)
|
|
{
|
|
reader.ReadUInt32();
|
|
reader.ReadUTF16String(); // utf16 player name
|
|
if(!isSkirmish)
|
|
{
|
|
reader.ReadByte(); // team
|
|
}
|
|
}
|
|
|
|
var offset = reader.ReadInt32();
|
|
var cnc3MagicLength = reader.ReadInt32();
|
|
_headerSize = reader.BaseStream.Position + offset;
|
|
reader.ReadBytes(cnc3MagicLength);
|
|
|
|
var modInfo = reader.ReadBytes(22);
|
|
Mod = new Mod(Encoding.UTF8.GetString(modInfo));
|
|
|
|
var timeStamp = reader.ReadUInt32();
|
|
Date = DateTimeOffset.FromUnixTimeSeconds(timeStamp).DateTime;
|
|
|
|
reader.ReadBytes(31);
|
|
var descriptionsLength = reader.ReadInt32();
|
|
var description = Encoding.UTF8.GetString(reader.ReadBytes(descriptionsLength));
|
|
|
|
var entries = null as Dictionary<string, string>;
|
|
try
|
|
{
|
|
var query = from splitted in description.Split(';')
|
|
where !string.IsNullOrWhiteSpace(splitted)
|
|
select splitted.Split(new[] { '=' }, 2);
|
|
entries = query.ToDictionary(x => x[0], x => x[1]);
|
|
}
|
|
catch(Exception exception)
|
|
{
|
|
throw new InvalidDataException($"Failed to parse string header of replay {Path}: \r\n{exception}");
|
|
}
|
|
|
|
try
|
|
{
|
|
_players = entries["S"].Split(':')
|
|
.TakeWhile(x => !string.IsNullOrWhiteSpace(x) && x[0] != 'X')
|
|
.Select(x => new Player(x.Split(',')))
|
|
.ToList();
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
throw new InvalidDataException($"Failed to parse playerdata from string header of replay {Path}: \r\n{exception}");
|
|
}
|
|
|
|
MapPath = entries["M"].Substring(3);
|
|
|
|
HasCommentator = !entries["PC"].Equals("-1");
|
|
|
|
var lanFlag = int.Parse(entries["GT"]) == 0;
|
|
if (lanFlag)
|
|
{
|
|
if(_players.First().PlayerIP == 0)
|
|
{
|
|
Type = ReplayType.Skirmish;
|
|
}
|
|
else
|
|
{
|
|
Type = ReplayType.Lan;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Type = ReplayType.Online;
|
|
}
|
|
|
|
_replaySaverIndex = reader.ReadByte();
|
|
|
|
reader.ReadBytes(8); // 8 bit paddings
|
|
var fileNameLength = reader.ReadInt32();
|
|
reader.ReadBytes(fileNameLength * 2);
|
|
reader.ReadBytes(16);
|
|
var verMagicLength = reader.ReadInt32();
|
|
reader.ReadBytes(verMagicLength);
|
|
reader.ReadBytes(85);
|
|
|
|
if(reader.BaseStream.Position != _headerSize)
|
|
{
|
|
throw new InvalidDataException();
|
|
}
|
|
|
|
reader.BaseStream.Seek(-4, SeekOrigin.End);
|
|
try
|
|
{
|
|
Footer = new ReplayFooter(reader, ReplayFooterOption.SeekToFooter);
|
|
Length = TimeSpan.FromSeconds(Math.Round(Footer.FinalTimeCode / 15.0));
|
|
}
|
|
catch(Exception)
|
|
{
|
|
Length = null;
|
|
Footer = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
public void ParseBody()
|
|
{
|
|
_body = new List<ReplayChunk>();
|
|
|
|
using (var stream = new FileStream(Path, FileMode.Open))
|
|
using (var reader = new BinaryReader(stream))
|
|
{
|
|
RawHeader = reader.ReadBytes((int)_headerSize);
|
|
while (true)
|
|
{
|
|
var timeCode = reader.ReadUInt32();
|
|
if (timeCode == ReplayFooter.Terminator)
|
|
{
|
|
Footer = new ReplayFooter(reader, ReplayFooterOption.CurrentlyAtFooter);
|
|
break;
|
|
}
|
|
|
|
_body.Add(new ReplayChunk(timeCode, reader));
|
|
}
|
|
}
|
|
}
|
|
|
|
public Dictionary<byte, int[]> GetCommandCounts()
|
|
{
|
|
var playerCommands = new Dictionary<byte, int[]>();
|
|
|
|
foreach(var chunk in _body)
|
|
{
|
|
if(chunk.Type != 1)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
foreach(var command in CommandChunk.Parse(chunk))
|
|
{
|
|
var commandCount = playerCommands.TryGetValue(command.CommandID, out var current) ? current : new int[Players.Count];
|
|
if(command.PlayerIndex >= commandCount.Length) // unknown or unparsable command?
|
|
{
|
|
commandCount = commandCount
|
|
.Concat(new int[command.PlayerIndex - commandCount.Length + 1])
|
|
.ToArray();
|
|
}
|
|
commandCount[command.PlayerIndex] = commandCount[command.PlayerIndex] + 1;
|
|
playerCommands[command.CommandID] = commandCount;
|
|
}
|
|
}
|
|
|
|
return playerCommands;
|
|
}
|
|
}
|
|
}
|