Compare commits
18 Commits
Author | SHA1 | Date | |
---|---|---|---|
22cfb1f0a9 | |||
a942768c8d | |||
e16eefa166 | |||
66458e831c | |||
08700abd4f | |||
2a96f8efac | |||
0863b2fd71 | |||
78a7310a3b | |||
23207b9085 | |||
6f5c21aa8f | |||
888ddce4ef | |||
5b907309e0 | |||
c3a41c0d4e | |||
b92d1d90ad | |||
3f5070df77 | |||
6fd0bf0046 | |||
1f5f1c8e6c | |||
79d9bf4cba |
63
APM.xaml
63
APM.xaml
@ -1,63 +0,0 @@
|
||||
<Window x:Class="AnotherReplayReader.APM"
|
||||
x:ClassModifier="internal"
|
||||
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"
|
||||
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="Visible"
|
||||
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>
|
||||
|
||||
</Grid>
|
||||
</Window>
|
230
APM.xaml.cs
230
APM.xaml.cs
@ -1,230 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class DataTableFactory
|
||||
{
|
||||
static public List<DataRow> Get(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 APM : Window
|
||||
{
|
||||
private PlayerIdentity _identity;
|
||||
|
||||
public APM(Replay replay, PlayerIdentity identity)
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_identity = identity;
|
||||
|
||||
if (_identity.IsUsable)
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
_setPlayerButton.IsEnabled = true;
|
||||
_setPlayerButton.Visibility = Visibility.Visible;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
_setPlayerButton.IsEnabled = false;
|
||||
_setPlayerButton.Visibility = Visibility.Hidden;
|
||||
});
|
||||
}
|
||||
|
||||
Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var dataList = DataTableFactory.Get(replay, _identity);
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
_table.Items.Clear();
|
||||
foreach (var row in dataList)
|
||||
{
|
||||
_table.Items.Add(row);
|
||||
}
|
||||
|
||||
_table.Columns[1].Header = dataList[0].Player1Value;
|
||||
_table.Columns[2].Header = dataList[0].Player2Value;
|
||||
_table.Columns[3].Header = dataList[0].Player3Value;
|
||||
_table.Columns[4].Header = dataList[0].Player4Value;
|
||||
_table.Columns[5].Header = dataList[0].Player5Value;
|
||||
_table.Columns[6].Header = dataList[0].Player6Value;
|
||||
});
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
MessageBox.Show($"加载录像信息失败:\r\n{e}");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void OnSetPlayerButtonClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var window1 = new Window1(_identity);
|
||||
window1.ShowDialog();
|
||||
}
|
||||
|
||||
private void OnTableMouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
{
|
||||
_table.SelectAll();
|
||||
}
|
||||
}
|
||||
}
|
91
About.xaml
91
About.xaml
@ -1,4 +1,5 @@
|
||||
<Window x:Class="AnotherReplayReader.About"
|
||||
x:ClassModifier="internal"
|
||||
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"
|
||||
@ -6,28 +7,76 @@
|
||||
xmlns:local="clr-namespace:AnotherReplayReader"
|
||||
mc:Ignorable="d"
|
||||
Title="About"
|
||||
Height="301.893"
|
||||
Width="429">
|
||||
<Grid Margin="0,0,-8,-59">
|
||||
Height="420"
|
||||
Width="450"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Loaded="OnAboutWindowLoaded">
|
||||
<Grid Margin="0,0,0,-50">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="280"/>
|
||||
<RowDefinition Height="50"/>
|
||||
<RowDefinition Height="420" />
|
||||
<RowDefinition />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBox x:Name="_idBox" HorizontalAlignment="Left" Margin="54,23,0,10" TextWrapping="Wrap" Text="TextBox" Width="300" BorderBrush="White" RenderTransformOrigin="0.497,0.438" Grid.Row="1"/>
|
||||
<TextBlock x:Name="textBlock" Margin="50,10,45,32" TextWrapping="Wrap" Grid.RowSpan="2">
|
||||
<Run Text="【自动录像机0.6】"/><LineBreak/>
|
||||
<Run Text="本工具目前额外支持以下mod的读取:AR、日冕、大蜗牛、Ins、FS、WOP、Eisenreich、TNW"/><LineBreak/>
|
||||
<Run Text="当mod增加新的阵营时,会出现未知阵营和阵营错乱现象"/><LineBreak/>
|
||||
<Run Text="有任何问题可以先去找苏醒或节操"/><LineBreak/>
|
||||
<Run Text="解析录像的代码主要来源于louisdx的研究:"/><LineBreak/>
|
||||
<Hyperlink NavigateUri="https://github.com/louisdx/cnc-replayreaders"><Run Text="https://github.com/louisdx/cnc-replayreaders"/></Hyperlink><LineBreak/>
|
||||
<Run Text="解析Big的代码来源于OpenSage:"/><LineBreak/>
|
||||
<Hyperlink NavigateUri="https://github.com/OpenSAGE/OpenSAGE"><Run Text="https://github.com/OpenSAGE/OpenSAGE"/></Hyperlink><LineBreak/>
|
||||
<Run Text="解析Tga的代码来源于Pfim:"/><LineBreak/>
|
||||
<Hyperlink NavigateUri="https://github.com/nickbabcock/Pfim"><Run Text="https://github.com/nickbabcock/Pfim"/></Hyperlink><LineBreak/>
|
||||
<Run Text="RA3吧:"/><LineBreak/>
|
||||
<Hyperlink NavigateUri="https://tieba.baidu.com/f?kw=%BA%EC%BE%AF3"><Run Text="https://tieba.baidu.com/f?kw=%BA%EC%BE%AF3"/></Hyperlink><LineBreak/>
|
||||
<Run Text="ARmod群号:656507961"/></TextBlock>
|
||||
<TextBlock x:Name="textBlock1" HorizontalAlignment="Left" Margin="10,23,0,11" TextWrapping="Wrap" Width="60" Grid.Row="1"><Run Text="ID"/><LineBreak/><Run/></TextBlock>
|
||||
<StackPanel Grid.Row="0"
|
||||
Orientation="Vertical">
|
||||
<StackPanel x:Name="_updatePanel"
|
||||
Orientation="Vertical"
|
||||
Visibility="Collapsed">
|
||||
<TextBlock x:Name="_updateInfo"
|
||||
Margin="24,16">
|
||||
<Run FontWeight="Bold">已经有新版本了呢!</Run>
|
||||
<LineBreak />
|
||||
</TextBlock>
|
||||
<Separator />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Vertical"
|
||||
Margin="24,16">
|
||||
<TextBlock TextWrapping="Wrap">
|
||||
<Run Text="{Binding Source={x:Static local:App.NameWithVersion},
|
||||
Mode=OneWay,
|
||||
StringFormat={}【{0}】}" />
|
||||
<LineBreak />
|
||||
这个工具目前额外支持以下 Mod 的读取:AR、日冕、大蜗牛、Ins、FS、WOP、Eisenreich、TNW<LineBreak />
|
||||
有任何问题可以先去找苏醒或节操问题(<LineBreak />
|
||||
解析录像的代码主要来源于
|
||||
<Hyperlink NavigateUri="https://www.gamereplays.org/community/index.php?showtopic=706067">R Schneider 的研究</Hyperlink>
|
||||
以及 BoolBada 的
|
||||
<Hyperlink NavigateUri="https://github.com/forcecore/KWReplayAutoSaver">KWReplayAutoSaver</Hyperlink>
|
||||
<LineBreak />
|
||||
解析 Big 的代码来源于
|
||||
<Hyperlink NavigateUri="https://github.com/Qibbi">Jana Mohn</Hyperlink>
|
||||
的 TechnologyAssembler<LineBreak />
|
||||
解析 Tga 的代码来源于
|
||||
<Hyperlink NavigateUri="https://github.com/nickbabcock/Pfim">Pfim</Hyperlink><LineBreak />
|
||||
使用了
|
||||
<Hyperlink NavigateUri="https://github.com/2881099/NPinyin">NPinyin</Hyperlink>
|
||||
以支持按照拼音来查询信息<LineBreak />
|
||||
APM 图表是通过
|
||||
<Hyperlink NavigateUri="https://oxyplot.github.io/">OxyPlot</Hyperlink>
|
||||
画出来的<LineBreak />
|
||||
<LineBreak />
|
||||
欢迎来到红警3吧:
|
||||
<Hyperlink NavigateUri="https://tieba.baidu.com/f?kw=%BA%EC%BE%AF3">https://tieba.baidu.com/ra3</Hyperlink><LineBreak />
|
||||
<Run Text="ARMod 群号:161660710" />
|
||||
</TextBlock>
|
||||
<CheckBox x:Name="_checkForUpdates"
|
||||
Margin="0,16,0,0"
|
||||
Content="自动检查更新"
|
||||
Checked="OnCheckForUpdatesCheckedChanged"
|
||||
Unchecked="OnCheckForUpdatesCheckedChanged" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<DockPanel x:Name="_bottom"
|
||||
Grid.Row="1">
|
||||
<Label DockPanel.Dock="Left"
|
||||
VerticalAlignment="Center"
|
||||
Content="ID" />
|
||||
<TextBox x:Name="_idBox"
|
||||
VerticalAlignment="Center"
|
||||
VerticalContentAlignment="Center"
|
||||
TextWrapping="Wrap"
|
||||
Text="TextBox"
|
||||
BorderBrush="White" />
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
|
@ -1,34 +1,68 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AnotherReplayReader.Utils;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Shapes;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
{
|
||||
/// <summary>
|
||||
/// About.xaml 的交互逻辑
|
||||
/// </summary>
|
||||
public partial class About : Window
|
||||
internal partial class About : Window
|
||||
{
|
||||
public About()
|
||||
private readonly Cache _cache;
|
||||
private readonly UpdateCheckerVersionData? _updateData;
|
||||
|
||||
public About(Cache cache)
|
||||
{
|
||||
_cache = cache;
|
||||
InitializeComponent();
|
||||
_idBox.Text = Auth.ID;
|
||||
}
|
||||
|
||||
private void Hyperlink_RequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e)
|
||||
public About(Cache cache, UpdateCheckerVersionData updateData)
|
||||
{
|
||||
_cache = cache;
|
||||
_updateData = updateData;
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private async void OnAboutWindowLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
foreach (var hyperlink in this.FindVisualChildren<Hyperlink>())
|
||||
{
|
||||
hyperlink.RequestNavigate += OnHyperlinkRequestNavigate;
|
||||
}
|
||||
|
||||
await _cache.Initialization;
|
||||
_checkForUpdates.IsChecked = _cache.GetOrDefault(UpdateChecker.CheckForUpdatesKey, false);
|
||||
var data = _updateData
|
||||
?? _cache.GetOrDefault<UpdateCheckerVersionData?>(UpdateChecker.CachedDataKey, null);
|
||||
if (data is { } updateData && updateData.IsNewVersion())
|
||||
{
|
||||
_updatePanel.Visibility = Visibility.Visible;
|
||||
_updateInfo.Inlines.Add(updateData.Description);
|
||||
_updateInfo.Inlines.Add(new LineBreak());
|
||||
updateData.Urls.Select(u =>
|
||||
{
|
||||
var h = new Hyperlink();
|
||||
h.Inlines.Add(u);
|
||||
h.NavigateUri = new(u);
|
||||
h.RequestNavigate += OnHyperlinkRequestNavigate;
|
||||
return h;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHyperlinkRequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e)
|
||||
{
|
||||
Process.Start(e.Uri.ToString());
|
||||
}
|
||||
|
||||
private async void OnCheckForUpdatesCheckedChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_cache.Set(UpdateChecker.CheckForUpdatesKey, _checkForUpdates.IsChecked is true);
|
||||
await _cache.Save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,193 +1,63 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
||||
<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<ProjectGuid>{A54AEAB3-D99C-4E29-8C47-3DFD5B1A0FDE}</ProjectGuid>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<RootNamespace>AnotherReplayReader</RootNamespace>
|
||||
<AssemblyName>AnotherReplayReader</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
<Deterministic>true</Deterministic>
|
||||
<TargetFrameworkProfile />
|
||||
<IsWebBootstrapper>false</IsWebBootstrapper>
|
||||
<PublishUrl>publish\</PublishUrl>
|
||||
<Install>true</Install>
|
||||
<InstallFrom>Disk</InstallFrom>
|
||||
<UpdateEnabled>false</UpdateEnabled>
|
||||
<UpdateMode>Foreground</UpdateMode>
|
||||
<UpdateInterval>7</UpdateInterval>
|
||||
<UpdateIntervalUnits>Days</UpdateIntervalUnits>
|
||||
<UpdatePeriodically>false</UpdatePeriodically>
|
||||
<UpdateRequired>false</UpdateRequired>
|
||||
<MapFileExtensions>true</MapFileExtensions>
|
||||
<ApplicationRevision>1</ApplicationRevision>
|
||||
<ApplicationVersion>0.0.1.%2a</ApplicationVersion>
|
||||
<UseApplicationTrust>false</UseApplicationTrust>
|
||||
<PublishWizardCompleted>true</PublishWizardCompleted>
|
||||
<BootstrapperEnabled>true</BootstrapperEnabled>
|
||||
<TargetFramework>net461</TargetFramework>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
<GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>
|
||||
<PublishUrl>publish\</PublishUrl>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
<UseWPF>true</UseWPF>
|
||||
<IncludePackageReferencesDuringMarkupCompilation>true</IncludePackageReferencesDuringMarkupCompilation>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<PropertyGroup>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<Optimize>false</Optimize>
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
<Prefer32Bit>false</Prefer32Bit>
|
||||
<LangVersion>7.2</LangVersion>
|
||||
<OutputPath>bin\$(Configuration)\</OutputPath>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DefineConstants>TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
<Prefer32Bit>false</Prefer32Bit>
|
||||
<LangVersion>7.2</LangVersion>
|
||||
<DocumentationFile>
|
||||
</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<ManifestCertificateThumbprint>DB7DFD435909EF54AE3424563219E8449DA47901</ManifestCertificateThumbprint>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<ManifestKeyFile>AnotherReplayReader_TemporaryKey.pfx</ManifestKeyFile>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<GenerateManifests>true</GenerateManifests>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<SignManifests>false</SignManifests>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup />
|
||||
<ItemGroup>
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Data" />
|
||||
<Compile Remove="publish\**" />
|
||||
<EmbeddedResource Remove="publish\**" />
|
||||
<None Remove="publish\**" />
|
||||
<Page Remove="publish\**" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Web" />
|
||||
<Reference Include="System.Web.Extensions" />
|
||||
<Reference Include="System.Xaml" />
|
||||
<Reference Include="System.Xml" />
|
||||
<Reference Include="System.Core" />
|
||||
<Reference Include="System.Xml.Linq" />
|
||||
<Reference Include="System.Data.DataSetExtensions" />
|
||||
<Reference Include="WindowsBase" />
|
||||
<Reference Include="PresentationCore" />
|
||||
<Reference Include="PresentationFramework" />
|
||||
<Reference Include="TechnologyAssembler.Core">
|
||||
<HintPath>TechnologyAssembler.Core.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ILMerge">
|
||||
<Version>3.0.29</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="ILMerge.MSBuild.Task">
|
||||
<Version>1.0.7</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="OpenSage.FileFormats.Big">
|
||||
<Version>1.0.0</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Pfim">
|
||||
<Version>0.7.0</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="System.ValueTuple" Version="4.5.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="System.Collections.Immutable" Version="5.0.0" />
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="9.0.0-preview.7.24405.7" />
|
||||
<PackageReference Include="System.Text.Json" Version="5.0.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ApplicationDefinition Include="App.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
<SubType>Designer</SubType>
|
||||
</ApplicationDefinition>
|
||||
<Compile Include="About.xaml.cs">
|
||||
<DependentUpon>About.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="APM.xaml.cs">
|
||||
<DependentUpon>APM.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="Auth.cs" />
|
||||
<Compile Include="Cache.cs" />
|
||||
<Compile Include="BigMinimapCache.cs" />
|
||||
<Compile Include="Debug.xaml.cs">
|
||||
<DependentUpon>Debug.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="MinimapReader.cs" />
|
||||
<Compile Include="ModData.cs" />
|
||||
<Compile Include="PlayerIdentity.cs" />
|
||||
<Compile Include="Replay.cs" />
|
||||
<Compile Include="Window1.xaml.cs">
|
||||
<DependentUpon>Window1.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Page Include="About.xaml">
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
<Page Include="APM.xaml">
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
<Page Include="Debug.xaml">
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
<Page Include="MainWindow.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
<SubType>Designer</SubType>
|
||||
</Page>
|
||||
<Compile Include="App.xaml.cs">
|
||||
<DependentUpon>App.xaml</DependentUpon>
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
<Compile Include="CommandChunk.cs" />
|
||||
<Compile Include="MainWindow.xaml.cs">
|
||||
<DependentUpon>MainWindow.xaml</DependentUpon>
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
<Page Include="Window1.xaml">
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Properties\AssemblyInfo.cs">
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
<Compile Include="Properties\Resources.Designer.cs">
|
||||
<AutoGen>True</AutoGen>
|
||||
<DesignTime>True</DesignTime>
|
||||
<DependentUpon>Resources.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="Properties\Settings.Designer.cs">
|
||||
<Compile Update="Properties\Settings.Designer.cs">
|
||||
<DesignTimeSharedInput>True</DesignTimeSharedInput>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Settings.settings</DependentUpon>
|
||||
<DesignTimeSharedInput>True</DesignTimeSharedInput>
|
||||
</Compile>
|
||||
<EmbeddedResource Include="Properties\Resources.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
<None Include="app.config" />
|
||||
<None Include="Properties\Settings.settings">
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Properties\Settings.settings">
|
||||
<Generator>SettingsSingleFileGenerator</Generator>
|
||||
<LastGenOutput>Settings.Designer.cs</LastGenOutput>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<BootstrapperPackage Include=".NETFramework,Version=v4.6.1">
|
||||
<Visible>False</Visible>
|
||||
<ProductName>Microsoft .NET Framework 4.6.1 %28x86 和 x64%29</ProductName>
|
||||
<Install>true</Install>
|
||||
</BootstrapperPackage>
|
||||
<BootstrapperPackage Include="Microsoft.Net.Framework.3.5.SP1">
|
||||
<Visible>False</Visible>
|
||||
<ProductName>.NET Framework 3.5 SP1</ProductName>
|
||||
<Install>false</Install>
|
||||
</BootstrapperPackage>
|
||||
</ItemGroup>
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
<Target Name="CustomAfterBuild" AfterTargets="Build">
|
||||
<ItemGroup>
|
||||
<_FilesToMove Include="$(OutputPath)*.dll" />
|
||||
</ItemGroup>
|
||||
<Message Text="_FilesToMove: @(_FilesToMove->'%(Filename)%(Extension)')" Importance="high" />
|
||||
<Message Text="DestFiles:
 @(_FilesToMove->'$(OutputPath)$(ProjectName)Data\%(Filename)%(Extension)')" Importance="high" />
|
||||
<Move SourceFiles="@(_FilesToMove)" DestinationFiles="@(_FilesToMove->'$(OutputPath)$(ProjectName)Data\%(Filename)%(Extension)')" />
|
||||
</Target>
|
||||
</Project>
|
@ -1,9 +1,9 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 16
|
||||
VisualStudioVersion = 16.0.30907.101
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.10.34928.147
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AnotherReplayReader", "AnotherReplayReader.csproj", "{A54AEAB3-D99C-4E29-8C47-3DFD5B1A0FDE}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AnotherReplayReader", "AnotherReplayReader.csproj", "{A54AEAB3-D99C-4E29-8C47-3DFD5B1A0FDE}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
|
239
Apm/ApmPlotter.cs
Normal file
239
Apm/ApmPlotter.cs
Normal file
@ -0,0 +1,239 @@
|
||||
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) && playerIndex >= 0)
|
||||
{
|
||||
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) || command.PlayerIndex < 0)
|
||||
{
|
||||
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) || command.PlayerIndex < 0)
|
||||
{
|
||||
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];
|
||||
}
|
||||
if (command.PlayerIndex >= 0 && command.PlayerIndex < 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,
|
||||
TimeSpan begin,
|
||||
TimeSpan end,
|
||||
out int apmRowIndex)
|
||||
{
|
||||
// these commands should appear in this order by default
|
||||
var orderedCommands = new List<byte>();
|
||||
orderedCommands.AddRange(
|
||||
[
|
||||
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,
|
||||
]);
|
||||
orderedCommands.AddRange(RA3Commands.AutoCommands);
|
||||
orderedCommands.AddRange(RA3Commands.UnknownCommands);
|
||||
|
||||
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)));
|
||||
}
|
||||
apmRowIndex = dataList.Count;
|
||||
// kill-death ratio
|
||||
if (plotter.Replay.Footer?.TryGetKillDeathRatios() is { } kdRatios)
|
||||
{
|
||||
var texts = kdRatios
|
||||
.Take(plotter.Players.Length)
|
||||
.Select(x => $"{x:0.##}");
|
||||
dataList.Add(new("击杀阵亡比(存疑)", texts));
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
122
ApmWindow.xaml
Normal file
122
ApmWindow.xaml
Normal file
@ -0,0 +1,122 @@
|
||||
<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="550"
|
||||
Width="800"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
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">
|
||||
<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>
|
387
ApmWindow.xaml.cs
Normal file
387
ApmWindow.xaml.cs
Normal file
@ -0,0 +1,387 @@
|
||||
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.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
{
|
||||
/// <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 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)
|
||||
{
|
||||
_replay = replay;
|
||||
_plotter = Task.Run(() => new ApmPlotter(replay));
|
||||
_plotController = new(this);
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
await FilterDataAsync(TimeSpan.MinValue, TimeSpan.MaxValue, true);
|
||||
_label.Content = "选择表格之后按 Ctrl+C 可以复制内容";
|
||||
}
|
||||
|
||||
private async Task FilterDataAsync(TimeSpan begin, TimeSpan end, bool updatePlot)
|
||||
{
|
||||
try
|
||||
{
|
||||
await FilterDataAsyncThrowable(begin, end, updatePlot);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
MessageBox.Show(this, $"加载录像信息失败:\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, 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:0.##}";
|
||||
}
|
||||
list.Insert(apmIndex, new(DataRow.PartialApmRow,
|
||||
instantApms.Select(PartialApmToString)));
|
||||
}
|
||||
list.Insert(apmIndex, new(DataRow.AverageApmRow,
|
||||
avg.Select(v => $"{v:0.##}")));
|
||||
|
||||
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 OnTableMouseDoubleClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
_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);
|
||||
}
|
||||
}
|
||||
}
|
3
App.xaml
3
App.xaml
@ -2,7 +2,8 @@
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:AnotherReplayReader"
|
||||
StartupUri="MainWindow.xaml">
|
||||
StartupUri="MainWindow.xaml"
|
||||
ShutdownMode="OnMainWindowClose">
|
||||
<Application.Resources>
|
||||
|
||||
</Application.Resources>
|
||||
|
67
App.xaml.cs
67
App.xaml.cs
@ -1,8 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Configuration;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Windows;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
@ -12,5 +12,64 @@ namespace AnotherReplayReader
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
private int _isInException = 0;
|
||||
|
||||
public const string Version = "0.7";
|
||||
public const string Name = "自动录像机";
|
||||
public const string NameWithVersion = Name + " v" + Version;
|
||||
|
||||
private static readonly Lazy<string> _libsFolder = new(GetLibraryFolder, LazyThreadSafetyMode.PublicationOnly);
|
||||
public static string LibsFolder => _libsFolder.Value;
|
||||
|
||||
public App()
|
||||
{
|
||||
static Assembly? LoadFromLibsFolder(object sender, ResolveEventArgs args)
|
||||
{
|
||||
var assemblyPath = Path.Combine(LibsFolder, new AssemblyName(args.Name).Name + ".dll");
|
||||
return File.Exists(assemblyPath)
|
||||
? Assembly.LoadFrom(assemblyPath)
|
||||
: null;
|
||||
}
|
||||
AppDomain.CurrentDomain.AssemblyResolve += new ResolveEventHandler(LoadFromLibsFolder);
|
||||
|
||||
AppDomain.CurrentDomain.UnhandledException += (sender, eventArgs) =>
|
||||
{
|
||||
if (Interlocked.Increment(ref _isInException) > 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
const string message = "哎呀呀,出现了一些无法处理的问题,只能退出了。要不要尝试保存一下日志文件呢?";
|
||||
var choice = MessageBox.Show($"{message}\r\n{eventArgs.ExceptionObject}", Name, MessageBoxButton.YesNo);
|
||||
if (choice == MessageBoxResult.Yes)
|
||||
{
|
||||
Debug.Instance.RequestSave();
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetLibraryFolder() => Path.Combine(GetExecutableFolder(), nameof(AnotherReplayReader) + "Data");
|
||||
|
||||
private static string GetExecutableFolder()
|
||||
{
|
||||
char[]? buffer = null;
|
||||
uint result;
|
||||
do
|
||||
{
|
||||
buffer = new char[(buffer?.Length ?? 128) * 2];
|
||||
result = GetModuleFileNameW(IntPtr.Zero, buffer, buffer.Length);
|
||||
if (result is 0)
|
||||
{
|
||||
throw new Exception("Failed to retrieve executable name");
|
||||
}
|
||||
}
|
||||
while (result >= buffer.Length);
|
||||
return Path.GetDirectoryName(new(buffer, 0, Array.IndexOf(buffer, '\0')));
|
||||
}
|
||||
|
||||
[DllImport("Kernel32.dll", CallingConvention = CallingConvention.Winapi, CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
|
||||
private static extern uint GetModuleFileNameW(IntPtr module, char[] fileName, int size);
|
||||
}
|
||||
}
|
||||
|
93
Auth.cs
93
Auth.cs
@ -1,93 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
{
|
||||
internal static class Auth
|
||||
{
|
||||
public static string ID { get; private set; }
|
||||
|
||||
static Auth()
|
||||
{
|
||||
ID = null;
|
||||
|
||||
var windowsID = null as string;
|
||||
try
|
||||
{
|
||||
using (var view64 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
|
||||
using (var winNt = view64?.OpenSubKey(@"Software\Microsoft\Windows NT\CurrentVersion", false))
|
||||
{
|
||||
windowsID = winNt?.GetValue("ProductId") as string;
|
||||
}
|
||||
}
|
||||
catch(Exception)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var randomKey = null as string;
|
||||
try
|
||||
{
|
||||
var folderPath = Cache.CacheDirectory;
|
||||
if (!Directory.Exists(folderPath))
|
||||
{
|
||||
Directory.CreateDirectory(folderPath);
|
||||
}
|
||||
|
||||
var keyPath = Path.Combine(folderPath, "id");
|
||||
if(!File.Exists(keyPath))
|
||||
{
|
||||
File.WriteAllText(keyPath, Guid.NewGuid().ToString());
|
||||
}
|
||||
|
||||
randomKey = File.ReadAllText(keyPath);
|
||||
}
|
||||
catch(Exception)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if(string.IsNullOrWhiteSpace(windowsID) || string.IsNullOrWhiteSpace(randomKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(windowsID + randomKey));
|
||||
ID = string.Concat(hash.Skip(3).Take(10).Select(x => $"{x:X2}"));
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetKey()
|
||||
{
|
||||
if(ID == null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var text = $"{ID}{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
|
||||
var bytes = Encoding.UTF8.GetBytes(text);
|
||||
var pre = Encoding.UTF8.GetBytes("playertable!");
|
||||
var salt = new byte[9];
|
||||
using (var rng = new RNGCryptoServiceProvider())
|
||||
{
|
||||
rng.GetNonZeroBytes(salt);
|
||||
}
|
||||
|
||||
for(var i = 0; i < bytes.Length; ++i)
|
||||
{
|
||||
bytes[i] = (byte)(bytes[i] ^ salt[i % salt.Length]);
|
||||
}
|
||||
|
||||
return Convert.ToBase64String(salt.Concat(bytes).Select((x, i) => (byte)(x ^ pre[i % pre.Length])).ToArray());
|
||||
}
|
||||
}
|
||||
}
|
@ -1,172 +1,36 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AnotherReplayReader.Utils;
|
||||
using Microsoft.Win32;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Win32;
|
||||
using OpenSage.FileFormats.Big;
|
||||
using TechnologyAssembler.Core.IO;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
{
|
||||
internal sealed class BigMinimapCache
|
||||
{
|
||||
private sealed class CacheAdapter
|
||||
{
|
||||
public List<string> Bigs { get; set; }
|
||||
public Dictionary<string, string> MapsToBigs { get; set; }
|
||||
private readonly object _lock = new();
|
||||
private SkuDefFileSystemProvider? _skudefFileSystem = null;
|
||||
|
||||
public CacheAdapter()
|
||||
{
|
||||
Bigs = new List<string>();
|
||||
MapsToBigs = new Dictionary<string, string>();
|
||||
}
|
||||
public BigMinimapCache(string? ra3Directory)
|
||||
{
|
||||
Task.Run(() => Initialize(ra3Directory));
|
||||
}
|
||||
|
||||
//private Cache _cache;
|
||||
private volatile IReadOnlyDictionary<string, string> _mapsToBigs = null;
|
||||
|
||||
public BigMinimapCache(Cache cache, string ra3Directory)
|
||||
public bool TryGetEntry(string path, out Stream? bigEntry)
|
||||
{
|
||||
//_cache = cache;
|
||||
bigEntry = null;
|
||||
|
||||
Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(ra3Directory))
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Will not initialize BigMinimapCache because RA3Directory {ra3Directory} does not exist.\r\n";
|
||||
return;
|
||||
}
|
||||
|
||||
var bigSet = ParseSkudefs(Directory.EnumerateFiles(ra3Directory, "*.SkuDef"));
|
||||
|
||||
//var cached = _cache.GetOrDefault("bigsCache", new CacheAdapter());
|
||||
var mapsToBigs = new Dictionary<string, string>();
|
||||
|
||||
foreach (var bigPath in bigSet/*.Where(x => !cached.Bigs.Contains(x))*/)
|
||||
{
|
||||
if (!File.Exists(bigPath))
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Big {bigPath} does not exist.\r\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
Debug.Instance.DebugMessage += $"Trying to add Big {bigPath} to big minimap cache...\r\n";
|
||||
try
|
||||
{
|
||||
using (var big = new BigArchive(bigPath))
|
||||
{
|
||||
foreach (var entry in big.Entries)
|
||||
{
|
||||
if (entry.FullName.EndsWith("_art.tga", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mapsToBigs[entry.FullName] = bigPath;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Exception when reading big:\r\n {exception}\r\n";
|
||||
}
|
||||
}
|
||||
//cached.Bigs = bigSet.ToList();
|
||||
|
||||
//_cache.Set("bigsCache", cached);
|
||||
_mapsToBigs = mapsToBigs; //cached.MapsToBigs;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Exception during initialization of BigMinimapCache: \r\n{exception}\r\n";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static HashSet<string> ParseSkudefs(IEnumerable<string> skudefs)
|
||||
{
|
||||
var skudefSet = new HashSet<string>(skudefs.Select(x => x.ToLowerInvariant()));
|
||||
var unreadSkudefs = new HashSet<string>();
|
||||
var bigSet = new HashSet<string>();
|
||||
|
||||
void ReadSkudefLine(string baseDirectory, string line, string expectedCommand, Action<string> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
char[] separators = { ' ', '\t' };
|
||||
line = line.ToLowerInvariant();
|
||||
var splitted = line.Split(separators, 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (splitted[0].Equals(expectedCommand))
|
||||
{
|
||||
var path = splitted[1];
|
||||
if (!Path.IsPathRooted(path))
|
||||
{
|
||||
path = Path.Combine(baseDirectory, path);
|
||||
}
|
||||
action(path);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Exception when parsing skudef line:\r\n {exception}\r\n";
|
||||
}
|
||||
}
|
||||
|
||||
void ReadSkudef(string fileName, Action<string, string> onBaseDirectoryAndLine)
|
||||
{
|
||||
try
|
||||
{
|
||||
var baseDirectory = Path.GetDirectoryName(fileName).ToLowerInvariant();
|
||||
foreach (var line in File.ReadAllLines(fileName))
|
||||
{
|
||||
onBaseDirectoryAndLine(baseDirectory, line);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Exception when parsing skudef file:\r\n {exception}\r\n";
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var skudef in skudefSet)
|
||||
{
|
||||
ReadSkudef(skudef, (baseDirectory, line) =>
|
||||
{
|
||||
ReadSkudefLine(baseDirectory, line, "add-config", x =>
|
||||
{
|
||||
if (!skudefSet.Contains(x))
|
||||
{
|
||||
unreadSkudefs.Add(x);
|
||||
}
|
||||
});
|
||||
|
||||
ReadSkudefLine(baseDirectory, line, "add-big", x => bigSet.Add(x));
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var skudef in unreadSkudefs)
|
||||
{
|
||||
ReadSkudef(skudef, (baseDirectory, line) =>
|
||||
{
|
||||
ReadSkudefLine(baseDirectory, line, "add-big", x => bigSet.Add(x));
|
||||
});
|
||||
}
|
||||
|
||||
return bigSet;
|
||||
}
|
||||
|
||||
public bool TryGetBigByEntryPath(string path, out BigArchive big)
|
||||
{
|
||||
big = null;
|
||||
|
||||
if (_mapsToBigs == null)
|
||||
using var locker = new Lock(_lock);
|
||||
if (_skudefFileSystem is not { } fs)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_mapsToBigs.ContainsKey(path))
|
||||
if (!fs.FileExists(path))
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Cannot find big entry [{path}].\r\n";
|
||||
return false;
|
||||
@ -174,58 +38,83 @@ namespace AnotherReplayReader
|
||||
|
||||
try
|
||||
{
|
||||
var bigPath = _mapsToBigs[path];
|
||||
big = new BigArchive(bigPath);
|
||||
if(big.GetEntry(path) == null)
|
||||
{
|
||||
//_cache.Remove("bigsCache");
|
||||
big.Dispose();
|
||||
big = null;
|
||||
return false;
|
||||
}
|
||||
bigEntry = fs.OpenStream(path, VirtualFileModeType.Open);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Exception during query (entryStream) of BigMinimapCache: \r\n{exception}\r\n";
|
||||
big = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public byte[] TryReadBytesFromBig(string path)
|
||||
private void Initialize(string? ra3Directory)
|
||||
{
|
||||
if(_mapsToBigs == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if(!_mapsToBigs.ContainsKey(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bigPath = _mapsToBigs[path];
|
||||
using (var big = new BigArchive(path))
|
||||
if (ra3Directory is null || !Directory.Exists(ra3Directory))
|
||||
{
|
||||
var entry = big.GetEntry(path);
|
||||
using (var stream = entry.Open())
|
||||
using (var reader = new BinaryReader(stream))
|
||||
Debug.Instance.DebugMessage += $"Will not initialize BigMinimapCache because RA3Directory {ra3Directory} does not exist.\r\n";
|
||||
return;
|
||||
}
|
||||
|
||||
var currentLanguage = RegistryUtils.RetrieveInRa3(RegistryHive.CurrentUser, "Language");
|
||||
var currentLanguage_ = $"{currentLanguage}_";
|
||||
double SkudefVersionSelector(string fullPath)
|
||||
{
|
||||
var value = -1.0;
|
||||
try
|
||||
{
|
||||
return reader.ReadBytes((int)entry.Length);
|
||||
const string skudefPrefix = "RA3";
|
||||
const int prefix = 1;
|
||||
const int language = 2;
|
||||
const int majorVersion = 3;
|
||||
const int minorVersion = 4;
|
||||
const int majorVersionMultiplier = 10000;
|
||||
const int correctLanguageBonus = 1000_0000;
|
||||
|
||||
value = 0;
|
||||
var stem = Path.GetFileNameWithoutExtension(fullPath);
|
||||
var match = Regex.Match(stem, @"([^_]*)_([^0-9]*)([0-9]*)\.([0-9]*)");
|
||||
if (!match.Success || match.Groups.Cast<Group>().Any(g => !g.Success))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
value += match.Groups[prefix].Value == skudefPrefix ? 0.1 : 0;
|
||||
value += match.Groups[language].Value == currentLanguage_ ? correctLanguageBonus : 0;
|
||||
value += int.Parse(match.Groups[majorVersion].Value) * majorVersionMultiplier;
|
||||
value += int.Parse(match.Groups[minorVersion].Value);
|
||||
return value;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Failed to retrieve skudef: {e}\r\n";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
var highestSkudef = (from p in Directory.EnumerateFiles(ra3Directory, "*.SkuDef")
|
||||
orderby SkudefVersionSelector(p) descending
|
||||
select p).First();
|
||||
Debug.Instance.DebugMessage += $"Retrieved highest skudef: {highestSkudef}\r\n";
|
||||
|
||||
using var locker = new Lock(_lock);
|
||||
DronePlatform.BuildTechnologyAssembler();
|
||||
_skudefFileSystem = new SkuDefFileSystemProvider("config", highestSkudef);
|
||||
}
|
||||
catch (ReflectionTypeLoadException e)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Exception during initialization of BigMinimapCache: \r\n{e}\r\nLoader Exceptions: {e.LoaderExceptions.Length}";
|
||||
foreach (var e2 in e.LoaderExceptions)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Exception during initialization of BigMinimapCache: \r\n{e2}\r\n";
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Exception during query (bytes) of BigMinimapCache: \r\n{exception}\r\n";
|
||||
//_cache.Remove("bigsCache");
|
||||
Debug.Instance.DebugMessage += $"Exception during initialization of BigMinimapCache: \r\n{exception}\r\n";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
95
Cache.cs
95
Cache.cs
@ -3,59 +3,99 @@ using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Web.Script.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using static System.Text.Json.JsonSerializer;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
{
|
||||
internal sealed class Cache
|
||||
public sealed class Cache
|
||||
{
|
||||
public static string CacheDirectory => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "RA3Bar.Lanyi.AnotherReplayReader");
|
||||
public static string OldCacheDirectory => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "RA3Bar.Lanyi.AnotherReplayReader");
|
||||
public static string CacheDirectory => App.LibsFolder;
|
||||
public static string CacheFilePath => Path.Combine(CacheDirectory, "AnotherReplayReader.cache");
|
||||
|
||||
private ConcurrentDictionary<string, string> _storage;
|
||||
private readonly ConcurrentDictionary<string, string> _storage = new();
|
||||
|
||||
public Task Initialization { get; }
|
||||
|
||||
public Cache()
|
||||
{
|
||||
try
|
||||
Initialization = Task.Run(async () =>
|
||||
{
|
||||
if (!Directory.Exists(CacheDirectory))
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(CacheDirectory);
|
||||
}
|
||||
if (!Directory.Exists(CacheDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(CacheDirectory);
|
||||
}
|
||||
|
||||
var serializer = new JavaScriptSerializer();
|
||||
_storage = serializer.Deserialize<ConcurrentDictionary<string, string>>(File.ReadAllText(CacheFilePath));
|
||||
}
|
||||
catch
|
||||
{
|
||||
_storage = new ConcurrentDictionary<string, string>();
|
||||
}
|
||||
try
|
||||
{
|
||||
var old = new DirectoryInfo(OldCacheDirectory);
|
||||
if (old.Exists)
|
||||
{
|
||||
foreach (var file in old.EnumerateFiles())
|
||||
{
|
||||
try
|
||||
{
|
||||
file.MoveTo(Path.Combine(CacheDirectory, file.Name));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
try
|
||||
{
|
||||
old.Delete();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
using var cacheStream = File.OpenRead(CacheFilePath);
|
||||
var futureCache = DeserializeAsync<Dictionary<string, string>>(cacheStream).ConfigureAwait(false);
|
||||
if (await futureCache is not { } cached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (var kv in cached)
|
||||
{
|
||||
_storage.TryAdd(kv.Key, kv.Value);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
}
|
||||
|
||||
public T GetOrDefault<T>(string key, in T defaultValue)
|
||||
public T GetOrDefault<T>(string key, T defaultValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_storage.TryGetValue(key, out var valueString))
|
||||
{
|
||||
var serializer = new JavaScriptSerializer();
|
||||
return serializer.Deserialize<T>(valueString);
|
||||
return Deserialize<T>(valueString) ?? defaultValue;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public void Set<T>(string key, in T value)
|
||||
public void Set<T>(string key, T value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var serializer = new JavaScriptSerializer();
|
||||
_storage[key] = serializer.Serialize(value);
|
||||
Save();
|
||||
_storage[key] = Serialize(value);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public void SetValues(params (string Key, object Value)[] values)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var (key, value) in values)
|
||||
{
|
||||
_storage[key] = Serialize(value);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
@ -63,15 +103,14 @@ namespace AnotherReplayReader
|
||||
public void Remove(string key)
|
||||
{
|
||||
_storage.TryRemove(key, out _);
|
||||
Save();
|
||||
}
|
||||
|
||||
public void Save()
|
||||
public async Task Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
var serializer = new JavaScriptSerializer();
|
||||
File.WriteAllText(CacheFilePath, serializer.Serialize(_storage));
|
||||
using var cacheStream = File.Open(CacheFilePath, FileMode.Create, FileAccess.Write);
|
||||
await SerializeAsync(cacheStream, _storage).ConfigureAwait(false);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
179
ChineseEncoding.cs
Normal file
179
ChineseEncoding.cs
Normal file
@ -0,0 +1,179 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
|
||||
namespace Ra3.BattleNet.Database.Utils;
|
||||
|
||||
public static class ChineseEncoding
|
||||
{
|
||||
private const int EncodedChineseTotalLength = 120;
|
||||
private const int EncodedChineseChecksumLength = 3;
|
||||
private const int EncodedChineseContentLength = EncodedChineseTotalLength - EncodedChineseChecksumLength;
|
||||
private const int ChineseCodeSize = 13;
|
||||
private const int ChineseCodeMask = 0b1111111111111;
|
||||
private const int AsciiSectionSize = 0x80;
|
||||
private const int Gb2312EucOffset = 0xA0;
|
||||
private const int Gb2312RowWidth = 94;
|
||||
private const int Gb2312LastRow = 87;
|
||||
private const int Gb2312FirstUnassignedSectionBegin = 10;
|
||||
private const int Gb2312FirstUnassignedSectionSize = 6;
|
||||
private const int Gb2312SecondUnassignedSectionBegin = 88;
|
||||
private const int Base64CodeSize = 6;
|
||||
private const int Base64CodeMask = 0b111111;
|
||||
private const string Base64Table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
|
||||
public static string DecodeChineseFromBase64(string original)
|
||||
{
|
||||
BigInteger bits = 0;
|
||||
int index = 0;
|
||||
foreach (var c in original)
|
||||
{
|
||||
BigInteger value = Base64Table.IndexOf(c);
|
||||
if (value == -1)
|
||||
{
|
||||
throw new Exception("Invalid base64 string");
|
||||
}
|
||||
bits = bits | (value << index);
|
||||
index += Base64CodeSize;
|
||||
}
|
||||
BigInteger checksum = 0;
|
||||
var result = new List<byte>();
|
||||
for (var bitIndex = 0; bitIndex < EncodedChineseContentLength; bitIndex += ChineseCodeSize)
|
||||
{
|
||||
int value = (int)((bits >> bitIndex) & ChineseCodeMask);
|
||||
checksum += value;
|
||||
if (value == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (value < AsciiSectionSize)
|
||||
{
|
||||
if (value < 0x20 || value == 0x7F)
|
||||
{
|
||||
throw new Exception("Invalid ASCII");
|
||||
}
|
||||
result.Add((byte)value);
|
||||
}
|
||||
else
|
||||
{
|
||||
value -= AsciiSectionSize;
|
||||
var index1 = value / Gb2312RowWidth + 1;
|
||||
var index2 = value % Gb2312RowWidth + 1;
|
||||
if (index1 >= Gb2312FirstUnassignedSectionBegin)
|
||||
{
|
||||
index1 += Gb2312FirstUnassignedSectionSize;
|
||||
}
|
||||
if (index1 >= Gb2312SecondUnassignedSectionBegin)
|
||||
{
|
||||
throw new Exception("Invalid first byte");
|
||||
}
|
||||
if (index2 > Gb2312RowWidth)
|
||||
{
|
||||
throw new Exception("Invalid second byte");
|
||||
}
|
||||
index1 += Gb2312EucOffset;
|
||||
index2 += Gb2312EucOffset;
|
||||
result.Add((byte)index1);
|
||||
result.Add((byte)index2);
|
||||
}
|
||||
}
|
||||
if ((bits >> EncodedChineseContentLength) != checksum % 8)
|
||||
{
|
||||
throw new Exception("Invalid checksum");
|
||||
}
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
return Encoding.GetEncoding(936).GetString(result.ToArray());
|
||||
}
|
||||
|
||||
public static string EncodeChineseToBase64(string original)
|
||||
{
|
||||
BigInteger bits = 0;
|
||||
int index = 0;
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
var bytes = Encoding.GetEncoding(936).GetBytes(original);
|
||||
BigInteger checksum = 0;
|
||||
for (var i = 0; i < bytes.Length;)
|
||||
{
|
||||
BigInteger c = bytes[i];
|
||||
if (c < AsciiSectionSize)
|
||||
{
|
||||
if (c < 0x20 || c == 0x7F)
|
||||
{
|
||||
throw new Exception("Invalid ASCII");
|
||||
}
|
||||
bits = bits | (c << index);
|
||||
index += ChineseCodeSize;
|
||||
checksum += c;
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (c <= Gb2312EucOffset || c > (Gb2312EucOffset + Gb2312LastRow))
|
||||
{
|
||||
throw new Exception("Invalid first byte");
|
||||
}
|
||||
BigInteger c2 = bytes[i + 1];
|
||||
if (c2 <= Gb2312EucOffset || c2 > (Gb2312EucOffset + Gb2312RowWidth))
|
||||
{
|
||||
throw new Exception("Invalid second byte");
|
||||
}
|
||||
var index1 = c - Gb2312EucOffset;
|
||||
if (index1 >= Gb2312FirstUnassignedSectionBegin)
|
||||
{
|
||||
var offset = index1 - Gb2312FirstUnassignedSectionBegin;
|
||||
if (offset < Gb2312FirstUnassignedSectionSize)
|
||||
{
|
||||
throw new Exception("AA-AF user defined zone not supported");
|
||||
}
|
||||
index1 -= Gb2312FirstUnassignedSectionSize;
|
||||
}
|
||||
var index2 = c2 - Gb2312EucOffset;
|
||||
if (index2 > Gb2312RowWidth)
|
||||
{
|
||||
throw new Exception("Invalid second byte");
|
||||
}
|
||||
var value = (index1 - 1) * Gb2312RowWidth + (index2 - 1);
|
||||
value = AsciiSectionSize + value;
|
||||
bits = bits | (value << index);
|
||||
index += ChineseCodeSize;
|
||||
checksum += value;
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
bits = bits | ((checksum % 8) << EncodedChineseContentLength);
|
||||
var result = "";
|
||||
var bitsIndex = 0;
|
||||
while (bitsIndex < EncodedChineseTotalLength)
|
||||
{
|
||||
int v = (int)((bits >> bitsIndex) & Base64CodeMask);
|
||||
result += Base64Table[v];
|
||||
bitsIndex += Base64CodeSize;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static string GetPrettyName(string name)
|
||||
{
|
||||
if (name.Length != 20)
|
||||
{
|
||||
return name;
|
||||
}
|
||||
foreach (var c in name)
|
||||
{
|
||||
if (!Base64Table.Contains(c))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
return DecodeChineseFromBase64(name);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
303
CommandChunk.cs
303
CommandChunk.cs
@ -1,303 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
{
|
||||
internal static class RA3Commands
|
||||
{
|
||||
//public static IReadOnlyDictionary<byte, Func<BinaryReader, string>> CommandParser { get; private set; }
|
||||
|
||||
public static IReadOnlyDictionary<byte, Action<BinaryReader>> CommandParser => _commandParser;
|
||||
public static IReadOnlyDictionary<byte, string> CommandNames => _commandNames;
|
||||
|
||||
private static Dictionary<byte, Action<BinaryReader>> _commandParser;
|
||||
private static 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 UnknownCommandParser(BinaryReader current, byte commandID)
|
||||
{
|
||||
while(true)
|
||||
{
|
||||
var value = current.ReadByte();
|
||||
if (value == 0xFF)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
//return $"(未知指令 0x{commandID:2X})";
|
||||
}
|
||||
|
||||
public static string GetCommandName(byte commandID)
|
||||
{
|
||||
return CommandNames.TryGetValue(commandID, out var storedName) ? storedName : $"(未知指令 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)
|
||||
{
|
||||
if (chunk.Type != 1)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
int ManglePlayerID(byte ID)
|
||||
{
|
||||
return ID / 8 - 2;
|
||||
}
|
||||
|
||||
using (var stream = new MemoryStream(chunk.Data))
|
||||
using (var reader = new BinaryReader(stream))
|
||||
{
|
||||
if(reader.ReadByte() != 1)
|
||||
{
|
||||
throw new InvalidDataException("Payload first byte not 1");
|
||||
}
|
||||
|
||||
var list = new List<CommandChunk>();
|
||||
var numberOfCommands = reader.ReadInt32();
|
||||
for(var i = 0; i < numberOfCommands; ++i)
|
||||
{
|
||||
var commandID = reader.ReadByte();
|
||||
var playerID = reader.ReadByte();
|
||||
if (RA3Commands.CommandParser.TryGetValue(commandID, out var parser))
|
||||
{
|
||||
RA3Commands.CommandParser[commandID](reader);
|
||||
}
|
||||
else
|
||||
{
|
||||
RA3Commands.UnknownCommandParser(reader, commandID);
|
||||
}
|
||||
|
||||
list.Add(new CommandChunk { CommandID = commandID, PlayerIndex = ManglePlayerID(playerID) });
|
||||
}
|
||||
|
||||
if(reader.BaseStream.Position != reader.BaseStream.Length)
|
||||
{
|
||||
throw new InvalidDataException("Payload not fully parsed");
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
20
Debug.xaml
20
Debug.xaml
@ -6,8 +6,20 @@
|
||||
xmlns:local="clr-namespace:AnotherReplayReader"
|
||||
mc:Ignorable="d"
|
||||
Title="Debug" Height="450" Width="800">
|
||||
<Grid>
|
||||
<TextBox x:Name="_textBox" Margin="10,34,10,10" TextWrapping="Wrap" Text="{Binding Path=DebugMessage, Mode=TwoWay}" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
||||
<Button x:Name="_export" Content="导出日志" HorizontalAlignment="Left" Margin="10,10,0,0" VerticalAlignment="Top" Width="75" Click="OnExport_Click"/>
|
||||
</Grid>
|
||||
<DockPanel Margin="10,10,10,10">
|
||||
<StackPanel DockPanel.Dock="Top"
|
||||
Orientation="Horizontal"
|
||||
Margin="0,0,0,10">
|
||||
<Button Padding="8,2"
|
||||
Margin="0,0,10,0"
|
||||
Content="导出日志"
|
||||
Click="OnExportButtonClick" />
|
||||
<Button Padding="8,2"
|
||||
Content="清空日志"
|
||||
Click="OnClearButtonClick" />
|
||||
</StackPanel>
|
||||
<TextBox x:Name="_textBox"
|
||||
TextWrapping="Wrap"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Auto" />
|
||||
</DockPanel>
|
||||
</Window>
|
||||
|
127
Debug.xaml.cs
127
Debug.xaml.cs
@ -1,68 +1,119 @@
|
||||
using System;
|
||||
using AnotherReplayReader.Utils;
|
||||
using Microsoft.Win32;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Shapes;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
{
|
||||
public sealed class DebugMessageWrapper : INotifyPropertyChanged
|
||||
public sealed class DebugMessageWrapper
|
||||
{
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
public string DebugMessage
|
||||
public readonly struct Proxy
|
||||
{
|
||||
get => _debugMessage;
|
||||
set
|
||||
public readonly string Payload;
|
||||
public Proxy(string text)
|
||||
{
|
||||
_debugMessage = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("DebugMessage"));
|
||||
Payload = text;
|
||||
}
|
||||
public static Proxy operator +(Proxy p, string text)
|
||||
{
|
||||
return string.IsNullOrEmpty(p.Payload) ? new Proxy(text) : new Proxy(p.Payload + text);
|
||||
}
|
||||
}
|
||||
|
||||
private string _debugMessage;
|
||||
private readonly object _lock = new();
|
||||
private readonly List<string> _list = new();
|
||||
|
||||
public event Action<string>? NewText;
|
||||
public Proxy DebugMessage
|
||||
{
|
||||
get => new();
|
||||
set
|
||||
{
|
||||
var text = value.Payload;
|
||||
using var locker = new Lock(_lock);
|
||||
if (NewText is null || _list.Count > 0)
|
||||
{
|
||||
_list.Add(text);
|
||||
if (NewText is not null)
|
||||
{
|
||||
text = string.Join(string.Empty, _list);
|
||||
_list.Clear();
|
||||
_list.Capacity = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
NewText(text);
|
||||
}
|
||||
}
|
||||
|
||||
public event Action? RequestedSave;
|
||||
public void RequestSave() => RequestedSave?.Invoke();
|
||||
}
|
||||
/// <summary>
|
||||
/// Debug.xaml 的交互逻辑
|
||||
/// </summary>
|
||||
public sealed partial class Debug : Window
|
||||
{
|
||||
public static readonly DebugMessageWrapper Instance = new DebugMessageWrapper();
|
||||
public static readonly DebugMessageWrapper Instance = new();
|
||||
private static readonly object _lock = new();
|
||||
private static Debug? _window = null;
|
||||
|
||||
public Debug()
|
||||
public static void Initialize()
|
||||
{
|
||||
DataContext = Instance;
|
||||
InitializeComponent();
|
||||
using var locker = new Lock(_lock);
|
||||
if (_window is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var window = new Debug();
|
||||
window.InitializeComponent();
|
||||
_window = window;
|
||||
Instance.NewText += _window.AppendText;
|
||||
Instance.RequestedSave += _window.ExportInMainWindow;
|
||||
}
|
||||
|
||||
private void OnExport_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var saveFileDialog = new SaveFileDialog
|
||||
{
|
||||
Filter = "文本文档 (*.txt)|*.txt|所有文件 (*.*)|*.*",
|
||||
OverwritePrompt = true,
|
||||
};
|
||||
public static new void ShowDialog() => Lock.Run(_lock, () => (_window as Window)?.ShowDialog());
|
||||
|
||||
var result = saveFileDialog.ShowDialog(this);
|
||||
if (result == true)
|
||||
protected override void OnClosing(CancelEventArgs e)
|
||||
{
|
||||
e.Cancel = true;
|
||||
Hide();
|
||||
}
|
||||
|
||||
private Debug() { }
|
||||
|
||||
private void OnExportButtonClick(object sender, RoutedEventArgs e) => Export(this);
|
||||
|
||||
private void OnClearButtonClick(object sender, RoutedEventArgs e) => _textBox.Clear();
|
||||
|
||||
private void AppendText(string s) => Dispatcher.InvokeAsync(() => _textBox.AppendText(s));
|
||||
|
||||
private void ExportInMainWindow() => Export(Application.Current.MainWindow);
|
||||
|
||||
private void Export(Window owner)
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
using (var file = saveFileDialog.OpenFile())
|
||||
using (var writer = new StreamWriter(file))
|
||||
var saveFileDialog = new SaveFileDialog
|
||||
{
|
||||
writer.Write(Instance.DebugMessage);
|
||||
Filter = "文本文档 (*.txt)|*.txt|所有文件 (*.*)|*.*",
|
||||
OverwritePrompt = true,
|
||||
};
|
||||
|
||||
var result = saveFileDialog.ShowDialog(owner);
|
||||
if (result == true)
|
||||
{
|
||||
using var file = saveFileDialog.OpenFile();
|
||||
using var writer = new StreamWriter(file);
|
||||
writer.Write(_textBox.Text);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
25
DronePlatform.cs
Normal file
25
DronePlatform.cs
Normal file
@ -0,0 +1,25 @@
|
||||
using AnotherReplayReader.Utils;
|
||||
using TechnologyAssembler;
|
||||
using TechnologyAssembler.Core.Diagnostics;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
{
|
||||
public static class DronePlatform
|
||||
{
|
||||
private static readonly object _lock = new();
|
||||
private static bool _built = false;
|
||||
|
||||
public static void BuildTechnologyAssembler()
|
||||
{
|
||||
using var locker = new Lock(_lock);
|
||||
if (_built)
|
||||
{
|
||||
return;
|
||||
}
|
||||
new TechnologyAssemblerCoreModule().Initialize();
|
||||
Tracer.SetTraceLevel(7);
|
||||
Tracer.TraceWrite += (source, type, message) => Debug.Instance.DebugMessage += $"[{source}][{type}] {message}\r\n";
|
||||
_built = true;
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
Veldrid\.SDL2\.dll
|
@ -1,121 +0,0 @@
|
||||
{
|
||||
"General": {
|
||||
"InputAssemblies": [
|
||||
"$(TargetDir)Microsoft.DotNet.PlatformAbstractions.dll",
|
||||
"$(TargetDir)Microsoft.Extensions.DependencyModel.dll",
|
||||
"$(TargetDir)Microsoft.Win32.Primitives.dll",
|
||||
"$(TargetDir)NativeLibraryLoader.dll",
|
||||
"$(TargetDir)netstandard.dll",
|
||||
"$(TargetDir)Newtonsoft.Json.dll",
|
||||
"$(TargetDir)NLog.dll",
|
||||
"$(TargetDir)OpenSage.Core.dll",
|
||||
"$(TargetDir)OpenSage.FileFormats.Big.dll",
|
||||
"$(TargetDir)OpenSage.FileFormats.dll",
|
||||
"$(TargetDir)OpenSage.FileFormats.RefPack.dll",
|
||||
"$(TargetDir)OpenSage.Mathematics.dll",
|
||||
"$(TargetDir)Pfim.dll",
|
||||
"$(TargetDir)System.AppContext.dll",
|
||||
"$(TargetDir)System.Collections.Concurrent.dll",
|
||||
"$(TargetDir)System.Collections.dll",
|
||||
"$(TargetDir)System.Collections.NonGeneric.dll",
|
||||
"$(TargetDir)System.Collections.Specialized.dll",
|
||||
"$(TargetDir)System.ComponentModel.dll",
|
||||
"$(TargetDir)System.ComponentModel.EventBasedAsync.dll",
|
||||
"$(TargetDir)System.ComponentModel.Primitives.dll",
|
||||
"$(TargetDir)System.ComponentModel.TypeConverter.dll",
|
||||
"$(TargetDir)System.Console.dll",
|
||||
"$(TargetDir)System.Data.Common.dll",
|
||||
"$(TargetDir)System.Diagnostics.Contracts.dll",
|
||||
"$(TargetDir)System.Diagnostics.Debug.dll",
|
||||
"$(TargetDir)System.Diagnostics.DiagnosticSource.dll",
|
||||
"$(TargetDir)System.Diagnostics.FileVersionInfo.dll",
|
||||
"$(TargetDir)System.Diagnostics.Process.dll",
|
||||
"$(TargetDir)System.Diagnostics.StackTrace.dll",
|
||||
"$(TargetDir)System.Diagnostics.TextWriterTraceListener.dll",
|
||||
"$(TargetDir)System.Diagnostics.Tools.dll",
|
||||
"$(TargetDir)System.Diagnostics.TraceSource.dll",
|
||||
"$(TargetDir)System.Diagnostics.Tracing.dll",
|
||||
"$(TargetDir)System.Drawing.Primitives.dll",
|
||||
"$(TargetDir)System.Dynamic.Runtime.dll",
|
||||
"$(TargetDir)System.Globalization.Calendars.dll",
|
||||
"$(TargetDir)System.Globalization.dll",
|
||||
"$(TargetDir)System.Globalization.Extensions.dll",
|
||||
"$(TargetDir)System.IO.Compression.dll",
|
||||
"$(TargetDir)System.IO.Compression.ZipFile.dll",
|
||||
"$(TargetDir)System.IO.dll",
|
||||
"$(TargetDir)System.IO.FileSystem.dll",
|
||||
"$(TargetDir)System.IO.FileSystem.DriveInfo.dll",
|
||||
"$(TargetDir)System.IO.FileSystem.Primitives.dll",
|
||||
"$(TargetDir)System.IO.FileSystem.Watcher.dll",
|
||||
"$(TargetDir)System.IO.IsolatedStorage.dll",
|
||||
"$(TargetDir)System.IO.MemoryMappedFiles.dll",
|
||||
"$(TargetDir)System.IO.Pipes.dll",
|
||||
"$(TargetDir)System.IO.UnmanagedMemoryStream.dll",
|
||||
"$(TargetDir)System.Linq.dll",
|
||||
"$(TargetDir)System.Linq.Expressions.dll",
|
||||
"$(TargetDir)System.Linq.Parallel.dll",
|
||||
"$(TargetDir)System.Linq.Queryable.dll",
|
||||
"$(TargetDir)System.Net.Http.dll",
|
||||
"$(TargetDir)System.Net.NameResolution.dll",
|
||||
"$(TargetDir)System.Net.NetworkInformation.dll",
|
||||
"$(TargetDir)System.Net.Ping.dll",
|
||||
"$(TargetDir)System.Net.Primitives.dll",
|
||||
"$(TargetDir)System.Net.Requests.dll",
|
||||
"$(TargetDir)System.Net.Security.dll",
|
||||
"$(TargetDir)System.Net.Sockets.dll",
|
||||
"$(TargetDir)System.Net.WebHeaderCollection.dll",
|
||||
"$(TargetDir)System.Net.WebSockets.Client.dll",
|
||||
"$(TargetDir)System.Net.WebSockets.dll",
|
||||
"$(TargetDir)System.Numerics.Vectors.dll",
|
||||
"$(TargetDir)System.ObjectModel.dll",
|
||||
"$(TargetDir)System.Reflection.dll",
|
||||
"$(TargetDir)System.Reflection.Extensions.dll",
|
||||
"$(TargetDir)System.Reflection.Primitives.dll",
|
||||
"$(TargetDir)System.Resources.Reader.dll",
|
||||
"$(TargetDir)System.Resources.ResourceManager.dll",
|
||||
"$(TargetDir)System.Resources.Writer.dll",
|
||||
"$(TargetDir)System.Runtime.CompilerServices.Unsafe.dll",
|
||||
"$(TargetDir)System.Runtime.CompilerServices.VisualC.dll",
|
||||
"$(TargetDir)System.Runtime.dll",
|
||||
"$(TargetDir)System.Runtime.Extensions.dll",
|
||||
"$(TargetDir)System.Runtime.Handles.dll",
|
||||
"$(TargetDir)System.Runtime.InteropServices.dll",
|
||||
"$(TargetDir)System.Runtime.InteropServices.RuntimeInformation.dll",
|
||||
"$(TargetDir)System.Runtime.Numerics.dll",
|
||||
"$(TargetDir)System.Runtime.Serialization.Formatters.dll",
|
||||
"$(TargetDir)System.Runtime.Serialization.Json.dll",
|
||||
"$(TargetDir)System.Runtime.Serialization.Primitives.dll",
|
||||
"$(TargetDir)System.Runtime.Serialization.Xml.dll",
|
||||
"$(TargetDir)System.Security.Claims.dll",
|
||||
"$(TargetDir)System.Security.Cryptography.Algorithms.dll",
|
||||
"$(TargetDir)System.Security.Cryptography.Csp.dll",
|
||||
"$(TargetDir)System.Security.Cryptography.Encoding.dll",
|
||||
"$(TargetDir)System.Security.Cryptography.Primitives.dll",
|
||||
"$(TargetDir)System.Security.Cryptography.X509Certificates.dll",
|
||||
"$(TargetDir)System.Security.Principal.dll",
|
||||
"$(TargetDir)System.Security.SecureString.dll",
|
||||
"$(TargetDir)System.Text.Encoding.CodePages.dll",
|
||||
"$(TargetDir)System.Text.Encoding.dll",
|
||||
"$(TargetDir)System.Text.Encoding.Extensions.dll",
|
||||
"$(TargetDir)System.Text.RegularExpressions.dll",
|
||||
"$(TargetDir)System.Threading.dll",
|
||||
"$(TargetDir)System.Threading.Overlapped.dll",
|
||||
"$(TargetDir)System.Threading.Tasks.dll",
|
||||
"$(TargetDir)System.Threading.Tasks.Parallel.dll",
|
||||
"$(TargetDir)System.Threading.Thread.dll",
|
||||
"$(TargetDir)System.Threading.ThreadPool.dll",
|
||||
"$(TargetDir)System.Threading.Timer.dll",
|
||||
"$(TargetDir)System.ValueTuple.dll",
|
||||
"$(TargetDir)System.Xml.ReaderWriter.dll",
|
||||
"$(TargetDir)System.Xml.XDocument.dll",
|
||||
"$(TargetDir)System.Xml.XmlDocument.dll",
|
||||
"$(TargetDir)System.Xml.XmlSerializer.dll",
|
||||
"$(TargetDir)System.Xml.XPath.dll",
|
||||
"$(TargetDir)System.Xml.XPath.XDocument.dll"
|
||||
]
|
||||
},
|
||||
"Advanced": {
|
||||
"AllowWildCards": true
|
||||
}
|
||||
}
|
||||
|
44
IpAndPlayer.cs
Normal file
44
IpAndPlayer.cs
Normal file
@ -0,0 +1,44 @@
|
||||
using AnotherReplayReader.Utils;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
{
|
||||
public sealed class IpAndPlayer
|
||||
{
|
||||
public static string SimpleIPToString(uint ip)
|
||||
{
|
||||
return $"{ip / 256 / 256 / 256}.{ip / 256 / 256 % 256}.{ip / 256 % 256}.{ip % 256}";
|
||||
}
|
||||
|
||||
[JsonPropertyName("IP")]
|
||||
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
|
||||
public uint Ip
|
||||
{
|
||||
get => _ip;
|
||||
set
|
||||
{
|
||||
_ip = value;
|
||||
IpString = SimpleIPToString(_ip);
|
||||
}
|
||||
}
|
||||
[JsonIgnore]
|
||||
public string IpString { get; private set; } = "0.0.0.0";
|
||||
|
||||
[JsonPropertyName("ID")]
|
||||
public string Id
|
||||
{
|
||||
get => _id;
|
||||
set
|
||||
{
|
||||
_id = value;
|
||||
_pinyin = _id.ToPinyin();
|
||||
}
|
||||
}
|
||||
[JsonIgnore]
|
||||
public string? PinyinId => _pinyin;
|
||||
|
||||
private uint _ip;
|
||||
private string _id = string.Empty;
|
||||
private string? _pinyin;
|
||||
}
|
||||
}
|
@ -5,7 +5,9 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:local="clr-namespace:AnotherReplayReader"
|
||||
mc:Ignorable="d"
|
||||
Title="MainWindow" Width="800" Height="600">
|
||||
Title="MainWindow" Width="800" Height="600"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Loaded="OnMainWindowLoaded">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="20"/>
|
||||
@ -22,8 +24,8 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label x:Name="label" Content="录像文件夹" Grid.Column="0" Margin="0,-3,5,8" HorizontalContentAlignment="Right" HorizontalAlignment="Right" Width="75"/>
|
||||
<TextBox x:Name="_replayFolderPathBox" Grid.Column="1" TextWrapping="Wrap" Text="{Binding Path=ReplayFolderPath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="OnReplayFolderPathBoxTextChanged" Margin="0,0,0,10" Grid.ColumnSpan="2" />
|
||||
<Button x:Name="_browseButton" Content="浏览..." Grid.Column="3" Margin="10,0,5,11" Click="OnBrowseButton_Click"/>
|
||||
<Button x:Name="_aboutButton" Content="关于..." Click="OnAboutButton_Click" Grid.Column="4" Margin="5,0,12,11"/>
|
||||
<Button x:Name="_browseButton" Content="浏览..." Grid.Column="3" Margin="10,0,5,11" Click="OnBrowseButtonClick"/>
|
||||
<Button x:Name="_aboutButton" Content="关于..." Click="OnAboutButtonClick" Grid.Column="4" Margin="5,0,12,11"/>
|
||||
</Grid>
|
||||
<Grid Grid.Row="2">
|
||||
<Grid.RowDefinitions>
|
||||
@ -35,26 +37,57 @@
|
||||
<ColumnDefinition Width="145"/>
|
||||
<ColumnDefinition Width="472*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="_replayFilterBox" Grid.Row="0" Grid.Column="2" Margin="120,10,10,0" TextWrapping="Wrap" Height="20" VerticalAlignment="Top" />
|
||||
<Grid Grid.Row="0"
|
||||
Grid.Column="2"
|
||||
Margin="120,10,10,0"
|
||||
Height="20"
|
||||
VerticalAlignment="Top">
|
||||
<TextBox x:Name="_replayFilterBox"
|
||||
TextChanged="OnReplayFilterBoxTextChanged" />
|
||||
<TextBlock IsHitTestVisible="False"
|
||||
Text="输入录像名称、玩家名称或地图名称等 可以筛选录像"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Left"
|
||||
Margin="10,0,0,0"
|
||||
Foreground="DarkGray">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="{x:Type TextBlock}">
|
||||
<Setter Property="Visibility"
|
||||
Value="Collapsed" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Text, ElementName=_replayFilterBox}"
|
||||
Value="">
|
||||
<Setter Property="Visibility"
|
||||
Value="Visible" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
|
||||
<Button x:Name="_refreshButton" Content="刷新" Grid.Column="2" HorizontalAlignment="Left" Margin="0,11,0,0" VerticalAlignment="Top" Width="80" Click="OnReplayFolderPathBoxTextChanged"/>
|
||||
<DataGrid x:Name="_dataGrid" Grid.Row="0" Grid.Column="2" Grid.RowSpan="2" Margin="0,30,10,16" SelectionMode="Single" SelectionChanged="OnReplaySelectionChanged">
|
||||
<DataGrid x:Name="_dataGrid" Grid.Row="0" Grid.Column="2" Grid.RowSpan="2" Margin="0,30,10,16"
|
||||
SelectionMode="Single" SelectionChanged="OnReplaySelectionChanged"
|
||||
AutoGenerateColumns="False">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="文件名" Binding="{Binding Path=FileName}" Width="4*" IsReadOnly="True"/>
|
||||
<DataGridTextColumn Header="玩家人数" Binding="{Binding Path=NumberOfPlayingPlayers}" Width="1.5*" IsReadOnly="True"/>
|
||||
<DataGridTextColumn Header="录像时长" Binding="{Binding Path=Length, TargetNullValue='?'}" Width="1.65*" IsReadOnly="True"/>
|
||||
<DataGridTextColumn Header="录像时长"
|
||||
Binding="{Binding Path=Length, TargetNullValue='?'}" Width="1.65*" IsReadOnly="True"/>
|
||||
<DataGridTextColumn Header="Mod" Binding="{Binding Path=Mod}" Width="1.5*" IsReadOnly="True"/>
|
||||
<DataGridTextColumn Header="录像日期" Binding="{Binding Path=Date, StringFormat='{}{0:yyyy/MM/dd HH:mm:SS}'}" Width="3*" IsReadOnly="True" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<Button x:Name="_debugButton" Content="调试信息" Grid.Row="0" Grid.Column="0" VerticalAlignment="Top" Click="OnDebugButton_Click" Margin="20,6,0,0" HorizontalAlignment="Left" Width="80"/>
|
||||
<Button x:Name="_debugButton" Content="调试信息" Grid.Row="0" Grid.Column="0" VerticalAlignment="Top" Click="OnDebugButtonClick" Margin="20,6,0,0" HorizontalAlignment="Left" Width="80"/>
|
||||
<TextBox x:Name="_replayDetailsBox" Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Margin="20,30,20,0" Text="{Binding Path=ReplayDetails}" TextWrapping="Wrap"/>
|
||||
<Image x:Name="_image" Grid.Row="1" Grid.Column="0" Margin="18,20,0,0" Stretch="Uniform" HorizontalAlignment="Left" Width="120" Height="120" VerticalAlignment="Top" />
|
||||
<Button x:Name="_playButton" Grid.Row="1" Grid.Column="1" Content="播放录像"
|
||||
Margin="10,0,20,96"
|
||||
Height="35" VerticalAlignment="Bottom" IsEnabled="{Binding Path=ReplayPlayable}" Click="OnPlayReplayButton_Click"/>
|
||||
Height="35" VerticalAlignment="Bottom" IsEnabled="{Binding Path=ReplayPlayable}" Click="OnPlayReplayButtonClick"/>
|
||||
<Button x:Name="_saveAsButton" Grid.Row="1" Grid.Column="1" Content="修复录像"
|
||||
Margin="10,0,20,56"
|
||||
Height="35" VerticalAlignment="Bottom" IsEnabled="{Binding Path=ReplayDamaged}" Click="OnFixReplayButton_Click"/>
|
||||
Height="35" VerticalAlignment="Bottom" IsEnabled="{Binding Path=ReplayDamaged}" Click="OnFixReplayButtonClick"/>
|
||||
<Button x:Name="_detailsButton"
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
@ -63,7 +96,7 @@
|
||||
Height="35"
|
||||
VerticalAlignment="Bottom"
|
||||
IsEnabled="{Binding Path=ReplaySelected}"
|
||||
Click="OnDetailsButton_Click" />
|
||||
Click="OnDetailsButtonClick" />
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
@ -1,6 +1,9 @@
|
||||
using Microsoft.Win32;
|
||||
using AnotherReplayReader.ReplayFile;
|
||||
using AnotherReplayReader.Utils;
|
||||
using Microsoft.Win32;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
@ -9,6 +12,7 @@ using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
{
|
||||
@ -16,21 +20,14 @@ namespace AnotherReplayReader
|
||||
{
|
||||
public MainWindowProperties()
|
||||
{
|
||||
string userDataLeafName = null;
|
||||
string replayFolderName = null;
|
||||
using (var view32 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32))
|
||||
using (var ra3Key = view32.OpenSubKey(@"Software\Electronic Arts\Electronic Arts\Red Alert 3", false))
|
||||
{
|
||||
RA3Directory = ra3Key?.GetValue("Install Dir") as string;
|
||||
userDataLeafName = ra3Key?.GetValue("UserDataLeafName") as string;
|
||||
replayFolderName = ra3Key?.GetValue("ReplayFolderName") as string;
|
||||
}
|
||||
|
||||
const RegistryHive hklm = RegistryHive.LocalMachine;
|
||||
RA3Directory = RegistryUtils.RetrieveInRa3(hklm, "Install Dir");
|
||||
string? userDataLeafName = RegistryUtils.RetrieveInRa3(hklm, "UserDataLeafName");
|
||||
string? replayFolderName = RegistryUtils.RetrieveInRa3(hklm, "ReplayFolderName");
|
||||
if (string.IsNullOrWhiteSpace(userDataLeafName))
|
||||
{
|
||||
userDataLeafName = "Red Alert 3";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(replayFolderName))
|
||||
{
|
||||
replayFolderName = "Replays";
|
||||
@ -42,17 +39,21 @@ namespace AnotherReplayReader
|
||||
ModsDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), userDataLeafName, "Mods");
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
// This method is called by the Set accessor of each property.
|
||||
// The CallerMemberName attribute that is applied to the optional propertyName
|
||||
// parameter causes the property name of the caller to be substituted as an argument.
|
||||
private void NotifyPropertyChanged<T>(T value, [CallerMemberName] string propertyName = "")
|
||||
private void SetAndNotifyPropertyChanged<T>(ref T target, T newValue, [CallerMemberName] string propertyName = "")
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
if (!Equals(target, newValue))
|
||||
{
|
||||
target = newValue;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
|
||||
public string RA3Directory { get; }
|
||||
public string? RA3Directory { get; }
|
||||
public string RA3ReplayFolderPath { get; }
|
||||
public string RA3Exe => Path.Combine(RA3Directory, "RA3.exe");
|
||||
public string CustomMapsDirectory { get; }
|
||||
@ -60,46 +61,38 @@ namespace AnotherReplayReader
|
||||
|
||||
public string ReplayFolderPath
|
||||
{
|
||||
get { return _replayFolderPath; }
|
||||
set { _replayFolderPath = value; NotifyPropertyChanged(_replayFolderPath); }
|
||||
get => _replayFolderPath;
|
||||
set => SetAndNotifyPropertyChanged(ref _replayFolderPath, value);
|
||||
}
|
||||
|
||||
public string ReplayFilterString
|
||||
public string? ReplayDetails
|
||||
{
|
||||
get { return _replayFilterString; }
|
||||
set { _replayFilterString = value; NotifyPropertyChanged(_replayFilterString); }
|
||||
get => _replayDetails;
|
||||
set => SetAndNotifyPropertyChanged(ref _replayDetails, value);
|
||||
}
|
||||
|
||||
public string ReplayDetails
|
||||
public bool ReplaySelected => CurrentReplay != null;
|
||||
|
||||
public bool ReplayPlayable => CurrentReplay?.HasFooter == true && CurrentReplay?.HasCommentator == true && (RA3Directory != null) && File.Exists(RA3Exe);
|
||||
|
||||
public bool ReplayDamaged => CurrentReplay?.HasFooter == false;
|
||||
|
||||
public Replay? CurrentReplay
|
||||
{
|
||||
get { return _replayDetails; }
|
||||
set { _replayDetails = value; NotifyPropertyChanged(_replayDetails); }
|
||||
}
|
||||
|
||||
public bool ReplaySelected => _currentReplay != null;
|
||||
|
||||
public bool ReplayPlayable => _currentReplay?.HasFooter == true && _currentReplay?.HasCommentator == true && (RA3Directory != null) && File.Exists(RA3Exe);
|
||||
|
||||
public bool ReplayDamaged => _currentReplay?.HasFooter == false;
|
||||
|
||||
public Replay CurrentReplay
|
||||
{
|
||||
get { return _currentReplay; }
|
||||
get => _currentReplay;
|
||||
set
|
||||
{
|
||||
_currentReplay = value;
|
||||
NotifyPropertyChanged(_currentReplay);
|
||||
SetAndNotifyPropertyChanged(ref _currentReplay, value);
|
||||
ReplayDetails = "";
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("ReplayPlayable"));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("ReplaySelected"));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("ReplayDamaged"));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ReplayPlayable)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ReplaySelected)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ReplayDamaged)));
|
||||
}
|
||||
}
|
||||
|
||||
private volatile string _replayFolderPath;
|
||||
private volatile string _replayDetails;
|
||||
private volatile string _replayFilterString;
|
||||
private volatile Replay _currentReplay;
|
||||
private string _replayFolderPath = null!;
|
||||
private string? _replayDetails;
|
||||
private Replay? _currentReplay;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -107,360 +100,287 @@ namespace AnotherReplayReader
|
||||
/// </summary>
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private MainWindowProperties _properties = new MainWindowProperties();
|
||||
private volatile List<Replay> _replayList;
|
||||
private Cache _cache = new Cache();
|
||||
private PlayerIdentity _playerIdentity;
|
||||
private BigMinimapCache _minimapCache;
|
||||
private MinimapReader _minimapReader;
|
||||
private CancellationTokenSource _loadReplaysToken;
|
||||
private readonly TaskQueue _taskQueue;
|
||||
private readonly MainWindowProperties _properties = new();
|
||||
private readonly Cache _cache = new();
|
||||
private readonly BigMinimapCache _minimapCache;
|
||||
private readonly MinimapReader _minimapReader;
|
||||
private readonly CancelManager _cancelLoadReplays = new();
|
||||
private readonly CancelManager _cancelFilterReplays = new();
|
||||
private readonly CancelManager _cancelDisplayReplays = new();
|
||||
private ReplayPinyinList _replayList;
|
||||
private ImmutableArray<string> _filterStrings = ImmutableArray<string>.Empty;
|
||||
private ImmutableArray<Replay> _filteredReplays = ImmutableArray<Replay>.Empty;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
_taskQueue = new(Dispatcher);
|
||||
_minimapCache = new BigMinimapCache(_properties.RA3Directory);
|
||||
_minimapReader = new MinimapReader(_minimapCache, _properties.CustomMapsDirectory, _properties.ModsDirectory);
|
||||
_replayList = new();
|
||||
|
||||
DataContext = _properties;
|
||||
|
||||
InitializeComponent();
|
||||
|
||||
var handling = new bool[1] { false };
|
||||
Application.Current.Dispatcher.UnhandledException += (sender, eventArgs) =>
|
||||
Closing += (sender, eventArgs) =>
|
||||
{
|
||||
if (handling == null || handling[0] == true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
handling[0] = true;
|
||||
Dispatcher.Invoke(() => MessageBox.Show($"错误:\r\n{eventArgs.Exception}"));
|
||||
_cache.Save().Wait();
|
||||
Application.Current.Shutdown();
|
||||
};
|
||||
|
||||
Closing += ((sender, eventArgs) => _cache.Save());
|
||||
|
||||
_playerIdentity = new PlayerIdentity(_cache);
|
||||
_minimapCache = new BigMinimapCache(_cache, _properties.RA3Directory);
|
||||
_minimapReader = new MinimapReader(_minimapCache, _properties.RA3Directory, _properties.CustomMapsDirectory, _properties.ModsDirectory);
|
||||
|
||||
LoadReplays();
|
||||
_ = AutoSaveReplays();
|
||||
}
|
||||
|
||||
private async Task AutoSaveReplays()
|
||||
private async void OnMainWindowLoaded(object sender, EventArgs eventArgs)
|
||||
{
|
||||
const string ourPrefix = "自动保存";
|
||||
var errorMessageCount = 0;
|
||||
// filename and last write time
|
||||
var previousFiles = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
||||
// filename and file size
|
||||
var lastReplays = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
Debug.Initialize();
|
||||
await _cache.Initialization;
|
||||
ReplayAutoSaver.SpawnAutoSaveReplaysTask(_properties.RA3ReplayFolderPath);
|
||||
var token = _cancelLoadReplays.ResetAndGetToken(CancellationToken.None);
|
||||
_ = _taskQueue.Enqueue(() => LoadReplays(null, token), token);
|
||||
|
||||
while (true)
|
||||
const string permissionKey = "questionAsked";
|
||||
if (_cache.GetOrDefault(permissionKey, false) is not true)
|
||||
{
|
||||
try
|
||||
_cache.Set(permissionKey, true);
|
||||
var sb = new StringWriter();
|
||||
sb.WriteLine("要不要自动检查更新呢?");
|
||||
sb.WriteLine("之后也可以在“关于”窗口里,设置自动更新的选项");
|
||||
var choice = MessageBox.Show(this, sb.ToString(), App.Name, MessageBoxButton.YesNo);
|
||||
_cache.Set(UpdateChecker.CheckForUpdatesKey, choice is MessageBoxResult.Yes);
|
||||
await _cache.Save();
|
||||
}
|
||||
|
||||
_ = UpdateChecker.CheckForUpdates(_cache).ContinueWith(t => Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var updateData = t.Result;
|
||||
if (updateData.IsNewVersion())
|
||||
{
|
||||
var changed = (from fileName in Directory.GetFiles(_properties.RA3ReplayFolderPath, "*.RA3Replay")
|
||||
let info = new FileInfo(fileName)
|
||||
where !info.Name.StartsWith(ourPrefix)
|
||||
where !previousFiles.ContainsKey(info.FullName) || previousFiles[info.FullName] != info.LastWriteTimeUtc
|
||||
select info).ToList();
|
||||
|
||||
foreach (var info in changed)
|
||||
var about = new About(_cache, updateData)
|
||||
{
|
||||
previousFiles[info.FullName] = info.LastWriteTimeUtc;
|
||||
Owner = this
|
||||
};
|
||||
about.ShowDialog();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private async Task LoadReplays(string? nextSelected, CancellationToken cancelToken)
|
||||
{
|
||||
if (!IsLoaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
cancelToken.ThrowIfCancellationRequested();
|
||||
var filterToken = _cancelFilterReplays.ResetAndGetToken(cancelToken);
|
||||
_cancelDisplayReplays.Reset(_cancelFilterReplays.Token);
|
||||
|
||||
_properties.CurrentReplay = null;
|
||||
_image.Source = null;
|
||||
if (_dataGrid.Items.Count > 0)
|
||||
{
|
||||
_dataGrid.ItemsSource = Array.Empty<Replay>();
|
||||
_dataGrid.Items.Refresh();
|
||||
}
|
||||
|
||||
cancelToken.ThrowIfCancellationRequested();
|
||||
_replayList = new();
|
||||
|
||||
var path = _properties.ReplayFolderPath;
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
await FilterReplays("这个文件夹并不存在。", nextSelected, filterToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await Task.Run(() =>
|
||||
{
|
||||
var list = new List<Replay>();
|
||||
var clock = new Stopwatch();
|
||||
clock.Start();
|
||||
foreach (var replayPath in Directory.EnumerateFiles(path, "*.RA3Replay"))
|
||||
{
|
||||
cancelToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var replay = new Replay(replayPath);
|
||||
list.Add(replay);
|
||||
}
|
||||
|
||||
var replays = changed.Select(info =>
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"正在尝试检测已更改的文件:{info.FullName}\r\n";
|
||||
try
|
||||
{
|
||||
using (var stream = info.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
return new Replay(info.FullName, stream);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"自动保存录像/检测录像更改时发生错误:{e}\r\n";
|
||||
return null;
|
||||
}
|
||||
}).Where(replay => replay != null);
|
||||
|
||||
var newLastReplays = from replay in replays
|
||||
let threshold = Math.Abs((DateTime.UtcNow - replay.Date).TotalSeconds)
|
||||
let endDate = replay.Date.Add(replay.Length ?? TimeSpan.Zero)
|
||||
let endThreshold = Math.Abs((DateTime.UtcNow - endDate).TotalSeconds)
|
||||
where threshold < 40 || endThreshold < 40
|
||||
select replay;
|
||||
|
||||
var toBeChecked = newLastReplays.ToDictionary(replay => replay.Path, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var savedLastReplay in lastReplays.Keys)
|
||||
{
|
||||
if (!toBeChecked.ContainsKey(savedLastReplay))
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var stream = File.Open(savedLastReplay, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
toBeChecked.Add(savedLastReplay, new Replay(savedLastReplay, stream));
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"自动保存录像/检测录像更改时发生错误:{e}\r\n";
|
||||
}
|
||||
}
|
||||
Debug.Instance.DebugMessage += $"Uncaught exception when loading replay list: \r\n{exception}\r\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var kv in toBeChecked)
|
||||
if (clock.ElapsedMilliseconds > 300)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"正在检测录像更改:{kv.Key}\r\n";
|
||||
var replay = kv.Value;
|
||||
if (lastReplays.TryGetValue(kv.Key, out var fileSize))
|
||||
{
|
||||
if (fileSize == replay.Size)
|
||||
{
|
||||
// skip if size is not changed
|
||||
Debug.Instance.DebugMessage += $"已跳过未更改的录像:{kv.Key}\r\n";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Debug.Instance.DebugMessage += $"将会自动保存已更改的录像:{kv.Key}\r\n";
|
||||
lastReplays[kv.Key] = replay.Size;
|
||||
|
||||
var date = replay.Date;
|
||||
|
||||
var playerString = $"{replay.NumberOfPlayingPlayers}名玩家";
|
||||
if (replay.NumberOfPlayingPlayers <= 2)
|
||||
{
|
||||
var playingPlayers = from player in replay.Players
|
||||
let faction = ModData.GetFaction(replay.Mod, player.FactionID)
|
||||
where faction.Kind != FactionKind.Observer
|
||||
select $"{player.PlayerName}({faction.Name})";
|
||||
playerString = playingPlayers.Aggregate(string.Empty, (x, y) => x + y);
|
||||
}
|
||||
|
||||
var dateString = $"{date.Year}{date.Month:D2}{date.Day:D2}_{date.Hour:D2}{date.Minute:D2}{date.Second:D2}";
|
||||
var destinationPath = Path.Combine(_properties.RA3ReplayFolderPath, $"{ourPrefix}-{playerString}{dateString}.RA3Replay");
|
||||
try
|
||||
{
|
||||
File.Copy(replay.Path, destinationPath, true);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new Exception($"复制文件({replay.Path} -> {destinationPath})失败:{e.Message}", e);
|
||||
}
|
||||
var text = $"正在加载录像列表,请稍候… 已加载 {list.Count} 个录像";
|
||||
Dispatcher.Invoke(() => _properties.ReplayDetails = text);
|
||||
clock.Restart();
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
return new ReplayPinyinList(list.ToImmutableArray());
|
||||
}, cancelToken);
|
||||
cancelToken.ThrowIfCancellationRequested();
|
||||
_replayList = result;
|
||||
await FilterReplays(string.Empty, nextSelected, filterToken);
|
||||
}
|
||||
|
||||
private async Task FilterReplays(string message, string? nextSelected, CancellationToken cancelToken)
|
||||
{
|
||||
if (!IsLoaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
cancelToken.ThrowIfCancellationRequested();
|
||||
_cancelDisplayReplays.Reset(cancelToken);
|
||||
_filteredReplays = _replayList.Replays;
|
||||
|
||||
_properties.CurrentReplay = null;
|
||||
_dataGrid.SelectedItem = null;
|
||||
_image.Source = null;
|
||||
|
||||
if (_filterStrings.Any())
|
||||
{
|
||||
_properties.ReplayDetails = "正在筛选符合条件的录像…";
|
||||
if (_dataGrid.Items.Count > 0)
|
||||
{
|
||||
var errorString = $"自动保存录像时出现错误:\r\n{e}\r\n";
|
||||
Debug.Instance.DebugMessage += errorString;
|
||||
_ = Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Interlocked.Increment(ref errorMessageCount) == 1)
|
||||
{
|
||||
MessageBox.Show(errorString);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref errorMessageCount);
|
||||
}
|
||||
});
|
||||
_dataGrid.ItemsSource = Array.Empty<Replay>();
|
||||
_dataGrid.Items.Refresh();
|
||||
}
|
||||
|
||||
await Task.Delay(10 * 1000);
|
||||
var (pinyins, list) = (_filterStrings, _replayList.Pinyins);
|
||||
var result = await Task.Run(() =>
|
||||
{
|
||||
var query = from replay in list.AsParallel().WithCancellation(cancelToken)
|
||||
where pinyins.Any(pinyin => replay.MatchPinyin(pinyin))
|
||||
select replay.Replay;
|
||||
return query.ToImmutableArray();
|
||||
}, cancelToken);
|
||||
cancelToken.ThrowIfCancellationRequested();
|
||||
_filteredReplays = result;
|
||||
}
|
||||
|
||||
_properties.CurrentReplay = null;
|
||||
_dataGrid.SelectedItem = null;
|
||||
_dataGrid.ItemsSource = _filteredReplays;
|
||||
_dataGrid.Items.Refresh();
|
||||
_properties.ReplayDetails = message;
|
||||
|
||||
if (nextSelected is not null)
|
||||
{
|
||||
for (var i = 0; i < _dataGrid.Items.Count; ++i)
|
||||
{
|
||||
|
||||
if (_dataGrid.Items[i] is Replay replay && replay.PathEquals(nextSelected))
|
||||
{
|
||||
_dataGrid.SelectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void LoadReplays(string nextSelected = null)
|
||||
private async Task DisplayReplayDetail(Replay replay, string replayDetails, CancellationToken cancelToken)
|
||||
{
|
||||
const string loadingString = "正在加载录像列表,请稍候";
|
||||
|
||||
Dispatcher.Invoke(() =>
|
||||
if (!IsLoaded)
|
||||
{
|
||||
if (_image != null)
|
||||
{
|
||||
_image.Source = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
cancelToken.ThrowIfCancellationRequested();
|
||||
_properties.CurrentReplay = null;
|
||||
_image.Source = null;
|
||||
_properties.ReplayDetails = replayDetails;
|
||||
|
||||
if (_dataGrid != null)
|
||||
{
|
||||
_dataGrid.Items.Clear();
|
||||
}
|
||||
});
|
||||
// 开始获取小地图
|
||||
var mapPath = replay.MapPath;
|
||||
var minimapTask = _minimapReader.TryReadTargaAsync(replay);
|
||||
|
||||
_loadReplaysToken?.Cancel();
|
||||
_loadReplaysToken = new CancellationTokenSource();
|
||||
|
||||
var cancelToken = _loadReplaysToken.Token;
|
||||
var path = _properties.ReplayFolderPath;
|
||||
var task = Task.Run(async () =>
|
||||
// 解析录像内容
|
||||
try
|
||||
{
|
||||
var messages = "";
|
||||
var replayList = new List<Replay>();
|
||||
replay = await Task.Run(() => new Replay(replay.Path, true));
|
||||
cancelToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Uncaught exception when loading replay body: \r\n{e}\r\n";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(path))
|
||||
// 假如小地图变了(这……),那么重新加载小地图
|
||||
if (replay.MapPath != mapPath)
|
||||
{
|
||||
minimapTask.Forget();
|
||||
minimapTask = _minimapReader.TryReadTargaAsync(replay);
|
||||
}
|
||||
var newDetails = replay.GetDetails();
|
||||
if (_properties.ReplayDetails != newDetails)
|
||||
{
|
||||
if (_replayList.Replays.FindIndex(r => r.PathEquals(replay)) is int index)
|
||||
{
|
||||
messages = "这个文件夹并不存在。";
|
||||
_replayList = _replayList.SetItem(index, replay.CloneHeader());
|
||||
var token = _cancelFilterReplays.ResetAndGetToken(_cancelLoadReplays.Token);
|
||||
await FilterReplays(newDetails, replay.Path, token);
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
var replays = Directory.EnumerateFiles(path, "*.RA3Replay");
|
||||
foreach (var replayPath in replays)
|
||||
{
|
||||
try
|
||||
{
|
||||
var replay = await Task.Run(() => new Replay(replayPath));
|
||||
replayList.Add(replay);
|
||||
_properties.ReplayDetails = loadingString + $"\n已加载 {replayList.Count} 个录像";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Uncaught exception when loading replay list: \r\n{exception}\r\n";
|
||||
}
|
||||
|
||||
cancelToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
}
|
||||
|
||||
return new { Replays = replayList, Messages = messages };
|
||||
});
|
||||
}
|
||||
_properties.CurrentReplay = replay;
|
||||
_properties.ReplayDetails = newDetails;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await task;
|
||||
_replayList = result.Replays;
|
||||
var newSource = await minimapTask;
|
||||
cancelToken.ThrowIfCancellationRequested();
|
||||
DisplayReplays(result.Messages, nextSelected);
|
||||
_image.Source = newSource;
|
||||
/* _image.Width = source.Width;
|
||||
_image.Height = source.Height; */
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
private void DisplayReplays(string message = null, string nextSelected = null)
|
||||
{
|
||||
var filtered = _replayList;
|
||||
Dispatcher.Invoke(() =>
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
_properties.CurrentReplay = null;
|
||||
_dataGrid.Items.Clear();
|
||||
filtered.ForEach(x => _dataGrid.Items.Add(x));
|
||||
_properties.ReplayDetails = message;
|
||||
|
||||
if (nextSelected != null)
|
||||
{
|
||||
for (var i = 0; i < _dataGrid.Items.Count; ++i)
|
||||
{
|
||||
var replay = _dataGrid.Items[i] as Replay;
|
||||
if (replay == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (replay.Path.Equals(nextSelected, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_dataGrid.SelectedIndex = i;
|
||||
OnReplaySelectionChanged(null, null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Debug.Instance.DebugMessage += $"Uncaught exception when loading minimap: \r\n{e}\r\n";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnReplayFolderPathBoxTextChanged(object sender, EventArgs e)
|
||||
private async void OnReplayFolderPathBoxTextChanged(object sender, EventArgs e)
|
||||
{
|
||||
LoadReplays();
|
||||
var token = _cancelLoadReplays.ResetAndGetToken(CancellationToken.None);
|
||||
await _taskQueue.Enqueue(() => LoadReplays(null, token), token);
|
||||
var text = _replayFolderPathBox.Text;
|
||||
const string assemblyMagic = "!DreamSign";
|
||||
const string jsonMagic = "!FantasySeal";
|
||||
switch (_replayFolderPathBox.Text)
|
||||
{
|
||||
case "!SpellCard":
|
||||
_replayDetailsBox.Text = $"{assemblyMagic}\r\n";
|
||||
_replayDetailsBox.Text += $"{jsonMagic}\r\n";
|
||||
break;
|
||||
case assemblyMagic:
|
||||
break;
|
||||
case jsonMagic:
|
||||
UpdateChecker.Sign();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnReplaySelectionChanged(object sender, EventArgs e)
|
||||
private async void OnReplaySelectionChanged(object sender, EventArgs e)
|
||||
{
|
||||
_properties.CurrentReplay = _dataGrid.SelectedItem as Replay;
|
||||
if (_properties.CurrentReplay == null)
|
||||
if (_dataGrid.SelectedItem is not Replay replay)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.Invoke(() => { _image.Source = null; });
|
||||
|
||||
string GetSizeString(double size)
|
||||
{
|
||||
if (size > 1024 * 1024)
|
||||
{
|
||||
return $"{Math.Round(size / (1024 * 1024), 2)}MB";
|
||||
}
|
||||
return $"{Math.Round(size / 1024)}KB";
|
||||
}
|
||||
|
||||
const string formatA = "文件名:{0}\n大小:{1}\n";
|
||||
const string formatB = "地图:{0}\n日期:{1}\n长度:{2}\n";
|
||||
const string formatC = "录像类别:{0}\n这个文件是{1}保存的\n";
|
||||
const string playerListTitle = "玩家列表:\n";
|
||||
var replay = _properties.CurrentReplay;
|
||||
var sizeString = GetSizeString(replay.Size);
|
||||
var lengthString = "录像已损坏,请先修复录像";
|
||||
if (replay.HasFooter)
|
||||
{
|
||||
lengthString = $"{replay.Length}";
|
||||
}
|
||||
|
||||
var replaySaver = "[无法获取保存录像的玩家]";
|
||||
try
|
||||
{
|
||||
replaySaver = replay.ReplaySaver.PlayerName;
|
||||
}
|
||||
catch { }
|
||||
|
||||
_properties.ReplayDetails = string.Format(formatA, replay.FileName, sizeString);
|
||||
_properties.ReplayDetails += string.Format(formatB, replay.MapName, replay.Date, lengthString);
|
||||
_properties.ReplayDetails += string.Format(formatC, replay.TypeString, replaySaver);
|
||||
_properties.ReplayDetails += playerListTitle;
|
||||
foreach (var player in replay.Players)
|
||||
{
|
||||
if (player == replay.Players.Last() && player.PlayerName.Equals("post Commentator"))
|
||||
{
|
||||
break;
|
||||
}
|
||||
var factionName = ModData.GetFaction(replay.Mod, player.FactionID).Name;
|
||||
var realName = replay.Type == ReplayType.Lan ? _playerIdentity.QueryRealName(player.PlayerIP) : string.Empty;
|
||||
_properties.ReplayDetails += $"{player.PlayerName + realName},{factionName}\n";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var mapPath = replay.MapPath.TrimEnd('/');
|
||||
var mapName = mapPath.Substring(mapPath.LastIndexOf('/') + 1);
|
||||
var minimapPath = $"{mapPath}/{mapName}_art.tga";
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
var source = _minimapReader.TryReadTarga(minimapPath, replay.Mod);
|
||||
_image.Source = source;
|
||||
/*_image.Width = source.Width;
|
||||
_image.Height = source.Height;*/
|
||||
});
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Uncaught exception when loading minimap: \r\n{exception}\r\n";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
replay.ParseBody();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Uncaught exception when loading replay body: \r\n{exception}\r\n";
|
||||
}
|
||||
var token = _cancelDisplayReplays.ResetAndGetToken(_cancelFilterReplays.Token);
|
||||
_properties.ReplayDetails = replay.GetDetails();
|
||||
await _taskQueue.Enqueue(() => DisplayReplayDetail(replay, _properties.ReplayDetails, token), token);
|
||||
}
|
||||
|
||||
private void OnAboutButton_Click(object sender, RoutedEventArgs e)
|
||||
private void OnAboutButtonClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var aboutWindow = new About();
|
||||
var aboutWindow = new About(_cache)
|
||||
{
|
||||
Owner = this
|
||||
};
|
||||
aboutWindow.ShowDialog();
|
||||
}
|
||||
|
||||
private void OnBrowseButton_Click(object sender, RoutedEventArgs e)
|
||||
private void OnBrowseButtonClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
@ -470,13 +390,12 @@ namespace AnotherReplayReader
|
||||
InitialDirectory = _properties.ReplayFolderPath,
|
||||
};
|
||||
|
||||
var result = openFileDialog.ShowDialog();
|
||||
var result = openFileDialog.ShowDialog(this);
|
||||
if (result == true)
|
||||
{
|
||||
var fileName = openFileDialog.FileName;
|
||||
var directoryName = Path.GetDirectoryName(fileName);
|
||||
_properties.ReplayFolderPath = directoryName;
|
||||
LoadReplays(fileName);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
@ -485,29 +404,28 @@ namespace AnotherReplayReader
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDetailsButton_Click(object sender, RoutedEventArgs e)
|
||||
private void OnDetailsButtonClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var detailsWindow = new APM(_properties.CurrentReplay, _playerIdentity);
|
||||
var detailsWindow = new ApmWindow(_properties.CurrentReplay!)
|
||||
{
|
||||
Owner = this
|
||||
};
|
||||
detailsWindow.ShowDialog();
|
||||
}
|
||||
|
||||
private void OnDebugButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var debug = new Debug();
|
||||
debug.ShowDialog();
|
||||
}
|
||||
private void OnDebugButtonClick(object sender, RoutedEventArgs e) => Debug.ShowDialog();
|
||||
|
||||
private void OnPlayReplayButton_Click(object sender, RoutedEventArgs e)
|
||||
private void OnPlayReplayButtonClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Process.Start(_properties.RA3Exe, $" -replayGame \"{_properties.CurrentReplay}\" ");
|
||||
}
|
||||
|
||||
private void OnFixReplayButton_Click(object sender, RoutedEventArgs e)
|
||||
private async void OnFixReplayButtonClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var replay = _properties.CurrentReplay ?? throw new InvalidOperationException("Trying to fix a null replay");
|
||||
|
||||
try
|
||||
{
|
||||
var replay = _properties.CurrentReplay;
|
||||
|
||||
var saveFileDialog = new SaveFileDialog
|
||||
{
|
||||
Filter = "红警3录像文件 (*.RA3Replay)|*.RA3Replay|所有文件 (*.*)|*.*",
|
||||
@ -519,19 +437,31 @@ namespace AnotherReplayReader
|
||||
var result = saveFileDialog.ShowDialog(this);
|
||||
if (result == true)
|
||||
{
|
||||
using (var file = saveFileDialog.OpenFile())
|
||||
using (var writer = new BinaryWriter(file))
|
||||
{
|
||||
writer.WriteReplay(replay);
|
||||
}
|
||||
using var file = saveFileDialog.OpenFile();
|
||||
using var writer = new BinaryWriter(file);
|
||||
writer.Write(replay);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
MessageBox.Show($"无法修复录像:\r\n{exception}");
|
||||
MessageBox.Show(this, $"无法修复录像:\r\n{exception}");
|
||||
}
|
||||
|
||||
LoadReplays();
|
||||
var token = _cancelLoadReplays.ResetAndGetToken(CancellationToken.None);
|
||||
await _taskQueue.Enqueue(() => LoadReplays(replay.Path, token), token);
|
||||
}
|
||||
|
||||
private async void OnReplayFilterBoxTextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
|
||||
{
|
||||
_filterStrings = _replayFilterBox.Text
|
||||
.Split(',', ' ', ',')
|
||||
.Select(x => x.ToPinyin())
|
||||
.Where(x => !string.IsNullOrEmpty(x))
|
||||
.ToImmutableArray()!;
|
||||
var currentReplayPath = _properties?.CurrentReplay?.Path;
|
||||
|
||||
var token = _cancelFilterReplays.ResetAndGetToken(_cancelLoadReplays.Token);
|
||||
await _taskQueue.Enqueue(() => FilterReplays(string.Empty, currentReplayPath, token), token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
143
MinimapReader.cs
143
MinimapReader.cs
@ -1,13 +1,13 @@
|
||||
using System;
|
||||
using AnotherReplayReader.ReplayFile;
|
||||
using Pfim;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using OpenSage.FileFormats.Big;
|
||||
using Pfim;
|
||||
using TechnologyAssembler.Core.IO;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
{
|
||||
@ -24,104 +24,93 @@ namespace AnotherReplayReader
|
||||
};
|
||||
|
||||
private readonly BigMinimapCache _cache;
|
||||
private readonly string _ra3InstallPath;
|
||||
private readonly string _mapFolderPath;
|
||||
private readonly string _modFolderPath;
|
||||
|
||||
public MinimapReader(BigMinimapCache cache, string ra3InstallPath, string mapFolderPath, string modFolderPath)
|
||||
public MinimapReader(BigMinimapCache cache, string mapFolderPath, string modFolderPath)
|
||||
{
|
||||
_cache = cache;
|
||||
_ra3InstallPath = ra3InstallPath;
|
||||
_mapFolderPath = mapFolderPath;
|
||||
_modFolderPath = modFolderPath;
|
||||
}
|
||||
|
||||
public BitmapSource TryReadTarga(string path, Mod mod, double dpiX = 96.0, double dpiY = 96.0)
|
||||
public Task<BitmapSource?> TryReadTargaAsync(Replay replay, double dpiX = 96.0, double dpiY = 96.0)
|
||||
{
|
||||
using (var targa = TryGetTarga(path, mod))
|
||||
{
|
||||
if(targa == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var mapPath = replay.MapPath.TrimEnd('/');
|
||||
var mapName = mapPath.Substring(mapPath.LastIndexOf('/') + 1);
|
||||
var minimapPath = $"{mapPath}/{mapName}_art.tga";
|
||||
return Task.Run(() => TryReadTarga(minimapPath, replay.Mod, dpiX, dpiY));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return BitmapSource.Create(targa.Width, targa.Height, dpiX, dpiY, FormatMapper[targa.Format], null, targa.Data, targa.Stride);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Exception creating BitmapSource from minimap:\r\n {exception}\r\n";
|
||||
return null;
|
||||
}
|
||||
public BitmapSource? TryReadTarga(string path, Mod mod, double dpiX = 96.0, double dpiY = 96.0)
|
||||
{
|
||||
using var targa = TryGetTarga(path, mod);
|
||||
if (targa == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bitmap = BitmapSource.Create(targa.Width, targa.Height, dpiX, dpiY, FormatMapper[targa.Format], null, targa.Data, targa.Stride);
|
||||
bitmap.Freeze();
|
||||
return bitmap;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Exception creating BitmapSource from minimap:\r\n {exception}\r\n";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Targa TryGetTarga(string path, Mod mod)
|
||||
private Targa? TryGetTarga(string path, Mod mod)
|
||||
{
|
||||
var tga = null as Targa;
|
||||
const string customMapPrefix = "data/maps/internal/";
|
||||
if (Directory.Exists(_mapFolderPath) && path.StartsWith(customMapPrefix))
|
||||
{
|
||||
var minimapPath = Path.Combine(_mapFolderPath, path.Substring(customMapPrefix.Length));
|
||||
if(File.Exists(minimapPath))
|
||||
{
|
||||
return Targa.Create(File.ReadAllBytes(minimapPath), new PfimConfig());
|
||||
}
|
||||
}
|
||||
|
||||
// now, normalize paths
|
||||
path = path.Replace('/', '\\');
|
||||
|
||||
if(!mod.IsRA3)
|
||||
{
|
||||
try
|
||||
{
|
||||
var modSkudefPaths = Enumerable.Empty<string>();
|
||||
foreach (var subFolder in Directory.EnumerateDirectories(_modFolderPath))
|
||||
{
|
||||
modSkudefPaths = modSkudefPaths.Concat(Directory.EnumerateFiles(subFolder, $"{mod.ModName}_{mod.ModVersion}.SkuDef"));
|
||||
}
|
||||
|
||||
foreach (var modSkudefPath in modSkudefPaths)
|
||||
{
|
||||
var modBigPaths = BigMinimapCache.ParseSkudefs(new[] { modSkudefPath });
|
||||
foreach (var modBigPath in modBigPaths)
|
||||
{
|
||||
using (var modBig = new BigArchive(modBigPath))
|
||||
{
|
||||
var entry = modBig.GetEntry(path);
|
||||
if (entry != null)
|
||||
{
|
||||
return Targa.Create(entry.Open(), new PfimConfig());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Exception when reading minimap from Skudef big:\r\n {exception}\r\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (_cache != null && _cache.TryGetBigByEntryPath(path, out var big))
|
||||
{
|
||||
using (big)
|
||||
{
|
||||
return Targa.Create(big.GetEntry(path).Open(), new PfimConfig());
|
||||
}
|
||||
}
|
||||
|
||||
if (Directory.Exists(_ra3InstallPath))
|
||||
{
|
||||
var minimapPath = Path.Combine(_mapFolderPath, path);
|
||||
if (File.Exists(minimapPath))
|
||||
{
|
||||
return Targa.Create(File.ReadAllBytes(minimapPath), new PfimConfig());
|
||||
}
|
||||
}
|
||||
|
||||
if (!mod.IsRA3)
|
||||
{
|
||||
var modSkudefPaths = Enumerable.Empty<string>();
|
||||
foreach (var subFolder in Directory.EnumerateDirectories(_modFolderPath))
|
||||
{
|
||||
modSkudefPaths = modSkudefPaths.Concat(Directory.EnumerateFiles(subFolder, $"{mod.ModName}_{mod.ModVersion}.SkuDef"));
|
||||
}
|
||||
|
||||
DronePlatform.BuildTechnologyAssembler();
|
||||
foreach (var modSkudefPath in modSkudefPaths)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var fs = new SkuDefFileSystemProvider("modConfig", modSkudefPath);
|
||||
if (!fs.FileExists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
using var stream = fs.OpenStream(path, VirtualFileModeType.Open);
|
||||
return Targa.Create(stream, new PfimConfig());
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Exception when reading minimap from mod bigs:\r\n {exception}\r\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_cache != null && _cache.TryGetEntry(path, out var big))
|
||||
{
|
||||
using (big)
|
||||
{
|
||||
return Targa.Create(big, new PfimConfig());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
108
ModData.cs
108
ModData.cs
@ -1,8 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
{
|
||||
@ -42,13 +39,13 @@ namespace AnotherReplayReader
|
||||
|
||||
public int CompareTo(object other)
|
||||
{
|
||||
if(!(other is Mod))
|
||||
if (!(other is Mod))
|
||||
{
|
||||
return GetType().FullName.CompareTo(other.GetType().FullName);
|
||||
}
|
||||
|
||||
var otherMod = (Mod)other;
|
||||
if(IsRA3 != otherMod.IsRA3)
|
||||
if (IsRA3 != otherMod.IsRA3)
|
||||
{
|
||||
if (IsRA3)
|
||||
{
|
||||
@ -84,20 +81,13 @@ namespace AnotherReplayReader
|
||||
|
||||
internal static class ModData
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<int, Faction> _ra3Factions;
|
||||
private static readonly IReadOnlyDictionary<int, Faction> _ra3ARFactions;
|
||||
private static readonly IReadOnlyDictionary<int, Faction> _ra3CoronaFactions;
|
||||
private static readonly IReadOnlyDictionary<int, Faction> _ra3DawnFactions;
|
||||
private static readonly IReadOnlyDictionary<int, Faction> _ra3INSFactions;
|
||||
private static readonly IReadOnlyDictionary<int, Faction> _ra3FSFactions;
|
||||
private static readonly IReadOnlyDictionary<int, Faction> _ra3EisenreichFactions;
|
||||
private static readonly IReadOnlyDictionary<int, Faction> _ra3TNWFactions;
|
||||
private static readonly IReadOnlyDictionary<int, Faction> _ra3WOPFactions;
|
||||
private static readonly Faction _unknown;
|
||||
private static readonly Faction _unknown = new(FactionKind.Unknown, "未知阵营");
|
||||
private static readonly IReadOnlyDictionary<string, IReadOnlyDictionary<int, Faction>> _factions;
|
||||
|
||||
|
||||
static ModData()
|
||||
{
|
||||
_ra3Factions = new Dictionary<int, Faction>
|
||||
var ra3Factions = new Dictionary<int, Faction>
|
||||
{
|
||||
{ 0, new Faction(FactionKind.Player, "AI") },
|
||||
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
||||
@ -106,8 +96,8 @@ namespace AnotherReplayReader
|
||||
{ 4, new Faction(FactionKind.Player, "盟军") },
|
||||
{ 7, new Faction(FactionKind.Player, "随机") },
|
||||
{ 8, new Faction(FactionKind.Player, "苏联") },
|
||||
};
|
||||
_ra3ARFactions = new Dictionary<int, Faction>
|
||||
};
|
||||
var arFactions = new Dictionary<int, Faction>
|
||||
{
|
||||
{ 1, new Faction(FactionKind.Player, "阳炎") },
|
||||
{ 2, new Faction(FactionKind.Player, "天琼") },
|
||||
@ -123,7 +113,7 @@ namespace AnotherReplayReader
|
||||
{ 14, new Faction(FactionKind.Player, "涅墨西斯") },
|
||||
{ 0, new Faction(FactionKind.Player, "AI") },
|
||||
};
|
||||
_ra3CoronaFactions = new Dictionary<int, Faction>
|
||||
var coronaFactions = new Dictionary<int, Faction>
|
||||
{
|
||||
{ 0, new Faction(FactionKind.Player, "AI") },
|
||||
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
||||
@ -134,7 +124,7 @@ namespace AnotherReplayReader
|
||||
{ 8, new Faction(FactionKind.Player, "苏联") },
|
||||
{ 9, new Faction(FactionKind.Player, "神州") },
|
||||
};
|
||||
_ra3DawnFactions = new Dictionary<int, Faction>
|
||||
var dawnFactions = new Dictionary<int, Faction>
|
||||
{
|
||||
{ 0, new Faction(FactionKind.Player, "AI") },
|
||||
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
||||
@ -147,7 +137,7 @@ namespace AnotherReplayReader
|
||||
{ 11, new Faction(FactionKind.Player, "随机") },
|
||||
{ 12, new Faction(FactionKind.Player, "苏联") },
|
||||
};
|
||||
_ra3INSFactions = new Dictionary<int, Faction>
|
||||
var insFactions = new Dictionary<int, Faction>
|
||||
{
|
||||
{ 0, new Faction(FactionKind.Player, "AI") },
|
||||
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
||||
@ -157,7 +147,7 @@ namespace AnotherReplayReader
|
||||
{ 7, new Faction(FactionKind.Player, "随机") },
|
||||
{ 8, new Faction(FactionKind.Player, "苏联") },
|
||||
};
|
||||
_ra3FSFactions = new Dictionary<int, Faction>
|
||||
var fsFactions = new Dictionary<int, Faction>
|
||||
{
|
||||
{ 0, new Faction(FactionKind.Player, "AI") },
|
||||
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
||||
@ -167,7 +157,7 @@ namespace AnotherReplayReader
|
||||
{ 7, new Faction(FactionKind.Player, "随机") },
|
||||
{ 8, new Faction(FactionKind.Player, "苏联") },
|
||||
};
|
||||
_ra3EisenreichFactions = new Dictionary<int, Faction>
|
||||
var eisenreichFactions = new Dictionary<int, Faction>
|
||||
{
|
||||
{ 0, new Faction(FactionKind.Player, "AI") },
|
||||
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
||||
@ -177,7 +167,7 @@ namespace AnotherReplayReader
|
||||
{ 7, new Faction(FactionKind.Player, "随机") },
|
||||
{ 8, new Faction(FactionKind.Player, "苏联") },
|
||||
};
|
||||
_ra3TNWFactions = new Dictionary<int, Faction>
|
||||
var tnwFactions = new Dictionary<int, Faction>
|
||||
{
|
||||
{ 0, new Faction(FactionKind.Player, "AI") },
|
||||
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
||||
@ -187,7 +177,7 @@ namespace AnotherReplayReader
|
||||
{ 7, new Faction(FactionKind.Player, "随机") },
|
||||
{ 8, new Faction(FactionKind.Player, "苏联") },
|
||||
};
|
||||
_ra3WOPFactions = new Dictionary<int, Faction>
|
||||
var wopFactions = new Dictionary<int, Faction>
|
||||
{
|
||||
{ 0, new Faction(FactionKind.Player, "AI") },
|
||||
{ 1, new Faction(FactionKind.Observer, "观察员") },
|
||||
@ -197,61 +187,31 @@ namespace AnotherReplayReader
|
||||
{ 7, new Faction(FactionKind.Player, "随机") },
|
||||
{ 8, new Faction(FactionKind.Player, "苏联") },
|
||||
};
|
||||
_unknown = new Faction(FactionKind.Unknown, "未知阵营");
|
||||
_factions = new Dictionary<string, IReadOnlyDictionary<int, Faction>>(StringComparer.CurrentCultureIgnoreCase)
|
||||
{
|
||||
["RA3"] = ra3Factions,
|
||||
["Armor Rush"] = arFactions,
|
||||
["ART"] = arFactions,
|
||||
["corona"] = coronaFactions,
|
||||
["Dawn"] = dawnFactions,
|
||||
["Insurrection"] = insFactions,
|
||||
["1.12+FS"] = fsFactions,
|
||||
["Eisenreich"] = eisenreichFactions,
|
||||
["The New World"] = tnwFactions,
|
||||
["War Of Powers"] = wopFactions
|
||||
};
|
||||
}
|
||||
|
||||
public static Faction GetFaction(Mod mod, int factionID)
|
||||
public static Faction GetFaction(Mod mod, int factionId)
|
||||
{
|
||||
new Faction(FactionKind.Player, mod.ModName + "-" + factionID);
|
||||
|
||||
if
|
||||
(mod.ModName.Equals("RA3"))
|
||||
if (_factions.TryGetValue(mod.ModName, out var table))
|
||||
{
|
||||
return _ra3Factions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
||||
if (table.TryGetValue(factionId, out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
if (mod.ModName.Equals("Armor Rush"))
|
||||
{
|
||||
return _ra3ARFactions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
||||
}
|
||||
if (mod.ModName.Equals("ART"))
|
||||
{
|
||||
return _ra3ARFactions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
||||
}
|
||||
if (mod.ModName.Equals("corona"))
|
||||
{
|
||||
return _ra3CoronaFactions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
||||
}
|
||||
if (mod.ModName.Equals("Dawn"))
|
||||
{
|
||||
return _ra3DawnFactions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
||||
}
|
||||
if (mod.ModName.Equals("Insurrection"))
|
||||
{
|
||||
return _ra3INSFactions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
||||
}
|
||||
if (mod.ModName.Equals("1.12+FS"))
|
||||
{
|
||||
return _ra3FSFactions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
||||
}
|
||||
if (mod.ModName.Equals("Eisenreich"))
|
||||
{
|
||||
return _ra3EisenreichFactions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
||||
}
|
||||
if (mod.ModName.Equals("The New World"))
|
||||
{
|
||||
return _ra3TNWFactions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
||||
}
|
||||
if (mod.ModName.Equals("War Of Powers"))
|
||||
{
|
||||
return _ra3WOPFactions.TryGetValue(factionID, out var faction) ? faction : _unknown;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return _unknown;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,159 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using System.Web.Script.Serialization;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
{
|
||||
internal sealed class IPAndPlayer
|
||||
{
|
||||
public static string SimpleIPToString(uint ip)
|
||||
{
|
||||
return $"{ip / 256 / 256 / 256}.{(ip / 256 / 256) % 256}.{(ip / 256) % 256}.{ip % 256}";
|
||||
}
|
||||
|
||||
public uint IP
|
||||
{
|
||||
get { return _ip; }
|
||||
set
|
||||
{
|
||||
_ip = value;
|
||||
IPString = SimpleIPToString(_ip);
|
||||
}
|
||||
}
|
||||
public string IPString { get; private set; }
|
||||
public string ID { get; set; }
|
||||
|
||||
private uint _ip;
|
||||
}
|
||||
|
||||
internal class PlayerIdentity
|
||||
{
|
||||
public bool IsUsable => IsListUsable(_list);
|
||||
|
||||
private Cache _cache;
|
||||
private volatile IReadOnlyDictionary<uint, string> _list;
|
||||
|
||||
public PlayerIdentity(Cache cache)
|
||||
{
|
||||
_cache = cache;
|
||||
Fetch();
|
||||
|
||||
try
|
||||
{
|
||||
var stored = _cache.GetOrDefault("pt", string.Empty);
|
||||
if (string.IsNullOrWhiteSpace(stored))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = Convert.FromBase64String(stored);
|
||||
var id = Encoding.UTF8.GetBytes(Auth.ID);
|
||||
var data = Encoding.UTF8.GetString(bytes.Select((x, i) => (byte)(x ^ id[i % id.Length])).ToArray());
|
||||
var serializer = new JavaScriptSerializer();
|
||||
var cachedTable = serializer.Deserialize<List<IPAndPlayer>>(data);
|
||||
if(cachedTable == null)
|
||||
{
|
||||
_list = null;
|
||||
return;
|
||||
}
|
||||
var converted = cachedTable.ToDictionary(x => x.IP, x => x.ID);
|
||||
converted[0] = "【没有网络连接,正在使用上次保存的数据】";
|
||||
|
||||
_list = converted;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static bool IsListUsable(IReadOnlyDictionary<uint, string> list)
|
||||
{
|
||||
return list != null && list.Count != 0;
|
||||
}
|
||||
|
||||
public Task Fetch()
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var key = HttpUtility.UrlEncode(Auth.GetKey());
|
||||
var request = WebRequest.Create($"https://lanyi.altervista.org/playertable/playertable.php?do=getTable&key={key}");
|
||||
|
||||
using (var stream = request.GetResponse().GetResponseStream())
|
||||
using (var reader = new StreamReader(stream))
|
||||
{
|
||||
var response = reader.ReadToEnd();
|
||||
var serializer = new JavaScriptSerializer();
|
||||
var temp = serializer.Deserialize<List<IPAndPlayer>>(response);
|
||||
if(temp == null)
|
||||
{
|
||||
_list = null;
|
||||
return;
|
||||
}
|
||||
var converted = temp.ToDictionary(x => x.IP, x => x.ID);
|
||||
_list = converted;
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(serializer.Serialize(temp));
|
||||
var id = Encoding.UTF8.GetBytes(Auth.ID);
|
||||
var base64 = Convert.ToBase64String(bytes.Select((x, i) => (byte)(x ^ id[i % id.Length])).ToArray());
|
||||
_cache.Set("pt", base64);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
}
|
||||
|
||||
public List<IPAndPlayer> AsSortedList()
|
||||
{
|
||||
var list = _list;
|
||||
|
||||
if(!IsListUsable(list))
|
||||
{
|
||||
return new List<IPAndPlayer>();
|
||||
}
|
||||
|
||||
return _list.Select((kv) => new IPAndPlayer { IP = kv.Key, ID = kv.Value }).OrderBy(x => x.IP).ToList();
|
||||
}
|
||||
|
||||
public string QueryRealName(uint ip)
|
||||
{
|
||||
var list = _list;
|
||||
|
||||
if (!IsListUsable(list))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if(ip == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var name = list.TryGetValue(ip, out var realName) ? realName : IPAndPlayer.SimpleIPToString(ip);
|
||||
return $"({name})";
|
||||
}
|
||||
|
||||
public string QueryRealNameAndIP(uint ip)
|
||||
{
|
||||
var list = _list;
|
||||
|
||||
if (!IsListUsable(list))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (ip == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var name = list.TryGetValue(ip, out var realName) ? realName + "," : string.Empty;
|
||||
return name + IPAndPlayer.SimpleIPToString(ip);
|
||||
}
|
||||
}
|
||||
}
|
@ -4,18 +4,6 @@ using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
|
||||
// 有关程序集的一般信息由以下
|
||||
// 控制。更改这些特性值可修改
|
||||
// 与程序集关联的信息。
|
||||
[assembly: AssemblyTitle("AnotherReplayReader")]
|
||||
[assembly: AssemblyDescription("")]
|
||||
[assembly: AssemblyConfiguration("")]
|
||||
[assembly: AssemblyCompany("")]
|
||||
[assembly: AssemblyProduct("AnotherReplayReader")]
|
||||
[assembly: AssemblyCopyright("Copyright © 2019")]
|
||||
[assembly: AssemblyTrademark("")]
|
||||
[assembly: AssemblyCulture("")]
|
||||
|
||||
// 将 ComVisible 设置为 false 会使此程序集中的类型
|
||||
//对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型
|
||||
//请将此类型的 ComVisible 特性设置为 true。
|
||||
@ -39,17 +27,3 @@ using System.Windows;
|
||||
//(未在页面中找到资源时使用,
|
||||
//、应用程序或任何主题专用资源字典中找到时使用)
|
||||
)]
|
||||
|
||||
|
||||
// 程序集的版本信息由下列四个值组成:
|
||||
//
|
||||
// 主版本
|
||||
// 次版本
|
||||
// 生成号
|
||||
// 修订号
|
||||
//
|
||||
// 可以指定所有值,也可以使用以下所示的 "*" 预置版本号和修订号
|
||||
// 方法是按如下所示使用“*”: :
|
||||
// [assembly: AssemblyVersion("1.0.*")]
|
||||
[assembly: AssemblyVersion("0.0.3.0")]
|
||||
[assembly: AssemblyFileVersion("0.0.3.0")]
|
||||
|
431
Replay.cs
431
Replay.cs
@ -1,431 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
{
|
||||
internal sealed class Player
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<string, string> ComputerNames = new Dictionary<string, string>
|
||||
{
|
||||
{ "E", "简单" },
|
||||
{ "M", "中等" },
|
||||
{ "H", "困难" },
|
||||
{ "B", "凶残" },
|
||||
};
|
||||
|
||||
public string PlayerName { get; private set; }
|
||||
public uint PlayerIP { get; private set; }
|
||||
public string PlayerRealName { get; private set; }
|
||||
public int FactionID { get; private set; }
|
||||
public int Team { get; private set; }
|
||||
|
||||
public Player(string[] playerEntry)
|
||||
{
|
||||
var isComputer = playerEntry[0][0] == 'C';
|
||||
|
||||
PlayerName = playerEntry[0].Substring(1);
|
||||
|
||||
if (isComputer)
|
||||
{
|
||||
PlayerName = ComputerNames[PlayerName];
|
||||
PlayerIP = 0;
|
||||
FactionID = int.Parse(playerEntry[2]);
|
||||
Team = int.Parse(playerEntry[4]);
|
||||
}
|
||||
else
|
||||
{
|
||||
PlayerIP = uint.Parse(playerEntry[1], System.Globalization.NumberStyles.HexNumber);
|
||||
FactionID = int.Parse(playerEntry[5]);
|
||||
Team = int.Parse(playerEntry[7]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
internal enum ReplayType
|
||||
{
|
||||
Skirmish,
|
||||
Lan,
|
||||
Online
|
||||
}
|
||||
|
||||
internal sealed class ReplayChunk
|
||||
{
|
||||
public uint TimeCode { get; private set; }
|
||||
public byte Type { get; private set; }
|
||||
public byte[] Data { get; private set; }
|
||||
|
||||
public ReplayChunk(uint timeCode, BinaryReader reader)
|
||||
{
|
||||
TimeCode = timeCode; // reader.ReadUInt32();
|
||||
Type = reader.ReadByte();
|
||||
var chunkSize = reader.ReadInt32();
|
||||
Data = reader.ReadBytes(chunkSize);
|
||||
if(reader.ReadInt32() != 0)
|
||||
{
|
||||
throw new InvalidDataException("Replay Chunk not ended with zero");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal enum ReplayFooterOption
|
||||
{
|
||||
SeekToFooter,
|
||||
CurrentlyAtFooter,
|
||||
}
|
||||
|
||||
internal sealed class ReplayFooter
|
||||
{
|
||||
public const uint Terminator = 0x7FFFFFFF;
|
||||
public static readonly byte[] FooterString = Encoding.ASCII.GetBytes("RA3 REPLAY FOOTER");
|
||||
public uint FinalTimeCode { get; private set; }
|
||||
public byte[] Data { get; private set; }
|
||||
|
||||
public ReplayFooter(BinaryReader reader, ReplayFooterOption option)
|
||||
{
|
||||
var currentPosition = reader.BaseStream.Position;
|
||||
reader.BaseStream.Seek(-4, SeekOrigin.End);
|
||||
var footerLength = reader.ReadInt32();
|
||||
|
||||
if (option == ReplayFooterOption.SeekToFooter)
|
||||
{
|
||||
currentPosition = reader.BaseStream.Length - footerLength;
|
||||
}
|
||||
|
||||
reader.BaseStream.Seek(currentPosition, SeekOrigin.Begin);
|
||||
var footer = reader.ReadBytes(footerLength);
|
||||
|
||||
if(footer.Length != footerLength || reader.BaseStream.Position != reader.BaseStream.Length)
|
||||
{
|
||||
throw new InvalidDataException("Invalid footer");
|
||||
}
|
||||
|
||||
using (var footerStream = new MemoryStream(footer))
|
||||
using (var footerReader = new BinaryReader(footerStream))
|
||||
{
|
||||
var footerString = footerReader.ReadBytes(17);
|
||||
if(!footerString.SequenceEqual(FooterString))
|
||||
{
|
||||
throw new InvalidDataException("Invalid footer, no footer string");
|
||||
}
|
||||
FinalTimeCode = footerReader.ReadUInt32();
|
||||
Data = footerReader.ReadBytes(footer.Length - 25);
|
||||
if(footerReader.ReadInt32() != footerLength)
|
||||
{
|
||||
throw new InvalidDataException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ReplayFooter(uint finalTimeCode)
|
||||
{
|
||||
FinalTimeCode = finalTimeCode;
|
||||
Data = new byte[] { 0x02, 0x1A, 0x00, 0x00, 0x00 };
|
||||
}
|
||||
|
||||
List<float> TryGetKillDeathRatio()
|
||||
{
|
||||
if(Data.Length < 24)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ratios = new List<float>();
|
||||
using (var stream = new MemoryStream(Data, Data.Length - 24, 24))
|
||||
using (var reader = new BinaryReader(stream))
|
||||
{
|
||||
ratios.Add(reader.ReadSingle());
|
||||
}
|
||||
|
||||
return ratios;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ReplayExtensions
|
||||
{
|
||||
public static string ReadUTF16String(this BinaryReader reader)
|
||||
{
|
||||
var currentBytes = new List<byte>();
|
||||
byte[] lastTwoBytes = null;
|
||||
while(true)
|
||||
{
|
||||
lastTwoBytes = reader.ReadBytes(2);
|
||||
if (lastTwoBytes.Length != 2)
|
||||
{
|
||||
throw new InvalidDataException();
|
||||
}
|
||||
if(lastTwoBytes.All(x => x == 0))
|
||||
{
|
||||
break;
|
||||
}
|
||||
currentBytes.AddRange(lastTwoBytes);
|
||||
}
|
||||
|
||||
return Encoding.Unicode.GetString(currentBytes.ToArray());
|
||||
}
|
||||
|
||||
public static void WriteChunk(this BinaryWriter writer, ReplayChunk chunk)
|
||||
{
|
||||
writer.Write(chunk.TimeCode);
|
||||
writer.Write(chunk.Type);
|
||||
writer.Write(chunk.Data.Length);
|
||||
writer.Write(chunk.Data);
|
||||
writer.Write(0);
|
||||
}
|
||||
|
||||
public static void WriteFooter(this BinaryWriter writer, ReplayFooter footer)
|
||||
{
|
||||
writer.Write(ReplayFooter.Terminator);
|
||||
writer.Write(ReplayFooter.FooterString);
|
||||
writer.Write(footer.FinalTimeCode);
|
||||
writer.Write(footer.Data);
|
||||
writer.Write(ReplayFooter.FooterString.Length + footer.Data.Length + 8);
|
||||
}
|
||||
|
||||
public static void WriteReplay(this BinaryWriter writer, Replay replay)
|
||||
{
|
||||
writer.Write(replay.RawHeader);
|
||||
|
||||
var lastTimeCode = (uint)0;
|
||||
foreach(var chunk in replay.Body)
|
||||
{
|
||||
lastTimeCode = chunk.TimeCode;
|
||||
writer.WriteChunk(chunk);
|
||||
}
|
||||
|
||||
if (replay.HasFooter)
|
||||
{
|
||||
writer.WriteFooter(replay.Footer);
|
||||
}
|
||||
else
|
||||
{
|
||||
writer.WriteFooter(new ReplayFooter(lastTimeCode));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Replay
|
||||
{
|
||||
public static readonly byte[] HeaderMagic = Encoding.ASCII.GetBytes("RA3 REPLAY HEADER");
|
||||
public static readonly Dictionary<ReplayType, string> TypeStrings = new Dictionary<ReplayType, string>()
|
||||
{
|
||||
{ ReplayType.Skirmish, "遭遇战录像" },
|
||||
{ ReplayType.Lan, "局域网录像" },
|
||||
{ ReplayType.Online, "官网录像" },
|
||||
};
|
||||
|
||||
public string Path { get; private set; }
|
||||
public string FileName => System.IO.Path.GetFileNameWithoutExtension(Path);
|
||||
public DateTime Date { get; private set; }
|
||||
public bool HasFooter => Footer != null;
|
||||
public TimeSpan? Length { get; private set; }
|
||||
public string MapName { get; private set; }
|
||||
public string MapPath { get; private set; }
|
||||
public IReadOnlyList<Player> Players => _players;
|
||||
public int NumberOfPlayingPlayers { get; private set; }
|
||||
public long Size { get; private set; }
|
||||
public Mod Mod { get; private set; }
|
||||
public ReplayType Type { get; private set; }
|
||||
public string TypeString => TypeStrings[Type];
|
||||
public bool HasCommentator { get; private set; }
|
||||
public Player ReplaySaver => Players[_replaySaverIndex];
|
||||
|
||||
public byte[] RawHeader { get; private set; }
|
||||
public IReadOnlyList<ReplayChunk> Body => _body;
|
||||
public ReplayFooter Footer { get; private set; }
|
||||
|
||||
private List<Player> _players;
|
||||
private byte _replaySaverIndex;
|
||||
private long _headerSize;
|
||||
private List<ReplayChunk> _body;
|
||||
|
||||
public Replay(string path)
|
||||
{
|
||||
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
Parse(path, stream);
|
||||
}
|
||||
}
|
||||
|
||||
public Replay(string path, Stream stream)
|
||||
{
|
||||
Parse(path, stream);
|
||||
}
|
||||
|
||||
private void Parse(string path, Stream stream)
|
||||
{
|
||||
Path = path;
|
||||
|
||||
using (var reader = new BinaryReader(stream))
|
||||
{
|
||||
Size = reader.BaseStream.Length;
|
||||
var headerMagic = reader.ReadBytes(HeaderMagic.Length);
|
||||
if(!headerMagic.SequenceEqual(HeaderMagic))
|
||||
{
|
||||
throw new InvalidDataException($"{Path} is not a replay, header is {BitConverter.ToString(headerMagic)}");
|
||||
}
|
||||
|
||||
var isSkirmish = reader.ReadByte() == 0x04;
|
||||
reader.ReadBytes(4 * 4); // version and builds
|
||||
reader.ReadBytes(2); // commentary flag, and padding zero byte
|
||||
|
||||
reader.ReadUTF16String(); // title
|
||||
reader.ReadUTF16String(); // description
|
||||
MapName = reader.ReadUTF16String(); // map name
|
||||
reader.ReadUTF16String(); // map id
|
||||
|
||||
NumberOfPlayingPlayers = reader.ReadByte();
|
||||
|
||||
for(var i = 0; i <= NumberOfPlayingPlayers; ++i)
|
||||
{
|
||||
reader.ReadUInt32();
|
||||
reader.ReadUTF16String(); // utf16 player name
|
||||
if(!isSkirmish)
|
||||
{
|
||||
reader.ReadByte(); // team
|
||||
}
|
||||
}
|
||||
|
||||
var offset = reader.ReadInt32();
|
||||
var cnc3MagicLength = reader.ReadInt32();
|
||||
_headerSize = reader.BaseStream.Position + offset;
|
||||
reader.ReadBytes(cnc3MagicLength);
|
||||
|
||||
var modInfo = reader.ReadBytes(22);
|
||||
Mod = new Mod(Encoding.UTF8.GetString(modInfo));
|
||||
|
||||
var timeStamp = reader.ReadUInt32();
|
||||
Date = DateTimeOffset.FromUnixTimeSeconds(timeStamp).DateTime;
|
||||
|
||||
reader.ReadBytes(31);
|
||||
var descriptionsLength = reader.ReadInt32();
|
||||
var description = Encoding.UTF8.GetString(reader.ReadBytes(descriptionsLength));
|
||||
|
||||
var entries = null as Dictionary<string, string>;
|
||||
try
|
||||
{
|
||||
entries = description.Split(';').Where(x => !string.IsNullOrWhiteSpace(x)).ToDictionary(x => x.Split('=')[0], x => x.Split('=')[1]);
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
throw new InvalidDataException($"Failed to parse string header of replay {Path}: \r\n{exception}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_players = entries["S"].Split(':')
|
||||
.TakeWhile(x => !string.IsNullOrWhiteSpace(x) && x[0] != 'X')
|
||||
.Select(x => new Player(x.Split(',')))
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new InvalidDataException($"Failed to parse playerdata from string header of replay {Path}: \r\n{exception}");
|
||||
}
|
||||
|
||||
MapPath = entries["M"].Substring(3);
|
||||
|
||||
HasCommentator = !entries["PC"].Equals("-1");
|
||||
|
||||
var lanFlag = int.Parse(entries["GT"]) == 0;
|
||||
if (lanFlag)
|
||||
{
|
||||
if(_players.First().PlayerIP == 0)
|
||||
{
|
||||
Type = ReplayType.Skirmish;
|
||||
}
|
||||
else
|
||||
{
|
||||
Type = ReplayType.Lan;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Type = ReplayType.Online;
|
||||
}
|
||||
|
||||
_replaySaverIndex = reader.ReadByte();
|
||||
|
||||
reader.ReadBytes(8); // 8 bit paddings
|
||||
var fileNameLength = reader.ReadInt32();
|
||||
reader.ReadBytes(fileNameLength * 2);
|
||||
reader.ReadBytes(16);
|
||||
var verMagicLength = reader.ReadInt32();
|
||||
reader.ReadBytes(verMagicLength);
|
||||
reader.ReadBytes(85);
|
||||
|
||||
if(reader.BaseStream.Position != _headerSize)
|
||||
{
|
||||
throw new InvalidDataException();
|
||||
}
|
||||
|
||||
reader.BaseStream.Seek(-4, SeekOrigin.End);
|
||||
try
|
||||
{
|
||||
Footer = new ReplayFooter(reader, ReplayFooterOption.SeekToFooter);
|
||||
Length = TimeSpan.FromSeconds(Math.Round(Footer.FinalTimeCode / 15.0));
|
||||
}
|
||||
catch(Exception)
|
||||
{
|
||||
Length = null;
|
||||
Footer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ParseBody()
|
||||
{
|
||||
_body = new List<ReplayChunk>();
|
||||
|
||||
using (var stream = new FileStream(Path, FileMode.Open))
|
||||
using (var reader = new BinaryReader(stream))
|
||||
{
|
||||
RawHeader = reader.ReadBytes((int)_headerSize);
|
||||
while (true)
|
||||
{
|
||||
var timeCode = reader.ReadUInt32();
|
||||
if (timeCode == ReplayFooter.Terminator)
|
||||
{
|
||||
Footer = new ReplayFooter(reader, ReplayFooterOption.CurrentlyAtFooter);
|
||||
break;
|
||||
}
|
||||
|
||||
_body.Add(new ReplayChunk(timeCode, reader));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<byte, int[]> GetCommandCounts()
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
148
ReplayAutoSaver.cs
Normal file
148
ReplayAutoSaver.cs
Normal file
@ -0,0 +1,148 @@
|
||||
using AnotherReplayReader.ReplayFile;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
{
|
||||
public static class ReplayAutoSaver
|
||||
{
|
||||
private static int _errorMessageCount = 0;
|
||||
|
||||
public static void SpawnAutoSaveReplaysTask(string replayFolderPath)
|
||||
{
|
||||
Task.Run(() => AutoSaveReplays(replayFolderPath));
|
||||
}
|
||||
|
||||
private static async Task AutoSaveReplays(string replayFolderPath)
|
||||
{
|
||||
const string ourPrefix = "自动保存";
|
||||
|
||||
// filename and last write time
|
||||
var previousFiles = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
||||
// filename and file size
|
||||
var lastReplays = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var changed = (from fileName in Directory.GetFiles(replayFolderPath, "*.RA3Replay")
|
||||
let info = new FileInfo(fileName)
|
||||
where !info.Name.StartsWith(ourPrefix)
|
||||
where !previousFiles.ContainsKey(info.FullName) || previousFiles[info.FullName] != info.LastWriteTimeUtc
|
||||
select info).ToList();
|
||||
|
||||
foreach (var info in changed)
|
||||
{
|
||||
previousFiles[info.FullName] = info.LastWriteTimeUtc;
|
||||
}
|
||||
|
||||
var replays = changed.Select(info =>
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"正在尝试检测已更改的文件:{info.FullName}\r\n";
|
||||
try
|
||||
{
|
||||
using var stream = info.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
return new Replay(info.FullName, stream);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"自动保存录像/检测录像更改时发生错误:{e}\r\n";
|
||||
return null;
|
||||
}
|
||||
}).Where(replay => replay != null);
|
||||
|
||||
var newLastReplays = from replay in replays
|
||||
let threshold = Math.Abs((DateTime.UtcNow - replay.Date).TotalSeconds)
|
||||
let endDate = replay.Date.Add(replay.Length ?? TimeSpan.Zero)
|
||||
let endThreshold = Math.Abs((DateTime.UtcNow - endDate).TotalSeconds)
|
||||
where threshold < 40 || endThreshold < 40
|
||||
select replay;
|
||||
|
||||
var toBeChecked = newLastReplays.ToDictionary(replay => replay.Path, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var savedLastReplay in lastReplays.Keys)
|
||||
{
|
||||
if (!toBeChecked.ContainsKey(savedLastReplay))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = File.Open(savedLastReplay, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
toBeChecked.Add(savedLastReplay, new Replay(savedLastReplay, stream));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"自动保存录像/检测录像更改时发生错误:{e}\r\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var kv in toBeChecked)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"正在检测录像更改:{kv.Key}\r\n";
|
||||
var replay = kv.Value;
|
||||
if (lastReplays.TryGetValue(kv.Key, out var fileSize))
|
||||
{
|
||||
if (fileSize == replay.Size)
|
||||
{
|
||||
// skip if size is not changed
|
||||
Debug.Instance.DebugMessage += $"已跳过未更改的录像:{kv.Key}\r\n";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Debug.Instance.DebugMessage += $"将会自动保存已更改的录像:{kv.Key}\r\n";
|
||||
lastReplays[kv.Key] = replay.Size;
|
||||
|
||||
var date = replay.Date;
|
||||
|
||||
var playerString = $"{replay.NumberOfPlayingPlayers}名玩家";
|
||||
if (replay.NumberOfPlayingPlayers <= 2)
|
||||
{
|
||||
var playingPlayers = from player in replay.Players
|
||||
let faction = ModData.GetFaction(replay.Mod, player.FactionId)
|
||||
where faction.Kind != FactionKind.Observer
|
||||
select $"{player.PlayerName}({faction.Name})";
|
||||
playerString = playingPlayers.Aggregate(string.Empty, (x, y) => x + y);
|
||||
}
|
||||
|
||||
var dateString = $"{date.Year}{date.Month:D2}{date.Day:D2}_{date.Hour:D2}{date.Minute:D2}{date.Second:D2}";
|
||||
var destinationPath = Path.Combine(replayFolderPath, $"{ourPrefix}-{playerString}{dateString}.RA3Replay");
|
||||
try
|
||||
{
|
||||
File.Copy(replay.Path, destinationPath, true);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new Exception($"复制文件({replay.Path} -> {destinationPath})失败:{e.Message}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
var errorString = $"自动保存录像时出现错误:\r\n{e}\r\n";
|
||||
Debug.Instance.DebugMessage += errorString;
|
||||
if (Interlocked.Increment(ref _errorMessageCount) == 1)
|
||||
{
|
||||
_ = Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
MessageBox.Show(errorString);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref _errorMessageCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(10 * 1000).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
57
ReplayFile/CommandChunk.cs
Normal file
57
ReplayFile/CommandChunk.cs
Normal file
@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace AnotherReplayReader.ReplayFile
|
||||
{
|
||||
internal sealed class CommandChunk
|
||||
{
|
||||
public byte CommandId { get; private set; }
|
||||
public int PlayerIndex { get; private set; }
|
||||
|
||||
public static List<CommandChunk> Parse(ReplayChunk chunk)
|
||||
{
|
||||
if (chunk.Type != 1)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
static int ManglePlayerId(byte id)
|
||||
{
|
||||
return id / 8 - 2;
|
||||
}
|
||||
|
||||
using var reader = chunk.GetReader();
|
||||
if (reader.ReadByte() != 1)
|
||||
{
|
||||
throw new InvalidDataException("Payload first byte not 1");
|
||||
}
|
||||
|
||||
var list = new List<CommandChunk>();
|
||||
var numberOfCommands = reader.ReadInt32();
|
||||
for (var i = 0; i < numberOfCommands; ++i)
|
||||
{
|
||||
var commandId = reader.ReadByte();
|
||||
var playerId = reader.ReadByte();
|
||||
reader.ReadCommandData(commandId);
|
||||
list.Add(new CommandChunk
|
||||
{
|
||||
CommandId = commandId,
|
||||
PlayerIndex = ManglePlayerId(playerId)
|
||||
});
|
||||
}
|
||||
|
||||
if (reader.BaseStream.Position != reader.BaseStream.Length)
|
||||
{
|
||||
throw new InvalidDataException("Payload not fully parsed");
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"[玩家 {PlayerIndex},{RA3Commands.GetCommandName(CommandId)}]";
|
||||
}
|
||||
}
|
||||
}
|
47
ReplayFile/Player.cs
Normal file
47
ReplayFile/Player.cs
Normal file
@ -0,0 +1,47 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AnotherReplayReader.ReplayFile
|
||||
{
|
||||
internal sealed class Player
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<string, string> ComputerNames = new Dictionary<string, string>
|
||||
{
|
||||
{ "E", "简单" },
|
||||
{ "M", "中等" },
|
||||
{ "H", "困难" },
|
||||
{ "B", "凶残" },
|
||||
};
|
||||
|
||||
public bool IsComputer { get; }
|
||||
public string PlayerName { get; }
|
||||
public uint PlayerIp { get; }
|
||||
public int FactionId { get; }
|
||||
public int Team { get; }
|
||||
|
||||
public Player(string[] playerEntry)
|
||||
{
|
||||
IsComputer = playerEntry[0][0] == 'C';
|
||||
|
||||
PlayerName = playerEntry[0].Substring(1);
|
||||
|
||||
if (IsComputer)
|
||||
{
|
||||
PlayerName = ComputerNames[PlayerName];
|
||||
PlayerIp = 0;
|
||||
FactionId = int.Parse(playerEntry[2]);
|
||||
Team = int.Parse(playerEntry[4]);
|
||||
}
|
||||
else
|
||||
{
|
||||
PlayerIp = uint.Parse(playerEntry[1], System.Globalization.NumberStyles.HexNumber);
|
||||
FactionId = int.Parse(playerEntry[5]);
|
||||
Team = int.Parse(playerEntry[7]);
|
||||
}
|
||||
|
||||
if (PlayerName.Length == 20)
|
||||
{
|
||||
PlayerName = Ra3.BattleNet.Database.Utils.ChineseEncoding.DecodeChineseFromBase64(PlayerName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
289
ReplayFile/RA3Commands.cs
Normal file
289
ReplayFile/RA3Commands.cs
Normal file
@ -0,0 +1,289 @@
|
||||
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)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Failed to parse command {command:X}, last byte is {lastByte:X}";
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
315
ReplayFile/Replay.cs
Normal file
315
ReplayFile/Replay.cs
Normal file
@ -0,0 +1,315 @@
|
||||
using AnotherReplayReader.Utils;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace AnotherReplayReader.ReplayFile
|
||||
{
|
||||
internal enum ReplayType
|
||||
{
|
||||
Skirmish,
|
||||
Lan,
|
||||
Online
|
||||
}
|
||||
|
||||
internal sealed class Replay
|
||||
{
|
||||
public static readonly byte[] HeaderMagic = Encoding.ASCII.GetBytes("RA3 REPLAY HEADER");
|
||||
public static readonly Dictionary<ReplayType, string> TypeStrings = new()
|
||||
{
|
||||
{ ReplayType.Skirmish, "遭遇战录像" },
|
||||
{ ReplayType.Lan, "局域网录像" },
|
||||
{ ReplayType.Online, "官网录像" },
|
||||
};
|
||||
public const double FrameRate = 15.0;
|
||||
public const string PostCommentator = "post Commentator";
|
||||
|
||||
private readonly byte _replaySaverIndex;
|
||||
private readonly byte[]? _rawHeader;
|
||||
|
||||
public string Path { get; }
|
||||
public DateTime Date { get; }
|
||||
public string MapName { get; }
|
||||
public string MapPath { get; }
|
||||
public ImmutableArray<Player> Players { get; }
|
||||
public int NumberOfPlayingPlayers { get; }
|
||||
public Mod Mod { get; }
|
||||
public ReplayType Type { get; }
|
||||
public bool HasCommentator { get; }
|
||||
|
||||
public long Size { get; }
|
||||
public ReplayFooter? Footer { get; }
|
||||
public ImmutableArray<ReplayChunk>? Body { get; }
|
||||
|
||||
public string FileName => System.IO.Path.GetFileNameWithoutExtension(Path);
|
||||
public Player ReplaySaver => Players[_replaySaverIndex];
|
||||
public string TypeString => TypeStrings[Type];
|
||||
public bool HasFooter => Footer != null;
|
||||
public ShortTimeSpan? Length => Footer?.ReplayLength;
|
||||
|
||||
public Replay(string path, bool parseBody = false) :
|
||||
this(path, new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite), parseBody)
|
||||
{
|
||||
}
|
||||
|
||||
public Replay(string path, Stream stream, bool parseBody = false)
|
||||
{
|
||||
Path = path;
|
||||
|
||||
using var reader = new BinaryReader(stream);
|
||||
Size = reader.BaseStream.Length;
|
||||
|
||||
var headerMagic = reader.ReadBytes(HeaderMagic.Length);
|
||||
if (!headerMagic.SequenceEqual(HeaderMagic))
|
||||
{
|
||||
throw new InvalidDataException($"{Path} is not a replay, header is {BitConverter.ToString(headerMagic)}");
|
||||
}
|
||||
|
||||
var isSkirmish = reader.ReadByte() == 0x04;
|
||||
reader.ReadBytes(4 * 4); // version and builds
|
||||
reader.ReadBytes(2); // commentary flag, and padding zero byte
|
||||
|
||||
reader.ReadUTF16String(); // title
|
||||
reader.ReadUTF16String(); // description
|
||||
MapName = reader.ReadUTF16String(); // map name
|
||||
reader.ReadUTF16String(); // map id
|
||||
|
||||
NumberOfPlayingPlayers = reader.ReadByte();
|
||||
|
||||
for (var i = 0; i <= NumberOfPlayingPlayers; ++i)
|
||||
{
|
||||
reader.ReadUInt32();
|
||||
reader.ReadUTF16String(); // utf16 player name
|
||||
if (!isSkirmish)
|
||||
{
|
||||
reader.ReadByte(); // team
|
||||
}
|
||||
}
|
||||
|
||||
var offset = reader.ReadInt32();
|
||||
var cnc3MagicLength = reader.ReadInt32();
|
||||
var headerSize = checked((int)(reader.BaseStream.Position + offset));
|
||||
reader.ReadBytes(cnc3MagicLength);
|
||||
|
||||
var modInfo = reader.ReadBytes(22);
|
||||
Mod = new Mod(Encoding.UTF8.GetString(modInfo));
|
||||
|
||||
var timeStamp = reader.ReadUInt32();
|
||||
Date = DateTimeOffset.FromUnixTimeSeconds(timeStamp).DateTime;
|
||||
|
||||
reader.ReadBytes(31);
|
||||
var descriptionsLength = reader.ReadInt32();
|
||||
var description = Encoding.UTF8.GetString(reader.ReadBytes(descriptionsLength));
|
||||
|
||||
var entries = null as Dictionary<string, string>;
|
||||
try
|
||||
{
|
||||
var query = from splitted in description.Split(';')
|
||||
where !string.IsNullOrWhiteSpace(splitted)
|
||||
select splitted.Split(new[] { '=' }, 2);
|
||||
entries = query.ToDictionary(x => x[0], x => x[1]);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new InvalidDataException($"Failed to parse string header of replay {Path}: \r\n{e}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Players = entries["S"].Split(':')
|
||||
.TakeWhile(x => !string.IsNullOrWhiteSpace(x) && x[0] != 'X')
|
||||
.Select(x => new Player(x.Split(',')))
|
||||
.ToImmutableArray();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new InvalidDataException($"Failed to parse playerdata from string header of replay {Path}: \r\n{exception}");
|
||||
}
|
||||
|
||||
MapPath = entries["M"].Substring(3);
|
||||
|
||||
HasCommentator = !entries["PC"].Equals("-1");
|
||||
|
||||
var lanFlag = int.Parse(entries["GT"]) == 0;
|
||||
if (lanFlag)
|
||||
{
|
||||
if (Players.First().PlayerIp == 0)
|
||||
{
|
||||
Type = ReplayType.Skirmish;
|
||||
}
|
||||
else
|
||||
{
|
||||
Type = ReplayType.Lan;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Type = ReplayType.Online;
|
||||
}
|
||||
|
||||
_replaySaverIndex = reader.ReadByte();
|
||||
|
||||
reader.ReadBytes(8); // 8 bit paddings
|
||||
var fileNameLength = reader.ReadInt32();
|
||||
reader.ReadBytes(fileNameLength * 2);
|
||||
reader.ReadBytes(16);
|
||||
var verMagicLength = reader.ReadInt32();
|
||||
reader.ReadBytes(verMagicLength);
|
||||
reader.ReadBytes(85);
|
||||
|
||||
if (reader.BaseStream.Position != headerSize)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Warning: the stored header size {headerSize} isn't correct (acutally {reader.BaseStream.Position})\r\n";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parseBody)
|
||||
{
|
||||
// jump to footer directly
|
||||
reader.BaseStream.Seek(-4, SeekOrigin.End);
|
||||
try
|
||||
{
|
||||
Footer = new ReplayFooter(reader, ReplayFooterOption.SeekToFooter);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Failed to parse replay footer, replay might be corrupt: {e}\r\n";
|
||||
Footer = null;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var body = new List<ReplayChunk>();
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var timeCode = reader.ReadUInt32();
|
||||
if (timeCode == ReplayFooter.Terminator)
|
||||
{
|
||||
Footer = new ReplayFooter(reader, ReplayFooterOption.CurrentlyAtFooter);
|
||||
break;
|
||||
}
|
||||
|
||||
body.Add(new ReplayChunk(timeCode, reader));
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Failed to parse replay body, replay might be corrupt: {e}\r\n";
|
||||
}
|
||||
|
||||
byte[]? rawHeader = null;
|
||||
try
|
||||
{
|
||||
// 重新读取原来的整个录像头
|
||||
reader.BaseStream.Seek(0, SeekOrigin.Begin);
|
||||
rawHeader = reader.ReadBytes(headerSize);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Warning: failed to read raw header: {e}\r\n";
|
||||
return;
|
||||
}
|
||||
if (rawHeader.Length != headerSize)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Warning: the stored header size {headerSize} isn't correct (raw header length = {rawHeader.Length})\r\n";
|
||||
return;
|
||||
}
|
||||
|
||||
_rawHeader = rawHeader;
|
||||
Body = body.ToImmutableArray();
|
||||
}
|
||||
|
||||
public Replay CloneHeader()
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (var writer = new BinaryWriter(stream, Encoding.UTF8, true))
|
||||
{
|
||||
WriteTo(writer);
|
||||
}
|
||||
stream.Position = 0;
|
||||
return new Replay(Path, stream);
|
||||
}
|
||||
|
||||
public bool PathEquals(Replay replay) => PathEquals(replay.Path);
|
||||
public bool PathEquals(string path) => Path.Equals(path, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public string GetDetails()
|
||||
{
|
||||
static string GetSizeString(double size)
|
||||
{
|
||||
if (size > 1024 * 1024)
|
||||
{
|
||||
return $"{Math.Round(size / (1024 * 1024), 2)}MB";
|
||||
}
|
||||
return $"{Math.Round(size / 1024)}KB";
|
||||
}
|
||||
|
||||
string size = GetSizeString(Size);
|
||||
string length = Length?.ToString() ?? "录像已损坏,请先修复录像";
|
||||
|
||||
var replaySaver = _replaySaverIndex < Players.Length
|
||||
? ReplaySaver.PlayerName
|
||||
: "[无法获取保存录像的玩家]";
|
||||
|
||||
using var writer = new StringWriter();
|
||||
writer.WriteLine("文件名:{0}", FileName);
|
||||
writer.WriteLine("大小:{0}", size);
|
||||
writer.WriteLine("地图:{0}", MapName);
|
||||
writer.WriteLine("日期:{0}", Date);
|
||||
writer.WriteLine("长度:{0}", length);
|
||||
writer.WriteLine("录像类别:{0}", TypeString);
|
||||
writer.WriteLine("这个文件是{0}保存的", replaySaver);
|
||||
writer.WriteLine("玩家列表:");
|
||||
foreach (var player in Players)
|
||||
{
|
||||
if (player == Players.Last() && player.PlayerName.Equals(PostCommentator))
|
||||
{
|
||||
break;
|
||||
}
|
||||
var factionName = ModData.GetFaction(Mod, player.FactionId).Name;
|
||||
writer.WriteLine($"{player.PlayerName},{factionName}");
|
||||
}
|
||||
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
public void WriteTo(BinaryWriter writer) => WriteTo(writer, false);
|
||||
|
||||
private void WriteTo(BinaryWriter writer, bool skipBody)
|
||||
{
|
||||
if ((_rawHeader is null || Body is null) && !skipBody)
|
||||
{
|
||||
throw new InvalidOperationException("Replay body must be parsed before writing replay");
|
||||
}
|
||||
|
||||
writer.Write(_rawHeader);
|
||||
|
||||
var lastTimeCode = Footer?.FinalTimeCode;
|
||||
if (Body is not null)
|
||||
{
|
||||
foreach (var chunk in Body)
|
||||
{
|
||||
lastTimeCode = chunk.TimeCode;
|
||||
writer.Write(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
if (Footer is not null)
|
||||
{
|
||||
writer.Write(Footer);
|
||||
}
|
||||
else if (lastTimeCode is uint lastTimeCodeValue)
|
||||
{
|
||||
writer.Write(new ReplayFooter(lastTimeCodeValue));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
40
ReplayFile/ReplayChunk.cs
Normal file
40
ReplayFile/ReplayChunk.cs
Normal file
@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace AnotherReplayReader.ReplayFile
|
||||
{
|
||||
internal sealed class ReplayChunk
|
||||
{
|
||||
private readonly byte[] _data;
|
||||
|
||||
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();
|
||||
Type = reader.ReadByte();
|
||||
var chunkSize = reader.ReadInt32();
|
||||
_data = reader.ReadBytes(chunkSize);
|
||||
if (reader.ReadInt32() != 0)
|
||||
{
|
||||
throw new InvalidDataException("Replay Chunk not ended with zero");
|
||||
}
|
||||
}
|
||||
|
||||
public BinaryReader GetReader() => new(new MemoryStream(_data, false));
|
||||
|
||||
public void WriteTo(BinaryWriter writer)
|
||||
{
|
||||
writer.Write(TimeCode);
|
||||
writer.Write(Type);
|
||||
writer.Write(_data.Length);
|
||||
writer.Write(_data);
|
||||
writer.Write(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
40
ReplayFile/ReplayExtensions.cs
Normal file
40
ReplayFile/ReplayExtensions.cs
Normal file
@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace AnotherReplayReader.ReplayFile
|
||||
{
|
||||
internal static class ReplayExtensions
|
||||
{
|
||||
public static string ReadUTF16String(this BinaryReader reader)
|
||||
{
|
||||
var currentBytes = new List<byte>();
|
||||
var lastTwoBytes = Array.Empty<byte>();
|
||||
while (true)
|
||||
{
|
||||
lastTwoBytes = reader.ReadBytes(2);
|
||||
if (lastTwoBytes.Length != 2)
|
||||
{
|
||||
throw new InvalidDataException();
|
||||
}
|
||||
if (lastTwoBytes.All(x => x == 0))
|
||||
{
|
||||
break;
|
||||
}
|
||||
currentBytes.AddRange(lastTwoBytes);
|
||||
}
|
||||
|
||||
return Encoding.Unicode.GetString(currentBytes.ToArray());
|
||||
}
|
||||
|
||||
public static void Write(this BinaryWriter writer, ReplayChunk chunk) => chunk.WriteTo(writer);
|
||||
|
||||
public static void Write(this BinaryWriter writer, ReplayFooter footer) => footer.WriteTo(writer);
|
||||
|
||||
public static void Write(this BinaryWriter writer, Replay replay) => replay.WriteTo(writer);
|
||||
}
|
||||
|
||||
|
||||
}
|
92
ReplayFile/ReplayFooter.cs
Normal file
92
ReplayFile/ReplayFooter.cs
Normal file
@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace AnotherReplayReader.ReplayFile
|
||||
{
|
||||
internal enum ReplayFooterOption
|
||||
{
|
||||
SeekToFooter,
|
||||
CurrentlyAtFooter,
|
||||
}
|
||||
|
||||
internal sealed class ReplayFooter
|
||||
{
|
||||
public const uint Terminator = 0x7FFFFFFF;
|
||||
public static readonly byte[] FooterString = Encoding.ASCII.GetBytes("RA3 REPLAY FOOTER");
|
||||
|
||||
private readonly byte[] _data;
|
||||
|
||||
public uint FinalTimeCode { get; }
|
||||
public TimeSpan ReplayLength => TimeSpan.FromSeconds(FinalTimeCode / Replay.FrameRate);
|
||||
|
||||
public ReplayFooter(BinaryReader reader, ReplayFooterOption option)
|
||||
{
|
||||
var currentPosition = reader.BaseStream.Position;
|
||||
reader.BaseStream.Seek(-4, SeekOrigin.End);
|
||||
var footerLength = reader.ReadInt32();
|
||||
|
||||
if (option == ReplayFooterOption.SeekToFooter)
|
||||
{
|
||||
currentPosition = reader.BaseStream.Length - footerLength;
|
||||
}
|
||||
|
||||
reader.BaseStream.Seek(currentPosition, SeekOrigin.Begin);
|
||||
var footer = reader.ReadBytes(footerLength);
|
||||
|
||||
if (footer.Length != footerLength || reader.BaseStream.Position != reader.BaseStream.Length)
|
||||
{
|
||||
throw new InvalidDataException("Invalid footer");
|
||||
}
|
||||
|
||||
using var footerStream = new MemoryStream(footer);
|
||||
using var footerReader = new BinaryReader(footerStream);
|
||||
var footerString = footerReader.ReadBytes(17);
|
||||
if (!footerString.SequenceEqual(FooterString))
|
||||
{
|
||||
throw new InvalidDataException("Invalid footer, no footer string");
|
||||
}
|
||||
FinalTimeCode = footerReader.ReadUInt32();
|
||||
_data = footerReader.ReadBytes(footer.Length - 25);
|
||||
if (footerReader.ReadInt32() != footerLength)
|
||||
{
|
||||
throw new InvalidDataException();
|
||||
}
|
||||
}
|
||||
|
||||
public ReplayFooter(uint finalTimeCode)
|
||||
{
|
||||
FinalTimeCode = finalTimeCode;
|
||||
_data = new byte[] { 0x02, 0x1A, 0x00, 0x00, 0x00 };
|
||||
}
|
||||
|
||||
public float[]? TryGetKillDeathRatios()
|
||||
{
|
||||
if (_data.Length < 24)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ratios = new float[6];
|
||||
using var stream = new MemoryStream(_data, _data.Length - 24, 24);
|
||||
using var reader = new BinaryReader(stream);
|
||||
for (var i = 0; i < ratios.Length; ++i)
|
||||
{
|
||||
ratios[i] = reader.ReadSingle();
|
||||
}
|
||||
return ratios;
|
||||
}
|
||||
|
||||
public void WriteTo(BinaryWriter writer)
|
||||
{
|
||||
writer.Write(Terminator);
|
||||
writer.Write(FooterString);
|
||||
writer.Write(FinalTimeCode);
|
||||
writer.Write(_data);
|
||||
writer.Write(FooterString.Length + _data.Length + 8);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
BIN
TechnologyAssembler.Core.dll
Normal file
BIN
TechnologyAssembler.Core.dll
Normal file
Binary file not shown.
90
UpdateChecker.cs
Normal file
90
UpdateChecker.cs
Normal file
@ -0,0 +1,90 @@
|
||||
using AnotherReplayReader.Utils;
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
{
|
||||
internal class UpdateCheckerVersionData
|
||||
{
|
||||
public string NewVersion { get; }
|
||||
public string Description { get; }
|
||||
public ImmutableArray<string> Urls { get; }
|
||||
|
||||
public UpdateCheckerVersionData(string newVersion,
|
||||
string description,
|
||||
ImmutableArray<string> urls)
|
||||
{
|
||||
NewVersion = newVersion;
|
||||
Description = description;
|
||||
Urls = urls;
|
||||
}
|
||||
|
||||
public bool IsNewVersion() => NewVersion != App.Version;
|
||||
}
|
||||
|
||||
internal class UpdateChecker
|
||||
{
|
||||
public const string CheckForUpdatesKey = "checkForUpdates";
|
||||
public const string CachedDataKey = "cachedUpdateData";
|
||||
private static readonly ImmutableArray<string> _updateSources = new[]
|
||||
{
|
||||
"https://lanyi.altervista.org/playertable/file.json"
|
||||
}.ToImmutableArray();
|
||||
|
||||
public static Task<UpdateCheckerVersionData> CheckForUpdates(Cache cache)
|
||||
{
|
||||
var taskSource = new TaskCompletionSource<UpdateCheckerVersionData>();
|
||||
if (cache.GetOrDefault(CheckForUpdatesKey, false) is not true)
|
||||
{
|
||||
return taskSource.Task;
|
||||
}
|
||||
foreach (var source in _updateSources)
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = await DownloadAndVerify(source).ConfigureAwait(false);
|
||||
if (data.IsNewVersion())
|
||||
{
|
||||
cache.Set(CachedDataKey, data);
|
||||
taskSource.TrySetResult(data);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Update check failed: {e}";
|
||||
}
|
||||
});
|
||||
}
|
||||
return taskSource.Task;
|
||||
}
|
||||
|
||||
private static async Task<UpdateCheckerVersionData> DownloadAndVerify(string url)
|
||||
{
|
||||
var payload = await Network.HttpGetJson<VerifierPayload>(url)
|
||||
?? throw new InvalidDataException(nameof(VerifierPayload) + " is null");
|
||||
if (!Verifier.Verify(payload))
|
||||
{
|
||||
throw new InvalidDataException(nameof(VerifierPayload) + " cannot be verified");
|
||||
}
|
||||
return JsonSerializer.Deserialize<UpdateCheckerVersionData>(payload.ByteData.Value, Network.CommonJsonOptions)
|
||||
?? throw new InvalidDataException(nameof(UpdateCheckerVersionData) + " is null");
|
||||
}
|
||||
|
||||
public static void Sign()
|
||||
{
|
||||
var sample = new UpdateCheckerVersionData(App.Version, "Alice Margatroid", _updateSources);
|
||||
var sampleText = JsonSerializer.Serialize(sample, Network.CommonJsonOptions);
|
||||
Verifier.Sign(sampleText);
|
||||
}
|
||||
}
|
||||
}
|
48
Utils/CancelManager.cs
Normal file
48
Utils/CancelManager.cs
Normal file
@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace AnotherReplayReader.Utils
|
||||
{
|
||||
internal class CancelManager : IDisposable
|
||||
{
|
||||
private CancellationToken _linkedToken;
|
||||
private CancellationTokenSource? _source;
|
||||
|
||||
public CancellationToken Token => Materialize().Token;
|
||||
|
||||
public void Reset(CancellationToken linked)
|
||||
{
|
||||
_linkedToken = linked;
|
||||
if (_source is { } source)
|
||||
{
|
||||
_source = null;
|
||||
try
|
||||
{
|
||||
source.Cancel();
|
||||
}
|
||||
catch (AggregateException e)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Cancellation failed: {e}";
|
||||
}
|
||||
source.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public CancellationToken ResetAndGetToken(CancellationToken linked)
|
||||
{
|
||||
Reset(linked);
|
||||
return Token;
|
||||
}
|
||||
|
||||
public void Dispose() => Reset(default);
|
||||
|
||||
private CancellationTokenSource Materialize()
|
||||
{
|
||||
if (_source is null)
|
||||
{
|
||||
_source = CancellationTokenSource.CreateLinkedTokenSource(_linkedToken);
|
||||
}
|
||||
return _source;
|
||||
}
|
||||
}
|
||||
}
|
23
Utils/CancellableTaskExtensions.cs
Normal file
23
Utils/CancellableTaskExtensions.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AnotherReplayReader.Utils
|
||||
{
|
||||
internal static class CancellableTaskExtensions
|
||||
{
|
||||
public static async Task IgnoreCancel(this Task task)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
public static void Forget(this Task task)
|
||||
{
|
||||
const TaskContinuationOptions flags = TaskContinuationOptions.NotOnRanToCompletion | TaskContinuationOptions.ExecuteSynchronously;
|
||||
task.ContinueWith(t => t.Exception?.Handle(_ => true), flags);
|
||||
}
|
||||
}
|
||||
}
|
20
Utils/ImmutableArrayExtensions.cs
Normal file
20
Utils/ImmutableArrayExtensions.cs
Normal file
@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace AnotherReplayReader.Utils
|
||||
{
|
||||
static class ImmutableArrayExtensions
|
||||
{
|
||||
public static int? FindIndex<T>(this in ImmutableArray<T> a, Predicate<T> p)
|
||||
{
|
||||
for (var i = 0; i < a.Length; ++i)
|
||||
{
|
||||
if (p(a[i]))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
32
Utils/Lock.cs
Normal file
32
Utils/Lock.cs
Normal file
@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace AnotherReplayReader.Utils
|
||||
{
|
||||
internal class Lock : IDisposable
|
||||
{
|
||||
public readonly object LockObject;
|
||||
private bool _disposed = false;
|
||||
|
||||
public Lock(object lockObject)
|
||||
{
|
||||
LockObject = lockObject;
|
||||
Monitor.Enter(LockObject);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
Monitor.Exit(LockObject);
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
public static T Run<T>(object @lock, Func<T> action)
|
||||
{
|
||||
using var locker = new Lock(@lock);
|
||||
return action();
|
||||
}
|
||||
}
|
||||
}
|
27
Utils/Network.cs
Normal file
27
Utils/Network.cs
Normal file
@ -0,0 +1,27 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
|
||||
namespace AnotherReplayReader.Utils
|
||||
{
|
||||
public static class Network
|
||||
{
|
||||
public static readonly JsonSerializerOptions CommonJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public static string UrlEncode(string text) => HttpUtility.UrlEncode(text);
|
||||
|
||||
public static async Task<T?> HttpGetJson<T>(string url,
|
||||
CancellationToken cancelToken = default)
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
using var response = await client.GetAsync(url, cancelToken).ConfigureAwait(false);
|
||||
using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||
return await JsonSerializer.DeserializeAsync<T>(stream, CommonJsonOptions, cancelToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
27
Utils/PinyinExtensions.cs
Normal file
27
Utils/PinyinExtensions.cs
Normal file
@ -0,0 +1,27 @@
|
||||
using NPinyin;
|
||||
using System;
|
||||
|
||||
namespace AnotherReplayReader.Utils
|
||||
{
|
||||
static class PinyinExtensions
|
||||
{
|
||||
public static bool ContainsIgnoreCase(this string self, string? s)
|
||||
{
|
||||
return s != null && self.IndexOf(s, StringComparison.CurrentCultureIgnoreCase) != -1;
|
||||
}
|
||||
|
||||
public static string? ToPinyin(this string self)
|
||||
{
|
||||
string pinyin;
|
||||
try
|
||||
{
|
||||
pinyin = Pinyin.GetPinyin(self);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return pinyin.Replace(" ", "");
|
||||
}
|
||||
}
|
||||
}
|
43
Utils/RegistryUtils.cs
Normal file
43
Utils/RegistryUtils.cs
Normal file
@ -0,0 +1,43 @@
|
||||
using Microsoft.Win32;
|
||||
using System;
|
||||
|
||||
namespace AnotherReplayReader.Utils
|
||||
{
|
||||
public static class RegistryUtils
|
||||
{
|
||||
public static string? Retrieve32(RegistryHive hive, string path, string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var view32 = RegistryKey.OpenBaseKey(hive, RegistryView.Registry32);
|
||||
using var key = view32.OpenSubKey(path, false);
|
||||
return key?.GetValue(value) as string;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Failed to retrieve registy {hive}:{path}:{value}: {e}";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static string? RetrieveInHklm64(string path, string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var view64 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
|
||||
using var key = view64?.OpenSubKey(path, false);
|
||||
return key?.GetValue(value) as string;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.Instance.DebugMessage += $"Failed to retrieve registy HKLM64:{path}:{value}: {e}";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static string? RetrieveInRa3(RegistryHive hive, string value)
|
||||
{
|
||||
return Retrieve32(hive, @"Software\Electronic Arts\Electronic Arts\Red Alert 3", value);
|
||||
}
|
||||
}
|
||||
}
|
56
Utils/ReplayPinyinList.cs
Normal file
56
Utils/ReplayPinyinList.cs
Normal file
@ -0,0 +1,56 @@
|
||||
using AnotherReplayReader.ReplayFile;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
|
||||
namespace AnotherReplayReader.Utils
|
||||
{
|
||||
internal class ReplayPinyinList
|
||||
{
|
||||
public ImmutableArray<Replay> Replays { get; } = ImmutableArray<Replay>.Empty;
|
||||
public ImmutableArray<ReplayPinyinData> Pinyins { get; } = ImmutableArray<ReplayPinyinData>.Empty;
|
||||
|
||||
public ReplayPinyinList() :
|
||||
this(ImmutableArray<Replay>.Empty)
|
||||
{
|
||||
}
|
||||
|
||||
public ReplayPinyinList(ImmutableArray<Replay> replay) :
|
||||
this(replay,
|
||||
replay.Select(replay => new ReplayPinyinData(replay)).ToImmutableArray())
|
||||
{
|
||||
}
|
||||
|
||||
private ReplayPinyinList(ImmutableArray<Replay> replay,
|
||||
ImmutableArray<ReplayPinyinData> pinyins)
|
||||
{
|
||||
Replays = replay;
|
||||
Pinyins = pinyins;
|
||||
}
|
||||
|
||||
public ReplayPinyinList SetItem(int index, Replay replay)
|
||||
{
|
||||
return new(Replays.SetItem(index, replay),
|
||||
Pinyins.SetItem(index, new(replay)));
|
||||
}
|
||||
}
|
||||
|
||||
class ReplayPinyinData
|
||||
{
|
||||
public Replay Replay { get; }
|
||||
public string? PinyinDetails { get; }
|
||||
public string? PinyinMod { get; }
|
||||
|
||||
public ReplayPinyinData(Replay replay)
|
||||
{
|
||||
Replay = replay;
|
||||
PinyinDetails = replay.GetDetails().ToPinyin();
|
||||
PinyinMod = replay.Mod.ModName.ToPinyin();
|
||||
}
|
||||
|
||||
public bool MatchPinyin(string? pinyin)
|
||||
{
|
||||
return PinyinDetails?.ContainsIgnoreCase(pinyin) is true
|
||||
|| PinyinMod?.ContainsIgnoreCase(pinyin) is true;
|
||||
}
|
||||
}
|
||||
}
|
28
Utils/ShortTimeSpan.cs
Normal file
28
Utils/ShortTimeSpan.cs
Normal file
@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AnotherReplayReader.Utils
|
||||
{
|
||||
public readonly struct ShortTimeSpan : IEquatable<ShortTimeSpan>, IComparable<ShortTimeSpan>, IComparable
|
||||
{
|
||||
public readonly TimeSpan Value;
|
||||
|
||||
public ShortTimeSpan(TimeSpan value) => Value = value;
|
||||
public static implicit operator TimeSpan(ShortTimeSpan span) => span.Value;
|
||||
public static implicit operator ShortTimeSpan(TimeSpan value) => new(value);
|
||||
public override string ToString() => $"{(int)Value.TotalMinutes:00}:{Value.Seconds:00}";
|
||||
|
||||
public int CompareTo(ShortTimeSpan other) => Value.CompareTo(other.Value);
|
||||
public int CompareTo(object obj) => obj is ShortTimeSpan span ? CompareTo(span) : 1;
|
||||
public override bool Equals(object? obj) => obj is ShortTimeSpan span && Equals(span);
|
||||
public bool Equals(ShortTimeSpan other) => Value.Equals(other.Value);
|
||||
public override int GetHashCode() => Value.GetHashCode();
|
||||
public static bool operator ==(ShortTimeSpan left, ShortTimeSpan right) => left.Equals(right);
|
||||
public static bool operator !=(ShortTimeSpan left, ShortTimeSpan right) => !(left == right);
|
||||
public static bool operator <(ShortTimeSpan left, ShortTimeSpan right) => left.CompareTo(right) < 0;
|
||||
public static bool operator >(ShortTimeSpan left, ShortTimeSpan right) => left.CompareTo(right) > 0;
|
||||
}
|
||||
}
|
31
Utils/TaskQueue.cs
Normal file
31
Utils/TaskQueue.cs
Normal file
@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace AnotherReplayReader.Utils
|
||||
{
|
||||
internal class TaskQueue
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly Dispatcher _dispatcher;
|
||||
private Task _current = Task.CompletedTask;
|
||||
|
||||
public TaskQueue(Dispatcher dispatcher)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
public Task Enqueue(Func<Task> getTask, CancellationToken cancelToken)
|
||||
{
|
||||
using var locker = new Lock(_lock);
|
||||
_current = _current.ContinueWith(async t =>
|
||||
{
|
||||
var task = await _dispatcher.InvokeAsync(getTask, DispatcherPriority.Background, cancelToken);
|
||||
await task.ConfigureAwait(false);
|
||||
}, cancelToken).Unwrap();
|
||||
return _current.IgnoreCancel();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
115
Utils/Verifier.cs
Normal file
115
Utils/Verifier.cs
Normal file
@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Windows;
|
||||
|
||||
namespace AnotherReplayReader.Utils
|
||||
{
|
||||
internal class VerifierPayload
|
||||
{
|
||||
public string Data { get; }
|
||||
public string Signature { get; }
|
||||
[JsonIgnore]
|
||||
public Lazy<byte[]> ByteData { get; }
|
||||
[JsonIgnore]
|
||||
public Lazy<byte[]> ByteSignature { get; }
|
||||
|
||||
public VerifierPayload(string data, string signature)
|
||||
{
|
||||
Data = data;
|
||||
Signature = signature;
|
||||
ByteData = new(() => Convert.FromBase64String(Data),
|
||||
LazyThreadSafetyMode.PublicationOnly);
|
||||
ByteSignature = new(() => Convert.FromBase64String(Signature),
|
||||
LazyThreadSafetyMode.PublicationOnly);
|
||||
}
|
||||
|
||||
public static VerifierPayload FromBytes(byte[] data, byte[] signature)
|
||||
{
|
||||
return new(Convert.ToBase64String(data), Convert.ToBase64String(signature));
|
||||
}
|
||||
}
|
||||
|
||||
internal class Verifier
|
||||
{
|
||||
private const string _publicKey = @"<RSAKeyValue><Modulus>3vw5CoFRDFt2ri4jLDTu75cw1U/tCRjya7q8X/IdULaOJOYG8C+uqrF2Atb4ou+4SrmF+bvJM9cFsf3yO7XpeIDpkxD3KGbIEw+0JixTIIm+y5xlLKDDwbZHnYjJOBTt6JBn0yqwx7vY2UEZIcRU6wlOmUapnkpiaC2anNhSPqk=</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>";
|
||||
|
||||
public static bool Verify(VerifierPayload payload)
|
||||
{
|
||||
using var rsa = new RSACng();
|
||||
rsa.FromXmlString(_publicKey);
|
||||
return rsa.VerifyData(payload.ByteData.Value, payload.ByteSignature.Value, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||
}
|
||||
|
||||
public static void Sign(object sample)
|
||||
{
|
||||
var fileName = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
var ea = Enumerable.Repeat<byte>(0xEA, 1024).ToArray();
|
||||
const string splitter = "|DuplexBarrier|";
|
||||
var isByteArray = false;
|
||||
if (sample is string sampleText)
|
||||
{
|
||||
File.WriteAllText(fileName, $"输入私钥信息以及需要签名的数据,用 `{splitter}` 分开\r\n\r\n{sampleText}");
|
||||
}
|
||||
else if (sample is byte[] readyArray)
|
||||
{
|
||||
isByteArray = true;
|
||||
File.WriteAllText(fileName, $"输入私钥信息以及需要签名的数据{splitter}{Convert.ToBase64String(readyArray)}{splitter}");
|
||||
}
|
||||
using (var process = Process.Start("notepad.exe", fileName))
|
||||
{
|
||||
process.WaitForExit();
|
||||
}
|
||||
var splitted = File.ReadAllText(fileName).Split(new[] { splitter }, StringSplitOptions.RemoveEmptyEntries);
|
||||
File.WriteAllBytes(fileName, ea);
|
||||
byte[] bytes;
|
||||
byte[] signature;
|
||||
|
||||
using (var rsa = new RSACng())
|
||||
{
|
||||
rsa.FromXmlString(splitted[0]);
|
||||
var text = splitted[1];
|
||||
splitted = null;
|
||||
GC.Collect();
|
||||
MessageBox.Show(text, $"快来确认一下~");
|
||||
bytes = isByteArray
|
||||
? Convert.FromBase64String(text)
|
||||
: Encoding.UTF8.GetBytes(text);
|
||||
signature = rsa.SignData(bytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||
}
|
||||
GC.Collect();
|
||||
var payload = VerifierPayload.FromBytes(bytes, signature);
|
||||
var serialized = JsonSerializer.Serialize(payload, Network.CommonJsonOptions);
|
||||
var choice = MessageBox.Show(serialized, "嗯哼", MessageBoxButton.YesNo);
|
||||
while (choice == MessageBoxResult.Yes)
|
||||
{
|
||||
File.WriteAllText(fileName, serialized);
|
||||
using (var process = Process.Start("notepad.exe", fileName))
|
||||
{
|
||||
process.WaitForExit();
|
||||
}
|
||||
using var rsa = new RSACng();
|
||||
rsa.FromXmlString(_publicKey);
|
||||
var checkContent = File.ReadAllText(fileName);
|
||||
var check = JsonSerializer.Deserialize<VerifierPayload>(checkContent, Network.CommonJsonOptions) ?? new("", "");
|
||||
var checkData = Convert.FromBase64String(check.Data);
|
||||
var checkSignature = Convert.FromBase64String(check.Signature);
|
||||
var verified = rsa.VerifyData(checkData, checkSignature, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||
choice = MessageBox.Show($"结果:{verified}", "嗯哼", MessageBoxButton.YesNo);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
29
Utils/WpfExtensions.cs
Normal file
29
Utils/WpfExtensions.cs
Normal file
@ -0,0 +1,29 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
|
||||
namespace AnotherReplayReader.Utils
|
||||
{
|
||||
internal static class WpfExtensions
|
||||
{
|
||||
public static IEnumerable<T> FindVisualChildren<T>(this DependencyObject depObj) where T : DependencyObject
|
||||
{
|
||||
foreach (var x in LogicalTreeHelper.GetChildren(depObj))
|
||||
{
|
||||
if (x is not DependencyObject child)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child is T tchild)
|
||||
{
|
||||
yield return tchild;
|
||||
}
|
||||
|
||||
foreach (var childOfChild in FindVisualChildren<T>(child))
|
||||
{
|
||||
yield return childOfChild;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
14
Window1.xaml
14
Window1.xaml
@ -8,15 +8,19 @@
|
||||
mc:Ignorable="d"
|
||||
Title="Window1" Height="450" Width="800">
|
||||
<Grid>
|
||||
<TextBox x:Name="_ipField" HorizontalAlignment="Left" Height="16" Margin="45,27,0,0" TextWrapping="Wrap" Text="" VerticalAlignment="Top" Width="155" TextChanged="OnIPFieldChanged" />
|
||||
<TextBox x:Name="_ipField" HorizontalAlignment="Left" Height="16" Margin="45,27,0,0" TextWrapping="Wrap" Text="" VerticalAlignment="Top" Width="155" TextChanged="OnIpFieldChanged" />
|
||||
<TextBlock x:Name="textBlock" HorizontalAlignment="Left" Margin="29,28,0,0" TextWrapping="Wrap" Text="IP" VerticalAlignment="Top"/>
|
||||
<TextBox x:Name="_idField" Height="16" Margin="294,27,92,0" TextWrapping="Wrap" Text="" VerticalAlignment="Top" />
|
||||
<TextBox x:Name="_idField" Height="16" Margin="294,27,92,0" TextWrapping="Wrap" Text="" VerticalAlignment="Top" TextChanged="OnIpFieldChanged" />
|
||||
<TextBlock x:Name="textBlock1" HorizontalAlignment="Left" Margin="205,28,0,0" TextWrapping="Wrap" Text="玩家名称 / 说明" VerticalAlignment="Top"/>
|
||||
<Button x:Name="_setIPButton" Content="上传" Margin="705,26,12,0" VerticalAlignment="Top" Click="OnClick"/>
|
||||
<DataGrid x:Name="_dataGrid" Margin="20,60,12,19">
|
||||
<DataGrid x:Name="_dataGrid"
|
||||
Margin="20,60,12,19"
|
||||
MouseDoubleClick="OnDataGridMouseDoubleClick"
|
||||
IsReadOnly="True"
|
||||
AutoGenerateColumns="False">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="IP" Binding="{Binding Path=IPString}"/>
|
||||
<DataGridTextColumn Header="玩家名称 / 说明" Binding="{Binding Path=ID}"/>
|
||||
<DataGridTextColumn Header="IP" Binding="{Binding Path=IpString}"/>
|
||||
<DataGridTextColumn Header="玩家名称 / 说明" Binding="{Binding Path=Id}"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Grid>
|
||||
|
133
Window1.xaml.cs
133
Window1.xaml.cs
@ -1,12 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AnotherReplayReader.Utils;
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using System.Web.Script.Serialization;
|
||||
using System.Windows;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
@ -16,113 +13,97 @@ namespace AnotherReplayReader
|
||||
/// </summary>
|
||||
internal partial class Window1 : Window
|
||||
{
|
||||
private PlayerIdentity _identity;
|
||||
|
||||
public Window1(PlayerIdentity identity)
|
||||
public Window1()
|
||||
{
|
||||
InitializeComponent();
|
||||
_identity = identity;
|
||||
Refresh();
|
||||
Refresh(true);
|
||||
}
|
||||
|
||||
private async void Refresh()
|
||||
private async void Refresh(bool showCached)
|
||||
{
|
||||
Dispatcher.Invoke(() => _setIPButton.IsEnabled = false);
|
||||
_setIPButton.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
var loading = new IpAndPlayer[] { new() { Ip = 0, Id = "正在加载..." } };
|
||||
_dataGrid.ItemsSource = loading;
|
||||
if (showCached)
|
||||
{
|
||||
_dataGrid.Items.Clear();
|
||||
_dataGrid.Items.Add(new IPAndPlayer { IP = 0, ID = "正在加载..." });
|
||||
});
|
||||
|
||||
await _identity.Fetch();
|
||||
|
||||
Display();
|
||||
await Display();
|
||||
_dataGrid.ItemsSource = loading.Concat(_dataGrid.ItemsSource.Cast<IpAndPlayer>());
|
||||
}
|
||||
await Display();
|
||||
}
|
||||
catch(Exception e)
|
||||
catch (Exception e)
|
||||
{
|
||||
Dispatcher.Invoke(() => MessageBox.Show(this, $"无法加载IP表:{e}"));
|
||||
MessageBox.Show(this, $"无法加载IP表:{e}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_setIPButton.IsEnabled = true;
|
||||
}
|
||||
Dispatcher.Invoke(() => _setIPButton.IsEnabled = true);
|
||||
}
|
||||
|
||||
private void Display(string filter = "")
|
||||
private async Task Display(string filter = "", string nameFilter = "")
|
||||
{
|
||||
var newList = _identity.AsSortedList().Where(x => x.IPString.StartsWith(filter));
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
_dataGrid.Items.Clear();
|
||||
foreach (var item in newList)
|
||||
{
|
||||
_dataGrid.Items.Add(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async void OnClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Dispatcher.Invoke(() => _setIPButton.IsEnabled = false);
|
||||
|
||||
await Task.Run(() =>
|
||||
_setIPButton.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
var ipText = Dispatcher.Invoke(() => _ipField.Text);
|
||||
|
||||
var ipText = _ipField.Text;
|
||||
if (!IPAddress.TryParse(ipText, out var ip))
|
||||
{
|
||||
Dispatcher.Invoke(() => MessageBox.Show(this, "IP格式不正确"));
|
||||
MessageBox.Show(this, "IP 格式不正确");
|
||||
return;
|
||||
}
|
||||
|
||||
var idText = Dispatcher.Invoke(() => _idField.Text);
|
||||
|
||||
var idText = _idField.Text;
|
||||
if (string.IsNullOrWhiteSpace(idText))
|
||||
{
|
||||
var result = Dispatcher.Invoke(() => MessageBox.Show(this, "你没填输入任何说明,是否确认继续?", "注意", MessageBoxButton.OKCancel));
|
||||
if(result != MessageBoxResult.OK)
|
||||
var choice = MessageBox.Show(this, "没有输入任何关于该玩家的说明,是否继续?", "注意", MessageBoxButton.OKCancel);
|
||||
if (choice != MessageBoxResult.OK)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
var result = await UpdateIpTable(ip, idText);
|
||||
if (!result)
|
||||
{
|
||||
var bytes = ip.GetAddressBytes();
|
||||
var ipNum = (uint)bytes[0] * 256 * 256 * 256 + bytes[1] * 256 * 256 + bytes[2] * 256 + bytes[3];
|
||||
var text = HttpUtility.UrlEncode(idText);
|
||||
|
||||
var key = HttpUtility.UrlEncode(Auth.GetKey());
|
||||
var request = WebRequest.Create($"https://lanyi.altervista.org/playertable/playertable.php?do=setIP&ip={ipNum}&id={text}&key={key}");
|
||||
|
||||
using (var stream = request.GetResponse().GetResponseStream())
|
||||
using (var reader = new StreamReader(stream))
|
||||
{
|
||||
var response = reader.ReadToEnd();
|
||||
var serializer = new JavaScriptSerializer();
|
||||
var result = serializer.Deserialize<bool>(response);
|
||||
if(!result)
|
||||
{
|
||||
Dispatcher.Invoke(() => MessageBox.Show(this, "设置IP表失败"));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Dispatcher.Invoke(() => MessageBox.Show(this, $"设置IP表时发生错误。\r\n{exception}"));
|
||||
MessageBox.Show(this, "设置 IP 表失败");
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
Refresh();
|
||||
Refresh(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
MessageBox.Show(this, $"设置 IP 表时发生错误。\r\n{exception}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_setIPButton.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnIPFieldChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
|
||||
private async Task<bool> UpdateIpTable(IPAddress ip, string idText)
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
var fieldText = Dispatcher.Invoke(() => _ipField.Text);
|
||||
Display(fieldText);
|
||||
});
|
||||
var bytes = ip.GetAddressBytes();
|
||||
var ipNum = (uint)bytes[0] * 256 * 256 * 256 + bytes[1] * 256 * 256 + bytes[2] * 256 + bytes[3];
|
||||
return false;
|
||||
}
|
||||
|
||||
private async void OnIpFieldChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
|
||||
{
|
||||
var ipText = _ipField.Text;
|
||||
var idText = _idField.Text;
|
||||
await Display(ipText, idText);
|
||||
}
|
||||
|
||||
private void OnDataGridMouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
76
app.manifest
Normal file
76
app.manifest
Normal file
@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<!-- UAC Manifest Options
|
||||
If you want to change the Windows User Account Control level replace the
|
||||
requestedExecutionLevel node with one of the following.
|
||||
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
|
||||
|
||||
Specifying requestedExecutionLevel element will disable file and registry virtualization.
|
||||
Remove this element if your application requires this virtualization for backwards
|
||||
compatibility.
|
||||
-->
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows Vista -->
|
||||
<!--<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />-->
|
||||
|
||||
<!-- Windows 7 -->
|
||||
<!--<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />-->
|
||||
|
||||
<!-- Windows 8 -->
|
||||
<!--<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />-->
|
||||
|
||||
<!-- Windows 8.1 -->
|
||||
<!--<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />-->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<!--<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />-->
|
||||
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
<!-- Indicates that the application is DPI-aware and will not be automatically scaled by Windows at higher
|
||||
DPIs. Windows Presentation Foundation (WPF) applications are automatically DPI-aware and do not need
|
||||
to opt in. Windows Forms applications targeting .NET Framework 4.6 that opt into this setting, should
|
||||
also set the 'EnableWindowsFormsHighDpiAutoResizing' setting to 'true' in their app.config.
|
||||
|
||||
Makes the application long-path aware. See https://docs.microsoft.com/windows/win32/fileio/maximum-file-path-limitation -->
|
||||
<!--
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
|
||||
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
-->
|
||||
|
||||
<!-- Enable themes for Windows common controls and dialogs (Windows XP and later) -->
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity
|
||||
type="win32"
|
||||
name="Microsoft.Windows.Common-Controls"
|
||||
version="6.0.0.0"
|
||||
processorArchitecture="*"
|
||||
publicKeyToken="6595b64144ccf1df"
|
||||
language="*"
|
||||
/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
</assembly>
|
95
autosave.txt
95
autosave.txt
@ -1,95 +0,0 @@
|
||||
private async Task AutoSaveReplays()
|
||||
{
|
||||
const string ourPrefix = "自动保存";
|
||||
|
||||
// filename and last write time
|
||||
Dictionary<string, DateTime> previousFiles = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
||||
// filename and file size
|
||||
Dictionary<string, long> lastReplays = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
while(true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var changed = from fileName in Directory.GetFiles(_properties.ReplayFolderPath, "*.RA3Replay")
|
||||
let info = new FileInfo(fileName)
|
||||
where !info.Name.StartsWith(ourPrefix)
|
||||
where !previousFiles.ContainsKey(info.FullName) || previousFiles[info.FullName] != info.LastWriteTimeUtc
|
||||
select info;
|
||||
|
||||
foreach (var info in changed)
|
||||
{
|
||||
previousFiles[info.FullName] = info.LastWriteTimeUtc;
|
||||
}
|
||||
|
||||
var replays = changed.Select(info =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return new Replay(info.FullName);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}).Where(replay => replay != null);
|
||||
|
||||
var newLastReplays = from replay in replays
|
||||
let threshold = Math.Abs((DateTime.UtcNow - replay.Date).TotalSeconds)
|
||||
where threshold < 20
|
||||
select replay;
|
||||
|
||||
var toBeChecked = newLastReplays.ToDictionary(replay => replay.FileName, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var savedLastReplay in lastReplays)
|
||||
{
|
||||
if (!toBeChecked.ContainsKey(savedLastReplay.Key))
|
||||
{
|
||||
try
|
||||
{
|
||||
toBeChecked.Add(savedLastReplay.Key, new Replay(savedLastReplay.Key));
|
||||
}
|
||||
catch(Exception)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var kv in toBeChecked)
|
||||
{
|
||||
var replay = kv.Value;
|
||||
if (lastReplays.TryGetValue(kv.Key, out var fileSize))
|
||||
{
|
||||
if (fileSize == replay.Size)
|
||||
{
|
||||
// skip if size is not changed
|
||||
continue;
|
||||
}
|
||||
}
|
||||
lastReplays[kv.Key] = replay.Size;
|
||||
|
||||
var date = replay.Date;
|
||||
var numberOfPlayers = replay.NumberOfPlayingPlayers;
|
||||
var playerString = $"{numberOfPlayers}名玩家";
|
||||
if (replay.NumberOfPlayingPlayers <= 2)
|
||||
{
|
||||
var playingPlayers = from player in replay.Players
|
||||
let faction = ModData.GetFaction(replay.Mod, player.FactionID)
|
||||
where faction.Kind != FactionKind.Observer
|
||||
select $"{player}({faction.Name})";
|
||||
playerString = playingPlayers.Aggregate((x, y) => x + y);
|
||||
}
|
||||
|
||||
var dateString = $"{date.Year}-{date.Month}-{date.Day}_{date.Hour}:{date.Minute}";
|
||||
|
||||
File.Copy(replay.FileName, $"{_properties.ReplayFolderPath}/{ourPrefix}-{playerString}{dateString}.RA3Replay");
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
_ = Dispatcher.InvokeAsync(() => MessageBox.Show($"自动保存录像时出现错误:\r\n{e}"));
|
||||
}
|
||||
|
||||
await Task.Delay(10 * 1000);
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user