diff --git a/AnotherReplayReader.csproj b/AnotherReplayReader.csproj
index 9fcf718..1ed9371 100644
--- a/AnotherReplayReader.csproj
+++ b/AnotherReplayReader.csproj
@@ -34,6 +34,7 @@
+
diff --git a/Apm/ApmPlotter.cs b/Apm/ApmPlotter.cs
new file mode 100644
index 0000000..3c6b1f2
--- /dev/null
+++ b/Apm/ApmPlotter.cs
@@ -0,0 +1,236 @@
+using AnotherReplayReader.ReplayFile;
+using OxyPlot;
+using OxyPlot.Axes;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+
+namespace AnotherReplayReader.Apm
+{
+ public class ApmPlotterFilterOptions
+ {
+ public bool CountUnknowns { get; }
+ public bool CountAutos { get; }
+ public bool CountClicks { get; }
+
+ public ApmPlotterFilterOptions(bool countUnknowns, bool countAutos, bool countClicks)
+ {
+ CountUnknowns = countUnknowns;
+ CountAutos = countAutos;
+ CountClicks = countClicks;
+ }
+
+ public bool ShouldSkip(byte command)
+ {
+ if (!CountUnknowns && ApmPlotter.IsUnknown(command))
+ {
+ return true;
+ }
+ if (!CountAutos && ApmPlotter.IsAuto(command))
+ {
+ return true;
+ }
+ if (!CountClicks && ApmPlotter.IsClick(command))
+ {
+ return true;
+ }
+ return false;
+ }
+ }
+
+ internal class ApmPlotter
+ {
+ private static readonly ConcurrentDictionary UnknownCommands;
+ private static readonly ConcurrentDictionary AutoCommands;
+ private readonly ImmutableArray<(TimeSpan, ImmutableArray)> _commands;
+
+ public Replay Replay { get; }
+ public ImmutableArray Players { get; }
+ public ImmutableArray PlayerLifes { get; }
+ public ImmutableArray StricterPlayerLifes { get; }
+ public TimeSpan ReplayLength { get; }
+
+ static ApmPlotter()
+ {
+ static KeyValuePair Create(byte x) => new(x, default);
+ UnknownCommands = new(RA3Commands.UnknownCommands.Select(Create));
+ AutoCommands = new(RA3Commands.AutoCommands.Select(Create));
+ }
+
+ public ApmPlotter(Replay replay)
+ {
+ if (replay.Body is not { } body)
+ {
+ throw new InvalidOperationException("Replay must be fully parsed!");
+ }
+
+ Replay = replay;
+
+ // player list without post Commentator
+ Players = (replay.Players.Last().PlayerName == Replay.PostCommentator
+ ? replay.Players.Take(replay.Players.Length - 1)
+ : replay.Players).ToImmutableArray();
+
+ // get all commands
+ IEnumerable Filter(ReplayChunk chunk)
+ {
+ foreach (var command in CommandChunk.Parse(chunk))
+ {
+ if (command.PlayerIndex >= Players.Length)
+ {
+ Debug.Instance.DebugMessage += $"Unknown command with invalid player index: {command}\r\n";
+ continue;
+ }
+ yield return command;
+ }
+ }
+ var query = from chunk in body
+ where chunk.Type is 1
+ select (chunk.Time, Filter(chunk).ToImmutableArray());
+ _commands = query.ToImmutableArray();
+
+ var playerLifes = new TimeSpan[Players.Length];
+ var threeSeconds = TimeSpan.FromSeconds(3);
+ var stricterLifes = new TimeSpan[Players.Length];
+ foreach (var (time, commands) in _commands)
+ {
+ var estimatedTime = time + threeSeconds;
+ foreach (var command in commands)
+ {
+ var commandId = command.CommandId;
+ var playerIndex = command.PlayerIndex;
+ if (commandId == 0x21)
+ {
+ if (playerLifes[playerIndex] < estimatedTime)
+ {
+ playerLifes[playerIndex] = estimatedTime;
+ }
+ }
+ else if (!IsUnknown(commandId) && !IsAuto(commandId))
+ {
+ if (stricterLifes[playerIndex] < estimatedTime)
+ {
+ stricterLifes[playerIndex] = estimatedTime;
+ }
+ }
+ }
+ }
+ PlayerLifes = playerLifes.ToImmutableArray();
+ StricterPlayerLifes = stricterLifes.ToImmutableArray();
+ ReplayLength = replay.Footer?.ReplayLength ?? PlayerLifes.Max();
+ }
+
+ public DataPoint[][] GetPoints(TimeSpan resolution, ApmPlotterFilterOptions options)
+ {
+ var lists = Players
+ .Select(x => new List { 0 })
+ .ToArray();
+ var currentTime = TimeSpan.Zero;
+ var currentIndex = 0;
+ foreach (var (time, commands) in _commands)
+ {
+ while (time >= currentTime + resolution)
+ {
+ currentTime += resolution;
+ ++currentIndex;
+ }
+ foreach (var command in commands)
+ {
+ if (options.ShouldSkip(command.CommandId))
+ {
+ continue;
+ }
+ var list = lists[command.PlayerIndex];
+ var gap = currentIndex - list.Count;
+ if (gap >= 0)
+ {
+ list.AddRange(Enumerable.Repeat(0, gap + 1));
+ }
+ ++lists[command.PlayerIndex][currentIndex];
+ }
+ }
+ var apmMultiplier = 1 / resolution.TotalMinutes;
+ DataPoint Create(int v, int i) => new(TimeSpanAxis.ToDouble(resolution) * i,
+ v * apmMultiplier);
+ return lists.Select(l => l.Select(Create).ToArray()).ToArray();
+ }
+
+ public double[] CalculateAverageApm(ApmPlotterFilterOptions options)
+ {
+ var apm = new double[Players.Length];
+ foreach (var (_, commands) in _commands)
+ {
+ foreach (var command in commands)
+ {
+ if (options.ShouldSkip(command.CommandId))
+ {
+ continue;
+ }
+ ++apm[command.PlayerIndex];
+ }
+ }
+ for (var i = 0; i < apm.Length; ++i)
+ {
+ apm[i] /= PlayerLifes[i].TotalMinutes;
+ }
+ return apm;
+ }
+
+ public static double[] CalculateInstantApm(DataPoint[][] data, TimeSpan begin, TimeSpan end)
+ {
+ return data.Select(playerData =>
+ {
+ return playerData
+ .SkipWhile(p => TimeSpanAxis.ToTimeSpan(p.X) < begin)
+ .TakeWhile(p => TimeSpanAxis.ToTimeSpan(p.X) < end)
+ .Average(p => new double?(p.Y)) ?? double.NaN;
+ }).ToArray();
+ }
+
+ public Dictionary GetCommandCounts(TimeSpan begin, TimeSpan end)
+ {
+ var playerCommands = new Dictionary();
+
+ foreach (var (time, commands) in _commands)
+ {
+ if (time < begin)
+ {
+ continue;
+ }
+ if (time >= end)
+ {
+ break;
+ }
+ foreach (var command in commands)
+ {
+ if (!playerCommands.TryGetValue(command.CommandId, out var commandCount))
+ {
+ commandCount = playerCommands[command.CommandId] = new int[Players.Length];
+ }
+ commandCount[command.PlayerIndex] = commandCount[command.PlayerIndex] + 1;
+ }
+ }
+ return playerCommands;
+ }
+
+ public static bool IsUnknown(byte commandId)
+ {
+ if (UnknownCommands.ContainsKey(commandId))
+ {
+ return true;
+ }
+ if (RA3Commands.IsUnknownCommand(commandId))
+ {
+ UnknownCommands.TryAdd(commandId, default);
+ return true;
+ }
+ return false;
+ }
+
+ public static bool IsAuto(byte commandId) => AutoCommands.ContainsKey(commandId);
+
+ public static bool IsClick(byte commandId) => commandId is 0xF8 or 0xF5;
+ }
+}
diff --git a/Apm/DataRow.cs b/Apm/DataRow.cs
new file mode 100644
index 0000000..e43273a
--- /dev/null
+++ b/Apm/DataRow.cs
@@ -0,0 +1,146 @@
+using AnotherReplayReader.ReplayFile;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace AnotherReplayReader.Apm
+{
+ internal class DataRow
+ {
+ public const string AverageApmRow = "APM(整局游戏)";
+ public const string PartialApmRow = "APM(当前时间段)";
+
+ public string Name { get; }
+ public bool IsDisabled { get; }
+ public DataValue Player1Value => _values[0];
+ public DataValue Player2Value => _values[1];
+ public DataValue Player3Value => _values[2];
+ public DataValue Player4Value => _values[3];
+ public DataValue Player5Value => _values[4];
+ public DataValue Player6Value => _values[5];
+
+ private readonly IReadOnlyList _values;
+
+ public DataRow(string name,
+ IEnumerable values,
+ bool isDisabled = false)
+ {
+ Name = name;
+ IsDisabled = isDisabled;
+
+ if (values.Count() < 6)
+ {
+ values = values.Concat(new string[6 - values.Count()]);
+ }
+ _values = values.Select(x => new DataValue(x)).ToArray();
+ }
+
+ public static List GetList(ApmPlotter plotter,
+ ApmPlotterFilterOptions options,
+ PlayerIdentity identity,
+ TimeSpan begin,
+ TimeSpan end,
+ out int apmRowIndex)
+ {
+ // these commands should appear in this order by default
+ var orderedCommands = new List();
+ orderedCommands.AddRange(RA3Commands.UnknownCommands);
+ orderedCommands.AddRange(RA3Commands.AutoCommands);
+ orderedCommands.AddRange(new byte[]
+ {
+ 0xF5,
+ 0xF8,
+ 0x2A,
+ 0xFA,
+ 0xFB,
+ 0x07,
+ 0x08,
+ 0x05,
+ 0x06,
+ 0x09,
+ 0x00,
+ 0x0A,
+ 0x03,
+ 0x04,
+ 0x28,
+ 0x29,
+ 0x0D,
+ 0x0E,
+ 0x15,
+ 0x14,
+ 0x36,
+ 0x16,
+ 0x2C,
+ 0x1A,
+ 0x4E,
+ 0xFE,
+ 0xFF,
+ 0x32,
+ 0x2E,
+ 0x2F,
+ 0x4B,
+ 0x4C,
+ 0x02,
+ 0x0C,
+ 0x10,
+ });
+
+ var dataList = new List
+ {
+ new("ID", plotter.Players.Select(x => x.PlayerName))
+ };
+ var isPartial = begin > TimeSpan.Zero || end <= plotter.ReplayLength;
+ if (isPartial)
+ {
+ string GetStatus(Player player, int i)
+ {
+ if (player.IsComputer)
+ {
+ return "这是 AI";
+ }
+ if (begin < plotter.StricterPlayerLifes[i])
+ {
+ return "存活";
+ }
+ return begin < plotter.PlayerLifes[i]
+ ? "变身天眼帝国,或双手离开键盘"
+ : "可能已离开房间";
+ }
+ dataList.Add(new("存活状态(推测)", plotter.Players.Select(GetStatus)));
+ }
+ if (plotter.Replay.Type is ReplayType.Lan && identity.IsUsable)
+ {
+ string GetIpAndName(Player player) => player.IsComputer
+ ? "这是 AI"
+ : identity.QueryRealNameAndIP(player.PlayerIp);
+ dataList.Add(new("局域网 IP", plotter.Players.Select(GetIpAndName)));
+ }
+ apmRowIndex = dataList.Count;
+ // get commands
+ var commandCounts = plotter.GetCommandCounts(begin, end);
+ // add commands in the order specified by the list
+ foreach (var command in orderedCommands)
+ {
+ var counts = commandCounts.TryGetValue(command, out var stored)
+ ? stored
+ : new int[plotter.Players.Length];
+ if (!isPartial || counts.Any(x => x > 0))
+ {
+ dataList.Add(new(RA3Commands.GetCommandName(command),
+ counts.Select(x => $"{x}"),
+ options.ShouldSkip(command)));
+ }
+ commandCounts.Remove(command);
+ }
+ // add other commands
+ foreach (var commandCount in commandCounts)
+ {
+ dataList.Add(new(RA3Commands.GetCommandName(commandCount.Key),
+ commandCount.Value.Select(x => $"{x}"),
+ options.ShouldSkip(commandCount.Key)));
+ }
+
+ return dataList;
+ }
+ }
+}
diff --git a/Apm/DataValue.cs b/Apm/DataValue.cs
new file mode 100644
index 0000000..043484c
--- /dev/null
+++ b/Apm/DataValue.cs
@@ -0,0 +1,43 @@
+using System;
+
+namespace AnotherReplayReader.Apm
+{
+ internal class DataValue : IComparable, IComparable
+ {
+ public int? NumberValue { get; }
+ public string Value { get; }
+
+ public DataValue(string value)
+ {
+ Value = value;
+ if (int.TryParse(value, out var numberValue))
+ {
+ NumberValue = numberValue;
+ }
+ }
+
+ public override string ToString() => Value;
+
+ public int CompareTo(DataValue other)
+ {
+ if (NumberValue.HasValue == other.NumberValue.HasValue)
+ {
+ if (!NumberValue.HasValue)
+ {
+ return Value.CompareTo(other.Value);
+ }
+ return NumberValue.Value.CompareTo(other.NumberValue!.Value);
+ }
+ return NumberValue.HasValue ? 1 : -1;
+ }
+
+ public int CompareTo(object obj)
+ {
+ if (obj is DataValue other)
+ {
+ return CompareTo(other);
+ }
+ throw new NotSupportedException();
+ }
+ }
+}
diff --git a/ApmWindow.xaml b/ApmWindow.xaml
index e892a4f..835ef66 100644
--- a/ApmWindow.xaml
+++ b/ApmWindow.xaml
@@ -1,63 +1,128 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ Height="550"
+ Width="800"
+ Loaded="OnApmWindowLoaded">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ApmWindow.xaml.cs b/ApmWindow.xaml.cs
index 8beae03..dfdcc37 100644
--- a/ApmWindow.xaml.cs
+++ b/ApmWindow.xaml.cs
@@ -1,174 +1,64 @@
-using AnotherReplayReader.ReplayFile;
+using AnotherReplayReader.Apm;
+using AnotherReplayReader.ReplayFile;
+using AnotherReplayReader.Utils;
+using OxyPlot;
+using OxyPlot.Annotations;
+using OxyPlot.Axes;
+using OxyPlot.Legends;
+using OxyPlot.Series;
using System;
-using System.Collections.Generic;
+using System.ComponentModel;
using System.Linq;
+using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Input;
namespace AnotherReplayReader
{
- internal class DataValue : IComparable, IComparable
- {
- public int? NumberValue { get; }
- public string Value { get; }
-
- public DataValue(string value)
- {
- Value = value;
- if (int.TryParse(value, out var numberValue))
- {
- NumberValue = numberValue;
- }
- }
-
- public override string ToString() => Value;
-
- public int CompareTo(DataValue other)
- {
- if (NumberValue.HasValue == other.NumberValue.HasValue)
- {
- if (!NumberValue.HasValue)
- {
- return Value.CompareTo(other.Value);
- }
- return NumberValue.Value.CompareTo(other.NumberValue!.Value);
- }
- return NumberValue.HasValue ? 1 : -1;
- }
-
- public int CompareTo(object obj)
- {
- if (obj is DataValue other)
- {
- return CompareTo(other);
- }
- throw new NotSupportedException();
- }
- }
-
- internal class DataRow
- {
- public string Name { get; }
- public DataValue Player1Value => _values[0];
- public DataValue Player2Value => _values[1];
- public DataValue Player3Value => _values[2];
- public DataValue Player4Value => _values[3];
- public DataValue Player5Value => _values[4];
- public DataValue Player6Value => _values[5];
-
- private readonly IReadOnlyList _values;
-
- public DataRow(string name, IEnumerable values)
- {
- Name = name;
-
- if (values.Count() < 6)
- {
- values = values.Concat(new string[6 - values.Count()]);
- }
- _values = values.Select(x => new DataValue(x)).ToArray();
- }
-
- public static List GetList(Replay replay, PlayerIdentity identity)
- {
- var list = new List
- {
- 0x0F,
- 0x5F,
- 0x12,
- 0x1B,
- 0x48,
- 0x52,
- 0xFC,
- 0xFD,
- 0x01,
- 0x21,
- 0x33,
- 0x34,
- 0x35,
- 0x37,
- 0x47,
- 0xF6,
- 0xF9,
- 0xF5,
- 0xF8,
- 0x2A,
- 0xFA,
- 0xFB,
- 0x07,
- 0x08,
- 0x05,
- 0x06,
- 0x09,
- 0x00,
- 0x0A,
- 0x03,
- 0x04,
- 0x28,
- 0x29,
- 0x0D,
- 0x0E,
- 0x15,
- 0x14,
- 0x36,
- 0x16,
- 0x2C,
- 0x1A,
- 0x4E,
- 0xFE,
- 0xFF,
- 0x32,
- 0x2E,
- 0x2F,
- 0x4B,
- 0x4C,
- 0x02,
- 0x0C,
- 0x10,
- };
-
- var dataList = new List
- {
- new DataRow("ID", replay.Players.Select(x => x.PlayerName))
- };
- if (replay.Type == ReplayType.Lan && identity.IsUsable)
- {
- dataList.Add(new DataRow("局域网IP", replay.Players.Select(x => identity.QueryRealNameAndIP(x.PlayerIp))));
- }
-
- var commandCounts = replay.GetCommandCounts();
- foreach (var command in list)
- {
- var counts = commandCounts.TryGetValue(command, out var stored) ? stored : new int[replay.Players.Count];
- dataList.Add(new DataRow(RA3Commands.GetCommandName(command), counts.Select(x => $"{x}")));
- commandCounts.Remove(command);
- }
-
- foreach (var commandCount in commandCounts)
- {
- dataList.Add(new DataRow(RA3Commands.GetCommandName(commandCount.Key), commandCount.Value.Select(x => $"{x}")));
- }
-
- return dataList;
- }
- }
-
///
/// APM.xaml 的交互逻辑
///
internal partial class ApmWindow : Window
{
+ public static readonly TimeSpan DefaultResolution = TimeSpan.FromSeconds(15);
+ private readonly Regex _resolutionRegex = new(@"[^0-9]+");
private readonly PlayerIdentity _identity;
+ private readonly Replay _replay;
+ private readonly Task _plotter;
+ private readonly ApmWindowPlotController _plotController;
+
+ public ApmWindowPlotViewModel PlotModel { get; } = new();
+ public TimeSpan PlotResolution { get; private set; } = DefaultResolution;
+ public bool SkipUnknowns { get; private set; } = true;
+ public bool SkipAutos { get; private set; } = true;
+ public bool SkipClicks { get; private set; } = false;
public ApmWindow(Replay replay, PlayerIdentity identity)
{
_identity = identity;
+ _replay = replay;
+ _plotter = Task.Run(() => new ApmPlotter(replay));
+ _plotController = new(this);
InitializeComponent();
- InitializeApmWindowData(replay);
}
- private async void InitializeApmWindowData(Replay replay)
+ public async void FilterData(TimeSpan begin, TimeSpan end, bool updatePlot)
+ {
+ await FilterDataAsync(begin, end, updatePlot);
+ }
+
+ private async void OnApmWindowLoaded(object sender, RoutedEventArgs e)
+ {
+ _plot.Controller = _plotController;
+ _resolution.Text = PlotResolution.TotalSeconds.ToString();
+ _skipUnknowns.IsChecked = SkipUnknowns;
+ _skipAutos.IsChecked = SkipAutos;
+ _skipClicks.IsChecked = SkipClicks;
+ await InitializeApmWindowData();
+ }
+
+ private async Task InitializeApmWindowData()
{
if (_identity.IsUsable)
{
@@ -181,21 +71,15 @@ namespace AnotherReplayReader
_setPlayerButton.Visibility = Visibility.Hidden;
}
+ await FilterDataAsync(TimeSpan.MinValue, TimeSpan.MaxValue, true);
+ _label.Content = "选择表格之后按 Ctrl+C 可以复制内容";
+ }
+
+ private async Task FilterDataAsync(TimeSpan begin, TimeSpan end, bool updatePlot)
+ {
try
{
- var dataList = await Task.Run(() => DataRow.GetList(replay, _identity));
- _table.Items.Clear();
- foreach (var row in dataList)
- {
- _table.Items.Add(row);
- }
-
- _table.Columns[1].Header = dataList[0].Player1Value;
- _table.Columns[2].Header = dataList[0].Player2Value;
- _table.Columns[3].Header = dataList[0].Player3Value;
- _table.Columns[4].Header = dataList[0].Player4Value;
- _table.Columns[5].Header = dataList[0].Player5Value;
- _table.Columns[6].Header = dataList[0].Player6Value;
+ await FilterDataAsyncThrowable(begin, end, updatePlot);
}
catch (Exception e)
{
@@ -203,6 +87,109 @@ namespace AnotherReplayReader
}
}
+ private async Task FilterDataAsyncThrowable(TimeSpan begin, TimeSpan end, bool updatePlot)
+ {
+ var resolution = PlotResolution;
+ var options = new ApmPlotterFilterOptions(!SkipUnknowns, !SkipAutos, !SkipClicks);
+ var (dataList, plotData, replayLength, isPartial) = await Task.Run(async () =>
+ {
+ var plotter = await _plotter.ConfigureAwait(false);
+ var list = DataRow.GetList(plotter, options, _identity, begin, end, out var apmIndex);
+ // avg apm
+ var avg = plotter.CalculateAverageApm(options);
+ // data for plotting and partial apm
+ var data = plotter.GetPoints(resolution, options);
+ // partial apm
+ var isPartial = begin > TimeSpan.Zero || end <= plotter.ReplayLength;
+ if (isPartial)
+ {
+ var instantApms = ApmPlotter.CalculateInstantApm(data, begin, end);
+ string PartialApmToString(double v, int i)
+ {
+ if (plotter.Players[i].IsComputer)
+ {
+ return "这是 AI";
+ }
+ return plotter.PlayerLifes[i] < begin
+ ? "玩家已战败"
+ : $"{v:#.##}";
+ }
+ list.Insert(apmIndex, new(DataRow.PartialApmRow,
+ instantApms.Select(PartialApmToString)));
+ }
+ list.Insert(apmIndex, new(DataRow.AverageApmRow,
+ avg.Select(v => $"{v:#.##}")));
+
+ return (list, data, plotter.ReplayLength, isPartial);
+ });
+ if (updatePlot)
+ {
+ BuildPlot(plotData, replayLength);
+ }
+
+ _table.Items.Clear();
+ foreach (var row in dataList)
+ {
+ _table.Items.Add(row);
+ }
+
+ _table.Columns[1].Header = dataList[0].Player1Value;
+ _table.Columns[2].Header = dataList[0].Player2Value;
+ _table.Columns[3].Header = dataList[0].Player3Value;
+ _table.Columns[4].Header = dataList[0].Player4Value;
+ _table.Columns[5].Header = dataList[0].Player5Value;
+ _table.Columns[6].Header = dataList[0].Player6Value;
+ if (isPartial)
+ {
+ _label.Content = $"{(ShortTimeSpan)begin} 至 {(ShortTimeSpan)end} 之间的 APM 数据";
+ _label.FontWeight = System.Windows.FontWeights.Bold;
+ }
+ else
+ {
+ _label.Content = "以下是整局游戏的 APM 数据:";
+ _label.FontWeight = System.Windows.FontWeights.Normal;
+ }
+ }
+
+ private void BuildPlot(DataPoint[][] data, TimeSpan replayLength)
+ {
+ var model = new PlotModel();
+ model.Annotations.Add(new ApmWindowPlotTextAnnotation
+ {
+ Text = "鼠标左键拖拽选择,滚轮缩放,右键拖拽移动",
+ });
+ var maxApm = data.SelectMany(x => x).Max(x => x.Y);
+ var yaxis = new LinearAxis
+ {
+ Position = AxisPosition.Right,
+ IsZoomEnabled = false,
+ AbsoluteMinimum = -maxApm / 10,
+ AbsoluteMaximum = maxApm,
+ MajorStep = 50,
+ };
+ var lengthInSeconds = replayLength.TotalSeconds;
+ var limit = lengthInSeconds / 10;
+ var xaxis = new TimeSpanAxis
+ {
+ Position = AxisPosition.Bottom,
+ AbsoluteMinimum = -limit,
+ AbsoluteMaximum = lengthInSeconds + limit,
+ };
+ model.Axes.Add(yaxis);
+ model.Axes.Add(xaxis);
+ foreach (var (points, i) in data.Select((v, i) => (v, i)))
+ {
+ var series = new LineSeries
+ {
+ Title = _replay.Players[i].PlayerName,
+ };
+ series.Points.AddRange(points);
+ model.Series.Add(series);
+ model.Legends.Add(new Legend());
+ }
+ PlotModel.Model = model;
+ }
+
private void OnSetPlayerButtonClick(object sender, RoutedEventArgs e)
{
var window1 = new Window1(_identity);
@@ -213,5 +200,207 @@ namespace AnotherReplayReader
{
_table.SelectAll();
}
+
+ private int NormalizeResolutionInput(string text)
+ {
+ if(!int.TryParse(text, out var value))
+ {
+ value = (int)PlotResolution.TotalSeconds;
+ }
+ return Math.Min(Math.Max(1, value), 3600);
+ }
+
+ private void OnResolutionPreviewTextInput(object sender, TextCompositionEventArgs e)
+ {
+ e.Handled = _resolutionRegex.IsMatch(e.Text);
+ }
+
+ private async void OnResolutionTextChanged(object sender, EventArgs e)
+ {
+ var seconds = NormalizeResolutionInput(_resolution.Text);
+ if (Math.Abs(seconds - PlotResolution.TotalSeconds) >= 0.5)
+ {
+ PlotResolution = TimeSpan.FromSeconds(seconds);
+ _resolution.Text = seconds.ToString();
+ _plotController.DiscardRectangle();
+ await FilterDataAsync(TimeSpan.MinValue, TimeSpan.MaxValue, true);
+ }
+ }
+
+ private async void OnSkipUnknownsCheckedChanged(object sender, RoutedEventArgs e)
+ {
+ if (_skipUnknowns.IsChecked != SkipUnknowns)
+ {
+ SkipUnknowns = _skipUnknowns.IsChecked is true;
+ _plotController.DiscardRectangle();
+ await FilterDataAsync(TimeSpan.MinValue, TimeSpan.MaxValue, true);
+ }
+ }
+
+ private async void OnSkipAutosCheckedChanged(object sender, RoutedEventArgs e)
+ {
+ if (_skipAutos.IsChecked != SkipAutos)
+ {
+ SkipAutos = _skipAutos.IsChecked is true;
+ _plotController.DiscardRectangle();
+ await FilterDataAsync(TimeSpan.MinValue, TimeSpan.MaxValue, true);
+ }
+ }
+
+ private async void OnSkipClicksCheckedChanged(object sender, RoutedEventArgs e)
+ {
+ if (_skipClicks.IsChecked != SkipClicks)
+ {
+ SkipClicks = _skipClicks.IsChecked is true;
+ _plotController.DiscardRectangle();
+ await FilterDataAsync(TimeSpan.MinValue, TimeSpan.MaxValue, true);
+ }
+ }
+ }
+
+ internal class ApmWindowPlotViewModel : INotifyPropertyChanged
+ {
+ private PlotModel _model = new();
+ public event PropertyChangedEventHandler? PropertyChanged;
+ public PlotModel Model
+ {
+ get => _model;
+ set
+ {
+ _model = value;
+ PropertyChanged?.Invoke(this, new(nameof(Model)));
+ }
+ }
+ }
+
+ internal class ApmWindowPlotController : PlotController
+ {
+ private readonly ApmWindow _window;
+ private RectangleAnnotation? _current;
+ private PlotModel Plot => _window.PlotModel.Model;
+ private double Resolution => _window.PlotResolution.TotalSeconds;
+ // private readonly Func
+
+ public ApmWindowPlotController(ApmWindow window)
+ {
+ _window = window;
+ }
+
+ public void DiscardRectangle()
+ {
+ _current = null;
+ RemoveSelections();
+ Plot.InvalidatePlot(false);
+ }
+
+ public override bool HandleMouseDown(IView view, OxyMouseDownEventArgs args)
+ {
+ TryBeginDragRectagle(args);
+ return base.HandleMouseDown(view, args);
+ }
+
+ public override bool HandleMouseMove(IView view, OxyMouseEventArgs args)
+ {
+ TryDragRectangle(args);
+ return base.HandleMouseMove(view, args);
+ }
+
+ public override bool HandleMouseUp(IView view, OxyMouseEventArgs args)
+ {
+ TryEndDragRectangle();
+ return base.HandleMouseUp(view, args);
+ }
+
+ private void RemoveSelections()
+ {
+ var list = Plot.Annotations
+ .Select((x, i) => (Item: x, Index: i))
+ .Where(t => t.Item is RectangleAnnotation)
+ .Reverse()
+ .ToArray();
+ foreach (var (_, index) in list)
+ {
+ Plot.Annotations.RemoveAt(index);
+ }
+ }
+
+ private void TryBeginDragRectagle(OxyMouseDownEventArgs args)
+ {
+ if (args.ChangedButton == OxyMouseButton.Left)
+ {
+ var x = Plot.Axes[1].InverseTransform(args.Position.X);
+ x = Math.Round(x / Resolution) * Resolution;
+ RemoveSelections();
+ _current = new()
+ {
+ ClipByYAxis = true,
+ Fill = OxyColor.FromArgb(128, 0, 128, 255),
+ MinimumY = double.NegativeInfinity,
+ MaximumY = double.PositiveInfinity,
+ MinimumX = x,
+ MaximumX = x
+ };
+ Plot.Annotations.Add(_current);
+ Plot.InvalidatePlot(false);
+ }
+ }
+
+ private void TryDragRectangle(OxyMouseEventArgs args)
+ {
+ if (_current is not null)
+ {
+ var x = Plot.Axes[1].InverseTransform(args.Position.X);
+ var offsetMultiplier = Math.Round((x - _current.MinimumX) / Resolution);
+ x = offsetMultiplier * Resolution;
+ _current.MaximumX = _current.MinimumX + x;
+ if (_current.MaximumX < _current.MinimumX)
+ {
+ var temp = _current.MinimumX;
+ _current.MinimumX = _current.MaximumX;
+ _current.MaximumX = temp;
+ }
+ Plot.InvalidatePlot(false);
+ }
+ }
+
+ private void TryEndDragRectangle()
+ {
+ if (_current is not null)
+ {
+ if (Math.Abs(_current.MaximumX - _current.MinimumX) < Resolution)
+ {
+ RemoveSelections();
+ Plot.InvalidatePlot(false);
+ _window.FilterData(TimeSpan.MinValue, TimeSpan.MaxValue, false);
+ }
+ else
+ {
+ _window.FilterData(TimeSpan.FromSeconds(_current.MinimumX),
+ TimeSpan.FromSeconds(_current.MaximumX),
+ false);
+ }
+ _current = null;
+ }
+ }
+ }
+
+ public class ApmWindowPlotTextAnnotation : Annotation
+ {
+ public string Text { get; set; } = string.Empty;
+ public double X { get; set; } = 8;
+ public double Y { get; set; } = 8;
+
+ public override void Render(IRenderContext rc)
+ {
+ base.Render(rc);
+ double pX = PlotModel.PlotArea.Left + X;
+ double pY = PlotModel.PlotArea.Top + Y;
+ rc.DrawMultilineText(new(pX, pY),
+ Text,
+ PlotModel.TextColor,
+ PlotModel.DefaultFont,
+ PlotModel.DefaultFontSize,
+ PlotModel.SubtitleFontWeight);
+ }
}
}
diff --git a/ReplayFile/CommandChunk.cs b/ReplayFile/CommandChunk.cs
index b18336b..a7554a1 100644
--- a/ReplayFile/CommandChunk.cs
+++ b/ReplayFile/CommandChunk.cs
@@ -1,264 +1,15 @@
using System;
using System.Collections.Generic;
using System.IO;
-using System.Linq;
namespace AnotherReplayReader.ReplayFile
{
- internal static class RA3Commands
- {
- private static readonly Dictionary> _commandParser;
- private static readonly Dictionary _commandNames;
-
- static RA3Commands()
- {
- Action fixedSizeParser(byte command, int size)
- {
- return (BinaryReader current) =>
- {
- var lastByte = current.ReadBytes(size - 2).Last();
- if (lastByte != 0xFF)
- {
- throw new InvalidDataException($"Failed to parse command {command:X}, last byte is {lastByte:X}");
- }
- };
- }
-
- Action variableSizeParser(byte command, int offset)
- {
- return (BinaryReader current) =>
- {
- var totalBytes = 2;
- totalBytes += current.ReadBytes(offset - 2).Length;
- for (var x = current.ReadByte(); x != 0xFF; x = current.ReadByte())
- {
- totalBytes += 1;
-
-
- var size = ((x >> 4) + 1) * 4;
- totalBytes += current.ReadBytes(size).Length;
- }
- totalBytes += 1;
- var chk = totalBytes;
- };
- };
-
- var list = new List<(byte, Func>, int, string)>
- {
- (0x00, fixedSizeParser, 45, "展开建筑/建造碉堡(?)"),
- (0x03, fixedSizeParser, 17, "开始升级"),
- (0x04, fixedSizeParser, 17, "暂停/中止升级"),
- (0x05, fixedSizeParser, 20, "开始生产单位或纳米核心"),
- (0x06, fixedSizeParser, 20, "暂停/取消生产单位或纳米核心"),
- (0x07, fixedSizeParser, 17, "开始建造建筑"),
- (0x08, fixedSizeParser, 17, "暂停/取消建造建筑"),
- (0x09, fixedSizeParser, 35, "摆放建筑"),
- (0x0F, fixedSizeParser, 16, "(未知指令)"),
- (0x14, fixedSizeParser, 16, "移动"),
- (0x15, fixedSizeParser, 16, "移动攻击(A)"),
- (0x16, fixedSizeParser, 16, "强制移动/碾压(G)"),
- (0x21, fixedSizeParser, 20, "[游戏每3秒自动产生的指令]"),
- (0x2C, fixedSizeParser, 29, "队形移动(左右键)"),
- (0x32, fixedSizeParser, 53, "释放技能或协议(多个目标,如侦察扫描协议)"),
- (0x34, fixedSizeParser, 45, "[游戏自动产生的UUID]"),
- (0x35, fixedSizeParser, 1049, "[玩家信息(?)]"),
- (0x36, fixedSizeParser, 16, "倒车移动(D)"),
- (0x5F, fixedSizeParser, 11, "(未知指令)"),
-
- (0x0A, variableSizeParser, 2, "出售建筑"),
- (0x0D, variableSizeParser, 2, "右键攻击"),
- (0x0E, variableSizeParser, 2, "强制攻击(Ctrl)"),
- (0x12, variableSizeParser, 2, "(未知指令)"),
- (0x1A, variableSizeParser, 2, "停止(S)"),
- (0x1B, variableSizeParser, 2, "(未知指令)"),
- (0x28, variableSizeParser, 2, "开始维修建筑"),
- (0x29, variableSizeParser, 2, "停止维修建筑"),
- (0x2A, variableSizeParser, 2, "选择所有单位(Q)"),
- (0x2E, variableSizeParser, 2, "切换警戒/侵略/固守/停火模式"),
- (0x2F, variableSizeParser, 2, "路径点模式(Alt)(?)"),
- (0x37, variableSizeParser, 2, "[游戏不定期自动产生的指令]"),
- (0x47, variableSizeParser, 2, "[游戏在第五帧自动产生的指令]"),
- (0x48, variableSizeParser, 2, "(未知指令)"),
- (0x4C, variableSizeParser, 2, "删除信标(或F9?)"),
- (0x4E, variableSizeParser, 2, "选择协议"),
- (0x52, variableSizeParser, 2, "(未知指令)"),
- (0xF5, variableSizeParser, 5, "选择单位"),
- (0xF6, variableSizeParser, 5, "[未知指令,貌似会在展开兵营核心时自动产生?]"),
- (0xF8, variableSizeParser, 4, "鼠标左键单击/取消选择"),
- (0xF9, variableSizeParser, 2, "[可能是步兵自行从进驻的建筑撤出]"),
- (0xFA, variableSizeParser, 7, "创建编队"),
- (0xFB, variableSizeParser, 2, "选择编队"),
- (0xFC, variableSizeParser, 2, "(未知指令)"),
- (0xFD, variableSizeParser, 7, "(未知指令)"),
- (0xFE, variableSizeParser, 15, "释放技能或协议(无目标)"),
- (0xFF, variableSizeParser, 34, "释放技能或协议(单个目标)"),
- };
-
- var specialList = new List<(byte, Action, string)>
- {
- (0x01, ParseSpecialChunk0x01, "[游戏自动生成的指令]"),
- (0x02, ParseSetRallyPoint0x02, "设计集结点"),
- (0x0C, ParseUngarrison0x0C, "从进驻的建筑撤出(?)"),
- (0x10, ParseGarrison0x10, "进驻建筑"),
- (0x33, ParseUuid0x33, "[游戏自动生成的UUID指令]"),
- (0x4B, ParsePlaceBeacon0x4B, "信标")
- };
-
- _commandParser = new Dictionary>();
- _commandNames = new Dictionary();
-
- foreach (var (id, maker, size, description) in list)
- {
- _commandParser.Add(id, maker(id, size));
- _commandNames.Add(id, description);
- }
-
- foreach (var (id, parser, description) in specialList)
- {
- _commandParser.Add(id, parser);
- _commandNames.Add(id, description);
- }
-
- _commandNames.Add(0x4D, "在信标里输入文字");
- }
-
- public static void ReadCommandData(this BinaryReader reader, byte commandId)
- {
- if (_commandParser.TryGetValue(commandId, out var parser))
- {
- _commandParser[commandId](reader);
- }
- else
- {
- UnknownCommandParser(reader, commandId);
- }
- }
-
- public static string GetCommandName(byte commandId)
- {
- return _commandNames.TryGetValue(commandId, out var storedName) ? storedName : $"(未知指令 0x{commandId:X2})";
- }
-
- public static void UnknownCommandParser(BinaryReader current, byte commandId)
- {
- while (true)
- {
- var value = current.ReadByte();
- if (value == 0xFF)
- {
- break;
- }
- }
-
- //return $"(未知指令 0x{commandID:2X})";
- }
-
- private static void ParseSpecialChunk0x01(BinaryReader current)
- {
- var firstByte = current.ReadByte();
- if (firstByte == 0xFF)
- {
- return;
- }
-
- var sixthByte = current.ReadBytes(5).Last();
- if (sixthByte == 0xFF)
- {
- return;
- }
-
- var sixteenthByte = current.ReadBytes(10).Last();
- var size = (int)(sixteenthByte + 1) * 4 + 14;
- var lastByte = current.ReadBytes(size).Last();
- if (lastByte != 0xFF)
- {
- throw new InvalidDataException();
- }
-
- return;
- }
-
- private static void ParseSetRallyPoint0x02(BinaryReader current)
- {
- var size = (current.ReadBytes(23).Last() + 1) * 2 + 1;
- var lastByte = current.ReadBytes(size).Last();
- if (lastByte != 0xFF)
- {
- throw new InvalidDataException();
- }
- }
-
- private static void ParseUngarrison0x0C(BinaryReader current)
- {
- current.ReadByte();
- var size = (current.ReadByte() + 1) * 4 + 1;
- var lastByte = current.ReadBytes(size).Last();
- if (lastByte != 0xFF)
- {
- throw new InvalidDataException();
- }
- }
-
- private static void ParseGarrison0x10(BinaryReader current)
- {
- var type = current.ReadByte();
- var size = -1;
- if (type == 0x14)
- {
- size = 9;
- }
- else if (type == 0x04)
- {
- size = 10;
- }
-
- var lastByte = current.ReadBytes(size).Last();
- if (lastByte != 0xFF)
- {
- throw new InvalidDataException();
- }
- }
-
- private static void ParseUuid0x33(BinaryReader current)
- {
- current.ReadByte();
- var firstStringLength = (int)current.ReadByte();
- current.ReadBytes(firstStringLength + 1);
- var secondStringLength = current.ReadByte() * 2;
- var lastByte = current.ReadBytes(secondStringLength + 6).Last();
- if (lastByte != 0xFF)
- {
- throw new InvalidDataException();
- }
- }
-
- private static void ParsePlaceBeacon0x4B(BinaryReader current)
- {
- var type = current.ReadByte();
- var size = -1;
- if (type == 0x04)
- {
- size = 5;
- }
- else if (type == 0x07)
- {
- size = 13;
- }
-
- var lastByte = current.ReadBytes(size).Last();
- if (lastByte != 0xFF)
- {
- throw new InvalidDataException();
- }
- }
- }
-
internal sealed class CommandChunk
{
public byte CommandId { get; private set; }
public int PlayerIndex { get; private set; }
- public static List Parse(in ReplayChunk chunk)
+ public static List Parse(ReplayChunk chunk)
{
if (chunk.Type != 1)
{
@@ -297,5 +48,10 @@ namespace AnotherReplayReader.ReplayFile
return list;
}
+
+ public override string ToString()
+ {
+ return $"[玩家 {PlayerIndex},{RA3Commands.GetCommandName(CommandId)}]";
+ }
}
}
diff --git a/ReplayFile/Player.cs b/ReplayFile/Player.cs
index a4f0dc5..0832305 100644
--- a/ReplayFile/Player.cs
+++ b/ReplayFile/Player.cs
@@ -12,6 +12,7 @@ namespace AnotherReplayReader.ReplayFile
{ "B", "凶残" },
};
+ public bool IsComputer { get; }
public string PlayerName { get; }
public uint PlayerIp { get; }
public int FactionId { get; }
@@ -19,11 +20,11 @@ namespace AnotherReplayReader.ReplayFile
public Player(string[] playerEntry)
{
- var isComputer = playerEntry[0][0] == 'C';
+ IsComputer = playerEntry[0][0] == 'C';
PlayerName = playerEntry[0].Substring(1);
- if (isComputer)
+ if (IsComputer)
{
PlayerName = ComputerNames[PlayerName];
PlayerIp = 0;
diff --git a/ReplayFile/RA3Commands.cs b/ReplayFile/RA3Commands.cs
new file mode 100644
index 0000000..777c5ac
--- /dev/null
+++ b/ReplayFile/RA3Commands.cs
@@ -0,0 +1,288 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.IO;
+using System.Linq;
+
+namespace AnotherReplayReader.ReplayFile
+{
+ internal static class RA3Commands
+ {
+ private static readonly Dictionary> _commandParser;
+ private static readonly Dictionary _commandNames;
+
+ public static ImmutableArray UnknownCommands { get; } = new byte[]
+ {
+ 0x0F, // unk
+ 0x5F, // unk
+ 0x12, // unk
+ 0x1B, // unk
+ 0x48, // unk
+ 0x52, // unk
+ 0xFC, // unk
+ 0xFD, // unk
+ }.ToImmutableArray();
+
+ public static ImmutableArray AutoCommands { get; } = new byte[]
+ {
+ 0x01, // auto gen
+ 0x21, // 3 seconds heartbeat
+ 0x33, // uuid
+ 0x34, // uuid
+ 0x35, // player info
+ 0x37, // indeterminate autogen
+ 0x47, // 5th frame auto gen
+ 0xF6, // auto, maybe from barrack deploy
+ 0xF9, // auto, maybe unit from structures
+ }.ToImmutableArray();
+
+ static RA3Commands()
+ {
+ Action FixedSizeParser(byte command, int size)
+ {
+ return (BinaryReader current) =>
+ {
+ var lastByte = current.ReadBytes(size - 2).Last();
+ if (lastByte != 0xFF)
+ {
+ throw new InvalidDataException($"Failed to parse command {command:X}, last byte is {lastByte:X}");
+ }
+ };
+ }
+
+ Action VariableSizeParser(byte command, int offset)
+ {
+ return (BinaryReader current) =>
+ {
+ var totalBytes = 2;
+ totalBytes += current.ReadBytes(offset - 2).Length;
+ for (var x = current.ReadByte(); x != 0xFF; x = current.ReadByte())
+ {
+ totalBytes += 1;
+
+
+ var size = ((x >> 4) + 1) * 4;
+ totalBytes += current.ReadBytes(size).Length;
+ }
+ totalBytes += 1;
+ var chk = totalBytes;
+ };
+ };
+
+ var list = new List<(byte, Func>, int, string)>
+ {
+ (0x00, FixedSizeParser, 45, "展开建筑/建造碉堡(?)"),
+ (0x03, FixedSizeParser, 17, "开始升级"),
+ (0x04, FixedSizeParser, 17, "暂停/中止升级"),
+ (0x05, FixedSizeParser, 20, "开始生产单位或纳米核心"),
+ (0x06, FixedSizeParser, 20, "暂停/取消生产单位或纳米核心"),
+ (0x07, FixedSizeParser, 17, "开始建造建筑"),
+ (0x08, FixedSizeParser, 17, "暂停/取消建造建筑"),
+ (0x09, FixedSizeParser, 35, "摆放建筑"),
+ (0x0F, FixedSizeParser, 16, "(未知指令 0x0F)"),
+ (0x14, FixedSizeParser, 16, "移动"),
+ (0x15, FixedSizeParser, 16, "移动攻击(A)"),
+ (0x16, FixedSizeParser, 16, "强制移动/碾压(G)"),
+ (0x21, FixedSizeParser, 20, "[游戏每3秒自动产生的指令]"),
+ (0x2C, FixedSizeParser, 29, "队形移动(左右键)"),
+ (0x32, FixedSizeParser, 53, "释放技能或协议(多个目标,如侦察扫描协议)"),
+ (0x34, FixedSizeParser, 45, "[游戏自动产生的UUID]"),
+ (0x35, FixedSizeParser, 1049, "[玩家信息(?)]"),
+ (0x36, FixedSizeParser, 16, "倒车移动(D)"),
+ (0x5F, FixedSizeParser, 11, "(未知指令 0x5F)"),
+
+ (0x0A, VariableSizeParser, 2, "出售建筑"),
+ (0x0D, VariableSizeParser, 2, "右键攻击"),
+ (0x0E, VariableSizeParser, 2, "强制攻击(Ctrl)"),
+ (0x12, VariableSizeParser, 2, "(未知指令 0x12)"),
+ (0x1A, VariableSizeParser, 2, "停止(S)"),
+ (0x1B, VariableSizeParser, 2, "(未知指令 0x1B)"),
+ (0x28, VariableSizeParser, 2, "开始维修建筑"),
+ (0x29, VariableSizeParser, 2, "停止维修建筑"),
+ (0x2A, VariableSizeParser, 2, "选择所有单位(Q)"),
+ (0x2E, VariableSizeParser, 2, "切换警戒/侵略/固守/停火模式"),
+ (0x2F, VariableSizeParser, 2, "路径点模式(Alt)(?)"),
+ (0x37, VariableSizeParser, 2, "[游戏不定期自动产生的指令]"),
+ (0x47, VariableSizeParser, 2, "[游戏在第五帧自动产生的指令]"),
+ (0x48, VariableSizeParser, 2, "(未知指令 0x48)"),
+ (0x4C, VariableSizeParser, 2, "删除信标(或 F9?)"),
+ (0x4E, VariableSizeParser, 2, "选择协议"),
+ (0x52, VariableSizeParser, 2, "(未知指令 0x52)"),
+ (0xF5, VariableSizeParser, 5, "选择单位"),
+ (0xF6, VariableSizeParser, 5, "[未知指令,貌似会在展开兵营核心时自动产生?]"),
+ (0xF8, VariableSizeParser, 4, "鼠标左键单击/取消选择"),
+ (0xF9, VariableSizeParser, 2, "[可能是步兵自行从进驻的建筑撤出]"),
+ (0xFA, VariableSizeParser, 7, "创建编队"),
+ (0xFB, VariableSizeParser, 2, "选择编队"),
+ (0xFC, VariableSizeParser, 2, "(未知指令 0xFC)"),
+ (0xFD, VariableSizeParser, 7, "(未知指令 0xFD)"),
+ (0xFE, VariableSizeParser, 15, "释放技能或协议(无目标)"),
+ (0xFF, VariableSizeParser, 34, "释放技能或协议(单个目标)"),
+ };
+
+ var specialList = new List<(byte, Action, string)>
+ {
+ (0x01, ParseSpecialChunk0x01, "[游戏自动生成的指令]"),
+ (0x02, ParseSetRallyPoint0x02, "设计集结点"),
+ (0x0C, ParseUngarrison0x0C, "从进驻的建筑撤出(?)"),
+ (0x10, ParseGarrison0x10, "进驻建筑"),
+ (0x33, ParseUuid0x33, "[游戏自动生成的UUID指令]"),
+ (0x4B, ParsePlaceBeacon0x4B, "信标")
+ };
+
+ _commandParser = new Dictionary>();
+ _commandNames = new Dictionary();
+
+ foreach (var (id, maker, size, description) in list)
+ {
+ _commandParser.Add(id, maker(id, size));
+ _commandNames.Add(id, description);
+ }
+
+ foreach (var (id, parser, description) in specialList)
+ {
+ _commandParser.Add(id, parser);
+ _commandNames.Add(id, description);
+ }
+
+ _commandNames.Add(0x4D, "在信标里输入文字");
+ }
+
+ public static void ReadCommandData(this BinaryReader reader, byte commandId)
+ {
+ if (_commandParser.TryGetValue(commandId, out var parser))
+ {
+ _commandParser[commandId](reader);
+ }
+ else
+ {
+ UnknownCommandParser(reader, commandId);
+ }
+ }
+
+ public static bool IsUnknownCommand(byte commandId)
+ {
+ return !_commandNames.ContainsKey(commandId);
+ }
+
+ public static string GetCommandName(byte commandId)
+ {
+ return _commandNames.TryGetValue(commandId, out var storedName)
+ ? storedName
+ : $"(未知指令 0x{commandId:X2})";
+ }
+
+ public static void UnknownCommandParser(BinaryReader current, byte commandId)
+ {
+ while (true)
+ {
+ var value = current.ReadByte();
+ if (value == 0xFF)
+ {
+ break;
+ }
+ }
+
+ //return $"(未知指令 0x{commandID:2X})";
+ }
+
+ private static void ParseSpecialChunk0x01(BinaryReader current)
+ {
+ var firstByte = current.ReadByte();
+ if (firstByte == 0xFF)
+ {
+ return;
+ }
+
+ var sixthByte = current.ReadBytes(5).Last();
+ if (sixthByte == 0xFF)
+ {
+ return;
+ }
+
+ var sixteenthByte = current.ReadBytes(10).Last();
+ var size = (int)(sixteenthByte + 1) * 4 + 14;
+ var lastByte = current.ReadBytes(size).Last();
+ if (lastByte != 0xFF)
+ {
+ throw new InvalidDataException();
+ }
+
+ return;
+ }
+
+ private static void ParseSetRallyPoint0x02(BinaryReader current)
+ {
+ var size = (current.ReadBytes(23).Last() + 1) * 2 + 1;
+ var lastByte = current.ReadBytes(size).Last();
+ if (lastByte != 0xFF)
+ {
+ throw new InvalidDataException();
+ }
+ }
+
+ private static void ParseUngarrison0x0C(BinaryReader current)
+ {
+ current.ReadByte();
+ var size = (current.ReadByte() + 1) * 4 + 1;
+ var lastByte = current.ReadBytes(size).Last();
+ if (lastByte != 0xFF)
+ {
+ throw new InvalidDataException();
+ }
+ }
+
+ private static void ParseGarrison0x10(BinaryReader current)
+ {
+ var type = current.ReadByte();
+ var size = -1;
+ if (type == 0x14)
+ {
+ size = 9;
+ }
+ else if (type == 0x04)
+ {
+ size = 10;
+ }
+
+ var lastByte = current.ReadBytes(size).Last();
+ if (lastByte != 0xFF)
+ {
+ throw new InvalidDataException();
+ }
+ }
+
+ private static void ParseUuid0x33(BinaryReader current)
+ {
+ current.ReadByte();
+ var firstStringLength = (int)current.ReadByte();
+ current.ReadBytes(firstStringLength + 1);
+ var secondStringLength = current.ReadByte() * 2;
+ var lastByte = current.ReadBytes(secondStringLength + 6).Last();
+ if (lastByte != 0xFF)
+ {
+ throw new InvalidDataException();
+ }
+ }
+
+ private static void ParsePlaceBeacon0x4B(BinaryReader current)
+ {
+ var type = current.ReadByte();
+ var size = -1;
+ if (type == 0x04)
+ {
+ size = 5;
+ }
+ else if (type == 0x07)
+ {
+ size = 13;
+ }
+
+ var lastByte = current.ReadBytes(size).Last();
+ if (lastByte != 0xFF)
+ {
+ throw new InvalidDataException();
+ }
+ }
+ }
+}
diff --git a/ReplayFile/Replay.cs b/ReplayFile/Replay.cs
index b360ae3..7c92695 100644
--- a/ReplayFile/Replay.cs
+++ b/ReplayFile/Replay.cs
@@ -1,6 +1,7 @@
using AnotherReplayReader.Utils;
using System;
using System.Collections.Generic;
+using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text;
@@ -23,6 +24,8 @@ namespace AnotherReplayReader.ReplayFile
{ ReplayType.Lan, "局域网录像" },
{ ReplayType.Online, "官网录像" },
};
+ public const double FrameRate = 15.0;
+ public const string PostCommentator = "post Commentator";
private readonly byte _replaySaverIndex;
private readonly byte[]? _rawHeader;
@@ -31,7 +34,7 @@ namespace AnotherReplayReader.ReplayFile
public DateTime Date { get; }
public string MapName { get; }
public string MapPath { get; }
- public IReadOnlyList Players { get; }
+ public ImmutableArray Players { get; }
public int NumberOfPlayingPlayers { get; }
public Mod Mod { get; }
public ReplayType Type { get; }
@@ -39,7 +42,7 @@ namespace AnotherReplayReader.ReplayFile
public long Size { get; }
public ReplayFooter? Footer { get; }
- public IReadOnlyList? Body { get; }
+ public ImmutableArray? Body { get; }
public string FileName => System.IO.Path.GetFileNameWithoutExtension(Path);
public Player ReplaySaver => Players[_replaySaverIndex];
@@ -119,7 +122,7 @@ namespace AnotherReplayReader.ReplayFile
Players = entries["S"].Split(':')
.TakeWhile(x => !string.IsNullOrWhiteSpace(x) && x[0] != 'X')
.Select(x => new Player(x.Split(',')))
- .ToList();
+ .ToImmutableArray();
}
catch (Exception exception)
{
@@ -219,40 +222,7 @@ namespace AnotherReplayReader.ReplayFile
}
_rawHeader = rawHeader;
- Body = body;
- }
-
- public Dictionary GetCommandCounts()
- {
- if (Body is null)
- {
- throw new InvalidOperationException("Replay body must be parsed before retrieving command chunks");
- }
-
- 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;
+ Body = body.ToImmutableArray();
}
public Replay CloneHeader()
@@ -283,7 +253,7 @@ namespace AnotherReplayReader.ReplayFile
string size = GetSizeString(Size);
string length = Length?.ToString() ?? "录像已损坏,请先修复录像";
- var replaySaver = _replaySaverIndex < Players.Count
+ var replaySaver = _replaySaverIndex < Players.Length
? ReplaySaver.PlayerName
: "[无法获取保存录像的玩家]";
@@ -298,7 +268,7 @@ namespace AnotherReplayReader.ReplayFile
writer.WriteLine("玩家列表:");
foreach (var player in Players)
{
- if (player == Players.Last() && player.PlayerName.Equals("post Commentator"))
+ if (player == Players.Last() && player.PlayerName.Equals(PostCommentator))
{
break;
}
diff --git a/ReplayFile/ReplayChunk.cs b/ReplayFile/ReplayChunk.cs
index b4943f1..1650101 100644
--- a/ReplayFile/ReplayChunk.cs
+++ b/ReplayFile/ReplayChunk.cs
@@ -1,4 +1,5 @@
-using System.IO;
+using System;
+using System.IO;
namespace AnotherReplayReader.ReplayFile
{
@@ -9,6 +10,8 @@ namespace AnotherReplayReader.ReplayFile
public uint TimeCode { get; }
public byte Type { get; }
+ public TimeSpan Time => TimeSpan.FromSeconds(TimeCode / Replay.FrameRate);
+
public ReplayChunk(uint timeCode, BinaryReader reader)
{
TimeCode = timeCode; // reader.ReadUInt32();
diff --git a/ReplayFile/ReplayFooter.cs b/ReplayFile/ReplayFooter.cs
index 1b3a637..55e661a 100644
--- a/ReplayFile/ReplayFooter.cs
+++ b/ReplayFile/ReplayFooter.cs
@@ -20,7 +20,7 @@ namespace AnotherReplayReader.ReplayFile
private readonly byte[] _data;
public uint FinalTimeCode { get; }
- public TimeSpan ReplayLength => TimeSpan.FromSeconds(FinalTimeCode / 15.0);
+ public TimeSpan ReplayLength => TimeSpan.FromSeconds(FinalTimeCode / Replay.FrameRate);
public ReplayFooter(BinaryReader reader, ReplayFooterOption option)
{