This commit is contained in:
2026-06-21 14:42:04 +02:00
parent 22cfb1f0a9
commit a3b4cc530d
15 changed files with 2437 additions and 403 deletions

View File

@@ -1,4 +1,5 @@
using AnotherReplayReader.ReplayFile;
using AnotherReplayReader.Utils;
using OxyPlot;
using OxyPlot.Axes;
using System;
@@ -6,6 +7,8 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Windows;
using System.Windows.Threading;
namespace AnotherReplayReader.Apm
{
@@ -22,7 +25,7 @@ namespace AnotherReplayReader.Apm
CountClicks = countClicks;
}
public bool ShouldSkip(byte command)
public bool ShouldSkip(int command)
{
if (!CountUnknowns && ApmPlotter.IsUnknown(command))
{
@@ -42,19 +45,21 @@ namespace AnotherReplayReader.Apm
internal class ApmPlotter
{
private static readonly ConcurrentDictionary<byte, byte> UnknownCommands;
private static readonly ConcurrentDictionary<byte, byte> AutoCommands;
private static readonly ConcurrentDictionary<int, byte> UnknownCommands;
private static readonly ConcurrentDictionary<int, byte> AutoCommands;
private readonly ImmutableArray<(TimeSpan, ImmutableArray<CommandChunk>)> _commands;
public Replay Replay { get; }
public ImmutableArray<Player> Players { get; }
public ImmutableSortedDictionary<int, Player> PlayersMap { get; }
public ImmutableArray<Player> PlayersArray { get; }
public ImmutableArray<TimeSpan> PlayerLifes { get; }
public ImmutableArray<TimeSpan> StricterPlayerLifes { get; }
public TimeSpan ReplayLength { get; }
public ImmutableArray<(TimeSpan, ImmutableArray<CommandChunk>)> Commands => _commands;
static ApmPlotter()
{
static KeyValuePair<byte, byte> Create(byte x) => new(x, default);
static KeyValuePair<int, byte> Create(int x) => new(x, default);
UnknownCommands = new(RA3Commands.UnknownCommands.Select(Create));
AutoCommands = new(RA3Commands.AutoCommands.Select(Create));
}
@@ -68,32 +73,99 @@ namespace AnotherReplayReader.Apm
Replay = replay;
// player list without post Commentator
Players = (replay.Players.Last().PlayerName == Replay.PostCommentator
? replay.Players.Take(replay.Players.Length - 1)
: replay.Players).ToImmutableArray();
var queryFull = from chunk in body
where chunk.Type is 1
from command in CommandChunk.Parse(chunk)
select command;
var playersIndices = queryFull
.Select(x => x.PlayerIndex)
.Distinct()
.OrderBy(x => x)
.ToArray();
// if length mismatch and last is post commentator, remove post commentator from replay players
var replayPlayers = replay.Players;
var postCommentatorIndex = replayPlayers.FindIndex(p => p.PlayerName == Replay.PostCommentator);
if (playersIndices.Length + 1 == replayPlayers.Length && postCommentatorIndex is int index)
{
replayPlayers = replayPlayers.RemoveAt(index);
}
if (playersIndices.Length != replayPlayers.Length)
{
throw new InvalidOperationException(
$"Player count mismatch. Indices={playersIndices.Length}, Players={replayPlayers.Length}");
}
PlayersMap = playersIndices
.Zip(replayPlayers, (index, player) => new KeyValuePair<int, Player>(index, player))
.ToImmutableSortedDictionary();
PlayersArray = PlayersMap.Values.ToImmutableArray();
//var queryFull = from chunk in body
// where chunk.Type is 1
// from command in CommandChunk.Parse(chunk)
// select (chunk.Time, command);
//var sb = new System.Text.StringBuilder();
//var stringHashes = System.IO.File.ReadAllText(@"C:\Apps\RA3-MODSDK-X\builtmods\StringHashes.xml");
//XDocument doc = XDocument.Parse(stringHashes);
//XNamespace ns = "uri:ea.com:eala:asset";
//var table = doc
// .Descendants(ns + "StringHashTable")
// .FirstOrDefault(x => (string?)x.Attribute("id") == "StringHashBin_INSTANCEID");
//if (table == null)
// throw new InvalidOperationException("StringHashBin_INSTANCEID not found.");
//Dictionary<uint, string> hashTable = table
// .Elements(ns + "StringAndHash")
// .ToDictionary(
// x => uint.Parse(x.Attribute("Hash")!.Value),
// x => x.Attribute("Text")!.Value);
//foreach (var (time, command) in queryFull)
//{
// if (IsUnknown(command.CommandId) || IsAuto(command.CommandId))
// {
// continue;
// }
// var name = RA3Commands.GetCommandName(command.CommandId);
// sb.Append($"[{time}] 玩家 {command.PlayerIndex}{name}\r\n");
// foreach (var kv in command.Data)
// {
// // try to convert int32 or uint32 to hashes and retrieve text
// var textFromHash = string.Empty;
// uint hashValue = kv.Key switch
// {
// CommandArgumentType.Int32 => (uint)(int)kv.Value,
// CommandArgumentType.UInt32 => (uint)kv.Value,
// CommandArgumentType.UInt32_2 => (uint)kv.Value,
// _ => 0
// };
// if (hashTable.TryGetValue(hashValue, out var text))
// {
// sb.Append($" {text}\r\n");
// }
// else
// {
// sb.Append($" {kv.Key}: {kv.Value}\r\n");
// }
// }
//}
//流水账 = sb.ToString();
//System.IO.File.WriteAllText("流水账.txt", 流水账);
// 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());
select (chunk.Time, CommandChunk.Parse(chunk).ToImmutableArray());
_commands = query.ToImmutableArray();
var playerLifes = new TimeSpan[Players.Length];
var playerLifes = new SortedDictionary<int, TimeSpan>(PlayersMap.Keys.ToDictionary(x => x, _ => TimeSpan.Zero));
var threeSeconds = TimeSpan.FromSeconds(3);
var stricterLifes = new TimeSpan[Players.Length];
var stricterLifes = new SortedDictionary<int, TimeSpan>(PlayersMap.Keys.ToDictionary(x => x, _ => TimeSpan.Zero));
foreach (var (time, commands) in _commands)
{
var estimatedTime = time + threeSeconds;
@@ -101,32 +173,34 @@ namespace AnotherReplayReader.Apm
{
var commandId = command.CommandId;
var playerIndex = command.PlayerIndex;
if (commandId == 0x21)
if (playerIndex != -1)
{
if (playerLifes[playerIndex] < estimatedTime)
if (commandId == 0x221)
{
playerLifes[playerIndex] = estimatedTime;
if (playerLifes[playerIndex] < estimatedTime)
{
playerLifes[playerIndex] = estimatedTime;
}
}
}
else if (!IsUnknown(commandId) && !IsAuto(commandId) && playerIndex >= 0)
{
if (stricterLifes[playerIndex] < estimatedTime)
else if (!IsUnknown(commandId) && !IsAuto(commandId))
{
stricterLifes[playerIndex] = estimatedTime;
if (stricterLifes[playerIndex] < estimatedTime)
{
stricterLifes[playerIndex] = estimatedTime;
}
}
}
}
}
PlayerLifes = playerLifes.ToImmutableArray();
StricterPlayerLifes = stricterLifes.ToImmutableArray();
PlayerLifes = playerLifes.Values.ToImmutableArray();
StricterPlayerLifes = stricterLifes.Values.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 lists = new SortedDictionary<int, List<int>>(PlayersMap.Keys.ToDictionary(x => x, _ => new List<int>()));
var currentTime = TimeSpan.Zero;
var currentIndex = 0;
foreach (var (time, commands) in _commands)
@@ -138,7 +212,7 @@ namespace AnotherReplayReader.Apm
}
foreach (var command in commands)
{
if (options.ShouldSkip(command.CommandId) || command.PlayerIndex < 0)
if (options.ShouldSkip(command.CommandId))
{
continue;
}
@@ -154,44 +228,40 @@ namespace AnotherReplayReader.Apm
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();
return [.. lists.Select(l => l.Value.Select(Create).ToArray())];
}
public double[] CalculateAverageApm(ApmPlotterFilterOptions options)
{
var apm = new double[Players.Length];
var apm = new SortedDictionary<int, double>(PlayersMap.Keys.ToDictionary(x => x, _ => 0.0));
foreach (var (_, commands) in _commands)
{
foreach (var command in commands)
{
if (options.ShouldSkip(command.CommandId) || command.PlayerIndex < 0)
if (options.ShouldSkip(command.CommandId))
{
continue;
}
++apm[command.PlayerIndex];
}
}
for (var i = 0; i < apm.Length; ++i)
{
apm[i] /= PlayerLifes[i].TotalMinutes;
}
return apm;
return [.. apm.Select((kv, i) => kv.Value / PlayerLifes[i].TotalMinutes)];
}
public static double[] CalculateInstantApm(DataPoint[][] data, TimeSpan begin, TimeSpan end)
{
return data.Select(playerData =>
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)
public Dictionary<int, int[]> GetCommandCounts(TimeSpan begin, TimeSpan end)
{
var playerCommands = new Dictionary<byte, int[]>();
var playerCommands = new Dictionary<int, SortedDictionary<int, int>>();
foreach (var (time, commands) in _commands)
{
@@ -207,18 +277,15 @@ namespace AnotherReplayReader.Apm
{
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;
commandCount = playerCommands[command.CommandId] = new(PlayersMap.Keys.ToDictionary(x => x, _ => 0));
}
commandCount[command.PlayerIndex] = commandCount[command.PlayerIndex] + 1;
}
}
return playerCommands;
return playerCommands.ToDictionary(kv => kv.Key, kv => kv.Value.Values.ToArray());
}
public static bool IsUnknown(byte commandId)
public static bool IsUnknown(int commandId)
{
if (UnknownCommands.ContainsKey(commandId))
{
@@ -232,8 +299,8 @@ namespace AnotherReplayReader.Apm
return false;
}
public static bool IsAuto(byte commandId) => AutoCommands.ContainsKey(commandId);
public static bool IsAuto(int commandId) => AutoCommands.ContainsKey(commandId);
public static bool IsClick(byte commandId) => commandId is 0xF8 or 0xF5;
public static bool IsClick(int commandId) => commandId is 0x1F8 or 0x1F5;
}
}

View File

@@ -42,51 +42,73 @@ namespace AnotherReplayReader.Apm
out int apmRowIndex)
{
// these commands should appear in this order by default
var orderedCommands = new List<byte>();
var orderedCommands = new List<int>();
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,
0x1F5, // 选择
0x1F6,
0x1F8,
0x1F9,
0x22A,
0x1FA, // 编队
0x1FB,
0x1FC,
0x207, // 生产/建造
0x208,
0x205,
0x206,
0x209, // 摆放
0x20A, // 出售
0x203, // 升级
0x204,
0x20D, // 右键攻击
0x20E, // 强A
0x20F, // 强A
0x215, // 移动攻击
0x214, // 移动
0x236, // 倒车
0x216, // 碾压
0x22C, // 队形
0x21A, // 停止
0x21B, // 散开
0x24E, // 选择协议
0x1FE, // 释放特殊能力
0x1FF,
0x200,
0x201,
0x232,
0x22E, // 姿态
0x22F, // 计划模式
0x228, // 维修
0x229,
0x202, // 集结点
0x210, // 进驻建筑
0x20C, // 从建筑中撤出
0x248, // 采矿交矿
0x212,
0x24B, // 信标
0x24C,
0x24D,
0x1, // 退出地图
]);
orderedCommands.AddRange(RA3Commands.AutoCommands);
orderedCommands.AddRange(RA3Commands.UnknownCommands);
var dataList = new List<DataRow>
{
new("ID", plotter.Players.Select(x => x.PlayerName))
new("ID", plotter.PlayersArray.Select(x => x.PlayerName))
};
var isPartial = begin > TimeSpan.Zero || end <= plotter.ReplayLength;
if (isPartial)
@@ -105,14 +127,14 @@ namespace AnotherReplayReader.Apm
? "变身天眼帝国,或双手离开键盘"
: "可能已离开房间";
}
dataList.Add(new("存活状态(推测)", plotter.Players.Select(GetStatus)));
dataList.Add(new("存活状态(推测)", plotter.PlayersArray.Select(GetStatus)));
}
apmRowIndex = dataList.Count;
// kill-death ratio
if (plotter.Replay.Footer?.TryGetKillDeathRatios() is { } kdRatios)
{
var texts = kdRatios
.Take(plotter.Players.Length)
.Take(plotter.PlayersArray.Length)
.Select(x => $"{x:0.##}");
dataList.Add(new("击杀阵亡比(存疑)", texts));
}
@@ -123,7 +145,7 @@ namespace AnotherReplayReader.Apm
{
var counts = commandCounts.TryGetValue(command, out var stored)
? stored
: new int[plotter.Players.Length];
: new int[plotter.PlayersArray.Length];
if (!isPartial || counts.Any(x => x > 0))
{
dataList.Add(new(RA3Commands.GetCommandName(command),

View File

@@ -26,41 +26,61 @@
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>
<Grid DockPanel.Dock="Bottom"
Margin="16,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- LEFT SIDE CONTROLS -->
<StackPanel Grid.Column="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>
<!-- RIGHT SIDE BUTTON -->
<Button x:Name="_openEventDump"
Grid.Column="1"
Padding="8,2"
Content="打开流水账"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Click="OnOpenEventDumpClicked"/>
</Grid>
<ox:PlotView x:Name="_plot"
DataContext="{Binding ElementName=ApmWindowInstance, Path=PlotModel}"
Model="{Binding Model}" />

View File

@@ -93,7 +93,7 @@ namespace AnotherReplayReader
var instantApms = ApmPlotter.CalculateInstantApm(data, begin, end);
string PartialApmToString(double v, int i)
{
if (plotter.Players[i].IsComputer)
if (plotter.PlayersArray[i].IsComputer)
{
return "这是 AI";
}
@@ -168,7 +168,7 @@ namespace AnotherReplayReader
{
var series = new LineSeries
{
Title = _replay.Players[i].PlayerName,
Title = _plotter.Result.PlayersArray[i].PlayerName,
};
series.Points.AddRange(points);
model.Series.Add(series);
@@ -237,6 +237,15 @@ namespace AnotherReplayReader
await FilterDataAsync(TimeSpan.MinValue, TimeSpan.MaxValue, true);
}
}
private async void OnOpenEventDumpClicked(object sender, RoutedEventArgs e)
{
var plotter = await _plotter;
var dump = new EventDump();
dump.LoadStringHashes();
dump.SetDumpData(_replay.Mod, plotter);
await dump.ShowPlainText();
}
}
internal class ApmWindowPlotViewModel : INotifyPropertyChanged

127
EventDump.xaml Normal file
View File

@@ -0,0 +1,127 @@
<Window x:Class="AnotherReplayReader.EventDump"
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="流水账" Height="450" Width="800">
<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" />
<Label Content="压缩程度"
Target="_compactLevelComboBox"
VerticalAlignment="Center"
Margin="0,0,4,0" />
<ComboBox x:Name="_compactLevelComboBox"
Width="120"
SelectionChanged="OnCompactLevelComboBoxSelectionChanged" />
<Label x:Name="_tokenUsageLabel"
VerticalAlignment="Center"
Margin="4,0,0,0" />
<Button Padding="8,2"
Margin="0,0,10,0"
Content="AI分析"
Click="OnAIAnalyzeClick" />
</StackPanel>
<TabControl>
<TabItem x:Name="_eventTab"
Header="流水账">
<TextBox x:Name="_textBox"
TextWrapping="Wrap"
IsReadOnly="True"
IsUndoEnabled="False"
UndoLimit="0"
ScrollViewer.VerticalScrollBarVisibility="Auto" />
</TabItem>
<TabItem x:Name="_aiTab"
Header="AI分析结果">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="60*" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="40*" />
</Grid.ColumnDefinitions>
<!-- 左侧 -->
<TextBox x:Name="_aiTextBox"
Grid.Row="0"
Grid.Column="0"
TextWrapping="Wrap"
IsReadOnly="True"
IsUndoEnabled="False"
UndoLimit="0"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto"/>
<!-- splitter -->
<GridSplitter Grid.Row="0"
Grid.Column="1"
Width="5"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"/>
<!-- 右侧 -->
<TextBox x:Name="_aiReasoningTextBox"
Grid.Row="0"
Grid.Column="2"
TextWrapping="Wrap"
IsReadOnly="True"
IsUndoEnabled="False"
UndoLimit="0"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto"/>
<StatusBar Grid.Row="1" Grid.ColumnSpan="3">
<StatusBarItem>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80"/>
<ColumnDefinition Width="140"/>
<ColumnDefinition Width="160"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="180"/>
</Grid.ColumnDefinitions>
<!-- elapsed time -->
<TextBlock x:Name="_elapsedTimeTextBlock"
Grid.Column="0"
TextTrimming="CharacterEllipsis"/>
<!-- total content -->
<TextBlock x:Name="_contentCountTextBlock"
Grid.Column="1"
TextTrimming="CharacterEllipsis"/>
<!-- speed -->
<TextBlock x:Name="_rateTextBlock"
Grid.Column="2"
TextTrimming="CharacterEllipsis"/>
<!-- tokens details -->
<TextBlock x:Name="_tokensDetailsTextBlock"
Grid.Column="3"
TextTrimming="CharacterEllipsis"/>
<!-- extra status -->
<TextBlock x:Name="_extraStatusTextBlock"
Grid.Column="4"
TextTrimming="CharacterEllipsis"
HorizontalAlignment="Right"
TextAlignment="Right"/>
</Grid>
</StatusBarItem>
</StatusBar>
</Grid>
</TabItem>
</TabControl>
</DockPanel>
</Window>

575
EventDump.xaml.cs Normal file
View File

@@ -0,0 +1,575 @@
using AnotherReplayReader.Apm;
using AnotherReplayReader.ReplayFile;
using AnotherReplayReader.Utils;
using Microsoft.Win32;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Markup;
using System.Windows.Threading;
using System.Xml.Linq;
namespace AnotherReplayReader
{
/// <summary>
/// EventDump.xaml 的交互逻辑
/// </summary>
public sealed partial class EventDump : Window
{
public enum CompactLevel
{
NoCompact,
ForAI,
VeryCompactedForAI,
}
private class Model(
Mod mod,
ImmutableSortedDictionary<int, Player> players,
ImmutableArray<(TimeSpan, ImmutableArray<CommandChunk>)> commands,
CompactLevel level
)
{
public Mod Mod { get; } = mod;
public ImmutableSortedDictionary<int, Player> Players { get; } = players;
public ImmutableArray<(TimeSpan, ImmutableArray<CommandChunk>)> Commands { get; } = commands;
public CompactLevel Level { get; } = level;
public ImmutableSortedDictionary<int, string>? PlayersNamesForAI { get; } = level <= CompactLevel.NoCompact
? null
: AIAnalyze.PlayerNamesForAI(mod, players);
public bool IsDefault => Players.IsEmpty && Commands.IsEmpty;
public Model() : this(
new("RA3"),
ImmutableSortedDictionary<int, Player>.Empty,
ImmutableArray<(TimeSpan, ImmutableArray<CommandChunk>)>.Empty, CompactLevel.NoCompact
)
{
}
public string PlayerNameByPlayerListIndex(int playerId)
{
var playerRawName = $"玩家#{playerId}";
var playerName = Players.TryGetValue(playerId, out var player)
? player.PlayerName
: playerRawName;
if (PlayersNamesForAI?.TryGetValue(playerId, out var playerNameForAI) is true)
{
playerName = playerNameForAI;
}
return playerName;
}
public string PlayerNameByGameSlotIndex(int index) => PlayerNameByPlayerListIndex(Players.ElementAt(index).Key);
}
private delegate string? ToStringHook(int argumentIndex, CommandArgumentType type, int elementIndex, object value, string currentTextValue);
private static readonly Regex _matchHotkey = new("^(.*)(左右键|[A-Za-z]+)$");
private static ImmutableDictionary<uint, string> _stringHashes = ImmutableDictionary<uint, string>.Empty;
private readonly CancellationTokenSource _cancellation = new();
private Model _model = new();
private string? _cached;
private DateTimeOffset _aiStartTime;
public EventDump()
{
InitializeComponent();
Closing += EventDump_Closing;
Closed += EventDump_Closed;
// add enum values of CompactLevel to _playerComboBox
var values = Enum.GetValues(typeof(CompactLevel)).Cast<CompactLevel>();
foreach (var value in values)
{
_compactLevelComboBox.Items.Add(value.ToString());
}
_compactLevelComboBox.SelectedIndex = (int)CompactLevel.VeryCompactedForAI;
}
private void EventDump_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
try
{
_cancellation.Cancel();
}
catch (Exception ex)
{
Debug.Instance.DebugMessage += $"EventDump_Closing: {ex}\r\n";
}
}
private void EventDump_Closed(object sender, EventArgs e)
{
try
{
_cancellation.Cancel();
}
catch (Exception ex)
{
Debug.Instance.DebugMessage += $"EventDump_Closed: {ex}\r\n";
}
}
public void LoadStringHashes()
{
var stringHashes = File.ReadAllText(@"C:\Apps\RA3-MODSDK-X\builtmods\StringHashes.xml");
XDocument doc = XDocument.Parse(stringHashes);
XNamespace ns = "uri:ea.com:eala:asset";
var table = doc
.Descendants(ns + "StringHashTable")
.FirstOrDefault(x => (string?)x.Attribute("id") == "StringHashBin_INSTANCEID");
if (table == null)
throw new InvalidOperationException("StringHashBin_INSTANCEID not found.");
_stringHashes = table
.Elements(ns + "StringAndHash")
.ToImmutableDictionary(
x => uint.Parse(x.Attribute("Hash")!.Value),
x => x.Attribute("Text")!.Value);
}
internal void SetDumpData(Mod mod, ApmPlotter plotter)
{
var level = (CompactLevel)_compactLevelComboBox.SelectedIndex;
_model = new Model(mod, plotter.PlayersMap, plotter.Commands, level);
}
public async Task ShowPlainText()
{
_cached = null;
if (_model.IsDefault)
{
_textBox.Text = "";
_tokenUsageLabel.Content = "";
return;
}
var playerData = _model.Players.Values.Select(x =>
{
var factionName = ModData.GetFaction(_model.Mod, x.FactionId).Name;
return $"{x.PlayerName},队伍{x.Team}{factionName}";
});
//await analyzer.AnalyzeAsync("deepseek-v4-flash",
// AIAnalyze.GetSystemPrompt(_model.Mod, _model.Players),
// AIAnalyze.BuildUserPrompt(_model.Mod, _model.Players, text),
// deepSeekExtraParams);
_textBox.Text = "正在加载,请稍候";
_tokenUsageLabel.Content = "";
Show();
var text = await Task.Run(() => GeneratePlainText(_model));
var (bytesCount, estimatedTokenCount) = AIAnalyze.EstimateTokenCount(text);
_textBox.Text = text;
_cached = text;
// display KB and K tokens in _tokenUsageLabel
_tokenUsageLabel.Content = $"大小: {bytesCount / 1024.0:0.00} KiB估计Token数: {estimatedTokenCount / 1000.0:0.00} K";
}
private static string GeneratePlainText(Model model)
{
var sb = new StringBuilder();
for (int chunkIndex = 0; chunkIndex < model.Commands.Length; ++chunkIndex)
{
var (time, commands) = model.Commands[chunkIndex];
var filtered = commands
.Where((c, i) => ShouldDisplay(model.Level, commands, i))
.ToList();
if (filtered.Count == 0)
{
continue;
}
sb.AppendLine($"[{TimeStampToString(time, model.Level)}]");
foreach (var command in filtered)
{
var commandName = RA3Commands.GetCommandName(command.CommandId);
if (model.Level > CompactLevel.NoCompact)
{
commandName = command.CommandId switch
{
0x1F5 => ((bool[])command.Data[0].Value)[0] switch
{
true when command.Data.Length <= 1 || command.Data[1].Count == 0 => "取消选择",
true => "重新选择单位",
false => "追加选择单位"
},
0x22E => "切换姿态",
_ => CommandNameRemoveDescription(commandName),
};
}
var playerName = model.PlayerNameByPlayerListIndex(command.PlayerIndex);
sb.AppendLine($"{playerName}: {commandName}");
AppendCommandArguments(sb, command, (i, type, j, value, text) => command.CommandId switch
{
0x1F5 when i == 0 => j switch
{
0 when model.Level > CompactLevel.NoCompact => null,
0 => (bool)value ? "替换现有选择" : "加入到当前选择",
1 => null,
_ => throw new NotImplementedException(),
},
0x1F8 when i == 0 && model.Level > CompactLevel.NoCompact => null,
0x205 or 0x206 when i == 2 => (bool)value ? "连续5个" : null,
0x205 or 0x206 when i == 3 => $"序列:{ProductionQueueTypeToString((int)value)}",
0x207 when i == 1 && j == 1 => $"序列:{ProductionQueueTypeToString((int)value)}",
0x207 or 0x208 or 0x209 when i == 0 => $"{text}(建造者)",
0x517 or 0x518 when i == 0 => $"{text}(出兵建筑)",
0x252 => $"{model.PlayerNameByGameSlotIndex((int)command.Data[0].Value)}已主动退出游戏",
0x22E when i == 0 => j == 0 ? StanceToString((int)value) : null,
_ => text,
});
sb.AppendLine();
}
}
return sb.ToString();
}
private static bool ShouldDisplay(CompactLevel level, ImmutableArray<CommandChunk> commands, int i)
{
var chunk = commands[i];
var commandId = chunk.CommandId;
if (commandId is 0x1 or 0x252)
{
return true;
}
if (ApmPlotter.IsUnknown(commandId))
{
return false;
}
if (ApmPlotter.IsAuto(commandId))
{
return false;
}
if (commandId is 0x1F5) // 选择单位
{
var isNewSelection = ((bool[])chunk.Data[0].Value)[0];
if (chunk.Data.Length <= 1 || chunk.Data[1].Count == 0)
{
// 空选择:
// 假如是追加到当前选择,那么等于无操作,没什么意义,可以过滤掉
// 假如是新建选择,那么等于取消当前选择,在最高 compact level 下也可以过滤掉
return level switch
{
<= CompactLevel.NoCompact => true,
CompactLevel.ForAI => isNewSelection,
>= CompactLevel.VeryCompactedForAI => false,
};
}
}
if (level >= CompactLevel.ForAI && commandId is 0x1F8) // 取消选择
{
if (level >= CompactLevel.VeryCompactedForAI)
{
return false;
}
var hasSelectGroupAfterThis = commands
.Skip(i + 1)
.Any(c => c.PlayerIndex == chunk.PlayerIndex && c.CommandId == 0x1FB);
if (hasSelectGroupAfterThis)
{
return false;
}
}
return true;
}
private static string TimeStampToString(TimeSpan t, CompactLevel level) => level switch
{
<= CompactLevel.NoCompact => $"{t:hh\\:mm\\:ss\\.ff}",
_ => $"{(int)t.TotalMinutes}:{t:ss\\.ff}",
};
private static string CommandNameRemoveDescription(string commandName)
{
var match = _matchHotkey.Match(commandName);
if (match.Success)
{
commandName = match.Groups[1].Value;
}
return commandName;
}
private static string ProductionQueueTypeToString(int value) => value switch
{
0 => "主要建筑",
1 => "其他建筑",
2 => "步兵",
3 => "载具",
4 => "飞行器",
5 => "升级",
6 => "舰船",
_ => $"无效({value})"
};
private static string StanceToString(int value) => value switch
{
0 => "Guard",
1 => "Aggressive",
2 => "HoldPosition",
3 => "HoldFire",
_ => $"Unknown({value})"
};
private static void AppendCommandArguments(StringBuilder sb, CommandChunk command, ToStringHook hook)
{
var count = 0;
foreach (var (argType, argValue, argCount) in command.Data)
{
++count;
var prefix = argType is CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2
? "[UnitId]"
: string.Empty;
if (argCount == 1)
{
var textValue = CommandArgumentToString(argType, argValue);
textValue = hook(count - 1, argType, 0, argValue, textValue);
if (textValue is not null)
{
sb.AppendLine($" {prefix}{textValue}");
}
}
else
{
var textValues = ((Array)argValue).Cast<object>().Select((x, i) =>
{
var textValue = CommandArgumentToString(argType, x);
textValue = hook(count - 1, argType, i, x, textValue);
return textValue;
});
if (textValues.All(x => x is null))
{
continue;
}
sb.AppendLine($" {prefix}{string.Join(",", textValues.Where(x => x is not null))}");
}
}
}
private static string CommandArgumentToString(CommandArgumentType type, object value)
{
return type switch
{
CommandArgumentType.Int32 => Int32BitToString((int)value),
CommandArgumentType.UInt32 or CommandArgumentType.UInt32_2 => Int32BitToString((uint)value),
CommandArgumentType.Float32 => ((float)value).ToString("0.##"),
CommandArgumentType.Bool
or CommandArgumentType.UInt16
or CommandArgumentType.ObjectId
or CommandArgumentType.ObjectId_2
or CommandArgumentType.AsciiString
or CommandArgumentType.UnicodeString
or CommandArgumentType.AssetId
or CommandArgumentType.Vector3 => value.ToString(),
_ => throw new InvalidOperationException($"Unknown argument type {value}"),
};
}
private static string Int32BitToString<T>(T value) where T : struct
{
// try to convert int32 or uint32 to hashes and retrieve text
uint hashValue = value switch
{
int intValue => unchecked((uint)intValue),
uint uintValue => uintValue,
_ => throw new InvalidOperationException($"Unexpected type {typeof(T)}"),
};
return _stringHashes.TryGetValue(hashValue, out var text) ? text : hashValue.ToString();
}
private void OnExportButtonClick(object sender, RoutedEventArgs e) => Export(this);
private void Export(Window owner)
{
Dispatcher.Invoke(() =>
{
var saveFileDialog = new SaveFileDialog
{
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);
}
});
}
private async void OnCompactLevelComboBoxSelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
{
var selectedLevel = (CompactLevel)_compactLevelComboBox.SelectedIndex;
_model = new Model(_model.Mod, _model.Players, _model.Commands, selectedLevel);
await ShowPlainText();
}
private async void OnAIAnalyzeClick(object sender, RoutedEventArgs e)
{
var cached = _cached;
if (cached is null)
{
MessageBox.Show(this, "请先生成文本");
return;
}
_aiStartTime = DateTimeOffset.MaxValue;
_elapsedTimeTextBlock.Text = "";
_contentCountTextBlock.Text = "";
_rateTextBlock.Text = "";
_tokensDetailsTextBlock.Text = "";
_extraStatusTextBlock.Text = "";
var pending = new ConcurrentQueue<AIAnalyzeUI.AIAnalyzeProgressData>();
var emaSpeedCalculator = new AIAnalyzeUI.EmaSpeed();
var totalCharacters = 0;
void TimerUpdateStatus(object sender, EventArgs ea)
{
if (_aiStartTime == DateTimeOffset.MaxValue)
{
_elapsedTimeTextBlock.Text = "";
return;
}
var buffer = new Dictionary<AIAnalyze.AIChunkType, (TextBox Target, StringBuilder Text)>
{
[AIAnalyze.AIChunkType.Content] = (_aiTextBox, new StringBuilder()),
[AIAnalyze.AIChunkType.Reasoning] = (_aiReasoningTextBox, new StringBuilder()),
};
while (pending.TryDequeue(out var result))
{
var (delta, timestamp, isExtra) = result;
buffer[delta.Type].Text.Append(delta.Text);
if (!isExtra)
{
totalCharacters += delta.Text.Length;
}
emaSpeedCalculator.ProcessEvent(result);
}
var now = DateTimeOffset.UtcNow;
var elapsed = now - _aiStartTime;
_elapsedTimeTextBlock.Text = $"{elapsed:mm\\:ss}";
_contentCountTextBlock.Text = $"内容字数: {totalCharacters}";
var display = emaSpeedCalculator.GetDisplaySpeed(now);
_rateTextBlock.Text = $"{display:0.00} 字/秒;平均{totalCharacters / elapsed.TotalSeconds:0.00} 字/秒";
foreach (var kv in buffer)
{
var (target, text) = kv.Value;
if (text.Length > 0)
{
target.AppendText(text.ToString());
text.Clear();
}
}
}
var timer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(0.1),
};
timer.Tick += TimerUpdateStatus;
timer.Start();
try
{
await LaunchAIAnalyze(cached, pending.Enqueue);
TimerUpdateStatus(this, EventArgs.Empty);
}
catch (Exception ex)
{
MessageBox.Show(this, $"AI分析失败: {ex}");
}
finally
{
_aiStartTime = DateTimeOffset.MaxValue;
timer.Stop();
}
}
private async Task LaunchAIAnalyze(string replayData, Action<AIAnalyzeUI.AIAnalyzeProgressData> newContent)
{
void AddExtraContent(string message)
{
var delta = new AIAnalyze.AIChunk
{
Type = AIAnalyze.AIChunkType.Reasoning,
Text = message,
};
newContent(new(Delta: delta, TimeStamp: null, IsExtra: true));
delta.Type = AIAnalyze.AIChunkType.Content;
newContent(new(Delta: delta, TimeStamp: null, IsExtra: true));
}
// var analyzer = new AIAnalyze("https://integrate.api.nvidia.com/v1/", "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");
var analyzer = new AIAnalyze("https://integrate.api.nvidia.com/v1/", "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");
var deepSeekExtraParams = new Dictionary<string, object>
{
["temperature"] = 0.75,
["top_p"] = 0.95,
["max_tokens"] = 16384,
["chat_template_kwargs"] = new
{
thinking = true,
reasoning_effort = "high"
},
["reasoning_effort"] = "high",
["thinking"] = new { type = "enabled" }
};
var systemPrompt = AIAnalyze.GetSystemPrompt(_model.Mod, _model.Players);
var userPrompt = AIAnalyze.BuildUserPrompt(_model.Mod, _model.Players, replayData);
#region info
var (systemPromptSize, systemPromptTokenCount) = AIAnalyze.EstimateTokenCount(systemPrompt);
var (userPromptSize, userPromptTokenCount) = AIAnalyze.EstimateTokenCount(userPrompt);
var totalPromptSize = systemPromptSize + userPromptSize;
var totalPromptTokenCount = systemPromptTokenCount + userPromptTokenCount;
var estimateText = $"系统提示大小: {systemPromptSize / 1024.0:0.00}KiB估计Token数: {systemPromptTokenCount / 1000.0:0.00}K\r\n" +
$"用户提示大小: {userPromptSize / 1024.0:0.00} KiB估计Token数: {userPromptTokenCount / 1000.0:0.00} K\r\n" +
$"总大小: {totalPromptSize / 1024.0:0.00} KiB估计总Token数: {totalPromptTokenCount / 1000.0:0.00} K\r\n";
MessageBox.Show(this, estimateText, "提示", MessageBoxButton.OK, MessageBoxImage.Information);
#endregion
_extraStatusTextBlock.Text = "AI正在了解录像……";
_aiStartTime = DateTimeOffset.UtcNow;
var firstReader = AIAnalyzeUI.BuildAIChunkReader(newContent);
var result = await Task.Run(() => analyzer.AnalyzeAsync("deepseek-ai/deepseek-v4-flash",
systemPrompt,
userPrompt,
deepSeekExtraParams,
firstReader,
_cancellation.Token
));
_tokensDetailsTextBlock.Text = $"Token: {result.TotalTokens}(输入{result.PromptTokens}";
AddExtraContent("\r\n--- // 开始分段分析\r\n");
while (result.CurrentSegment < result.Segments.Count)
{
var currentSegmentName = $"{result.CurrentSegment + 1}";
_extraStatusTextBlock.Text = $"AI正在分析录像{currentSegmentName}/{result.Segments.Count}";
var segmentReader = AIAnalyzeUI.BuildAIChunkReader(newContent);
result = await Task.Run(() => analyzer.ContinueAnalyzeAsync(
segmentReader,
_cancellation.Token
));
_tokensDetailsTextBlock.Text = $"Token: {result.TotalTokens}(输入{result.PromptTokens}";
AddExtraContent($"\r\n--- // 第{currentSegmentName}段已分析完毕\r\n");
}
}
}
}

