APM!
This commit is contained in:
parent
78a7310a3b
commit
0863b2fd71
@ -34,6 +34,7 @@
|
||||
<PackageReference Include="ILMerge" Version="3.0.41" />
|
||||
<PackageReference Include="ILMerge.MSBuild.Task" Version="1.0.7" />
|
||||
<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="System.Collections.Immutable" Version="5.0.0" />
|
||||
<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"
|
||||
x:ClassModifier="internal"
|
||||
x:Name="ApmWindowInstance"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:local="clr-namespace:AnotherReplayReader"
|
||||
xmlns:ox="http://oxyplot.org/wpf"
|
||||
mc:Ignorable="d"
|
||||
Title="APM"
|
||||
Height="450"
|
||||
Width="800">
|
||||
<Grid>
|
||||
<Label Margin="20,10,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
Height="30"
|
||||
VerticalAlignment="Top">
|
||||
选择表格之后按 Ctrl+C 可以复制内容
|
||||
</Label>
|
||||
<Button x:Name="_setPlayerButton"
|
||||
Content="设置玩家信息..."
|
||||
Margin="0,19,22,0"
|
||||
VerticalAlignment="Top"
|
||||
Padding="5,2"
|
||||
Visibility="Hidden"
|
||||
Click="OnSetPlayerButtonClick"
|
||||
HorizontalAlignment="Right"/>
|
||||
<DataGrid x:Name="_table"
|
||||
Margin="20,40,22,19"
|
||||
Grid.ColumnSpan="2"
|
||||
CanUserSortColumns="True">
|
||||
<DataGrid.Resources>
|
||||
<Style TargetType="DataGridCell">
|
||||
<EventSetter Event="MouseDoubleClick"
|
||||
Handler="OnTableMouseDoubleClick" />
|
||||
</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>
|
||||
|
||||
Height="550"
|
||||
Width="800"
|
||||
Loaded="OnApmWindowLoaded">
|
||||
<Grid Margin="0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="200*" />
|
||||
<RowDefinition Height="300*" />
|
||||
</Grid.RowDefinitions>
|
||||
<GridSplitter Grid.Row="0"
|
||||
VerticalAlignment="Bottom"
|
||||
HorizontalAlignment="Stretch"
|
||||
Height="6"
|
||||
BorderBrush="Gray"
|
||||
BorderThickness="1" />
|
||||
<DockPanel Grid.Row="0"
|
||||
Margin="0,0,0,12">
|
||||
<StackPanel DockPanel.Dock="Bottom"
|
||||
Margin="16,0"
|
||||
Orientation="Horizontal">
|
||||
<Label Content="分辨率"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBox x:Name="_resolution"
|
||||
Width="42"
|
||||
TextAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
PreviewTextInput="OnResolutionPreviewTextInput"
|
||||
TextChanged="OnResolutionTextChanged" />
|
||||
<Label Content="秒"
|
||||
VerticalAlignment="Center" />
|
||||
<CheckBox x:Name="_skipUnknowns"
|
||||
Margin="24,0,0,0"
|
||||
Content="忽略未知操作"
|
||||
IsChecked="True"
|
||||
VerticalAlignment="Center"
|
||||
Checked="OnSkipUnknownsCheckedChanged"
|
||||
Unchecked="OnSkipUnknownsCheckedChanged" />
|
||||
<CheckBox x:Name="_skipAutos"
|
||||
Margin="24,0,0,0"
|
||||
Content="忽略自动操作"
|
||||
IsChecked="True"
|
||||
VerticalAlignment="Center"
|
||||
Checked="OnSkipAutosCheckedChanged"
|
||||
Unchecked="OnSkipAutosCheckedChanged" />
|
||||
<CheckBox x:Name="_skipClicks"
|
||||
Margin="24,0,0,0"
|
||||
Content="忽略左键点选"
|
||||
IsChecked="False"
|
||||
VerticalAlignment="Center"
|
||||
Checked="OnSkipClicksCheckedChanged"
|
||||
Unchecked="OnSkipClicksCheckedChanged" />
|
||||
</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>
|
||||
</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.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<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>
|
||||
/// APM.xaml 的交互逻辑
|
||||
/// </summary>
|
||||
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<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)
|
||||
{
|
||||
_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<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.Collections.Generic;
|
||||
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;
|
||||
|
||||
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
|
||||
{
|
||||
public byte CommandId { 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)
|
||||
{
|
||||
@ -297,5 +48,10 @@ namespace AnotherReplayReader.ReplayFile
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"[玩家 {PlayerIndex},{RA3Commands.GetCommandName(CommandId)}]";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
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 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<Player> Players { get; }
|
||||
public ImmutableArray<Player> 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<ReplayChunk>? Body { get; }
|
||||
public ImmutableArray<ReplayChunk>? 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<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;
|
||||
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;
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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)
|
||||
{
|
||||
|
Loading…
x
Reference in New Issue
Block a user