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 { /// /// EventDump.xaml 的交互逻辑 /// public sealed partial class EventDump : Window { public enum CompactLevel { NoCompact, ForAI, VeryCompactedForAI, } private class Model( Mod mod, ImmutableSortedDictionary players, ImmutableArray<(TimeSpan, ImmutableArray)> commands, CompactLevel level ) { public Mod Mod { get; } = mod; public ImmutableSortedDictionary Players { get; } = players; public ImmutableArray<(TimeSpan, ImmutableArray)> Commands { get; } = commands; public CompactLevel Level { get; } = level; public ImmutableSortedDictionary? PlayersNamesForAI { get; } = level <= CompactLevel.NoCompact ? null : AIAnalyze.PlayerNamesForAI(mod, players); public bool IsDefault => Players.IsEmpty && Commands.IsEmpty; public Model() : this( new("RA3"), 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 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(); 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 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().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.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(); 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.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 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 { ["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"); } } } }