627 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
/// <summary>
/// Если параметр активен, картинки баннера и бейджа игроков не парсятся
/// <para/>
/// Player.Banner.Bitmap = null
/// <para/>
/// Player.Badge.Bitmap = null
/// </summary>
public bool SkipImages { get; set; }
/// <summary>
/// Если параметр активен, на игроков будут повешены BugChecker'ы в зависимости от их расы
/// </summary>
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<byte, int>(_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<long>();
Replay.Players = new List<PlayerModel>(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));
var uintLine = MemoryMarshal.Cast<ReplayColor, uint>(line);
for (var x = 0; x < uintLine.Length; x++)
uintLine[x] = BinaryPrimitives.ReverseEndianness(uintLine[x]);
}
}
}
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<IGameAction>(Replay.TotalTicks / 2);
Replay.ChatMessages = new List<ChatMessageModel>();
// Иногда в конце реплея появляются остаточные байты
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)
{
_binaryReader.Skip(tickSize); // Всегда 17 байт
}
else
{
byte[] rentBytes = null;
var bytes = tickSize switch
{
> 0 and < 0xFF => stackalloc byte[tickSize],
_ => rentBytes = ArrayPool<byte>.Shared.Rent(tickSize)
};
try
{
bytes = _binaryReader.ReadBytes(bytes[..tickSize]);
ParseOrdinaryTick(bytes);
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
finally
{
if (rentBytes is not null)
ArrayPool<byte>.Shared.Return(rentBytes);
}
}
CurrentTick++;
}
else if (tickType == TickType.Extra)
ReadExtraTick();
else
ReadWierdTick((int)tickType, tickSize);
}
private void ParseOrdinaryTick(Span<byte> 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();
}
}