commit f2db04033d1342d3bfc016f1b7c1debadb800fea Author: Tretiner Date: Fri Jul 26 20:49:10 2024 +0500 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..add57be --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ \ No newline at end of file diff --git a/.idea/.idea.SoulstormReplayReader/.idea/.gitignore b/.idea/.idea.SoulstormReplayReader/.idea/.gitignore new file mode 100644 index 0000000..9b0c2a1 --- /dev/null +++ b/.idea/.idea.SoulstormReplayReader/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/projectSettingsUpdater.xml +/modules.xml +/contentModel.xml +/.idea.SoulstormReplayReader.iml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.SoulstormReplayReader/.idea/misc.xml b/.idea/.idea.SoulstormReplayReader/.idea/misc.xml new file mode 100644 index 0000000..30bab2a --- /dev/null +++ b/.idea/.idea.SoulstormReplayReader/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/SoulstormReplayReader.Core/.idea/.idea.SoulstormReplayReader.dir/.idea/.gitignore b/SoulstormReplayReader.Core/.idea/.idea.SoulstormReplayReader.dir/.idea/.gitignore new file mode 100644 index 0000000..c75b213 --- /dev/null +++ b/SoulstormReplayReader.Core/.idea/.idea.SoulstormReplayReader.dir/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/projectSettingsUpdater.xml +/.idea.SoulstormReplayReader.iml +/contentModel.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/SoulstormReplayReader.Core/.idea/.idea.SoulstormReplayReader.dir/.idea/misc.xml b/SoulstormReplayReader.Core/.idea/.idea.SoulstormReplayReader.dir/.idea/misc.xml new file mode 100644 index 0000000..30bab2a --- /dev/null +++ b/SoulstormReplayReader.Core/.idea/.idea.SoulstormReplayReader.dir/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Const.cs b/SoulstormReplayReader.Core/Const.cs new file mode 100644 index 0000000..e36bd19 --- /dev/null +++ b/SoulstormReplayReader.Core/Const.cs @@ -0,0 +1,6 @@ +namespace SoulstormReplayReader.Core; + +public static class Consts +{ + public const int PlayersMaxCount = 8; +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Domain/Action/GameActionModel.cs b/SoulstormReplayReader.Core/Domain/Action/GameActionModel.cs new file mode 100644 index 0000000..4aeabbd --- /dev/null +++ b/SoulstormReplayReader.Core/Domain/Action/GameActionModel.cs @@ -0,0 +1,86 @@ +namespace SoulstormReplayReader.Core.Domain.Action; + +public sealed class GameActionModel(int tick) : IGameAction +{ + public int Tick { get; } = tick; + + public int PlayerId { get; set; } + public int PlayerActionCount { get; set; } + + public int Cmd { get; set; } + public byte Arg { get; set; } + public short SubCmd { get; set; } + public byte SomeByte { get; set; } + public bool ShiftPressed { get; set; } + + public string Name => GetName(Cmd); + + public override string ToString() => $"{Cmd,3} {Arg,3} {SubCmd:x} {SomeByte:x} {ShiftPressed,5}"; + + private static string GetName(int playerCommand) => playerCommand switch + { + 0 => "Moved unit", + 2 => "Destroyed building", + 3 => "Train unit", + 4 => "Canceled research", + 6 => "Research started", + 7 => "Building is upgrading", + 10 => "UI building attack", + 14 => "Point of gathering", + 18 => "Changed building behavior", + 20 => "Building abils?? (laud hailer)", + 24 => "Auto train", + 26 => "Get from building", + 28 => "[eldar] Teleport building", + 56 => "UI move", + 57 => "UI stop", + 58 => "Delete unit", + 62 => "Clicked to reinforce", + 63 => "Training squad leader", + 64 => "Weapon upgrade row:1 col:$Arg", + 65 => "Weapon reinforce (->64)", + 66 => "UI attack ground", + 67 => "UI attack melee", + 68 => "Changed troops behavior", + 69 => "UI melee/range", + 70 => "UI attack move", // 0x09EB1DB8=attackmove_modal + 71 => "Jump/Teleport", + 73 => "Turn on ability", + 74 => "Connect leader", + 75 => "Disconnect leader", + 77 => "Squad leader auto reinforce", + 78 => "Auto reinforce", + 79 => "Auto reinforce weapon (->64)", + 80 => "Building repair", + 82 => "Land troops", + 84 => "[ork] Skwiggott stomps", + 85 => "Cargo unit button", + 89 => "[necr] Start collecting bodies", + 90 => "[necr] Stop collecting bodies", + 91 => "[necr] Summon nw from spider", + 94 => "[necr] Summon attack scarabs", + 97 => "[necr] Possess the tech", + 107 => "[eld] Dance of death", + 100 => "[necr] Summon lie monolith", + 103 => "[necr] Cast stasis field", + 110 => "[necr] Deceived", + 112 => "[necr] Turn in nightbringer", + 113 => "[necr] Turn in deceiver", + 117 => "Building started", + 126 => "All overwatch", + 127 => "Cancel all overwatch", + _ => "UNKNOWN" + }; +} + +/* +auto reinforce flames +auto reinforce +cancel auto reinforce +cancel auto reinforce flames +auto reinforce flames +auto reinforce +cancel auto reinforce +click reinforce +auto reinforce +*/ \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Domain/Action/IGameAction.cs b/SoulstormReplayReader.Core/Domain/Action/IGameAction.cs new file mode 100644 index 0000000..618dcef --- /dev/null +++ b/SoulstormReplayReader.Core/Domain/Action/IGameAction.cs @@ -0,0 +1,15 @@ +namespace SoulstormReplayReader.Core.Domain.Action; + +public interface IGameAction +{ + public int Tick { get; } + + public int PlayerId { get; set; } + public int PlayerActionCount { get; set; } + + public int Cmd { get; set; } + public byte Arg { get; set; } + public short SubCmd { get; set; } + public byte SomeByte { get; set; } + public bool ShiftPressed { get; set; } +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Domain/BugCheckers/BugCheckerBase.cs b/SoulstormReplayReader.Core/Domain/BugCheckers/BugCheckerBase.cs new file mode 100644 index 0000000..8ba4bae --- /dev/null +++ b/SoulstormReplayReader.Core/Domain/BugCheckers/BugCheckerBase.cs @@ -0,0 +1,15 @@ +using SoulstormReplayReader.Core.Domain.Action; + +namespace SoulstormReplayReader.Core.Domain.BugCheckers; + +public abstract class BugCheckerBase(int playerId, RaceEnum playerRace) +{ + public readonly int PlayerId = playerId; + public readonly RaceEnum PlayerRace = playerRace; + + public abstract void Check(IGameAction action); + + public bool IsCompleted { get; set; } + public string Accusation { get; set; } + public bool HasAccusation => !string.IsNullOrEmpty(Accusation); +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Domain/BugCheckers/FastT2Checker.cs b/SoulstormReplayReader.Core/Domain/BugCheckers/FastT2Checker.cs new file mode 100644 index 0000000..7731969 --- /dev/null +++ b/SoulstormReplayReader.Core/Domain/BugCheckers/FastT2Checker.cs @@ -0,0 +1,61 @@ +using SoulstormReplayReader.Core.Domain.Action; +using SoulstormReplayReader.Core.Utils; + +namespace SoulstormReplayReader.Core.Domain.BugCheckers; + +public sealed class FastT2Checker(int playerId, RaceEnum playerRace) : BugCheckerBase(playerId, playerRace) +{ + private byte _gensBuilt = 0; + private bool _isAspectPortalBuilt = false; + private bool _isSoulShrineBuilt = false; + private bool _isListenPostBuilt = false; + private bool _isMachineryBuilt = false; + + public override void Check(IGameAction action) + { + if (IsCompleted) + return; + + if (PlayerId != action.PlayerId) + return; + + if (action.Cmd != 117) + return; + + switch (action.Arg) + { + case 24: + _gensBuilt++; + break; + case 14: + _isListenPostBuilt = true; + break; + case 7: + _isAspectPortalBuilt = true; + break; + case 16: + _isSoulShrineBuilt = true; + break; + case 22: + _isMachineryBuilt = true; + break; + } + + if (_isMachineryBuilt) + { + if (action.Tick <= (2 * 60 + 30) * 8) + { + IsCompleted = true; + Accusation = "FastT2: machinery build started in under 2:30 minutes"; + } + else if (_gensBuilt >= 3 && _isSoulShrineBuilt && !_isAspectPortalBuilt) + { + IsCompleted = true; + var time = TicksFormatter.Format(action.Tick, TicksFormatter.Pattern.Hms); + Accusation = $"FastT2: machinery build started in {time} but there is no aspect portal"; + if (!_isListenPostBuilt) + Accusation += " and listening posts"; + } + } + } +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Domain/BugCheckers/GenBugChecker.cs b/SoulstormReplayReader.Core/Domain/BugCheckers/GenBugChecker.cs new file mode 100644 index 0000000..782c6ad --- /dev/null +++ b/SoulstormReplayReader.Core/Domain/BugCheckers/GenBugChecker.cs @@ -0,0 +1,37 @@ +using SoulstormReplayReader.Core.Domain.Action; + +namespace SoulstormReplayReader.Core.Domain.BugCheckers; + +public sealed class GenBugChecker(int playerId, RaceEnum playerRace) : BugCheckerBase(playerId, playerRace) +{ + private const int TicksRange = 4; + private const int GensToTrigger = 3; + + private int _startTick; + private int _gensBuilt; + + public override void Check(IGameAction action) + { + if (IsCompleted) + return; + + if (PlayerId != action.PlayerId) + return; + + if (action.Cmd != 117 || action.Arg != 141) + return; + + if (_startTick == 0 || action.Tick - _startTick > TicksRange) + { + _startTick = action.Tick; + _gensBuilt = 0; + return; + } + + if (++_gensBuilt == GensToTrigger) + { + Accusation = $"GenBug: {GensToTrigger}+ gens added in under {TicksRange / 8.0} sec"; + IsCompleted = true; + } + } +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Domain/ChatMessage/ChatMessageModel.cs b/SoulstormReplayReader.Core/Domain/ChatMessage/ChatMessageModel.cs new file mode 100644 index 0000000..379d5fc --- /dev/null +++ b/SoulstormReplayReader.Core/Domain/ChatMessage/ChatMessageModel.cs @@ -0,0 +1,36 @@ +using SoulstormReplayReader.Core.Utils; + +namespace SoulstormReplayReader.Core.Domain.ChatMessage; + +public readonly struct ChatMessageModel +{ + public readonly int Ticks; + public readonly string SenderName; + public readonly int SenderId; + public readonly string Text; + public readonly SenderType From; + public readonly ReceiverType To; + + public ChatMessageModel( + int ticks, + string senderName, + int senderId, + string text, + SenderType from, + ReceiverType to + ) + { + Ticks = ticks; + SenderName = senderName; + SenderId = senderId; + Text = text; + From = from; + To = to; + } + + public string FormattedTime => TicksFormatter.ShortFormat(Ticks); + + public string FromTo => From == SenderType.System ? "(All) System:" : $"({To}) {SenderName}:"; + + public override string ToString() => $"[{FormattedTime}] {FromTo} {Text}"; +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Domain/ChatMessage/ReceiverType.cs b/SoulstormReplayReader.Core/Domain/ChatMessage/ReceiverType.cs new file mode 100644 index 0000000..8a014b4 --- /dev/null +++ b/SoulstormReplayReader.Core/Domain/ChatMessage/ReceiverType.cs @@ -0,0 +1,8 @@ +namespace SoulstormReplayReader.Core.Domain.ChatMessage; + +public enum ReceiverType : byte +{ + All = 0, + Team, + System +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Domain/ChatMessage/SenderType.cs b/SoulstormReplayReader.Core/Domain/ChatMessage/SenderType.cs new file mode 100644 index 0000000..15a4ee0 --- /dev/null +++ b/SoulstormReplayReader.Core/Domain/ChatMessage/SenderType.cs @@ -0,0 +1,8 @@ +namespace SoulstormReplayReader.Core.Domain.ChatMessage; + +public enum SenderType : byte +{ + Player = 0, + Observer, + System +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Domain/GameSettings.cs b/SoulstormReplayReader.Core/Domain/GameSettings.cs new file mode 100644 index 0000000..dc53452 --- /dev/null +++ b/SoulstormReplayReader.Core/Domain/GameSettings.cs @@ -0,0 +1,17 @@ +namespace SoulstormReplayReader.Core.Domain; + +public sealed class GameSettings +{ + public byte AiDifficulty { get; set; } + public byte StartResources { get; set; } + public byte LockTeams { get; set; } + public byte CheatsOn { get; set; } + public byte StartingLocation { get; set; } + public byte GameSpeed { get; set; } + public byte ResourceSharing { get; set; } + public byte ResourceRate { get; set; } + + public bool IsQuickStart => StartResources == 1; + public bool AreCheatsOn => CheatsOn == 1; + public bool ArePositionsRandomized => StartingLocation == 0; +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Domain/Player/ColorScheme.cs b/SoulstormReplayReader.Core/Domain/Player/ColorScheme.cs new file mode 100644 index 0000000..cd75585 --- /dev/null +++ b/SoulstormReplayReader.Core/Domain/Player/ColorScheme.cs @@ -0,0 +1,14 @@ +using SoulstormReplayReader.Core.Models; + +namespace SoulstormReplayReader.Core.Domain.Player; + +public sealed class ColorScheme +{ + public string Name { get; set; } + + public ReplayColor Primary { get; set; } + public ReplayColor Secondary { get; set; } + public ReplayColor Trim { get; set; } + public ReplayColor Weapons { get; set; } + public ReplayColor Eyes { get; set; } +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Domain/Player/PlayerBugChecker.cs b/SoulstormReplayReader.Core/Domain/Player/PlayerBugChecker.cs new file mode 100644 index 0000000..4c37b31 --- /dev/null +++ b/SoulstormReplayReader.Core/Domain/Player/PlayerBugChecker.cs @@ -0,0 +1,40 @@ +using System.Text; +using SoulstormReplayReader.Core.Domain.Action; +using SoulstormReplayReader.Core.Domain.BugCheckers; + +namespace SoulstormReplayReader.Core.Domain.Player; + +public class PlayerBugChecker +{ + private readonly List _bugCheckers = new(); + + public PlayerBugChecker Add(BugCheckerBase checker) + { + _bugCheckers.Add(checker); + return this; + } + + public void Check(IGameAction action) + { + _bugCheckers.ForEach(bugChecker => bugChecker.Check(action)); + } + + public bool HasAccusations() => _bugCheckers.Any(checker => checker.HasAccusation); + + public string GetAccusationsList() + { + var sb = new StringBuilder(); + + foreach (var checker in _bugCheckers.Where(checker => checker.HasAccusation)) + { + sb.AppendLine(checker.Accusation); + } + + var accusationsList = sb.ToString(); + + if (string.IsNullOrEmpty(accusationsList)) + return null; + + return accusationsList; + } +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Domain/Player/PlayerModel.cs b/SoulstormReplayReader.Core/Domain/Player/PlayerModel.cs new file mode 100644 index 0000000..1562921 --- /dev/null +++ b/SoulstormReplayReader.Core/Domain/Player/PlayerModel.cs @@ -0,0 +1,57 @@ +using SoulstormReplayReader.Core.Enums; +using SoulstormReplayReader.Core.Models; + +namespace SoulstormReplayReader.Core.Domain.Player; + +public sealed class PlayerModel +{ + public static readonly PlayerModel Empty = new(); + + public PlayerBugChecker BugChecker = null; + + public int Id { get; set; } + public string Name { get; set; } + public int RawType { get; set; } + public string RawRace { get; set; } + public int Team { get; set; } + + public ColorScheme ColorScheme { get; set; } + public ReplayImage Banner { get; set; } + public ReplayImage Badge { get; set; } + + public ReplayPlayerType ResolvedType => RawType switch + { + _ when Name is null && RawRace is null => ReplayPlayerType.EmptySlot, + 0 => ReplayPlayerType.Host, + 1 => ReplayPlayerType.Computer, + 2 => ReplayPlayerType.Player, + 3 => ReplayPlayerType.Computer, + 4 => ReplayPlayerType.Spectator, + 7 => ReplayPlayerType.EmptySlot, + 11 => ReplayPlayerType.Computer, + _ => ReplayPlayerType.EmptySlot + }; + + public RaceEnum Race => RawRace?.ToLowerInvariant() switch + { + "space_marine_race" => RaceEnum.SpaceMarines, + "ork_race" => RaceEnum.Orks, + "eldar_race" => RaceEnum.Eldar, + "chaos_marine_race" => RaceEnum.ChaosSpaceMarines, + "dark_eldar_race" => RaceEnum.DarkEldar, + "necron_race" => RaceEnum.Necrons, + "sisters_race" => RaceEnum.SistersOfBattle, + "tau_race" => RaceEnum.Tau, + "guard_race" => RaceEnum.ImperialGuard, + _ => RaceEnum.Unknown + }; + + public string RaceName => RawRace[..^5].Replace('_', ' '); + + public string NameTrimNewLines => Name.Replace("\t", "").Split('\r', '\n')[0]; + + public bool IsHost => ResolvedType is ReplayPlayerType.Host; + public bool IsActive => ResolvedType is ReplayPlayerType.Player or ReplayPlayerType.Host or ReplayPlayerType.Computer; + public bool IsSpectator => ResolvedType is ReplayPlayerType.Spectator; + public bool IsEmpty => string.IsNullOrEmpty(Name) || ResolvedType is ReplayPlayerType.EmptySlot; +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Domain/RaceEnum.cs b/SoulstormReplayReader.Core/Domain/RaceEnum.cs new file mode 100644 index 0000000..8d8e650 --- /dev/null +++ b/SoulstormReplayReader.Core/Domain/RaceEnum.cs @@ -0,0 +1,15 @@ +namespace SoulstormReplayReader.Core.Domain; + +public enum RaceEnum +{ + Unknown = 0, + SpaceMarines, + Orks, + Eldar, + ChaosSpaceMarines, + DarkEldar, + Necrons, + SistersOfBattle, + Tau, + ImperialGuard +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Domain/Replay/Map.cs b/SoulstormReplayReader.Core/Domain/Replay/Map.cs new file mode 100644 index 0000000..2a3e39d --- /dev/null +++ b/SoulstormReplayReader.Core/Domain/Replay/Map.cs @@ -0,0 +1,8 @@ +namespace SoulstormReplayReader.Core.Domain.Replay; + +public sealed class Map +{ + public string Name { get; set; } + public int MaxPlayersCount { get; set; } + public int Size { get; set; } +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Domain/Replay/ReplayModel.cs b/SoulstormReplayReader.Core/Domain/Replay/ReplayModel.cs new file mode 100644 index 0000000..3366b81 --- /dev/null +++ b/SoulstormReplayReader.Core/Domain/Replay/ReplayModel.cs @@ -0,0 +1,34 @@ +using SoulstormReplayReader.Core.Domain.Action; +using SoulstormReplayReader.Core.Domain.ChatMessage; +using SoulstormReplayReader.Core.Domain.Player; +using SoulstormReplayReader.Core.Enums; + +namespace SoulstormReplayReader.Core.Domain.Replay; + +public sealed class ReplayModel +{ + public ReplayVersion Version { get; set; } + + public string IngameName { get; set; } + public string ModName { get; set; } + public string EngineName { get; set; } + public string EngineAddon { get; set; } // Не очень понятно что это + + public uint WorldSeed { get; set; } + public int TotalTicks { get; set; } + + public Map Map { get; } = new(); + + public List Players { get; set; } + public List Actions { get; set; } + public List ChatMessages { get; set; } + + public GameSettings GameSettings { get; } = new(); + public WinConditions WinConditions { get; } = new(); + + public Exception Exception { get; set; } + + public IEnumerable ActivePlayers => Players.Where(x => x.IsActive); + public int ActivePlayersCount => Players.Count(p => p.IsActive); + public int TeamsCount => Players.Select(player => player.Team).Max(); +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Domain/Tick/TickType.cs b/SoulstormReplayReader.Core/Domain/Tick/TickType.cs new file mode 100644 index 0000000..d4a16a2 --- /dev/null +++ b/SoulstormReplayReader.Core/Domain/Tick/TickType.cs @@ -0,0 +1,7 @@ +namespace SoulstormReplayReader.Core.Domain.Tick; + +public enum TickType: byte +{ + Normal = 0, + Extra = 1 +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Domain/WinCondition.cs b/SoulstormReplayReader.Core/Domain/WinCondition.cs new file mode 100644 index 0000000..e15527a --- /dev/null +++ b/SoulstormReplayReader.Core/Domain/WinCondition.cs @@ -0,0 +1,75 @@ +using System.Text; + +namespace SoulstormReplayReader.Core.Domain; + +public sealed class WinConditions +{ + public enum ConditionValues + { + Annihilate = 1003066394, + SuddenDeath = -1826760460, + Assassinate = 200405640, + EconomicVictory = -242444938, + ControlArea = 735076042, + DestroyHQ = 1509920563, + TakeAndHold = 1959084950, + GameTimer = 69421273 + } + + // [DWORD] [WIN_CONDITION] + // 767227721 Annihilate + // -1826760460 SuddenDeath + // 200405640 Assassinate + // -242444938 EconomicVictory + // 735076042 ControlArea + // 1509920563 DestroyHQ + // 1959084950 TakeAndHold + // 69421273 GameTime + + public bool TakeAndHold { get; set; } + + public bool DestroyHQ { get; set; } + + public bool ControlArea { get; set; } + + public bool EconomicVictory { get; set; } + + public bool SuddenDeath { get; set; } + + public bool Annihilate { get; set; } + + public bool Assassinate { get; set; } + + public bool GameTimer { get; set; } + + public override string ToString() + { + var sb = new StringBuilder(); + + if (Annihilate) + sb.Append("Annihilate "); + + if (Assassinate) + sb.Append(nameof(Assassinate) + ' '); + + if (ControlArea) + sb.Append("ControlArea "); + + if (DestroyHQ) + sb.Append("DestroyHQ "); + + if (EconomicVictory) + sb.Append("EconomicVictory "); + + if (GameTimer) + sb.Append("GameTimer "); + + if (SuddenDeath) + sb.Append("SuddenDeath "); + + if (TakeAndHold) + sb.Append("TakeAndHold "); + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Enums/ReplayPlayerType.cs b/SoulstormReplayReader.Core/Enums/ReplayPlayerType.cs new file mode 100644 index 0000000..95f4365 --- /dev/null +++ b/SoulstormReplayReader.Core/Enums/ReplayPlayerType.cs @@ -0,0 +1,10 @@ +namespace SoulstormReplayReader.Core.Enums; + +public enum ReplayPlayerType +{ + EmptySlot = 0, + Host, + Computer, + Spectator, + Player +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Enums/ReplayVersion.cs b/SoulstormReplayReader.Core/Enums/ReplayVersion.cs new file mode 100644 index 0000000..584f19c --- /dev/null +++ b/SoulstormReplayReader.Core/Enums/ReplayVersion.cs @@ -0,0 +1,7 @@ +namespace SoulstormReplayReader.Core.Enums; + +public enum ReplayVersion : byte +{ + _1_2 = 9, + Steam = 10 +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Extensions/ExBinaryReader.cs b/SoulstormReplayReader.Core/Extensions/ExBinaryReader.cs new file mode 100644 index 0000000..9fada4f --- /dev/null +++ b/SoulstormReplayReader.Core/Extensions/ExBinaryReader.cs @@ -0,0 +1,87 @@ +using System.Text; + +namespace SoulstormReplayReader.Core.Extensions; + +public class ExBinaryReader : BinaryReader +{ + public long Position => BaseStream.Position; + public long Length => BaseStream.Length; + public long BytesLeft => Length - Position; + public bool HasBytes => Position < Length; + + public ExBinaryReader(Stream stream) : base(stream) { } + + public ExBinaryReader(byte[] byteArray) : base(new MemoryStream(byteArray)) { } + + public ExBinaryReader SkipInt32(int count = 1) => Skip(count * 4); + + public ExBinaryReader Skip(int bytesCount) + { + BaseStream.Seek(bytesCount, SeekOrigin.Current); + return this; + } + + public ExBinaryReader Seek(int offset, SeekOrigin seekOrigin = SeekOrigin.Begin) + { + BaseStream.Seek(offset, seekOrigin); + return this; + } + + public ExBinaryReader Seek(uint offset, SeekOrigin seekOrigin = SeekOrigin.Begin) + { + BaseStream.Seek(offset, seekOrigin); + return this; + } + + public ExBinaryReader Seek(long offset, SeekOrigin seekOrigin = SeekOrigin.Begin) + { + BaseStream.Seek(offset, seekOrigin); + return this; + } + + public string ReadNextUnicodeString() => ReadUnicodeString(ReadInt32()); + + public string ReadUnicodeString(int length) + { + var doubleLength = length * 2; + var bytes = doubleLength switch + { + > 0 and < 0xFF => ReadBytes(stackalloc byte[doubleLength]), + _ => ReadBytes(doubleLength) + }; + + return Encoding.UTF8.GetString(bytes); + } + + public string ReadNextAsciiString() => ReadAsciiString(ReadInt32()); + + public string ReadAsciiString(int length) + { + var bytes = length switch + { + > 0 and < 0xFF => ReadBytes(stackalloc byte[length]), + _ => ReadBytes(length) + }; + + return Encoding.ASCII.GetString(bytes); + } + + public string ReadCString(int length = 150) + { + var bytes = length switch + { + > 0 and < 0xFF => ReadBytes(stackalloc byte[length]), + _ => ReadBytes(length) + }; + + bytes = bytes[..bytes.IndexOf(byte.MinValue)]; + + return Encoding.ASCII.GetString(bytes); + } + + public Span ReadBytes(Span buffer) + { + BaseStream.ReadExactly(buffer); + return buffer; + } +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Extensions/Extensions.cs b/SoulstormReplayReader.Core/Extensions/Extensions.cs new file mode 100644 index 0000000..2e1a50a --- /dev/null +++ b/SoulstormReplayReader.Core/Extensions/Extensions.cs @@ -0,0 +1,93 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text; +using SoulstormReplayReader.Core.Domain.Player; +using SoulstormReplayReader.Core.Models; + +namespace SoulstormReplayReader.Core.Extensions; + +public static class Extensions +{ + public static void RequireInRange(this int num, int min, int max, string errorMsg) + { + if (num < min || num > max) + throw new ArgumentOutOfRangeException(errorMsg); + } + + public static void Swap(this IList list, int i1, int i2) + { + (list[i1], list[i2]) = (list[i2], list[i1]); + } + + public static Span TrimEnd(this Span span, byte val = 0) + { + var i = span.Length - 1; + while (i > 0 && span[i--] == val) ; + return span; + } + + public static ExBinaryReader NextAsciiStringMustEqual(this ExBinaryReader binReader, string text) + { + if (!binReader.IsNextAsciiStringEquals(text)) + { + throw new Exception(text + "Нарушение структуры файла. "); + } + + return binReader; + } + + public static bool IsNextAsciiStringEquals(this ExBinaryReader binReader, string text) + { + Span textSpan = Encoding.ASCII.GetBytes(text); + var nextTextSpan = binReader.ReadBytes(stackalloc byte[text.Length]); + + return textSpan.Length == nextTextSpan.Length && SpansEqualInvariant(textSpan, nextTextSpan); + + static bool SpansEqualInvariant(ReadOnlySpan span1, ReadOnlySpan span2) + { + for (var i = 0; i < span1.Length; i++) + { + if (span1[i] == span2[i]) + continue; + + var char1 = char.ToLower((char)span1[i]); + var char2 = char.ToLower((char)span2[i]); + if (char1 != char2) + { + return false; + } + } + + return true; + } + } + + public static ReplayColor ReadBgraToArgb(this ExBinaryReader binReader) + { + var bgra = binReader.ReadUInt32(); + + var argb = ReverseBytes(bgra); + + return Unsafe.As(ref argb); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint ReverseBytes(uint value) => + ((value & 0x000000FFU) << 24) | ((value & 0x0000FF00U) << 8) | + ((value & 0x00FF0000U) >> 8) | ((value & 0xFF000000U) >> 24); + + public static string ToContentString(this IEnumerable bytes) => + string.Join(' ', + bytes.Select(b => $"{b:x}".PadLeft(2, '0')) + ); + + [Conditional("DEBUGLOGGING")] + public static void LogPlayers(this List players) + { + Console.WriteLine("NAME TEAM TYPE RACE"); + foreach (var pl in players.Where(p => p != null)) + { + Console.WriteLine($"{pl.Name ?? "",-15} {pl.Team,-8} {pl.RawType} {pl.ResolvedType,-10} {pl.Race}"); + } + } +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Models/ReplayColor.cs b/SoulstormReplayReader.Core/Models/ReplayColor.cs new file mode 100644 index 0000000..d3e8486 --- /dev/null +++ b/SoulstormReplayReader.Core/Models/ReplayColor.cs @@ -0,0 +1,23 @@ +namespace SoulstormReplayReader.Core.Models; + +public readonly struct ReplayColor +{ + public readonly byte A; + public readonly byte R; + public readonly byte G; + public readonly byte B; + + private ReplayColor(byte a, byte r, byte g, byte b) + { + A = a; + R = r; + G = g; + B = b; + } + + public static ReplayColor FromArgb(params byte[] argb) => new(argb[0], argb[1], argb[2], argb[3]); + + public static ReplayColor FromBgra(params byte[] bgra) => new(bgra[3], bgra[2], bgra[1], bgra[0]); + + public readonly override string ToString() => $"{A} {R} {G} {B}"; +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Models/ReplayImage.cs b/SoulstormReplayReader.Core/Models/ReplayImage.cs new file mode 100644 index 0000000..57908e3 --- /dev/null +++ b/SoulstormReplayReader.Core/Models/ReplayImage.cs @@ -0,0 +1,24 @@ +namespace SoulstormReplayReader.Core.Models; + +/// +/// Native dow rgd image consists of bgra pixels +/// +public sealed class ReplayImage +{ + public string Name { get; set; } + public int Width { get; private set; } + public int Height { get; private set; } + public ReplayColor[] Bitmap { get; private set; } + + public void Init(int width, int height) + { + Width = width; + Height = height; + Bitmap = new ReplayColor[height * width]; + } + + public void SetPixel(int x, int y, ReplayColor color) + { + Bitmap[y * Width + x] = color; + } +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/ReplayDescriptor.cs b/SoulstormReplayReader.Core/ReplayDescriptor.cs new file mode 100644 index 0000000..63099db --- /dev/null +++ b/SoulstormReplayReader.Core/ReplayDescriptor.cs @@ -0,0 +1,24 @@ +namespace SoulstormReplayReader.Core; + +public sealed class ReplayDescriptor +{ + public int FileNameStart { get; set; } + + public long FoldInfoStart { get; set; } + public int FoldInfoSize { get; set; } + + public int DataBaseChunkSize { get; set; } + public long BeginDataBaseChunkSize { get; set; } + + public long DataBaseStart { get; set; } + public int DataBaseSize { get; set; } + + public long FoldWmanStart { get; set; } + public int FoldWmanSize { get; set; } + + public long PlayersChunkStart { get; set; } + public List PlayerStartPoses { get; set; } + + public long ActionsChunkStart { get; set; } + public long ActionsChunkSize { get; set; } +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Services/ReplayService.cs b/SoulstormReplayReader.Core/Services/ReplayService.cs new file mode 100644 index 0000000..4e837f5 --- /dev/null +++ b/SoulstormReplayReader.Core/Services/ReplayService.cs @@ -0,0 +1,42 @@ +using System.Text; +using SoulstormReplayReader.Core.Extensions; + +namespace SoulstormReplayReader.Core.Services; + +public static class ReplayService +{ + public static void Rename(Stream inStream, Stream outStream, string newName) + { + var binaryReader = new ExBinaryReader(inStream); + var binaryWriter = new BinaryWriter(outStream); + + // Костыльное нахождение длины старого имени игры + binaryReader + .Skip(182) // Перемещаюсь на позицию после (GameInfo.FOLDWMAN....) + .Skip(binaryReader.ReadInt32() + 20) // DataSdscSize + 24 + .Skip(85); + var bytesLengthDifference = (newName.Length - binaryReader.ReadInt32()) * 2; + binaryReader.Seek(0); + + binaryWriter.Write(binaryReader.ReadBytes(149 + 4)); // Перемещаюсь к позиции перед размером FOLDINFO + binaryWriter.Write(binaryReader.ReadInt32() + bytesLengthDifference); // Переписываю, добавляя разность длинн имен + + binaryWriter.Write(binaryReader.ReadBytes(21 + 4)); // Иду к позиции перед размером FOLDWMAN + + var foldWmanSize = binaryReader.ReadInt32(); + binaryWriter.Write(foldWmanSize); + binaryWriter.Write(binaryReader.ReadBytes(foldWmanSize + 16)); // Телепортируюсь к позиции перед размером DataBase + binaryWriter.Write(binaryReader.ReadInt32() + bytesLengthDifference); // Переписываю, добавляя разность длинн имен + + binaryWriter.Write(binaryReader.ReadBytes(85)); // Первые 85 байт DataBase статичные + binaryWriter.Write(newName.Length); // После них идет длина имени игры + + binaryReader.Skip(binaryReader.ReadInt32() * 2); + binaryWriter.Write(Encoding.Unicode.GetBytes(newName)); // И ее имя + + while (binaryReader.HasBytes) + binaryWriter.Write(binaryReader.ReadByte()); + + binaryWriter.Flush(); + } +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/SoulstormReplayReader.Core.csproj b/SoulstormReplayReader.Core/SoulstormReplayReader.Core.csproj new file mode 100644 index 0000000..b9eb0b2 --- /dev/null +++ b/SoulstormReplayReader.Core/SoulstormReplayReader.Core.csproj @@ -0,0 +1,11 @@ + + + + Library + net8.0 + enable + true + Debug;Release;DebugLogging + AnyCPU + + diff --git a/SoulstormReplayReader.Core/SsReplayReader.cs b/SoulstormReplayReader.Core/SsReplayReader.cs new file mode 100644 index 0000000..159cc56 --- /dev/null +++ b/SoulstormReplayReader.Core/SsReplayReader.cs @@ -0,0 +1,614 @@ +using System.Runtime.CompilerServices; +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 ReplayDescriptor Descriptor { get; } = new(); + public ReplayModel Replay { get; set; } + public int CurrentTick { get; set; } + + public ReplayModel ReadFull() + { + ReadInfo(); + + ReadTicks(); + + 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--) + { + for (var x = 0; x < width; x++) + { + playerImage.SetPixel(x, y, _binaryReader.ReadBgraToArgb()); + } + } + } + } + else _binaryReader.Skip(-8); + + return player; + } + + private void ReadTicks() + { + 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(); + +#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() + { + var tickType = (TickType)_binaryReader.ReadInt32(); + var tickSize = _binaryReader.ReadInt32(); + + if (tickType == TickType.Normal) + { + if (tickSize == 17) + SkipEmptyTick(); + else + ReadOrdinaryTick(); + + CurrentTick++; + } + else if (tickType == TickType.Extra) + ReadExtraTick(); + else + ReadWierdTick((int)tickType, tickSize); + } + + + // Всегда 17 байт + private void SkipEmptyTick() + { + _binaryReader.Skip(17); + } + + + private void ReadOrdinaryTick() + { + // -- TICK HEADER -- + _binaryReader.Skip(1); // 0x50 + var tickCount = _binaryReader.ReadInt32(); + _binaryReader.Skip(8); // int32 "Номер действия игрока" ??? + int32 Random + var playerChunksCount = _binaryReader.ReadInt32(); + + // -- TICK BODY -- + for (var i = 0; i < playerChunksCount; i++) // для каждого игрока предусмотрен свой чанк со своим размером + { + // -- PLAYER CHUNK HEADER -- + _binaryReader.Skip(8); // ??? + var playerChunkSize = _binaryReader.ReadInt32(); + _binaryReader.Skip(1); // то же самое + + // -- PLAYER CHUNK BODY -- + while (playerChunkSize != 0) + { + var actionSize = _binaryReader.ReadInt16(); // размер захватывает 2 байта след чанка + + var action = ReadPlayerAction(tickCount, actionSize); + + Replay.Actions.Add(action); + playerChunkSize -= actionSize; + } + } + } + + + private GameActionModel ReadPlayerAction(int tickCount, int actionSize) + { + var actionEndPos = _binaryReader.Position + actionSize - 2; + + // -- ACTION HEADER -- + _binaryReader.SkipInt32(); // какое то время (свое для каждого игрока) + // (начинается с рандомного значения и увеличивается по ходу игры) + // Скорее всего является global timer + + // var s_cmd = _binaryReader.ReadInt32(); + // var s_param = _binaryReader.ReadInt32(); + // var s_shiftPressed = _binaryReader.ReadByte() == 1; + // var s_playerId = _binaryReader.Skip(2).ReadInt16() % 10; + // + // + // var leftOverBytes2 = actionEndPos - _binaryReader.Position; + // Console.WriteLine(_binaryReader.ReadBytes((int)leftOverBytes2).ToContentString()); + // return new GameActionModel(1); + + var cmd = _binaryReader.ReadInt32(); + var arg = _binaryReader.ReadByte(); // int32 in game + var subCmd = _binaryReader.ReadInt16(); + var someNum = _binaryReader.ReadByte(); + var shiftPressed = _binaryReader.ReadByte() == 1; + + var playerId = _binaryReader.Skip(2).ReadInt16() % 10; + var playerActionCount = _binaryReader.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(tickCount) + { + Cmd = cmd, + Arg = arg, + SubCmd = subCmd, + SomeByte = someNum, + ShiftPressed = shiftPressed, + + PlayerId = playerId, + PlayerActionCount = playerActionCount + }; + + + if (CheckForBugs) + Replay.Players[playerId].BugChecker?.Check(curAction); + + +#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(); + } +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Utils/NativeDowRandom.cs b/SoulstormReplayReader.Core/Utils/NativeDowRandom.cs new file mode 100644 index 0000000..981e598 --- /dev/null +++ b/SoulstormReplayReader.Core/Utils/NativeDowRandom.cs @@ -0,0 +1,81 @@ +using System.Diagnostics; + +namespace SoulstormReplayReader.Core.Utils; + +internal sealed class NativeDowRandom +{ + public uint Seed { get; set; } + public uint Index { get; set; } + public uint IterLimit { get; set; } + + public int Lock { get; set; } + + public NativeDowRandom(uint iterLimit = 4u) + { + IterLimit = iterLimit; + } + + public NativeDowRandom SetSeed(uint seed) + { + Seed = seed; + Index = 0; + + return this; + } + + public int GetMax(uint num) + { + if (num == 0) return 0; + var res = (int)(HashRandom() % num); + Debug.WriteLine($"{num} {res}"); + return res; + } + + public int GetMax(int num) => GetMax((uint)num); + + #region Dow decompiled code + + private uint HashRandom() + { + var result = Index; + Index++; + var seed = Seed; + + if (IterLimit == 0) return result; + + for (var ind = 0; ind < IterLimit; ind++) + { + var s4 = result ^ s_arr1[ind]; + var s5 = result; + var s6 = (ushort)s4; + s4 >>= 16; + var s7 = s_arr2[ind] ^ RotateLeft4((uint)(s6 * s6 + ~(s4 * s4)), '\x10'); + result = (seed ^ (s6 * s4 + s7)); + seed = s5; + } + + return result; + } + + private static uint RotateLeft4(uint x, char n) => (x << n) | (x >> (32 - n)); + + private static readonly uint[] s_arr1 = + { + 3131664519, 504877868, 62708796, 255054258, 1990609181, + 3312506973, 3816993251, 2027635554, 1949936084, 2632616645, + 2303949916, 629732701, 1096506909, 560872853, 2244371175, + 1586148200, 1051053196, 3530166132, 4087947819, 1219155940, + 0, 0, 0, 0 + }; + + private static readonly uint[] s_arr2 = + { + 1259289432, 3899977923, 1767228838, 1437059654, 1301978502, + 4264075669, 2982836325, 1797478225, 2593749601, 2859374024, + 2863238881, 1526273356, 4237173347, 957308556, 1493161986, + 2886360762, 2890429686, 2849966355, 2260618356, 3979088443, + 0, 0, 0, 0 + }; + + #endregion +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Utils/PlayersRandomizer.cs b/SoulstormReplayReader.Core/Utils/PlayersRandomizer.cs new file mode 100644 index 0000000..3dd4485 --- /dev/null +++ b/SoulstormReplayReader.Core/Utils/PlayersRandomizer.cs @@ -0,0 +1,128 @@ +using System.Diagnostics; +using SoulstormReplayReader.Core.Extensions; +using SoulstormReplayReader.Core.Domain.Player; + +namespace SoulstormReplayReader.Core.Utils; + +// TODO: Посмотреть что происходит с наблюдателями +internal sealed class PlayersRandomizer +{ + private readonly NativeDowRandom _rand = new(); + + public uint Seed + { + get => _rand.Seed; + set => _rand.SetSeed(value); + } + + public PlayersRandomizer(uint seed = 0) + { + Seed = seed; + Debug.WriteLine($"The seed is: {Seed}"); + } + + public List Randomize(List players, int mapPlayersCount) + { + var isFfa = GetIsFfa(players); + Debug.WriteLine(isFfa ? "FFA" : "SET TEAMS"); + + Debug.WriteLine("RANDOMIZING TEAMS"); + var newPlayers = RandomizeTeams(players, isFfa); + + Debug.WriteLine("RANDOMIZING HOLES"); + RandomizeHoles(newPlayers, mapPlayersCount); + + if (!isFfa) + { + Debug.WriteLine("RANDOMIZING PLAYERS IN TEAMS"); + RandomizePlayersInTeams(newPlayers); + } + + return newPlayers; + } + + private static bool GetIsFfa(List players) + { + var lastTeam = 0; + foreach (var player in players.OrderBy(static p => p.Team)) + { + if (player.Team - lastTeam > 1) + return true; + + lastTeam = player.Team; + } + + return false; + } + + private List RandomizeTeams(List players, bool isFfa) + { + if (isFfa) + { + var nPlayers = players.ToList(); + + var activePlayersCount = players.Count(static p => p.IsActive); + for (var i = 1; i < 8 && activePlayersCount > 1; i++) + { + if (nPlayers[i].IsActive) activePlayersCount--; + + nPlayers.Swap(i, _rand.GetMax(i + 1)); + } + + return nPlayers.Where(static p => p.IsActive).ToList(); + } + + var teams = players + .Where(static p => p.IsActive) + .OrderBy(static p => p.Team) + .GroupBy(static pl => pl.Team) + .Select(static team => team.ToList()).ToList(); + + for (var i = 1; i < teams.Count; i++) + teams.Swap(i, _rand.GetMax(i + 1)); + + return teams.SelectMany(static p => p).ToList(); + } + + private void RandomizeHoles(List players, int mapPlayersCount) + { + var holesCount = mapPlayersCount - players.Count; + for (var i = 1; i < players.Count && holesCount != 0; i++) + { + var playerHolesCount = _rand.GetMax(holesCount + 1); + if (playerHolesCount == 0) continue; + + holesCount -= playerHolesCount; + + for (var j = 0; j < playerHolesCount; j++) + { + players.Insert(i, PlayerModel.Empty); + i++; + } + } + + if (holesCount == 0) return; + + Debug.WriteLine($"{holesCount + 1} {holesCount}"); + while (holesCount-- != 0) + players.Add(PlayerModel.Empty); + } + + private void RandomizePlayersInTeams(List players) + { + var indexTeams = players + .Select(static (p, i) => new KeyValuePair(p.Team, i)) + .Where(static kv => kv.Key != 0) + .GroupBy(static kv => kv.Key) + .Select(static teamIndexes => teamIndexes.ToList()) + .ToList(); + + foreach (var indexTeam in indexTeams) + for (var i = 1; i < indexTeam.Count; i++) + { + var n = _rand.GetMax(i + 1); + indexTeam.Swap(i, n); + players.Swap(indexTeam[i].Value, indexTeam[n].Value); + } + } +} \ No newline at end of file diff --git a/SoulstormReplayReader.Core/Utils/TicksFormatter.cs b/SoulstormReplayReader.Core/Utils/TicksFormatter.cs new file mode 100644 index 0000000..8ca51ab --- /dev/null +++ b/SoulstormReplayReader.Core/Utils/TicksFormatter.cs @@ -0,0 +1,38 @@ +namespace SoulstormReplayReader.Core.Utils; + +public static class TicksFormatter +{ + public static class Pattern + { + /// hours : minutes : seconds . milliseconds + public const string Hmsf = @"hh\:mm\:ss\.fff"; + + /// hours : minutes : seconds + public const string Hms = @"hh\:mm\:ss"; + + /// minutes : seconds + public const string Ms = @"mm\:ss"; + + /// minutes : seconds . milliseconds + public const string Msf = @"mm\:ss\.fff"; + + /// minutes : seconds . milliseconds + public const string Sf = @"ss\.fff"; + } + + public static string Format(int ticks, string pattern = Pattern.Hmsf) => + TimeSpan.FromMilliseconds(ticks * 125).ToString(pattern); + + public static string ShortFormat(int ticks) + { + var tickMillis = TimeSpan.FromMilliseconds(ticks * 125); + + var formatPattern = tickMillis switch + { + { Hours: 0 } => Pattern.Ms, + _ => Pattern.Hms + }; + + return tickMillis.ToString(formatPattern); + } +} \ No newline at end of file diff --git a/SoulstormReplayReader.sln b/SoulstormReplayReader.sln new file mode 100644 index 0000000..be585f7 --- /dev/null +++ b/SoulstormReplayReader.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SoulstormReplayReader.Core", "SoulstormReplayReader.Core\SoulstormReplayReader.Core.csproj", "{5DAD7ED6-C5A1-4489-9D17-57C752A5296A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5DAD7ED6-C5A1-4489-9D17-57C752A5296A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5DAD7ED6-C5A1-4489-9D17-57C752A5296A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DAD7ED6-C5A1-4489-9D17-57C752A5296A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5DAD7ED6-C5A1-4489-9D17-57C752A5296A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal