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 ComputerNames = new Dictionary { { "E", "简单" }, { "M", "中等" }, { "H", "困难" }, { "B", "凶残" }, }; public string PlayerName { get; private set; } public uint PlayerIP { get; private set; } public string PlayerRealName { 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 TryGetKillDeathRatio() { if(Data.Length < 24) { return null; } var ratios = new List(); 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[] 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 TypeStrings = new Dictionary() { { 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 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 Body => _body; public ReplayFooter Footer { get; private set; } private List _players; private byte _replaySaverIndex; private long _headerSize; private List _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; 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(); 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 GetCommandCounts() { var playerCommands = new Dictionary(); 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; } } }