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(0));
_binaryReader // relic chunky
.Skip(44)
.NextAsciiStringMustEqual("POSTGAMEINFO\0DATADATA"u8)
.SkipInt32(3);
Replay.TotalTicks = _binaryReader.ReadInt32(); // Полное игровое время (1 сек = 8 тиков)
_binaryReader // relic chunky
.Skip(24)
.NextAsciiStringMustEqual("FOLDINFO"u8)
.SkipInt32();
Descriptor.FoldInfoStart = _binaryReader.Position; // 153
Descriptor.FoldInfoSize = _binaryReader.ReadInt32();
_binaryReader
.SkipInt32()
.NextAsciiStringMustEqual("GAMEINFO\0FOLDWMAN"u8)
.SkipInt32();
Descriptor.FoldWmanStart = _binaryReader.Position; // 182
Descriptor.FoldWmanSize = _binaryReader.ReadInt32();
_binaryReader
.SkipInt32()
.NextAsciiStringMustEqual("DATASDSC"u8)
.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"u8)
.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"u8))
{
_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"u8)
.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"u8))
{
_binaryReader
.Skip(12) // int32 version 1; int32 size; int32 strLen 0;
.NextAsciiStringMustEqual("DATALCIN"u8)
.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"u8) // 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"u8)
.Skip(8); // int32 version 1; int32 size;
playerImage.Name = _binaryReader.ReadNextAsciiString();
_binaryReader
.NextAsciiStringMustEqual("DATAATTR"u8)
.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"u8)
.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();
}
}