Files
AnotherReplayReader/EventDump.xaml.cs
2026-06-21 14:42:04 +02:00

576 lines
23 KiB
C#
Raw Permalink 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.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");
}
}
}
}