deepseek wip

This commit is contained in:
2026-07-07 11:03:44 +02:00
parent 645189f21c
commit 00c67dd66a
9 changed files with 904 additions and 40 deletions
+288 -14
View File
@@ -95,7 +95,9 @@ namespace AnotherReplayReader.Utils
@"```(?:json)?\s*(\{[\s\S]*?\})\s*```",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static AIValidationResult ValidateMachineReadableClaims(string response)
public static AIValidationResult ValidateMachineReadableClaims(
string response,
ReplayFactIndex? factIndex = null)
{
var issues = ImmutableArray.CreateBuilder<AIValidationIssue>();
var json = ExtractJsonObject(response);
@@ -115,6 +117,11 @@ namespace AnotherReplayReader.Utils
}
ValidateClaimSelfConsistency(claims, issues);
ValidateUnpackAmbiguity(claims, issues);
if (factIndex is not null)
{
ValidateTimelineConsistency(claims, factIndex, issues);
}
return new AIValidationResult(claims, issues.ToImmutable());
}
@@ -180,11 +187,11 @@ namespace AnotherReplayReader.Utils
}
return new AIMachineReadableClaims(
ReadUnitClaims(root),
ReadSimpleClaims(root, "eventClaims")
ReadUnitClaims(root, issues),
ReadSimpleClaims(root, "eventClaims", issues)
.Select(c => new AIEventClaim(c.Claim, c.EvidenceLevel, c.Evidence))
.ToImmutableArray(),
ReadSimpleClaims(root, "timelineClaims")
ReadSimpleClaims(root, "timelineClaims", issues)
.Select(c => new AITimelineClaim(c.Claim, c.EvidenceLevel, c.Evidence))
.ToImmutableArray());
}
@@ -198,7 +205,9 @@ namespace AnotherReplayReader.Utils
}
}
private static ImmutableArray<AIUnitClaim> ReadUnitClaims(JsonElement root)
private const int MaxUnitClaims = 10;
private static ImmutableArray<AIUnitClaim> ReadUnitClaims(JsonElement root, ImmutableArray<AIValidationIssue>.Builder issues)
{
if (!TryGetArray(root, "unitClaims", out var unitClaims))
{
@@ -206,6 +215,7 @@ namespace AnotherReplayReader.Utils
}
var result = ImmutableArray.CreateBuilder<AIUnitClaim>();
var totalCount = 0;
foreach (var item in unitClaims.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object)
@@ -213,15 +223,30 @@ namespace AnotherReplayReader.Utils
continue;
}
totalCount++;
if (result.Count >= MaxUnitClaims)
{
continue;
}
result.Add(new AIUnitClaim(
ReadFlexibleString(item, "unitId"),
ReadFlexibleString(item, "player"),
ReadFlexibleString(item, "claim"),
ReadEvidenceLevel(item),
ReadEvidenceLevel(item, issues),
ReadStringArray(item, "evidence"),
ReadStringArray(item, "alternatives"),
ReadStringArray(item, "needsConfirmation")));
}
if (totalCount > MaxUnitClaims)
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Info,
AIValidationIssueKind.InvalidMachineReadableClaims,
$"unitClaims 包含 {totalCount} 条声明,仅处理前 {MaxUnitClaims} 条,其余已忽略。"));
}
return result.ToImmutable();
}
@@ -230,7 +255,7 @@ namespace AnotherReplayReader.Utils
AIEvidenceLevel EvidenceLevel,
ImmutableArray<string> Evidence);
private static ImmutableArray<SimpleClaim> ReadSimpleClaims(JsonElement root, string propertyName)
private static ImmutableArray<SimpleClaim> ReadSimpleClaims(JsonElement root, string propertyName, ImmutableArray<AIValidationIssue>.Builder issues)
{
if (!TryGetArray(root, propertyName, out var claims))
{
@@ -238,6 +263,13 @@ namespace AnotherReplayReader.Utils
}
var result = ImmutableArray.CreateBuilder<SimpleClaim>();
var maxClaims = propertyName switch
{
"eventClaims" => 5,
"timelineClaims" => 3,
_ => 50,
};
var totalCount = 0;
foreach (var item in claims.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object)
@@ -245,11 +277,32 @@ namespace AnotherReplayReader.Utils
continue;
}
totalCount++;
if (result.Count >= maxClaims)
{
continue;
}
var claim = ReadFlexibleString(item, "claim");
if (string.IsNullOrWhiteSpace(claim) && propertyName == "eventClaims")
{
claim = ReadFlexibleString(item, "event");
}
result.Add(new SimpleClaim(
ReadFlexibleString(item, "claim"),
ReadEvidenceLevel(item),
claim,
ReadEvidenceLevel(item, issues),
ReadStringArray(item, "evidence")));
}
if (totalCount > maxClaims)
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Info,
AIValidationIssueKind.InvalidMachineReadableClaims,
$"{propertyName} 包含 {totalCount} 条声明,仅处理前 {maxClaims} 条,其余已忽略。"));
}
return result.ToImmutable();
}
@@ -296,6 +349,216 @@ namespace AnotherReplayReader.Utils
}
}
#region Structured evidence parsing
internal enum AIEvidenceType
{
Build,
Place,
Produce,
Sell,
Select,
Move,
Power,
Unknown
}
internal sealed record StructuredEvidence(
AIEvidenceType Type,
string Time,
ImmutableArray<string> Parameters,
string Raw)
{
public string? GetSpecialPowerName() =>
Type == AIEvidenceType.Power && Parameters.Length >= 1 ? Parameters[0] : null;
public string? GetUnitId() => Type switch
{
AIEvidenceType.Build or AIEvidenceType.Place when Parameters.Length >= 2 => Parameters[1],
AIEvidenceType.Produce when Parameters.Length >= 2 => Parameters[1],
AIEvidenceType.Power when Parameters.Length >= 2 => Parameters[1],
AIEvidenceType.Sell when Parameters.Length >= 1 => Parameters[0],
AIEvidenceType.Select when Parameters.Length >= 1 => Parameters[0],
_ => null,
};
public string? GetAssetName() => Type switch
{
AIEvidenceType.Build or AIEvidenceType.Place or AIEvidenceType.Produce
when Parameters.Length >= 1 => Parameters[0],
_ => null,
};
}
private static readonly Regex _structuredEvidenceRegex = new(
@"^(build|place|produce|sell|select|move|power)\|([^|]+(?:\|(?!\|).*)?)$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
internal static StructuredEvidence ParseStructuredEvidence(string text)
{
var match = _structuredEvidenceRegex.Match(text.Trim());
if (!match.Success)
{
return new StructuredEvidence(AIEvidenceType.Unknown, string.Empty, ImmutableArray<string>.Empty, text);
}
var type = match.Groups[1].Value.ToLowerInvariant() switch
{
"build" => AIEvidenceType.Build,
"place" => AIEvidenceType.Place,
"produce" => AIEvidenceType.Produce,
"sell" => AIEvidenceType.Sell,
"select" => AIEvidenceType.Select,
"move" => AIEvidenceType.Move,
"power" => AIEvidenceType.Power,
_ => AIEvidenceType.Unknown,
};
var rest = match.Groups[2].Value;
var parts = rest.Split('|');
var time = parts.Length >= 1 ? parts[0].Trim() : string.Empty;
var parameters = parts.Skip(1).Select(p => p.Trim()).ToImmutableArray();
return new StructuredEvidence(type, time, parameters, text);
}
internal static ImmutableArray<StructuredEvidence> ParseAllEvidence(ImmutableArray<string> evidenceStrings)
{
return evidenceStrings
.Select(ParseStructuredEvidence)
.ToImmutableArray();
}
#endregion
#region Validation rules
private static void ValidateUnpackAmbiguity(
AIMachineReadableClaims claims,
ImmutableArray<AIValidationIssue>.Builder issues)
{
foreach (var claim in claims.UnitClaims)
{
if (claim.EvidenceLevel is not (AIEvidenceLevel.Confirmed or AIEvidenceLevel.HighlyLikely))
{
continue;
}
var evidence = ParseAllEvidence(claim.Evidence);
var hasUnpack = evidence.Any(e =>
e.GetSpecialPowerName() is string p &&
p.IndexOf("UnpackReplaceSelf", StringComparison.OrdinalIgnoreCase) >= 0);
if (!hasUnpack)
{
continue;
}
var hasPack = evidence.Any(e =>
e.GetSpecialPowerName() is string p &&
p.IndexOf("PackReplaceSelf", StringComparison.OrdinalIgnoreCase) >= 0);
if (hasPack)
{
continue;
}
issues.Add(new AIValidationIssue(
AIValidationSeverity.WeakEvidence,
AIValidationIssueKind.MissingAlternative,
$"UnitId {claim.UnitId} 使用了 UnpackReplaceSelf 但证据中无对应 PackReplaceSelf。UnpackReplaceSelf 可能对应基地车展开或矿车展开成指挥中心,建议降低置信度或添加 alternative。",
claim.UnitId));
}
}
private static void ValidateTimelineConsistency(
AIMachineReadableClaims claims,
ReplayFactIndex factIndex,
ImmutableArray<AIValidationIssue>.Builder issues)
{
foreach (var claim in claims.UnitClaims)
{
if (string.IsNullOrWhiteSpace(claim.UnitId))
{
continue;
}
if (!uint.TryParse(claim.UnitId, out var unitId))
{
continue;
}
// Check 1: UnitId referenced in claim exists in the replay
if (!factIndex.UnitIdFirstObservedTime.ContainsKey(unitId))
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Warning,
AIValidationIssueKind.InvalidMachineReadableClaims,
$"UnitId {claim.UnitId} 在回放数据中从未出现过,AI 可能编造了不存在的 UnitId。",
claim.UnitId));
continue;
}
// Check 2: Verify special power evidence against fact index
var evidence = ParseAllEvidence(claim.Evidence);
foreach (var ev in evidence)
{
if (ev.Type != AIEvidenceType.Power)
{
continue;
}
var evUnitIdStr = ev.GetUnitId();
if (evUnitIdStr is null || !uint.TryParse(evUnitIdStr, out var evUnitId))
{
continue;
}
var powerName = ev.GetSpecialPowerName();
if (powerName is null)
{
continue;
}
// Does this UnitId exist in the fact index?
if (!factIndex.UnitIdFirstObservedTime.ContainsKey(evUnitId))
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Info,
AIValidationIssueKind.InvalidMachineReadableClaims,
$"证据引用了回放中不存在的 UnitId {evUnitIdStr}。",
claim.UnitId));
continue;
}
// Did this UnitId actually use this special power?
if (factIndex.UnitIdSpecialPowers.TryGetValue(evUnitId, out var actualPowers)
&& !actualPowers.Contains(powerName))
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Contradiction,
AIValidationIssueKind.UnitCapabilityContradiction,
$"UnitId {evUnitIdStr} 在回放中使用过以下特殊能力:{string.Join(", ", actualPowers.OrderBy(x => x))},但 AI 声称其使用了“{powerName}”——此能力未在该 UnitId 上观察到。",
claim.UnitId));
}
}
// Check 3: UnitId used as builder vs claim
var isBuilderInReplay = factIndex.BuilderUnitIds.Contains(unitId);
var claimLooksLikeBuilder = claim.Claim.IndexOf("MCV", StringComparison.OrdinalIgnoreCase) >= 0
|| claim.Claim.IndexOf("基地车", StringComparison.OrdinalIgnoreCase) >= 0
|| claim.Claim.IndexOf("Nanocore", StringComparison.OrdinalIgnoreCase) >= 0
|| claim.Claim.IndexOf("纳米核心", StringComparison.OrdinalIgnoreCase) >= 0
|| claim.Claim.IndexOf("builder", StringComparison.OrdinalIgnoreCase) >= 0
|| claim.Claim.IndexOf("建造者", StringComparison.OrdinalIgnoreCase) >= 0;
if (claimLooksLikeBuilder && !isBuilderInReplay)
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.WeakEvidence,
AIValidationIssueKind.UnitCapabilityContradiction,
$"AI 推测 UnitId {claim.UnitId} 是“{claim.Claim}”(推测是建造单位),但该 UnitId 在回放中从未作为建造者(建造建筑)出现。",
claim.UnitId));
}
}
}
#endregion
private static bool TryGetArray(JsonElement root, string propertyName, out JsonElement array)
{
if (root.TryGetProperty(propertyName, out array)
@@ -308,23 +571,34 @@ namespace AnotherReplayReader.Utils
return false;
}
private static AIEvidenceLevel ReadEvidenceLevel(JsonElement item)
private static AIEvidenceLevel ReadEvidenceLevel(JsonElement item, ImmutableArray<AIValidationIssue>.Builder issues)
{
var value = ReadFlexibleString(item, "evidenceLevel");
return NormalizeEvidenceLevel(value);
return NormalizeEvidenceLevel(value, issues);
}
private static AIEvidenceLevel NormalizeEvidenceLevel(string value)
private static AIEvidenceLevel NormalizeEvidenceLevel(string value, ImmutableArray<AIValidationIssue>.Builder issues)
{
value = value.Trim().Replace("_", "").Replace("-", "").Replace(" ", "");
return value.ToLowerInvariant() switch
var result = value.ToLowerInvariant() switch
{
"confirmed" or "确定" => AIEvidenceLevel.Confirmed,
"highlylikely" or "high" or "高度可能" => AIEvidenceLevel.HighlyLikely,
"possible" or "可能" => AIEvidenceLevel.Possible,
"ruledout" or "excluded" or "已排除" => AIEvidenceLevel.RuledOut,
_ => AIEvidenceLevel.Uncertain,
_ => (AIEvidenceLevel?)null,
};
if (result is not null)
{
return result.Value;
}
issues.Add(new AIValidationIssue(
AIValidationSeverity.Info,
AIValidationIssueKind.InvalidEvidenceLevel,
$"无法识别的证据等级 \"{value.Trim()}\",已降级为不确定。"));
return AIEvidenceLevel.Uncertain;
}
private static string ReadFlexibleString(JsonElement item, string propertyName)
+32 -6
View File
@@ -57,7 +57,7 @@ namespace AnotherReplayReader.Utils
- 你只能根据用户提供的操作记录、玩家信息、下方游戏规则和明确给出的背景知识进行分析。
- 不要使用现实世界常识或其他 RTS 游戏常识覆盖这里的游戏设定。例如:步兵、直升机、建筑水陆摆放、运输能力、两栖能力都必须以这里的规则和单位描述为准。
- 不确定时必须保留多个候选,不要为了让解说流畅而过早下定论。
- 对 UnitId、单位类型、战术意图的判断必须区分证据等级:确定、高度可能、可能、待确认、已排除。
- 对 UnitId、单位类型、战术意图的判断必须区分证据等级:确定、高度可能、可能、不确定、已排除。
- 每个关键推理都应当包含支持证据;如果存在会推翻该推理的反证,也要主动指出。
- 如果某个技能或行为可以对应多个单位,先列出候选,并说明还需要哪些后续迹象才能确认。
- 对已经被操作记录直接否定的判断必须修正或放弃,不要坚持原结论。
@@ -130,6 +130,7 @@ namespace AnotherReplayReader.Utils
# 机器可读声明
仅限于:分段分析阶段(第2阶段)
如果你对 UnitId、关键事件或时间线做出了可验证推测,请在回答末尾附加下面格式。
请限制推测数量:UnitId 推测不超过 10 个,事件推测不超过 5 个,时间线推测不超过 3 个。
必须先输出一行`[机器可读声明]`,然后输出一个 JSON 代码块:
[机器可读声明]
```json
@@ -140,15 +141,40 @@ namespace AnotherReplayReader.Utils
""player"": ""PlayerA"",
""claim"": ""AlliedMCV"",
""evidenceLevel"": ""possible"",
""evidence"": [""8:30 使用 SpecialPower_UnpackReplaceSelf""],
""evidence"": [""power|1:24.00|SpecialPower_PackReplaceSelf|246"", ""power|1:41.00|SpecialPower_UnpackReplaceSelf|123""],
""alternatives"": [""AlliedMiner 展开后的指挥中心""],
""needsConfirmation"": [""是否曾使用 SpecialPower_PackReplaceSelf"", ""后续是否作为建造者出现""]
""needsConfirmation"": [""是否曾作为建造者出现""]
}
],
""eventClaims"": [],
""timelineClaims"": []
""eventClaims"": [
{
""claim"": ""PlayerA 主基地打包并开始迁移"",
""evidenceLevel"": ""confirmed"",
""evidence"": [""power|1:24.00|SpecialPower_PackReplaceSelf|246""]
}
],
""timelineClaims"": [
{
""claim"": ""PlayerA 在 2 分钟内完成基地迁移"",
""evidenceLevel"": ""confirmed"",
""evidence"": [""power|1:24.00|SpecialPower_PackReplaceSelf|246"", ""power|1:41.00|SpecialPower_UnpackReplaceSelf|123""]
}
]
}
```
- `unitClaims`:每个条目需要包含 unitId(数字)、player(代码ID)、claim(推测内容)、evidenceLevel(证据等级)、evidence(结构化证据列表)、alternatives(其他可能性)、needsConfirmation(需要哪些后续迹象才能确认)。
- `eventClaims`:每个条目需要包含 claim(事件描述)、evidenceLevel(证据等级)、evidence(结构化证据列表)。
- `timelineClaims`:每个条目需要包含 claim(时间线描述)、evidenceLevel(证据等级)、evidence(结构化证据列表)。
**evidence 格式**:每条 evidence 必须是以下 pipe 分隔格式之一,不允许使用自然语言描述:
- `build|时间|建筑名|建造者UnitId` — 开始建造建筑,例如 `build|0:01.26|AlliedBarracks|246`
- `place|时间|建筑名|建造者UnitId|x,y,z` — 摆放建筑,例如 `place|0:01.46|AlliedBarracks|246|1905,2231,210`
- `produce|时间|单位名|出兵建筑UnitId` — 开始出兵,例如 `produce|0:14.66|AlliedScoutInfantry|291`
- `sell|时间|建筑UnitId` — 出售建筑,例如 `sell|2:21.93|255`
- `select|时间|单位UnitId` — 选择单位,例如 `select|1:24.13|587`
- `move|时间|x,y,z` — 移动,例如 `move|1:24.26|2026,2800,280`
- `power|时间|技能名|单位UnitId` — 释放特殊能力,例如 `power|1:24.00|SpecialPower_PackReplaceSelf|246`
如果没有可验证推测,可以输出空数组。不要在 JSON 里写注释。
# 推理指南
@@ -1015,7 +1041,7 @@ PlayerA: 开始出兵
你需要列出{beginText}至{endText}的主要事件、以及其他有分析价值的事件。
请按照按照[观察]、[分析]、[推理]、[进一步思考(可选)]的步骤,对各个事件进行分析和推理。
假如当前阶段存在一些较为重要的单位、而且能够推测出它们可能是什么单位,则可以列出单位的UnitId以及你对单位的推测
假如当前阶段存在一些较为重要的单位、而且能够推测出它们可能是什么单位,则可以列出单位的UnitId以及你对单位的推测。UnitId 推测最多 10 个,事件推测最多 5 个,时间线推测最多 3 个。
如果你列出了 UnitId 推测、关键事件推测或时间线推测,请在回答末尾附加`[机器可读声明]` JSON 代码块;如果没有相关推测,则输出空数组。
";
return instruction.Trim().Replace("\r", "");
+385
View File
@@ -0,0 +1,385 @@
using AnotherReplayReader.ReplayFile;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace AnotherReplayReader.Utils
{
/// <summary>
/// Index of replay facts extracted from CommandChunk data.
/// Used by AIAnalysisValidation to cross-reference LLM claims against
/// actual replay operations.
/// </summary>
internal sealed class ReplayFactIndex
{
/// <summary>First time a UnitId was observed in any command.</summary>
public ImmutableDictionary<uint, TimeSpan> UnitIdFirstObservedTime { get; }
/// <summary>Special powers used by each UnitId.</summary>
public ImmutableDictionary<uint, ImmutableHashSet<string>> UnitIdSpecialPowers { get; }
/// <summary>UnitIds that appeared as builder ("建造者") in construction commands.</summary>
public ImmutableHashSet<uint> BuilderUnitIds { get; }
/// <summary>UnitIds that appeared as production structures ("出兵建筑").</summary>
public ImmutableHashSet<uint> ProducerUnitIds { get; }
/// <summary>Per player, per unit asset name, first production start time.</summary>
public ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>> PlayerFirstProductionTime { get; }
/// <summary>Per player, which UnitIds they have selected.</summary>
public ImmutableDictionary<int, ImmutableHashSet<uint>> PlayerSelectedUnitIds { get; }
public ReplayFactIndex(
ImmutableDictionary<uint, TimeSpan> unitIdFirstObservedTime,
ImmutableDictionary<uint, ImmutableHashSet<string>> unitIdSpecialPowers,
ImmutableHashSet<uint> builderUnitIds,
ImmutableHashSet<uint> producerUnitIds,
ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>> playerFirstProductionTime,
ImmutableDictionary<int, ImmutableHashSet<uint>> playerSelectedUnitIds)
{
UnitIdFirstObservedTime = unitIdFirstObservedTime;
UnitIdSpecialPowers = unitIdSpecialPowers;
BuilderUnitIds = builderUnitIds;
ProducerUnitIds = producerUnitIds;
PlayerFirstProductionTime = playerFirstProductionTime;
PlayerSelectedUnitIds = playerSelectedUnitIds;
}
public static ReplayFactIndex Build(
ImmutableArray<(TimeSpan Time, ImmutableArray<CommandChunk> Commands)> timeline,
IReadOnlyDictionary<uint, string> stringHashTable)
{
var unitFirstObserved = new Dictionary<uint, TimeSpan>();
var unitSpecialPowers = new Dictionary<uint, HashSet<string>>();
var builderUnits = new HashSet<uint>();
var producerUnits = new HashSet<uint>();
var playerFirstProduction = new Dictionary<int, Dictionary<string, TimeSpan>>();
var playerSelected = new Dictionary<int, HashSet<uint>>();
foreach (var (time, commands) in timeline)
{
foreach (var command in commands)
{
ProcessCommand(time, command, stringHashTable,
unitFirstObserved, unitSpecialPowers,
builderUnits, producerUnits,
playerFirstProduction, playerSelected);
}
}
return new ReplayFactIndex(
unitFirstObserved.ToImmutableDictionary(),
unitSpecialPowers.ToImmutableDictionary(
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()),
builderUnits.ToImmutableHashSet(),
producerUnits.ToImmutableHashSet(),
playerFirstProduction.ToImmutableDictionary(
kv => kv.Key, kv => kv.Value.ToImmutableDictionary()),
playerSelected.ToImmutableDictionary(
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()));
}
private static void ProcessCommand(
TimeSpan time,
CommandChunk command,
IReadOnlyDictionary<uint, string> stringHashTable,
Dictionary<uint, TimeSpan> unitFirstObserved,
Dictionary<uint, HashSet<string>> unitSpecialPowers,
HashSet<uint> builderUnits,
HashSet<uint> producerUnits,
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction,
Dictionary<int, HashSet<uint>> playerSelected)
{
var player = command.PlayerIndex;
var cmdId = command.CommandId;
switch (cmdId)
{
// select unit(s): 0x1F5
case 0x1F5:
RecordSelectUnit(time, command, player, unitFirstObserved, playerSelected);
break;
// special power (no target): 0x1FE
case 0x1FE:
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
break;
// special power (target position): 0x1FF
case 0x1FF:
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
break;
// special power (target position and angle): 0x200
case 0x200:
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
break;
// special power (target unit): 0x201
case 0x201:
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
break;
// special power (one or more targets): 0x232
case 0x232:
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
break;
// start production: 0x205
case 0x205:
RecordProduction(time, command, player, unitFirstObserved, producerUnits, playerFirstProduction);
break;
// start construction: 0x207
case 0x207:
RecordConstruction(time, command, unitFirstObserved, builderUnits);
break;
// place building: 0x209
case 0x209:
RecordPlaceBuilding(time, command, unitFirstObserved, builderUnits);
break;
// sell building: 0x20A
case 0x20A:
RecordObjectReference(time, command, unitFirstObserved);
break;
// move: 0x214
case 0x214:
// attack move: 0x215
case 0x215:
// These commands operate on currently selected units.
// The target is a position, not a UnitId.
break;
}
}
private static void RecordSelectUnit(
TimeSpan time,
CommandChunk command,
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
Dictionary<int, HashSet<uint>> playerSelected)
{
// Data layout for 0x1F5:
// Data[0]: Bool (isReplace), if count > 0 the rest are ObjectIds
// Data[1..]: ObjectIds of selected units
foreach (var entry in command.Data)
{
if (entry.Type is CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2)
{
if (entry.Count == 1)
{
var unitId = (uint)entry.Value;
TryRecordFirstObserved(unitId, time, unitFirstObserved);
RecordPlayerSelection(player, unitId, playerSelected);
}
else
{
foreach (var id in (uint[])entry.Value)
{
TryRecordFirstObserved(id, time, unitFirstObserved);
RecordPlayerSelection(player, id, playerSelected);
}
}
}
}
}
private static void RecordSpecialPower(
TimeSpan time,
CommandChunk command,
IReadOnlyDictionary<uint, string> stringHashTable,
Dictionary<uint, TimeSpan> unitFirstObserved,
Dictionary<uint, HashSet<string>> unitSpecialPowers)
{
string? powerName = null;
var unitIds = new List<uint>();
foreach (var entry in command.Data)
{
switch (entry.Type)
{
case CommandArgumentType.Int32 when powerName is null:
{
// First Int32 is the special power hash ID
var hash = unchecked((uint)(int)entry.Value);
powerName = stringHashTable.TryGetValue(hash, out var name)
? name
: $"Hash_{hash:X8}";
break;
}
case CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2:
{
if (entry.Count == 1)
{
var id = (uint)entry.Value;
if (id != 0) unitIds.Add(id);
}
else
{
foreach (var id in (uint[])entry.Value)
{
if (id != 0) unitIds.Add(id);
}
}
break;
}
}
}
if (powerName is null || unitIds.Count == 0)
{
return;
}
foreach (var unitId in unitIds)
{
TryRecordFirstObserved(unitId, time, unitFirstObserved);
if (!unitSpecialPowers.TryGetValue(unitId, out var powers))
{
powers = new HashSet<string>();
unitSpecialPowers[unitId] = powers;
}
powers.Add(powerName);
}
}
private static void RecordProduction(
TimeSpan time,
CommandChunk command,
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
HashSet<uint> producerUnits,
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction)
{
uint? producerId = null;
string? unitName = null;
foreach (var entry in command.Data)
{
switch (entry.Type)
{
case CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2
when entry.Count == 1 && producerId is null:
producerId = (uint)entry.Value;
break;
case CommandArgumentType.AsciiString or CommandArgumentType.UnicodeString
when unitName is null:
unitName = entry.Value.ToString() ?? string.Empty;
break;
}
}
if (producerId.HasValue)
{
TryRecordFirstObserved(producerId.Value, time, unitFirstObserved);
producerUnits.Add(producerId.Value);
}
if (!string.IsNullOrWhiteSpace(unitName))
{
if (!playerFirstProduction.TryGetValue(player, out var perPlayer))
{
perPlayer = new Dictionary<string, TimeSpan>();
playerFirstProduction[player] = perPlayer;
}
if (!perPlayer.ContainsKey(unitName))
{
perPlayer[unitName] = time;
}
}
}
private static void RecordConstruction(
TimeSpan time,
CommandChunk command,
Dictionary<uint, TimeSpan> unitFirstObserved,
HashSet<uint> builderUnits)
{
// Data[0]: ObjectId (builder)
// Data[1]: AsciiString (building name)
RecordBuilder(time, command, unitFirstObserved, builderUnits);
}
private static void RecordPlaceBuilding(
TimeSpan time,
CommandChunk command,
Dictionary<uint, TimeSpan> unitFirstObserved,
HashSet<uint> builderUnits)
{
// Data[0]: ObjectId (builder)
// Data[1]: AsciiString (building name)
// Data[2]: Int32 (count)
// Data[3]: Vector3 (position)
// Data[4]: Float32 (angle)
RecordBuilder(time, command, unitFirstObserved, builderUnits);
}
private static void RecordBuilder(
TimeSpan time,
CommandChunk command,
Dictionary<uint, TimeSpan> unitFirstObserved,
HashSet<uint> builderUnits)
{
foreach (var entry in command.Data)
{
if (entry.Type is CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2
&& entry.Count == 1)
{
var id = (uint)entry.Value;
if (id != 0)
{
TryRecordFirstObserved(id, time, unitFirstObserved);
builderUnits.Add(id);
}
return; // only first ObjectId is the builder
}
}
}
private static void RecordObjectReference(
TimeSpan time,
CommandChunk command,
Dictionary<uint, TimeSpan> unitFirstObserved)
{
foreach (var entry in command.Data)
{
if (entry.Type is CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2)
{
if (entry.Count == 1)
{
TryRecordFirstObserved((uint)entry.Value, time, unitFirstObserved);
}
else
{
foreach (var id in (uint[])entry.Value)
{
TryRecordFirstObserved(id, time, unitFirstObserved);
}
}
}
}
}
private static void RecordPlayerSelection(int player, uint unitId, Dictionary<int, HashSet<uint>> playerSelected)
{
if (!playerSelected.TryGetValue(player, out var set))
{
set = new HashSet<uint>();
playerSelected[player] = set;
}
set.Add(unitId);
}
private static void TryRecordFirstObserved(uint unitId, TimeSpan time, Dictionary<uint, TimeSpan> unitFirstObserved)
{
if (unitId == 0) return;
if (unitFirstObserved.ContainsKey(unitId)) return;
unitFirstObserved[unitId] = time;
}
}
}