View File

@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices.ComTypes;
using System.Threading.Tasks;
using System.Windows.Media;
using System.Windows.Media.Imaging;
@@ -44,7 +45,29 @@ namespace AnotherReplayReader
public BitmapSource? TryReadTarga(string path, Mod mod, double dpiX = 96.0, double dpiY = 96.0)
{
using var targa = TryGetTarga(path, mod);
using var memoryStream = new MemoryStream();
{
using var stream = TryGetStream(path, mod);
if (stream is null)
{
return null;
}
stream.CopyTo(memoryStream);
}
bool isJpgPng = IsJpgPng(memoryStream);
memoryStream.Position = 0;
if (isJpgPng)
{
try
{
var decoder = BitmapDecoder.Create(memoryStream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
return decoder.Frames[0];
}
catch { }
}
memoryStream.Position = 0;
using var targa = Targa.Create(memoryStream, new PfimConfig());
if (targa == null)
{
return null;
@@ -63,7 +86,7 @@ namespace AnotherReplayReader
}
}
private Targa? TryGetTarga(string path, Mod mod)
private Stream? TryGetStream(string path, Mod mod)
{
const string customMapPrefix = "data/maps/internal/";
if (Directory.Exists(_mapFolderPath) && path.StartsWith(customMapPrefix))
@@ -71,7 +94,7 @@ namespace AnotherReplayReader
var minimapPath = Path.Combine(_mapFolderPath, path.Substring(customMapPrefix.Length));
if (File.Exists(minimapPath))
{
return Targa.Create(File.ReadAllBytes(minimapPath), new PfimConfig());
return File.OpenRead(minimapPath);
}
}
@@ -93,8 +116,7 @@ namespace AnotherReplayReader
{
continue;
}
using var stream = fs.OpenStream(path, VirtualFileModeType.Open);
return Targa.Create(stream, new PfimConfig());
return fs.OpenStream(path, VirtualFileModeType.Open);
}
catch (Exception exception)
{
@@ -105,13 +127,38 @@ namespace AnotherReplayReader
if (_cache != null && _cache.TryGetEntry(path, out var big))
{
using (big)
{
return Targa.Create(big, new PfimConfig());
}
return big;
}
return null;
}
private static bool IsJpgPng(Stream stream)
{
var header = new byte[4];
var pos = stream.Position;
stream.Read(header, 0, header.Length);
stream.Position = pos;
// PNG
if (header[0] == 0x89 &&
header[1] == 0x50 &&
header[2] == 0x4E &&
header[3] == 0x47)
{
return true;
}
// JPEG
if (header[0] == 0xFF &&
header[1] == 0xD8)
{
return true;
}
// TGA很粗略判断通常无统一magic只能 fallback
return false;
}
}
}

