diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e9fefed..911ee4e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,9 +1,10 @@ name: .NET Build & Test on: - - push - - pull_request - - workflow_call + push: + pull_request: + workflow_call: + workflow_dispatch: jobs: build: diff --git a/Directory.Build.props b/Directory.Build.props index 4a95f88..2c2c7db 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,9 +4,6 @@ True latest - - - Nodsoft Systems imkindaprogrammermyself, Sakura Akeno Isayeki, floribe2000, StewieoO WoWS Replays Unpack @@ -33,10 +30,6 @@ - - - - diff --git a/Nodsoft.WowsReplaysUnpack.Console/Nodsoft.WowsReplaysUnpack.Console.csproj b/Nodsoft.WowsReplaysUnpack.Console/Nodsoft.WowsReplaysUnpack.Console.csproj index ff27f56..bbc188a 100644 --- a/Nodsoft.WowsReplaysUnpack.Console/Nodsoft.WowsReplaysUnpack.Console.csproj +++ b/Nodsoft.WowsReplaysUnpack.Console/Nodsoft.WowsReplaysUnpack.Console.csproj @@ -16,6 +16,7 @@ + diff --git a/Nodsoft.WowsReplaysUnpack.Console/Program.cs b/Nodsoft.WowsReplaysUnpack.Console/Program.cs index 3d0bd32..b191b52 100644 --- a/Nodsoft.WowsReplaysUnpack.Console/Program.cs +++ b/Nodsoft.WowsReplaysUnpack.Console/Program.cs @@ -6,22 +6,31 @@ using Nodsoft.WowsReplaysUnpack.EntitySerializer; using Nodsoft.WowsReplaysUnpack.ExtendedData; using Nodsoft.WowsReplaysUnpack.ExtendedData.Models; +using Nodsoft.WowsReplaysUnpack.FileStore.Definitions; using Nodsoft.WowsReplaysUnpack.Services; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.Serialization; +using System.Threading.Tasks; -string samplePath = Path.Join(Directory.GetCurrentDirectory(), "../../../..", "Replay-Samples"); +string samplePath = Path.Join(Directory.GetCurrentDirectory(), "../../../../Nodsoft.WowsReplaysUnpack.Tests", "Replay-Samples"); FileStream _GetReplayFile(string name) => File.OpenRead(Path.Join(samplePath, name)); ServiceProvider? services = new ServiceCollection() - .AddWowsReplayUnpacker(builder => - { - //builder.AddReplayController(); - builder.AddExtendedData(); - }) + //.AddWowsReplayUnpacker(builder => + //{ + // //builder.AddReplayController(); + // //builder.AddExtendedData(); + //}) + .AddWowsReplayUnpacker(builder => builder + .WithDefinitionLoader()) + .Configure(options => + { + options.RootDirectory = options.RootDirectory = Path.Join(Directory.GetCurrentDirectory(), + "..", "..", "..", "..", "Nodsoft.WowsReplaysUnpack.Core", "Definitions", "Versions"); + }) .AddLogging(logging => { logging.ClearProviders(); @@ -41,8 +50,32 @@ // Console.WriteLine($"[{GetGroupString(msg)}] {msg.EntityId} : {msg.MessageContent}"); //} +const int CYCLE = 20; +async Task syncTasks(bool sync) +{ + List unpackedReplays = new List(); + if (sync) + { + for (int i = 0; i < CYCLE; i++) + { + replayUnpacker.GetUnpacker().Unpack(_GetReplayFile("good.wowsreplay")); + } + } + else + { + Parallel.ForEach(Enumerable.Range(0, CYCLE), (i) => + { + unpackedReplays.Add(replayUnpacker.GetUnpacker().Unpack(_GetReplayFile("good.wowsreplay"))); + }); + } + return unpackedReplays.ToArray(); +} +DateTime start = DateTime.Now; +await syncTasks(false); +Console.WriteLine(DateTime.Now - start); +var goodReplay = replayUnpacker.GetUnpacker().Unpack(_GetReplayFile("good.wowsreplay")); var alphaReplay = replayUnpacker.GetUnpacker().Unpack(_GetReplayFile("press_account_alpha.wowsreplay")); var bravoReplay = replayUnpacker.GetUnpacker().Unpack(_GetReplayFile("unfinished_replay.wowsreplay")); diff --git a/Nodsoft.WowsReplaysUnpack.Core/Consts.cs b/Nodsoft.WowsReplaysUnpack.Core/Consts.cs index 4ecbe1c..9a3c669 100644 --- a/Nodsoft.WowsReplaysUnpack.Core/Consts.cs +++ b/Nodsoft.WowsReplaysUnpack.Core/Consts.cs @@ -3,4 +3,8 @@ public static class Consts { public const int Infinity = 0xFFFF; + + public const char Underscore = '_'; + public const char Dot = '.'; + public const char Comma = ','; } \ No newline at end of file diff --git a/Nodsoft.WowsReplaysUnpack.Core/Definitions/AssemblyDefinitionLoader.cs b/Nodsoft.WowsReplaysUnpack.Core/Definitions/AssemblyDefinitionLoader.cs new file mode 100644 index 0000000..4862f10 --- /dev/null +++ b/Nodsoft.WowsReplaysUnpack.Core/Definitions/AssemblyDefinitionLoader.cs @@ -0,0 +1,114 @@ +using Nodsoft.WowsReplaysUnpack.Core.Extensions; +using System.Reflection; +using System.Xml; + +namespace Nodsoft.WowsReplaysUnpack.Core.Definitions; +public class AssemblyDefinitionLoader : IDefinitionLoader +{ + private static readonly XmlReaderSettings _xmlReaderSettings = new() { IgnoreComments = true }; + + /// + /// Assembly of the Definition store (defaults to the implementation assembly). + /// + protected readonly Assembly Assembly; + + /// + /// Version -> Definitions Directory + /// + protected readonly Dictionary DirectoryCache = new(); + + public AssemblyDefinitionLoader() + { + + Assembly = typeof(DefaultDefinitionStore).Assembly; + } + + /// + public Version[] GetSupportedVersions() + { + string versionsDirectory = JoinPath(Assembly.FullName!.GetStringBeforeIndex(','), "Definitions", "Versions"); + return Assembly.GetManifestResourceNames() + .Where(name => name.StartsWith(versionsDirectory)) + .Select(name => name.GetStringAfterLength(versionsDirectory + '.').GetStringBeforeIndex('.')[1..]) + .Distinct() + .Select(static version => version.Split('_').Select(int.Parse).ToArray()) + .Select(static arr => new Version(arr[0], arr[1], arr[2])) + .OrderByDescending(static version => version) + .ToArray(); + } + + /// + public virtual XmlDocument GetFileAsXml(Version clientVersion, string name, params string[] directoryNames) + { + DefinitionDirectory directory = FindDirectory(clientVersion, directoryNames); + + if (directory.Files.SingleOrDefault(f => f.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase)) is not { } file) + { + throw new InvalidOperationException("File could not be found"); + } + + XmlReader reader = XmlReader.Create(Assembly.GetManifestResourceStream(file.Path) ?? throw new InvalidOperationException("File not found"), _xmlReaderSettings); + XmlDocument xmlDocument = new(); + xmlDocument.Load(reader); + + return xmlDocument; + } + + /// + /// Finds a definition directory by given names. + /// + /// The game client version. + /// The names of the directories. + /// The definition directory. + public virtual DefinitionDirectory FindDirectory(Version clientVersion, IEnumerable directoryNames) + { + DefinitionDirectory folder = GetRootDirectory(clientVersion); + + foreach (string? folderName in directoryNames) + { + DefinitionDirectory? foundFolder = folder.Directories.SingleOrDefault(f => f.Name.Equals(folderName, StringComparison.InvariantCultureIgnoreCase)); + + if (foundFolder is null) + { + break; + } + + folder = foundFolder; + } + + return folder; + } + + /// + /// Gets the root definition directory for a given game client version. + /// + /// The game client version. + /// The root definition directory. + public virtual DefinitionDirectory GetRootDirectory(Version clientVersion) + { + if (DirectoryCache.TryGetValue(clientVersion.ToString(), out DefinitionDirectory? rootDirectory)) + { + return rootDirectory; + } + + string scriptsDirectory = JoinPath(Assembly.FullName!.GetStringBeforeIndex(Consts.Comma), + "Definitions", "Versions", $"{Consts.Underscore}{clientVersion.ToString().Replace(Consts.Dot, Consts.Underscore)}", "scripts" + ); + + string[] fileNames = Assembly.GetManifestResourceNames() + .Where(name => name.StartsWith(scriptsDirectory)) + .ToArray(); + + rootDirectory = new("scripts", scriptsDirectory, fileNames); + DirectoryCache.Add(clientVersion.ToString(), rootDirectory); + + return rootDirectory; + } + + /// + /// Joins parts of an XML path, separated by a dot. + /// + /// Parts of the path. + /// The joined path. + protected static string JoinPath(params string[] parts) => string.Join('.', parts); +} diff --git a/Nodsoft.WowsReplaysUnpack.Core/Definitions/DefaultDefinitionStore.cs b/Nodsoft.WowsReplaysUnpack.Core/Definitions/DefaultDefinitionStore.cs index 6acb473..0971d43 100644 --- a/Nodsoft.WowsReplaysUnpack.Core/Definitions/DefaultDefinitionStore.cs +++ b/Nodsoft.WowsReplaysUnpack.Core/Definitions/DefaultDefinitionStore.cs @@ -2,7 +2,6 @@ using Nodsoft.WowsReplaysUnpack.Core.DataTypes; using Nodsoft.WowsReplaysUnpack.Core.Exceptions; using Nodsoft.WowsReplaysUnpack.Core.Extensions; -using System.Reflection; using System.Xml; namespace Nodsoft.WowsReplaysUnpack.Core.Definitions; @@ -22,18 +21,9 @@ public class DefaultDefinitionStore : IDefinitionStore /// /// Logger instance used by this definition store. /// - protected readonly ILogger Logger; + protected ILogger Logger { get; } - /// - /// Assembly of the Definition store (defaults to the implementation assembly). - /// - protected readonly Assembly Assembly; - - - /// - /// Version -> Definitions Directory - /// - protected readonly Dictionary DirectoryCache = new(); + protected IDefinitionLoader DefinitionLoader { get; } /// /// version_name -> Definition @@ -55,25 +45,13 @@ public class DefaultDefinitionStore : IDefinitionStore /// protected readonly Dictionary> TypeMappings = new(); - public DefaultDefinitionStore(ILogger logger) + public DefaultDefinitionStore(ILogger logger, IDefinitionLoader definitionLoader) { - Assembly = typeof(DefaultDefinitionStore).Assembly; - Logger = logger; - string versionsDirectory = JoinPath(Assembly.FullName![..Assembly.FullName!.IndexOf(',')], "Definitions", "Versions"); + Logger = logger; + DefinitionLoader = definitionLoader; - _supportedVersions = Assembly.GetManifestResourceNames() - .Where(name => name.StartsWith(versionsDirectory)) - .Select(name => - { - string tempQualifier = name[$"{versionsDirectory}.".Length..]; - return tempQualifier[..tempQualifier.IndexOf('.')][1..]; - }) - .Distinct() - .Select(version => version.Split('_').Select(int.Parse).ToArray()) - .Select(arr => new Version(arr[0], arr[1], arr[2])) - .OrderByDescending(version => version) - .ToArray(); + _supportedVersions = DefinitionLoader.GetSupportedVersions(); } #region EntityDefinitions @@ -131,31 +109,15 @@ protected virtual string GetEntityDefinitionName(Version clientVersion, int inde /// A dictionary of entity indexes and their names. protected virtual Dictionary GetEntityIndexes(Version clientVersion) { - return GetFileAsXml(clientVersion, EntitiesXml).DocumentElement!.SelectSingleNode("ClientServerEntities")!.ChildNodes() + return DefinitionLoader.GetFileAsXml(GetActualVersion(clientVersion), EntitiesXml).DocumentElement!.SelectSingleNode("ClientServerEntities")!.ChildNodes() .Select((node, index) => new { node, index }) .ToDictionary(i => i.index, i => i.node.Name); } #endregion - /// - public virtual XmlDocument GetFileAsXml(Version clientVersion, string name, params string[] directoryNames) - { - clientVersion = GetActualVersion(clientVersion); - DefinitionDirectory directory = FindDirectory(clientVersion, directoryNames); - - if (directory.Files.SingleOrDefault(f => f.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase)) is not { } file) - { - throw new InvalidOperationException("File could not be found"); - } - - XmlReaderSettings settings = new() { IgnoreComments = true }; - XmlReader reader = XmlReader.Create(Assembly.GetManifestResourceStream(file.Path) ?? throw new InvalidOperationException("File not found"), settings); - XmlDocument xmlDocument = new(); - xmlDocument.Load(reader); - - return xmlDocument; - } + public virtual XmlDocument GetFileAsXml(Version clientVersion, string name, params string[] directoryNames) + => DefinitionLoader.GetFileAsXml(GetActualVersion(clientVersion), name, directoryNames); /// public virtual DataTypeBase GetDataType(Version clientVersion, XmlNode typeOrArgXmlNode) @@ -168,7 +130,7 @@ public virtual DataTypeBase GetDataType(Version clientVersion, XmlNode typeOrArg return GetDataTypeInternal(clientVersion, typeMapping, typeOrArgXmlNode); } - XmlDocument aliasXml = GetFileAsXml(clientVersion, "alias.xml", "entity_defs"); + XmlDocument aliasXml = DefinitionLoader.GetFileAsXml(clientVersion, "alias.xml", "entity_defs"); typeMapping = new(); foreach (XmlNode node in aliasXml.DocumentElement!.ChildNodes) @@ -181,56 +143,6 @@ public virtual DataTypeBase GetDataType(Version clientVersion, XmlNode typeOrArg return GetDataTypeInternal(clientVersion, typeMapping, typeOrArgXmlNode); } - /// - /// Finds a definition directory by given names. - /// - /// The game client version. - /// The names of the directories. - /// The definition directory. - protected virtual DefinitionDirectory FindDirectory(Version clientVersion, IEnumerable directoryNames) - { - DefinitionDirectory folder = GetRootDirectory(clientVersion); - - foreach (string? folderName in directoryNames) - { - DefinitionDirectory? foundFolder = folder.Directories.SingleOrDefault(f => f.Name.Equals(folderName, StringComparison.InvariantCultureIgnoreCase)); - - if (foundFolder is null) - { - break; - } - - folder = foundFolder; - } - - return folder; - } - - /// - /// Gets the root definition directory for a given game client version. - /// - /// The game client version. - /// The root definition directory. - protected virtual DefinitionDirectory GetRootDirectory(Version clientVersion) - { - if (DirectoryCache.TryGetValue(clientVersion.ToString(), out DefinitionDirectory? rootDirectory)) - { - return rootDirectory; - } - - string scriptsDirectory = JoinPath(Assembly.FullName![..Assembly.FullName!.IndexOf(',')], - "Definitions", "Versions", $"_{clientVersion.ToString().Replace('.', '_')}", "scripts" - ); - - string[] fileNames = Assembly.GetManifestResourceNames() - .Where(name => name.StartsWith(scriptsDirectory)) - .ToArray(); - - rootDirectory = new("scripts", scriptsDirectory, fileNames); - DirectoryCache.Add(clientVersion.ToString(), rootDirectory); - - return rootDirectory; - } /// /// Internal method for getting a data type. @@ -273,13 +185,6 @@ private Version GetActualVersion(Version version) return actualVersion; } - /// - /// Joins parts of an XML path, separated by a dot. - /// - /// Parts of the path. - /// The joined path. - protected static string JoinPath(params string[] parts) => string.Join('.', parts); - /// /// Joins parts of a cache key, separated by an underscore. /// diff --git a/Nodsoft.WowsReplaysUnpack.Core/Definitions/DefinitionDirectory.cs b/Nodsoft.WowsReplaysUnpack.Core/Definitions/DefinitionDirectory.cs index 504250f..0096f67 100644 --- a/Nodsoft.WowsReplaysUnpack.Core/Definitions/DefinitionDirectory.cs +++ b/Nodsoft.WowsReplaysUnpack.Core/Definitions/DefinitionDirectory.cs @@ -40,10 +40,9 @@ private void ParseChildrenRecursive(string[] fileNames) foreach (string fileName in fileNames) { - string after = $"{Path}."; - string actualFileName = fileName[after.Length..]; + string actualFileName = fileName.GetStringAfterLength(Path + '.'); - if (actualFileName.Count(c => c is '.') is 1) + if (actualFileName.Count(static c => c is '.') is 1) { Files.Add(new(actualFileName, fileName)); } diff --git a/Nodsoft.WowsReplaysUnpack.Core/Definitions/EntityDefinition.cs b/Nodsoft.WowsReplaysUnpack.Core/Definitions/EntityDefinition.cs index 55e949f..f286c97 100644 --- a/Nodsoft.WowsReplaysUnpack.Core/Definitions/EntityDefinition.cs +++ b/Nodsoft.WowsReplaysUnpack.Core/Definitions/EntityDefinition.cs @@ -18,10 +18,8 @@ public record EntityDefinition : BaseDefinition /// public List ClientMethods { get; private set; } = new(); - public EntityDefinition(Version clientVersion, IDefinitionStore definitionStore, - string name) : base(clientVersion, definitionStore, name, ENTITY_DEFS) - { - } + public EntityDefinition(Version clientVersion, IDefinitionStore definitionStore, string name) + : base(clientVersion, definitionStore, name, ENTITY_DEFS) { } /// /// Parses a .def file for the entity definition. diff --git a/Nodsoft.WowsReplaysUnpack.Core/Definitions/IDefinitionLoader.cs b/Nodsoft.WowsReplaysUnpack.Core/Definitions/IDefinitionLoader.cs new file mode 100644 index 0000000..f9206f2 --- /dev/null +++ b/Nodsoft.WowsReplaysUnpack.Core/Definitions/IDefinitionLoader.cs @@ -0,0 +1,21 @@ +using System.Xml; + +namespace Nodsoft.WowsReplaysUnpack.Core.Definitions; + +public interface IDefinitionLoader +{ + /// + /// Reads XML data from a .def file (seeking through directories as needed). + /// + /// Game client version + /// Name of the .def file to read + /// Directories where to search for the .def file + /// XML data + XmlDocument GetFileAsXml(Version clientVersion, string name, params string[] directoryNames); + + /// + /// Gets all game client versions supported by this definition loader. + /// + /// An array of all supported game client versions ordered newest -> oldest. + Version[] GetSupportedVersions(); +} \ No newline at end of file diff --git a/Nodsoft.WowsReplaysUnpack.Core/Definitions/IDefinitionStore.cs b/Nodsoft.WowsReplaysUnpack.Core/Definitions/IDefinitionStore.cs index f098e9c..5f06955 100644 --- a/Nodsoft.WowsReplaysUnpack.Core/Definitions/IDefinitionStore.cs +++ b/Nodsoft.WowsReplaysUnpack.Core/Definitions/IDefinitionStore.cs @@ -16,7 +16,7 @@ public interface IDefinitionStore /// XML node to get the data type of /// Data type of the XML node DataTypeBase GetDataType(Version clientVersion, XmlNode typeOrArgXmlNode); - + /// /// Gets an entity definition by its index. /// @@ -24,7 +24,7 @@ public interface IDefinitionStore /// Index of the entity definition /// Entity definition EntityDefinition GetEntityDefinition(Version clientVersion, int index); - + /// /// Gets a property definition by its name. /// @@ -32,9 +32,9 @@ public interface IDefinitionStore /// Name of the property definition /// Property definition EntityDefinition GetEntityDefinition(Version clientVersion, string name); - + /// - /// Reads XML data from a .def file (seeking through directories as needed). + /// Gets the definition file from the definition loader /// /// Game client version /// Name of the .def file to read diff --git a/Nodsoft.WowsReplaysUnpack.Core/Extensions/StringExtensions.cs b/Nodsoft.WowsReplaysUnpack.Core/Extensions/StringExtensions.cs index 79efe1c..79a1391 100644 --- a/Nodsoft.WowsReplaysUnpack.Core/Extensions/StringExtensions.cs +++ b/Nodsoft.WowsReplaysUnpack.Core/Extensions/StringExtensions.cs @@ -1,4 +1,11 @@ namespace Nodsoft.WowsReplaysUnpack.Core.Extensions; public static class StringExtensions -{ } \ No newline at end of file +{ + public static string GetStringBeforeIndex(this string str, char before) => str[..str.IndexOf(before)]; + + public static string GetStringBeforeIndex(this string str, string before) => str[..str.IndexOf(before, StringComparison.Ordinal)]; + + public static string GetStringAfterLength(this string str, string after) => str[after.Length..]; +} + \ No newline at end of file diff --git a/Nodsoft.WowsReplaysUnpack.Core/Nodsoft.WowsReplaysUnpack.Core.csproj b/Nodsoft.WowsReplaysUnpack.Core/Nodsoft.WowsReplaysUnpack.Core.csproj index a38cb30..e61cd0a 100644 --- a/Nodsoft.WowsReplaysUnpack.Core/Nodsoft.WowsReplaysUnpack.Core.csproj +++ b/Nodsoft.WowsReplaysUnpack.Core/Nodsoft.WowsReplaysUnpack.Core.csproj @@ -1,4 +1,4 @@ - + Library diff --git a/Nodsoft.WowsReplaysUnpack.FileStore/Definitions/FileSystemDefinitionLoader.cs b/Nodsoft.WowsReplaysUnpack.FileStore/Definitions/FileSystemDefinitionLoader.cs new file mode 100644 index 0000000..cb2e4a6 --- /dev/null +++ b/Nodsoft.WowsReplaysUnpack.FileStore/Definitions/FileSystemDefinitionLoader.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Options; +using Nodsoft.WowsReplaysUnpack.Core.Definitions; +using System.Runtime.CompilerServices; +using System.Xml; + +namespace Nodsoft.WowsReplaysUnpack.FileStore.Definitions; + +/// +/// Provides a definition loader using the filesystem as a backing store. +/// +public class FileSystemDefinitionLoader : IDefinitionLoader +{ + private static readonly XmlReaderSettings _xmlReaderSettings = new() + { + IgnoreComments = true + }; + + private readonly IOptionsMonitor _options; + + public FileSystemDefinitionLoader(IOptionsMonitor options) + { + _options = options; + } + + /// + public XmlDocument GetFileAsXml(Version clientVersion, string name, params string[] directoryNames) + { + string path = Path.Combine(_options.CurrentValue.RootDirectory, ToFilesystemString(clientVersion), "scripts", Path.Combine(directoryNames), name); + + if (!File.Exists(path)) + { + throw new FileNotFoundException($"Could not find definition file {name} in {path}"); + } + + using Stream stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); + + // Use an XmlReader to load the file, as it is more efficient than loading the entire file into memory. + using XmlReader reader = XmlReader.Create(stream, _xmlReaderSettings); + XmlDocument document = new(); + document.Load(reader); + return document; + } + + /// + public Version[] GetSupportedVersions() + { + // For a set directory, we're expecting the child directories to be the versions. + // These will be structured by __ (e.g. 0_11_0), + // so we'll need to parse them accordingly into Version objects. + Version[] versions = new DirectoryInfo(_options.CurrentValue.RootDirectory).GetDirectories() + .Select(static dir => FromFilesystemString(dir.Name)) + .OrderByDescending(static version => version) + .ToArray(); + + return versions; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static string ToFilesystemString(Version version) + { + return version.ToString().Replace('.', '_'); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Version FromFilesystemString(string version) + { + return new(version.Replace('_', '.')); + } +} \ No newline at end of file diff --git a/Nodsoft.WowsReplaysUnpack.FileStore/Definitions/FileSystemDefinitionLoaderOptions.cs b/Nodsoft.WowsReplaysUnpack.FileStore/Definitions/FileSystemDefinitionLoaderOptions.cs new file mode 100644 index 0000000..1319332 --- /dev/null +++ b/Nodsoft.WowsReplaysUnpack.FileStore/Definitions/FileSystemDefinitionLoaderOptions.cs @@ -0,0 +1,17 @@ +namespace Nodsoft.WowsReplaysUnpack.FileStore.Definitions; + +/// +/// Defines settings for the . +/// +public record FileSystemDefinitionLoaderOptions +{ + /// + /// Gets or sets the path to the directory containing the definitions. + /// + public string RootDirectory { get; set; } + + /// + /// Gets or sets whether the loader should poll the filesystem for changes. + /// + public bool EnableChangePolling { get; set; } +} \ No newline at end of file diff --git a/Nodsoft.WowsReplaysUnpack.FileStore/Nodsoft.WowsReplaysUnpack.FileStore.csproj b/Nodsoft.WowsReplaysUnpack.FileStore/Nodsoft.WowsReplaysUnpack.FileStore.csproj new file mode 100644 index 0000000..692e682 --- /dev/null +++ b/Nodsoft.WowsReplaysUnpack.FileStore/Nodsoft.WowsReplaysUnpack.FileStore.csproj @@ -0,0 +1,18 @@ + + + + Library + net6.0 + enable + enable + + + + + + + + + + + diff --git a/Nodsoft.WowsReplaysUnpack.Tests/Nodsoft.WowsReplaysUnpack.Tests.csproj b/Nodsoft.WowsReplaysUnpack.Tests/Nodsoft.WowsReplaysUnpack.Tests.csproj index 1461a8a..8b5bb33 100644 --- a/Nodsoft.WowsReplaysUnpack.Tests/Nodsoft.WowsReplaysUnpack.Tests.csproj +++ b/Nodsoft.WowsReplaysUnpack.Tests/Nodsoft.WowsReplaysUnpack.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/Nodsoft.WowsReplaysUnpack.Tests/ReplayParsingExecutionTests.cs b/Nodsoft.WowsReplaysUnpack.Tests/ReplayParsingExecutionTests.cs new file mode 100644 index 0000000..0cda896 --- /dev/null +++ b/Nodsoft.WowsReplaysUnpack.Tests/ReplayParsingExecutionTests.cs @@ -0,0 +1,67 @@ +using Microsoft.Extensions.DependencyInjection; +using Nodsoft.WowsReplaysUnpack.Core.Models; +using Nodsoft.WowsReplaysUnpack.FileStore.Definitions; +using Nodsoft.WowsReplaysUnpack.Services; +using Xunit; + + +namespace Nodsoft.WowsReplaysUnpack.Tests; + +/// +/// Provides integration tests for the different replay parser configurations. +/// +public class ReplayParsingExecutionTests +{ + /// + /// Tests the parsing of a replay file, using the default configuration. + /// + [Fact] + public void TestDefaultParsing() + { + // Arrange + IServiceProvider services = new ServiceCollection() + .AddLogging() + .AddWowsReplayUnpacker() + .BuildServiceProvider(); + + ReplayUnpackerFactory unpackerFactory = services.GetRequiredService(); + IReplayUnpackerService service = unpackerFactory.GetUnpacker(); + + // Act + UnpackedReplay metadata = service.Unpack(Utilities.LoadReplay("good.wowsreplay")); + + // Assert + Assert.NotNull(metadata); + } + + /// + /// Tests the parsing of a replay file, using the filesystem defs loader. + /// + /// + /// This test requires the filesystem to be setup correctly, and definitions to be present. + /// + [Fact] + public void TestFilesystemParsing() + { + // Arrange + IServiceProvider services = new ServiceCollection() + .AddLogging() + .AddWowsReplayUnpacker(builder => builder + .WithDefinitionLoader()) + .Configure(options => + { + options.RootDirectory = options.RootDirectory = Path.Join(Directory.GetCurrentDirectory(), + "..", "..", "..", "..", "Nodsoft.WowsReplaysUnpack.Core", "Definitions", "Versions"); + }) + .BuildServiceProvider(); + + ReplayUnpackerFactory unpackerFactory = services.GetRequiredService(); + IReplayUnpackerService service = unpackerFactory.GetUnpacker(); + + // Act + UnpackedReplay metadata = service.Unpack(Utilities.LoadReplay("good.wowsreplay")); + + // Assert + Assert.NotNull(metadata); + } +} \ No newline at end of file diff --git a/Nodsoft.WowsReplaysUnpack.Tests/ReplaySanitizerTests.cs b/Nodsoft.WowsReplaysUnpack.Tests/ReplaySanitizerTests.cs index ca41ead..f77e69d 100644 --- a/Nodsoft.WowsReplaysUnpack.Tests/ReplaySanitizerTests.cs +++ b/Nodsoft.WowsReplaysUnpack.Tests/ReplaySanitizerTests.cs @@ -7,10 +7,13 @@ namespace Nodsoft.WowsReplaysUnpack.Tests; +/// +/// Provides tests for the replay RCE detection system. +/// public class ReplaySanitizerTests { private readonly ReplayUnpackerFactory _factory; - private readonly string _sampleFolder = Path.Join(Directory.GetCurrentDirectory(), "Replay-Samples"); + public ReplaySanitizerTests() { @@ -27,26 +30,16 @@ public ReplaySanitizerTests() [Fact] public void TestGoodReplay_Pass() { - UnpackedReplay replay = _factory.GetUnpacker().Unpack(LoadReplay("good.wowsreplay")); + UnpackedReplay replay = _factory.GetUnpacker().Unpack(Utilities.LoadReplay("good.wowsreplay")); Assert.NotNull(replay); } - private MemoryStream LoadReplay(string replayPath) - { - using FileStream fs = File.OpenRead(Path.Join(_sampleFolder, replayPath)); - MemoryStream ms = new(); - fs.CopyTo(ms); - ms.Position = 0; - - return ms; - } - /// /// Test malicious replay detection (Assets/payload.wowsreplay) /// [Fact] public void TestPayloadReplayDetection() { - Assert.Throws(() => _factory.GetUnpacker().Unpack(LoadReplay("payload.wowsreplay"))); + Assert.Throws(() => _factory.GetUnpacker().Unpack(Utilities.LoadReplay("payload.wowsreplay"))); } } \ No newline at end of file diff --git a/Nodsoft.WowsReplaysUnpack.Tests/Utilities.cs b/Nodsoft.WowsReplaysUnpack.Tests/Utilities.cs new file mode 100644 index 0000000..6e58243 --- /dev/null +++ b/Nodsoft.WowsReplaysUnpack.Tests/Utilities.cs @@ -0,0 +1,23 @@ +using System.Runtime.CompilerServices; + +namespace Nodsoft.WowsReplaysUnpack.Tests; + +/// +/// Helper methods used in tests. +/// +public static class Utilities +{ + private static readonly string _sampleFolder = Path.Join(Directory.GetCurrentDirectory(), "Replay-Samples"); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static MemoryStream LoadReplay(string replayPath) + { + using FileStream fs = File.Open(Path.Join(_sampleFolder, replayPath), FileMode.Open, FileAccess.Read, FileShare.Read); + MemoryStream ms = new(); + fs.CopyTo(ms); + fs.Close(); + ms.Position = 0; + + return ms; + } +} \ No newline at end of file diff --git a/Nodsoft.WowsReplaysUnpack/ReplayUnpackerBuilder.cs b/Nodsoft.WowsReplaysUnpack/ReplayUnpackerBuilder.cs new file mode 100644 index 0000000..5d761f7 --- /dev/null +++ b/Nodsoft.WowsReplaysUnpack/ReplayUnpackerBuilder.cs @@ -0,0 +1,113 @@ +using Microsoft.Extensions.DependencyInjection; +using Nodsoft.WowsReplaysUnpack.Controllers; +using Nodsoft.WowsReplaysUnpack.Core.Definitions; +using Nodsoft.WowsReplaysUnpack.Services; + +namespace Nodsoft.WowsReplaysUnpack; + +/// +/// Provides a fluent API to build a WOWS replay data unpacker. +/// +public class ReplayUnpackerBuilder +{ + private bool replayDataParserAdded; + private bool definitionStoreAdded; + private bool definitionLoaderAdded; + + public IServiceCollection Services { get; } + + /// + /// Intializes a new instance of the class, + /// by registering a as baseline. + /// + /// + public ReplayUnpackerBuilder(IServiceCollection services) + { + Services = services; + AddReplayController(); + } + + /// + /// Registers a for use in the WOWS replay data unpacker. + /// + /// The type of the replay data parser. + /// The builder. + public ReplayUnpackerBuilder WithReplayDataParser() where TParser : class, IReplayDataParser + { + Services.AddScoped(); + replayDataParserAdded = true; + return this; + } + + /// + /// Registers a for use in the WOWS replay data unpacker. + /// + /// The type of the replay controller. + /// The builder. + public ReplayUnpackerBuilder AddReplayController() where TController : class, IReplayController + { + Services.AddScoped>(); + Services.AddScoped(); + return this; + } + + /// + /// Registers a for use in the WOWS replay data unpacker. + /// + /// The type of the definition loader. + /// The builder. + public ReplayUnpackerBuilder WithDefinitionLoader() where TLoader : class, IDefinitionLoader + { + Services.AddScoped(); + definitionLoaderAdded = true; + return this; + } + + /// + /// Registers a for use in the WOWS replay data unpacker. + /// + /// The type of the definition store. + /// The builder. + public ReplayUnpackerBuilder WithDefinitionStore() where TStore : class, IDefinitionStore + { + Services.AddSingleton(); + definitionStoreAdded = true; + return this; + } + + + // stewie says: No need for that since they will be added either way if you don't add other ones + ///// + ///// Registers the Assembly definition loader and the default definition store for the WOWS replay data unpacker. + ///// These are considered the default definition services for the unpacker. + ///// + ///// The replay unpacker builder. + ///// The service collection. + //public static ReplayUnpackerBuilder WithDefaultDefinitions(this ReplayUnpackerBuilder builder) + //{ + // builder.WithDefinitionLoader(); + // builder.WithDefinitionStore(); + // return builder; + //} + + /// + /// Builds the WOWS replay data unpacker, registering any missing services. + /// + public void Build() + { + if (!replayDataParserAdded) + { + WithReplayDataParser(); + } + + if (!definitionStoreAdded) + { + WithDefinitionStore(); + } + + if (!definitionLoaderAdded) + { + WithDefinitionLoader(); + } + } +} \ No newline at end of file diff --git a/Nodsoft.WowsReplaysUnpack/ServiceCollectionExtensions.cs b/Nodsoft.WowsReplaysUnpack/ServiceCollectionExtensions.cs index 53d4023..e8ee16e 100644 --- a/Nodsoft.WowsReplaysUnpack/ServiceCollectionExtensions.cs +++ b/Nodsoft.WowsReplaysUnpack/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.DependencyInjection; -using Nodsoft.WowsReplaysUnpack.Controllers; using Nodsoft.WowsReplaysUnpack.Core.Definitions; using Nodsoft.WowsReplaysUnpack.Services; @@ -18,25 +17,28 @@ public static class ServiceCollectionExtensions /// The service collection. /// The service collection. public static IServiceCollection AddWowsReplayUnpacker(this IServiceCollection services) - => services.AddWowsReplayUnpacker(); - + => services.AddWowsReplayUnpacker(); + /// /// Registers the WOWS replay data unpacker, - /// using the specified and + /// using the specified , and /// for parsing the replay data and definitions. /// /// The service collection to add the services to. /// The type of the replay data parser. /// The type of the definition store. + /// The type of the definition loader. /// The service collection. - public static IServiceCollection AddWowsReplayUnpacker(this IServiceCollection services) + public static IServiceCollection AddWowsReplayUnpacker(this IServiceCollection services) where TReplayDataParser : class, IReplayDataParser where TDefinitionStore : class, IDefinitionStore + where TDefinitionLoader : class, IDefinitionLoader => services.AddWowsReplayUnpacker(unpacker => { unpacker .WithReplayDataParser() - .WithDefinitionStore(); + .WithDefinitionStore() + .WithDefinitionLoader(); } ); @@ -56,78 +58,4 @@ public static IServiceCollection AddWowsReplayUnpacker(this IServiceCollection s services.AddScoped(); return services; } - - /// - /// Provides a fluent API to build a WOWS replay data unpacker. - /// - public class ReplayUnpackerBuilder - { - private bool replayDataParserAdded; - private bool definitionStoreAdded; - - public IServiceCollection Services { get; } - - /// - /// Intializes a new instance of the class, - /// by registering a as baseline. - /// - /// - public ReplayUnpackerBuilder(IServiceCollection services) - { - Services = services; - AddReplayController(); - } - - /// - /// Registers a for use in the WOWS replay data unpacker. - /// - /// The type of the replay data parser. - /// The builder. - public ReplayUnpackerBuilder WithReplayDataParser() where TParser : class, IReplayDataParser - { - Services.AddScoped(); - replayDataParserAdded = true; - return this; - } - - /// - /// Registers a for use in the WOWS replay data unpacker. - /// - /// The type of the replay controller. - /// The builder. - public ReplayUnpackerBuilder AddReplayController() where TController : class, IReplayController - { - Services.AddScoped>(); - Services.AddScoped(); - return this; - } - - /// - /// Registers a for use in the WOWS replay data unpacker. - /// - /// The type of the definition store. - /// The builder. - public ReplayUnpackerBuilder WithDefinitionStore() where TStore : class, IDefinitionStore - { - Services.AddSingleton(); - definitionStoreAdded = true; - return this; - } - - /// - /// Builds the WOWS replay data unpacker, registering any missing services. - /// - public void Build() - { - if (!replayDataParserAdded) - { - WithReplayDataParser(); - } - - if (!definitionStoreAdded) - { - WithDefinitionStore(); - } - } - } } diff --git a/Nodsoft.WowsReplaysUnpack/Services/ReplayUnpackerService.cs b/Nodsoft.WowsReplaysUnpack/Services/ReplayUnpackerService.cs index 5d699a0..f92b9b4 100644 --- a/Nodsoft.WowsReplaysUnpack/Services/ReplayUnpackerService.cs +++ b/Nodsoft.WowsReplaysUnpack/Services/ReplayUnpackerService.cs @@ -9,6 +9,7 @@ using System.IO.Compression; using System.Text; using System.Text.Json; +using System.Threading; namespace Nodsoft.WowsReplaysUnpack.Services; @@ -22,6 +23,8 @@ public sealed class ReplayUnpackerService : ReplayUnpackerService, private readonly JsonSerializerOptions _jsonSerializerOptions = new() { PropertyNameCaseInsensitive = true }; private readonly IReplayDataParser _replayDataParser; private readonly IReplayController _replayController; + private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1); + private const int _semephoreTimeOut = 2000; public ReplayUnpackerService(IReplayDataParser replayDataParser, TController replayController) { @@ -70,12 +73,13 @@ Seek to offset 4 in the replay file (skipping the magic number) See http://wiki.vbaddict.net/pages/File_Replays for more details. */ options ??= new(); - + _semaphore.Wait(_semephoreTimeOut); BinaryReader binaryReader = new(stream); byte[] signature = binaryReader.ReadBytes(4); int jsonBlockCount = binaryReader.ReadInt32(); + _semaphore.Release(); // Verify replay signature if (!signature.SequenceEqual(ReplaySignature)) { @@ -86,11 +90,13 @@ Seek to offset 4 in the replay file (skipping the magic number) // Read it and create the unpacked replay model ArenaInfo arenaInfo = ReadJsonBlock(binaryReader); UnpackedReplay replay = _replayController.CreateUnpackedReplay(arenaInfo); + _semaphore.Wait(_semephoreTimeOut); ReadExtraJsonBlocks(replay, binaryReader, jsonBlockCount); MemoryStream decryptedStream = new(); Decrypt(binaryReader, decryptedStream); + _semaphore.Release(); // Initial stream and reader not used anymore binaryReader.Dispose(); @@ -100,14 +106,13 @@ Seek to offset 4 in the replay file (skipping the magic number) // Decrypted stream not used anymore decryptedStream.Dispose(); - Version gameclientVersion = Version.Parse(arenaInfo.ClientVersionFromExe.Replace(',', '.')); - + _semaphore.Wait(_semephoreTimeOut); foreach (NetworkPacketBase networkPacket in _replayDataParser.ParseNetworkPackets(replayDataStream, options, gameclientVersion)) { _replayController.HandleNetworkPacket(networkPacket, options); } - + _semaphore.Release(); return replay; } diff --git a/WoWS-ReplaysUnpack.sln b/WoWS-ReplaysUnpack.sln index 966a679..cc4aa9b 100644 --- a/WoWS-ReplaysUnpack.sln +++ b/WoWS-ReplaysUnpack.sln @@ -24,6 +24,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Library", "Library", "{367B EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nodsoft.WowsReplaysUnpack.ExtendedData", "Nodsoft.WowsReplaysUnpack.ExtendedData\Nodsoft.WowsReplaysUnpack.ExtendedData.csproj", "{A9D51B91-8D9E-4E8E-A1A7-26BD5223EC53}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nodsoft.WowsReplaysUnpack.FileStore", "Nodsoft.WowsReplaysUnpack.FileStore\Nodsoft.WowsReplaysUnpack.FileStore.csproj", "{5278C904-639B-4368-A98C-6F7F79B1683E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -54,6 +56,10 @@ Global {A9D51B91-8D9E-4E8E-A1A7-26BD5223EC53}.Debug|Any CPU.Build.0 = Debug|Any CPU {A9D51B91-8D9E-4E8E-A1A7-26BD5223EC53}.Release|Any CPU.ActiveCfg = Release|Any CPU {A9D51B91-8D9E-4E8E-A1A7-26BD5223EC53}.Release|Any CPU.Build.0 = Release|Any CPU + {5278C904-639B-4368-A98C-6F7F79B1683E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5278C904-639B-4368-A98C-6F7F79B1683E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5278C904-639B-4368-A98C-6F7F79B1683E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5278C904-639B-4368-A98C-6F7F79B1683E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -62,6 +68,7 @@ Global {B7A3AA8B-E20E-4DE9-B63A-A1A96741EFCD} = {367BF192-3EC3-4A87-B8E4-3E444E7019D0} {18EF6FA2-5AF1-4277-81F4-7104BB35B3BD} = {367BF192-3EC3-4A87-B8E4-3E444E7019D0} {A9D51B91-8D9E-4E8E-A1A7-26BD5223EC53} = {367BF192-3EC3-4A87-B8E4-3E444E7019D0} + {5278C904-639B-4368-A98C-6F7F79B1683E} = {367BF192-3EC3-4A87-B8E4-3E444E7019D0} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {26B5CF6B-2C64-417B-8149-3DFD8E93D497}