316 lines
11 KiB
C#
316 lines
11 KiB
C#
using AnotherReplayReader.Utils;
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Collections.Immutable;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text;
|
||
|
||
namespace AnotherReplayReader.ReplayFile
|
||
{
|
||
internal enum ReplayType
|
||
{
|
||
Skirmish,
|
||
Lan,
|
||
Online
|
||
}
|
||
|
||
internal sealed class Replay
|
||
{
|
||
public static readonly byte[] HeaderMagic = Encoding.ASCII.GetBytes("RA3 REPLAY HEADER");
|
||
public static readonly Dictionary<ReplayType, string> TypeStrings = new()
|
||
{
|
||
{ ReplayType.Skirmish, "遭遇战录像" },
|
||
{ ReplayType.Lan, "局域网录像" },
|
||
{ ReplayType.Online, "官网录像" },
|
||
};
|
||
public const double FrameRate = 15.0;
|
||
public const string PostCommentator = "post Commentator";
|
||
|
||
private readonly byte _replaySaverIndex;
|
||
private readonly byte[]? _rawHeader;
|
||
|
||
public string Path { get; }
|
||
public DateTime Date { get; }
|
||
public string MapName { get; }
|
||
public string MapPath { get; }
|
||
public ImmutableArray<Player> Players { get; }
|
||
public int NumberOfPlayingPlayers { get; }
|
||
public Mod Mod { get; }
|
||
public ReplayType Type { get; }
|
||
public bool HasCommentator { get; }
|
||
|
||
public long Size { get; }
|
||
public ReplayFooter? Footer { get; }
|
||
public ImmutableArray<ReplayChunk>? Body { get; }
|
||
|
||
public string FileName => System.IO.Path.GetFileNameWithoutExtension(Path);
|
||
public Player ReplaySaver => Players[_replaySaverIndex];
|
||
public string TypeString => TypeStrings[Type];
|
||
public bool HasFooter => Footer != null;
|
||
public ShortTimeSpan? Length => Footer?.ReplayLength;
|
||
|
||
public Replay(string path, bool parseBody = false) :
|
||
this(path, new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite), parseBody)
|
||
{
|
||
}
|
||
|
||
public Replay(string path, Stream stream, bool parseBody = false)
|
||
{
|
||
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();
|
||
var headerSize = checked((int)(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 e)
|
||
{
|
||
throw new InvalidDataException($"Failed to parse string header of replay {Path}: \r\n{e}");
|
||
}
|
||
|
||
try
|
||
{
|
||
Players = entries["S"].Split(':')
|
||
.TakeWhile(x => !string.IsNullOrWhiteSpace(x) && x[0] != 'X')
|
||
.Select(x => new Player(x.Split(',')))
|
||
.ToImmutableArray();
|
||
}
|
||
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)
|
||
{
|
||
Debug.Instance.DebugMessage += $"Warning: the stored header size {headerSize} isn't correct (acutally {reader.BaseStream.Position})\r\n";
|
||
return;
|
||
}
|
||
|
||
if (!parseBody)
|
||
{
|
||
// jump to footer directly
|
||
reader.BaseStream.Seek(-4, SeekOrigin.End);
|
||
try
|
||
{
|
||
Footer = new ReplayFooter(reader, ReplayFooterOption.SeekToFooter);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.Instance.DebugMessage += $"Failed to parse replay footer, replay might be corrupt: {e}\r\n";
|
||
Footer = null;
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
var body = new List<ReplayChunk>();
|
||
try
|
||
{
|
||
while (true)
|
||
{
|
||
var timeCode = reader.ReadUInt32();
|
||
if (timeCode == ReplayFooter.Terminator)
|
||
{
|
||
Footer = new ReplayFooter(reader, ReplayFooterOption.CurrentlyAtFooter);
|
||
break;
|
||
}
|
||
|
||
body.Add(new ReplayChunk(timeCode, reader));
|
||
}
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.Instance.DebugMessage += $"Failed to parse replay body, replay might be corrupt: {e}\r\n";
|
||
}
|
||
|
||
byte[]? rawHeader = null;
|
||
try
|
||
{
|
||
// 重新读取原来的整个录像头
|
||
reader.BaseStream.Seek(0, SeekOrigin.Begin);
|
||
rawHeader = reader.ReadBytes(headerSize);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.Instance.DebugMessage += $"Warning: failed to read raw header: {e}\r\n";
|
||
return;
|
||
}
|
||
if (rawHeader.Length != headerSize)
|
||
{
|
||
Debug.Instance.DebugMessage += $"Warning: the stored header size {headerSize} isn't correct (raw header length = {rawHeader.Length})\r\n";
|
||
return;
|
||
}
|
||
|
||
_rawHeader = rawHeader;
|
||
Body = body.ToImmutableArray();
|
||
}
|
||
|
||
public Replay CloneHeader()
|
||
{
|
||
using var stream = new MemoryStream();
|
||
using (var writer = new BinaryWriter(stream, Encoding.UTF8, true))
|
||
{
|
||
WriteTo(writer);
|
||
}
|
||
stream.Position = 0;
|
||
return new Replay(Path, stream);
|
||
}
|
||
|
||
public bool PathEquals(Replay replay) => PathEquals(replay.Path);
|
||
public bool PathEquals(string path) => Path.Equals(path, StringComparison.OrdinalIgnoreCase);
|
||
|
||
public string GetDetails()
|
||
{
|
||
static string GetSizeString(double size)
|
||
{
|
||
if (size > 1024 * 1024)
|
||
{
|
||
return $"{Math.Round(size / (1024 * 1024), 2)}MB";
|
||
}
|
||
return $"{Math.Round(size / 1024)}KB";
|
||
}
|
||
|
||
string size = GetSizeString(Size);
|
||
string length = Length?.ToString() ?? "录像已损坏,请先修复录像";
|
||
|
||
var replaySaver = _replaySaverIndex < Players.Length
|
||
? ReplaySaver.PlayerName
|
||
: "[无法获取保存录像的玩家]";
|
||
|
||
using var writer = new StringWriter();
|
||
writer.WriteLine("文件名:{0}", FileName);
|
||
writer.WriteLine("大小:{0}", size);
|
||
writer.WriteLine("地图:{0}", MapName);
|
||
writer.WriteLine("日期:{0}", Date);
|
||
writer.WriteLine("长度:{0}", length);
|
||
writer.WriteLine("录像类别:{0}", TypeString);
|
||
writer.WriteLine("这个文件是{0}保存的", replaySaver);
|
||
writer.WriteLine("玩家列表:");
|
||
foreach (var player in Players)
|
||
{
|
||
if (player == Players.Last() && player.PlayerName.Equals(PostCommentator))
|
||
{
|
||
break;
|
||
}
|
||
var factionName = ModData.GetFaction(Mod, player.FactionId).Name;
|
||
writer.WriteLine($"{player.PlayerName},{factionName}");
|
||
}
|
||
|
||
return writer.ToString();
|
||
}
|
||
|
||
public void WriteTo(BinaryWriter writer) => WriteTo(writer, false);
|
||
|
||
private void WriteTo(BinaryWriter writer, bool skipBody)
|
||
{
|
||
if ((_rawHeader is null || Body is null) && !skipBody)
|
||
{
|
||
throw new InvalidOperationException("Replay body must be parsed before writing replay");
|
||
}
|
||
|
||
writer.Write(_rawHeader);
|
||
|
||
var lastTimeCode = Footer?.FinalTimeCode;
|
||
if (Body is not null)
|
||
{
|
||
foreach (var chunk in Body)
|
||
{
|
||
lastTimeCode = chunk.TimeCode;
|
||
writer.Write(chunk);
|
||
}
|
||
}
|
||
|
||
if (Footer is not null)
|
||
{
|
||
writer.Write(Footer);
|
||
}
|
||
else if (lastTimeCode is uint lastTimeCodeValue)
|
||
{
|
||
writer.Write(new ReplayFooter(lastTimeCodeValue));
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
}
|