View File

@@ -1,13 +1,41 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
namespace AnotherReplayReader.ReplayFile
{
internal sealed class CommandChunk
public enum CommandArgumentType
{
public byte CommandId { get; private set; }
Int32 = 0,
Float32 = 1,
Bool = 2,
AssetId = 3,
ObjectId = 4,
ObjectId_2 = 5,
UInt32 = 6,
Vector3 = 7,
UInt32_2 = 8,
UInt16 = 9,
AsciiString = 10,
UnicodeString = 11,
}
public record struct CommandArgumentEntry(CommandArgumentType Type, object Value, int Count);
public record struct Vector3(float X, float Y, float Z)
{
public override readonly string ToString() => $"(X={Math.Round(X)},Y={Math.Round(Y)},Z={Math.Round(Z)})";
}
public record struct AssetId(uint TypeId, uint InstanceId)
{
public override readonly string ToString() => $"(TypeId={TypeId:X},InstanceId={InstanceId:X})";
}
public sealed class CommandChunk
{
public int CommandId { get; private set; }
public int PlayerIndex { get; private set; }
public ImmutableArray<CommandArgumentEntry> Data { get; private set; }
public static List<CommandChunk> Parse(ReplayChunk chunk)
{
@@ -16,11 +44,6 @@ namespace AnotherReplayReader.ReplayFile
throw new NotImplementedException();
}
static int ManglePlayerId(byte id)
{
return id / 8 - 2;
}
using var reader = chunk.GetReader();
if (reader.ReadByte() != 1)
{
@@ -31,13 +54,15 @@ namespace AnotherReplayReader.ReplayFile
var numberOfCommands = reader.ReadInt32();
for (var i = 0; i < numberOfCommands; ++i)
{
var commandId = reader.ReadByte();
var playerId = reader.ReadByte();
reader.ReadCommandData(commandId);
var commandIdAndPlayerId = reader.ReadUInt16();
var commandId = commandIdAndPlayerId & 0x7FF;
var playerId = commandIdAndPlayerId >> 11;
var data = reader.ReadCommandData();
list.Add(new CommandChunk
{
CommandId = commandId,
PlayerIndex = ManglePlayerId(playerId)
PlayerIndex = playerId,
Data = data.ToImmutableArray(),
});
}
@@ -54,4 +79,5 @@ namespace AnotherReplayReader.ReplayFile
return $"[玩家 {PlayerIndex}{RA3Commands.GetCommandName(CommandId)}]";
}
}
}

