wip
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
102
Apm/DataRow.cs
102
Apm/DataRow.cs
@@ -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),
|
||||
|
||||
@@ -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}" />
|
||||
|
||||
@@ -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
127
EventDump.xaml
Normal 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
575
EventDump.xaml.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}]";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
{
|
||||
|
||||
@@ -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 value = current.ReadByte();
|
||||
if (value == 0xFF)
|
||||
{
|
||||
break;
|
||||
var version = reader.ReadByte();
|
||||
var typeId = reader.ReadUInt32();
|
||||
var instanceId = reader.ReadUInt32();
|
||||
return new(TypeId: typeId, InstanceId: instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
//return $"(未知指令 0x{commandID:2X})";
|
||||
}
|
||||
|
||||
private static void ParseSpecialChunk0x01(BinaryReader current)
|
||||
private static Vector3 ReadVector3(BinaryReader reader)
|
||||
{
|
||||
var firstByte = current.ReadByte();
|
||||
if (firstByte == 0xFF)
|
||||
{
|
||||
return;
|
||||
var x = reader.ReadSingle();
|
||||
var y = reader.ReadSingle();
|
||||
var z = reader.ReadSingle();
|
||||
return new(X: x, Y: y, Z: z);
|
||||
}
|
||||
|
||||
var sixthByte = current.ReadBytes(5).Last();
|
||||
if (sixthByte == 0xFF)
|
||||
private static string ReadString(BinaryReader current, CommandArgumentType type)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sixteenthByte = current.ReadBytes(10).Last();
|
||||
var size = (int)(sixteenthByte + 1) * 4 + 14;
|
||||
var lastByte = current.ReadBytes(size).Last();
|
||||
if (lastByte != 0xFF)
|
||||
var length = (int)current.ReadByte();
|
||||
if (length == 0xFF)
|
||||
{
|
||||
throw new InvalidDataException();
|
||||
length = current.ReadInt32();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
private static void ParseSetRallyPoint0x02(BinaryReader current)
|
||||
// read byte string or wchar_t string based on T type
|
||||
switch (type)
|
||||
{
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
1115
Utils/AIAnalyze.cs
Normal file
File diff suppressed because it is too large
Load Diff
89
Utils/AIAnalyzeUI.cs
Normal file
89
Utils/AIAnalyzeUI.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
6
Utils/CompilerServices.cs
Normal file
6
Utils/CompilerServices.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace System.Runtime.CompilerServices
|
||||
{
|
||||
internal static class IsExternalInit
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user