This commit is contained in:
lanyi 2021-10-20 22:22:19 +02:00
parent 78a7310a3b
commit 0863b2fd71
12 changed files with 1206 additions and 508 deletions

View File

@ -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
View 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
View 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
View 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();
}
}
}

View File

@ -1,38 +1,103 @@
<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*" />
</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 可以复制内容 选择表格之后按 Ctrl+C 可以复制内容
</Label> </Label>
<Button x:Name="_setPlayerButton" </DockPanel>
Content="设置玩家信息..."
Margin="0,19,22,0"
VerticalAlignment="Top"
Padding="5,2"
Visibility="Hidden"
Click="OnSetPlayerButtonClick"
HorizontalAlignment="Right"/>
<DataGrid x:Name="_table" <DataGrid x:Name="_table"
Margin="20,40,22,19" CanUserSortColumns="True"
Grid.ColumnSpan="2" VirtualizingPanel.ScrollUnit="Pixel">
CanUserSortColumns="True">
<DataGrid.Resources> <DataGrid.Resources>
<Style TargetType="DataGridCell"> <Style TargetType="DataGridCell">
<EventSetter Event="MouseDoubleClick" <EventSetter Event="MouseDoubleClick"
Handler="OnTableMouseDoubleClick" /> Handler="OnTableMouseDoubleClick" />
</Style> </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.Resources>
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="项" <DataGridTextColumn Header="项"
@ -58,6 +123,6 @@
IsReadOnly="True" /> IsReadOnly="True" />
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
</DockPanel>
</Grid> </Grid>
</Window> </Window>

View File

@ -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,9 +71,62 @@ 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);
}
catch (Exception e)
{
MessageBox.Show($"加载录像信息失败:\r\n{e}");
}
}
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(); _table.Items.Clear();
foreach (var row in dataList) foreach (var row in dataList)
{ {
@ -196,11 +139,55 @@ namespace AnotherReplayReader
_table.Columns[4].Header = dataList[0].Player4Value; _table.Columns[4].Header = dataList[0].Player4Value;
_table.Columns[5].Header = dataList[0].Player5Value; _table.Columns[5].Header = dataList[0].Player5Value;
_table.Columns[6].Header = dataList[0].Player6Value; _table.Columns[6].Header = dataList[0].Player6Value;
} if (isPartial)
catch (Exception e)
{ {
MessageBox.Show($"加载录像信息失败:\r\n{e}"); _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)
@ -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);
}
} }
} }

View File

@ -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)}]";
}
} }
} }

View File

@ -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
View 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();
}
}
}
}

View File

@ -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;
} }

View File

@ -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();

View File

@ -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)
{ {