using AnotherReplayReader.ReplayFile; using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; namespace AnotherReplayReader.Utils { /// /// Index of replay facts extracted from CommandChunk data. /// Used by AIAnalysisValidation to cross-reference LLM claims against /// actual replay operations. /// internal sealed class ReplayFactIndex { /// First time a UnitId was observed in any command. public ImmutableDictionary UnitIdFirstObservedTime { get; } /// Special powers used by each UnitId. public ImmutableDictionary> UnitIdSpecialPowers { get; } /// UnitIds that appeared as builder ("建造者") in construction commands. public ImmutableHashSet BuilderUnitIds { get; } /// UnitIds that appeared as production structures ("出兵建筑"). public ImmutableHashSet ProducerUnitIds { get; } /// Per player, per unit asset name, first production start time. public ImmutableDictionary> PlayerFirstProductionTime { get; } /// Per player, which UnitIds they have selected. public ImmutableDictionary> PlayerSelectedUnitIds { get; } public ReplayFactIndex( ImmutableDictionary unitIdFirstObservedTime, ImmutableDictionary> unitIdSpecialPowers, ImmutableHashSet builderUnitIds, ImmutableHashSet producerUnitIds, ImmutableDictionary> playerFirstProductionTime, ImmutableDictionary> playerSelectedUnitIds) { UnitIdFirstObservedTime = unitIdFirstObservedTime; UnitIdSpecialPowers = unitIdSpecialPowers; BuilderUnitIds = builderUnitIds; ProducerUnitIds = producerUnitIds; PlayerFirstProductionTime = playerFirstProductionTime; PlayerSelectedUnitIds = playerSelectedUnitIds; } public static ReplayFactIndex Build( ImmutableArray<(TimeSpan Time, ImmutableArray Commands)> timeline, IReadOnlyDictionary stringHashTable) { var unitFirstObserved = new Dictionary(); var unitSpecialPowers = new Dictionary>(); var builderUnits = new HashSet(); var producerUnits = new HashSet(); var playerFirstProduction = new Dictionary>(); var playerSelected = new Dictionary>(); 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 stringHashTable, Dictionary unitFirstObserved, Dictionary> unitSpecialPowers, HashSet builderUnits, HashSet producerUnits, Dictionary> playerFirstProduction, Dictionary> 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 unitFirstObserved, Dictionary> 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 stringHashTable, Dictionary unitFirstObserved, Dictionary> unitSpecialPowers) { string? powerName = null; var unitIds = new List(); 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(); unitSpecialPowers[unitId] = powers; } powers.Add(powerName); } } private static void RecordProduction( TimeSpan time, CommandChunk command, int player, Dictionary unitFirstObserved, HashSet producerUnits, Dictionary> 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(); playerFirstProduction[player] = perPlayer; } if (!perPlayer.ContainsKey(unitName)) { perPlayer[unitName] = time; } } } private static void RecordConstruction( TimeSpan time, CommandChunk command, Dictionary unitFirstObserved, HashSet builderUnits) { // Data[0]: ObjectId (builder) // Data[1]: AsciiString (building name) RecordBuilder(time, command, unitFirstObserved, builderUnits); } private static void RecordPlaceBuilding( TimeSpan time, CommandChunk command, Dictionary unitFirstObserved, HashSet 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 unitFirstObserved, HashSet 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 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> playerSelected) { if (!playerSelected.TryGetValue(player, out var set)) { set = new HashSet(); playerSelected[player] = set; } set.Add(unitId); } private static void TryRecordFirstObserved(uint unitId, TimeSpan time, Dictionary unitFirstObserved) { if (unitId == 0) return; if (unitFirstObserved.ContainsKey(unitId)) return; unitFirstObserved[unitId] = time; } } }