View File

@@ -2,7 +2,7 @@
namespace AnotherReplayReader.ReplayFile
{
internal sealed class Player
public sealed class Player
{
public static readonly IReadOnlyDictionary<string, string> ComputerNames = new Dictionary<string, string>
{

View File

@@ -2,288 +2,220 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text;
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[]
private interface IArgumentReader
{
0x0F, // unk
0x5F, // unk
0x12, // unk
0x1B, // unk
0x48, // unk
0x52, // unk
0xFC, // unk
0xFD, // unk
object Read(BinaryReader reader, int count);
}
private sealed class ArgumentReader<T> : IArgumentReader
{
private readonly Func<BinaryReader, T> _reader;
public ArgumentReader(Func<BinaryReader, T> reader) => _reader = reader;
public object Read(BinaryReader reader, int count)
{
if (count == 1)
{
return _reader(reader)!;
}
var result = new T[count];
for (int i = 0; i < count; i++)
{
result[i] = _reader(reader);
}
return result;
}
}
private static readonly Dictionary<CommandArgumentType, IArgumentReader> _readers = new()
{
[CommandArgumentType.Int32] = new ArgumentReader<int>(r => r.ReadInt32()),
[CommandArgumentType.Float32] = new ArgumentReader<float>(r => r.ReadSingle()),
[CommandArgumentType.Bool] = new ArgumentReader<bool>(r => r.ReadByte() != 0),
[CommandArgumentType.UInt16] = new ArgumentReader<ushort>(r => r.ReadUInt16()),
[CommandArgumentType.UInt32] = new ArgumentReader<uint>(r => r.ReadUInt32()),
[CommandArgumentType.UInt32_2] = new ArgumentReader<uint>(r => r.ReadUInt32()),
[CommandArgumentType.ObjectId] = new ArgumentReader<uint>(r => r.ReadUInt32()),
[CommandArgumentType.ObjectId_2] = new ArgumentReader<uint>(r => r.ReadUInt32()),
[CommandArgumentType.Vector3] = new ArgumentReader<Vector3>(ReadVector3),
[CommandArgumentType.AssetId] = new ArgumentReader<AssetId>(ReadAssetId),
[CommandArgumentType.AsciiString] = new ArgumentReader<string>(r => ReadString(r, CommandArgumentType.AsciiString)),
[CommandArgumentType.UnicodeString] = new ArgumentReader<string>(r => ReadString(r, CommandArgumentType.UnicodeString)),
};
private static readonly Dictionary<int, string> _commandNames;
public static ImmutableArray<int> UnknownCommands { get; } = new[]
{
0x1FD, // unk
0x25F, // unk
}.ToImmutableArray();
public static ImmutableArray<byte> AutoCommands { get; } = new byte[]
public static ImmutableArray<int> AutoCommands { get; } = new[]
{
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
0x1,
0x221, // 3 seconds heartbeat
0x233, // uuid
0x234, // uuid
0x235, // player info
0x237, // indeterminate autogen
0x247, // 5th frame auto gen
0x252, // another player quits
}.ToImmutableArray();
static RA3Commands()
{
Action<BinaryReader> FixedSizeParser(byte command, int size)
_commandNames = new()
{
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}");
}
};
}
[0x1] = "[游戏结束]", // 1
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;
[0x1F5] = "选择单位", // 501
[0x1F6] = "选择相同单位W", // 502
[0x1F8] = "取消选择", // 504
[0x1F9] = "从选择中移除单位", // 505
[0x1FA] = "创建编队", // 506
[0x1FB] = "选择编队", // 507
[0x1FC] = "将编队加入选择", // 508
[0x1FD] = "(未知指令 0x1FD", // 509
[0x1FE] = "释放特殊能力(无目标)", // 510
[0x1FF] = "释放特殊能力(指定位置)", // 511
[0x200] = "释放特殊能力(指定位置和角度)", // 512
[0x201] = "释放特殊能力(指定目标)", // 513
[0x202] = "设置集结点", // 514
[0x203] = "开始升级", // 515
[0x204] = "暂停/中止升级", // 516
[0x205] = "开始出兵", // 517
[0x206] = "暂停/取消出兵", // 518
[0x207] = "开始建造", // 519
[0x208] = "暂停/取消建造", // 520
[0x209] = "摆放建筑", // 521
[0x20A] = "出售建筑", // 522
// 523
[0x20C] = "从进驻的建筑撤出(?)", // 524
[0x20D] = "集火攻击", // 525
[0x20E] = "强制攻击单位Ctrl", // 526
[0x20F] = "强制攻击地板Ctrl", // 527
[0x210] = "进驻建筑", // 528
// 529
[0x212] = "命令矿车交矿", // 530
// 531
[0x214] = "移动", // 532
[0x215] = "行进攻击A", // 533
[0x216] = "强制移动/碾压G", // 534
// 535
// 536
// 537
[0x21A] = "停止S", // 538
[0x21B] = "散开X", // 539
var size = ((x >> 4) + 1) * 4;
totalBytes += current.ReadBytes(size).Length;
}
totalBytes += 1;
var chk = totalBytes;
};
// 0x21E 542 有可能是 AI 信标相关的?
[0x221] = "[游戏每3秒自动产生的检测不同步指令]", // 545
[0x228] = "开始维修建筑", // 552
[0x229] = "停止维修建筑", // 553
[0x22A] = "选择所有单位Q", // 554
// 555
[0x22C] = "队形操作(左右键)", // 556
// 557
[0x22E] = "切换姿态(警戒/侵略/固守/停火模式)", // 558
[0x22F] = "路径点模式/计划模式Alt", // 559
// 560
// 561
[0x232] = "释放特殊能力(一个或多个目标)", // 562
[0x233] = "[游戏自动生成的UUID指令]", // 563
[0x234] = "[游戏自动产生的UUID]", // 564
[0x235] = "[玩家信息(?)]", // 565
[0x236] = "倒车D", // 566
[0x237] = "[游戏不定期自动产生的指令]", // 567
[0x247] = "[游戏在第五帧自动产生的指令]", // 583
[0x248] = "让矿车去采矿", // 584
[0x24B] = "信标", // 587
[0x24C] = "删除信标", // 588
[0x24D] = "在信标里输入文字", // 589
[0x24E] = "选择协议", // 590
[0x252] = "[其他玩家主动退出游戏]", // 594
[0x25F] = "(未知指令 0x25F", // 607
};
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)
public static List<CommandArgumentEntry> ReadCommandData(this BinaryReader reader)
{
if (_commandParser.TryGetValue(commandId, out var parser))
var list = new List<CommandArgumentEntry>();
while (true)
{
_commandParser[commandId](reader);
}
else
{
UnknownCommandParser(reader, commandId);
byte head = reader.ReadByte();
if (head == 0xFF)
break;
int count = (head >> 4) + 1;
var type = (CommandArgumentType)(head & 0xF);
var value = _readers[type].Read(reader, count);
list.Add(new CommandArgumentEntry(type, value, count));
}
return list;
}
public static bool IsUnknownCommand(byte commandId)
public static bool IsUnknownCommand(int commandId)
{
return !_commandNames.ContainsKey(commandId);
}
public static string GetCommandName(byte commandId)
public static string GetCommandName(int commandId)
{
return _commandNames.TryGetValue(commandId, out var storedName)
? storedName
return _commandNames.TryGetValue(commandId, out var storedName)
? storedName
: $"(未知指令 0x{commandId:X2}";
}
public static void UnknownCommandParser(BinaryReader current, byte commandId)
private static AssetId ReadAssetId(BinaryReader reader)
{
while (true)
{
var value = current.ReadByte();
if (value == 0xFF)
{
break;
}
}
//return $"(未知指令 0x{commandID:2X}";
var version = reader.ReadByte();
var typeId = reader.ReadUInt32();
var instanceId = reader.ReadUInt32();
return new(TypeId: typeId, InstanceId: instanceId);
}
private static void ParseSpecialChunk0x01(BinaryReader current)
private static Vector3 ReadVector3(BinaryReader reader)
{
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;
var x = reader.ReadSingle();
var y = reader.ReadSingle();
var z = reader.ReadSingle();
return new(X: x, Y: y, Z: z);
}
private static void ParseSetRallyPoint0x02(BinaryReader current)
private static string ReadString(BinaryReader current, CommandArgumentType type)
{
var size = (current.ReadBytes(23).Last() + 1) * 2 + 1;
var lastByte = current.ReadBytes(size).Last();
if (lastByte != 0xFF)
var length = (int)current.ReadByte();
if (length == 0xFF)
{
throw new InvalidDataException();
length = current.ReadInt32();
}
}
private static void ParseUngarrison0x0C(BinaryReader current)
{
current.ReadByte();
var size = (current.ReadByte() + 1) * 4 + 1;
var lastByte = current.ReadBytes(size).Last();
if (lastByte != 0xFF)
// read byte string or wchar_t string based on T type
switch (type)
{
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();
case CommandArgumentType.AsciiString:
return Encoding.UTF8.GetString(current.ReadBytes(length));
case CommandArgumentType.UnicodeString:
return Encoding.Unicode.GetString(current.ReadBytes(length * 2));
}
throw new InvalidDataException($"Invalid string type {type}");
}
}
}

