APM!
This commit is contained in:
parent
78a7310a3b
commit
0863b2fd71
@ -34,6 +34,7 @@
|
|||||||
<PackageReference Include="ILMerge" Version="3.0.41" />
|
<PackageReference Include="ILMerge" Version="3.0.41" />
|
||||||
<PackageReference Include="ILMerge.MSBuild.Task" Version="1.0.7" />
|
<PackageReference Include="ILMerge.MSBuild.Task" Version="1.0.7" />
|
||||||
<PackageReference Include="NPinyin.Core" Version="3.0.0" />
|
<PackageReference Include="NPinyin.Core" Version="3.0.0" />
|
||||||
|
<PackageReference Include="OxyPlot.Wpf" Version="2.1.0" />
|
||||||
<PackageReference Include="Pfim" Version="0.10.1" />
|
<PackageReference Include="Pfim" Version="0.10.1" />
|
||||||
<PackageReference Include="System.Collections.Immutable" Version="5.0.0" />
|
<PackageReference Include="System.Collections.Immutable" Version="5.0.0" />
|
||||||
<PackageReference Include="System.Text.Json" Version="5.0.2" />
|
<PackageReference Include="System.Text.Json" Version="5.0.2" />
|
||||||
|
236
Apm/ApmPlotter.cs
Normal file
236
Apm/ApmPlotter.cs
Normal file
@ -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<byte, byte> UnknownCommands;
|
||||||
|
private static readonly ConcurrentDictionary<byte, byte> AutoCommands;
|
||||||
|
private readonly ImmutableArray<(TimeSpan, ImmutableArray<CommandChunk>)> _commands;
|
||||||
|
|
||||||
|
public Replay Replay { get; }
|
||||||
|
public ImmutableArray<Player> Players { get; }
|
||||||
|
public ImmutableArray<TimeSpan> PlayerLifes { get; }
|
||||||
|
public ImmutableArray<TimeSpan> StricterPlayerLifes { get; }
|
||||||
|
public TimeSpan ReplayLength { get; }
|
||||||
|
|
||||||
|
static ApmPlotter()
|
||||||
|
{
|
||||||
|
static KeyValuePair<byte, byte> 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<CommandChunk> 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<int> { 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<byte, int[]> GetCommandCounts(TimeSpan begin, TimeSpan end)
|
||||||
|
{
|
||||||
|
var playerCommands = new Dictionary<byte, int[]>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
146
Apm/DataRow.cs
Normal file
146
Apm/DataRow.cs
Normal file
@ -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<DataValue> _values;
|
||||||
|
|
||||||
|
public DataRow(string name,
|
||||||
|
IEnumerable<string> 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<DataRow> 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<byte>();
|
||||||
|
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<DataRow>
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
43
Apm/DataValue.cs
Normal file
43
Apm/DataValue.cs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AnotherReplayReader.Apm
|
||||||
|
{
|
||||||
|
internal class DataValue : IComparable<DataValue>, 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
169
ApmWindow.xaml
169
ApmWindow.xaml
@ -1,63 +1,128 @@
|
|||||||
<Window x:Class="AnotherReplayReader.ApmWindow"
|
<Window x:Class="AnotherReplayReader.ApmWindow"
|
||||||
x:ClassModifier="internal"
|
x:ClassModifier="internal"
|
||||||
|
x:Name="ApmWindowInstance"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:local="clr-namespace:AnotherReplayReader"
|
xmlns:local="clr-namespace:AnotherReplayReader"
|
||||||
|
xmlns:ox="http://oxyplot.org/wpf"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
Title="APM"
|
Title="APM"
|
||||||
Height="450"
|
Height="550"
|
||||||
Width="800">
|
Width="800"
|
||||||
<Grid>
|
Loaded="OnApmWindowLoaded">
|
||||||
<Label Margin="20,10,0,0"
|
<Grid Margin="0">
|
||||||
HorizontalAlignment="Left"
|
<Grid.RowDefinitions>
|
||||||
Height="30"
|
<RowDefinition Height="200*" />
|
||||||
VerticalAlignment="Top">
|
<RowDefinition Height="300*" />
|
||||||
选择表格之后按 Ctrl+C 可以复制内容
|
</Grid.RowDefinitions>
|
||||||
</Label>
|
<GridSplitter Grid.Row="0"
|
||||||
<Button x:Name="_setPlayerButton"
|
VerticalAlignment="Bottom"
|
||||||
Content="设置玩家信息..."
|
HorizontalAlignment="Stretch"
|
||||||
Margin="0,19,22,0"
|
Height="6"
|
||||||
VerticalAlignment="Top"
|
BorderBrush="Gray"
|
||||||
Padding="5,2"
|
BorderThickness="1" />
|
||||||
Visibility="Hidden"
|
<DockPanel Grid.Row="0"
|
||||||
Click="OnSetPlayerButtonClick"
|
Margin="0,0,0,12">
|
||||||
HorizontalAlignment="Right"/>
|
<StackPanel DockPanel.Dock="Bottom"
|
||||||
<DataGrid x:Name="_table"
|
Margin="16,0"
|
||||||
Margin="20,40,22,19"
|
Orientation="Horizontal">
|
||||||
Grid.ColumnSpan="2"
|
<Label Content="分辨率"
|
||||||
CanUserSortColumns="True">
|
VerticalAlignment="Center" />
|
||||||
<DataGrid.Resources>
|
<TextBox x:Name="_resolution"
|
||||||
<Style TargetType="DataGridCell">
|
Width="42"
|
||||||
<EventSetter Event="MouseDoubleClick"
|
TextAlignment="Right"
|
||||||
Handler="OnTableMouseDoubleClick" />
|
VerticalAlignment="Center"
|
||||||
</Style>
|
PreviewTextInput="OnResolutionPreviewTextInput"
|
||||||
</DataGrid.Resources>
|
TextChanged="OnResolutionTextChanged" />
|
||||||
<DataGrid.Columns>
|
<Label Content="秒"
|
||||||
<DataGridTextColumn Header="项"
|
VerticalAlignment="Center" />
|
||||||
Binding="{Binding Path=Name}"
|
<CheckBox x:Name="_skipUnknowns"
|
||||||
IsReadOnly="True" />
|
Margin="24,0,0,0"
|
||||||
<DataGridTextColumn Header="玩家1"
|
Content="忽略未知操作"
|
||||||
Binding="{Binding Path=Player1Value}"
|
IsChecked="True"
|
||||||
IsReadOnly="True" />
|
VerticalAlignment="Center"
|
||||||
<DataGridTextColumn Header="玩家2"
|
Checked="OnSkipUnknownsCheckedChanged"
|
||||||
Binding="{Binding Path=Player2Value}"
|
Unchecked="OnSkipUnknownsCheckedChanged" />
|
||||||
IsReadOnly="True" />
|
<CheckBox x:Name="_skipAutos"
|
||||||
<DataGridTextColumn Header="玩家3"
|
Margin="24,0,0,0"
|
||||||
Binding="{Binding Path=Player3Value}"
|
Content="忽略自动操作"
|
||||||
IsReadOnly="True" />
|
IsChecked="True"
|
||||||
<DataGridTextColumn Header="玩家4"
|
VerticalAlignment="Center"
|
||||||
Binding="{Binding Path=Player4Value}"
|
Checked="OnSkipAutosCheckedChanged"
|
||||||
IsReadOnly="True" />
|
Unchecked="OnSkipAutosCheckedChanged" />
|
||||||
<DataGridTextColumn Header="玩家5"
|
<CheckBox x:Name="_skipClicks"
|
||||||
Binding="{Binding Path=Player5Value}"
|
Margin="24,0,0,0"
|
||||||
IsReadOnly="True" />
|
Content="忽略左键点选"
|
||||||
<DataGridTextColumn Header="玩家6"
|
IsChecked="False"
|
||||||
Binding="{Binding Path=Player6Value}"
|
VerticalAlignment="Center"
|
||||||
IsReadOnly="True" />
|
Checked="OnSkipClicksCheckedChanged"
|
||||||
</DataGrid.Columns>
|
Unchecked="OnSkipClicksCheckedChanged" />
|
||||||
</DataGrid>
|
</StackPanel>
|
||||||
|
<ox:PlotView x:Name="_plot"
|
||||||
|
DataContext="{Binding ElementName=ApmWindowInstance, Path=PlotModel}"
|
||||||
|
Model="{Binding Model}" />
|
||||||
|
</DockPanel>
|
||||||
|
<DockPanel Grid.Row="1"
|
||||||
|
Margin="16,8,16,16">
|
||||||
|
<DockPanel DockPanel.Dock="Top">
|
||||||
|
<Button x:Name="_setPlayerButton"
|
||||||
|
DockPanel.Dock="Right"
|
||||||
|
Content="设置玩家信息..."
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Padding="8,4"
|
||||||
|
Visibility="Visible"
|
||||||
|
Click="OnSetPlayerButtonClick" />
|
||||||
|
<Label x:Name="_label"
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="0"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
选择表格之后按 Ctrl+C 可以复制内容
|
||||||
|
</Label>
|
||||||
|
</DockPanel>
|
||||||
|
<DataGrid x:Name="_table"
|
||||||
|
CanUserSortColumns="True"
|
||||||
|
VirtualizingPanel.ScrollUnit="Pixel">
|
||||||
|
<DataGrid.Resources>
|
||||||
|
<Style TargetType="DataGridCell">
|
||||||
|
<EventSetter Event="MouseDoubleClick"
|
||||||
|
Handler="OnTableMouseDoubleClick" />
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="DataGridRow">
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding Path=IsDisabled}"
|
||||||
|
Value="True">
|
||||||
|
<Setter Property="Foreground"
|
||||||
|
Value="Gray" />
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</DataGrid.Resources>
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="项"
|
||||||
|
Binding="{Binding Path=Name}"
|
||||||
|
IsReadOnly="True" />
|
||||||
|
<DataGridTextColumn Header="玩家1"
|
||||||
|
Binding="{Binding Path=Player1Value}"
|
||||||
|
IsReadOnly="True" />
|
||||||
|
<DataGridTextColumn Header="玩家2"
|
||||||
|
Binding="{Binding Path=Player2Value}"
|
||||||
|
IsReadOnly="True" />
|
||||||
|
<DataGridTextColumn Header="玩家3"
|
||||||
|
Binding="{Binding Path=Player3Value}"
|
||||||
|
IsReadOnly="True" />
|
||||||
|
<DataGridTextColumn Header="玩家4"
|
||||||
|
Binding="{Binding Path=Player4Value}"
|
||||||
|
IsReadOnly="True" />
|
||||||
|
<DataGridTextColumn Header="玩家5"
|
||||||
|
Binding="{Binding Path=Player5Value}"
|
||||||
|
IsReadOnly="True" />
|
||||||
|
<DataGridTextColumn Header="玩家6"
|
||||||
|
Binding="{Binding Path=Player6Value}"
|
||||||
|
IsReadOnly="True" />
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</DockPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
@ -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;
|
||||||
using System.Collections.Generic;
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
|
|
||||||
namespace AnotherReplayReader
|
namespace AnotherReplayReader
|
||||||
{
|
{
|
||||||
internal class DataValue : IComparable<DataValue>, 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<DataValue> _values;
|
|
||||||
|
|
||||||
public DataRow(string name, IEnumerable<string> 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<DataRow> GetList(Replay replay, PlayerIdentity identity)
|
|
||||||
{
|
|
||||||
var list = new List<byte>
|
|
||||||
{
|
|
||||||
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<DataRow>
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// APM.xaml 的交互逻辑
|
/// APM.xaml 的交互逻辑
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal partial class ApmWindow : Window
|
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 PlayerIdentity _identity;
|
||||||
|
private readonly Replay _replay;
|
||||||
|
private readonly Task<ApmPlotter> _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)
|
public ApmWindow(Replay replay, PlayerIdentity identity)
|
||||||
{
|
{
|
||||||
_identity = identity;
|
_identity = identity;
|
||||||
|
_replay = replay;
|
||||||
|
_plotter = Task.Run(() => new ApmPlotter(replay));
|
||||||
|
_plotController = new(this);
|
||||||
InitializeComponent();
|
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)
|
if (_identity.IsUsable)
|
||||||
{
|
{
|
||||||
@ -181,21 +71,15 @@ namespace AnotherReplayReader
|
|||||||
_setPlayerButton.Visibility = Visibility.Hidden;
|
_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
|
try
|
||||||
{
|
{
|
||||||
var dataList = await Task.Run(() => DataRow.GetList(replay, _identity));
|
await FilterDataAsyncThrowable(begin, end, updatePlot);
|
||||||
_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;
|
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
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)
|
private void OnSetPlayerButtonClick(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var window1 = new Window1(_identity);
|
var window1 = new Window1(_identity);
|
||||||
@ -213,5 +200,207 @@ namespace AnotherReplayReader
|
|||||||
{
|
{
|
||||||
_table.SelectAll();
|
_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<OxyMouseDownEventArgs>
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,264 +1,15 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace AnotherReplayReader.ReplayFile
|
namespace AnotherReplayReader.ReplayFile
|
||||||
{
|
{
|
||||||
internal static class RA3Commands
|
|
||||||
{
|
|
||||||
private static readonly Dictionary<byte, Action<BinaryReader>> _commandParser;
|
|
||||||
private static readonly Dictionary<byte, string> _commandNames;
|
|
||||||
|
|
||||||
static RA3Commands()
|
|
||||||
{
|
|
||||||
Action<BinaryReader> 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<BinaryReader> 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<byte, int, Action<BinaryReader>>, 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<BinaryReader>, string)>
|
|
||||||
{
|
|
||||||
(0x01, ParseSpecialChunk0x01, "[游戏自动生成的指令]"),
|
|
||||||
(0x02, ParseSetRallyPoint0x02, "设计集结点"),
|
|
||||||
(0x0C, ParseUngarrison0x0C, "从进驻的建筑撤出(?)"),
|
|
||||||
(0x10, ParseGarrison0x10, "进驻建筑"),
|
|
||||||
(0x33, ParseUuid0x33, "[游戏自动生成的UUID指令]"),
|
|
||||||
(0x4B, ParsePlaceBeacon0x4B, "信标")
|
|
||||||
};
|
|
||||||
|
|
||||||
_commandParser = new Dictionary<byte, Action<BinaryReader>>();
|
|
||||||
_commandNames = new Dictionary<byte, string>();
|
|
||||||
|
|
||||||
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
|
internal sealed class CommandChunk
|
||||||
{
|
{
|
||||||
public byte CommandId { get; private set; }
|
public byte CommandId { get; private set; }
|
||||||
public int PlayerIndex { get; private set; }
|
public int PlayerIndex { get; private set; }
|
||||||
|
|
||||||
public static List<CommandChunk> Parse(in ReplayChunk chunk)
|
public static List<CommandChunk> Parse(ReplayChunk chunk)
|
||||||
{
|
{
|
||||||
if (chunk.Type != 1)
|
if (chunk.Type != 1)
|
||||||
{
|
{
|
||||||
@ -297,5 +48,10 @@ namespace AnotherReplayReader.ReplayFile
|
|||||||
|
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return $"[玩家 {PlayerIndex},{RA3Commands.GetCommandName(CommandId)}]";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,7 @@ namespace AnotherReplayReader.ReplayFile
|
|||||||
{ "B", "凶残" },
|
{ "B", "凶残" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public bool IsComputer { get; }
|
||||||
public string PlayerName { get; }
|
public string PlayerName { get; }
|
||||||
public uint PlayerIp { get; }
|
public uint PlayerIp { get; }
|
||||||
public int FactionId { get; }
|
public int FactionId { get; }
|
||||||
@ -19,11 +20,11 @@ namespace AnotherReplayReader.ReplayFile
|
|||||||
|
|
||||||
public Player(string[] playerEntry)
|
public Player(string[] playerEntry)
|
||||||
{
|
{
|
||||||
var isComputer = playerEntry[0][0] == 'C';
|
IsComputer = playerEntry[0][0] == 'C';
|
||||||
|
|
||||||
PlayerName = playerEntry[0].Substring(1);
|
PlayerName = playerEntry[0].Substring(1);
|
||||||
|
|
||||||
if (isComputer)
|
if (IsComputer)
|
||||||
{
|
{
|
||||||
PlayerName = ComputerNames[PlayerName];
|
PlayerName = ComputerNames[PlayerName];
|
||||||
PlayerIp = 0;
|
PlayerIp = 0;
|
||||||
|
288
ReplayFile/RA3Commands.cs
Normal file
288
ReplayFile/RA3Commands.cs
Normal file
@ -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<byte, Action<BinaryReader>> _commandParser;
|
||||||
|
private static readonly Dictionary<byte, string> _commandNames;
|
||||||
|
|
||||||
|
public static ImmutableArray<byte> UnknownCommands { get; } = new byte[]
|
||||||
|
{
|
||||||
|
0x0F, // unk
|
||||||
|
0x5F, // unk
|
||||||
|
0x12, // unk
|
||||||
|
0x1B, // unk
|
||||||
|
0x48, // unk
|
||||||
|
0x52, // unk
|
||||||
|
0xFC, // unk
|
||||||
|
0xFD, // unk
|
||||||
|
}.ToImmutableArray();
|
||||||
|
|
||||||
|
public static ImmutableArray<byte> 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<BinaryReader> 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<BinaryReader> 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<byte, int, Action<BinaryReader>>, 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<BinaryReader>, string)>
|
||||||
|
{
|
||||||
|
(0x01, ParseSpecialChunk0x01, "[游戏自动生成的指令]"),
|
||||||
|
(0x02, ParseSetRallyPoint0x02, "设计集结点"),
|
||||||
|
(0x0C, ParseUngarrison0x0C, "从进驻的建筑撤出(?)"),
|
||||||
|
(0x10, ParseGarrison0x10, "进驻建筑"),
|
||||||
|
(0x33, ParseUuid0x33, "[游戏自动生成的UUID指令]"),
|
||||||
|
(0x4B, ParsePlaceBeacon0x4B, "信标")
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandParser = new Dictionary<byte, Action<BinaryReader>>();
|
||||||
|
_commandNames = new Dictionary<byte, string>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
using AnotherReplayReader.Utils;
|
using AnotherReplayReader.Utils;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@ -23,6 +24,8 @@ namespace AnotherReplayReader.ReplayFile
|
|||||||
{ ReplayType.Lan, "局域网录像" },
|
{ ReplayType.Lan, "局域网录像" },
|
||||||
{ ReplayType.Online, "官网录像" },
|
{ ReplayType.Online, "官网录像" },
|
||||||
};
|
};
|
||||||
|
public const double FrameRate = 15.0;
|
||||||
|
public const string PostCommentator = "post Commentator";
|
||||||
|
|
||||||
private readonly byte _replaySaverIndex;
|
private readonly byte _replaySaverIndex;
|
||||||
private readonly byte[]? _rawHeader;
|
private readonly byte[]? _rawHeader;
|
||||||
@ -31,7 +34,7 @@ namespace AnotherReplayReader.ReplayFile
|
|||||||
public DateTime Date { get; }
|
public DateTime Date { get; }
|
||||||
public string MapName { get; }
|
public string MapName { get; }
|
||||||
public string MapPath { get; }
|
public string MapPath { get; }
|
||||||
public IReadOnlyList<Player> Players { get; }
|
public ImmutableArray<Player> Players { get; }
|
||||||
public int NumberOfPlayingPlayers { get; }
|
public int NumberOfPlayingPlayers { get; }
|
||||||
public Mod Mod { get; }
|
public Mod Mod { get; }
|
||||||
public ReplayType Type { get; }
|
public ReplayType Type { get; }
|
||||||
@ -39,7 +42,7 @@ namespace AnotherReplayReader.ReplayFile
|
|||||||
|
|
||||||
public long Size { get; }
|
public long Size { get; }
|
||||||
public ReplayFooter? Footer { get; }
|
public ReplayFooter? Footer { get; }
|
||||||
public IReadOnlyList<ReplayChunk>? Body { get; }
|
public ImmutableArray<ReplayChunk>? Body { get; }
|
||||||
|
|
||||||
public string FileName => System.IO.Path.GetFileNameWithoutExtension(Path);
|
public string FileName => System.IO.Path.GetFileNameWithoutExtension(Path);
|
||||||
public Player ReplaySaver => Players[_replaySaverIndex];
|
public Player ReplaySaver => Players[_replaySaverIndex];
|
||||||
@ -119,7 +122,7 @@ namespace AnotherReplayReader.ReplayFile
|
|||||||
Players = entries["S"].Split(':')
|
Players = entries["S"].Split(':')
|
||||||
.TakeWhile(x => !string.IsNullOrWhiteSpace(x) && x[0] != 'X')
|
.TakeWhile(x => !string.IsNullOrWhiteSpace(x) && x[0] != 'X')
|
||||||
.Select(x => new Player(x.Split(',')))
|
.Select(x => new Player(x.Split(',')))
|
||||||
.ToList();
|
.ToImmutableArray();
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
@ -219,40 +222,7 @@ namespace AnotherReplayReader.ReplayFile
|
|||||||
}
|
}
|
||||||
|
|
||||||
_rawHeader = rawHeader;
|
_rawHeader = rawHeader;
|
||||||
Body = body;
|
Body = body.ToImmutableArray();
|
||||||
}
|
|
||||||
|
|
||||||
public Dictionary<byte, int[]> GetCommandCounts()
|
|
||||||
{
|
|
||||||
if (Body is null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Replay body must be parsed before retrieving command chunks");
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Replay CloneHeader()
|
public Replay CloneHeader()
|
||||||
@ -283,7 +253,7 @@ namespace AnotherReplayReader.ReplayFile
|
|||||||
string size = GetSizeString(Size);
|
string size = GetSizeString(Size);
|
||||||
string length = Length?.ToString() ?? "录像已损坏,请先修复录像";
|
string length = Length?.ToString() ?? "录像已损坏,请先修复录像";
|
||||||
|
|
||||||
var replaySaver = _replaySaverIndex < Players.Count
|
var replaySaver = _replaySaverIndex < Players.Length
|
||||||
? ReplaySaver.PlayerName
|
? ReplaySaver.PlayerName
|
||||||
: "[无法获取保存录像的玩家]";
|
: "[无法获取保存录像的玩家]";
|
||||||
|
|
||||||
@ -298,7 +268,7 @@ namespace AnotherReplayReader.ReplayFile
|
|||||||
writer.WriteLine("玩家列表:");
|
writer.WriteLine("玩家列表:");
|
||||||
foreach (var player in Players)
|
foreach (var player in Players)
|
||||||
{
|
{
|
||||||
if (player == Players.Last() && player.PlayerName.Equals("post Commentator"))
|
if (player == Players.Last() && player.PlayerName.Equals(PostCommentator))
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using System.IO;
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
namespace AnotherReplayReader.ReplayFile
|
namespace AnotherReplayReader.ReplayFile
|
||||||
{
|
{
|
||||||
@ -9,6 +10,8 @@ namespace AnotherReplayReader.ReplayFile
|
|||||||
public uint TimeCode { get; }
|
public uint TimeCode { get; }
|
||||||
public byte Type { get; }
|
public byte Type { get; }
|
||||||
|
|
||||||
|
public TimeSpan Time => TimeSpan.FromSeconds(TimeCode / Replay.FrameRate);
|
||||||
|
|
||||||
public ReplayChunk(uint timeCode, BinaryReader reader)
|
public ReplayChunk(uint timeCode, BinaryReader reader)
|
||||||
{
|
{
|
||||||
TimeCode = timeCode; // reader.ReadUInt32();
|
TimeCode = timeCode; // reader.ReadUInt32();
|
||||||
|
@ -20,7 +20,7 @@ namespace AnotherReplayReader.ReplayFile
|
|||||||
private readonly byte[] _data;
|
private readonly byte[] _data;
|
||||||
|
|
||||||
public uint FinalTimeCode { get; }
|
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)
|
public ReplayFooter(BinaryReader reader, ReplayFooterOption option)
|
||||||
{
|
{
|
||||||
|
Loading…
x
Reference in New Issue
Block a user