Files
AnotherReplayReader/EventDump.xaml.cs
T
2026-07-06 13:48:26 +02:00

457 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.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.Threading;
using System.Xml.Linq;
using static AnotherReplayReader.AIProviderSettingsControl;
namespace AnotherReplayReader
{
/// <summary>
/// EventDump.xaml 的交互逻辑
/// </summary>
public sealed partial class EventDump : Window
{
public enum CompactLevel
{
NoCompact,
ForAI,
VeryCompactedForAI,
}
private record Model(
Replay? Replay,
ImmutableSortedDictionary<int, Player> Players,
ImmutableArray<(TimeSpan, ImmutableArray<CommandChunk>)> Commands,
CompactLevel Level
)
{
public Mod Mod => Replay?.Mod ?? new("RA3");
public ImmutableSortedDictionary<int, string>? PlayersNamesForAI { get; } = Replay is null || Level <= CompactLevel.NoCompact
? null
: AIAnalyze.PlayerNamesForAI(Replay.Mod, Players);
public bool IsDefault => Players.IsEmpty && Commands.IsEmpty;
public Model() : this(
null,
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 TimeIndexedPrefixSums? _cachedPrefixSums;
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(ApmPlotter plotter)
{
var level = (CompactLevel)_compactLevelComboBox.SelectedIndex;
_model = new Model(plotter.Replay, 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, prefixSums) = await Task.Run(() =>
{
var text = GeneratePlainText(_model, out var prefixSums);
return (text, prefixSums);
});
var (bytesCount, estimatedTokenCount) = AIAnalyze.EstimateTokenCount(text);
_textBox.Text = text;
_cached = text;
_cachedPrefixSums = prefixSums;
// 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, out TimeIndexedPrefixSums prefixSums)
{
prefixSums = new([], []);
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;
}
prefixSums.Add(time, filtered.Count);
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}(建造者)",
0x205 or 0x206 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 (level == CompactLevel.NoCompact)
{
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.Replay, _model.Players, _model.Commands, selectedLevel);
await ShowPlainText();
}
private async void OnAIAnalyzeClick(object sender, RoutedEventArgs e)
{
if (_model.Replay is not { } replay)
{
MessageBox.Show(this, "请先选择录像");
return;
}
if (_cached is not { } cached || _cachedPrefixSums is not { } cachedPrefixSums)
{
MessageBox.Show(this, "请先生成文本");
return;
}
var context = _aiSettings.GetCurrentContext();
if (context == null)
{
MessageBox.Show("请先在“AI 设置”页配置提供商并选择模型");
return;
}
_aiPanel.GetRequestContext = _aiSettings.GetCurrentContext!;
await _aiPanel.StartAnalysisAsync(replay, _model.Players, cached, cachedPrefixSums, _cancellation.Token);
}
}
}