View File

@@ -3,7 +3,7 @@ using System.IO;
namespace AnotherReplayReader.ReplayFile
{
internal sealed class ReplayChunk
public sealed class ReplayChunk
{
private readonly byte[] _data;
@@ -35,6 +35,4 @@ namespace AnotherReplayReader.ReplayFile
writer.Write(0);
}
}
}

1115
Utils/AIAnalyze.cs Normal file

File diff suppressed because it is too large Load Diff

89
Utils/AIAnalyzeUI.cs Normal file
View File

@@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace AnotherReplayReader.Utils
{
internal static class AIAnalyzeUI
{
public record AIAnalyzeProgressData(AIAnalyze.AIChunk Delta, DateTimeOffset? TimeStamp, bool IsExtra);
public class EmaSpeed
{
const double Tau = 5;
private DateTimeOffset lastEventTime = DateTimeOffset.UtcNow;
private DateTimeOffset timeSinceLastSpeedMeasure = DateTimeOffset.UtcNow;
private int bufferedCharactersSinceLastSpeedMeasure = 0;
private double emaSpeed = double.NaN;
public void ProcessEvent(AIAnalyzeProgressData data)
{
if (data.IsExtra)
{
return;
}
if (data.TimeStamp is { } timestamp)
{
lastEventTime = timestamp;
}
bufferedCharactersSinceLastSpeedMeasure += data.Delta.Text.Length;
}
public double GetDisplaySpeed(DateTimeOffset now)
{
// =========================
// 2. 计算 instant speed真实时间
// =========================
double instant = 0;
double dt = (now - timeSinceLastSpeedMeasure).TotalSeconds;
if (bufferedCharactersSinceLastSpeedMeasure > 0 && dt > 0.05)
{
instant = bufferedCharactersSinceLastSpeedMeasure / dt;
// reset window
bufferedCharactersSinceLastSpeedMeasure = 0;
timeSinceLastSpeedMeasure = now;
}
double alpha = 1 - Math.Exp(-dt / Tau);
if (double.IsNaN(emaSpeed))
{
emaSpeed = instant;
}
else
{
emaSpeed += alpha * (instant - emaSpeed);
}
double idle = (now - lastEventTime).TotalSeconds;
double display = emaSpeed;
if (idle > 0.5)
{
double decay = Math.Exp(-(idle - 0.5) / Tau);
display *= decay;
}
return display;
}
}
public static Action<AIAnalyze.AIChunk> BuildAIChunkReader(Action<AIAnalyzeProgressData> newContent)
{
void OnAIChunk(AIAnalyze.AIChunk c)
{
if (c.Type == AIAnalyze.AIChunkType.Json)
{
return;
}
newContent(new(Delta: c, TimeStamp: DateTimeOffset.UtcNow, IsExtra: false));
}
return OnAIChunk;
}
}
}

View File

@@ -0,0 +1,6 @@
namespace System.Runtime.CompilerServices
{
internal static class IsExternalInit
{
}
}

View File

@@ -44,8 +44,9 @@ namespace AnotherReplayReader
}
}
private async Task Display(string filter = "", string nameFilter = "")
private Task Display(string filter = "", string nameFilter = "")
{
return Task.CompletedTask;
}
private async void OnClick(object sender, RoutedEventArgs e)
@@ -87,11 +88,11 @@ namespace AnotherReplayReader
}
}
private async Task<bool> UpdateIpTable(IPAddress ip, string idText)
private Task<bool> UpdateIpTable(IPAddress ip, string idText)
{
var bytes = ip.GetAddressBytes();
var ipNum = (uint)bytes[0] * 256 * 256 * 256 + bytes[1] * 256 * 256 + bytes[2] * 256 + bytes[3];
return false;
return Task.FromResult(false);
}
private async void OnIpFieldChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)