using System.Buffers; using System.Buffers.Binary; using System.Runtime.InteropServices; using System.Text; using SoulstormReplayReader.Core.Domain; using SoulstormReplayReader.Core.Domain.Action; using SoulstormReplayReader.Core.Domain.BugCheckers; using SoulstormReplayReader.Core.Domain.ChatMessage; using SoulstormReplayReader.Core.Domain.Player; using SoulstormReplayReader.Core.Domain.Replay; using SoulstormReplayReader.Core.Domain.Tick; using SoulstormReplayReader.Core.Enums; using SoulstormReplayReader.Core.Extensions; using SoulstormReplayReader.Core.Models; using SoulstormReplayReader.Core.Utils; namespace SoulstormReplayReader.Core; // Существует благодаря // http://forums.warforge.ru/index.php?showtopic=101573&hl=replay // а также реплей менеджеру эламаунта :0 /* * Relic chunk struct: * - int32 version * - int32 chunkSize * - int32 variableStringLength or else * - ...other data... */ public sealed class SsReplayReader(Stream stream) : IDisposable { private readonly ExBinaryReader _binaryReader = new(stream); /// /// Если параметр активен, картинки баннера и бейджа игроков не парсятся /// /// Player.Banner.Bitmap = null /// /// Player.Badge.Bitmap = null /// public bool SkipImages { get; set; } /// /// Если параметр активен, на игроков будут повешены BugChecker'ы в зависимости от их расы /// public bool CheckForBugs { get; set; } public PixelType ImagePixelType { get; set; } = PixelType.Bgra8888; public ReplayDescriptor Descriptor { get; } = new(); public ReplayModel Replay { get; set; } public int CurrentTick { get; set; } public ReplayModel ReadFull(bool readOrdinaryTicks = true) { ReadInfo(); ReadTicks(readOrdinaryTicks); return Replay; } public ReplayModel ReadInfo() { ReadHeader(); ReadPlayers(); return Replay; } public ReplayModel ReadHeader() { Reset(); ReadStaticHeader(); // 226 - Последняя статичная позиция ReadDynamicHeader(); return Replay; } public void Reset() { _binaryReader.Seek(0); Replay = new ReplayModel(); CurrentTick = 0; } private void ReadStaticHeader() { Replay.Version = (ReplayVersion)_binaryReader.ReadInt32(); var modNameSpan = _binaryReader.ReadBytes(stackalloc byte[32]); Replay.ModName = Encoding.ASCII.GetString(modNameSpan.TrimEnd()); _binaryReader // relic chunky .Skip(44) .NextAsciiStringMustEqual("POSTGAMEINFO\0DATADATA") .SkipInt32(3); Replay.TotalTicks = _binaryReader.ReadInt32(); // Полное игровое время (1 сек = 8 тиков) _binaryReader // relic chunky .Skip(24) .NextAsciiStringMustEqual("FOLDINFO") .SkipInt32(); Descriptor.FoldInfoStart = _binaryReader.Position; // 153 Descriptor.FoldInfoSize = _binaryReader.ReadInt32(); _binaryReader .SkipInt32() .NextAsciiStringMustEqual("GAMEINFO\0FOLDWMAN") .SkipInt32(); Descriptor.FoldWmanStart = _binaryReader.Position; // 182 Descriptor.FoldWmanSize = _binaryReader.ReadInt32(); _binaryReader .SkipInt32() .NextAsciiStringMustEqual("DATASDSC") .Skip(20); Replay.Map.MaxPlayersCount = _binaryReader.ReadInt32(); Replay.Map.Size = _binaryReader.ReadInt32(); } private void ReadDynamicHeader() { var modNameLength = _binaryReader.ReadInt32(); modNameLength.RequireInRange(1, 1000, "Не удалось считать название мода"); Replay.EngineAddon = _binaryReader.ReadAsciiString(modNameLength); // Название движка (?) var engineNameLength = _binaryReader.ReadInt32(); engineNameLength.RequireInRange(1, 1000, "Не удалось считать название движка"); Replay.EngineName = _binaryReader.ReadUnicodeString(engineNameLength); var mapNameLength = _binaryReader.ReadInt32(); mapNameLength.RequireInRange(1, 1000, "Не удалось считать название карты"); // DATA:Scenarios\MP\2P_FATA_MORGANA // Пропускаем DATA:Scenarios\MP\ Replay.Map.Name = _binaryReader.Skip(18).ReadAsciiString(mapNameLength - 18); _binaryReader .SkipInt32(4) .NextAsciiStringMustEqual("DATABASE") .SkipInt32(); Descriptor.BeginDataBaseChunkSize = _binaryReader.Position; Descriptor.DataBaseChunkSize = _binaryReader.ReadInt32(); _binaryReader.SkipInt32(3); Replay.WorldSeed = _binaryReader.ReadUInt32(); _binaryReader.SkipInt32(); // Размер слотов в игре. Всегда 8 Descriptor.DataBaseStart = _binaryReader.Position; Descriptor.DataBaseSize = Descriptor.DataBaseChunkSize - 20; ReadGameSettings(); Descriptor.FileNameStart = (int)_binaryReader.Position; var ingameNameLength = _binaryReader.ReadInt32(); ingameNameLength.RequireInRange(0, 300, "Не удалось считать имя реплея"); Replay.IngameName = _binaryReader.ReadUnicodeString(ingameNameLength); _binaryReader.SkipInt32(); // 0 var winConditionCount = _binaryReader.ReadInt32(); winConditionCount.RequireInRange(0, 1000, "Не удалось считать условия победы"); var winConditions = MemoryMarshal.Cast(_binaryReader.ReadBytes(stackalloc byte[winConditionCount * 4])); Replay.WinConditions.Annihilate = winConditions.Contains((int)WinConditions.ConditionValues.Annihilate); Replay.WinConditions.SuddenDeath = winConditions.Contains((int)WinConditions.ConditionValues.SuddenDeath); Replay.WinConditions.Assassinate = winConditions.Contains((int)WinConditions.ConditionValues.Assassinate); Replay.WinConditions.EconomicVictory = winConditions.Contains((int)WinConditions.ConditionValues.EconomicVictory); Replay.WinConditions.ControlArea = winConditions.Contains((int)WinConditions.ConditionValues.ControlArea); Replay.WinConditions.DestroyHQ = winConditions.Contains((int)WinConditions.ConditionValues.DestroyHQ); Replay.WinConditions.TakeAndHold = winConditions.Contains((int)WinConditions.ConditionValues.TakeAndHold); Replay.WinConditions.GameTimer = winConditions.Contains((int)WinConditions.ConditionValues.GameTimer); _binaryReader.Skip(5); Descriptor.PlayersChunkStart = _binaryReader.Position; } // Game setting: // * 0000 - value // * AAAA - compact name // res: 0000COLS = (0000)random (COLS=SLOC)Starting locations private void ReadGameSettings() { Replay.GameSettings.AiDifficulty = _binaryReader.ReadByte(); // FDIA = AIDF = AI Difficulty _binaryReader.Skip(7); Replay.GameSettings.StartResources = _binaryReader.ReadByte(); // TSSR = RSST = Starting Resources _binaryReader.Skip(7); Replay.GameSettings.LockTeams = _binaryReader.ReadByte(); // MTKL = LKTM = Lock Teams _binaryReader.Skip(7); Replay.GameSettings.CheatsOn = _binaryReader.ReadByte(); // AEHC = CHEA = Cheats Enabled _binaryReader.Skip(7); Replay.GameSettings.StartingLocation = _binaryReader.ReadByte(); // COLS = SLOC = Starting Location _binaryReader.Skip(7); Replay.GameSettings.GameSpeed = _binaryReader.ReadByte(); // DPSG = GSPD = Game Speed _binaryReader.Skip(7); Replay.GameSettings.ResourceSharing = _binaryReader.ReadByte(); // HSSR = RSSH = Resource Sharing _binaryReader.Skip(7); Replay.GameSettings.ResourceRate = _binaryReader.ReadByte(); // TRSR = RSRT = Resource Rate _binaryReader.Skip(7); #if DEBUGLOGGING Console.WriteLine($"{Replay.GameSettings.AiDifficulty} " + $"{Replay.GameSettings.StartResources} " + $"{Replay.GameSettings.LockTeams} " + $"{Replay.GameSettings.CheatsOn} " + $"{Replay.GameSettings.StartingLocation} " + $"{Replay.GameSettings.GameSpeed} " + $"{Replay.GameSettings.ResourceSharing} " + $"{Replay.GameSettings.ResourceRate}" ); #endif _binaryReader.Skip(1); // 0 } public void ReadPlayers() { Descriptor.PlayerStartPoses = new List(); Replay.Players = new List(Consts.PlayersMaxCount); for (var players = 0; players < Consts.PlayersMaxCount; players++) Replay.Players.Add(ReadPlayer()); if (Replay.GameSettings.ArePositionsRandomized) { var randomizer = new PlayersRandomizer(Replay.WorldSeed); Replay.Players = randomizer.Randomize(Replay.Players, Replay.Map.MaxPlayersCount); } Descriptor.ActionsChunkStart = _binaryReader.Position; Descriptor.ActionsChunkSize = _binaryReader.BytesLeft; } private PlayerModel ReadPlayer() { // В реплеях c ботами нет пустых мест if (!_binaryReader.IsNextAsciiStringEquals("FOLDGPLY")) { _binaryReader.Skip(-8); return PlayerModel.Empty; } Descriptor.PlayerStartPoses.Add(_binaryReader.Position); var player = new PlayerModel(); _binaryReader .Skip(12) // int32 version 2; int32 size; int32 strLen 0; .NextAsciiStringMustEqual("DATAINFO") .Skip(12); // int32 version 1; int32 size; int32 strLen 0; player.Name = _binaryReader.ReadNextUnicodeString(); player.RawType = _binaryReader.ReadInt32(); // Тип игрока: 0 Host/2 player/4 specc/7 empty/1,3,11 computer player.Team = _binaryReader.ReadInt32() + 1; // Команда игрока (тут начинается с 0 (в игре с 1)) player.RawRace = _binaryReader.ReadNextAsciiString(); // Раса игрока: necron_race _binaryReader .Skip(Replay.Version == ReplayVersion.Steam ? 21 : 9) // byte; int32 0; int32 0xFFFFFFFF; .Skip(_binaryReader.ReadInt32()) // непонятно зачем и почему .Skip(36); if (!_binaryReader.HasBytes) return player; if (_binaryReader.IsNextAsciiStringEquals("FOLDTCUC")) { _binaryReader .Skip(12) // int32 version 1; int32 size; int32 strLen 0; .NextAsciiStringMustEqual("DATALCIN") .Skip(4); // int32 version 2; _binaryReader.ReadNextAsciiString(); // описание раскрасок отправителя реплея? // ◄ chaos_marine_race↔ chaos_marine_race/AlphaLegion↔ chaos_marine_race/AlphaLe // ← inquisition_daemonhunt_race0 inquisition_daemonhunt_race/lodge_magister_badge1 inquisition_daemonhunt_race/lodge_magister_ba _binaryReader .Skip(4) // int32 strLen 0; .NextAsciiStringMustEqual("DATAUNCU") // 42192 .Skip(12); // int32 version 1; int32 size; int32 strLen 0; player.ColorScheme = new ColorScheme { Name = _binaryReader.ReadNextUnicodeString(), Primary = _binaryReader.ReadBgraToArgb(), Secondary = _binaryReader.ReadBgraToArgb(), Trim = _binaryReader.ReadBgraToArgb(), Weapons = _binaryReader.ReadBgraToArgb(), Eyes = _binaryReader.ReadBgraToArgb() }; // Бейдж и\или баннер (могут не существовать или быть перепутаны) for (var imgNum = 0; imgNum < 2; imgNum++) { var imgTypeName = _binaryReader.ReadAsciiString(8); if (!imgTypeName.StartsWith("FOLDTC")) // fold team color { _binaryReader.Skip(-8); break; } if (SkipImages) { _binaryReader .Skip(4) .Skip(_binaryReader.ReadInt32() + 4); continue; } _binaryReader.Skip(12); // int32 version 1; int32 size; int32 strLen 0; var playerImage = new ReplayImage(); if (imgTypeName.EndsWith("BN")) { player.Banner = playerImage; } else { player.Badge = playerImage; } _binaryReader .NextAsciiStringMustEqual("FOLDIMAG") .Skip(8); // int32 version 1; int32 size; playerImage.Name = _binaryReader.ReadNextAsciiString(); _binaryReader .NextAsciiStringMustEqual("DATAATTR") .Skip(16); // int32 version 2; int32 size; 2 * int32 (?) 0; var width = _binaryReader.ReadInt32(); var height = _binaryReader.ReadInt32(); _binaryReader .Skip(4) // int32 (?) 1; .NextAsciiStringMustEqual("DATADATA") .Skip(12); // int32 version 2; int32 size; int32 (?) 0; playerImage.Init(width, height); for (var y = height - 1; y >= 0; y--) { var line = playerImage.GetLine(y); _binaryReader.ReadBytes(MemoryMarshal.AsBytes(line)); if (ImagePixelType == PixelType.Argb8888) { var uintLine = MemoryMarshal.Cast(line); for (var x = 0; x < uintLine.Length; x++) uintLine[x] = BinaryPrimitives.ReverseEndianness(uintLine[x]); } } } } else _binaryReader.Skip(-8); return player; } private void ReadTicks(bool readOrdinaryTicks) { if (Replay.Version < ReplayVersion._1_2) // Действия в старых версиях читаются не правильно { Console.WriteLine("SS version must be at least 1.2 to read actions"); return; } if (!Replay.GameSettings.IsQuickStart && !Replay.GameSettings.AreCheatsOn && CheckForBugs) AttachBugCheckers(); Replay.Actions = new List(Replay.TotalTicks / 2); Replay.ChatMessages = new List(); // Иногда в конце реплея появляются остаточные байты while (_binaryReader.BytesLeft >= 17) ReadTick(readOrdinaryTicks); #if DEBUGLOGGING if (_binaryReader.HasBytes) Console.WriteLine($"bytes left: {_binaryReader.BytesLeft} at pos: {_binaryReader.Position}"); #endif } private void AttachBugCheckers() { for (var playerId = 0; playerId < Replay.Players.Count; playerId++) { var player = Replay.Players[playerId]; if (!player.IsActive) continue; player.Id = playerId; player.BugChecker = player.Race switch { RaceEnum.Necrons => new PlayerBugChecker() .Add(new GenBugChecker(player.Id, player.Race)), RaceEnum.Eldar => new PlayerBugChecker() .Add(new FastT2Checker(player.Id, player.Race)), _ => null }; } } // структура тика: // tick: // - header // - player chunk 1: // - - action 1 // - - action 2 // - player chunk 2: // - - action 1 private void ReadTick(bool readOrdinaryTicks) { var tickType = (TickType)_binaryReader.ReadInt32(); var tickSize = _binaryReader.ReadInt32(); if (tickType == TickType.Normal) { if (tickSize == 17 || !readOrdinaryTicks) { _binaryReader.Skip(tickSize); // Всегда 17 байт } else { byte[] rentBytes = null; var bytes = tickSize switch { > 0 and < 0xFF => stackalloc byte[tickSize], _ => rentBytes = ArrayPool.Shared.Rent(tickSize) }; try { bytes = _binaryReader.ReadBytes(bytes[..tickSize]); ParseOrdinaryTick(bytes); } catch (Exception ex) { Console.WriteLine(ex); } finally { if (rentBytes is not null) ArrayPool.Shared.Return(rentBytes); } } CurrentTick++; } else if (tickType == TickType.Extra) ReadExtraTick(); else ReadWierdTick((int)tickType, tickSize); } private void ParseOrdinaryTick(Span bytes) { var spanReader = new SpanReader(bytes); // -- TICK HEADER -- size: 17 spanReader.Skip(1); // 1 byte = 0x50 var tickCount = spanReader.ReadInt32(); spanReader.Skip(8); // 8 bytes = int32 "Номер действия игрока" ??? + int32 Random var playerChunksCount = spanReader.ReadInt32(); // -- TICK BODY -- for (var i = 0; i < playerChunksCount; i++) // для каждого игрока предусмотрен свой чанк со своим размером { // -- PLAYER CHUNK HEADER -- size: 13 + пока размер чанка != 0 spanReader.Skip(8); // 8 bytes = ??? var playerChunkSize = spanReader.ReadInt32(); spanReader.Skip(1); // 1 byte = playerChunkSize, но байт // -- PLAYER CHUNK BODY -- while (playerChunkSize != 0) { var actionSize = spanReader.ReadInt16(); // размер захватывает 2 байта (размера) следующего экшена spanReader.SliceToOffset(); var action = ParsePlayerAction(ref spanReader, actionSize - 2); action.Tick = tickCount; Replay.Actions.Add(action); playerChunkSize -= actionSize; } } } private GameActionModel ParsePlayerAction(ref SpanReader spanReader, int actionSize) { // -- ACTION HEADER -- spanReader.Skip(4); // 4 bytes = какое то время (свое для каждого игрока) // (начинается с рандомного значения и увеличивается по ходу игры) // Скорее всего является global timer var cmd = spanReader.ReadInt32(); var arg = spanReader.ReadByte(); // int32 in game var subCmd = spanReader.ReadInt16(); var someNum = spanReader.ReadByte(); var shiftPressed = spanReader.ReadByte() == 1; // 2 bytes = player id var playerId = spanReader.Skip(2).ReadInt16() % 10; var playerActionCount = spanReader.ReadInt16(); #if DEBUGLOGGING var selectedCount = _binaryReader.ReadByte(); var actionTypeNum = _binaryReader.ReadByte(); var actionType = actionTypeNum switch { 1 => "1.Builder", 2 => "2.Building", 3 => "3.Army", _ => throw new ArgumentOutOfRangeException($"Action type num: {actionTypeNum} was not recognized") }; var primaryId = _binaryReader.ReadInt32(); var actionBodyDataType = _binaryReader.ReadByte(); #else // _binaryReader.Skip(7); #endif var curAction = new GameActionModel { Cmd = cmd, Arg = arg, SubCmd = subCmd, SomeByte = someNum, ShiftPressed = shiftPressed, PlayerId = playerId, PlayerActionCount = playerActionCount }; if (CheckForBugs) Replay.Players[playerId].BugChecker?.Check(curAction); spanReader.Skip((uint)(actionSize - spanReader.offset)); #if DEBUGLOGGING // -- ACTION BODY -- var leftOverBytes = actionEndPos - _binaryReader.Position; var bytes = _binaryReader.ReadBytes((int)leftOverBytes); var player = Replay.Players[curAction.PlayerId]; var playerRace = player.Race == RaceEnum.Unknown ? player.RawRace : player.Race.ToString(); // curAction.PlayerId == 0 && pCommandNum == 117 && Replay.EngineAddon == "dxp2" dataLeftType == 1 && !nn.Contains(f) && !player.Name.StartsWith("Computer") // if (Replay.EngineAddon == "dxp2") // { var playname = player.Name; var playnameFormatted = player.Name.PadRight(50 - player.Name.Length, '#'); Console.WriteLine($"{playname.Length} {playname} \n{playnameFormatted.Length} {playnameFormatted}"); // Actions.Add(curAction.Cmd); Console.Write($"[{TicksFormatter.Format(curAction.Tick)}] | "); // base p:{startPos,-7} Console.Write($"{playnameFormatted} | {playerRace,-18} | "); // player info Console.Write($"{curAction.Name,-30} | {curAction} | {selectedCount} units | "); Console.Write($"{actionType,-10} | {primaryId,-5} | "); Console.WriteLine($"{actionBodyDataType} | {bytes.ToContentString()}"); // } #else // -- ACTION BODY -- // var leftOverBytes = actionEndPos - _binaryReader.Position; // _binaryReader.Skip((int)leftOverBytes); #endif return curAction; } private void ReadExtraTick() { var tickType = _binaryReader.ReadInt32(); _binaryReader.Skip(4); // Размер тика _binaryReader.Skip(1); // Размер тика, но байт if (tickType == 0) ReadPlayerQuit(); else if (tickType == 1) Replay.ChatMessages.Add(ReadChatMessage()); } private void ReadPlayerQuit() // Событие выхода игрока. (может отсутствовать) { // ED 03 00 00 00 00 00 00 => 03 ED = int16 player id + 1000; _binaryReader.Skip(8); } private ChatMessageModel ReadChatMessage() => new( ticks: CurrentTick, senderName: _binaryReader.ReadNextUnicodeString(), senderId: _binaryReader.ReadInt32() % 10, from: (SenderType)_binaryReader.ReadInt32(), // 0 player | 1 observer | 2 system to: (ReceiverType)_binaryReader.ReadInt32(), // 0 all | 1 team | 2 system text: _binaryReader.ReadNextUnicodeString() ); private void ReadWierdTick(int type, int size) { Replay.Exception = new Exception($"Wierd tick at {_binaryReader.Position} of type:{type} and size:{size}"); // Console.WriteLine(Replay.Exception.Message); _binaryReader.Skip(size); } public void Dispose() { _binaryReader.Dispose(); } }