503 lines
20 KiB
C#
503 lines
20 KiB
C#
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;
|
||
private ImmutableArray<EventSpan> _cachedSpans = ImmutableArray<EventSpan>.Empty;
|
||
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<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
|
||
{
|
||
_aiSettings.SaveCurrentSelection();
|
||
_cancellation.Cancel();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.Instance.DebugMessage += $"EventDump_Closing: {ex}\r\n";
|
||
}
|
||
}
|
||
|
||
private void EventDump_Closed(object sender, EventArgs e)
|
||
{
|
||
try
|
||
{
|
||
_aiSettings.SaveCurrentSelection();
|
||
_cancellation.Cancel();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.Instance.DebugMessage += $"EventDump_Closed: {ex}\r\n";
|
||
}
|
||
}
|
||
|
||
public void LoadStringHashes()
|
||
{
|
||
// 临时方案:优先使用随程序分发的快照,找不到再回退到本地 SDK。
|
||
// 该快照后续应改为可配置路径,或只打包当前命令实际用到的 hash 子集。
|
||
var bundledPath = Path.Combine(AppContext.BaseDirectory, "Data", "StringHashes.xml");
|
||
var externalPath = @"C:\Apps\RA3-MODSDK-X\builtmods\StringHashes.xml";
|
||
var sourcePath = File.Exists(bundledPath) ? bundledPath : externalPath;
|
||
if (!File.Exists(sourcePath))
|
||
{
|
||
throw new FileNotFoundException(
|
||
"未找到 StringHashes.xml;请检查程序目录下的 Data\\StringHashes.xml,或安装 RA3-MODSDK-X。",
|
||
sourcePath);
|
||
}
|
||
Debug.Instance.DebugMessage += $"StringHashes 来源: {sourcePath}\r\n";
|
||
|
||
var stringHashes = File.ReadAllText(sourcePath);
|
||
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, spans) = await Task.Run(() => GeneratePlainText(_model));
|
||
var (bytesCount, estimatedTokenCount) = AIAnalyze.EstimateTokenCount(text);
|
||
_textBox.Text = text;
|
||
_cached = text;
|
||
_cachedPrefixSums = prefixSums;
|
||
_cachedSpans = spans;
|
||
_cachedFactIndex = await Task.Run(() => 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 Text, TimeIndexedPrefixSums PrefixSums, ImmutableArray<EventSpan> EventSpans) GeneratePlainText(Model model)
|
||
{
|
||
var prefixSums = new TimeIndexedPrefixSums([], []);
|
||
var spans = ImmutableArray.CreateBuilder<EventSpan>();
|
||
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;
|
||
}
|
||
var blockStart = sb.Length;
|
||
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(FirstInt32(command.Data[0]) ?? 0)}已主动退出游戏",
|
||
0x22E when i == 0 => j == 0 ? StanceToString((int)value) : null,
|
||
_ => text,
|
||
});
|
||
sb.AppendLine();
|
||
}
|
||
var blockLength = sb.Length - blockStart;
|
||
var tokens = (int)Math.Ceiling(
|
||
Encoding.UTF8.GetByteCount(sb.ToString(blockStart, blockLength)) / 2.2);
|
||
spans.Add(new EventSpan(time, blockStart, blockLength, tokens));
|
||
}
|
||
return (sb.ToString(), prefixSums, spans.ToImmutable());
|
||
}
|
||
|
||
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 = chunk.Data[0].Value switch
|
||
{
|
||
bool[] bools => bools.Length > 0 && bools[0],
|
||
bool b => b,
|
||
_ => false,
|
||
};
|
||
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 int? FirstInt32(CommandArgumentEntry entry) =>
|
||
entry.Count == 1 && entry.Value is int singleInt
|
||
? singleInt
|
||
: entry.Value is int[] ints && ints.Length > 0
|
||
? ints[0]
|
||
: (int?)null;
|
||
|
||
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 (_model.Level == CompactLevel.NoCompact)
|
||
{
|
||
MessageBox.Show(this, "AI 分析需要使用 AI 压缩级别(ForAI 或 VeryCompactedForAI),请先在右上角切换。");
|
||
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,
|
||
_cachedSpans,
|
||
_cancellation.Token);
|
||
}
|
||
}
|
||
}
|