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,14 +173,16 @@ namespace AnotherReplayReader.Apm
{
var commandId = command.CommandId;
var playerIndex = command.PlayerIndex;
if (commandId == 0x21)
if (playerIndex != -1)
{
if (commandId == 0x221)
{
if (playerLifes[playerIndex] < estimatedTime)
{
playerLifes[playerIndex] = estimatedTime;
}
}
else if (!IsUnknown(commandId) && !IsAuto(commandId) && playerIndex >= 0)
else if (!IsUnknown(commandId) && !IsAuto(commandId))
{
if (stricterLifes[playerIndex] < estimatedTime)
{
@@ -117,16 +191,16 @@ namespace AnotherReplayReader.Apm
}
}
}
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];
commandCount = playerCommands[command.CommandId] = new(PlayersMap.Keys.ToDictionary(x => x, _ => 0));
}
if (command.PlayerIndex >= 0 && command.PlayerIndex < Players.Length)
{
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,8 +26,15 @@
BorderThickness="1" />
<DockPanel Grid.Row="0"
Margin="0,0,0,12">
<StackPanel DockPanel.Dock="Bottom"
Margin="16,0"
<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" />
@@ -39,6 +46,7 @@
TextChanged="OnResolutionTextChanged" />
<Label Content="秒"
VerticalAlignment="Center" />
<CheckBox x:Name="_skipUnknowns"
Margin="24,0,0,0"
Content="忽略未知操作"
@@ -46,6 +54,7 @@
VerticalAlignment="Center"
Checked="OnSkipUnknownsCheckedChanged"
Unchecked="OnSkipUnknownsCheckedChanged" />
<CheckBox x:Name="_skipAutos"
Margin="24,0,0,0"
Content="忽略自动操作"
@@ -53,6 +62,7 @@
VerticalAlignment="Center"
Checked="OnSkipAutosCheckedChanged"
Unchecked="OnSkipAutosCheckedChanged" />
<CheckBox x:Name="_skipClicks"
Margin="24,0,0,0"
Content="忽略左键点选"
@@ -61,6 +71,16 @@
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
[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
// 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
};
}
Action<BinaryReader> VariableSizeParser(byte command, int offset)
public static List<CommandArgumentEntry> ReadCommandData(this BinaryReader reader)
{
return (BinaryReader current) =>
var list = new List<CommandArgumentEntry>();
while (true)
{
var totalBytes = 2;
totalBytes += current.ReadBytes(offset - 2).Length;
for (var x = current.ReadByte(); x != 0xFF; x = current.ReadByte())
{
totalBytes += 1;
byte head = reader.ReadByte();
if (head == 0xFF)
break;
var size = ((x >> 4) + 1) * 4;
totalBytes += current.ReadBytes(size).Length;
}
totalBytes += 1;
var chk = totalBytes;
};
};
int count = (head >> 4) + 1;
var type = (CommandArgumentType)(head & 0xF);
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"),
var value = _readers[type].Read(reader, count);
(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);
list.Add(new CommandArgumentEntry(type, value, count));
}
foreach (var (id, parser, description) in specialList)
{
_commandParser.Add(id, parser);
_commandNames.Add(id, description);
return list;
}
_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)
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
: $"(未知指令 0x{commandId:X2}";
}
public static void UnknownCommandParser(BinaryReader current, byte commandId)
private static AssetId ReadAssetId(BinaryReader reader)
{
while (true)
var version = reader.ReadByte();
var typeId = reader.ReadUInt32();
var instanceId = reader.ReadUInt32();
return new(TypeId: typeId, InstanceId: instanceId);
}
private static Vector3 ReadVector3(BinaryReader reader)
{
var value = current.ReadByte();
if (value == 0xFF)
var x = reader.ReadSingle();
var y = reader.ReadSingle();
var z = reader.ReadSingle();
return new(X: x, Y: y, Z: z);
}
private static string ReadString(BinaryReader current, CommandArgumentType type)
{
break;
}
}
//return $"(未知指令 0x{commandID:2X}";
}
private static void ParseSpecialChunk0x01(BinaryReader current)
var length = (int)current.ReadByte();
if (length == 0xFF)
{
var firstByte = current.ReadByte();
if (firstByte == 0xFF)
length = current.ReadInt32();
}
// read byte string or wchar_t string based on T type
switch (type)
{
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();
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)