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 { /// /// EventDump.xaml 的交互逻辑 /// public sealed partial class EventDump : Window { public enum CompactLevel { NoCompact, ForAI, VeryCompactedForAI, } private record Model( Replay? Replay, ImmutableSortedDictionary Players, ImmutableArray<(TimeSpan, ImmutableArray)> Commands, CompactLevel Level ) { public Mod Mod => Replay?.Mod ?? new("RA3"); public ImmutableSortedDictionary? 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.Empty, ImmutableArray<(TimeSpan, ImmutableArray)>.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 _stringHashes = ImmutableDictionary.Empty; private readonly CancellationTokenSource _cancellation = new(); private Model _model = new(); private string? _cached; private TimeIndexedPrefixSums? _cachedPrefixSums; private ReplayFactIndex? _cachedFactIndex; public EventDump() { InitializeComponent(); Closing += EventDump_Closing; Closed += EventDump_Closed; // add enum values of CompactLevel to _playerComboBox var values = Enum.GetValues(typeof(CompactLevel)).Cast(); 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; _cachedFactIndex = ReplayFactIndex.Build(_model.Commands, _stringHashes); // 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 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().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 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 || _cachedFactIndex is not { } factIndex) { MessageBox.Show(this, "请先生成文本"); return; } var context = _aiSettings.GetCurrentContext(); if (context == null) { MessageBox.Show("请先在“AI 设置”页配置提供商并选择模型"); return; } _aiPanel.GetRequestContext = _aiSettings.GetCurrentContext!; _aiPanel.GetPromptSettings = _aiSettings.GetPromptSettings; await _aiPanel.StartAnalysisAsync(replay, _model.Players, cached, cachedPrefixSums, factIndex, _cancellation.Token); } } }