diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41accc4..1c7a7be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,6 +55,8 @@ jobs: # Change into the artifacts directory to avoid including the directory itself in the zip archive working-directory: ./releases/net8.0 run: zip -r ../ModVerify-Net8.zip . + - name: Rename .NET Framework executable + run: mv ./releases/net48/ModVerify.CliApp.exe ./releases/net48/ModVerify.exe - uses: dotnet/nbgv@v0.4.2 id: nbgv - name: Create GitHub release @@ -65,5 +67,5 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} generate_release_notes: true files: | - ./releases/net48/ModVerify.CliApp.exe + ./releases/net48/ModVerify.exe ./releases/ModVerify-Net8.zip \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 1dda2dd..1d41c28 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ all - 3.6.133 + 3.6.139 \ No newline at end of file diff --git a/PetroglyphTools b/PetroglyphTools index 16461a0..3efe081 160000 --- a/PetroglyphTools +++ b/PetroglyphTools @@ -1 +1 @@ -Subproject commit 16461a02bb8f57a584cc6e9fff0f7a4d1c3901b5 +Subproject commit 3efe081031d1ab331c5af917be07454a943a2d4c diff --git a/README.md b/README.md index bb3e92a..3555a77 100644 --- a/README.md +++ b/README.md @@ -1 +1,77 @@ -# ModVerify \ No newline at end of file +# ModVerify: A Mod Verification Tool + +ModVerify is a command-line tool designed to verify mods for the game Star Wars: Empire at War and its expansion Forces of Corruption. + +## Table of Contents + +- [Installation](#installation) +- [Usage](#usage) +- [Options](#options) +- [Available Checks](#available-checks) + +## Installation + +Download the latest release from the [releases page](https://github.com/AlamoEngine-Tools/ModVerify/releases). There are two versions of the application available. + +1. `ModVerify.exe` is the default version. Use this if you simply want to verify your mods. This version only works on Windows. +2. `ModVerify-NetX.zip` is the cross-platform app. It works on Windows and Linux and is most likely the version you want to use to include it in some CI/CD scenarios. + +You can place the files anywhere on your system, eg. your Desktop. There is no need to place it inside a mod's directory. + +*Note: Both versions have the exact same feature set. They just target a different .NET runtime. Linux and CI/CD support is not fully tested yet. Current priority is on the Windows-only version.* + +## Usage + +Simply run the executable file `ModVerify.exe`. + +When given no specific argument through the command line, the app will ask you which game or mod you want to verify. When the tool is done, it will write the verification results into new files next to the executable. + +A `.JSON` file lists all found issues. Into seperate `.txt` files the same errors get grouped by a category of the finding. The text files may be easier to read, while the json file is more useful for 3rd party tool processing. + +## Options + +You can also run the tool with command line arguments to adjust the tool to your needs. + +To see all available options, open the command line and type: + +```bash +ModVerify.exe --help +``` + +Here is a list of the most relevant options: + +### `--path` +Specifies a path that shall be analyzed. **There will be no user input required when using this option** + +### `--output` +Specified the output path where analysis result shall be written to. + +### `--baseline` +Specifies a baseline file that shall be used to filter out known errors. You can download the [FoC baseline](focBaseline.json) which includes all errors produced by the vanilla game. + +### `--createBaseline` +If you want to create your own baseline, add this option with a file path such as `myModBaseline.json`. + +### Example +This is an example run configuration that analyzes a specific mod, uses a the FoC basline and writes the output into a dedicated directory: + +```bash +ModVerify.exe --path "C:\My Games\FoC\Mods\MyMod" --output "C:\My Games\FoC\Mods\MyMod\verifyResults" --baseline focBaseline.json +``` + + +## Available Checks + +The following verifiers are currently implemented: + +### For SFX Events: +- Checks whether coded preset exists +- Checks the referenced samples for validity (bit rate, sample size, channels, etc.) +- Duplicates + + +### For GameObjects +- Checks the referenced models for validity (textures, particles and shaders) +- Duplicates + + diff --git a/aet.ico b/aet.ico new file mode 100644 index 0000000..ba9d880 Binary files /dev/null and b/aet.ico differ diff --git a/src/ModVerify.CliApp/FodyWeavers.xml b/src/ModVerify.CliApp/FodyWeavers.xml index 5029e70..5bce71f 100644 --- a/src/ModVerify.CliApp/FodyWeavers.xml +++ b/src/ModVerify.CliApp/FodyWeavers.xml @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/src/ModVerify.CliApp/GameFinderResult.cs b/src/ModVerify.CliApp/GameFinderResult.cs index a98ccd0..5d6cc94 100644 --- a/src/ModVerify.CliApp/GameFinderResult.cs +++ b/src/ModVerify.CliApp/GameFinderResult.cs @@ -2,4 +2,4 @@ namespace ModVerify.CliApp; -public readonly record struct GameFinderResult(IGame Game, IGame FallbackGame); \ No newline at end of file +internal record GameFinderResult(IGame Game, IGame? FallbackGame); \ No newline at end of file diff --git a/src/ModVerify.CliApp/GameFinderService.cs b/src/ModVerify.CliApp/GameFinderService.cs new file mode 100644 index 0000000..4b9f23a --- /dev/null +++ b/src/ModVerify.CliApp/GameFinderService.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.IO.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using PG.StarWarsGame.Infrastructure.Clients.Steam; +using PG.StarWarsGame.Infrastructure.Games; +using PG.StarWarsGame.Infrastructure.Mods; +using PG.StarWarsGame.Infrastructure.Services; +using PG.StarWarsGame.Infrastructure.Services.Dependencies; +using PG.StarWarsGame.Infrastructure.Services.Detection; + +namespace ModVerify.CliApp; + +internal class GameFinderService +{ + private readonly IServiceProvider _serviceProvider; + private readonly IFileSystem _fileSystem; + private readonly ILogger? _logger; + private readonly IGameFactory _gameFactory; + private readonly IModFactory _modFactory; + + public GameFinderService(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _fileSystem = _serviceProvider.GetRequiredService(); + _gameFactory = _serviceProvider.GetRequiredService(); + _modFactory = _serviceProvider.GetRequiredService(); + _logger = _serviceProvider.GetService()?.CreateLogger(GetType()); + } + + public GameFinderResult FindGames() + { + var detectors = new List + { + new DirectoryGameDetector(_fileSystem.DirectoryInfo.New(Environment.CurrentDirectory), _serviceProvider), + new SteamPetroglyphStarWarsGameDetector(_serviceProvider), + }; + + return FindGames(detectors); + } + + public GameFinderResult FindGamesFromPath(string path) + { + // There are three common situations: + // 1. path points to the actual game directory + // 2. path points to a local mod in game/Mods/ModDir + // 3. path points to a workshop mod + var givenDirectory = _fileSystem.DirectoryInfo.New(path); + var possibleGameDir = givenDirectory.Parent?.Parent; + var possibleSteamAppsFolder = givenDirectory.Parent?.Parent?.Parent?.Parent?.Parent; + + var detectors = new List + { + new DirectoryGameDetector(givenDirectory, _serviceProvider) + }; + + if (possibleGameDir is not null) + detectors.Add(new DirectoryGameDetector(possibleGameDir, _serviceProvider)); + + if (possibleSteamAppsFolder is not null && possibleSteamAppsFolder.Name == "steamapps" && uint.TryParse(givenDirectory.Name, out _)) + detectors.Add(new SteamPetroglyphStarWarsGameDetector(_serviceProvider)); + + return FindGames(detectors); + } + + private bool TryDetectGame(GameType gameType, IList detectors, out GameDetectionResult result) + { + var gd = new CompositeGameDetector(detectors, _serviceProvider, true); + result = gd.Detect(new GameDetectorOptions(gameType)); + + if (result.Error is not null) + { + _logger?.LogTrace($"Unable to find game installation: {result.Error.Message}", result.Error); + return false; + } + if (result.GameLocation is null) + return false; + + return true; + } + + + private void SetupMods(IGame game) + { + var modFinder = _serviceProvider.GetRequiredService(); + var modRefs = modFinder.FindMods(game); + + var mods = new List(); + + foreach (var modReference in modRefs) + { + var mod = _modFactory.FromReference(game, modReference); + mods.AddRange(mod); + } + + foreach (var mod in mods) + game.AddMod(mod); + + // Mods need to be added to the game first, before resolving their dependencies. + foreach (var mod in mods) + { + var resolver = _serviceProvider.GetRequiredService(); + mod.ResolveDependencies(resolver, + new DependencyResolverOptions { CheckForCycle = true, ResolveCompleteChain = true }); + } + } + + private GameFinderResult FindGames(IList detectors) + { + // FoC needs to be tried first + if (!TryDetectGame(GameType.Foc, detectors, out var result)) + { + _logger?.LogTrace("Unable to find FoC installation. Trying again with EaW..."); + if (!TryDetectGame(GameType.EaW, detectors, out result)) + throw new GameException("Unable to find game installation: Wrong install path?"); + } + + if (result.GameLocation is null) + throw new GameException("Unable to find game installation: Wrong install path?"); + + _logger?.LogTrace($"Found game installation: {result.GameIdentity} at {result.GameLocation.FullName}"); + + var game = _gameFactory.CreateGame(result); + + SetupMods(game); + + + IGame? fallbackGame = null; + // If the game is Foc we want to set up Eaw as well as the fallbackGame + if (game.Type == GameType.Foc) + { + var fallbackDetectors = new List(); + + if (game.Platform == GamePlatform.SteamGold) + fallbackDetectors.Add(new SteamPetroglyphStarWarsGameDetector(_serviceProvider)); + else + throw new NotImplementedException("Searching fallback game for non-Steam games is currently is not yet implemented."); + + if (!TryDetectGame(GameType.EaW, fallbackDetectors, out var fallbackResult) || fallbackResult.GameLocation is null) + throw new GameException("Unable to find fallback game installation: Wrong install path?"); + + _logger?.LogTrace($"Found fallback game installation: {fallbackResult.GameIdentity} at {fallbackResult.GameLocation.FullName}"); + + fallbackGame = _gameFactory.CreateGame(fallbackResult); + + SetupMods(fallbackGame); + } + + return new GameFinderResult(game, fallbackGame); + } +} \ No newline at end of file diff --git a/src/ModVerify.CliApp/ModFinderService.cs b/src/ModVerify.CliApp/ModFinderService.cs deleted file mode 100644 index ab61067..0000000 --- a/src/ModVerify.CliApp/ModFinderService.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO.Abstractions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using PG.StarWarsGame.Infrastructure.Clients.Steam; -using PG.StarWarsGame.Infrastructure.Games; -using PG.StarWarsGame.Infrastructure.Mods; -using PG.StarWarsGame.Infrastructure.Services; -using PG.StarWarsGame.Infrastructure.Services.Dependencies; -using PG.StarWarsGame.Infrastructure.Services.Detection; - -namespace ModVerify.CliApp; - -internal class ModFinderService -{ - private readonly IServiceProvider _serviceProvider; - private readonly IFileSystem _fileSystem; - private readonly ILogger? _logger; - private readonly IGameFactory _gameFactory; - private readonly IModFactory _modFactory; - - public ModFinderService(IServiceProvider serviceProvider) - { - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - _fileSystem = _serviceProvider.GetRequiredService(); - _gameFactory = _serviceProvider.GetRequiredService(); - _modFactory = _serviceProvider.GetRequiredService(); - _logger = _serviceProvider.GetService()?.CreateLogger(GetType()); - } - - public GameFinderResult FindAndAddModInCurrentDirectory() - { - var currentDirectory = _fileSystem.DirectoryInfo.New(Environment.CurrentDirectory); - - // Assuming the currentDir is inside a Mod's directory, we need to go up two level (Game/Mods/ModDir) - var potentialGameDirectory = currentDirectory.Parent?.Parent; - if (potentialGameDirectory is null) - throw new GameException("Unable to find game installation: Wrong install path?"); - - var gd = new CompositeGameDetector(new List - { - new DirectoryGameDetector(potentialGameDirectory, _serviceProvider), - new SteamPetroglyphStarWarsGameDetector(_serviceProvider) - }, _serviceProvider, true); - - var focDetectionResult = gd.Detect(new GameDetectorOptions(GameType.Foc)); - - if (focDetectionResult.Error is not null) - throw new GameException($"Unable to find game installation: {focDetectionResult.Error.Message}", focDetectionResult.Error); - - if (focDetectionResult.GameLocation is null) - throw new GameException("Unable to find game installation: Wrong install path?"); - - _logger?.LogDebug($"Found game {focDetectionResult.GameIdentity} at '{focDetectionResult.GameLocation.FullName}'"); - - var foc = _gameFactory.CreateGame(focDetectionResult); - - var modFinder = _serviceProvider.GetRequiredService(); - var modRefs = modFinder.FindMods(foc); - - var mods = new List(); - - foreach (var modReference in modRefs) - { - var mod = _modFactory.FromReference(foc, modReference); - mods.AddRange(mod); - } - - foreach (var mod in mods) - foc.AddMod(mod); - - // Mods need to be added to the game first, before resolving their dependencies. - foreach (var mod in mods) - { - var resolver = _serviceProvider.GetRequiredService(); - mod.ResolveDependencies(resolver, - new DependencyResolverOptions { CheckForCycle = true, ResolveCompleteChain = true }); - } - - - var eawDetectionResult = gd.Detect(new GameDetectorOptions(GameType.EaW)); - if (eawDetectionResult.GameLocation is null) - throw new GameException("Unable to find Empire at War installation."); - if (eawDetectionResult.Error is not null) - throw new GameException($"Unable to find game installation: {eawDetectionResult.Error.Message}", eawDetectionResult.Error); - _logger?.LogDebug($"Found game {eawDetectionResult.GameIdentity} at '{eawDetectionResult.GameLocation.FullName}'"); - - var eaw = _gameFactory.CreateGame(eawDetectionResult); - - return new GameFinderResult(foc, eaw); - } -} \ No newline at end of file diff --git a/src/ModVerify.CliApp/ModOrGameSelector.cs b/src/ModVerify.CliApp/ModOrGameSelector.cs new file mode 100644 index 0000000..7659c38 --- /dev/null +++ b/src/ModVerify.CliApp/ModOrGameSelector.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.IO.Abstractions; +using EawModinfo.Spec; +using Microsoft.Extensions.DependencyInjection; +using PG.StarWarsGame.Infrastructure; +using PG.StarWarsGame.Infrastructure.Games; +using PG.StarWarsGame.Infrastructure.Mods; + +namespace ModVerify.CliApp; + +internal class ModOrGameSelector(IServiceProvider serviceProvider) +{ + private readonly GameFinderService _gameFinderService = new(serviceProvider); + private readonly IFileSystem _fileSystem = serviceProvider.GetRequiredService(); + + public ModSelectionResult SelectModOrGame(string? searchPath) + { + if (string.IsNullOrEmpty(searchPath)) + return SelectFromConsoleInput(); + return SelectFromPath(searchPath!); + } + + private ModSelectionResult SelectFromPath(string searchPath) + { + var fullSearchPath = _fileSystem.Path.GetFullPath(searchPath); + var gameResult = _gameFinderService.FindGamesFromPath(fullSearchPath); + + IPhysicalPlayableObject? gameOrMod = null; + + if (gameResult.Game.Directory.FullName.Equals(fullSearchPath, StringComparison.OrdinalIgnoreCase)) + gameOrMod = gameResult.Game; + else + { + foreach (var mod in gameResult.Game.Mods) + { + if (mod is IPhysicalMod physicalMod) + { + if (physicalMod.Directory.FullName.Equals(fullSearchPath, StringComparison.OrdinalIgnoreCase)) + { + gameOrMod = physicalMod; + break; + } + } + } + } + + if (gameOrMod is null) + throw new GameException($"Unable to a game or mod matching the path '{fullSearchPath}'."); + + return new ModSelectionResult + { + FallbackGame = gameResult.FallbackGame, + ModOrGame = gameOrMod + }; + } + + private ModSelectionResult SelectFromConsoleInput() + { + var gameResult = _gameFinderService.FindGames(); + + var list = new List(); + + var game = gameResult.Game; + list.Add(gameResult.Game); + + Console.WriteLine($"0: {game.Name}"); + + var counter = 1; + + var workshopMods = new HashSet(new ModEqualityComparer(false, false)); + + foreach (var mod in game.Mods) + { + var isSteam = mod.Type == ModType.Workshops; + if (isSteam) + { + workshopMods.Add(mod); + continue; + } + Console.WriteLine($"{counter++}:\t{mod.Name}"); + list.Add(mod); + } + + if (gameResult.FallbackGame is not null) + { + var fallbackGame = gameResult.FallbackGame; + list.Add(fallbackGame); + Console.WriteLine($"{counter++}: {fallbackGame.Name}"); + + foreach (var mod in fallbackGame.Mods) + { + var isSteam = mod.Type == ModType.Workshops; + if (isSteam) + { + workshopMods.Add(mod); + continue; + } + Console.WriteLine($"{counter++}:\t{mod.Name}"); + list.Add(mod); + } + } + + Console.WriteLine("Workshop Items:"); + foreach (var mod in workshopMods) + { + Console.WriteLine($"{counter++}:\t{mod.Name}"); + list.Add(mod); + } + + + IPlayableObject? selectedObject = null; + + do + { + Console.Write("Select a game or mod to verify: "); + var numberString = Console.ReadLine(); + + if (!int.TryParse(numberString, out var number)) + continue; + if (number < list.Count) + selectedObject = list[number]; + } while (selectedObject is null); + + if (selectedObject is not IPhysicalPlayableObject physicalPlayableObject) + throw new InvalidOperationException(); + + var coercedFallbackGame = gameResult.FallbackGame; + if (selectedObject.Equals(gameResult.FallbackGame)) + coercedFallbackGame = null; + else if (selectedObject.Game.Equals(gameResult.FallbackGame)) + coercedFallbackGame = null; + + + return new ModSelectionResult + { + ModOrGame = physicalPlayableObject, + FallbackGame = coercedFallbackGame + }; + } +} \ No newline at end of file diff --git a/src/ModVerify.CliApp/ModSelectionResult.cs b/src/ModVerify.CliApp/ModSelectionResult.cs new file mode 100644 index 0000000..dee1c45 --- /dev/null +++ b/src/ModVerify.CliApp/ModSelectionResult.cs @@ -0,0 +1,10 @@ +using PG.StarWarsGame.Infrastructure; +using PG.StarWarsGame.Infrastructure.Games; + +namespace ModVerify.CliApp; + +internal record ModSelectionResult +{ + public IPhysicalPlayableObject ModOrGame { get; init; } + public IGame? FallbackGame { get; init; } +} \ No newline at end of file diff --git a/src/ModVerify.CliApp/ModVerify.CliApp.csproj b/src/ModVerify.CliApp/ModVerify.CliApp.csproj index 66924d6..22a7164 100644 --- a/src/ModVerify.CliApp/ModVerify.CliApp.csproj +++ b/src/ModVerify.CliApp/ModVerify.CliApp.csproj @@ -4,8 +4,10 @@ false net8.0;net48 Exe + $(RepoRootPath)aet.ico AET.ModVerify.CommandLine AET.ModVerify + Application that allows to verify to verify game modifications for Empire at War / Forces of Corruption against a set of common rules. AlamoEngineTools.ModVerify.CliApp alamo,petroglyph,glyphx @@ -16,8 +18,14 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -31,6 +39,12 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -38,4 +52,18 @@ + + + + + + + + + + + + + + diff --git a/src/ModVerify.CliApp/ModVerifyApp.cs b/src/ModVerify.CliApp/ModVerifyApp.cs new file mode 100644 index 0000000..0d6929b --- /dev/null +++ b/src/ModVerify.CliApp/ModVerifyApp.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using System.Threading.Tasks; +using AET.ModVerify; +using AET.ModVerify.Reporting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using PG.StarWarsGame.Engine; +using PG.StarWarsGame.Infrastructure.Mods; +using PG.StarWarsGame.Infrastructure.Services.Dependencies; + +namespace ModVerify.CliApp; + +internal class ModVerifyApp(ModVerifyAppSettings settings, IServiceProvider services) +{ + private readonly ILogger? _logger = services.GetService()?.CreateLogger(typeof(ModVerifyApp)); + private readonly IFileSystem _fileSystem = services.GetRequiredService(); + + public async Task RunApplication() + { + var returnCode = 0; + + var gameSetupData = CreateGameSetupData(settings, services); + var verifyPipeline = new ModVerifyPipeline(gameSetupData.EngineType, gameSetupData.GameLocations, settings.GameVerifySettigns, services); + + try + { + _logger?.LogInformation($"Verifying {gameSetupData.VerifyObject.Name}..."); + await verifyPipeline.RunAsync().ConfigureAwait(false); + _logger?.LogInformation("Finished Verifying"); + } + catch (GameVerificationException e) + { + returnCode = e.HResult; + } + + if (settings.CreateNewBaseline) + await WriteBaseline(verifyPipeline.Errors, settings.NewBaselinePath).ConfigureAwait(false); + + return returnCode; + } + + private async Task WriteBaseline(IEnumerable errors, string baselineFile) + { + var fullPath = _fileSystem.Path.GetFullPath(baselineFile); +#if NET + await +#endif + using var fs = _fileSystem.FileStream.New(fullPath, FileMode.Create, FileAccess.Write, FileShare.None); + var baseline = new VerificationBaseline(errors); + await baseline.ToJsonAsync(fs); + } + + private static VerifyGameSetupData CreateGameSetupData(ModVerifyAppSettings options, IServiceProvider services) + { + var selectionResult = new ModOrGameSelector(services).SelectModOrGame(options.PathToVerify); + + IList mods = Array.Empty(); + if (selectionResult.ModOrGame is IMod mod) + { + var traverser = services.GetRequiredService(); + mods = traverser.Traverse(mod) + .Select(x => x.Mod) + .OfType().Select(x => x.Directory.FullName) + .ToList(); + } + + var fallbackPaths = new List(); + if (selectionResult.FallbackGame is not null) + fallbackPaths.Add(selectionResult.FallbackGame.Directory.FullName); + + if (!string.IsNullOrEmpty(options.AdditionalFallbackPath)) + fallbackPaths.Add(options.AdditionalFallbackPath); + + var gameLocations = new GameLocations( + mods, + selectionResult.ModOrGame.Game.Directory.FullName, + fallbackPaths); + + return new VerifyGameSetupData + { + EngineType = GameEngineType.Foc, + GameLocations = gameLocations, + VerifyObject = selectionResult.ModOrGame, + }; + } +} \ No newline at end of file diff --git a/src/ModVerify.CliApp/ModVerifyAppSettings.cs b/src/ModVerify.CliApp/ModVerifyAppSettings.cs new file mode 100644 index 0000000..3e7092c --- /dev/null +++ b/src/ModVerify.CliApp/ModVerifyAppSettings.cs @@ -0,0 +1,21 @@ +using System; +using AET.ModVerify.Settings; +using System.Diagnostics.CodeAnalysis; + +namespace ModVerify.CliApp; + +public record ModVerifyAppSettings +{ + public GameVerifySettings GameVerifySettigns { get; init; } + + public string? PathToVerify { get; init; } = null; + + public string Output { get; init; } = Environment.CurrentDirectory; + + public string? AdditionalFallbackPath { get; init; } = null; + + [MemberNotNullWhen(true, nameof(NewBaselinePath))] + public bool CreateNewBaseline => !string.IsNullOrEmpty(NewBaselinePath); + + public string? NewBaselinePath { get; init; } +} \ No newline at end of file diff --git a/src/ModVerify.CliApp/ModVerifyOptions.cs b/src/ModVerify.CliApp/ModVerifyOptions.cs new file mode 100644 index 0000000..323b78d --- /dev/null +++ b/src/ModVerify.CliApp/ModVerifyOptions.cs @@ -0,0 +1,43 @@ +using AET.ModVerify.Reporting; +using CommandLine; + +namespace ModVerify.CliApp; + +internal class ModVerifyOptions +{ + [Option('p', "path", Required = false, + HelpText = "The path to a mod directory to verify. If not path is specified, " + + "the app searches for game installations and asks the user to select a game or mod.")] + public string? Path { get; set; } + + [Option('o', "output", Required = false, HelpText = "directory where result files shall be stored to.")] + public string? Output { get; set; } + + [Option('v', "verbose", Required = false, HelpText = "Set output to verbose messages.")] + public bool Verbose { get; set; } + + [Option("baseline", Required = false, HelpText = "Path to a JSON baseline file.")] + public string? Baseline { get; set; } + + [Option("suppressions", Required = false, HelpText = "Path to a JSON suppression file.")] + public string? Suppressions { get; set; } + + [Option("minFailSeverity", Required = false, Default = null, + HelpText = "When set, the application return with an error, if any finding has at least the specified severity value.")] + public VerificationSeverity? MinimumFailureSeverity { get; set; } + + + [Option("failFast", Required = false, Default = false, + HelpText = "When set, the application will abort on the first failure. The option also recognized the 'MinimumFailureSeverity' setting.")] + public bool FailFast { get; set; } + + [Option("createBaseline", Required = false, + HelpText = "When a path is specified, the tools creates a new baseline file. " + + "An existing file will be overwritten. " + + "Previous baselines are merged into the new baseline.")] + public string? NewBaselineFile { get; set; } + + [Option("fallbackPath", Required = false, + HelpText = "Additional fallback path, which may contain assets that shall be included when doing the verification.")] + public string? AdditionalFallbackPath { get; set; } +} \ No newline at end of file diff --git a/src/ModVerify.CliApp/ModVerifyPipeline.cs b/src/ModVerify.CliApp/ModVerifyPipeline.cs new file mode 100644 index 0000000..03fbfe3 --- /dev/null +++ b/src/ModVerify.CliApp/ModVerifyPipeline.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using AET.ModVerify; +using AET.ModVerify.Settings; +using AET.ModVerify.Verifiers; +using Microsoft.Extensions.DependencyInjection; +using PG.StarWarsGame.Engine; +using PG.StarWarsGame.Engine.Database; + +namespace ModVerify.CliApp; + +internal class ModVerifyPipeline( + GameEngineType targetType, + GameLocations gameLocations, + GameVerifySettings settings, + IServiceProvider serviceProvider) + : VerifyGamePipeline(targetType, gameLocations, settings, serviceProvider) +{ + protected override IEnumerable CreateVerificationSteps(IGameDatabase database) + { + var verifyProvider = ServiceProvider.GetRequiredService(); + return verifyProvider.GetAllDefaultVerifiers(database, Settings); + } +} \ No newline at end of file diff --git a/src/ModVerify.CliApp/Program.cs b/src/ModVerify.CliApp/Program.cs index b4157d8..1a99252 100644 --- a/src/ModVerify.CliApp/Program.cs +++ b/src/ModVerify.CliApp/Program.cs @@ -1,117 +1,104 @@ using System; -using System.Collections.Generic; using System.IO.Abstractions; -using System.Linq; using System.Runtime.CompilerServices; using System.Threading.Tasks; using AET.ModVerify; +using AET.ModVerify.Reporting.Reporters; +using AET.ModVerify.Reporting.Reporters.JSON; +using AET.ModVerify.Reporting.Reporters.Text; using AET.SteamAbstraction; +using AnakinRaW.CommonUtilities.FileSystem; using AnakinRaW.CommonUtilities.Hashing; using AnakinRaW.CommonUtilities.Registry; using AnakinRaW.CommonUtilities.Registry.Windows; -using EawModinfo.Spec; +using CommandLine; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Console; using PG.Commons.Extensibility; using PG.StarWarsGame.Engine; -using PG.StarWarsGame.Engine.FileSystem; using PG.StarWarsGame.Files.ALO; using PG.StarWarsGame.Files.DAT.Services.Builder; using PG.StarWarsGame.Files.MEG.Data.Archives; +using PG.StarWarsGame.Files.XML; using PG.StarWarsGame.Infrastructure; using PG.StarWarsGame.Infrastructure.Clients; -using PG.StarWarsGame.Infrastructure.Games; -using PG.StarWarsGame.Infrastructure.Mods; -using PG.StarWarsGame.Infrastructure.Services.Dependencies; +using Serilog; +using Serilog.Events; +using Serilog.Filters; namespace ModVerify.CliApp; internal class Program { - private static IServiceProvider _services = null!; + private const string EngineParserNamespace = "PG.StarWarsGame.Engine.Xml.Parsers"; + private const string ParserNamespace = "PG.StarWarsGame.Files.XML.Parsers"; - static async Task Main(string[] args) + private static async Task Main(string[] args) { - _services = CreateAppServices(); - - var gameFinderResult = new ModFinderService(_services).FindAndAddModInCurrentDirectory(); + var result = 0; - var game = gameFinderResult.Game; - Console.WriteLine($"0: {game.Name}"); - - var list = new List { game }; + var parseResult = Parser.Default.ParseArguments(args); - var counter = 1; - foreach (var mod in game.Mods) + ModVerifyOptions? verifyOptions = null!; + await parseResult.WithParsedAsync(o => { - var isSteam = mod.Type == ModType.Workshops; - var line = $"{counter++}: {mod.Name}"; - if (isSteam) - line += "*"; - Console.WriteLine(line); - list.Add(mod); - } + verifyOptions = o; + return Task.CompletedTask; + }).ConfigureAwait(false); - IPlayableObject? selectedObject = null; + await parseResult.WithNotParsedAsync(e => + { + result = 0xA0; + return Task.CompletedTask; + }).ConfigureAwait(false); - do + if (verifyOptions is null) { - Console.Write("Select a game or mod to verify: "); - var numberString = Console.ReadLine(); + if (result != 0) + return result; + throw new InvalidOperationException("Mod verify was executed with the wrong arguments."); + } - if (int.TryParse(numberString, out var number)) - { - if (number < list.Count) - selectedObject = list[number]; - } - } while (selectedObject is null); + var coreServiceCollection = CreateCoreServices(verifyOptions.Verbose); + var coreServices = coreServiceCollection.BuildServiceProvider(); + var logger = coreServices.GetService()?.CreateLogger(typeof(Program)); + try + { + var settings = new SettingsBuilder(coreServices) + .BuildSettings(verifyOptions); - Console.WriteLine($"Verifying {selectedObject.Name}..."); + var services = CreateAppServices(coreServiceCollection, settings); - var verifyPipeline = BuildPipeline(selectedObject, gameFinderResult.FallbackGame); + var verifier = new ModVerifyApp(settings, services); - try - { - await verifyPipeline.RunAsync(); + return await verifier.RunApplication().ConfigureAwait(false); } - catch (GameVerificationException e) + catch (Exception e) { + logger?.LogCritical(e, e.Message); Console.WriteLine(e.Message); + return e.HResult; } } - - private static VerifyGamePipeline BuildPipeline(IPlayableObject playableObject, IGame fallbackGame) + + private static IServiceCollection CreateCoreServices(bool verboseLogging) { - IList mods = Array.Empty(); - if (playableObject is IMod mod) - { - var traverser = _services.GetRequiredService(); - mods = traverser.Traverse(mod) - .Select(x => x.Mod) - .OfType().Select(x => x.Directory.FullName) - .ToList(); - } + var fileSystem = new FileSystem(); + var serviceCollection = new ServiceCollection(); - var gameLocations = new GameLocations( - mods, - playableObject.Game.Directory.FullName, - fallbackGame.Directory.FullName); + serviceCollection.AddSingleton(new WindowsRegistry()); + serviceCollection.AddSingleton(fileSystem); - var repo = _services.GetRequiredService().Create(GameEngineType.Foc, gameLocations); + serviceCollection.AddLogging(builder => ConfigureLogging(builder, fileSystem, verboseLogging)); - return new VerifyGamePipeline(repo, VerificationSettings.Default, _services); + return serviceCollection; } - private static IServiceProvider CreateAppServices() - { - var fileSystem = new FileSystem(); - var serviceCollection = new ServiceCollection(); - - serviceCollection.AddLogging(ConfigureLogging); - serviceCollection.AddSingleton(new WindowsRegistry()); + private static IServiceProvider CreateAppServices(IServiceCollection serviceCollection, ModVerifyAppSettings settings) + { serviceCollection.AddSingleton(sp => new HashingService(sp)); - serviceCollection.AddSingleton(fileSystem); SteamAbstractionLayer.InitializeServices(serviceCollection); PetroglyphGameClients.InitializeServices(serviceCollection); @@ -121,13 +108,35 @@ private static IServiceProvider CreateAppServices() RuntimeHelpers.RunClassConstructor(typeof(IMegArchive).TypeHandle); AloServiceContribution.ContributeServices(serviceCollection); serviceCollection.CollectPgServiceContributions(); + XmlServiceContribution.ContributeServices(serviceCollection); PetroglyphEngineServiceContribution.ContributeServices(serviceCollection); + ModVerifyServiceContribution.ContributeServices(serviceCollection); + + SetupReporting(serviceCollection, settings); + return serviceCollection.BuildServiceProvider(); } - private static void ConfigureLogging(ILoggingBuilder loggingBuilder) + private static void SetupReporting(IServiceCollection serviceCollection, ModVerifyAppSettings settings) + { + serviceCollection.RegisterConsoleReporter(); + + serviceCollection.RegisterJsonReporter(new JsonReporterSettings + { + OutputDirectory = settings.Output, + MinimumReportSeverity = settings.GameVerifySettigns.GlobalReportSettings.MinimumReportSeverity + }); + + serviceCollection.RegisterTextFileReporter(new TextFileReporterSettings + { + OutputDirectory = settings.Output, + MinimumReportSeverity = settings.GameVerifySettigns.GlobalReportSettings.MinimumReportSeverity + }); + } + + private static void ConfigureLogging(ILoggingBuilder loggingBuilder, IFileSystem fileSystem, bool verbose) { loggingBuilder.ClearProviders(); @@ -136,8 +145,48 @@ private static void ConfigureLogging(ILoggingBuilder loggingBuilder) #if DEBUG logLevel = LogLevel.Debug; loggingBuilder.AddDebug(); +#else + if (verbose) + { + logLevel = LogLevel.Debug; + loggingBuilder.AddDebug(); + } #endif - loggingBuilder.AddConsole(); + loggingBuilder.SetMinimumLevel(logLevel); + + SetupFileLogging(loggingBuilder, fileSystem); + + loggingBuilder.AddFilter((category, level) => + { + if (level < logLevel) + return false; + if (string.IsNullOrEmpty(category)) + return false; + if (category.StartsWith(EngineParserNamespace) || category.StartsWith(ParserNamespace)) + return false; + return true; + }).AddConsole(); } + + private static void SetupFileLogging(ILoggingBuilder loggingBuilder, IFileSystem fileSystem) + { + var logPath = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), "ModVerify_log.txt"); + + var logger = new LoggerConfiguration() + .Enrich.FromLogContext() + .MinimumLevel.Verbose() + .Filter.ByExcluding(IsXmlParserLogging) + .WriteTo.RollingFile( + logPath, + outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] [{SourceContext}] {Message}{NewLine}{Exception}") + .CreateLogger(); + + loggingBuilder.AddSerilog(logger); + } + + private static bool IsXmlParserLogging(LogEvent logEvent) + { + return Matching.FromSource(ParserNamespace)(logEvent) || Matching.FromSource(EngineParserNamespace)(logEvent); + } } \ No newline at end of file diff --git a/src/ModVerify.CliApp/Properties/launchSettings.json b/src/ModVerify.CliApp/Properties/launchSettings.json new file mode 100644 index 0000000..2bbebc6 --- /dev/null +++ b/src/ModVerify.CliApp/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "No-Arguments": { + "commandName": "Project", + "commandLineArgs": "-o verifyResults --minFailSeverity Information --baseline test.json" + }, + "From-path": { + "commandName": "Project", + "commandLineArgs": "-p \"C:\\Program Files (x86)\\Steam\\steamapps\\common\\Star Wars Empire at War\\corruption\\Mods\\Republic_at_War\" --baseline test.json" + } + } +} \ No newline at end of file diff --git a/src/ModVerify.CliApp/SettingsBuilder.cs b/src/ModVerify.CliApp/SettingsBuilder.cs new file mode 100644 index 0000000..3821bb8 --- /dev/null +++ b/src/ModVerify.CliApp/SettingsBuilder.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; +using System.IO.Abstractions; +using AET.ModVerify.Reporting; +using AET.ModVerify.Settings; +using Microsoft.Extensions.DependencyInjection; + +namespace ModVerify.CliApp; + +internal class SettingsBuilder(IServiceProvider services) +{ + private readonly IFileSystem _fileSystem = services.GetRequiredService(); + + public ModVerifyAppSettings BuildSettings(ModVerifyOptions options) + { + var output = Environment.CurrentDirectory; + if (!string.IsNullOrEmpty(options.Output)) + output = _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(Environment.CurrentDirectory, options.Output)); + + + return new ModVerifyAppSettings + { + GameVerifySettigns = BuildGameVerifySettings(options), + PathToVerify = options.Path, + Output = output, + AdditionalFallbackPath = options.AdditionalFallbackPath, + NewBaselinePath = options.NewBaselineFile + }; + } + + private GameVerifySettings BuildGameVerifySettings(ModVerifyOptions options) + { + var settings = GameVerifySettings.Default; + + return settings with + { + GlobalReportSettings = BuilderGlobalReportSettings(options), + AbortSettings = BuildAbortSettings(options) + }; + } + + private VerificationAbortSettings BuildAbortSettings(ModVerifyOptions options) + { + return new VerificationAbortSettings + { + FailFast = options.FailFast, + MinimumAbortSeverity = options.MinimumFailureSeverity ?? VerificationSeverity.Information, + ThrowsGameVerificationException = options.MinimumFailureSeverity.HasValue || options.FailFast + }; + } + + private GlobalVerificationReportSettings BuilderGlobalReportSettings(ModVerifyOptions options) + { + var baseline = VerificationBaseline.Empty; + var suppressions = SuppressionList.Empty; + + if (options.Baseline is not null) + { + using var fs = _fileSystem.FileStream.New(options.Baseline, FileMode.Open, FileAccess.Read); + baseline = VerificationBaseline.FromJson(fs); + } + + if (options.Suppressions is not null) + { + using var fs = _fileSystem.FileStream.New(options.Suppressions, FileMode.Open, FileAccess.Read); + suppressions = SuppressionList.FromJson(fs); + } + + return new GlobalVerificationReportSettings + { + Baseline = baseline, + Suppressions = suppressions, + MinimumReportSeverity = VerificationSeverity.Information, + }; + } +} \ No newline at end of file diff --git a/src/ModVerify.CliApp/VerifyGameSetupData.cs b/src/ModVerify.CliApp/VerifyGameSetupData.cs new file mode 100644 index 0000000..621e0c2 --- /dev/null +++ b/src/ModVerify.CliApp/VerifyGameSetupData.cs @@ -0,0 +1,13 @@ +using PG.StarWarsGame.Engine; +using PG.StarWarsGame.Infrastructure; + +namespace ModVerify.CliApp; + +internal class VerifyGameSetupData +{ + public required IPhysicalPlayableObject VerifyObject { get; init; } + + public required GameEngineType EngineType { get; init; } + + public required GameLocations GameLocations { get; init; } +} \ No newline at end of file diff --git a/src/ModVerify/GameVerificationException.cs b/src/ModVerify/GameVerificationException.cs index 0844557..64415d9 100644 --- a/src/ModVerify/GameVerificationException.cs +++ b/src/ModVerify/GameVerificationException.cs @@ -1,17 +1,16 @@ using System; using System.Collections.Generic; using System.Text; -using AET.ModVerify.Steps; -using AnakinRaW.CommonUtilities.SimplePipeline; +using AET.ModVerify.Reporting; namespace AET.ModVerify; -public sealed class GameVerificationException(IEnumerable failedSteps) : Exception +public sealed class GameVerificationException(IEnumerable errors) : Exception { private readonly string? _error = null; - private readonly IEnumerable _failedSteps = failedSteps ?? throw new ArgumentNullException(nameof(failedSteps)); + private readonly IEnumerable _errors = errors ?? throw new ArgumentNullException(nameof(errors)); - public GameVerificationException(GameVerificationStep step) : this([step]) + public GameVerificationException(VerificationError error) : this([error]) { } @@ -26,8 +25,8 @@ private string Error return _error; var stringBuilder = new StringBuilder(); - foreach (var step in _failedSteps) - stringBuilder.Append($"Verification step '{step}' has errors;"); + foreach (var error in _errors) + stringBuilder.AppendLine($"Verification error: {error.Id}:{error.Message};"); return stringBuilder.ToString().TrimEnd(';'); } } diff --git a/src/ModVerify/IGameVerifier.cs b/src/ModVerify/IGameVerifier.cs new file mode 100644 index 0000000..1184c86 --- /dev/null +++ b/src/ModVerify/IGameVerifier.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using AET.ModVerify.Reporting; + +namespace AET.ModVerify; + +public interface IGameVerifier +{ + string Name { get; } + + string FriendlyName { get; } + + IReadOnlyCollection VerifyErrors { get; } +} \ No newline at end of file diff --git a/src/ModVerify/IVerificationProvider.cs b/src/ModVerify/IVerificationProvider.cs new file mode 100644 index 0000000..2d87ab5 --- /dev/null +++ b/src/ModVerify/IVerificationProvider.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using AET.ModVerify.Settings; +using AET.ModVerify.Verifiers; +using PG.StarWarsGame.Engine.Database; + +namespace AET.ModVerify; + +public interface IVerificationProvider +{ + IEnumerable GetAllDefaultVerifiers(IGameDatabase database, GameVerifySettings settings); +} \ No newline at end of file diff --git a/src/ModVerify/ModVerify.csproj b/src/ModVerify/ModVerify.csproj index 8febcd9..d6ce1ec 100644 --- a/src/ModVerify/ModVerify.csproj +++ b/src/ModVerify/ModVerify.csproj @@ -4,6 +4,7 @@ netstandard2.0;netstandard2.1 AET.ModVerify AET.ModVerify + Provides interfaces and classes to verify Empire at War / Forces of Corruption game modifications. AlamoEngineTools.ModVerify AET.ModVerify alamo,petroglyph,glyphx @@ -20,7 +21,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -29,6 +30,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/ModVerify/ModVerifyServiceContribution.cs b/src/ModVerify/ModVerifyServiceContribution.cs new file mode 100644 index 0000000..d223d2c --- /dev/null +++ b/src/ModVerify/ModVerifyServiceContribution.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace AET.ModVerify; + +public static class ModVerifyServiceContribution +{ + public static void ContributeServices(IServiceCollection serviceCollection) + { + serviceCollection.AddSingleton(sp => new VerificationProvider(sp)); + } +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/AssetsEqualityComparer.cs b/src/ModVerify/Reporting/AssetsEqualityComparer.cs new file mode 100644 index 0000000..8f058bc --- /dev/null +++ b/src/ModVerify/Reporting/AssetsEqualityComparer.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace AET.ModVerify.Reporting; + +internal class AssetsEqualityComparer : IEqualityComparer> +{ + readonly IEqualityComparer> _setComparer = HashSet.CreateSetComparer(); + + public static AssetsEqualityComparer Instance { get; } = new(); + + private AssetsEqualityComparer() + { + } + + public bool Equals(HashSet x, HashSet y) + { + return _setComparer.Equals(x, y); + } + + public int GetHashCode(HashSet obj) + { + return _setComparer.GetHashCode(obj); + } +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/FileBasedReporterSettings.cs b/src/ModVerify/Reporting/FileBasedReporterSettings.cs new file mode 100644 index 0000000..6616258 --- /dev/null +++ b/src/ModVerify/Reporting/FileBasedReporterSettings.cs @@ -0,0 +1,14 @@ +using System; + +namespace AET.ModVerify.Reporting; + +public record FileBasedReporterSettings : VerificationReportSettings +{ + private readonly string _outputDirectory = Environment.CurrentDirectory; + + public string OutputDirectory + { + get => _outputDirectory; + init => _outputDirectory = string.IsNullOrEmpty(value) ? Environment.CurrentDirectory : value; + } +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/GlobalVerificationReportSettings.cs b/src/ModVerify/Reporting/GlobalVerificationReportSettings.cs new file mode 100644 index 0000000..3982d4d --- /dev/null +++ b/src/ModVerify/Reporting/GlobalVerificationReportSettings.cs @@ -0,0 +1,10 @@ +using System; + +namespace AET.ModVerify.Reporting; + +public record GlobalVerificationReportSettings : VerificationReportSettings +{ + public VerificationBaseline Baseline { get; init; } = VerificationBaseline.Empty; + + public SuppressionList Suppressions { get; init; } = SuppressionList.Empty; +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/IVerificationReporter.cs b/src/ModVerify/Reporting/IVerificationReporter.cs new file mode 100644 index 0000000..a1a9c48 --- /dev/null +++ b/src/ModVerify/Reporting/IVerificationReporter.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace AET.ModVerify.Reporting; + +public interface IVerificationReporter +{ + public void Report(IReadOnlyCollection errors); +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/Json/JsonSuppressionFilter.cs b/src/ModVerify/Reporting/Json/JsonSuppressionFilter.cs new file mode 100644 index 0000000..92047d8 --- /dev/null +++ b/src/ModVerify/Reporting/Json/JsonSuppressionFilter.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace AET.ModVerify.Reporting.Json; + +internal class JsonSuppressionFilter(SuppressionFilter filter) +{ + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; } = filter.Id; + + [JsonPropertyName("Verifier")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Verifier { get; } = filter.Verifier; + + [JsonPropertyName("assets")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IEnumerable? Assets { get; } = filter.Assets; +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/Json/JsonSuppressionList.cs b/src/ModVerify/Reporting/Json/JsonSuppressionList.cs new file mode 100644 index 0000000..1a8cb4a --- /dev/null +++ b/src/ModVerify/Reporting/Json/JsonSuppressionList.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; + +namespace AET.ModVerify.Reporting.Json; + +internal class JsonSuppressionList +{ + [JsonPropertyName("suppressions")] + public ICollection Filters { get; } + + public JsonSuppressionList(IEnumerable suppressionList) + { + Filters = suppressionList.Select(x => new JsonSuppressionFilter(x)).ToList(); + } +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/Json/JsonVerificationBaseline.cs b/src/ModVerify/Reporting/Json/JsonVerificationBaseline.cs new file mode 100644 index 0000000..303e168 --- /dev/null +++ b/src/ModVerify/Reporting/Json/JsonVerificationBaseline.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; + +namespace AET.ModVerify.Reporting.Json; + +internal class JsonVerificationBaseline +{ + [JsonPropertyName("errors")] + public IEnumerable Errors { get; } + + public JsonVerificationBaseline(VerificationBaseline baseline) + { + Errors = baseline.Select(x => new JsonVerificationError(x)); + } + + [JsonConstructor] + private JsonVerificationBaseline(IEnumerable errors) + { + Errors = errors; + } +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/Json/JsonVerificationError.cs b/src/ModVerify/Reporting/Json/JsonVerificationError.cs new file mode 100644 index 0000000..9c69d8d --- /dev/null +++ b/src/ModVerify/Reporting/Json/JsonVerificationError.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace AET.ModVerify.Reporting.Json; + +internal class JsonVerificationError +{ + [JsonPropertyName("id")] + public string Id { get; } + + [JsonPropertyName("verifier")] + public string Verifier { get; } + + [JsonPropertyName("message")] + public string Message { get; } + + [JsonPropertyName("severity")] + public VerificationSeverity Severity { get; } + + [JsonPropertyName("assets")] + public IEnumerable Assets { get; } + + [JsonConstructor] + private JsonVerificationError(string id, string verifier, string message, VerificationSeverity severity, IEnumerable assets) + { + Id = id; + Verifier = verifier; + Message = message; + Severity = severity; + Assets = assets; + } + + public JsonVerificationError(VerificationError error) + { + Id = error.Id; + Verifier = error.Verifier; + Message = error.Message; + Severity = error.Severity; + Assets = error.AffectedAssets; + } +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/Json/JsonVerificationReport.cs b/src/ModVerify/Reporting/Json/JsonVerificationReport.cs new file mode 100644 index 0000000..6ebb926 --- /dev/null +++ b/src/ModVerify/Reporting/Json/JsonVerificationReport.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace AET.ModVerify.Reporting.Json; + +internal class JsonVerificationReport(IEnumerable errors) +{ + [JsonPropertyName("errors")] + public IEnumerable Errors { get; } = errors; +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/Json/ModVerifyJsonSettings.cs b/src/ModVerify/Reporting/Json/ModVerifyJsonSettings.cs new file mode 100644 index 0000000..054f0ad --- /dev/null +++ b/src/ModVerify/Reporting/Json/ModVerifyJsonSettings.cs @@ -0,0 +1,11 @@ +using System.Text.Json; + +namespace AET.ModVerify.Reporting.Json; + +internal static class ModVerifyJsonSettings +{ + public static readonly JsonSerializerOptions JsonSettings = new() + { + WriteIndented = true + }; +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/Reporters/ConsoleReporter.cs b/src/ModVerify/Reporting/Reporters/ConsoleReporter.cs new file mode 100644 index 0000000..9dd7f30 --- /dev/null +++ b/src/ModVerify/Reporting/Reporters/ConsoleReporter.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AET.ModVerify.Reporting.Reporters; + +internal class ConsoleReporter(VerificationReportSettings settings, IServiceProvider serviceProvider) : ReporterBase(settings, serviceProvider) +{ + public override void Report(IReadOnlyCollection errors) + { + var filteredErrors = FilteredErrors(errors).ToList(); + + Console.WriteLine(); + Console.WriteLine("GAME VERIFICATION RESULT"); + Console.WriteLine($"Errors of severity {Settings.MinimumReportSeverity}: {filteredErrors.Count}"); + Console.WriteLine(); + + if (filteredErrors.Count == 0) + Console.WriteLine("No errors!"); + + foreach (var error in filteredErrors) + Console.WriteLine(error); + + Console.WriteLine(); + } +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/Reporters/FileBasedReporter.cs b/src/ModVerify/Reporting/Reporters/FileBasedReporter.cs new file mode 100644 index 0000000..d0213d0 --- /dev/null +++ b/src/ModVerify/Reporting/Reporters/FileBasedReporter.cs @@ -0,0 +1,21 @@ +using System; +using System.IO; +using System.IO.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace AET.ModVerify.Reporting.Reporters; + +public abstract class FileBasedReporter(T settings, IServiceProvider serviceProvider) : ReporterBase(settings, serviceProvider) where T : FileBasedReporterSettings +{ + private readonly IFileSystem _fileSystem = serviceProvider.GetRequiredService(); + + protected Stream CreateFile(string fileName) + { + var outputDirectory = Settings.OutputDirectory; + _fileSystem.Directory.CreateDirectory(outputDirectory); + + var filePath = _fileSystem.Path.Combine(outputDirectory, fileName); + + return _fileSystem.FileStream.New(_fileSystem.Path.GetFullPath(filePath), FileMode.Create, FileAccess.Write); + } +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/Reporters/JSON/JsonReporter.cs b/src/ModVerify/Reporting/Reporters/JSON/JsonReporter.cs new file mode 100644 index 0000000..abbe5f0 --- /dev/null +++ b/src/ModVerify/Reporting/Reporters/JSON/JsonReporter.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using AET.ModVerify.Reporting.Json; + +namespace AET.ModVerify.Reporting.Reporters.JSON; + +internal class JsonReporter(JsonReporterSettings settings, IServiceProvider serviceProvider) : FileBasedReporter(settings, serviceProvider) +{ + public const string FileName = "VerificationResult.json"; + + + public override void Report(IReadOnlyCollection errors) + { + var report = new JsonVerificationReport(errors.Select(x => new JsonVerificationError(x))); + using var fs = CreateFile(FileName); + JsonSerializer.Serialize(fs, report, ModVerifyJsonSettings.JsonSettings); + } +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/Reporters/JSON/JsonReporterSettings.cs b/src/ModVerify/Reporting/Reporters/JSON/JsonReporterSettings.cs new file mode 100644 index 0000000..fd5962d --- /dev/null +++ b/src/ModVerify/Reporting/Reporters/JSON/JsonReporterSettings.cs @@ -0,0 +1,3 @@ +namespace AET.ModVerify.Reporting.Reporters.JSON; + +public record JsonReporterSettings : FileBasedReporterSettings; \ No newline at end of file diff --git a/src/ModVerify/Reporting/Reporters/ReporterBase.cs b/src/ModVerify/Reporting/Reporters/ReporterBase.cs new file mode 100644 index 0000000..d7945af --- /dev/null +++ b/src/ModVerify/Reporting/Reporters/ReporterBase.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AET.ModVerify.Reporting.Reporters; + +public abstract class ReporterBase(T settings, IServiceProvider serviceProvider) : IVerificationReporter where T : VerificationReportSettings +{ + protected IServiceProvider ServiceProvider { get; } = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + + protected T Settings { get; } = settings ?? throw new ArgumentNullException(nameof(settings)); + + + public abstract void Report(IReadOnlyCollection errors); + + protected IEnumerable FilteredErrors(IReadOnlyCollection errors) + { + return errors.Where(x => x.Severity >= Settings.MinimumReportSeverity); + } +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/Reporters/Text/TextFileReporter.cs b/src/ModVerify/Reporting/Reporters/Text/TextFileReporter.cs new file mode 100644 index 0000000..86ce6d4 --- /dev/null +++ b/src/ModVerify/Reporting/Reporters/Text/TextFileReporter.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; + +namespace AET.ModVerify.Reporting.Reporters.Text; + +internal class TextFileReporter(TextFileReporterSettings settings, IServiceProvider serviceProvider) : FileBasedReporter(settings, serviceProvider) +{ + internal const string SingleReportFileName = "VerificationResult.txt"; + + private readonly IFileSystem _fileSystem = serviceProvider.GetRequiredService(); + + public override void Report(IReadOnlyCollection errors) + { + if (Settings.SplitIntoFiles) + ReportByVerifier(errors); + else + ReportWhole(errors); + } + + private void ReportWhole(IReadOnlyCollection errors) + { + using var streamWriter = new StreamWriter(CreateFile(SingleReportFileName)); + + foreach (var error in errors.OrderBy(x => x.Id)) + WriteError(error, streamWriter); + + } + + private void ReportByVerifier(IReadOnlyCollection errors) + { + var grouped = errors.GroupBy(x => x.Verifier); + foreach (var group in grouped) + ReportToSingleFile(group); + } + + private void ReportToSingleFile(IGrouping group) + { + var fileName = $"{GetVerifierName(group.Key)}Results.txt"; + using var streamWriter = new StreamWriter(CreateFile(fileName)); + foreach (var error in group.OrderBy(x => x.Id)) + WriteError(error, streamWriter); + } + + private static string GetVerifierName(string verifierTypeName) + { + var typeNameSpan = verifierTypeName.AsSpan(); + var nameIndex = typeNameSpan.LastIndexOf('.'); + + if (nameIndex == -1) + return verifierTypeName; + + // They type name must not be empty + if (typeNameSpan.Length == nameIndex) + throw new InvalidOperationException(); + + var name = typeNameSpan.Slice(nameIndex + 1); + + // Normalize subtypes (such as C/M) to avoid creating directories + if (name.IndexOf('/') != -1) + return name.ToString().Replace('/', '.'); + + return name.ToString(); + } + + private static void WriteError(VerificationError error, StreamWriter writer) + { + writer.WriteLine($"[{error.Id}] {error.Message}"); + } +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/Reporters/Text/TextFileReporterSettings.cs b/src/ModVerify/Reporting/Reporters/Text/TextFileReporterSettings.cs new file mode 100644 index 0000000..e6847a3 --- /dev/null +++ b/src/ModVerify/Reporting/Reporters/Text/TextFileReporterSettings.cs @@ -0,0 +1,6 @@ +namespace AET.ModVerify.Reporting.Reporters.Text; + +public record TextFileReporterSettings : FileBasedReporterSettings +{ + public bool SplitIntoFiles { get; init; } = true; +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/Reporters/VerificationReportersExtensions.cs b/src/ModVerify/Reporting/Reporters/VerificationReportersExtensions.cs new file mode 100644 index 0000000..04f7c7d --- /dev/null +++ b/src/ModVerify/Reporting/Reporters/VerificationReportersExtensions.cs @@ -0,0 +1,47 @@ +using AET.ModVerify.Reporting.Reporters.JSON; +using AET.ModVerify.Reporting.Reporters.Text; +using Microsoft.Extensions.DependencyInjection; + +namespace AET.ModVerify.Reporting.Reporters; + +public static class VerificationReportersExtensions +{ + public static IServiceCollection RegisterJsonReporter(this IServiceCollection serviceCollection) + { + return RegisterJsonReporter(serviceCollection, new JsonReporterSettings + { + OutputDirectory = "." + }); + } + + public static IServiceCollection RegisterTextFileReporter(this IServiceCollection serviceCollection) + { + return RegisterTextFileReporter(serviceCollection, new TextFileReporterSettings + { + OutputDirectory = "." + }); + } + + public static IServiceCollection RegisterConsoleReporter(this IServiceCollection serviceCollection) + { + return RegisterConsoleReporter(serviceCollection, new VerificationReportSettings + { + MinimumReportSeverity = VerificationSeverity.Error + }); + } + + public static IServiceCollection RegisterJsonReporter(this IServiceCollection serviceCollection, JsonReporterSettings settings) + { + return serviceCollection.AddSingleton(sp => new JsonReporter(settings, sp)); + } + + public static IServiceCollection RegisterTextFileReporter(this IServiceCollection serviceCollection, TextFileReporterSettings settings) + { + return serviceCollection.AddSingleton(sp => new TextFileReporter(settings, sp)); + } + + public static IServiceCollection RegisterConsoleReporter(this IServiceCollection serviceCollection, VerificationReportSettings settings) + { + return serviceCollection.AddSingleton(sp => new ConsoleReporter(settings, sp)); + } +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/SuppressionFilter.cs b/src/ModVerify/Reporting/SuppressionFilter.cs new file mode 100644 index 0000000..7fb41de --- /dev/null +++ b/src/ModVerify/Reporting/SuppressionFilter.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AET.ModVerify.Reporting.Json; + +namespace AET.ModVerify.Reporting; + +public sealed class SuppressionFilter : IEquatable +{ + private static readonly AssetsEqualityComparer AssetComparer = AssetsEqualityComparer.Instance; + + private readonly HashSet? _assets; + + public string? Id { get; } + + public string? Verifier { get; } + + public IReadOnlyCollection? Assets { get; } + + public SuppressionFilter(string? id, string? verifier, ICollection? assets) + { + Id = id; + Verifier = verifier; + if (assets is not null) + _assets = new HashSet(assets); + Assets = _assets?.ToList() ?? null; + } + + internal SuppressionFilter(JsonSuppressionFilter filter) + { + Id = filter.Id; + Verifier = filter.Verifier; + + if (filter.Assets is not null) + _assets = new HashSet(filter.Assets); + Assets = _assets?.ToList() ?? null; + } + + public bool Suppresses(VerificationError error) + { + var suppresses = false; + + if (Id is not null) + { + if (Id.Equals(error.Id)) + suppresses = true; + else + return false; + } + + if (Verifier is not null) + { + if (Verifier.Equals(error.Verifier)) + suppresses = true; + else + return false; + } + + if (_assets is not null) + { + if (_assets.Count != error.AffectedAssets.Count) + return false; + + if (!_assets.SetEquals(error.AffectedAssets)) + return false; + + suppresses = true; + } + + return suppresses; + } + + public bool IsDisabled() + { + return Id == null && Verifier == null && (Assets == null || !Assets.Any()); + } + + public int Specificity() + { + var specificity = 0; + if (Id != null) + specificity++; + if (Verifier != null) + specificity++; + if (Assets != null && Assets.Any()) + specificity++; + return specificity; + } + + public bool IsSupersededBy(SuppressionFilter other) + { + if (Id != null && other.Id != null && other.Id != Id) + return false; + + if (Verifier != null && other.Verifier != null && other.Verifier != Verifier) + return false; + + if (Assets != null && other.Assets != null && !AssetComparer.Equals(_assets, other._assets)) + return false; + + return other.Specificity() < Specificity(); + } + + public bool Equals(SuppressionFilter? other) + { + if (ReferenceEquals(null, other)) + return false; + if (ReferenceEquals(this, other)) + return true; + + if (Id != other.Id) + return false; + if (Verifier != other.Verifier) + return false; + + return AssetComparer.Equals(_assets, other._assets); + } + + public override bool Equals(object? obj) + { + return obj is SuppressionFilter other && Equals(other); + } + + public override int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(Id); + hashCode.Add(Verifier); + if (_assets is not null) + hashCode.Add(_assets, AssetComparer); + else + hashCode.Add((HashSet)null!); + return hashCode.ToHashCode(); + } +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/SuppressionList.cs b/src/ModVerify/Reporting/SuppressionList.cs new file mode 100644 index 0000000..23d1563 --- /dev/null +++ b/src/ModVerify/Reporting/SuppressionList.cs @@ -0,0 +1,90 @@ +using AET.ModVerify.Reporting.Json; +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; + +namespace AET.ModVerify.Reporting; + +public sealed class SuppressionList : IReadOnlyCollection +{ + public static readonly SuppressionList Empty = new([]); + + private readonly IReadOnlyCollection _filters; + private readonly IReadOnlyCollection _minimizedFilters; + + public int Count => _filters.Count; + + public SuppressionList(IEnumerable suppressionFilters) + { + if (suppressionFilters == null) + throw new ArgumentNullException(nameof(suppressionFilters)); + + _filters = new List(suppressionFilters); + _minimizedFilters = MinimizeSuppressions(_filters); + } + + internal SuppressionList(JsonSuppressionList suppressionList) + { + if (suppressionList == null) + throw new ArgumentNullException(nameof(suppressionList)); + + _filters = suppressionList.Filters.Select(x => new SuppressionFilter(x)).ToList(); + _minimizedFilters = MinimizeSuppressions(_filters); + } + + public void ToJson(Stream stream) + { + JsonSerializer.Serialize(stream, new JsonSuppressionList(this), ModVerifyJsonSettings.JsonSettings); + } + + public static SuppressionList FromJson(Stream stream) + { + var baselineJson = JsonSerializer.Deserialize(stream, JsonSerializerOptions.Default); + if (baselineJson is null) + throw new InvalidOperationException("Unable to deserialize baseline"); + return new SuppressionList(baselineJson); + } + + + public bool Suppresses(VerificationError error) + { + foreach (var filter in _minimizedFilters) + { + if (filter.Suppresses(error)) + { + return true; + } + } + + return false; + } + + private static IReadOnlyCollection MinimizeSuppressions(IEnumerable filters) + { + var sortedFilters = filters.Where(f => !f.IsDisabled()) + .OrderBy(x => x.Specificity()); + + var result = new List(); + + foreach (var filter in sortedFilters) + { + if (result.All(x => !filter.IsSupersededBy(x))) + result.Add(filter); + } + + return result; + } + + public IEnumerator GetEnumerator() + { + return _filters.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/VerificationBaseline.cs b/src/ModVerify/Reporting/VerificationBaseline.cs new file mode 100644 index 0000000..05bdcbd --- /dev/null +++ b/src/ModVerify/Reporting/VerificationBaseline.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using AET.ModVerify.Reporting.Json; + +namespace AET.ModVerify.Reporting; + +public sealed class VerificationBaseline : IReadOnlyCollection +{ + public static readonly VerificationBaseline Empty = new([]); + + private readonly HashSet _errors; + + /// + public int Count => _errors.Count; + + internal VerificationBaseline(JsonVerificationBaseline baseline) + { + _errors = new HashSet(baseline.Errors.Select(x => new VerificationError(x))); + } + + public VerificationBaseline(IEnumerable errors) + { + _errors = new(errors); + } + + public void ToJson(Stream stream) + { + JsonSerializer.Serialize(stream, new JsonVerificationBaseline(this), ModVerifyJsonSettings.JsonSettings); + } + + public Task ToJsonAsync(Stream stream) + { + return JsonSerializer.SerializeAsync(stream, new JsonVerificationBaseline(this), ModVerifyJsonSettings.JsonSettings); + } + + public static VerificationBaseline FromJson(Stream stream) + { + var baselineJson = JsonSerializer.Deserialize(stream, JsonSerializerOptions.Default); + if (baselineJson is null) + throw new InvalidOperationException("Unable to deserialize baseline"); + return new VerificationBaseline(baselineJson); + } + + /// + public IEnumerator GetEnumerator() + { + return _errors.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/VerificationError.cs b/src/ModVerify/Reporting/VerificationError.cs new file mode 100644 index 0000000..fa25cf3 --- /dev/null +++ b/src/ModVerify/Reporting/VerificationError.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AET.ModVerify.Reporting.Json; +using AnakinRaW.CommonUtilities; + +namespace AET.ModVerify.Reporting; + +public sealed class VerificationError : IEquatable +{ + private static readonly AssetsEqualityComparer AssetComparer = AssetsEqualityComparer.Instance; + + private readonly HashSet _assets; + + public string Id { get; } + + public string Message { get; } + + public string Verifier { get; } + + public IReadOnlyCollection AffectedAssets { get; } + + public VerificationSeverity Severity { get; } + + public VerificationError(string id, string message, string verifier, IEnumerable affectedAssets, VerificationSeverity severity) + { + if (affectedAssets == null) + throw new ArgumentNullException(nameof(affectedAssets)); + ThrowHelper.ThrowIfNullOrEmpty(id); + Id = id; + Message = message ?? throw new ArgumentNullException(nameof(message)); + Verifier = verifier; + Severity = severity; + _assets = new HashSet(affectedAssets); + AffectedAssets = _assets.ToList(); + } + + internal VerificationError(JsonVerificationError error) + { + Id = error.Id; + Message = error.Message; + Verifier = error.Verifier; + _assets = new HashSet(error.Assets); + AffectedAssets = _assets.ToList(); + } + + public static VerificationError Create( + IGameVerifier verifier, + string id, + string message, + VerificationSeverity severity, + IEnumerable assets) + { + return new VerificationError(id, message, verifier.Name, assets, severity); + } + + + public static VerificationError Create( + IGameVerifier verifier, + string id, + string message, + VerificationSeverity severity, + params string[] assets) + { + return new VerificationError(id, message, verifier.Name, assets, severity); + } + + public static VerificationError Create( + IGameVerifier verifier, + string id, + string message, + VerificationSeverity severity) + { + return Create(verifier, id, message, severity, []); + } + + internal static VerificationError Create( + IGameVerifier verifier, + string id, + Exception exception, + VerificationSeverity severity, + params string[] assets) + { + return new VerificationError(id, exception.Message, verifier.Name, assets, severity); + } + + internal static VerificationError Create( + IGameVerifier verifier, + string id, + Exception exception, + VerificationSeverity severity) + { + return Create(verifier, id, exception, severity, []); + } + + + public bool Equals(VerificationError? other) + { + if (other is null) + return false; + if (ReferenceEquals(this, other)) + return true; + + if (!Id.Equals(other.Id)) + return false; + if (!Verifier.Equals(other.Verifier)) + return false; + + return AssetComparer.Equals(_assets, other._assets); + } + + public override bool Equals(object? obj) + { + return obj is VerificationError other && Equals(other); + } + + public override int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(Id); + hashCode.Add(Verifier); + hashCode.Add(_assets, AssetComparer); + return hashCode.ToHashCode(); + } + + public override string ToString() + { + return $"[{Severity}] [{Verifier}] {Id}: Message={Message}; Affected Assets=[{string.Join(",", AffectedAssets)}];"; + } +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/VerificationReportBroker.cs b/src/ModVerify/Reporting/VerificationReportBroker.cs new file mode 100644 index 0000000..57e7a03 --- /dev/null +++ b/src/ModVerify/Reporting/VerificationReportBroker.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AET.ModVerify.Verifiers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace AET.ModVerify.Reporting; + +internal class VerificationReportBroker(GlobalVerificationReportSettings reportSettings, IServiceProvider serviceProvider) +{ + private readonly ILogger? _logger = serviceProvider.GetService()?.CreateLogger(typeof(VerificationReportBroker)); + + + public IReadOnlyCollection Report(IEnumerable steps) + { + var suppressions = new SuppressionList(reportSettings.Suppressions); + + var errors = GetReportableErrors(steps, suppressions); + + var reporters = serviceProvider.GetServices(); + + + foreach (var reporter in reporters) + { + try + { + reporter.Report(errors); + } + catch (Exception e) + { + _logger?.LogError(e, "Exception while reporting verification error"); + } + } + + return errors; + } + + private IReadOnlyCollection GetReportableErrors(IEnumerable steps, SuppressionList suppressions) + { + var allErrors = steps.SelectMany(s => s.VerifyErrors); + + var errorsToReport = new List(); + foreach (var error in allErrors) + { + if (reportSettings.Baseline.Contains(error)) + continue; + + if (suppressions.Suppresses(error)) + continue; + + errorsToReport.Add(error); + } + + // NB: We don't filter for severity here, as that is something the individual reporters should handle. + // This allows better control over what gets reported. + return errorsToReport; + } +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/VerificationReportSettings.cs b/src/ModVerify/Reporting/VerificationReportSettings.cs new file mode 100644 index 0000000..fc02024 --- /dev/null +++ b/src/ModVerify/Reporting/VerificationReportSettings.cs @@ -0,0 +1,6 @@ +namespace AET.ModVerify.Reporting; + +public record VerificationReportSettings +{ + public VerificationSeverity MinimumReportSeverity { get; init; } = VerificationSeverity.Information; +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/VerificationSeverity.cs b/src/ModVerify/Reporting/VerificationSeverity.cs new file mode 100644 index 0000000..f6617e9 --- /dev/null +++ b/src/ModVerify/Reporting/VerificationSeverity.cs @@ -0,0 +1,21 @@ +namespace AET.ModVerify.Reporting; + +public enum VerificationSeverity +{ + /// + /// Indicates that a finding is most likely not affecting the game. + /// + Information = 0, + /// + /// Indicates that a finding might cause undefined behavior or unpredictable results are to be expected. + /// + Warning, + /// + /// Indicates that a finding will most likely not function as expected in the game. + /// + Error, + /// + /// Indicates that a finding will most likely cause a CTD of the game. + /// + Critical +} \ No newline at end of file diff --git a/src/ModVerify/Settings/GameVerifySettings.cs b/src/ModVerify/Settings/GameVerifySettings.cs new file mode 100644 index 0000000..488bfe4 --- /dev/null +++ b/src/ModVerify/Settings/GameVerifySettings.cs @@ -0,0 +1,21 @@ +using AET.ModVerify.Reporting; + +namespace AET.ModVerify.Settings; + +public record GameVerifySettings +{ + public static readonly GameVerifySettings Default = new() + { + AbortSettings = new(), + GlobalReportSettings = new(), + LocalizationOption = VerifyLocalizationOption.English + }; + + public int ParallelVerifiers { get; init; } = 4; + + public VerificationAbortSettings AbortSettings { get; init; } + + public GlobalVerificationReportSettings GlobalReportSettings { get; init; } + + public VerifyLocalizationOption LocalizationOption { get; init; } +} \ No newline at end of file diff --git a/src/ModVerify/Settings/VerificationAbortSettings.cs b/src/ModVerify/Settings/VerificationAbortSettings.cs new file mode 100644 index 0000000..767cef4 --- /dev/null +++ b/src/ModVerify/Settings/VerificationAbortSettings.cs @@ -0,0 +1,12 @@ +using AET.ModVerify.Reporting; + +namespace AET.ModVerify.Settings; + +public record VerificationAbortSettings +{ + public bool FailFast { get; init; } = false; + + public VerificationSeverity MinimumAbortSeverity { get; init; } = VerificationSeverity.Warning; + + public bool ThrowsGameVerificationException { get; init; } = false; +} \ No newline at end of file diff --git a/src/ModVerify/Settings/VerifyLocalizationOption.cs b/src/ModVerify/Settings/VerifyLocalizationOption.cs new file mode 100644 index 0000000..1c0ee7f --- /dev/null +++ b/src/ModVerify/Settings/VerifyLocalizationOption.cs @@ -0,0 +1,9 @@ +namespace AET.ModVerify.Settings; + +public enum VerifyLocalizationOption +{ + English, + CurrentSystem, + AllInstalled, + All +} \ No newline at end of file diff --git a/src/ModVerify/Steps/GameVerificationStep.cs b/src/ModVerify/Steps/GameVerificationStep.cs deleted file mode 100644 index 295cd70..0000000 --- a/src/ModVerify/Steps/GameVerificationStep.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.IO.Abstractions; -using System.Threading; -using AnakinRaW.CommonUtilities.SimplePipeline.Steps; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using PG.StarWarsGame.Engine; -using PG.StarWarsGame.Engine.FileSystem; -using PG.StarWarsGame.Engine.Pipeline; - -namespace AET.ModVerify.Steps; - -public abstract class GameVerificationStep( - CreateGameDatabaseStep createDatabaseStep, - IGameRepository repository, - VerificationSettings settings, - IServiceProvider serviceProvider) - : PipelineStep(serviceProvider) -{ - protected readonly IFileSystem FileSystem = serviceProvider.GetRequiredService(); - private readonly List _verifyErrors = new(); - - private StreamWriter _errorLog = null!; - - public IReadOnlyCollection VerifyErrors => _verifyErrors; - - protected VerificationSettings Settings { get; } = settings; - - protected GameDatabase Database { get; private set; } = null!; - - protected IGameRepository Repository => repository; - - protected abstract string LogFileName { get; } - - public abstract string Name { get; } - - protected sealed override void RunCore(CancellationToken token) - { - createDatabaseStep.Wait(); - Database = createDatabaseStep.GameDatabase; - - Logger?.LogInformation($"Running verifier '{Name}'..."); - try - { - _errorLog = CreateVerificationLogFile(); - RunVerification(token); - } - finally - { - Logger?.LogInformation($"Finished verifier '{Name}'"); - _errorLog.Dispose(); - _errorLog = null!; - } - } - - protected abstract void RunVerification(CancellationToken token); - - - protected void AddError(VerificationError error) - { - if (!OnError(error)) - { - Logger?.LogTrace($"Error suppressed: '{error}'"); - return; - } - _verifyErrors.Add(error); - _errorLog.WriteLine(error.Message); - - if (Settings.ThrowBehavior == VerifyThrowBehavior.FailFast) - throw new GameVerificationException(this); - } - - protected virtual bool OnError(VerificationError error) - { - return true; - } - - protected void GuardedVerify(Action action, Predicate handleException, string errorContext) - { - try - { - action(); - } - catch (Exception e) when (handleException(e)) - { - AddError(VerificationError.CreateFromException(e, errorContext)); - } - } - private StreamWriter CreateVerificationLogFile() - { - var fileName = $"VerifyResult_{LogFileName}.txt"; - var fs = FileSystem.FileStream.New(fileName, FileMode.Create, FileAccess.Write, FileShare.Read); - return new StreamWriter(fs); - } -} \ No newline at end of file diff --git a/src/ModVerify/Steps/VerifyReferencedModelsStep.cs b/src/ModVerify/Steps/VerifyReferencedModelsStep.cs deleted file mode 100644 index 5151e5b..0000000 --- a/src/ModVerify/Steps/VerifyReferencedModelsStep.cs +++ /dev/null @@ -1,192 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using Microsoft.Extensions.DependencyInjection; -using PG.Commons.Binary; -using PG.Commons.Files; -using PG.Commons.Utilities; -using PG.StarWarsGame.Engine; -using PG.StarWarsGame.Engine.FileSystem; -using PG.StarWarsGame.Engine.Pipeline; -using PG.StarWarsGame.Files.ALO.Files.Models; -using PG.StarWarsGame.Files.ALO.Files.Particles; -using PG.StarWarsGame.Files.ALO.Services; -using PG.StarWarsGame.Files.ChunkFiles.Data; - -namespace AET.ModVerify.Steps; - -internal sealed class VerifyReferencedModelsStep( - CreateGameDatabaseStep createDatabaseStep, - IGameRepository repository, - VerificationSettings settings, - IServiceProvider serviceProvider) - : GameVerificationStep(createDatabaseStep, repository, settings, serviceProvider) -{ - public const string ModelNotFound = "ALO00"; - public const string ModelBroken = "ALO01"; - public const string ModelMissingTexture = "ALO02"; - public const string ModelMissingProxy = "ALO03"; - public const string ModelMissingShader = "ALO04"; - - private const string ProxyAltIdentifier = "_ALT"; - - private static readonly string[] TextureExtensions = ["dds", "tga"]; - - private readonly IAloFileService _modelFileService = serviceProvider.GetRequiredService(); - - protected override string LogFileName => "Model"; - - public override string Name => "Model"; - - protected override void RunVerification(CancellationToken token) - { - var aloQueue = new Queue(Database.GameObjects - .SelectMany(x => x.Models) - .Concat(FocHardcodedConstants.HardcodedModels)); - - var visitedAloFiles = new HashSet(StringComparer.OrdinalIgnoreCase); - - while (aloQueue.Count != 0) - { - var model = aloQueue.Dequeue(); - if (!visitedAloFiles.Add(model)) - continue; - - token.ThrowIfCancellationRequested(); - - using var modelStream = Repository.TryOpenFile(BuildModelPath(model)); - - if (modelStream is null) - { - var error = VerificationError.Create(ModelNotFound, $"Unable to find .ALO data: {model}", model); - AddError(error); - } - else - VerifyModelOrParticle(modelStream, aloQueue); - } - } - - private void VerifyModelOrParticle(Stream modelStream, Queue workingQueue) - { - try - { - using var aloData = _modelFileService.Load(modelStream, AloLoadOptions.Assets); - switch (aloData) - { - case IAloModelFile model: - VerifyModel(model, workingQueue); - break; - case IAloParticleFile particle: - VerifyParticle(particle); - break; - default: - throw new InvalidOperationException("The data stream is neither a model nor particle."); - } - } - catch (BinaryCorruptedException e) - { - var aloFile = modelStream.GetFilePath(); - var message = $"{aloFile} is corrupted: {e.Message}"; - AddError(VerificationError.Create(ModelBroken, message, aloFile)); - } - } - - private void VerifyParticle(IAloParticleFile particle) - { - } - - private void VerifyModel(IAloModelFile file, Queue workingQueue) - { - foreach (var texture in file.Content.Textures) - { - GuardedVerify(() => VerifyTextureExists(file, texture), - e => e is ArgumentException, - $"texture '{texture}'"); - } - - foreach (var shader in file.Content.Shaders) - { - GuardedVerify(() => VerifyShaderExists(file, shader), - e => e is ArgumentException, - $"shader '{shader}'"); - } - - - foreach (var proxy in file.Content.Proxies) - { - GuardedVerify(() => VerifyProxyExists(file, proxy, workingQueue), - e => e is ArgumentException, - $"proxy '{proxy}'"); - } - } - - private void VerifyTextureExists(IPetroglyphFileHolder model, string texture) - { - if (texture == "None") - return; - var texturePath = FileSystem.Path.Combine("Data/Art/Textures", texture); - if (!Repository.FileExists(texturePath, TextureExtensions)) - { - var message = $"{model.FilePath} references missing texture: {texture}"; - var error = VerificationError<(string Model, string Texture)> - .Create(ModelMissingTexture, message, (model.FilePath, texture)); - AddError(error); - } - } - - private void VerifyProxyExists(IPetroglyphFileHolder model, string proxy, Queue workingQueue) - { - var proxyName = ProxyNameWithoutAlt(proxy); - var particle = FileSystem.Path.ChangeExtension(proxyName, "alo"); - if (!Repository.FileExists(BuildModelPath(particle))) - { - var message = $"{model.FilePath} references missing proxy particle: {particle}"; - var error = VerificationError<(string Model, string Proxy)> - .Create(ModelMissingProxy, message, (model.FilePath, particle)); - AddError(error); - } - else - workingQueue.Enqueue(particle); - } - - private string BuildModelPath(string fileName) - { - return FileSystem.Path.Combine("Data/Art/Models", fileName); - } - - private void VerifyShaderExists(IPetroglyphFileHolder data, string shader) - { - if (shader is "alDefault.fx" or "alDefault.fxo") - return; - - if (!Repository.EffectsRepository.FileExists(shader)) - { - var message = $"{data.FilePath} references missing shader effect: {shader}"; - var error = VerificationError<(string, string)> - .Create(ModelMissingShader, message, (data.FilePath, shader)); - AddError(error); - } - } - - private static string ProxyNameWithoutAlt(string proxy) - { - var proxyName = proxy.AsSpan(); - - var altSpan = ProxyAltIdentifier.AsSpan(); - - var altIndex = proxyName.LastIndexOf(altSpan); - - if (altIndex == -1) - return proxy; - - while (altIndex != -1) - { - proxyName = proxyName.Slice(0, altIndex); - altIndex = proxyName.LastIndexOf(altSpan); - } - - return proxyName.ToString(); - } -} diff --git a/src/ModVerify/VerificationError.cs b/src/ModVerify/VerificationError.cs deleted file mode 100644 index b98b7a5..0000000 --- a/src/ModVerify/VerificationError.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using AnakinRaW.CommonUtilities; - -namespace AET.ModVerify; - -public class VerificationError -{ - public const string GeneralExceptionId = "VE01"; - - public string Id { get; } - - public string Message { get; } - - public override string ToString() - { - return $"{Id}: {Message}"; - } - - protected VerificationError(string id, string message) - { - ThrowHelper.ThrowIfNullOrEmpty(id); - Id = id; - Message = message ?? throw new ArgumentNullException(nameof(message)); - } - - public static VerificationError Create(string id, string message) - { - return new VerificationError(id, message); - } - - public static VerificationError CreateFromException(Exception exception, string failedOperation) - { - return Create(GeneralExceptionId, $"Verification of {failedOperation} caused an {exception.GetType().Name}: {exception.Message}"); - } -} - -public class VerificationError : VerificationError -{ - public T Context { get; } - - protected VerificationError(string id, string message, T context) : base(id, message) - { - Context = context; - } - - public static VerificationError Create(string id, string message, T context) - { - return new VerificationError(id, message, context); - } -} \ No newline at end of file diff --git a/src/ModVerify/VerificationProvider.cs b/src/ModVerify/VerificationProvider.cs new file mode 100644 index 0000000..a1800dc --- /dev/null +++ b/src/ModVerify/VerificationProvider.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using AET.ModVerify.Settings; +using AET.ModVerify.Verifiers; +using PG.StarWarsGame.Engine.Database; + +namespace AET.ModVerify; + +internal class VerificationProvider(IServiceProvider serviceProvider) : IVerificationProvider +{ + public IEnumerable GetAllDefaultVerifiers(IGameDatabase database, GameVerifySettings settings) + { + yield return new ReferencedModelsVerifier(database, settings, serviceProvider); + yield return new DuplicateNameFinder(database, settings, serviceProvider); + yield return new AudioFilesVerifier(database, settings, serviceProvider); + } +} \ No newline at end of file diff --git a/src/ModVerify/VerificationSettings.cs b/src/ModVerify/VerificationSettings.cs deleted file mode 100644 index ab5e618..0000000 --- a/src/ModVerify/VerificationSettings.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace AET.ModVerify; - -public record VerificationSettings -{ - public static readonly VerificationSettings Default = new() - { - ThrowBehavior = VerifyThrowBehavior.None - }; - - public VerifyThrowBehavior ThrowBehavior { get; init; } -} \ No newline at end of file diff --git a/src/ModVerify/Verifiers/AudioFilesVerifier.cs b/src/ModVerify/Verifiers/AudioFilesVerifier.cs new file mode 100644 index 0000000..1f4a3e9 --- /dev/null +++ b/src/ModVerify/Verifiers/AudioFilesVerifier.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using System.Threading; +using AET.ModVerify.Reporting; +using AET.ModVerify.Settings; +using Microsoft.Extensions.DependencyInjection; +using PG.Commons.Hashing; +using PG.StarWarsGame.Engine; +using PG.StarWarsGame.Engine.Database; +using PG.StarWarsGame.Engine.DataTypes; +using PG.StarWarsGame.Engine.Language; +using PG.StarWarsGame.Files.MEG.Services.Builder.Normalization; +#if NETSTANDARD2_0 +using AnakinRaW.CommonUtilities.FileSystem; +#endif + +namespace AET.ModVerify.Verifiers; + +public class AudioFilesVerifier : GameVerifierBase +{ + private readonly PetroglyphDataEntryPathNormalizer _pathNormalizer; + private readonly ICrc32HashingService _hashingService; + private readonly IFileSystem _fileSystem; + private readonly IGameLanguageManager _languageManager; + + public AudioFilesVerifier(IGameDatabase gameDatabase, GameVerifySettings settings, IServiceProvider serviceProvider) : base(gameDatabase, settings, serviceProvider) + { + _pathNormalizer = new(serviceProvider); + _hashingService = serviceProvider.GetRequiredService(); + _fileSystem = serviceProvider.GetRequiredService(); + _languageManager = serviceProvider.GetRequiredService() + .GetLanguageManager(Repository.EngineType); + } + + public override string FriendlyName => "Verify Audio Files"; + + protected override void RunVerification(CancellationToken token) + { + var visitedSamples = new HashSet(); + var languagesToVerify = GetLanguagesToVerify().ToList(); + foreach (var sfxEvent in Database.SfxEvents.Entries) + { + foreach (var codedSample in sfxEvent.AllSamples) + { + VerifySample(codedSample, sfxEvent, languagesToVerify, visitedSamples); + } + } + } + + private void VerifySample(string sample, SfxEvent sfxEvent, IEnumerable languagesToVerify, HashSet visitedSamples) + { + Span sampleNameBuffer = stackalloc char[PGConstants.MaxPathLength]; + + var i = _pathNormalizer.Normalize(sample.AsSpan(), sampleNameBuffer); + var normalizedSampleName = sampleNameBuffer.Slice(0, i); + var crc = _hashingService.GetCrc32(normalizedSampleName, PGConstants.PGCrc32Encoding); + if (!visitedSamples.Add(crc)) + return; + + + if (normalizedSampleName.Length > PGConstants.MaxPathLength) + { + AddError(VerificationError.Create( + this, + VerifierErrorCodes.FilePathTooLong, + $"Sample name '{sample}' is too long.", + VerificationSeverity.Error, + sample)); + return; + } + + var normalizedSampleNameString = normalizedSampleName.ToString(); + + if (sfxEvent.IsLocalized) + { + foreach (var language in languagesToVerify) + { + var localizedSampleName = _languageManager.LocalizeFileName(normalizedSampleNameString, language, out var localized); + VerifySample(localizedSampleName, sfxEvent); + + if (!localized) + return; + } + } + else + { + VerifySample(normalizedSampleNameString, sfxEvent); + } + } + + private void VerifySample(string sample, SfxEvent sfxEvent) + { + using var sampleStream = Repository.TryOpenFile(sample); + if (sampleStream is null) + { + AddError(VerificationError.Create( + this, + VerifierErrorCodes.SampleNotFound, + $"Audio file '{sample}' could not be found.", + VerificationSeverity.Error, + sample)); + return; + } + using var binaryReader = new BinaryReader(sampleStream); + + // Skip Header + "fmt " + binaryReader.BaseStream.Seek(16, SeekOrigin.Begin); + + var fmtSize = binaryReader.ReadInt32(); + var format = (WaveFormats)binaryReader.ReadInt16(); + var channels = binaryReader.ReadInt16(); + + var sampleRate = binaryReader.ReadInt32(); + var bytesPerSecond = binaryReader.ReadInt32(); + + var frameSize = binaryReader.ReadInt16(); + var bitPerSecondPerChannel = binaryReader.ReadInt16(); + + if (format != WaveFormats.PCM) + { + AddError(VerificationError.Create( + this, + VerifierErrorCodes.SampleNotPCM, + $"Audio file '{sample}' has an invalid format '{format}'. Supported is {WaveFormats.PCM}", + VerificationSeverity.Error, + sample)); + } + + if (channels > 1 && !IsAmbient2D(sfxEvent)) + { + AddError(VerificationError.Create( + this, + VerifierErrorCodes.SampleNotMono, + $"Audio file '{sample}' is not mono audio.", + VerificationSeverity.Information, + sample)); + } + + if (sampleRate > 48_000) + { + AddError(VerificationError.Create( + this, + VerifierErrorCodes. InvalidSampleRate, + $"Audio file '{sample}' has a too high sample rate of {sampleRate}. Maximum is 48.000Hz.", + VerificationSeverity.Error, + sample)); + } + + if (bitPerSecondPerChannel > 16) + { + AddError(VerificationError.Create( + this, + VerifierErrorCodes.InvalidBitsPerSeconds, + $"Audio file '{sample}' has an invalid bit size of {bitPerSecondPerChannel}. Supported are 16bit.", + VerificationSeverity.Error, + sample)); + } + } + + // Some heuristics whether a SFXEvent is most likely to be an ambient sound. + private bool IsAmbient2D(SfxEvent sfxEvent) + { + if (!sfxEvent.Is2D) + return false; + + if (sfxEvent.IsPreset) + return false; + + // If the event is located in SFXEventsAmbient.xml we simply assume it's an ambient sound. + var fileName = _fileSystem.Path.GetFileName(sfxEvent.Location.XmlFile.AsSpan()); + if (fileName.Equals("SFXEventsAmbient.xml".AsSpan(), StringComparison.OrdinalIgnoreCase)) + return true; + + if (string.IsNullOrEmpty(sfxEvent.UsePresetName)) + return false; + + if (sfxEvent.UsePresetName!.StartsWith("Preset_AMB_2D")) + return true; + + return true; + } + + private IEnumerable GetLanguagesToVerify() + { + switch (Settings.LocalizationOption) + { + case VerifyLocalizationOption.English: + return new List { LanguageType.English }; + case VerifyLocalizationOption.CurrentSystem: + return new List { _languageManager.GetLanguagesFromUser() }; + case VerifyLocalizationOption.AllInstalled: + return Database.InstalledLanguages; + case VerifyLocalizationOption.All: + return _languageManager.SupportedLanguages; + default: + throw new NotSupportedException($"{Settings.LocalizationOption} is not supported"); + } + } + + private enum WaveFormats + { + PCM = 1, + MSADPCM = 2, + IEEE_Float = 3, + } +} \ No newline at end of file diff --git a/src/ModVerify/Verifiers/DuplicateNameFinder.cs b/src/ModVerify/Verifiers/DuplicateNameFinder.cs new file mode 100644 index 0000000..14d2e67 --- /dev/null +++ b/src/ModVerify/Verifiers/DuplicateNameFinder.cs @@ -0,0 +1,55 @@ +using System; +using System.Linq; +using System.Threading; +using AET.ModVerify.Reporting; +using AET.ModVerify.Settings; +using AnakinRaW.CommonUtilities.Collections; +using PG.StarWarsGame.Engine.Database; +using PG.StarWarsGame.Engine.DataTypes; + +namespace AET.ModVerify.Verifiers; + +public sealed class DuplicateNameFinder( + IGameDatabase gameDatabase, + GameVerifySettings settings, + IServiceProvider serviceProvider) + : GameVerifierBase(gameDatabase, settings, serviceProvider) +{ + public override string FriendlyName => "Duplicate Definitions"; + + protected override void RunVerification(CancellationToken token) + { + CheckDatabaseForDuplicates(Database.GameObjects, "GameObject"); + CheckDatabaseForDuplicates(Database.SfxEvents, "SFXEvent"); + } + + private void CheckDatabaseForDuplicates(IXmlDatabase database, string databaseName) where T : XmlObject + { + foreach (var key in database.EntryKeys) + { + var entries = database.GetEntries(key); + if (entries.Count > 1) + { + var entryNames = entries.Select(x => x.Name); + AddError(VerificationError.Create( + this, + VerifierErrorCodes.DuplicateFound, + CreateDuplicateErrorMessage(databaseName, entries), + VerificationSeverity.Warning, + entryNames)); + } + } + } + + private string CreateDuplicateErrorMessage(string databaseName, ReadOnlyFrugalList entries) where T : XmlObject + { + var firstEntry = entries.First(); + + var message = $"{databaseName} '{firstEntry.Name}' ({firstEntry.Crc32}) has duplicate definitions: "; + + foreach (var entry in entries) + message += $"['{entry.Name}' in {entry.Location.XmlFile}:{entry.Location.Line}] "; + + return message.TrimEnd(); + } +} \ No newline at end of file diff --git a/src/ModVerify/Verifiers/GameVerifierBase.cs b/src/ModVerify/Verifiers/GameVerifierBase.cs new file mode 100644 index 0000000..a1f10cd --- /dev/null +++ b/src/ModVerify/Verifiers/GameVerifierBase.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO.Abstractions; +using System.Threading; +using AET.ModVerify.Reporting; +using AET.ModVerify.Settings; +using AnakinRaW.CommonUtilities.FileSystem; +using AnakinRaW.CommonUtilities.SimplePipeline.Steps; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using PG.StarWarsGame.Engine.Database; +using PG.StarWarsGame.Engine.Repositories; + +namespace AET.ModVerify.Verifiers; + +public abstract class GameVerifierBase( + IGameDatabase gameDatabase, + GameVerifySettings settings, + IServiceProvider serviceProvider) + : PipelineStep(serviceProvider), IGameVerifier +{ + + protected readonly IFileSystem FileSystem = serviceProvider.GetRequiredService(); + + private readonly HashSet _verifyErrors = new(); + + public IReadOnlyCollection VerifyErrors => _verifyErrors; + + protected GameVerifySettings Settings { get; } = settings; + + protected IGameDatabase Database { get; } = gameDatabase ?? throw new ArgumentNullException(nameof(gameDatabase)); + + protected IGameRepository Repository => gameDatabase.GameRepository; + + public virtual string FriendlyName => GetType().Name; + + public string Name => GetType().FullName; + + protected sealed override void RunCore(CancellationToken token) + { + Logger?.LogInformation($"Running verifier '{FriendlyName}'..."); + try + { + RunVerification(token); + } + finally + { + Logger?.LogInformation($"Finished verifier '{FriendlyName}'"); + } + } + + protected abstract void RunVerification(CancellationToken token); + + protected void AddError(VerificationError error) + { + _verifyErrors.Add(error); + if (Settings.AbortSettings.FailFast && error.Severity >= Settings.AbortSettings.MinimumAbortSeverity) + throw new GameVerificationException(error); + } + + protected void GuardedVerify(Action action, Predicate exceptionFilter, Action exceptionHandler) + { + try + { + action(); + } + catch (Exception e) when (exceptionFilter(e)) + { + exceptionHandler(e); + } + } + + protected string GetGameStrippedPath(string path) + { + if (!FileSystem.Path.IsPathFullyQualified(path)) + return path; + + if (path.Length <= Repository.Path.Length) + return path; + + if (path.StartsWith(Repository.Path, StringComparison.OrdinalIgnoreCase)) + return path.Substring(Repository.Path.Length); + + return path; + } +} \ No newline at end of file diff --git a/src/ModVerify/Verifiers/ReferencedModelsVerifier.cs b/src/ModVerify/Verifiers/ReferencedModelsVerifier.cs new file mode 100644 index 0000000..8a6e7ba --- /dev/null +++ b/src/ModVerify/Verifiers/ReferencedModelsVerifier.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using AET.ModVerify.Reporting; +using AET.ModVerify.Settings; +using Microsoft.Extensions.DependencyInjection; +using PG.Commons.Binary; +using PG.Commons.Files; +using PG.Commons.Utilities; +using PG.StarWarsGame.Engine; +using PG.StarWarsGame.Engine.Database; +using PG.StarWarsGame.Files.ALO.Files.Models; +using PG.StarWarsGame.Files.ALO.Files.Particles; +using PG.StarWarsGame.Files.ALO.Services; +using PG.StarWarsGame.Files.ChunkFiles.Data; +using AnakinRaW.CommonUtilities.FileSystem; +using System.Reflection; + +namespace AET.ModVerify.Verifiers; + +public sealed class ReferencedModelsVerifier( + IGameDatabase database, + GameVerifySettings settings, + IServiceProvider serviceProvider) + : GameVerifierBase(database, settings, serviceProvider) +{ + private const string ProxyAltIdentifier = "_ALT"; + + private readonly IAloFileService _modelFileService = serviceProvider.GetRequiredService(); + + public override string FriendlyName => "Referenced Models"; + + protected override void RunVerification(CancellationToken token) + { + var aloQueue = new Queue(Database.GameObjects.Entries + .SelectMany(x => x.Models) + .Concat(FocHardcodedConstants.HardcodedModels)); + + var visitedAloFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + + while (aloQueue.Count != 0) + { + var model = aloQueue.Dequeue(); + if (!visitedAloFiles.Add(model)) + continue; + + token.ThrowIfCancellationRequested(); + + using var modelStream = Repository.TryOpenFile(BuildModelPath(model)); + + if (modelStream is null) + { + var error = VerificationError.Create( + this, + VerifierErrorCodes.ModelNotFound, + $"Unable to find .ALO file '{model}'", + VerificationSeverity.Error, + model); + + AddError(error); + } + else + VerifyModelOrParticle(modelStream, aloQueue); + } + } + + private void VerifyModelOrParticle(Stream modelStream, Queue workingQueue) + { + try + { + using var aloData = _modelFileService.Load(modelStream, AloLoadOptions.Assets); + switch (aloData) + { + case IAloModelFile model: + VerifyModel(model, workingQueue); + break; + case IAloParticleFile particle: + VerifyParticle(particle); + break; + default: + throw new InvalidOperationException("The data stream is neither a model nor particle."); + } + } + catch (BinaryCorruptedException e) + { + var aloFile = GetGameStrippedPath(modelStream.GetFilePath()); + var message = $"{aloFile} is corrupted: {e.Message}"; + AddError(VerificationError.Create(this, VerifierErrorCodes.ModelBroken, message, VerificationSeverity.Critical, aloFile)); + } + } + + private void VerifyParticle(IAloParticleFile file) + { + foreach (var texture in file.Content.Textures) + { + GuardedVerify(() => VerifyTextureExists(file, texture), + e => e is ArgumentException, + _ => + { + var modelFilePath = GetGameStrippedPath(file.FilePath); + AddError(VerificationError.Create( + this, + VerifierErrorCodes.InvalidTexture, + $"Invalid texture file name" + + $" '{texture}' in particle 'modelFilePath'", + VerificationSeverity.Error, + texture, + modelFilePath)); + }); + } + + var fileName = FileSystem.Path.GetFileNameWithoutExtension(file.FilePath.AsSpan()); + var name = file.Content.Name.AsSpan(); + + if (!fileName.Equals(name, StringComparison.OrdinalIgnoreCase)) + { + var modelFilePath = GetGameStrippedPath(file.FilePath); + AddError(VerificationError.Create( + this, + VerifierErrorCodes.InvalidParticleName, + $"The particle name '{file.Content.Name}' does not match file name '{modelFilePath}'", + VerificationSeverity.Error, + modelFilePath)); + } + + } + + private void VerifyModel(IAloModelFile file, Queue workingQueue) + { + foreach (var texture in file.Content.Textures) + { + GuardedVerify(() => VerifyTextureExists(file, texture), + e => e is ArgumentException, + _ => + { + var modelFilePath = GetGameStrippedPath(file.FilePath); + AddError(VerificationError.Create( + this, + VerifierErrorCodes.InvalidTexture, + $"Invalid texture file name '{texture}' in model '{modelFilePath}'", + VerificationSeverity.Error, + texture, modelFilePath)); + }); + } + + foreach (var shader in file.Content.Shaders) + { + GuardedVerify(() => VerifyShaderExists(file, shader), + e => e is ArgumentException, + _ => + { + var modelFilePath = GetGameStrippedPath(file.FilePath); + AddError(VerificationError.Create( + this, + VerifierErrorCodes.InvalidShader, + $"Invalid texture file name '{shader}' in model '{modelFilePath}'", + VerificationSeverity.Error, + shader, modelFilePath)); + }); + } + + + foreach (var proxy in file.Content.Proxies) + { + GuardedVerify(() => VerifyProxyExists(file, proxy, workingQueue), + e => e is ArgumentException, + _ => + { + var modelFilePath = GetGameStrippedPath(file.FilePath); + AddError(VerificationError.Create( + this, + VerifierErrorCodes.InvalidProxy, + $"Invalid proxy file name '{proxy}' in model '{modelFilePath}'", + VerificationSeverity.Error, + proxy, modelFilePath)); + }); + } + } + + private void VerifyTextureExists(IPetroglyphFileHolder model, string texture) + { + if (texture == "None") + return; + + if (!Repository.TextureRepository.FileExists(texture)) + { + var modelFilePath = GetGameStrippedPath(model.FilePath); + var message = $"{modelFilePath} references missing texture: {texture}"; + var error = VerificationError.Create(this, VerifierErrorCodes.ModelMissingTexture, message, VerificationSeverity.Error, modelFilePath, texture); + AddError(error); + } + } + + private void VerifyProxyExists(IPetroglyphFileHolder model, string proxy, Queue workingQueue) + { + var proxyName = ProxyNameWithoutAlt(proxy); + var particle = FileSystem.Path.ChangeExtension(proxyName, "alo"); + if (!Repository.FileExists(BuildModelPath(particle))) + { + var modelFilePath = GetGameStrippedPath(model.FilePath); + var message = $"{modelFilePath} references missing proxy particle: {particle}"; + var error = VerificationError.Create(this, VerifierErrorCodes.ModelMissingProxy, message, VerificationSeverity.Error, modelFilePath, particle); + AddError(error); + } + else + workingQueue.Enqueue(particle); + } + + private string BuildModelPath(string fileName) + { + return FileSystem.Path.Combine("DATA/ART/MODELS", fileName); + } + + private void VerifyShaderExists(IPetroglyphFileHolder data, string shader) + { + if (shader is "alDefault.fx" or "alDefault.fxo") + return; + + if (!Repository.EffectsRepository.FileExists(shader)) + { + var modelFilePath = GetGameStrippedPath(data.FilePath); + var message = $"{modelFilePath} references missing shader effect: {shader}"; + var error = VerificationError.Create(this, VerifierErrorCodes.ModelMissingShader, message, VerificationSeverity.Error, modelFilePath, shader); + AddError(error); + } + } + + private static string ProxyNameWithoutAlt(string proxy) + { + var proxyName = proxy.AsSpan(); + + var altSpan = ProxyAltIdentifier.AsSpan(); + + var altIndex = proxyName.LastIndexOf(altSpan); + + if (altIndex == -1) + return proxy; + + while (altIndex != -1) + { + proxyName = proxyName.Slice(0, altIndex); + altIndex = proxyName.LastIndexOf(altSpan); + } + + return proxyName.ToString(); + } +} diff --git a/src/ModVerify/Verifiers/VerifierErrorCodes.cs b/src/ModVerify/Verifiers/VerifierErrorCodes.cs new file mode 100644 index 0000000..d4b3fa0 --- /dev/null +++ b/src/ModVerify/Verifiers/VerifierErrorCodes.cs @@ -0,0 +1,35 @@ +namespace AET.ModVerify.Verifiers; + +public static class VerifierErrorCodes +{ + public const string GenericExceptionErrorCode = "MV00"; + + public const string DuplicateFound = "DUP00"; + + public const string SampleNotFound = "WAV00"; + public const string FilePathTooLong = "WAV01"; + public const string SampleNotPCM = "WAV02"; + public const string SampleNotMono = "WAV03"; + public const string InvalidSampleRate = "WAV04"; + public const string InvalidBitsPerSeconds = "WAV05"; + + public const string ModelNotFound = "ALO00"; + public const string ModelBroken = "ALO01"; + public const string ModelMissingTexture = "ALO02"; + public const string ModelMissingProxy = "ALO03"; + public const string ModelMissingShader = "ALO04"; + public const string InvalidTexture = "ALO05"; + public const string InvalidShader = "ALO06"; + public const string InvalidProxy = "ALO07"; + public const string InvalidParticleName = "ALO08"; + + public const string GenericXmlError = "XML00"; + public const string EmptyXmlRoot = "XML01"; + public const string MissingXmlFile = "XML02"; + public const string InvalidXmlValue = "XML03"; + public const string MalformedXmlValue = "XML04"; + public const string MissingXmlAttribute = "XML05"; + public const string MissingXmlReference = "XML06"; + public const string XmlValueTooLong = "XML07"; + public const string XmlDataBeforeHeader = "XML08"; +} \ No newline at end of file diff --git a/src/ModVerify/Verifiers/XmlParseErrorCollector.cs b/src/ModVerify/Verifiers/XmlParseErrorCollector.cs new file mode 100644 index 0000000..0819eb8 --- /dev/null +++ b/src/ModVerify/Verifiers/XmlParseErrorCollector.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using AET.ModVerify.Reporting; +using AET.ModVerify.Settings; +using PG.StarWarsGame.Engine.Database; +using PG.StarWarsGame.Files.XML.ErrorHandling; + +namespace AET.ModVerify.Verifiers; + +public sealed class XmlParseErrorCollector( + IEnumerable xmlErrors, + IGameDatabase gameDatabase, + GameVerifySettings settings, + IServiceProvider serviceProvider) : + GameVerifierBase(gameDatabase, settings, serviceProvider) +{ + public override string FriendlyName => "XML Parsing Errors"; + + protected override void RunVerification(CancellationToken token) + { + foreach (var xmlError in xmlErrors) + AddError(ConvertXmlToVerificationError(xmlError)); + } + + private VerificationError ConvertXmlToVerificationError(XmlParseErrorEventArgs xmlError) + { + var id = GetIdFromError(xmlError.ErrorKind); + var severity = GetSeverityFromError(xmlError.ErrorKind); + + var assets = new List + { + GetGameStrippedPath(xmlError.File.ToUpperInvariant()) + }; + + var xmlElement = xmlError.Element; + + if (xmlElement is not null) + { + assets.Add(xmlElement.Name.LocalName); + + var parent = xmlElement.Parent; + + if (parent != null) + { + var parentName = parent.Attribute("Name"); + assets.Add(parentName != null ? $"parentName='{parentName.Value}'" : $"parentTag='{parent.Name.LocalName}'"); + } + + } + + return VerificationError.Create(this, id, xmlError.Message, severity, assets); + + } + + private VerificationSeverity GetSeverityFromError(XmlParseErrorKind xmlErrorErrorKind) + { + return xmlErrorErrorKind switch + { + XmlParseErrorKind.EmptyRoot => VerificationSeverity.Critical, + XmlParseErrorKind.MissingFile => VerificationSeverity.Error, + XmlParseErrorKind.InvalidValue => VerificationSeverity.Information, + XmlParseErrorKind.MalformedValue => VerificationSeverity.Warning, + XmlParseErrorKind.MissingAttribute => VerificationSeverity.Error, + XmlParseErrorKind.MissingReference => VerificationSeverity.Error, + XmlParseErrorKind.TooLongData => VerificationSeverity.Warning, + XmlParseErrorKind.DataBeforeHeader => VerificationSeverity.Information, + _ => VerificationSeverity.Warning + }; + } + + private string GetIdFromError(XmlParseErrorKind xmlErrorErrorKind) + { + return xmlErrorErrorKind switch + { + XmlParseErrorKind.EmptyRoot => VerifierErrorCodes.EmptyXmlRoot, + XmlParseErrorKind.MissingFile => VerifierErrorCodes.MissingXmlFile, + XmlParseErrorKind.InvalidValue => VerifierErrorCodes.InvalidXmlValue, + XmlParseErrorKind.MalformedValue => VerifierErrorCodes.MalformedXmlValue, + XmlParseErrorKind.MissingAttribute => VerifierErrorCodes.MissingXmlAttribute, + XmlParseErrorKind.MissingReference => VerifierErrorCodes.MissingXmlReference, + XmlParseErrorKind.TooLongData => VerifierErrorCodes.XmlValueTooLong, + XmlParseErrorKind.Unknown => VerifierErrorCodes.GenericXmlError, + XmlParseErrorKind.DataBeforeHeader => VerifierErrorCodes.XmlDataBeforeHeader, + _ => throw new ArgumentOutOfRangeException(nameof(xmlErrorErrorKind), xmlErrorErrorKind, null) + }; + } +} \ No newline at end of file diff --git a/src/ModVerify/VerifyGamePipeline.cs b/src/ModVerify/VerifyGamePipeline.cs index 8b51723..994253e 100644 --- a/src/ModVerify/VerifyGamePipeline.cs +++ b/src/ModVerify/VerifyGamePipeline.cs @@ -1,72 +1,121 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; -using AET.ModVerify.Steps; +using AET.ModVerify.Reporting; +using AET.ModVerify.Settings; +using AET.ModVerify.Verifiers; using AnakinRaW.CommonUtilities.SimplePipeline; +using AnakinRaW.CommonUtilities.SimplePipeline.Runners; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using PG.StarWarsGame.Engine.FileSystem; -using PG.StarWarsGame.Engine.Pipeline; +using PG.StarWarsGame.Engine; +using PG.StarWarsGame.Engine.Database; +using PG.StarWarsGame.Files.XML.ErrorHandling; +using PG.StarWarsGame.Files.XML.Parsers; namespace AET.ModVerify; -public class VerifyGamePipeline : ParallelPipeline +public abstract class VerifyGamePipeline : Pipeline { - private IList _verificationSteps = null!; - private readonly IGameRepository _repository; - private readonly VerificationSettings _settings; + private readonly List _verificationSteps = new(); + private readonly GameEngineType _targetType; + private readonly GameLocations _gameLocations; + private readonly ParallelRunner _verifyRunner; + + protected GameVerifySettings Settings { get; } - public VerifyGamePipeline(IGameRepository gameRepository, VerificationSettings settings, IServiceProvider serviceProvider) - : base(serviceProvider, 4, false) - { - _repository = gameRepository; - _settings = settings; - } + public IReadOnlyCollection Errors { get; private set; } = Array.Empty(); + + private readonly ConcurrentBag _xmlParseErrors = new(); - protected override Task> BuildSteps() + protected VerifyGamePipeline(GameEngineType targetType, GameLocations gameLocations, GameVerifySettings settings, IServiceProvider serviceProvider) + : base(serviceProvider) { - var buildIndexStep = new CreateGameDatabaseStep(_repository, ServiceProvider); + _targetType = targetType; + _gameLocations = gameLocations ?? throw new ArgumentNullException(nameof(gameLocations)); + Settings = settings ?? throw new ArgumentNullException(nameof(settings)); - _verificationSteps = new List - { - new VerifyReferencedModelsStep(buildIndexStep, _repository, _settings, ServiceProvider), - }; + if (settings.ParallelVerifiers is < 0 or > 64) + throw new ArgumentException("Settings has invalid parallel worker number.", nameof(settings)); + + _verifyRunner = new ParallelRunner(settings.ParallelVerifiers, serviceProvider); + } - var allSteps = new List - { - buildIndexStep - }; - allSteps.AddRange(_verificationSteps); - return Task.FromResult>(allSteps); + protected sealed override Task PrepareCoreAsync() + { + _verificationSteps.Clear(); + return Task.FromResult(true); } - public override async Task RunAsync(CancellationToken token = default) + protected sealed override async Task RunCoreAsync(CancellationToken token) { - Logger?.LogInformation("Verifying game..."); + Logger?.LogInformation("Verifying..."); try { - await base.RunAsync(token).ConfigureAwait(false); + var databaseService = ServiceProvider.GetRequiredService(); + + IGameDatabase database; + try + { + databaseService.XmlParseError += OnXmlParseError; + database = await databaseService.CreateDatabaseAsync(_targetType, _gameLocations, token); + } + finally + { + databaseService.XmlParseError -= OnXmlParseError; + databaseService.Dispose(); + } - var stepsWithVerificationErrors = _verificationSteps.Where(x => x.VerifyErrors.Any()).ToList(); + + AddStep(new XmlParseErrorCollector(_xmlParseErrors, database, Settings, ServiceProvider)); + + foreach (var gameVerificationStep in CreateVerificationSteps(database)) + AddStep(gameVerificationStep); - var failedSteps = new List(); - foreach (var verificationStep in _verificationSteps) + try { - if (verificationStep.VerifyErrors.Any()) - { - failedSteps.Add(verificationStep); - Logger?.LogWarning($"Verifier '{verificationStep.Name}' reported errors!"); - } + Logger?.LogInformation("Verifying..."); + _verifyRunner.Error += OnError; + await _verifyRunner.RunAsync(token); } + finally + { + _verifyRunner.Error -= OnError; + Logger?.LogInformation("Finished Verifying"); + } + - if (_settings.ThrowBehavior == VerifyThrowBehavior.FinalThrow && failedSteps.Count > 0) - throw new GameVerificationException(stepsWithVerificationErrors); + Logger?.LogInformation("Reporting Errors"); + + var reportBroker = new VerificationReportBroker(Settings.GlobalReportSettings, ServiceProvider); + var errors = reportBroker.Report(_verificationSteps); + + Errors = errors; + + if (Settings.AbortSettings.ThrowsGameVerificationException && + errors.Any(x => x.Severity >= Settings.AbortSettings.MinimumAbortSeverity)) + throw new GameVerificationException(errors); } finally { Logger?.LogInformation("Finished game verification"); } } + + protected abstract IEnumerable CreateVerificationSteps(IGameDatabase database); + + private void AddStep(GameVerifierBase verifier) + { + _verifyRunner.AddStep(verifier); + _verificationSteps.Add(verifier); + } + + private void OnXmlParseError(IPetroglyphXmlParser sender, XmlParseErrorEventArgs e) + { + _xmlParseErrors.Add(e); + } } \ No newline at end of file diff --git a/src/ModVerify/VerifyThrowBehavior.cs b/src/ModVerify/VerifyThrowBehavior.cs deleted file mode 100644 index d1e9192..0000000 --- a/src/ModVerify/VerifyThrowBehavior.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace AET.ModVerify; - -public enum VerifyThrowBehavior -{ - None, - FinalThrow, - FailFast -} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/DataTypes/GameObject.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/DataTypes/GameObject.cs index 27bea09..6cfd4c0 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/DataTypes/GameObject.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/DataTypes/GameObject.cs @@ -1,24 +1,30 @@ using System; using System.Collections.Generic; using System.Linq; -using PG.Commons.DataTypes; using PG.Commons.Hashing; using PG.StarWarsGame.Files.XML; namespace PG.StarWarsGame.Engine.DataTypes; -public sealed class GameObject(string type, string name, Crc32 nameCrc, GameObjectType estimatedType, ValueListDictionary properties, XmlLocationInfo location) - : IHasCrc32 +public sealed class GameObject : XmlObject { - public string Type { get; } = type ?? throw new ArgumentNullException(nameof(type)); - - public string Name { get; } = name ?? throw new ArgumentNullException(nameof(name)); + internal GameObject( + string type, + string name, + Crc32 nameCrc, + GameObjectType estimatedType, + IReadOnlyValueListDictionary properties, + XmlLocationInfo location) + : base(name, nameCrc, properties, location) + { + Type = type ?? throw new ArgumentNullException(nameof(type)); + EstimatedType = estimatedType; + } - public Crc32 Crc32 { get; } = nameCrc; + public string Type { get; } - public GameObjectType EstimatedType { get; } = estimatedType; - public XmlLocationInfo Location { get; } = location; + public GameObjectType EstimatedType { get; } /// /// Gets all model files (including particles) the game object references. @@ -27,22 +33,24 @@ public ISet Models { get { - var models = properties.AggregateValues(new HashSet - { - "Galactic_Model_Name", - "Destroyed_Galactic_Model_Name", - "Land_Model_Name", - "Space_Model_Name", - "Model_Name", - "Tactical_Model_Name", - "Galactic_Fleet_Override_Model_Name", - "GUI_Model_Name", - "GUI_Model", - // This can either be a model or a unit reference - "Land_Model_Anim_Override_Name", - "xxxSpace_Model_Name", - "Damaged_Smoke_Asset_Name" - }, v => v.EndsWith(".alo", StringComparison.OrdinalIgnoreCase)); + var models = XmlProperties.AggregateValues + (new HashSet + { + "Galactic_Model_Name", + "Destroyed_Galactic_Model_Name", + "Land_Model_Name", + "Space_Model_Name", + "Model_Name", + "Tactical_Model_Name", + "Galactic_Fleet_Override_Model_Name", + "GUI_Model_Name", + "GUI_Model", + // This can either be a model or a unit reference + "Land_Model_Anim_Override_Name", + "xxxSpace_Model_Name", + "Damaged_Smoke_Asset_Name" + }, v => v.EndsWith(".alo", StringComparison.OrdinalIgnoreCase), + ValueListDictionaryExtensions.AggregateStrategy.LastValuePerKey); var terrainMappedModels = LandTerrainModelMapping?.Select(x => x.Model); if (terrainMappedModels is null) @@ -54,11 +62,4 @@ public ISet Models public IList<(string Terrain, string Model)>? LandTerrainModelMapping => GetLastPropertyOrDefault>("Land_Terrain_Model_Mapping"); - - private T? GetLastPropertyOrDefault(string tagName, T? defaultValue = default) - { - if (!properties.TryGetLastValue(tagName, out var value)) - return defaultValue; - return (T)value; - } } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/DataTypes/SfxEvent.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/DataTypes/SfxEvent.cs new file mode 100644 index 0000000..9920449 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/DataTypes/SfxEvent.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using PG.Commons.Hashing; +using PG.StarWarsGame.Engine.Xml.Tags; +using PG.StarWarsGame.Files.XML; + +namespace PG.StarWarsGame.Engine.DataTypes; + +public sealed class SfxEvent : XmlObject +{ + public const byte MaxVolumeValue = 100; + public const byte MaxPitchValue = 200; + public const byte MinPitchValue = 50; + public const byte MaxPan2dValue = 100; + public const byte MinPriorityValue = 1; + public const byte MaxPriorityValue = 5; + public const byte MaxProbability = 100; + public const sbyte MinMaxInstances = 0; + public const sbyte InfinitivePlayCount = -1; + public const float MinLoopSeconds = 0.0f; + public const float MinVolumeSaturation = 0.0f; + + // Default values which are not the default value of the type + public const byte DefaultPriority = 3; + public const bool DefaultIs3d = true; + public const byte DefaultProbability = 100; + public const sbyte DefaultPlayCount = 1; + public const sbyte DefaultMaxInstances = 1; + public const byte DefaultMinVolume = 100; + public const byte DefaultMaxVolume = 100; + public const byte DefaultMinPitch = 100; + public const byte DefaultMaxPitch = 100; + public const byte DefaultMinPan2d = 50; + public const byte DefaultMaxPan2d = 50; + public const float DefaultVolumeSaturationDistance = 300.0f; + + private SfxEvent? _preset; + private string? _presetName; + private string? _overlapTestName; + private string? _chainedSfxEvent; + private IReadOnlyList? _textIds; + private IReadOnlyList? _preSamples; + private IReadOnlyList? _samples; + private IReadOnlyList? _postSamples; + private bool? _isPreset; + private bool? _is2D; + private bool? _is3D; + private bool? _isGui; + private bool? _isHudVo; + private bool? _isUnitResponseVo; + private bool? _isAmbientVo; + private bool? _isLocalized; + private bool? _playSequentially; + private bool? _killsPreviousObjectsSfx; + private byte? _priority; + private byte? _probability; + private sbyte? _playCount; + private sbyte? _maxInstances; + private uint? _minPredelay; + private uint? _maxPredelay; + private uint? _minPostdelay; + private uint? _maxPostdelay; + private float? _loopFadeInSeconds; + private float? _loopFadeOutSeconds; + private float? _volumeSaturationDistance; + + private static readonly Func PriorityCoercion = priority => + { + if (!priority.HasValue) + return DefaultPriority; + if (priority < MinPriorityValue) + return MinPriorityValue; + if (priority > MaxPriorityValue) + return MaxPriorityValue; + return priority; + }; + + private static readonly Func MaxInstancesCoercion = maxInstances => + { + if (!maxInstances.HasValue) + return DefaultMaxInstances; + if (maxInstances < MinMaxInstances) + return MinMaxInstances; + return maxInstances; + }; + + private static readonly Func ProbabilityCoercion = probability => + { + if (!probability.HasValue) + return DefaultProbability; + if (probability > MaxProbability) + return MaxProbability; + return probability; + }; + + private static readonly Func PlayCountCoercion = playCount => + { + if (!playCount.HasValue) + return DefaultPlayCount; + if (playCount < InfinitivePlayCount) + return InfinitivePlayCount; + return playCount; + }; + + private static readonly Func LoopAndSaturationCoercion = loopSeconds => + { + if (!loopSeconds.HasValue) + return DefaultVolumeSaturationDistance; + if (loopSeconds < MinLoopSeconds) + return MinLoopSeconds; + return loopSeconds; + }; + + public bool IsPreset => LazyInitValue(ref _isPreset, SfxEventXmlTags.IsPreset, false)!.Value; + + public bool Is3D => LazyInitValue(ref _is3D, SfxEventXmlTags.Is3D, DefaultIs3d)!.Value; + + public bool Is2D => LazyInitValue(ref _is2D, SfxEventXmlTags.Is2D, false)!.Value; + + public bool IsGui => LazyInitValue(ref _isGui, SfxEventXmlTags.IsGui, false)!.Value; + + public bool IsHudVo => LazyInitValue(ref _isHudVo, SfxEventXmlTags.IsHudVo, false)!.Value; + + public bool IsUnitResponseVo => LazyInitValue(ref _isUnitResponseVo, SfxEventXmlTags.IsUnitResponseVo, false)!.Value; + + public bool IsAmbientVo => LazyInitValue(ref _isAmbientVo, SfxEventXmlTags.IsAmbientVo, false)!.Value; + + public bool IsLocalized => LazyInitValue(ref _isLocalized, SfxEventXmlTags.Localize, false)!.Value; + + public SfxEvent? Preset => LazyInitValue(ref _preset, SfxEventXmlTags.PresetXRef, null); + + public string? UsePresetName => LazyInitValue(ref _presetName, SfxEventXmlTags.UsePreset, null); + + public bool PlaySequentially => LazyInitValue(ref _playSequentially, SfxEventXmlTags.PlaySequentially, false)!.Value; + + public IEnumerable AllSamples => PreSamples.Concat(Samples).Concat(PostSamples); + + public IReadOnlyList PreSamples => LazyInitValue(ref _preSamples, SfxEventXmlTags.PreSamples, Array.Empty()); + + public IReadOnlyList Samples => LazyInitValue(ref _samples, SfxEventXmlTags.Samples, Array.Empty()); + + public IReadOnlyList PostSamples => LazyInitValue(ref _postSamples, SfxEventXmlTags.PostSamples, Array.Empty()); + + public IReadOnlyList LocalizedTextIDs => LazyInitValue(ref _textIds, SfxEventXmlTags.TextID, Array.Empty()); + + public byte Priority => LazyInitValue(ref _priority, SfxEventXmlTags.Priority, DefaultPriority, PriorityCoercion)!.Value; + + public byte Probability => LazyInitValue(ref _probability, SfxEventXmlTags.Probability, DefaultProbability, ProbabilityCoercion)!.Value; + + public sbyte PlayCount => LazyInitValue(ref _playCount, SfxEventXmlTags.PlayCount, DefaultPlayCount, PlayCountCoercion)!.Value; + + public float LoopFadeInSeconds => LazyInitValue(ref _loopFadeInSeconds, SfxEventXmlTags.LoopFadeInSeconds, 0f, LoopAndSaturationCoercion)!.Value; + + public float LoopFadeOutSeconds => LazyInitValue(ref _loopFadeOutSeconds, SfxEventXmlTags.LoopFadeOutSeconds, 0f, LoopAndSaturationCoercion)!.Value; + + public sbyte MaxInstances => LazyInitValue(ref _maxInstances, SfxEventXmlTags.MaxInstances, DefaultMaxInstances, MaxInstancesCoercion)!.Value; + + public uint MinPredelay => LazyInitValue(ref _minPredelay, SfxEventXmlTags.MinPredelay, 0u)!.Value; + + public uint MaxPredelay => LazyInitValue(ref _maxPredelay, SfxEventXmlTags.MaxPredelay, 0u)!.Value; + + public uint MinPostdelay => LazyInitValue(ref _minPostdelay, SfxEventXmlTags.MinPostdelay, 0u)!.Value; + + public uint MaxPostdelay => LazyInitValue(ref _maxPostdelay, SfxEventXmlTags.MaxPostdelay, 0u)!.Value; + + public float VolumeSaturationDistance => LazyInitValue(ref _volumeSaturationDistance, + SfxEventXmlTags.VolumeSaturationDistance, DefaultVolumeSaturationDistance, LoopAndSaturationCoercion)!.Value; + + public bool KillsPreviousObjectsSfx => LazyInitValue(ref _killsPreviousObjectsSfx, SfxEventXmlTags.KillsPreviousObjectSFX, false)!.Value; + + public string? OverlapTestName => LazyInitValue(ref _overlapTestName, SfxEventXmlTags.OverlapTest, null); + + public string? ChainedSfxEventName => LazyInitValue(ref _chainedSfxEvent, SfxEventXmlTags.ChainedSfxEvent, null); + + public byte MinVolume { get; } + + public byte MaxVolume { get; } + + public byte MinPitch { get; } + + public byte MaxPitch { get; } + + public byte MinPan2D { get; } + + public byte MaxPan2D { get; } + + internal SfxEvent(string name, Crc32 nameCrc, IReadOnlyValueListDictionary properties, + XmlLocationInfo location) + : base(name, nameCrc, properties, location) + { + var minMaxVolume = GetMinMaxVolume(properties); + MinVolume = minMaxVolume.min; + MaxVolume = minMaxVolume.max; + + var minMaxPitch = GetMinMaxPitch(properties); + MinPitch = minMaxPitch.min; + MaxPitch = minMaxPitch.max; + + var minMaxPan = GetMinMaxPan2d(properties); + MinPan2D = minMaxPan.min; + MaxPan2D = minMaxPan.max; + } + + private static (byte min, byte max) GetMinMaxVolume(IReadOnlyValueListDictionary properties) + { + return GetMinMaxValues(properties, SfxEventXmlTags.MinVolume, SfxEventXmlTags.MaxVolume, DefaultMinVolume, + DefaultMaxVolume, null, MaxVolumeValue); + } + + private static (byte min, byte max) GetMinMaxPitch(IReadOnlyValueListDictionary properties) + { + return GetMinMaxValues(properties, SfxEventXmlTags.MinPitch, SfxEventXmlTags.MaxPitch, DefaultMinPitch, + DefaultMaxPitch, MinPitchValue, MaxPitchValue); + } + + private static (byte min, byte max) GetMinMaxPan2d(IReadOnlyValueListDictionary properties) + { + return GetMinMaxValues(properties, SfxEventXmlTags.MinPan2D, SfxEventXmlTags.MaxPan2D, DefaultMinPan2d, + DefaultMaxPan2d, null, MaxPan2dValue); + } + + + private static (byte min, byte max) GetMinMaxValues( + IReadOnlyValueListDictionary properties, + string minTag, + string maxTag, + byte defaultMin, + byte defaultMax, + byte? totalMinValue, + byte? totalMaxValue) + { + var minValue = !properties.TryGetLastValue(minTag, out var minObj) ? defaultMin : Convert.ToByte(minObj); + var maxValue = !properties.TryGetLastValue(maxTag, out var maxObj) ? defaultMax : Convert.ToByte(maxObj); + + if (totalMaxValue.HasValue) + { + if (minValue > totalMaxValue) + minValue = totalMaxValue.Value; + if (maxValue > totalMaxValue) + maxValue = totalMaxValue.Value; + } + + if (totalMinValue.HasValue) + { + if (minValue < totalMinValue) + minValue = totalMinValue.Value; + if (maxValue < totalMinValue) + maxValue = totalMinValue.Value; + } + + if (minValue > maxValue) + minValue = maxValue; + + if (maxValue < minValue) + maxValue = minValue; + + return (minValue, maxValue); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/DataTypes/XmlObject.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/DataTypes/XmlObject.cs new file mode 100644 index 0000000..b8161b1 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/DataTypes/XmlObject.cs @@ -0,0 +1,47 @@ +using System; +using PG.Commons.DataTypes; +using PG.Commons.Hashing; +using PG.StarWarsGame.Files.XML; + +namespace PG.StarWarsGame.Engine.DataTypes; + +public abstract class XmlObject( + string name, + Crc32 nameCrc, + IReadOnlyValueListDictionary properties, + XmlLocationInfo location) + : IHasCrc32 +{ + public XmlLocationInfo Location { get; } = location; + + public Crc32 Crc32 { get; } = nameCrc; + + public IReadOnlyValueListDictionary XmlProperties { get; } = properties ?? throw new ArgumentNullException(nameof(properties)); + + public string Name { get; } = name ?? throw new ArgumentNullException(nameof(name)); + + public T? GetLastPropertyOrDefault(string tagName, T? defaultValue = default) + { + if (!XmlProperties.TryGetLastValue(tagName, out var value)) + return defaultValue; + return (T)value; + } + + protected T LazyInitValue(ref T? field, string tag, T defaultValue, Func? coerceFunc = null) + { + if (field is null) + { + if (XmlProperties.TryGetLastValue(tag, out var value)) + { + var tValue = (T)value; + if (coerceFunc is not null) + tValue = coerceFunc(tValue); + field = tValue; + } + else + field = defaultValue; + } + + return field; + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/GameDatabase.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/GameDatabase.cs new file mode 100644 index 0000000..f32f347 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/GameDatabase.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using PG.StarWarsGame.Engine.DataTypes; +using PG.StarWarsGame.Engine.Language; +using PG.StarWarsGame.Engine.Repositories; + +namespace PG.StarWarsGame.Engine.Database; + +internal class GameDatabase : IGameDatabase +{ + public required IGameRepository GameRepository { get; init; } + + public required GameConstants GameConstants { get; init; } + + public required IXmlDatabase GameObjects { get; init; } + + public required IXmlDatabase SfxEvents { get; init; } + + public IEnumerable InstalledLanguages { get; init; } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/GameDatabaseService.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/GameDatabaseService.cs new file mode 100644 index 0000000..fc1f5ca --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/GameDatabaseService.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using AnakinRaW.CommonUtilities; +using Microsoft.Extensions.DependencyInjection; +using PG.StarWarsGame.Engine.Database.Initialization; +using PG.StarWarsGame.Engine.Repositories; +using PG.StarWarsGame.Files.XML.ErrorHandling; +using PG.StarWarsGame.Files.XML.Parsers; + +namespace PG.StarWarsGame.Engine.Database; + +internal class GameDatabaseService : DisposableObject, IXmlParserErrorListener, IGameDatabaseService +{ + public event XmlErrorEventHandler? XmlParseError; + + private readonly IServiceProvider _serviceProvider; + private IPrimitiveXmlErrorParserProvider _primitiveXmlParserErrorProvider; + + public GameDatabaseService(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + _primitiveXmlParserErrorProvider = serviceProvider.GetRequiredService(); + _primitiveXmlParserErrorProvider.XmlParseError += OnXmlParseError; + } + + public async Task CreateDatabaseAsync( + GameEngineType targetEngineType, + GameLocations locations, + CancellationToken cancellationToken = default) + { + var repoFactory = _serviceProvider.GetRequiredService(); + var repository = repoFactory.Create(targetEngineType, locations, this); + + var pipeline = new GameDatabaseCreationPipeline(repository, this, _serviceProvider); + await pipeline.RunAsync(cancellationToken); + return pipeline.GameDatabase; + } + + protected override void DisposeManagedResources() + { + base.DisposeManagedResources(); + _primitiveXmlParserErrorProvider.XmlParseError -= OnXmlParseError; + _primitiveXmlParserErrorProvider = null!; + } + + public void OnXmlParseError(IPetroglyphXmlParser parser, XmlParseErrorEventArgs error) + { + XmlParseError?.Invoke(parser, error); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/IGameDatabase.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/IGameDatabase.cs new file mode 100644 index 0000000..15dd8a1 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/IGameDatabase.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using PG.StarWarsGame.Engine.DataTypes; +using PG.StarWarsGame.Engine.Language; +using PG.StarWarsGame.Engine.Repositories; + +namespace PG.StarWarsGame.Engine.Database; + +public interface IGameDatabase +{ + IGameRepository GameRepository { get; } + + GameConstants GameConstants { get; } + + IXmlDatabase GameObjects { get; } + + IXmlDatabase SfxEvents { get; } + + IEnumerable InstalledLanguages { get; } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/IGameDatabaseService.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/IGameDatabaseService.cs new file mode 100644 index 0000000..575c521 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/IGameDatabaseService.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using PG.StarWarsGame.Files.XML.ErrorHandling; + +namespace PG.StarWarsGame.Engine.Database; + +public interface IGameDatabaseService : IXmlParserErrorProvider, IDisposable +{ + Task CreateDatabaseAsync( + GameEngineType targetEngineType, + GameLocations locations, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/IXmlDatabase.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/IXmlDatabase.cs new file mode 100644 index 0000000..c2edb8e --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/IXmlDatabase.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using AnakinRaW.CommonUtilities.Collections; +using PG.Commons.Hashing; +using PG.StarWarsGame.Engine.DataTypes; + +namespace PG.StarWarsGame.Engine.Database; + +public interface IXmlDatabase where T : XmlObject +{ + ICollection Entries { get; } + + ICollection EntryKeys { get; } + + ReadOnlyFrugalList GetEntries(Crc32 key); +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Pipeline/CreateDatabaseStep.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/Initialization/CreateDatabaseStep.cs similarity index 73% rename from src/PetroglyphTools/PG.StarWarsGame.Engine/Pipeline/CreateDatabaseStep.cs rename to src/PetroglyphTools/PG.StarWarsGame.Engine/Database/Initialization/CreateDatabaseStep.cs index 9905b93..2c1f20e 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Pipeline/CreateDatabaseStep.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/Initialization/CreateDatabaseStep.cs @@ -2,11 +2,11 @@ using System.Threading; using AnakinRaW.CommonUtilities.SimplePipeline.Steps; using Microsoft.Extensions.Logging; -using PG.StarWarsGame.Engine.FileSystem; +using PG.StarWarsGame.Engine.Repositories; -namespace PG.StarWarsGame.Engine.Pipeline; +namespace PG.StarWarsGame.Engine.Database.Initialization; -public abstract class CreateDatabaseStep(IGameRepository repository, IServiceProvider serviceProvider) +internal abstract class CreateDatabaseStep(IGameRepository repository, IServiceProvider serviceProvider) : PipelineStep(serviceProvider) where T : class { public T Database { get; private set; } = null!; diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/Initialization/GameDatabaseCreationPipeline.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/Initialization/GameDatabaseCreationPipeline.cs new file mode 100644 index 0000000..f5aff84 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/Initialization/GameDatabaseCreationPipeline.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AnakinRaW.CommonUtilities.SimplePipeline; +using AnakinRaW.CommonUtilities.SimplePipeline.Runners; +using AnakinRaW.CommonUtilities.SimplePipeline.Steps; +using Microsoft.Extensions.Logging; +using PG.StarWarsGame.Engine.DataTypes; +using PG.StarWarsGame.Engine.Repositories; +using PG.StarWarsGame.Files.XML.ErrorHandling; + +namespace PG.StarWarsGame.Engine.Database.Initialization; + +internal class GameDatabaseCreationPipeline(GameRepository repository, IXmlParserErrorListener xmlParserErrorListener, IServiceProvider serviceProvider) + : Pipeline(serviceProvider) +{ + private ParseSingletonXmlStep _parseGameConstants = null!; + private ParseXmlDatabaseFromContainerStep _parseGameObjects = null!; + private ParseXmlDatabaseFromContainerStep _parseSfxEvents = null!; + + private StepRunner _parseXmlRunner = null!; + + public GameDatabase GameDatabase { get; private set; } = null!; + + protected override Task PrepareCoreAsync() + { + _parseXmlRunner = new ParallelRunner(4, ServiceProvider); + foreach (var xmlParserStep in CreateXmlParserSteps()) + _parseXmlRunner.AddStep(xmlParserStep); + + return Task.FromResult(true); + } + + private IEnumerable CreateXmlParserSteps() + { + // TODO: Use same load order as the engine! + + yield return _parseGameConstants = new ParseSingletonXmlStep("GameConstants", + "DATA\\XML\\GAMECONSTANTS.XML", repository, xmlParserErrorListener, ServiceProvider); + + yield return _parseGameObjects = new ParseXmlDatabaseFromContainerStep("GameObjects", + "DATA\\XML\\GAMEOBJECTFILES.XML", repository, xmlParserErrorListener, ServiceProvider); + + yield return _parseSfxEvents = new ParseXmlDatabaseFromContainerStep("SFXEvents", + "DATA\\XML\\SFXEventFiles.XML", repository, xmlParserErrorListener, ServiceProvider); + + // GUIDialogs.xml + // LensFlares.xml + // SurfaceFX.xml + // TerrainDecalFX.xml + // GraphicDetails.xml + // DynamicTrackFX.xml + // ShadowBlobMaterials.xml + // TacticalCameras.xml + // LightSources.xml + // StarWars3DTextCrawl.xml + // MusicEvents.xml + // SpeechEvents.xml + // GameConstants.xml + // Audio.xml + // WeatherAudio.xml + // HeroClash.xml + // TradeRouteLines.xml + // RadarMap.xml + // WeatherModifiers.xml + // Movies.xml + // LightningEffectTypes.xml + // DifficultyAdjustments.xml + // WeatherScenarios.xml + // UnitAbilityTypes.xml + // BlackMarketItems.xml + // MovementClassTypeDefs.xml + // AITerrainEffectiveness.xml + + + // CONTAINER FILES: + // GameObjectFiles.xml + // CommandBarComponentFiles.xml + // TradeRouteFiles.xml + // HardPointDataFiles.xml + // CampaignFiles.xml + // FactionFiles.xml + // TargetingPrioritySetFiles.xml + // MousePointerFiles.xml + } + + protected override async Task RunCoreAsync(CancellationToken token) + { + Logger?.LogInformation("Creating Game Database..."); + + try + { + try + { + Logger?.LogInformation("Parsing XML Files..."); + _parseXmlRunner.Error += OnError; + await _parseXmlRunner.RunAsync(token); + } + finally + { + Logger?.LogInformation("Finished parsing XML Files..."); + _parseXmlRunner.Error -= OnError; + } + + ThrowIfAnyStepsFailed(_parseXmlRunner.Steps); + + token.ThrowIfCancellationRequested(); + + + var installedLanguages = repository.InitializeInstalledSfxMegFiles(); + + + repository.Seal(); + + GameDatabase = new GameDatabase + { + GameRepository = repository, + GameConstants = _parseGameConstants.Database, + GameObjects = _parseGameObjects.Database, + SfxEvents = _parseSfxEvents.Database, + InstalledLanguages = installedLanguages + }; + } + finally + { + Logger?.LogInformation("Finished creating game database"); + } + } + + private sealed class ParseSingletonXmlStep(string name, string xmlFile, IGameRepository repository, IXmlParserErrorListener? listener, IServiceProvider serviceProvider) + : ParseXmlDatabaseStep(xmlFile, repository, listener, serviceProvider) where T : class + { + protected override string Name => name; + + protected override T CreateDatabase(IList parsedDatabaseEntries) + { + if (parsedDatabaseEntries.Count != 1) + throw new InvalidOperationException($"There can be only one {Name} model."); + + return parsedDatabaseEntries.First(); + } + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/Initialization/ParseXmlDatabaseFromContainerStep.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/Initialization/ParseXmlDatabaseFromContainerStep.cs new file mode 100644 index 0000000..5dcde76 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/Initialization/ParseXmlDatabaseFromContainerStep.cs @@ -0,0 +1,69 @@ +using System; +using System.IO.Abstractions; +using System.Linq; +using System.Xml; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using PG.Commons.Hashing; +using PG.StarWarsGame.Engine.DataTypes; +using PG.StarWarsGame.Engine.Repositories; +using PG.StarWarsGame.Engine.Xml; +using PG.StarWarsGame.Files.XML; +using PG.StarWarsGame.Files.XML.ErrorHandling; + +namespace PG.StarWarsGame.Engine.Database.Initialization; + +internal class ParseXmlDatabaseFromContainerStep( + string name, + string xmlFile, + IGameRepository repository, + IXmlParserErrorListener? listener, + IServiceProvider serviceProvider) + : CreateDatabaseStep>(repository, serviceProvider) + where T : XmlObject +{ + private readonly IFileSystem _fileSystem = serviceProvider.GetRequiredService(); + + protected readonly IPetroglyphXmlFileParserFactory FileParserFactory = serviceProvider.GetRequiredService(); + + protected override string Name => name; + + protected sealed override IXmlDatabase CreateDatabase() + { + using var containerStream = GameRepository.OpenFile(xmlFile); + var containerParser = FileParserFactory.GetFileParser(listener); + Logger?.LogDebug($"Parsing container data '{xmlFile}'"); + var container = containerParser.ParseFile(containerStream); + + var xmlFiles = container.Files.Select(x => _fileSystem.Path.Combine("DATA\\XML", x)).ToList(); + + var parsedEntries = new ValueListDictionary(); + + foreach (var file in xmlFiles) + { + using var fileStream = GameRepository.TryOpenFile(file); + + var parser = FileParserFactory.GetFileParser(listener); + + if (fileStream is null) + { + listener?.OnXmlParseError(parser, XmlParseErrorEventArgs.FromMissingFile(file)); + Logger?.LogWarning($"Could not find XML file '{file}'"); + continue; + } + + Logger?.LogDebug($"Parsing File '{file}'"); + + try + { + parser.ParseFile(fileStream, parsedEntries); + } + catch (XmlException e) + { + listener?.OnXmlParseError(parser, new XmlParseErrorEventArgs(file, null, XmlParseErrorKind.Unknown, e.Message)); + } + } + + return new XmlDatabase(parsedEntries, Services); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Pipeline/ParseXmlDatabaseStep.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/Initialization/ParseXmlDatabaseStep.cs similarity index 70% rename from src/PetroglyphTools/PG.StarWarsGame.Engine/Pipeline/ParseXmlDatabaseStep.cs rename to src/PetroglyphTools/PG.StarWarsGame.Engine/Database/Initialization/ParseXmlDatabaseStep.cs index 516d33c..8050f02 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Pipeline/ParseXmlDatabaseStep.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/Initialization/ParseXmlDatabaseStep.cs @@ -2,21 +2,24 @@ using System.Collections.Generic; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using PG.StarWarsGame.Engine.FileSystem; +using PG.StarWarsGame.Engine.Repositories; using PG.StarWarsGame.Engine.Xml; +using PG.StarWarsGame.Files.XML.ErrorHandling; -namespace PG.StarWarsGame.Engine.Pipeline; +namespace PG.StarWarsGame.Engine.Database.Initialization; -public abstract class ParseXmlDatabaseStep( +internal abstract class ParseXmlDatabaseStep( IList xmlFiles, IGameRepository repository, + IXmlParserErrorListener? listener, IServiceProvider serviceProvider) : CreateDatabaseStep(repository, serviceProvider) where T : class { protected readonly IPetroglyphXmlFileParserFactory FileParserFactory = serviceProvider.GetRequiredService(); - protected ParseXmlDatabaseStep(string xmlFile, IGameRepository repository, IServiceProvider serviceProvider) : this([xmlFile], repository, serviceProvider) + protected ParseXmlDatabaseStep(string xmlFile, IGameRepository repository, IXmlParserErrorListener? listener, IServiceProvider serviceProvider) + : this([xmlFile], repository, listener, serviceProvider) { } @@ -27,7 +30,7 @@ protected sealed override T CreateDatabase() { using var fileStream = GameRepository.OpenFile(xmlFile); - var parser = FileParserFactory.GetFileParser(); + var parser = FileParserFactory.GetFileParser(listener); Logger?.LogDebug($"Parsing File '{xmlFile}'"); var parsedData = parser.ParseFile(fileStream)!; parsedDatabaseEntries.Add(parsedData); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/XmlDatabase.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/XmlDatabase.cs new file mode 100644 index 0000000..38d0a78 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Database/XmlDatabase.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using AnakinRaW.CommonUtilities.Collections; +using PG.Commons.Hashing; +using PG.StarWarsGame.Engine.DataTypes; +using PG.StarWarsGame.Files.XML; + +namespace PG.StarWarsGame.Engine.Database; + +internal class XmlDatabase(IReadOnlyValueListDictionary parsedObjects, IServiceProvider serviceProvider) : IXmlDatabase + where T : XmlObject +{ + + private readonly IServiceProvider _serviceProvider = + serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + + private readonly IReadOnlyValueListDictionary _parsedObjects = parsedObjects ?? throw new ArgumentNullException(nameof(parsedObjects)); + + public ICollection Entries => _parsedObjects.Values; + + public ICollection EntryKeys => _parsedObjects.Keys; + + public ReadOnlyFrugalList GetEntries(Crc32 key) + { + return _parsedObjects.GetValues(key); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/AudioRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/AudioRepository.cs deleted file mode 100644 index 8ffc276..0000000 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/AudioRepository.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace PG.StarWarsGame.Engine.FileSystem; - -public class AudioRepository -{ - -} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/EffectsRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/EffectsRepository.cs deleted file mode 100644 index ea4f080..0000000 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/EffectsRepository.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.IO; -using System.IO.Abstractions; -using System.Linq; -using Microsoft.Extensions.DependencyInjection; - -namespace PG.StarWarsGame.Engine.FileSystem; - -public class EffectsRepository(IGameRepository baseRepository, IServiceProvider serviceProvider) : IRepository -{ - private readonly IFileSystem _fileSystem = serviceProvider.GetRequiredService(); - - private static readonly string[] LookupPaths = - [ - "Data\\Art\\Shaders", - "Data\\Art\\Shaders\\Terrain", - "Data\\Art\\Shaders\\Engine", - ]; - - private static readonly string[] ShaderExtensions = [".fx", ".fxo", ".fxh"]; - - public Stream OpenFile(string filePath, bool megFileOnly = false) - { - var shaderStream = TryOpenFile(filePath, megFileOnly); - if (shaderStream is null) - throw new FileNotFoundException($"Unable to find game data: {filePath}"); - return shaderStream; - } - - public bool FileExists(string filePath, bool megFileOnly = false) - { - var result = false; - ShaderFileAction(filePath, shaderPath => - { - if (!baseRepository.FileExists(shaderPath, megFileOnly)) - return false; - - result = true; - return true; - - }); - return result; - } - - public Stream? TryOpenFile(string filePath, bool megFileOnly = false) - { - Stream? shaderStream = null; - ShaderFileAction(filePath, shaderPath => - { - var stream = baseRepository.TryOpenFile(shaderPath, megFileOnly); - if (stream is null) - return false; - shaderStream = stream; - return true; - }); - - return shaderStream; - } - - private void ShaderFileAction(string filePath, Predicate action) - { - var currExt = _fileSystem.Path.GetExtension(filePath); - if (!ShaderExtensions.Contains(currExt, StringComparer.OrdinalIgnoreCase)) - throw new ArgumentException("Invalid data extension for shader. Must be .fx, .fxh or .fxo", nameof(filePath)); - - foreach (var directory in LookupPaths) - { - var lookupPath = _fileSystem.Path.Combine(directory, filePath); - - foreach (var ext in ShaderExtensions) - { - lookupPath = _fileSystem.Path.ChangeExtension(lookupPath, ext); - if (action(lookupPath)) - return; - } - } - } -} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/GameRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/GameRepository.cs deleted file mode 100644 index f7b1c63..0000000 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/GameRepository.cs +++ /dev/null @@ -1,304 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.IO.Abstractions; -using System.Linq; -using AnakinRaW.CommonUtilities.FileSystem; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using PG.Commons.Hashing; -using PG.StarWarsGame.Engine.Xml; -using PG.StarWarsGame.Files.MEG.Data.Archives; -using PG.StarWarsGame.Files.MEG.Files; -using PG.StarWarsGame.Files.MEG.Services; -using PG.StarWarsGame.Files.MEG.Services.Builder.Normalization; -using PG.StarWarsGame.Files.XML; - -namespace PG.StarWarsGame.Engine.FileSystem; - -// EaW file lookup works slightly different! -public class FocGameRepository : IGameRepository -{ - private readonly IServiceProvider _serviceProvider; - private readonly IFileSystem _fileSystem; - private readonly PetroglyphDataEntryPathNormalizer _megPathNormalizer; - private readonly ICrc32HashingService _crc32HashingService; - private readonly IMegFileExtractor _megExtractor; - private readonly IMegFileService _megFileService; - private readonly ILogger? _logger; - - private readonly string _gameDirectory; - - private readonly IList _modPaths = new List(); - private readonly IList _fallbackPaths = new List(); - - private readonly IVirtualMegArchive? _masterMegArchive; - - public GameEngineType EngineType => GameEngineType.Foc; - - public IRepository EffectsRepository { get; } - - public FocGameRepository(GameLocations gameLocations, IServiceProvider serviceProvider) - { - if (gameLocations == null) - throw new ArgumentNullException(nameof(gameLocations)); - - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - _megPathNormalizer = serviceProvider.GetRequiredService(); - _crc32HashingService = serviceProvider.GetRequiredService(); - _megExtractor = serviceProvider.GetRequiredService(); - _megFileService = serviceProvider.GetRequiredService(); - _logger = serviceProvider.GetService()?.CreateLogger(GetType()); - - _fileSystem = serviceProvider.GetRequiredService(); - - foreach (var mod in gameLocations.ModPaths) - { - if (string.IsNullOrEmpty(mod)) - { - _logger?.LogTrace("Skipping null or empty mod path."); - continue; - } - _modPaths.Add(_fileSystem.Path.GetFullPath(mod)); - } - - _gameDirectory = _fileSystem.Path.GetFullPath(gameLocations.GamePath); - - - foreach (var fallbackPath in gameLocations.FallbackPaths) - { - if (string.IsNullOrEmpty(fallbackPath)) - { - _logger?.LogTrace("Skipping null or empty fallback path."); - continue; - } - _fallbackPaths.Add(_fileSystem.Path.GetFullPath(fallbackPath)); - } - - _masterMegArchive = CreateMasterMegArchive(); - - EffectsRepository = new EffectsRepository(this, serviceProvider); - } - - private IVirtualMegArchive CreateMasterMegArchive() - { - var builder = _serviceProvider.GetRequiredService(); - - var megsToConsider = new List(); - - // We assume that the first fallback path (if present at all) is always Empire at War. - var firstFallback = _fallbackPaths.FirstOrDefault(); - if (firstFallback is not null) - { - var eawMegs = LoadMegArchivesFromXml(firstFallback); - var eawPatch = LoadMegArchive(_fileSystem.Path.Combine(firstFallback, "Data\\Patch.meg")); - var eawPatch2 = LoadMegArchive(_fileSystem.Path.Combine(firstFallback, "Data\\Patch2.meg")); - var eaw64Patch = LoadMegArchive(_fileSystem.Path.Combine(firstFallback, "Data\\64Patch.meg")); - - megsToConsider.AddRange(eawMegs); - if (eawPatch is not null) - megsToConsider.Add(eawPatch); - if (eawPatch2 is not null) - megsToConsider.Add(eawPatch2); - if (eaw64Patch is not null) - megsToConsider.Add(eaw64Patch); - } - - var focOrModMegs = LoadMegArchivesFromXml("."); - var focPatch = LoadMegArchive("Data\\Patch.meg"); - var focPatch2 = LoadMegArchive("Data\\Patch2.meg"); - var foc64Patch = LoadMegArchive("Data\\64Patch.meg"); - - megsToConsider.AddRange(focOrModMegs); - if (focPatch is not null) - megsToConsider.Add(focPatch); - if (focPatch2 is not null) - megsToConsider.Add(focPatch2); - if (foc64Patch is not null) - megsToConsider.Add(foc64Patch); - - return builder.BuildFrom(megsToConsider, true); - } - - private IList LoadMegArchivesFromXml(string lookupPath) - { - var megFilesXmlPath = _fileSystem.Path.Combine(lookupPath, "Data\\MegaFiles.xml"); - - var fileParserFactory = _serviceProvider.GetRequiredService(); - - using var xmlStream = TryOpenFile(megFilesXmlPath); - - if (xmlStream is null) - { - _logger?.LogWarning($"Unable to find MegaFiles.xml at '{lookupPath}'"); - return Array.Empty(); - } - - var parser = fileParserFactory.GetFileParser(); - var megaFilesXml = parser.ParseFile(xmlStream); - - - var megs = new List(megaFilesXml.Files.Count); - - foreach (var file in megaFilesXml.Files.Select(x => x.Trim())) - { - var megPath = _fileSystem.Path.Combine(lookupPath, file); - var megFile = LoadMegArchive(megPath); - if (megFile is not null) - megs.Add(megFile); - } - - return megs; - } - - private IMegFile? LoadMegArchive(string megPath) - { - using var megFileStream = TryOpenFile(megPath); - if (megFileStream is not FileSystemStream fileSystemStream) - { - _logger?.LogWarning($"Unable to find MEG data at '{megPath}'"); - return null; - } - - var megFile = _megFileService.Load(fileSystemStream); - - if (megFile.FileInformation.FileVersion != MegFileVersion.V1) - throw new InvalidOperationException("MEG data version must be V1."); - - return megFile; - } - - public Stream OpenFile(string filePath, bool megFileOnly = false) - { - var stream = TryOpenFile(filePath, megFileOnly); - if (stream is null) - throw new FileNotFoundException($"Unable to find game data: {filePath}"); - return stream; - } - - public bool FileExists(string filePath, string[] extensions, bool megFileOnly = false) - { - foreach (var extension in extensions) - { - var newPath = _fileSystem.Path.ChangeExtension(filePath, extension); - if (FileExists(newPath, megFileOnly)) - return true; - } - return false; - } - - - public bool FileExists(string filePath, bool megFileOnly = false) - { - if (!megFileOnly) - { - // This is a custom rule - if (_fileSystem.Path.IsPathFullyQualified(filePath)) - return _fileSystem.File.Exists(filePath); - - foreach (var modPath in _modPaths) - { - var modFilePath = _fileSystem.Path.Combine(modPath, filePath); - if (_fileSystem.File.Exists(modFilePath)) - return true; - } - - var normalFilePath = _fileSystem.Path.Combine(_gameDirectory, filePath); - if (_fileSystem.File.Exists(normalFilePath)) - return true; - } - - if (_masterMegArchive is not null) - { - var normalizedPath = _megPathNormalizer.Normalize(filePath); - var crc = _crc32HashingService.GetCrc32(normalizedPath, PGConstants.PGCrc32Encoding); - - var entry = _masterMegArchive.FirstEntryWithCrc(crc); - if (entry is not null) - return true; - } - - if (!megFileOnly) - { - - foreach (var fallbackPath in _fallbackPaths) - { - var fallbackFilePath = _fileSystem.Path.Combine(fallbackPath, filePath); - if (_fileSystem.File.Exists(fallbackFilePath)) - return true; - } - } - - return false; - } - - public Stream? TryOpenFile(string filePath, bool megFileOnly = false) - { - if (!megFileOnly) - { - // This is a custom rule - if (_fileSystem.Path.IsPathFullyQualified(filePath)) - return !_fileSystem.File.Exists(filePath) ? null : OpenFileRead(filePath); - - foreach (var modPath in _modPaths) - { - var modFilePath = _fileSystem.Path.Combine(modPath, filePath); - if (_fileSystem.File.Exists(modFilePath)) - return OpenFileRead(modFilePath); - } - - - var normalFilePath = _fileSystem.Path.Combine(_gameDirectory, filePath); - if (_fileSystem.File.Exists(normalFilePath)) - return OpenFileRead(normalFilePath); - } - - if (_masterMegArchive is not null) - { - var normalizedPath = _megPathNormalizer.Normalize(filePath); - var crc = _crc32HashingService.GetCrc32(normalizedPath, PGConstants.PGCrc32Encoding); - - var entry = _masterMegArchive.FirstEntryWithCrc(crc); - if (entry is not null) - return _megExtractor.GetFileData(entry.Location); - } - - if (!megFileOnly) - { - foreach (var fallbackPath in _fallbackPaths) - { - var fallbackFilePath = _fileSystem.Path.Combine(fallbackPath, filePath); - if (_fileSystem.File.Exists(fallbackFilePath)) - return OpenFileRead(fallbackFilePath); - } - } - return null; - } - - private FileSystemStream OpenFileRead(string filePath) - { - if (!AllowOpenFile(filePath)) - throw new UnauthorizedAccessException("The data is not part of the Games!"); - return _fileSystem.FileStream.New(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); - } - - private bool AllowOpenFile(string filePath) - { - foreach (var modPath in _modPaths) - { - if (_fileSystem.Path.IsChildOf(modPath, filePath)) - return true; - } - - if (_fileSystem.Path.IsChildOf(_gameDirectory, filePath)) - return true; - - foreach (var fallbackPath in _fallbackPaths) - { - if (_fileSystem.Path.IsChildOf(fallbackPath, filePath)) - return true; - } - - return false; - } -} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/GameRepositoryFactory.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/GameRepositoryFactory.cs deleted file mode 100644 index 02e24d2..0000000 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/GameRepositoryFactory.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; - -namespace PG.StarWarsGame.Engine.FileSystem; - -internal sealed class GameRepositoryFactory(IServiceProvider serviceProvider) : IGameRepositoryFactory -{ - public IGameRepository Create(GameEngineType engineType, GameLocations gameLocations) - { - if (engineType == GameEngineType.Eaw) - throw new NotImplementedException("Empire at War is currently not supported."); - return new FocGameRepository(gameLocations, serviceProvider); - } -} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/IGameRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/IGameRepository.cs deleted file mode 100644 index 8965189..0000000 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/IGameRepository.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace PG.StarWarsGame.Engine.FileSystem; - -public interface IGameRepository : IRepository -{ - public GameEngineType EngineType { get; } - - IRepository EffectsRepository { get; } - - bool FileExists(string filePath, string[] extensions, bool megFileOnly = false); -} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/IGameRepositoryFactory.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/IGameRepositoryFactory.cs deleted file mode 100644 index ed6600f..0000000 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/IGameRepositoryFactory.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace PG.StarWarsGame.Engine.FileSystem; - -public interface IGameRepositoryFactory -{ - IGameRepository Create(GameEngineType engineType, GameLocations gameLocations); -} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/GameDatabase.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/GameDatabase.cs deleted file mode 100644 index 7abf073..0000000 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/GameDatabase.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Collections.Generic; -using PG.StarWarsGame.Engine.DataTypes; - -namespace PG.StarWarsGame.Engine; - -public class GameDatabase -{ - public required GameEngineType EngineType { get; init; } - - public required GameConstants GameConstants { get; init; } - - public required IList GameObjects { get; init; } -} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/GameLocations.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/GameLocations.cs similarity index 94% rename from src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/GameLocations.cs rename to src/PetroglyphTools/PG.StarWarsGame.Engine/GameLocations.cs index c2939bf..18539a7 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/GameLocations.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/GameLocations.cs @@ -3,7 +3,7 @@ using System.Linq; using AnakinRaW.CommonUtilities; -namespace PG.StarWarsGame.Engine.FileSystem; +namespace PG.StarWarsGame.Engine; public sealed class GameLocations { @@ -30,7 +30,7 @@ public GameLocations(IList modPaths, string gamePath, string fallbackGam public GameLocations(IList modPaths, string gamePath, IList fallbackPaths) { - if (modPaths == null) + if (modPaths == null) throw new ArgumentNullException(nameof(modPaths)); if (fallbackPaths == null) throw new ArgumentNullException(nameof(fallbackPaths)); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/EawGameLanguageManager.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/EawGameLanguageManager.cs new file mode 100644 index 0000000..2c720b4 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/EawGameLanguageManager.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace PG.StarWarsGame.Engine.Language; + +internal sealed class EawGameLanguageManager(IServiceProvider serviceProvider) : GameLanguageManager(serviceProvider) +{ + public override IEnumerable SupportedLanguages { get; } = new HashSet + { + LanguageType.English, LanguageType.German, LanguageType.French, + LanguageType.Spanish, LanguageType.Italian, LanguageType.Japanese, + LanguageType.Korean, LanguageType.Chinese, LanguageType.Russian, + LanguageType.Polish, LanguageType.Thai + }; +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/FocGameLanguageManager.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/FocGameLanguageManager.cs new file mode 100644 index 0000000..3636c40 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/FocGameLanguageManager.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace PG.StarWarsGame.Engine.Language; + +internal sealed class FocGameLanguageManager(IServiceProvider serviceProvider) : GameLanguageManager(serviceProvider) +{ + public override IEnumerable SupportedLanguages { get; } = new HashSet + { + LanguageType.English, LanguageType.German, LanguageType.French, + LanguageType.Spanish, LanguageType.Italian, LanguageType.Russian, + LanguageType.Polish + }; + + public override LanguageType GetLanguagesFromUser() + { + var language = base.GetLanguagesFromUser(); + return language is LanguageType.Thai or LanguageType.Chinese or LanguageType.Japanese or LanguageType.Korean + ? LanguageType.English + : language; + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/GameLanguageManager.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/GameLanguageManager.cs index d4111bc..ec4d6b2 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/GameLanguageManager.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/GameLanguageManager.cs @@ -1,13 +1,15 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; using Microsoft.Extensions.Logging; using PG.Commons.Services; -using PG.StarWarsGame.Engine.Utilities; namespace PG.StarWarsGame.Engine.Language; -internal sealed class GameLanguageManager(IServiceProvider serviceProvider) : ServiceBase(serviceProvider), IGameLanguageManager -{ +internal abstract class GameLanguageManager(IServiceProvider serviceProvider) : ServiceBase(serviceProvider), IGameLanguageManager +{ private static readonly IDictionary LanguageToFileSuffixMapMp3 = new Dictionary { @@ -40,39 +42,27 @@ internal sealed class GameLanguageManager(IServiceProvider serviceProvider) : Se { LanguageType.Thai, "_THA.WAV" }, }; - public IReadOnlyCollection FocSupportedLanguages { get; } = - new HashSet - { - LanguageType.English, LanguageType.German, LanguageType.French, - LanguageType.Spanish, LanguageType.Italian, LanguageType.Russian, - LanguageType.Polish - }; - - public IReadOnlyCollection EawSupportedLanguages { get; } = - new HashSet - { - LanguageType.English, LanguageType.German, LanguageType.French, - LanguageType.Spanish, LanguageType.Italian, LanguageType.Japanese, - LanguageType.Korean, LanguageType.Chinese, LanguageType.Russian, - LanguageType.Polish, LanguageType.Thai - }; - + public abstract IEnumerable SupportedLanguages { get; } - public bool IsFileNameLocalizable(string fileName, bool requiredEnglishName) + public bool TryGetLanguage(string languageName, out LanguageType language) + { + language = LanguageType.English; + return Enum.TryParse(languageName, true, out language); + } + + public bool IsFileNameLocalizable(ReadOnlySpan fileName, bool requiredEnglishName) { - var fileSpan = fileName.AsSpan(); - if (requiredEnglishName) { - if (fileSpan.EndsWith("_ENG.WAV".AsSpan(), StringComparison.OrdinalIgnoreCase)) + if (fileName.EndsWith("_ENG.WAV".AsSpan(), StringComparison.OrdinalIgnoreCase)) return true; - if (fileSpan.EndsWith("_ENG.MP3".AsSpan(), StringComparison.OrdinalIgnoreCase)) + if (fileName.EndsWith("_ENG.MP3".AsSpan(), StringComparison.OrdinalIgnoreCase)) return true; return false; } - var isWav = fileSpan.EndsWith(".WAV".AsSpan(), StringComparison.OrdinalIgnoreCase); - var isMp3 = fileSpan.EndsWith(".MP3".AsSpan(), StringComparison.OrdinalIgnoreCase); + var isWav = fileName.EndsWith(".WAV".AsSpan(), StringComparison.OrdinalIgnoreCase); + var isMp3 = fileName.EndsWith(".MP3".AsSpan(), StringComparison.OrdinalIgnoreCase); ICollection? checkList = null; if (isWav) @@ -85,44 +75,98 @@ public bool IsFileNameLocalizable(string fileName, bool requiredEnglishName) foreach (var toCheck in checkList) { - if (fileSpan.EndsWith(toCheck.AsSpan(), StringComparison.OrdinalIgnoreCase)) + if (fileName.EndsWith(toCheck.AsSpan(), StringComparison.OrdinalIgnoreCase)) return true; } return false; } + public virtual LanguageType GetLanguagesFromUser() + { + CultureInfo culture; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var langId = GetUserDefaultUILanguage(); + culture = new CultureInfo(langId); + } + else + culture = CultureInfo.CurrentCulture; + + var rootCulture = GetParentCultureRecursive(culture); + + var cultureName = rootCulture.EnglishName; + + if (TryGetLanguage(cultureName, out var language)) + return language; + return LanguageType.English; + } + + private CultureInfo GetParentCultureRecursive(CultureInfo culture) + { + var parent = culture.Parent; + if (parent.Equals(CultureInfo.InvariantCulture)) + return culture; + return GetParentCultureRecursive(parent); + } + public string LocalizeFileName(string fileName, LanguageType language, out bool localized) { - if (fileName.Length > 260) + if (fileName.Length > PGConstants.MaxPathLength) throw new ArgumentOutOfRangeException(nameof(fileName), "fileName is too long"); - localized = true; - // The game assumes that all localized audio files are referenced by using their english name. // Thus, PG takes this shortcut if (language == LanguageType.English) + { + localized = true; return fileName; + } + + + Span localizedName = stackalloc char[fileName.Length]; + var length = LocalizeFileName(fileName.AsSpan(), language, localizedName, out localized); + if (!localized) + return fileName; + + Debug.Assert(localizedName.Length == length); + return localizedName.ToString(); + } - var fileSpan = fileName.AsSpan(); + + public int LocalizeFileName(ReadOnlySpan fileName, LanguageType language, Span destination, out bool localized) + { + if (fileName.Length > PGConstants.MaxPathLength) + throw new ArgumentOutOfRangeException(nameof(fileName), "fileName is too long"); + + if (destination.Length < fileName.Length) + throw new ArgumentException("destination is too short", nameof(destination)); + + localized = true; + + // The game assumes that all localized audio files are referenced by using their english name. + // Thus, PG takes this shortcut + if (language == LanguageType.English) + { + fileName.CopyTo(destination); + return fileName.Length; + } var isWav = false; var isMp3 = false; // The game only localizes file names iff they have the english suffix // NB: Also note that the engine does *not* check whether the filename actually ends with this suffix - // but instead only take the first occurrence. This means that a file name like - // 'test_eng.wav_ger.wav' - // will trick the algorithm. - var engSuffixIndex = fileSpan.IndexOf("_ENG.WAV".AsSpan(), StringComparison.OrdinalIgnoreCase); + // but instead only take the first occurrence. This means that a file name like 'test_eng.wav_ger.wav' will trick the algorithm. + var engSuffixIndex = fileName.IndexOf("_ENG.WAV".AsSpan(), StringComparison.OrdinalIgnoreCase); if (engSuffixIndex != -1) isWav = true; if (!isWav) { - engSuffixIndex = fileSpan.IndexOf("_ENG.MP3".AsSpan(), StringComparison.OrdinalIgnoreCase); - if (engSuffixIndex != -1) + engSuffixIndex = fileName.IndexOf("_ENG.MP3".AsSpan(), StringComparison.OrdinalIgnoreCase); + if (engSuffixIndex != -1) isMp3 = true; } @@ -130,12 +174,13 @@ public string LocalizeFileName(string fileName, LanguageType language, out bool if (engSuffixIndex == -1) { localized = false; - Logger.LogWarning($"Unable to localize '{fileName}'"); - return fileName; + Logger.LogWarning($"Unable to localize '{fileName.ToString()}'"); + fileName.CopyTo(destination); + return fileName.Length; } - var withoutSuffix = fileSpan.Slice(0, engSuffixIndex); - + var withoutSuffix = fileName.Slice(0, engSuffixIndex); + ReadOnlySpan newLocalizedSuffix; if (isWav) newLocalizedSuffix = LanguageToFileSuffixMapWav[language].AsSpan(); @@ -144,13 +189,13 @@ public string LocalizeFileName(string fileName, LanguageType language, out bool else throw new InvalidOperationException(); - // 260 is roughly the MAX file path size on default Windows, - // so we don't expect game files to be larger. - var sb = new ValueStringBuilder(stackalloc char[260]); - - sb.Append(withoutSuffix); - sb.Append(newLocalizedSuffix); + withoutSuffix.CopyTo(destination); + newLocalizedSuffix.CopyTo(destination.Slice(withoutSuffix.Length, newLocalizedSuffix.Length)); - return sb.ToString(); + return fileName.Length; } + + + [DllImport("Kernel32.dll", CharSet = CharSet.Auto)] + static extern ushort GetUserDefaultUILanguage(); } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/GameLanguageManagerProvider.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/GameLanguageManagerProvider.cs new file mode 100644 index 0000000..b6b00e8 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/GameLanguageManagerProvider.cs @@ -0,0 +1,19 @@ +using System; + +namespace PG.StarWarsGame.Engine.Language; + +internal class GameLanguageManagerProvider(IServiceProvider serviceProvider) : IGameLanguageManagerProvider +{ + private readonly Lazy _eawLanguageManager = new(() => new EawGameLanguageManager(serviceProvider)); + private readonly Lazy _focLanguageManager = new(() => new FocGameLanguageManager(serviceProvider)); + + public IGameLanguageManager GetLanguageManager(GameEngineType engine) + { + return engine switch + { + GameEngineType.Eaw => _eawLanguageManager.Value, + GameEngineType.Foc => _focLanguageManager.Value, + _ => throw new InvalidOperationException($"Engine '{engine}' not supported!") + }; + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/IGameLanguageManager.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/IGameLanguageManager.cs index d061c95..b0e03da 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/IGameLanguageManager.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/IGameLanguageManager.cs @@ -1,14 +1,19 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; namespace PG.StarWarsGame.Engine.Language; public interface IGameLanguageManager -{ - IReadOnlyCollection FocSupportedLanguages { get; } +{ + IEnumerable SupportedLanguages { get; } - IReadOnlyCollection EawSupportedLanguages { get; } + bool TryGetLanguage(string languageName, out LanguageType language); string LocalizeFileName(string fileName, LanguageType language, out bool localized); - bool IsFileNameLocalizable(string fileName, bool requireEnglishName); + int LocalizeFileName(ReadOnlySpan fileName, LanguageType language, Span destination, out bool localized); + + bool IsFileNameLocalizable(ReadOnlySpan fileName, bool requireEnglishName); + + LanguageType GetLanguagesFromUser(); } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/IGameLanguageManagerProvider.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/IGameLanguageManagerProvider.cs new file mode 100644 index 0000000..c47d0a4 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/IGameLanguageManagerProvider.cs @@ -0,0 +1,6 @@ +namespace PG.StarWarsGame.Engine.Language; + +public interface IGameLanguageManagerProvider +{ + IGameLanguageManager GetLanguageManager(GameEngineType engine); +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj b/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj index 4487bb3..705e571 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj @@ -17,7 +17,12 @@ true - + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -26,13 +31,14 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - - - \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/PGConstants.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/PGConstants.cs index a04aaa4..486aa40 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/PGConstants.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/PGConstants.cs @@ -5,4 +5,6 @@ namespace PG.StarWarsGame.Engine; public static class PGConstants { public static readonly Encoding PGCrc32Encoding = Encoding.ASCII; + + public const int MaxPathLength = 260; } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/PetroglyphEngineServiceContribution.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/PetroglyphEngineServiceContribution.cs index f1ff96c..ae4269b 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/PetroglyphEngineServiceContribution.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/PetroglyphEngineServiceContribution.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; -using PG.StarWarsGame.Engine.FileSystem; +using PG.StarWarsGame.Engine.Database; using PG.StarWarsGame.Engine.Language; +using PG.StarWarsGame.Engine.Repositories; using PG.StarWarsGame.Engine.Xml; namespace PG.StarWarsGame.Engine; @@ -10,7 +11,9 @@ public static class PetroglyphEngineServiceContribution public static void ContributeServices(IServiceCollection serviceCollection) { serviceCollection.AddSingleton(sp => new GameRepositoryFactory(sp)); - serviceCollection.AddSingleton(sp => new GameLanguageManager(sp)); + serviceCollection.AddSingleton(sp => new GameLanguageManagerProvider(sp)); serviceCollection.AddSingleton(sp => new PetroglyphXmlFileParserFactory(sp)); + + serviceCollection.AddTransient(sp => new GameDatabaseService(sp)); } } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Pipeline/CreateGameDatabasePipeline.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Pipeline/CreateGameDatabasePipeline.cs deleted file mode 100644 index 53fb102..0000000 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Pipeline/CreateGameDatabasePipeline.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using AnakinRaW.CommonUtilities.SimplePipeline; -using PG.StarWarsGame.Engine.DataTypes; -using PG.StarWarsGame.Engine.FileSystem; - -namespace PG.StarWarsGame.Engine.Pipeline; - -internal class CreateGameDatabasePipeline(IGameRepository repository, IServiceProvider serviceProvider) - : ParallelPipeline(serviceProvider) -{ - private ParseSingletonXmlStep _parseGameConstants = null!; - private ParseFromContainerStep _parseGameObjects = null!; - - public GameDatabase GameDatabase { get; private set; } = null!; - - protected override Task> BuildSteps() - { - _parseGameConstants = new ParseSingletonXmlStep("GameConstants", "DATA\\XML\\GAMECONSTANTS.XML", repository, ServiceProvider); - _parseGameObjects = new ParseFromContainerStep("GameObjects", "DATA\\XML\\GAMEOBJECTFILES.XML", repository, ServiceProvider); - - // GUIDialogs.xml - // LensFlares.xml - // SurfaceFX.xml - // TerrainDecalFX.xml - // GraphicDetails.xml - // DynamicTrackFX.xml - // ShadowBlobMaterials.xml - // TacticalCameras.xml - // LightSources.xml - // StarWars3DTextCrawl.xml - // MusicEvents.xml - // SpeechEvents.xml - // GameConstants.xml - // Audio.xml - // WeatherAudio.xml - // HeroClash.xml - // TradeRouteLines.xml - // RadarMap.xml - // WeatherModifiers.xml - // Movies.xml - // LightningEffectTypes.xml - // DifficultyAdjustments.xml - // WeatherScenarios.xml - // UnitAbilityTypes.xml - // BlackMarketItems.xml - // MovementClassTypeDefs.xml - // AITerrainEffectiveness.xml - - - // CONTAINER FILES: - // GameObjectFiles.xml - // SFXEventFiles.xml - // CommandBarComponentFiles.xml - // TradeRouteFiles.xml - // HardPointDataFiles.xml - // CampaignFiles.xml - // FactionFiles.xml - // TargetingPrioritySetFiles.xml - // MousePointerFiles.xml - - return Task.FromResult>(new List - { - _parseGameConstants, - _parseGameObjects - }); - } - - protected override async Task RunCoreAsync(CancellationToken token) - { - await base.RunCoreAsync(token); - - GameDatabase = new GameDatabase - { - EngineType = repository.EngineType, - GameConstants = _parseGameConstants.Database, - GameObjects = _parseGameObjects.Database - }; - } - - private sealed class ParseSingletonXmlStep(string name, string xmlFile, IGameRepository repository, IServiceProvider serviceProvider) - : ParseXmlDatabaseStep(xmlFile, repository, serviceProvider) where T : class - { - protected override string Name => name; - - protected override T CreateDatabase(IList parsedDatabaseEntries) - { - if (parsedDatabaseEntries.Count != 1) - throw new InvalidOperationException($"There can be only one {Name} model."); - - return parsedDatabaseEntries.First(); - } - } - - - private sealed class ParseFromContainerStep( - string name, - string xmlFile, - IGameRepository repository, - IServiceProvider serviceProvider) - : ParseXmlDatabaseFromContainerStep>(xmlFile, repository, serviceProvider) - { - protected override string Name => name; - - protected override IList CreateDatabase(IList> parsedDatabaseEntries) - { - return parsedDatabaseEntries.SelectMany(x => x).ToList(); - } - } -} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Pipeline/CreateGameDatabaseStep.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Pipeline/CreateGameDatabaseStep.cs deleted file mode 100644 index 361e823..0000000 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Pipeline/CreateGameDatabaseStep.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Threading; -using AnakinRaW.CommonUtilities.SimplePipeline.Steps; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using PG.StarWarsGame.Engine.FileSystem; - -namespace PG.StarWarsGame.Engine.Pipeline; - -public class CreateGameDatabaseStep : SynchronizedStep -{ - private readonly IGameRepository _gameRepository; - private readonly ILogger? _logger; - - public GameDatabase GameDatabase { get; private set; } = null!; - - public CreateGameDatabaseStep(IGameRepository gameRepository, IServiceProvider serviceProvider) : base(serviceProvider) - { - _logger = Services.GetService()?.CreateLogger(GetType()); - _gameRepository = gameRepository; - } - - protected override void RunSynchronized(CancellationToken token) - { - _logger?.LogInformation("Creating Game Database..."); - var indexGamesPipeline = new CreateGameDatabasePipeline(_gameRepository, Services); - indexGamesPipeline.RunAsync(token).Wait(); - GameDatabase = indexGamesPipeline.GameDatabase; - _logger?.LogInformation("Finished creating game database"); - } -} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Pipeline/ParseXmlDatabaseFromContainerStep.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Pipeline/ParseXmlDatabaseFromContainerStep.cs deleted file mode 100644 index ee22402..0000000 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Pipeline/ParseXmlDatabaseFromContainerStep.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO.Abstractions; -using System.Linq; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using PG.StarWarsGame.Engine.FileSystem; -using PG.StarWarsGame.Engine.Xml; -using PG.StarWarsGame.Files.XML; - -namespace PG.StarWarsGame.Engine.Pipeline; - -public abstract class ParseXmlDatabaseFromContainerStep( - string xmlFile, - IGameRepository repository, - IServiceProvider serviceProvider) - : CreateDatabaseStep(repository, serviceProvider) - where T : class -{ - private readonly IFileSystem _fileSystem = serviceProvider.GetRequiredService(); - - protected readonly IPetroglyphXmlFileParserFactory FileParserFactory = serviceProvider.GetRequiredService(); - - protected sealed override T CreateDatabase() - { - using var containerStream = GameRepository.OpenFile(xmlFile); - var containerParser = FileParserFactory.GetFileParser(); - Logger?.LogDebug($"Parsing container data '{xmlFile}'"); - var container = containerParser.ParseFile(containerStream); - - var xmlFiles = container.Files.Select(x => _fileSystem.Path.Combine("DATA\\XML", x)).ToList(); - - - var parsedDatabaseEntries = new List(); - foreach (var file in xmlFiles) - { - using var fileStream = GameRepository.OpenFile(file); - - var parser = FileParserFactory.GetFileParser(); - Logger?.LogDebug($"Parsing File '{file}'"); - var parsedData = parser.ParseFile(fileStream); - parsedDatabaseEntries.Add(parsedData); - } - return CreateDatabase(parsedDatabaseEntries); - } - - protected abstract T CreateDatabase(IList files); -} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/EffectsRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/EffectsRepository.cs new file mode 100644 index 0000000..4421c88 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/EffectsRepository.cs @@ -0,0 +1,42 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace PG.StarWarsGame.Engine.Repositories; + +public class EffectsRepository(IGameRepository baseRepository, IServiceProvider serviceProvider) : MultiPassRepository(baseRepository, serviceProvider) +{ + private static readonly string[] LookupPaths = + [ + "Data\\Art\\Shaders", + "Data\\Art\\Shaders\\Terrain", + "Data\\Art\\Shaders\\Engine", + ]; + + // The engine does not support ".fxh" as a shader lookup, but as there might be som pre-compiling going on, this should be OK. + private static readonly string[] ShaderExtensions = [".fx", ".fxo", ".fxh"]; + + [return:MaybeNull] + protected override T MultiPassAction(string inputPath, Func fileAction) + { + var currExt = FileSystem.Path.GetExtension(inputPath); + if (!ShaderExtensions.Contains(currExt, StringComparer.OrdinalIgnoreCase)) + throw new ArgumentException("Invalid data extension for shader. Must be .fx, .fxh or .fxo", nameof(inputPath)); + + foreach (var directory in LookupPaths) + { + var lookupPath = FileSystem.Path.Combine(directory, inputPath); + + foreach (var ext in ShaderExtensions) + { + lookupPath = FileSystem.Path.ChangeExtension(lookupPath, ext); + + var actionResult = fileAction(lookupPath); + if (actionResult.success) + return actionResult.result; + } + } + + return default; + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/FocGameRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/FocGameRepository.cs new file mode 100644 index 0000000..a36382c --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/FocGameRepository.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using PG.StarWarsGame.Files.MEG.Files; +using PG.StarWarsGame.Files.XML.ErrorHandling; +using PG.StarWarsGame.Files.XML.Parsers; + +namespace PG.StarWarsGame.Engine.Repositories; + +// EaW file lookup works slightly different! +internal class FocGameRepository : GameRepository +{ + public override GameEngineType EngineType => GameEngineType.Foc; + + public FocGameRepository(GameLocations gameLocations, IXmlParserErrorListener? listener, IServiceProvider serviceProvider) : base(gameLocations, listener, serviceProvider) + { + if (gameLocations == null) + throw new ArgumentNullException(nameof(gameLocations)); + + var megsToConsider = new List(); + + // We assume that the first fallback path (if present at all) is always Empire at War. + var firstFallback = FallbackPaths.FirstOrDefault(); + if (firstFallback is not null) + { + var eawMegs = LoadMegArchivesFromXml(firstFallback); + var eawPatch = LoadMegArchive(FileSystem.Path.Combine(firstFallback, "Data\\Patch.meg")); + var eawPatch2 = LoadMegArchive(FileSystem.Path.Combine(firstFallback, "Data\\Patch2.meg")); + var eaw64Patch = LoadMegArchive(FileSystem.Path.Combine(firstFallback, "Data\\64Patch.meg")); + + megsToConsider.AddRange(eawMegs); + if (eawPatch is not null) + megsToConsider.Add(eawPatch); + if (eawPatch2 is not null) + megsToConsider.Add(eawPatch2); + if (eaw64Patch is not null) + megsToConsider.Add(eaw64Patch); + } + + var focOrModMegs = LoadMegArchivesFromXml("."); + var focPatch = LoadMegArchive("Data\\Patch.meg"); + var focPatch2 = LoadMegArchive("Data\\Patch2.meg"); + var foc64Patch = LoadMegArchive("Data\\64Patch.meg"); + + megsToConsider.AddRange(focOrModMegs); + if (focPatch is not null) + megsToConsider.Add(focPatch); + if (focPatch2 is not null) + megsToConsider.Add(focPatch2); + if (foc64Patch is not null) + megsToConsider.Add(foc64Patch); + + AddMegFiles(megsToConsider); + } + + + protected override T? RepositoryFileLookup(string filePath, Func> pathAction, Func> megAction, bool megFileOnly, T? defaultValue = default) where T: default + { + if (!megFileOnly) + { + foreach (var modPath in ModPaths) + { + var modFilePath = FileSystem.Path.Combine(modPath, filePath); + + var result = pathAction(modFilePath); + if (result.ShallReturn) + return result.Result; + } + + { + var normalFilePath = FileSystem.Path.Combine(GameDirectory, filePath); + var result = pathAction(normalFilePath); + if (result.ShallReturn) + return result.Result; + } + + } + + if (MasterMegArchive is not null) + { + var result = megAction(filePath); + if (result.ShallReturn) + return result.Result; + } + + if (!megFileOnly) + { + foreach (var fallbackPath in FallbackPaths) + { + var fallbackFilePath = FileSystem.Path.Combine(fallbackPath, filePath); + + var result = pathAction(fallbackFilePath); + if (result.ShallReturn) + return result.Result; + } + } + + return defaultValue; + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/GameRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/GameRepository.cs new file mode 100644 index 0000000..48c99f0 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/GameRepository.cs @@ -0,0 +1,429 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using System.Runtime.InteropServices; +using AnakinRaW.CommonUtilities.FileSystem; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Extensions.Logging; +using PG.Commons.Hashing; +using PG.Commons.Services; +using PG.StarWarsGame.Engine.Language; +using PG.StarWarsGame.Engine.Utilities; +using PG.StarWarsGame.Engine.Xml; +using PG.StarWarsGame.Files.MEG.Data.Archives; +using PG.StarWarsGame.Files.MEG.Data.Entries; +using PG.StarWarsGame.Files.MEG.Data.EntryLocations; +using PG.StarWarsGame.Files.MEG.Files; +using PG.StarWarsGame.Files.MEG.Services; +using PG.StarWarsGame.Files.MEG.Services.Builder.Normalization; +using PG.StarWarsGame.Files.XML; +using PG.StarWarsGame.Files.XML.ErrorHandling; + +namespace PG.StarWarsGame.Engine.Repositories; + +internal abstract class GameRepository : ServiceBase, IGameRepository +{ + private readonly IMegFileService _megFileService; + private readonly IMegFileExtractor _megExtractor; + private readonly PetroglyphDataEntryPathNormalizer _megPathNormalizer; + private readonly ICrc32HashingService _crc32HashingService; + private readonly IVirtualMegArchiveBuilder _virtualMegBuilder; + private readonly IGameLanguageManagerProvider _languageManagerProvider; + private readonly IXmlParserErrorListener? _xmlParserErrorListener; + + protected readonly string GameDirectory; + + protected readonly IList ModPaths = new List(); + protected readonly IList FallbackPaths = new List(); + + private bool _sealed; + + public string Path { get; } + public abstract GameEngineType EngineType { get; } + + public IRepository EffectsRepository { get; } + public IRepository TextureRepository { get; } + + + private readonly List _loadedMegFiles = new(); + protected IVirtualMegArchive? MasterMegArchive { get; private set; } + + protected GameRepository(GameLocations gameLocations, IXmlParserErrorListener? errorListener, IServiceProvider serviceProvider) : base(serviceProvider) + { + if (gameLocations == null) + throw new ArgumentNullException(nameof(gameLocations)); + + _megExtractor = serviceProvider.GetRequiredService(); + _megFileService = serviceProvider.GetRequiredService(); + _virtualMegBuilder = serviceProvider.GetRequiredService(); + _crc32HashingService = serviceProvider.GetRequiredService(); + _megPathNormalizer = new PetroglyphDataEntryPathNormalizer(serviceProvider); + _languageManagerProvider = serviceProvider.GetRequiredService(); + _xmlParserErrorListener = errorListener; + + foreach (var mod in gameLocations.ModPaths) + { + if (string.IsNullOrEmpty(mod)) + throw new InvalidOperationException("Mods with empty paths are not valid."); + + ModPaths.Add(FileSystem.Path.GetFullPath(mod)); + } + + GameDirectory = FileSystem.Path.GetFullPath(gameLocations.GamePath); + + + foreach (var fallbackPath in gameLocations.FallbackPaths) + { + if (string.IsNullOrEmpty(fallbackPath)) + { + Logger.LogTrace("Skipping null or empty fallback path."); + continue; + } + FallbackPaths.Add(FileSystem.Path.GetFullPath(fallbackPath)); + } + + EffectsRepository = new EffectsRepository(this, serviceProvider); + TextureRepository = new TextureRepository(this, serviceProvider); + + + var path = ModPaths.Any() ? ModPaths.First() : GameDirectory; + if (!FileSystem.Path.HasTrailingDirectorySeparator(path)) + path += FileSystem.Path.DirectorySeparatorChar; + + Path = path; + } + + + public void AddMegFiles(IList megFiles) + { + ThrowIfSealed(); + if (MasterMegArchive is null) + MasterMegArchive = _virtualMegBuilder.BuildFrom(megFiles, true); + else + { + var newLocations = new List(); + foreach (var megFile in megFiles) + newLocations.AddRange(megFile.Archive.Select(entry => + new MegDataEntryReference(new MegDataEntryLocationReference(megFile, entry)))); + MasterMegArchive = _virtualMegBuilder.BuildFrom(MasterMegArchive.ToList().Concat(newLocations), true); + } + + _loadedMegFiles.AddRange(megFiles.Select(x => x.FilePath)); + } + + public void AddMegFile(string megFile) + { + var megArchive = LoadMegArchive(megFile); + if (megArchive is null) + { + Logger.LogWarning($"Unable to find MEG file at '{megFile}'"); + return; + } + + AddMegFiles([megArchive]); + } + + public bool FileExists(string filePath, bool megFileOnly = false) + { + return RepositoryFileLookup(filePath, fp => + { + if (FileSystem.File.Exists(fp)) + return new ActionResult(true, true); + return ActionResult.DoNotReturn; + }, + fp => + { + var entry = FindFileInMasterMeg(fp); + if (entry is not null) + return new ActionResult(true, true); + return ActionResult.DoNotReturn; + }, megFileOnly); + } + + public Stream? TryOpenFile(string filePath, bool megFileOnly = false) + { + return RepositoryFileLookup(filePath, fp => + { + if (FileSystem.File.Exists(fp)) + return new ActionResult(true, OpenFileRead(fp)); + return ActionResult.DoNotReturn; + }, + fp => + { + var entry = FindFileInMasterMeg(fp); + if (entry is not null) + return new ActionResult(true, _megExtractor.GetFileData(entry.Location)); + return ActionResult.DoNotReturn; + }, megFileOnly); + } + + + public Stream OpenFile(string filePath, bool megFileOnly = false) + { + var stream = TryOpenFile(filePath, megFileOnly); + if (stream is null) + throw new FileNotFoundException($"Unable to find game data: {filePath}"); + return stream; + } + + public bool FileExists(string filePath, string[] extensions, bool megFileOnly = false) + { + foreach (var extension in extensions) + { + var newPath = FileSystem.Path.ChangeExtension(filePath, extension); + if (FileExists(newPath, megFileOnly)) + return true; + } + return false; + } + + public IEnumerable FindFiles(string searchPattern, bool megFileOnly = false) + { + var files = new HashSet(); + + var matcher = new Matcher(); + matcher.AddInclude(searchPattern); + + RepositoryFileLookup(searchPattern, + pattern => + { + var path = pattern.AsSpan().TrimEnd(searchPattern.AsSpan()); + + var matcherResult = matcher.Execute(FileSystem, path.ToString()); + + foreach (var matchedFile in matcherResult.Files) + { + var normalizedFile = _megPathNormalizer.Normalize(matchedFile.Path); + files.Add(normalizedFile); + } + + return ActionResult.DoNotReturn; + }, + _ => + { + var foundFiles = MasterMegArchive!.FindAllEntries(searchPattern, true); + foreach (var x in foundFiles) + files.Add(x.FilePath); + + return ActionResult.DoNotReturn; + }, megFileOnly); + + return files; + } + + public bool IsLanguageInstalled(LanguageType language) + { + // A language is considered to be installed if its Text, Speech and localized 2d file exists in the current game + var languageFiles = new LanguageFiles(language); + + if (!FileExists(languageFiles.MasterTextDatFilePath)) + return false; + + if (!FileExists(languageFiles.Sfx2dMegFilePath)) + return false; + + + foreach (var loadedMegFile in _loadedMegFiles) + { + var file = FileSystem.Path.GetFileName(loadedMegFile.AsSpan()); + var speechFileName = languageFiles.SpeechMegFileName.AsSpan(); + + if (file.Equals(speechFileName, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + public IEnumerable InitializeInstalledSfxMegFiles() + { + ThrowIfSealed(); + + var megsToAdd = new List(); + + var firstFallback = FallbackPaths.FirstOrDefault(); + if (firstFallback is not null) + { + var fallback2dNonLocalized = LoadMegArchive(FileSystem.Path.Combine(firstFallback, "DATA\\AUDIO\\SFX\\SFX2D_NON_LOCALIZED.MEG")); + var fallback3dNonLocalized = LoadMegArchive(FileSystem.Path.Combine(firstFallback, "DATA\\AUDIO\\SFX\\SFX3D_NON_LOCALIZED.MEG")); + + if (fallback2dNonLocalized is not null) + megsToAdd.Add(fallback2dNonLocalized); + + if (fallback3dNonLocalized is not null) + megsToAdd.Add(fallback3dNonLocalized); + } + + var nonLocalized2d = LoadMegArchive("DATA\\AUDIO\\SFX\\SFX2D_NON_LOCALIZED.MEG"); + var nonLocalized3d = LoadMegArchive("DATA\\AUDIO\\SFX\\SFX3D_NON_LOCALIZED.MEG"); + + if (nonLocalized2d is not null) + megsToAdd.Add(nonLocalized2d); + + if (nonLocalized3d is not null) + megsToAdd.Add(nonLocalized3d); + + + var languageManager = _languageManagerProvider.GetLanguageManager(EngineType); + var languages = new List(); + foreach (var language in languageManager.SupportedLanguages) + { + if (!IsLanguageInstalled(language)) + continue; + languages.Add(language); + + var languageFiles = new LanguageFiles(language); + + if (firstFallback is not null) + { + var fallback2dLang = LoadMegArchive(FileSystem.Path.Combine(firstFallback, languageFiles.Sfx2dMegFilePath)); + if (fallback2dLang is not null) + megsToAdd.Add(fallback2dLang); + } + + var lang2d = LoadMegArchive(languageFiles.Sfx2dMegFilePath); + if (lang2d is not null) + megsToAdd.Add(lang2d); + } + + if (languages.Count == 0) + Logger.LogWarning("Unable to initialize any language."); + + AddMegFiles(megsToAdd); + + return languages; + } + + protected IList LoadMegArchivesFromXml(string lookupPath) + { + var megFilesXmlPath = FileSystem.Path.Combine(lookupPath, "Data\\MegaFiles.xml"); + + var fileParserFactory = Services.GetRequiredService(); + + using var xmlStream = TryOpenFile(megFilesXmlPath); + + if (xmlStream is null) + { + Logger.LogWarning($"Unable to find MegaFiles.xml at '{lookupPath}'"); + return Array.Empty(); + } + + var parser = fileParserFactory.GetFileParser(_xmlParserErrorListener); + var megaFilesXml = parser.ParseFile(xmlStream); + + + var megs = new List(megaFilesXml.Files.Count); + + foreach (var file in megaFilesXml.Files.Select(x => x.Trim())) + { + var megPath = FileSystem.Path.Combine(lookupPath, file); + var megFile = LoadMegArchive(megPath); + if (megFile is not null) + megs.Add(megFile); + } + + return megs; + } + + internal void Seal() + { + _sealed = true; + } + + protected abstract T? RepositoryFileLookup(string filePath, Func> pathAction, + Func> megAction, bool megFileOnly, T? defaultValue = default); + + protected IMegFile? LoadMegArchive(string megPath) + { + using var megFileStream = TryOpenFile(megPath); + if (megFileStream is not FileSystemStream fileSystemStream) + return null; + + var megFile = _megFileService.Load(fileSystemStream); + + if (megFile.FileInformation.FileVersion != MegFileVersion.V1) + throw new InvalidOperationException("MEG data version must be V1."); + + return megFile; + } + + protected MegDataEntryReference? FindFileInMasterMeg(string filePath) + { + Span fileNameBuffer = stackalloc char[PGConstants.MaxPathLength]; + + // TODO: Is the engine really "to-uppering" the input??? + var length = _megPathNormalizer.Normalize(filePath.AsSpan(), fileNameBuffer); + var fileName = fileNameBuffer.Slice(0, length); + var crc = _crc32HashingService.GetCrc32(fileName, PGConstants.PGCrc32Encoding); + + return MasterMegArchive?.FirstEntryWithCrc(crc); + } + + protected FileSystemStream OpenFileRead(string filePath) + { + if (!AllowOpenFile(filePath)) + throw new UnauthorizedAccessException("The data is not part of the Games!"); + return FileSystem.FileStream.New(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + } + + private bool AllowOpenFile(string filePath) + { + foreach (var modPath in ModPaths) + { + if (FileSystem.Path.IsChildOf(modPath, filePath)) + return true; + } + + if (FileSystem.Path.IsChildOf(GameDirectory, filePath)) + return true; + + foreach (var fallbackPath in FallbackPaths) + { + if (FileSystem.Path.IsChildOf(fallbackPath, filePath)) + return true; + } + + return false; + } + + private void ThrowIfSealed() + { + if (_sealed) + throw new InvalidOperationException("The object is sealed for modifications"); + } + + protected readonly struct ActionResult(bool shallReturn, T? result) + { + public T? Result { get; } = result; + + public bool ShallReturn { get; } = shallReturn; + + public static ActionResult DoNotReturn => default; + } + + [StructLayout(LayoutKind.Explicit)] + private readonly struct EmptyStruct; + + private sealed class LanguageFiles + { + public LanguageType Language { get; } + + public string MasterTextDatFilePath { get; } + + public string Sfx2dMegFilePath { get; } + + public string SpeechMegFileName { get; } + + public LanguageFiles(LanguageType language) + { + Language = language; + var languageString = language.ToString().ToUpperInvariant(); + MasterTextDatFilePath = $"DATA\\TEXT\\MasterTextFile_{languageString}.DAT"; + Sfx2dMegFilePath = $"DATA\\AUDIO\\SFX\\SFX2D_{languageString}.MEG"; + SpeechMegFileName = $"{languageString}SPEECH.MEG"; + } + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/GameRepositoryFactory.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/GameRepositoryFactory.cs new file mode 100644 index 0000000..fe64368 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/GameRepositoryFactory.cs @@ -0,0 +1,15 @@ +using System; +using PG.StarWarsGame.Files.XML.ErrorHandling; +using PG.StarWarsGame.Files.XML.Parsers; + +namespace PG.StarWarsGame.Engine.Repositories; + +internal sealed class GameRepositoryFactory(IServiceProvider serviceProvider) : IGameRepositoryFactory +{ + public GameRepository Create(GameEngineType engineType, GameLocations gameLocations, IXmlParserErrorListener? xmlParserErrorListener) + { + if (engineType == GameEngineType.Eaw) + throw new NotImplementedException("Empire at War is currently not supported."); + return new FocGameRepository(gameLocations, xmlParserErrorListener, serviceProvider); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/IGameRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/IGameRepository.cs new file mode 100644 index 0000000..62e6619 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/IGameRepository.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using PG.StarWarsGame.Engine.Language; + +namespace PG.StarWarsGame.Engine.Repositories; + +public interface IGameRepository : IRepository +{ + /// + /// Gets the full qualified path of this repository with a trailing directory separator + /// + public string Path { get; } + + public GameEngineType EngineType { get; } + + IRepository EffectsRepository { get; } + + IRepository TextureRepository { get; } + + bool FileExists(string filePath, string[] extensions, bool megFileOnly = false); + + IEnumerable FindFiles(string searchPattern, bool megFileOnly = false); + + bool IsLanguageInstalled(LanguageType languageType); +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/IGameRepositoryFactory.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/IGameRepositoryFactory.cs new file mode 100644 index 0000000..e7f25e2 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/IGameRepositoryFactory.cs @@ -0,0 +1,9 @@ +using PG.StarWarsGame.Files.XML.ErrorHandling; +using PG.StarWarsGame.Files.XML.Parsers; + +namespace PG.StarWarsGame.Engine.Repositories; + +internal interface IGameRepositoryFactory +{ + GameRepository Create(GameEngineType engineType, GameLocations gameLocations, IXmlParserErrorListener? xmlParserErrorListener); +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/IRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/IRepository.cs similarity index 84% rename from src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/IRepository.cs rename to src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/IRepository.cs index 972c91f..fe5764f 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/FileSystem/IRepository.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/IRepository.cs @@ -1,6 +1,6 @@ using System.IO; -namespace PG.StarWarsGame.Engine.FileSystem; +namespace PG.StarWarsGame.Engine.Repositories; public interface IRepository { diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/MultiPassRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/MultiPassRepository.cs new file mode 100644 index 0000000..c4238bb --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/MultiPassRepository.cs @@ -0,0 +1,46 @@ +using System; +using System.IO; +using System.IO.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace PG.StarWarsGame.Engine.Repositories; + +public abstract class MultiPassRepository(IGameRepository baseRepository, IServiceProvider serviceProvider) : IRepository +{ + protected readonly IFileSystem FileSystem = serviceProvider.GetRequiredService(); + + public Stream OpenFile(string filePath, bool megFileOnly = false) + { + var fileStream = TryOpenFile(filePath, megFileOnly); + if (fileStream is null) + throw new FileNotFoundException($"Unable to find game file: {filePath}"); + return fileStream; + } + + public bool FileExists(string filePath, bool megFileOnly = false) + { + return MultiPassAction(filePath, actualPath => baseRepository.FileExists(actualPath, megFileOnly)); + } + + public Stream? TryOpenFile(string filePath, bool megFileOnly = false) + { + return MultiPassAction(filePath, path => + { + var stream = baseRepository.TryOpenFile(path, megFileOnly); + if (stream is null) + return (false, null); + return (true, stream); + }); + } + + protected abstract T? MultiPassAction(string inputPath, Func fileAction); + + protected bool MultiPassAction(string inputPath, Predicate fileAction) + { + return MultiPassAction(inputPath, path => + { + var result = fileAction(path); + return (result, result); + }); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/TextureRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/TextureRepository.cs new file mode 100644 index 0000000..e5253e0 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Repositories/TextureRepository.cs @@ -0,0 +1,64 @@ +using System; + +namespace PG.StarWarsGame.Engine.Repositories; + +public class TextureRepository(IGameRepository baseRepository, IServiceProvider serviceProvider) : MultiPassRepository(baseRepository, serviceProvider) +{ + protected override T? MultiPassAction(string inputPath, Func fileAction) where T : default + { + if (FindTexture(inputPath, fileAction, out var result)) + return result; + + var ddsFilePath = ChangeExtensionTo(inputPath, ".dds"); + + if (FindTexture(ddsFilePath, fileAction, out result)) + return result; + + return default; + } + + private bool FindTexture(string inputPath, Func fileAction, out T? result) + { + result = default; + + var actionResult = fileAction(inputPath); + if (actionResult.success) + { + result = actionResult.result; + return true; + } + + var newInput = inputPath; + + // Only PG knows why they only search for backslash and not also forward slash, + // when in fact in other methods, they handle both. + var separatorIndex = inputPath.LastIndexOf('\\'); + if (separatorIndex != -1 && separatorIndex + 1 < inputPath.Length) + newInput = inputPath.Substring(separatorIndex + 1); + + var pathWithFolders = FileSystem.Path.Combine("DATA\\ART\\TEXTURES", newInput); + actionResult = fileAction(pathWithFolders); + + if (actionResult.success) + { + result = actionResult.result; + return true; + } + + + return false; + } + + + private static string ChangeExtensionTo(string input, string extension) + { + // We cannot use Path.ChangeExtension as the PG implementation supports some strange things + // like that a string "c:\\file.tga\\" ending with a directory separator. The PG result will be + // "c:\\file.dds" while Path.ChangeExtension would return "c:\\file.tga\\.dds" + + // Also, while there are many cases, where method breaks (such as "c:/test.abc/path.dds"), + // it's the way how the engine works... + var firstPart = input.Split('.')[0]; + return firstPart + extension; + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/DirectoryInfoGlobbingWrapper.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/DirectoryInfoGlobbingWrapper.cs new file mode 100644 index 0000000..2f2114a --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/DirectoryInfoGlobbingWrapper.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; + +namespace PG.StarWarsGame.Engine.Utilities; + +// Taken from https://github.com/vipentti/Vipentti.IO.Abstractions.FileSystemGlobbing + +/// +/// Wraps to be used with +/// +public sealed class DirectoryInfoGlobbingWrapper : Microsoft.Extensions.FileSystemGlobbing.Abstractions.DirectoryInfoBase +{ + private readonly IFileSystem _fileSystem; + private readonly IDirectoryInfo _directoryInfo; + private readonly bool _isParentPath; + + /// + public override string Name => _isParentPath ? ".." : _directoryInfo.Name; + + /// + public override string FullName => _directoryInfo.FullName; + + /// + public override Microsoft.Extensions.FileSystemGlobbing.Abstractions.DirectoryInfoBase? ParentDirectory => + _directoryInfo.Parent is null + ? null + : new DirectoryInfoGlobbingWrapper(_fileSystem, _directoryInfo.Parent); + + /// + /// Construct a new instance of + /// + /// The filesystem + /// The directory + public DirectoryInfoGlobbingWrapper(IFileSystem fileSystem, IDirectoryInfo directoryInfo) + : this(fileSystem, directoryInfo, isParentPath: false) + { + } + + private DirectoryInfoGlobbingWrapper(IFileSystem fileSystem, IDirectoryInfo directoryInfo, bool isParentPath) + { + _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + _directoryInfo = directoryInfo ?? throw new ArgumentNullException(nameof(directoryInfo)); + _isParentPath = isParentPath; + } + + /// + public override IEnumerable EnumerateFileSystemInfos() + { + if (_directoryInfo.Exists) + { + IEnumerable fileSystemInfos; + try + { + fileSystemInfos = _directoryInfo.EnumerateFileSystemInfos("*", SearchOption.TopDirectoryOnly); + } + catch (DirectoryNotFoundException) + { + yield break; + } + + foreach (var fileSystemInfo in fileSystemInfos) + { + yield return fileSystemInfo switch + { + IDirectoryInfo directoryInfo => new DirectoryInfoGlobbingWrapper(_fileSystem, directoryInfo), + IFileInfo fileInfo => new FileInfoGlobbingWrapper(_fileSystem, fileInfo), + _ => throw new NotSupportedException() + }; + } + } + } + + /// + public override Microsoft.Extensions.FileSystemGlobbing.Abstractions.DirectoryInfoBase? GetDirectory(string path) + { + var isParentPath = string.Equals(path, "..", StringComparison.Ordinal); + + if (isParentPath) + { + return new DirectoryInfoGlobbingWrapper(_fileSystem, + _fileSystem.DirectoryInfo.New(Path.Combine(_directoryInfo.FullName, path)), isParentPath); + } + + var dirs = _directoryInfo.GetDirectories(path); + + return dirs switch + { + { Length: 1 } + => new DirectoryInfoGlobbingWrapper(_fileSystem, dirs[0], isParentPath), + { Length: 0 } => null, + // This shouldn't happen. The parameter name isn't supposed to contain wild card. + _ + => throw new InvalidOperationException( + $"More than one sub directories are found under {_directoryInfo.FullName} with name {path}." + ), + }; + } + + /// + public override Microsoft.Extensions.FileSystemGlobbing.Abstractions.FileInfoBase GetFile(string path) + { + return new FileInfoGlobbingWrapper(_fileSystem, _fileSystem.FileInfo.New(Path.Combine(FullName, path))); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/FileInfoGlobbingWrapper.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/FileInfoGlobbingWrapper.cs new file mode 100644 index 0000000..744c377 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/FileInfoGlobbingWrapper.cs @@ -0,0 +1,35 @@ +using System.IO.Abstractions; + +namespace PG.StarWarsGame.Engine.Utilities; + +// Taken from https://github.com/vipentti/Vipentti.IO.Abstractions.FileSystemGlobbing + +internal sealed class FileInfoGlobbingWrapper + : Microsoft.Extensions.FileSystemGlobbing.Abstractions.FileInfoBase +{ + private readonly IFileSystem _fileSystem; + private readonly IFileInfo _fileInfo; + + /// + public override string Name => _fileInfo.Name; + + /// + public override string FullName => _fileInfo.FullName; + + /// + public override Microsoft.Extensions.FileSystemGlobbing.Abstractions.DirectoryInfoBase? ParentDirectory => + _fileInfo.Directory is null + ? null + : new DirectoryInfoGlobbingWrapper(_fileSystem, _fileInfo.Directory); + + /// + /// Initialize a new instance + /// + /// The filesystem + /// The file + public FileInfoGlobbingWrapper(IFileSystem fileSystem, IFileInfo fileInfo) + { + _fileSystem = fileSystem; + _fileInfo = fileInfo; + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/MatcherExtensions.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/MatcherExtensions.cs new file mode 100644 index 0000000..e2bde1e --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/MatcherExtensions.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.IO.Abstractions; +using System.Linq; +using Microsoft.Extensions.FileSystemGlobbing; + +namespace PG.StarWarsGame.Engine.Utilities; + +// Taken from https://github.com/vipentti/Vipentti.IO.Abstractions.FileSystemGlobbing + +/// +/// Provides extensions for to support +/// +public static class MatcherExtensions +{ + /// + /// Searches the directory specified for all files matching patterns added to this instance of + /// + /// The matcher + /// The filesystem + /// The root directory for the search + /// Always returns instance of , even if no files were matched + public static PatternMatchingResult Execute(this Matcher matcher, IFileSystem fileSystem, string directoryPath) + { + if (matcher == null) + throw new ArgumentNullException(nameof(matcher)); + if (fileSystem == null) + throw new ArgumentNullException(nameof(fileSystem)); + return Execute(matcher, fileSystem, fileSystem.DirectoryInfo.New(directoryPath)); + } + + /// + public static PatternMatchingResult Execute(this Matcher matcher, IFileSystem fileSystem, IDirectoryInfo directoryInfo) + { + if (matcher == null) + throw new ArgumentNullException(nameof(matcher)); + if (fileSystem == null) + throw new ArgumentNullException(nameof(fileSystem)); + if (directoryInfo == null) + throw new ArgumentNullException(nameof(directoryInfo)); + return matcher.Execute(new DirectoryInfoGlobbingWrapper(fileSystem, directoryInfo)); + } + + /// + /// Searches the directory specified for all files matching patterns added to this instance of + /// + /// The matcher + /// The filesystem + /// The root directory for the search + /// Absolute file paths of all files matched. Empty enumerable if no files matched given patterns. + public static IEnumerable GetResultsInFullPath(this Matcher matcher, IFileSystem fileSystem, string directoryPath) + { + if (matcher == null) + throw new ArgumentNullException(nameof(matcher)); + if (fileSystem == null) + throw new ArgumentNullException(nameof(fileSystem)); + return GetResultsInFullPath(matcher, fileSystem, fileSystem.DirectoryInfo.New(directoryPath)); + } + + /// + public static IEnumerable GetResultsInFullPath(this Matcher matcher, IFileSystem fileSystem, IDirectoryInfo directoryInfo) + { + var matches = Execute(matcher, fileSystem, directoryInfo); + + if (!matches.HasMatches) + return Enumerable.Empty(); + + var fsPath = fileSystem.Path; + var directoryFullName = directoryInfo.FullName; + + return matches.Files.Select(GetFullPath); + + string GetFullPath(FilePatternMatch match) + { + return fsPath.GetFullPath(fsPath.Combine(directoryFullName, match.Path)); + } + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/StringUtilities.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/StringUtilities.cs deleted file mode 100644 index 6511f68..0000000 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/StringUtilities.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; - -namespace PG.StarWarsGame.Engine.Utilities; - -internal static class StringUtilities -{ - internal static unsafe string Concat(ReadOnlySpan str0, ReadOnlySpan str1, ReadOnlySpan str2) - { - var result = new string('\0', checked(str0.Length + str1.Length + str2.Length)); - fixed (char* resultPtr = result) - { - var resultSpan = new Span(resultPtr, result.Length); - - str0.CopyTo(resultSpan); - resultSpan = resultSpan.Slice(str0.Length); - - str1.CopyTo(resultSpan); - resultSpan = resultSpan.Slice(str1.Length); - - str2.CopyTo(resultSpan); - } - return result; - } -} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs index 6145d3c..e210518 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs @@ -5,7 +5,7 @@ namespace PG.StarWarsGame.Engine.Utilities; // From https://github.com/dotnet/runtime/blob/main/src/libraries/Common/src/System/Text/ValueStringBuilder.cs internal ref struct ValueStringBuilder(Span initialBuffer) { - private Span _chars = initialBuffer; + private readonly Span _chars = initialBuffer; private int _pos = 0; public override string ToString() diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/IPetroglyphXmlFileParserFactory.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/IPetroglyphXmlFileParserFactory.cs index ac389ae..36cb12c 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/IPetroglyphXmlFileParserFactory.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/IPetroglyphXmlFileParserFactory.cs @@ -1,11 +1,9 @@ -using System; +using PG.StarWarsGame.Files.XML.ErrorHandling; using PG.StarWarsGame.Files.XML.Parsers; namespace PG.StarWarsGame.Engine.Xml; public interface IPetroglyphXmlFileParserFactory { - IPetroglyphXmlFileParser GetFileParser(); - - IPetroglyphXmlFileParser GetFileParser(Type type); + IPetroglyphXmlFileParser GetFileParser(IXmlParserErrorListener? listener = null); } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/ParserNotFoundException.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/ParserNotFoundException.cs new file mode 100644 index 0000000..19bc797 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/ParserNotFoundException.cs @@ -0,0 +1,18 @@ +using System; + +namespace PG.StarWarsGame.Engine.Xml; + +public sealed class ParserNotFoundException : Exception +{ + public override string Message { get; } + + public ParserNotFoundException(Type type) + { + Message = $"The parser for the type {type} was not found."; + } + + public ParserNotFoundException(string tag) + { + Message = $"The parser for the tag {tag} was not found."; + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/Data/GameConstantsParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/Data/GameConstantsParser.cs new file mode 100644 index 0000000..212bab1 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/Data/GameConstantsParser.cs @@ -0,0 +1,23 @@ +using System; +using System.Xml.Linq; +using PG.Commons.Hashing; +using PG.StarWarsGame.Engine.DataTypes; +using PG.StarWarsGame.Files.XML; +using PG.StarWarsGame.Files.XML.ErrorHandling; +using PG.StarWarsGame.Files.XML.Parsers; + +namespace PG.StarWarsGame.Engine.Xml.Parsers.Data; + +internal class GameConstantsParser(IServiceProvider serviceProvider, IXmlParserErrorListener? listener = null) : + PetroglyphXmlFileParser(serviceProvider, listener) +{ + public override GameConstants Parse(XElement element) + { + return new GameConstants(); + } + + protected override void Parse(XElement element, IValueListDictionary parsedElements) + { + throw new NotSupportedException(); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/GameObjectParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/Data/GameObjectParser.cs similarity index 76% rename from src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/GameObjectParser.cs rename to src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/Data/GameObjectParser.cs index d3039f5..831cc55 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/GameObjectParser.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/Data/GameObjectParser.cs @@ -1,25 +1,25 @@ using System; -using System.Linq; using System.Xml.Linq; -using Microsoft.Extensions.DependencyInjection; using PG.Commons.Hashing; using PG.StarWarsGame.Engine.DataTypes; using PG.StarWarsGame.Files.XML; +using PG.StarWarsGame.Files.XML.ErrorHandling; using PG.StarWarsGame.Files.XML.Parsers; -using PG.StarWarsGame.Files.XML.Parsers.Primitives; -namespace PG.StarWarsGame.Engine.Xml.Parsers; +namespace PG.StarWarsGame.Engine.Xml.Parsers.Data; -public sealed class GameObjectParser(IServiceProvider serviceProvider) : PetroglyphXmlElementParser(serviceProvider) +public sealed class GameObjectParser( + IReadOnlyValueListDictionary parsedElements, + IServiceProvider serviceProvider, + IXmlParserErrorListener? listener = null) + : XmlObjectParser(parsedElements, serviceProvider, listener) { - private readonly ICrc32HashingService _crc32Hashing = serviceProvider.GetRequiredService(); - protected override IPetroglyphXmlElementParser? GetParser(string tag) { switch (tag) { case "Land_Terrain_Model_Mapping": - return new CommaSeparatedStringKeyValueListParser(ServiceProvider); + return PrimitiveParserProvider.CommaSeparatedStringKeyValueListParser; case "Galactic_Model_Name": case "Destroyed_Galactic_Model_Name": case "Land_Model_Name": @@ -32,21 +32,20 @@ public sealed class GameObjectParser(IServiceProvider serviceProvider) : Petrogl case "Land_Model_Anim_Override_Name": case "xxxSpace_Model_Name": case "Damaged_Smoke_Asset_Name": - return PetroglyphXmlStringParser.Instance; + return PrimitiveParserProvider.StringParser; default: return null; } } - public override GameObject Parse(XElement element) + public override GameObject Parse(XElement element, out Crc32 nameCrc) { - var properties = ToKeyValuePairList(element); + var properties = ParseXmlElement(element); var name = GetNameAttributeValue(element); - var nameCrc = _crc32Hashing.GetCrc32(name, PGConstants.PGCrc32Encoding); + nameCrc = HashingService.GetCrc32Upper(name.AsSpan(), PGConstants.PGCrc32Encoding); var type = GetTagName(element); var objectType = EstimateType(type); - var location = XmlLocationInfo.FromElement(element); - var gameObject = new GameObject(type, name, nameCrc, objectType, properties, location); + var gameObject = new GameObject(type, name, nameCrc, objectType, properties, XmlLocationInfo.FromElement(element)); return gameObject; } @@ -101,15 +100,5 @@ private static GameObjectType EstimateType(string tagName) }; } - public string GetNameAttributeValue(XElement element) - { - var nameAttribute = element.Attributes() - .FirstOrDefault(a => a.Name.LocalName == "Name"); - return nameAttribute is null ? string.Empty : nameAttribute.Value; - } - - public string GetTagName(XElement element) - { - return element.Name.LocalName; - } + public override GameObject Parse(XElement element) => throw new NotSupportedException(); } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/Data/SfxEventParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/Data/SfxEventParser.cs new file mode 100644 index 0000000..ffb7e18 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/Data/SfxEventParser.cs @@ -0,0 +1,123 @@ +using System; +using System.Xml.Linq; +using PG.Commons.Hashing; +using PG.StarWarsGame.Engine.DataTypes; +using PG.StarWarsGame.Engine.Xml.Tags; +using PG.StarWarsGame.Files.XML; +using PG.StarWarsGame.Files.XML.ErrorHandling; +using PG.StarWarsGame.Files.XML.Parsers; + +namespace PG.StarWarsGame.Engine.Xml.Parsers.Data; + +public sealed class SfxEventParser( + IReadOnlyValueListDictionary parsedElements, + IServiceProvider serviceProvider, + IXmlParserErrorListener? listener = null) + : XmlObjectParser(parsedElements, serviceProvider, listener) +{ + protected override IPetroglyphXmlElementParser? GetParser(string tag) + { + switch (tag) + { + case SfxEventXmlTags.UsePreset: + case SfxEventXmlTags.OverlapTest: + case SfxEventXmlTags.ChainedSfxEvent: + return PrimitiveParserProvider.StringParser; + case SfxEventXmlTags.IsPreset: + case SfxEventXmlTags.Is3D: + case SfxEventXmlTags.Is2D: + case SfxEventXmlTags.IsGui: + case SfxEventXmlTags.IsHudVo: + case SfxEventXmlTags.IsUnitResponseVo: + case SfxEventXmlTags.IsAmbientVo: + case SfxEventXmlTags.Localize: + case SfxEventXmlTags.PlaySequentially: + case SfxEventXmlTags.KillsPreviousObjectSFX: + return PrimitiveParserProvider.BooleanParser; + case SfxEventXmlTags.Samples: + case SfxEventXmlTags.PreSamples: + case SfxEventXmlTags.PostSamples: + case SfxEventXmlTags.TextID: + return PrimitiveParserProvider.LooseStringListParser; + case SfxEventXmlTags.Priority: + case SfxEventXmlTags.MinPitch: + case SfxEventXmlTags.MaxPitch: + case SfxEventXmlTags.MinPan2D: + case SfxEventXmlTags.MaxPan2D: + case SfxEventXmlTags.PlayCount: + case SfxEventXmlTags.MaxInstances: + return PrimitiveParserProvider.IntParser; + case SfxEventXmlTags.Probability: + case SfxEventXmlTags.MinVolume: + case SfxEventXmlTags.MaxVolume: + return PrimitiveParserProvider.Max100ByteParser; + case SfxEventXmlTags.MinPredelay: + case SfxEventXmlTags.MaxPredelay: + case SfxEventXmlTags.MinPostdelay: + case SfxEventXmlTags.MaxPostdelay: + return PrimitiveParserProvider.UIntParser; + case SfxEventXmlTags.LoopFadeInSeconds: + case SfxEventXmlTags.LoopFadeOutSeconds: + case SfxEventXmlTags.VolumeSaturationDistance: + return PrimitiveParserProvider.FloatParser; + default: + return null; + } + } + + public override SfxEvent Parse(XElement element, out Crc32 nameCrc) + { + var name = GetNameAttributeValue(element); + nameCrc = HashingService.GetCrc32Upper(name.AsSpan(), PGConstants.PGCrc32Encoding); + + var properties = ParseXmlElement(element); + + return new SfxEvent(name, nameCrc, properties, XmlLocationInfo.FromElement(element)); + } + + protected override bool OnParsed(XElement element, string tag, object value, ValueListDictionary properties, string? outerElementName) + { + if (tag == SfxEventXmlTags.UsePreset) + { + var presetName = value as string; + var presetNameCrc = HashingService.GetCrc32Upper(presetName.AsSpan(), PGConstants.PGCrc32Encoding); + if (presetNameCrc != default && ParsedElements.TryGetFirstValue(presetNameCrc, out var preset)) + CopySfxPreset(properties, preset); + else + { + var location = XmlLocationInfo.FromElement(element); + OnParseError(new XmlParseErrorEventArgs(location.XmlFile, element, XmlParseErrorKind.MissingReference, + $"Cannot to find preset '{presetName}' for SFXEvent '{outerElementName ?? "NONE"}'")); + } + } + return true; + } + + private static void CopySfxPreset(ValueListDictionary currentXmlProperties, SfxEvent preset) + { + /* + * The engine also copies the Use_Preset *of* the preset, (which almost most cases is null) + * As this would cause that the SfxEvent using the preset, would not have a reference to its original preset, we do not copy the preset + * Example: + * + * + * Preset Yes + * 90 + * + * + * Engine Behavior: SFXEvent instance(Name: A, Use_Preset: null, Min_Volume: 90) + * PG.StarWarsGame.Engine Behavior: SFXEvent instance(Name: A, Use_Preset: Preset, Min_Volume: 90) + */ + + foreach (var keyValuePair in preset.XmlProperties) + { + if (keyValuePair.Key is SfxEventXmlTags.UsePreset or SfxEventXmlTags.IsPreset) + continue; + currentXmlProperties.Add(keyValuePair.Key, keyValuePair.Value); + } + + currentXmlProperties.Add(SfxEventXmlTags.PresetXRef, preset); + } + + public override SfxEvent Parse(XElement element) => throw new NotSupportedException(); +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/File/GameObjectFileFileParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/File/GameObjectFileFileParser.cs new file mode 100644 index 0000000..4eaedc7 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/File/GameObjectFileFileParser.cs @@ -0,0 +1,32 @@ +using System; +using System.Xml.Linq; +using PG.Commons.Hashing; +using PG.StarWarsGame.Engine.DataTypes; +using PG.StarWarsGame.Engine.Xml.Parsers.Data; +using PG.StarWarsGame.Files.XML; +using PG.StarWarsGame.Files.XML.ErrorHandling; +using PG.StarWarsGame.Files.XML.Parsers; + +namespace PG.StarWarsGame.Engine.Xml.Parsers.File; + +internal class GameObjectFileFileParser(IServiceProvider serviceProvider, IXmlParserErrorListener? listener = null) + : PetroglyphXmlFileParser(serviceProvider, listener) +{ + private readonly IXmlParserErrorListener? _listener = listener; + + protected override void Parse(XElement element, IValueListDictionary parsedElements) + { + var parser = new GameObjectParser(parsedElements, ServiceProvider, _listener); + + foreach (var xElement in element.Elements()) + { + var gameObject = parser.Parse(xElement, out var nameCrc); + parsedElements.Add(nameCrc, gameObject); + } + } + + public override GameObject Parse(XElement element) + { + throw new NotSupportedException(); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/File/SfxEventFileParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/File/SfxEventFileParser.cs new file mode 100644 index 0000000..7edc637 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/File/SfxEventFileParser.cs @@ -0,0 +1,53 @@ +using System; +using System.Xml.Linq; +using Microsoft.Extensions.Logging; +using PG.Commons.Hashing; +using PG.StarWarsGame.Engine.DataTypes; +using PG.StarWarsGame.Engine.Xml.Parsers.Data; +using PG.StarWarsGame.Files.XML; +using PG.StarWarsGame.Files.XML.ErrorHandling; +using PG.StarWarsGame.Files.XML.Parsers; + +namespace PG.StarWarsGame.Engine.Xml.Parsers.File; + +internal class SfxEventFileParser(IServiceProvider serviceProvider, IXmlParserErrorListener? listener = null) + : PetroglyphXmlFileParser(serviceProvider, listener) +{ + private readonly IXmlParserErrorListener? _listener = listener; + + protected override void Parse(XElement element, IValueListDictionary parsedElements) + { + var parser = new SfxEventParser(parsedElements, ServiceProvider, _listener); + + if (!element.HasElements) + { + OnParseError(XmlParseErrorEventArgs.FromEmptyRoot(XmlLocationInfo.FromElement(element).XmlFile, element)); + return; + } + + foreach (var xElement in element.Elements()) + { + var sfxEvent = parser.Parse(xElement, out var nameCrc); + if (nameCrc == default) + { + var location = XmlLocationInfo.FromElement(xElement); + OnParseError(new XmlParseErrorEventArgs(location.XmlFile, xElement, XmlParseErrorKind.MissingAttribute, + $"SFXEvent has no name at location '{location}'")); + } + + parsedElements.Add(nameCrc, sfxEvent); + } + + } + + protected override void OnParseError(XmlParseErrorEventArgs e) + { + Logger?.LogWarning($"Error while parsing {e.File}: {e.Message}"); + base.OnParseError(e); + } + + public override SfxEvent Parse(XElement element) + { + throw new NotSupportedException(); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/GameConstantsFileParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/GameConstantsFileParser.cs deleted file mode 100644 index ac20c1c..0000000 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/GameConstantsFileParser.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using System.Xml.Linq; -using PG.StarWarsGame.Engine.DataTypes; -using PG.StarWarsGame.Files.XML.Parsers; - -namespace PG.StarWarsGame.Engine.Xml.Parsers; - -public class GameConstantsFileParser(IServiceProvider serviceProvider) : PetroglyphXmlFileParser(serviceProvider) -{ - public override GameConstants Parse(XElement element) - { - return new GameConstants(); - } -} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/GameObjectFileFileParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/GameObjectFileFileParser.cs deleted file mode 100644 index 3bb7e0a..0000000 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/GameObjectFileFileParser.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Xml.Linq; -using PG.StarWarsGame.Engine.DataTypes; -using PG.StarWarsGame.Files.XML.Parsers; - -namespace PG.StarWarsGame.Engine.Xml.Parsers; - -public class GameObjectFileFileParser(IServiceProvider serviceProvider) : PetroglyphXmlFileParser>(serviceProvider) -{ - protected override bool LoadLineInfo => true; - - public override IList Parse(XElement element) - { - var parser = new GameObjectParser(ServiceProvider); - return element.Elements().Select(parser.Parse).ToList(); - } -} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/XmlObjectParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/XmlObjectParser.cs new file mode 100644 index 0000000..5c3142c --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Parsers/XmlObjectParser.cs @@ -0,0 +1,52 @@ +using System; +using System.Xml.Linq; +using Microsoft.Extensions.DependencyInjection; +using PG.Commons.Hashing; +using PG.StarWarsGame.Engine.DataTypes; +using PG.StarWarsGame.Files.XML; +using PG.StarWarsGame.Files.XML.ErrorHandling; +using PG.StarWarsGame.Files.XML.Parsers; + +namespace PG.StarWarsGame.Engine.Xml.Parsers; + +public abstract class XmlObjectParser(IReadOnlyValueListDictionary parsedElements, IServiceProvider serviceProvider, IXmlParserErrorListener? listener = null) + : PetroglyphXmlElementParser(serviceProvider, listener) where T : XmlObject +{ + protected IReadOnlyValueListDictionary ParsedElements { get; } = parsedElements ?? throw new ArgumentNullException(nameof(parsedElements)); + + protected ICrc32HashingService HashingService { get; } = serviceProvider.GetRequiredService(); + + public abstract T Parse(XElement element, out Crc32 nameCrc); + + protected abstract IPetroglyphXmlElementParser? GetParser(string tag); + + protected ValueListDictionary ParseXmlElement(XElement element, string? name = null) + { + var xmlProperties = new ValueListDictionary(); + foreach (var elm in element.Elements()) + { + var tagName = elm.Name.LocalName; + + var parser = GetParser(tagName); + + if (parser is null) + { + // TODO + //var nameOrPosition = name ?? XmlLocationInfo.FromElement(element).ToString(); + //Logger?.LogWarning($"Unable to find parser for tag '{tagName}' in element '{nameOrPosition}'"); + continue; + } + + var value = parser.Parse(elm); + + if (OnParsed(elm, tagName, value, xmlProperties, name)) + xmlProperties.Add(tagName, value); + } + return xmlProperties; + } + + protected virtual bool OnParsed(XElement element, string tag, object value, ValueListDictionary properties, string? outerElementName) + { + return true; + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/PetroglyphXmlParserFactory.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/PetroglyphXmlParserFactory.cs index a29f79a..7a283e5 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/PetroglyphXmlParserFactory.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/PetroglyphXmlParserFactory.cs @@ -1,8 +1,9 @@ using System; -using System.Collections.Generic; using PG.StarWarsGame.Engine.DataTypes; -using PG.StarWarsGame.Engine.Xml.Parsers; +using PG.StarWarsGame.Engine.Xml.Parsers.Data; +using PG.StarWarsGame.Engine.Xml.Parsers.File; using PG.StarWarsGame.Files.XML; +using PG.StarWarsGame.Files.XML.ErrorHandling; using PG.StarWarsGame.Files.XML.Parsers; using PG.StarWarsGame.Files.XML.Parsers.Primitives; @@ -10,22 +11,25 @@ namespace PG.StarWarsGame.Engine.Xml; internal sealed class PetroglyphXmlFileParserFactory(IServiceProvider serviceProvider) : IPetroglyphXmlFileParserFactory { - public IPetroglyphXmlFileParser GetFileParser() + public IPetroglyphXmlFileParser GetFileParser(IXmlParserErrorListener? listener = null) { - return (IPetroglyphXmlFileParser)GetFileParser(typeof(T)); + return (IPetroglyphXmlFileParser)GetFileParser(typeof(T), listener); } - public IPetroglyphXmlFileParser GetFileParser(Type type) + private IPetroglyphXmlFileParser GetFileParser(Type type, IXmlParserErrorListener? listener) { if (type == typeof(XmlFileContainer)) - return new XmlFileContainerParser(serviceProvider); + return new XmlFileContainerParser(serviceProvider, listener); if (type == typeof(GameConstants)) - return new GameConstantsFileParser(serviceProvider); + return new GameConstantsParser(serviceProvider, listener); - if (type == typeof(IList)) - return new GameObjectFileFileParser(serviceProvider); + if (type == typeof(GameObject)) + return new GameObjectFileFileParser(serviceProvider, listener); - throw new NotImplementedException($"The parser for the type {type} is not yet implemented."); + if (type == typeof(SfxEvent)) + return new SfxEventFileParser(serviceProvider, listener); + + throw new ParserNotFoundException(type); } } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Tags/SfxEventXmlTags.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Tags/SfxEventXmlTags.cs new file mode 100644 index 0000000..959da6b --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/Tags/SfxEventXmlTags.cs @@ -0,0 +1,41 @@ +namespace PG.StarWarsGame.Engine.Xml.Tags; + +public static class SfxEventXmlTags +{ + internal const string PresetXRef = "XREF_PRESET"; + + public const string IsPreset = "Is_Preset"; + public const string UsePreset = "Use_Preset"; + public const string Samples = "Samples"; + public const string PreSamples = "Pre_Samples"; + public const string PostSamples = "Post_Samples"; + public const string TextID = "Text_ID"; + public const string PlaySequentially = "Play_Sequentially"; + public const string Priority = "Priority"; + public const string Probability = "Probability"; + public const string PlayCount = "Play_Count"; + public const string LoopFadeInSeconds = "Loop_Fade_In_Seconds"; + public const string LoopFadeOutSeconds = "Loop_Fade_Out_Seconds"; + public const string MaxInstances = "Max_Instances"; + public const string MinVolume = "Min_Volume"; + public const string MaxVolume = "Max_Volume"; + public const string MinPitch = "Min_Pitch"; + public const string MaxPitch = "Max_Pitch"; + public const string MinPan2D = "Min_Pan2D"; + public const string MaxPan2D = "Max_Pan2D"; + public const string MinPredelay = "Min_Predelay"; + public const string MaxPredelay = "Max_Predelay"; + public const string MinPostdelay = "Min_Postdelay"; + public const string MaxPostdelay = "Max_Postdelay"; + public const string VolumeSaturationDistance = "Volume_Saturation_Distance"; + public const string KillsPreviousObjectSFX = "Kills_Previous_Object_SFX"; + public const string OverlapTest = "Overlap_Test"; + public const string Localize = "Localize"; + public const string Is2D = "Is_2D"; + public const string Is3D = "Is_3D"; + public const string IsGui = "Is_GUI"; + public const string IsHudVo = "Is_HUD_VO"; + public const string IsUnitResponseVo = "Is_Unit_Response_VO"; + public const string IsAmbientVo = "Is_Ambient_VO"; + public const string ChainedSfxEvent = "Chained_SFXEvent"; +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/ParticleReaderV1.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/ParticleReaderV1.cs index a2436c2..1f487f7 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/ParticleReaderV1.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/ParticleReaderV1.cs @@ -1,6 +1,11 @@ -using System.IO; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using PG.Commons.Binary; using PG.StarWarsGame.Files.ALO.Data; using PG.StarWarsGame.Files.ALO.Services; +using PG.StarWarsGame.Files.ChunkFiles.Binary.Metadata; namespace PG.StarWarsGame.Files.ALO.Binary.Reader; @@ -8,6 +13,116 @@ internal class ParticleReaderV1(AloLoadOptions loadOptions, Stream stream) : Alo { public override AlamoParticle Read() { - return new AlamoParticle(); + var textures = new HashSet(StringComparer.OrdinalIgnoreCase); + + string? name = null; + + var rootChunk = ChunkReader.ReadChunk(); + + if (rootChunk.Type != (int)ChunkType.Particle) + throw new NotSupportedException("This reader only support V1 particles."); + + var actualSize = 0; + + do + { + var chunk = ChunkReader.ReadChunk(ref actualSize); + + switch (chunk.Type) + { + case (int)ChunkType.Name: + ReadName(chunk.Size, out name); + break; + case (int)ChunkType.Emitters: + ReadEmitters(chunk.Size, textures); + break; + default: + ChunkReader.Skip(chunk.Size); + break; + } + + actualSize += chunk.Size; + + } while (actualSize < rootChunk.Size); + + + + if (actualSize != rootChunk.Size) + throw new BinaryCorruptedException(); + + if (string.IsNullOrEmpty(name)) + throw new BinaryCorruptedException("The particle does not contain a name."); + + return new AlamoParticle + { + Name = name!, + Textures = textures, + }; + } + + private void ReadEmitters(int size, HashSet textures) + { + var actualSize = 0; + + do + { + var chunk = ChunkReader.ReadChunk(ref actualSize); + + if (chunk.Type != (int)ChunkType.Emitter) + throw new BinaryCorruptedException("Unable to read particle"); + + ReadEmitter(chunk.Size, textures); + + actualSize += chunk.Size; + + + } while (actualSize < size); + + if (size != actualSize) + throw new BinaryCorruptedException("Unable to read particle."); + } + + private void ReadEmitter(int chunkSize, HashSet textures) + { + var actualSize = 0; + + do + { + var chunk = ChunkReader.ReadChunk(ref actualSize); + + if (chunk.Type == (int)ChunkType.Properties) + { + var shader = ChunkReader.ReadDword(); + ChunkReader.Skip(chunk.Size - sizeof(uint)); + } + else if (chunk.Type == (int)ChunkType.ColorTextureName) + { + var texture = ChunkReader.ReadString(chunk.Size, Encoding.ASCII, true); + textures.Add(texture); + } + else if (chunk.Type == (int)ChunkType.BumpTextureName) + { + var bump = ChunkReader.ReadString(chunk.Size, Encoding.ASCII, true); + textures.Add(bump); + } + else + { + ChunkReader.Skip(chunk.Size); + } + + actualSize += chunk.Size; + + } while (actualSize < chunkSize); + + if (actualSize != chunkSize) + throw new BinaryCorruptedException("Unable to read particle"); + } + + private void ReadName(int size, out string name) + { + var actualSize = 0; + name = ChunkReader.ReadString(size, Encoding.ASCII, true, ref actualSize); + if (size != actualSize) + throw new BinaryCorruptedException("Unable to read alo particle."); } } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/ParticleReaderV2.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/ParticleReaderV2.cs index d311417..fc79903 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/ParticleReaderV2.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/ParticleReaderV2.cs @@ -1,4 +1,5 @@ -using System.IO; +using System; +using System.IO; using PG.StarWarsGame.Files.ALO.Data; using PG.StarWarsGame.Files.ALO.Services; @@ -8,6 +9,6 @@ internal class ParticleReaderV2(AloLoadOptions loadOptions, Stream stream) : Alo { public override AlamoParticle Read() { - return new AlamoParticle(); + throw new NotImplementedException(); } } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Data/AlamoParticle.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Data/AlamoParticle.cs index 6fadab9..b2d94cb 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Data/AlamoParticle.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Data/AlamoParticle.cs @@ -1,7 +1,13 @@ -namespace PG.StarWarsGame.Files.ALO.Data; +using System.Collections.Generic; + +namespace PG.StarWarsGame.Files.ALO.Data; public class AlamoParticle : IAloDataContent { + public required string Name { get; init; } + + public ISet Textures { get; init; } + public void Dispose() { } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Metadata/ChunkType.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Metadata/ChunkType.cs index 145a0f7..9b34a66 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Metadata/ChunkType.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Metadata/ChunkType.cs @@ -3,6 +3,12 @@ public enum ChunkType { Unknown, + Name = 0x0, + Id = 0x1, + Persistance = 0x2, + Properties = 0x2, + ColorTextureName = 0x3, + BumpTextureName = 0x45, Skeleton = 0x200, BoneCount = 0x201, Bone = 0x202, @@ -10,14 +16,16 @@ public enum ChunkType Mesh = 0x400, MeshName = 0x401, MeshInformation = 0x402, - Light = 0x1300, Connections = 0x600, ConnectionCounts = 0x601, ObjectConnection = 0x602, ProxyConnection = 0x603, Dazzle = 0x604, + Emitter = 0x700, + Emitters = 0x800, Particle = 0x900, Animation = 0x1000, + Light = 0x1300, ParticleUaW = 0x1500, SubMeshData = 0x00010000, SubMeshMaterialInformation = 0x00010100, diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Reader/ChunkReader.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Reader/ChunkReader.cs index 4097792..60105e5 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Reader/ChunkReader.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Reader/ChunkReader.cs @@ -61,6 +61,11 @@ public uint ReadDword(ref int readSize) return value; } + public uint ReadDword() + { + return _binaryReader.ReadUInt32(); + } + public void Skip(int bytesToSkip, ref int readBytes) { _binaryReader.BaseStream.Seek(bytesToSkip, SeekOrigin.Current); @@ -79,13 +84,27 @@ public string ReadString(int size, Encoding encoding, bool zeroTerminated, ref i return value; } + public string ReadString(int size, Encoding encoding, bool zeroTerminated) + { + var value = _binaryReader.ReadString(size, encoding, zeroTerminated); + return value; + } + public ChunkMetadata? TryReadChunk() + { + var _ = 0; + return TryReadChunk(ref _); + } + + public ChunkMetadata? TryReadChunk(ref int size) { if (_binaryReader.BaseStream.Position == _binaryReader.BaseStream.Length) return null; try { - return ReadChunk(); + var chunk = ReadChunk(); + size += 8; + return chunk; } catch (EndOfStreamException e) { diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/IPrimitiveXmlErrorParserProvider.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/IPrimitiveXmlErrorParserProvider.cs new file mode 100644 index 0000000..3ed446e --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/IPrimitiveXmlErrorParserProvider.cs @@ -0,0 +1,3 @@ +namespace PG.StarWarsGame.Files.XML.ErrorHandling; + +public interface IPrimitiveXmlErrorParserProvider : IXmlParserErrorProvider; \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/IPrimitiveXmlParserErrorListener.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/IPrimitiveXmlParserErrorListener.cs new file mode 100644 index 0000000..5157546 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/IPrimitiveXmlParserErrorListener.cs @@ -0,0 +1,3 @@ +namespace PG.StarWarsGame.Files.XML.ErrorHandling; + +internal interface IPrimitiveXmlParserErrorListener : IXmlParserErrorListener, IPrimitiveXmlErrorParserProvider; \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/IXmlParserErrorListener.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/IXmlParserErrorListener.cs new file mode 100644 index 0000000..59b395d --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/IXmlParserErrorListener.cs @@ -0,0 +1,8 @@ +using PG.StarWarsGame.Files.XML.Parsers; + +namespace PG.StarWarsGame.Files.XML.ErrorHandling; + +public interface IXmlParserErrorListener +{ + public void OnXmlParseError(IPetroglyphXmlParser parser, XmlParseErrorEventArgs error); +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/IXmlParserErrorProvider.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/IXmlParserErrorProvider.cs new file mode 100644 index 0000000..2470187 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/IXmlParserErrorProvider.cs @@ -0,0 +1,6 @@ +namespace PG.StarWarsGame.Files.XML.ErrorHandling; + +public interface IXmlParserErrorProvider +{ + event XmlErrorEventHandler XmlParseError; +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/PrimitiveXmlParserErrorBroker.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/PrimitiveXmlParserErrorBroker.cs new file mode 100644 index 0000000..c66ba6b --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/PrimitiveXmlParserErrorBroker.cs @@ -0,0 +1,13 @@ +using PG.StarWarsGame.Files.XML.Parsers; + +namespace PG.StarWarsGame.Files.XML.ErrorHandling; + +internal class PrimitiveXmlParserErrorBroker : IPrimitiveXmlParserErrorListener +{ + public event XmlErrorEventHandler? XmlParseError; + + public void OnXmlParseError(IPetroglyphXmlParser parser, XmlParseErrorEventArgs error) + { + XmlParseError?.Invoke(parser, error); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/XmlErrorEventHandler.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/XmlErrorEventHandler.cs new file mode 100644 index 0000000..69962e3 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/XmlErrorEventHandler.cs @@ -0,0 +1,5 @@ +using PG.StarWarsGame.Files.XML.Parsers; + +namespace PG.StarWarsGame.Files.XML.ErrorHandling; + +public delegate void XmlErrorEventHandler(IPetroglyphXmlParser parser, XmlParseErrorEventArgs error); \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/XmlParseErrorEventArgs.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/XmlParseErrorEventArgs.cs new file mode 100644 index 0000000..1ea7d70 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/XmlParseErrorEventArgs.cs @@ -0,0 +1,37 @@ +using System; +using System.Xml.Linq; +using AnakinRaW.CommonUtilities; + +namespace PG.StarWarsGame.Files.XML.ErrorHandling; + +public class XmlParseErrorEventArgs : EventArgs +{ + public string File { get; } + + public XElement? Element { get; } + + public XmlParseErrorKind ErrorKind { get; } + + public string Message { get; } + + public XmlParseErrorEventArgs(string file, XElement? element, XmlParseErrorKind errorKind, string message) + { + ThrowHelper.ThrowIfNullOrEmpty(file); + File = file; + Element = element; + ErrorKind = errorKind; + Message = message; + } + + public static XmlParseErrorEventArgs FromMissingFile(string file) + { + ThrowHelper.ThrowIfNullOrEmpty(file); + return new XmlParseErrorEventArgs(file, null, XmlParseErrorKind.MissingFile, $"XML file '{file}' not found."); + } + + public static XmlParseErrorEventArgs FromEmptyRoot(string file, XElement element) + { + ThrowHelper.ThrowIfNullOrEmpty(file); + return new XmlParseErrorEventArgs(file, element, XmlParseErrorKind.EmptyRoot, $"XML file '{file}' has an empty root node."); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/XmlParseErrorKind.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/XmlParseErrorKind.cs new file mode 100644 index 0000000..ea329d9 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ErrorHandling/XmlParseErrorKind.cs @@ -0,0 +1,42 @@ +namespace PG.StarWarsGame.Files.XML.ErrorHandling; + +public enum XmlParseErrorKind +{ + /// + /// The error not specified any further. + /// + Unknown = 0, + /// + /// The XML file could not be found. + /// + MissingFile = 1, + /// + /// The root node of an XML file is empty. + /// + EmptyRoot = 2, + /// + /// A tag's value is syntactically correct, but the semantics of value are not valid. For example, + /// when the input is '-1' but an uint type is expected. + /// + InvalidValue = 3, + /// + /// A tag's value is has an invalid syntax. + /// + MalformedValue = 4, + /// + /// The value is too long + /// + TooLongData = 5, + /// + /// The data is missing an XML attribute. Usually this is the Name attribute. + /// + MissingAttribute = 6, + /// + /// The data points to a non-existing reference. + /// + MissingReference = 7, + /// + /// The XML file does not start with the XML header. + /// + DataBeforeHeader = 8, +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/PG.StarWarsGame.Files.XML.csproj.DotSettings b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/PG.StarWarsGame.Files.XML.csproj.DotSettings new file mode 100644 index 0000000..2dedfd6 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/PG.StarWarsGame.Files.XML.csproj.DotSettings @@ -0,0 +1,6 @@ + + False + True + True + True + True \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/IPetroglyphXmlElementParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/IPetroglyphXmlElementParser.cs index a26a06b..e699b80 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/IPetroglyphXmlElementParser.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/IPetroglyphXmlElementParser.cs @@ -1,13 +1,5 @@ -using System.Xml.Linq; +namespace PG.StarWarsGame.Files.XML.Parsers; -namespace PG.StarWarsGame.Files.XML.Parsers; +public interface IPetroglyphXmlElementParser : IPetroglyphXmlParser; -public interface IPetroglyphXmlElementParser -{ - public object? Parse(XElement element); -} - -public interface IPetroglyphXmlElementParser : IPetroglyphXmlElementParser -{ - public new T Parse(XElement element); -} \ No newline at end of file +public interface IPetroglyphXmlElementParser : IPetroglyphXmlElementParser, IPetroglyphXmlParser; \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/IPetroglyphXmlFileParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/IPetroglyphXmlFileParser.cs index b958e1f..2c81137 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/IPetroglyphXmlFileParser.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/IPetroglyphXmlFileParser.cs @@ -1,13 +1,16 @@ using System.IO; +using PG.Commons.Hashing; namespace PG.StarWarsGame.Files.XML.Parsers; -public interface IPetroglyphXmlFileParser : IPetroglyphXmlElementParser +public interface IPetroglyphXmlFileParser : IPetroglyphXmlParser { public object? ParseFile(Stream stream); } -public interface IPetroglyphXmlFileParser : IPetroglyphXmlElementParser, IPetroglyphXmlFileParser +public interface IPetroglyphXmlFileParser : IPetroglyphXmlParser, IPetroglyphXmlFileParser { public new T ParseFile(Stream stream); + + public void ParseFile(Stream stream, IValueListDictionary parsedEntries); } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/IPetroglyphXmlParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/IPetroglyphXmlParser.cs new file mode 100644 index 0000000..220fa05 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/IPetroglyphXmlParser.cs @@ -0,0 +1,13 @@ +using System.Xml.Linq; + +namespace PG.StarWarsGame.Files.XML.Parsers; + +public interface IPetroglyphXmlParser +{ + object Parse(XElement element); +} + +public interface IPetroglyphXmlParser : IPetroglyphXmlParser +{ + new T Parse(XElement element); +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/PetroglyphXmlElementParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/PetroglyphXmlElementParser.cs index 850bf26..53ce6cb 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/PetroglyphXmlElementParser.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/PetroglyphXmlElementParser.cs @@ -1,41 +1,22 @@ using System; +using System.Linq; using System.Xml.Linq; -using PG.StarWarsGame.Files.XML.Parsers.Primitives; +using PG.StarWarsGame.Files.XML.ErrorHandling; namespace PG.StarWarsGame.Files.XML.Parsers; -public abstract class PetroglyphXmlElementParser(IServiceProvider serviceProvider) : IPetroglyphXmlElementParser +public abstract class PetroglyphXmlElementParser(IServiceProvider serviceProvider, IXmlParserErrorListener? listener = null) + : PetroglyphXmlParser(serviceProvider, listener) { - protected IServiceProvider ServiceProvider { get; } = serviceProvider; - - protected virtual IPetroglyphXmlElementParser? GetParser(string tag) - { - return PetroglyphXmlStringParser.Instance; - } - - public abstract T Parse(XElement element); - - public ValueListDictionary ToKeyValuePairList(XElement element) + protected string GetTagName(XElement element) { - var keyValuePairList = new ValueListDictionary(); - foreach (var elm in element.Elements()) - { - var tagName = elm.Name.LocalName; - - var parser = GetParser(tagName); - - if (parser is not null) - { - var value = parser.Parse(elm); - keyValuePairList.Add(tagName, value); - } - } - - return keyValuePairList; + return element.Name.LocalName; } - object? IPetroglyphXmlElementParser.Parse(XElement element) + protected string GetNameAttributeValue(XElement element) { - return Parse(element); + var nameAttribute = element.Attributes() + .FirstOrDefault(a => a.Name.LocalName == "Name"); + return nameAttribute is null ? string.Empty : nameAttribute.Value; } } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/PetroglyphXmlFileParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/PetroglyphXmlFileParser.cs index 16e5778..f9b64f9 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/PetroglyphXmlFileParser.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/PetroglyphXmlFileParser.cs @@ -1,30 +1,99 @@ using System; using System.IO; +using System.IO.Abstractions; +using System.Text; using System.Xml; using System.Xml.Linq; +using AnakinRaW.CommonUtilities.FileSystem; +using Microsoft.Extensions.DependencyInjection; +using PG.Commons.Hashing; using PG.Commons.Utilities; +using PG.StarWarsGame.Files.XML.ErrorHandling; namespace PG.StarWarsGame.Files.XML.Parsers; -public abstract class PetroglyphXmlFileParser(IServiceProvider serviceProvider) : PetroglyphXmlElementParser(serviceProvider), IPetroglyphXmlFileParser +public abstract class PetroglyphXmlFileParser(IServiceProvider serviceProvider, IXmlParserErrorListener? listener = null) : + PetroglyphXmlParser(serviceProvider, listener), IPetroglyphXmlFileParser { - protected virtual bool LoadLineInfo => false; + private readonly IXmlParserErrorListener? _listener = listener; + private readonly IFileSystem _fileSystem = serviceProvider.GetRequiredService(); + + protected virtual bool LoadLineInfo => true; + public T ParseFile(Stream xmlStream) { - var fileName = xmlStream.GetFilePath(); - var xmlReader = XmlReader.Create(xmlStream, new XmlReaderSettings(), fileName); + var root = GetRootElement(xmlStream); + return root is null ? default! : Parse(root); + } + + public void ParseFile(Stream xmlStream, IValueListDictionary parsedEntries) + { + var root = GetRootElement(xmlStream); + if (root is not null) + Parse(root, parsedEntries); + } + + protected abstract void Parse(XElement element, IValueListDictionary parsedElements); + + private XElement? GetRootElement(Stream xmlStream) + { + var fileName = GetStrippedFileName(xmlStream.GetFilePath()); + + if (string.IsNullOrEmpty(fileName)) + throw new InvalidOperationException("Unable to parse XML from unnamed stream. Either parse from a file or MEG stream."); + + SkipLeadingWhiteSpace(fileName, xmlStream); + + var xmlReader = XmlReader.Create(xmlStream, new XmlReaderSettings + { + IgnoreWhitespace = true, + IgnoreComments = true, + IgnoreProcessingInstructions = true + }, fileName); var options = LoadOptions.SetBaseUri; if (LoadLineInfo) options |= LoadOptions.SetLineInfo; var doc = XDocument.Load(xmlReader, options); - var root = doc.Root; - if (root is null) - return default!; - return Parse(root); + return doc.Root; + } + + private string GetStrippedFileName(string filePath) + { + if (!_fileSystem.Path.IsPathFullyQualified(filePath)) + return filePath; + + var pathPartIndex = filePath.LastIndexOf("DATA\\XML\\", StringComparison.OrdinalIgnoreCase); + + if (pathPartIndex == -1) + return filePath; + + return filePath.Substring(pathPartIndex); + } + + + private void SkipLeadingWhiteSpace(string fileName, Stream stream) + { + using var r = new StreamReader(stream, Encoding.ASCII, false, 10, true); + var count = 0; + + while (true) + { + var c = (char)r.Read(); + if (!char.IsWhiteSpace(c)) + break; + count++; + } + + if (count != 0) + _listener?.OnXmlParseError(this, new XmlParseErrorEventArgs(fileName, null, + XmlParseErrorKind.DataBeforeHeader, $"XML header is not the first entry of the file '{fileName}'")); + + stream.Position = count; } + object? IPetroglyphXmlFileParser.ParseFile(Stream stream) { return ParseFile(stream); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/PetroglyphXmlParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/PetroglyphXmlParser.cs new file mode 100644 index 0000000..21b4b43 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/PetroglyphXmlParser.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Xml.Linq; +using Microsoft.Extensions.DependencyInjection; +using PG.StarWarsGame.Files.XML.ErrorHandling; +using PG.StarWarsGame.Files.XML.Parsers.Primitives; + +namespace PG.StarWarsGame.Files.XML.Parsers; + +public abstract class PetroglyphXmlParser : IPetroglyphXmlParser +{ + private readonly IXmlParserErrorListener? _errorListener; + + protected IServiceProvider ServiceProvider { get; } + + protected ILogger? Logger { get; } + + protected IPrimitiveParserProvider PrimitiveParserProvider { get; } + + protected PetroglyphXmlParser(IServiceProvider serviceProvider, IXmlParserErrorListener? errorListener = null) + { + ServiceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + Logger = serviceProvider.GetService()?.CreateLogger(GetType()); + PrimitiveParserProvider = serviceProvider.GetRequiredService(); + _errorListener = errorListener; + } + + public abstract T Parse(XElement element); + + protected virtual void OnParseError(XmlParseErrorEventArgs e) + { + _errorListener?.OnXmlParseError(this, e); + } + + object IPetroglyphXmlParser.Parse(XElement element) + { + return Parse(element); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/PetroglyphXmlPrimitiveElementParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/PetroglyphXmlPrimitiveElementParser.cs new file mode 100644 index 0000000..cd97dd8 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/PetroglyphXmlPrimitiveElementParser.cs @@ -0,0 +1,12 @@ +using System; +using PG.StarWarsGame.Files.XML.ErrorHandling; + +namespace PG.StarWarsGame.Files.XML.Parsers; + +public abstract class PetroglyphXmlPrimitiveElementParser : PetroglyphXmlParser, IPetroglyphXmlElementParser +{ + private protected PetroglyphXmlPrimitiveElementParser(IServiceProvider serviceProvider, IPrimitiveXmlParserErrorListener listener) : + base(serviceProvider, listener) + { + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/CommaSeparatedStringKeyValueListParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/CommaSeparatedStringKeyValueListParser.cs index ee1929d..a0418db 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/CommaSeparatedStringKeyValueListParser.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/CommaSeparatedStringKeyValueListParser.cs @@ -1,15 +1,20 @@ using System; using System.Collections.Generic; using System.Xml.Linq; +using PG.StarWarsGame.Files.XML.ErrorHandling; namespace PG.StarWarsGame.Files.XML.Parsers.Primitives; // Used e.g, by // Format: Key, Value, Key, Value // There might be arbitrary spaces, tabs and newlines -public sealed class CommaSeparatedStringKeyValueListParser(IServiceProvider serviceProvider) - : PetroglyphXmlElementParser>(serviceProvider) +// TODO: This class is not yet implemented, compliant to the engine +public sealed class CommaSeparatedStringKeyValueListParser : PetroglyphXmlPrimitiveElementParser> { + internal CommaSeparatedStringKeyValueListParser(IServiceProvider serviceProvider, IPrimitiveXmlParserErrorListener listener) : base(serviceProvider, listener) + { + } + public override IList<(string key, string value)> Parse(XElement element) { var values = element.Value.Split(','); @@ -20,7 +25,7 @@ public sealed class CommaSeparatedStringKeyValueListParser(IServiceProvider serv var keyValueList = new List<(string key, string value)>(values.Length + 1 / 2); - for (int i = 0; i < values.Length; i += 2) + for (var i = 0; i < values.Length; i += 2) { // Case: Incomplete key-value pair if (values.Length - 1 < i + 1) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/IPrimitiveParserProvider.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/IPrimitiveParserProvider.cs new file mode 100644 index 0000000..3be1a0f --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/IPrimitiveParserProvider.cs @@ -0,0 +1,22 @@ +namespace PG.StarWarsGame.Files.XML.Parsers.Primitives; + +public interface IPrimitiveParserProvider +{ + PetroglyphXmlStringParser StringParser { get; } + + PetroglyphXmlUnsignedIntegerParser UIntParser { get; } + + PetroglyphXmlLooseStringListParser LooseStringListParser { get; } + + PetroglyphXmlIntegerParser IntParser { get; } + + PetroglyphXmlFloatParser FloatParser { get; } + + PetroglyphXmlByteParser ByteParser { get; } + + PetroglyphXmlMax100ByteParser Max100ByteParser { get; } + + PetroglyphXmlBooleanParser BooleanParser { get; } + + CommaSeparatedStringKeyValueListParser CommaSeparatedStringKeyValueListParser { get; } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlBooleanParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlBooleanParser.cs new file mode 100644 index 0000000..f0124a8 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlBooleanParser.cs @@ -0,0 +1,31 @@ +using System; +using System.Xml.Linq; +using PG.StarWarsGame.Files.XML.ErrorHandling; + +namespace PG.StarWarsGame.Files.XML.Parsers.Primitives; + +public sealed class PetroglyphXmlBooleanParser : PetroglyphXmlPrimitiveElementParser +{ + internal PetroglyphXmlBooleanParser(IServiceProvider serviceProvider, IPrimitiveXmlParserErrorListener listener) : base(serviceProvider, listener) + { + } + + public override bool Parse(XElement element) + { + var valueSpan = element.Value.AsSpan(); + var trimmed = valueSpan.Trim(); + + if (trimmed.Length == 0) + return false; + + // Yes! The engine only checks if the values is exact 1 or starts with Tt or Yy + // At least it's efficient, I guess... + if (trimmed.Length == 1 && trimmed[0] == '1') + return true; + + if (trimmed[0] is 'y' or 'Y' or 't' or 'T') + return true; + + return false; + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlByteParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlByteParser.cs new file mode 100644 index 0000000..68d8ec7 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlByteParser.cs @@ -0,0 +1,34 @@ +using System; +using System.Xml.Linq; +using Microsoft.Extensions.Logging; +using PG.StarWarsGame.Files.XML.ErrorHandling; + +namespace PG.StarWarsGame.Files.XML.Parsers.Primitives; + +public sealed class PetroglyphXmlByteParser : PetroglyphXmlPrimitiveElementParser +{ + internal PetroglyphXmlByteParser(IServiceProvider serviceProvider, IPrimitiveXmlParserErrorListener listener) : base(serviceProvider, listener) + { + } + + public override byte Parse(XElement element) + { + var intValue = PrimitiveParserProvider.IntParser.Parse(element); + + var asByte = (byte)intValue; + if (intValue != asByte) + { + var location = XmlLocationInfo.FromElement(element); + OnParseError(new XmlParseErrorEventArgs(location.XmlFile, element, XmlParseErrorKind.InvalidValue, + $"Expected a byte value (0 - 255) but got value '{intValue}' at {location}")); + } + + return asByte; + } + + protected override void OnParseError(XmlParseErrorEventArgs e) + { + Logger?.LogWarning(e.Message); + base.OnParseError(e); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlFloatParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlFloatParser.cs new file mode 100644 index 0000000..6691061 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlFloatParser.cs @@ -0,0 +1,33 @@ +using System; +using System.Globalization; +using System.Xml.Linq; +using Microsoft.Extensions.Logging; +using PG.StarWarsGame.Files.XML.ErrorHandling; + +namespace PG.StarWarsGame.Files.XML.Parsers.Primitives; + +public sealed class PetroglyphXmlFloatParser : PetroglyphXmlPrimitiveElementParser +{ + internal PetroglyphXmlFloatParser(IServiceProvider serviceProvider, IPrimitiveXmlParserErrorListener listener) : base(serviceProvider, listener) + { + } + + public override float Parse(XElement element) + { + // The engine always loads FP numbers a long double and then converts that result to float + if (!double.TryParse(element.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var doubleValue)) + { + var location = XmlLocationInfo.FromElement(element); + OnParseError(new XmlParseErrorEventArgs(location.XmlFile, element, XmlParseErrorKind.MalformedValue, + $"Expected double but got value '{element.Value}' at {location}")); + return 0.0f; + } + return (float)doubleValue; + } + + protected override void OnParseError(XmlParseErrorEventArgs e) + { + Logger?.LogWarning(e.Message); + base.OnParseError(e); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlIntegerParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlIntegerParser.cs new file mode 100644 index 0000000..02400b8 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlIntegerParser.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Xml.Linq; +using PG.StarWarsGame.Files.XML.ErrorHandling; + +namespace PG.StarWarsGame.Files.XML.Parsers.Primitives; + +public sealed class PetroglyphXmlIntegerParser : PetroglyphXmlPrimitiveElementParser +{ + internal PetroglyphXmlIntegerParser(IServiceProvider serviceProvider, IPrimitiveXmlParserErrorListener listener) : base(serviceProvider, listener) + { + } + + public override int Parse(XElement element) + { + // The engines uses the C++ function std::atoi which is a little more loose. + // For example the value '123d' get parsed to 123, + // whereas in C# int.TryParse returns (false, 0) + + if (!int.TryParse(element.Value, out var i)) + { + var location = XmlLocationInfo.FromElement(element); + OnParseError(new XmlParseErrorEventArgs(location.XmlFile, element, XmlParseErrorKind.MalformedValue, + $"Expected integer but got '{element.Value}' at {location}")); + return 0; + } + + return i; + } + + protected override void OnParseError(XmlParseErrorEventArgs e) + { + Logger?.LogWarning(e.Message); + base.OnParseError(e); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlLooseStringListParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlLooseStringListParser.cs new file mode 100644 index 0000000..82fbb38 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlLooseStringListParser.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Xml.Linq; +using PG.StarWarsGame.Files.XML.ErrorHandling; + +namespace PG.StarWarsGame.Files.XML.Parsers.Primitives; + +public sealed class PetroglyphXmlLooseStringListParser : PetroglyphXmlPrimitiveElementParser> +{ + // These are the characters the engine uses as a generic list separator + private static readonly char[] Separators = + [ + ' ', + ',', + '\t', + '\n', + '\r' + ]; + + internal PetroglyphXmlLooseStringListParser(IServiceProvider serviceProvider, IPrimitiveXmlParserErrorListener listener) : base(serviceProvider, listener) + { + } + + public override IList Parse(XElement element) + { + var trimmedValued = element.Value.Trim(); + + if (trimmedValued.Length == 0) + return Array.Empty(); + + if (trimmedValued.Length > 0x2000) + { + var location = XmlLocationInfo.FromElement(element); + OnParseError(new XmlParseErrorEventArgs(location.XmlFile, element, XmlParseErrorKind.TooLongData, + $"Input value is too long '{trimmedValued.Length}' at {XmlLocationInfo.FromElement(element)}")); + + return Array.Empty(); + } + + var entries = trimmedValued.Split(Separators, StringSplitOptions.RemoveEmptyEntries); + + return entries; + } + + protected override void OnParseError(XmlParseErrorEventArgs e) + { + Logger?.LogWarning(e.Message); + base.OnParseError(e); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlMax100ByteParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlMax100ByteParser.cs new file mode 100644 index 0000000..af72d8b --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlMax100ByteParser.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Xml.Linq; +using PG.StarWarsGame.Files.XML.ErrorHandling; + +namespace PG.StarWarsGame.Files.XML.Parsers.Primitives; + +public sealed class PetroglyphXmlMax100ByteParser : PetroglyphXmlPrimitiveElementParser +{ + internal PetroglyphXmlMax100ByteParser(IServiceProvider serviceProvider, IPrimitiveXmlParserErrorListener listener) : base(serviceProvider, listener) + { + + } + + public override byte Parse(XElement element) + { + var intValue = PrimitiveParserProvider.IntParser.Parse(element); + + if (intValue > 100) + intValue = 100; + + var asByte = (byte)intValue; + if (intValue != asByte) + { + var location = XmlLocationInfo.FromElement(element); + + OnParseError(new XmlParseErrorEventArgs(location.XmlFile, element, XmlParseErrorKind.InvalidValue, + $"Expected a byte value (0 - 255) but got value '{intValue}' at {location}")); + } + + // Add additional check, cause the PG implementation is broken, but we need to stay "bug-compatible". + if (asByte > 100) + { + var location = XmlLocationInfo.FromElement(element); + OnParseError(new XmlParseErrorEventArgs(location.XmlFile, element, XmlParseErrorKind.InvalidValue, + $"Expected a byte value (0 - 100) but got value '{asByte}' at {location}")); + } + + return asByte; + } + + protected override void OnParseError(XmlParseErrorEventArgs e) + { + Logger?.LogWarning(e.Message); + base.OnParseError(e); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlStringParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlStringParser.cs index 66253ae..bc8ca42 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlStringParser.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlStringParser.cs @@ -1,14 +1,12 @@ using System; using System.Xml.Linq; +using PG.StarWarsGame.Files.XML.ErrorHandling; namespace PG.StarWarsGame.Files.XML.Parsers.Primitives; -public sealed class PetroglyphXmlStringParser(IServiceProvider serviceProvider) - : PetroglyphXmlElementParser(serviceProvider) +public sealed class PetroglyphXmlStringParser : PetroglyphXmlPrimitiveElementParser { - public static readonly PetroglyphXmlStringParser Instance = new(); - - private PetroglyphXmlStringParser() : this(null!) + internal PetroglyphXmlStringParser(IServiceProvider serviceProvider, IPrimitiveXmlParserErrorListener listener) : base(serviceProvider, listener) { } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlUnsignedIntegerParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlUnsignedIntegerParser.cs new file mode 100644 index 0000000..2206f12 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PetroglyphXmlUnsignedIntegerParser.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Xml.Linq; +using PG.StarWarsGame.Files.XML.ErrorHandling; + +namespace PG.StarWarsGame.Files.XML.Parsers.Primitives; + +public sealed class PetroglyphXmlUnsignedIntegerParser : PetroglyphXmlPrimitiveElementParser +{ + internal PetroglyphXmlUnsignedIntegerParser(IServiceProvider serviceProvider, IPrimitiveXmlParserErrorListener listener) : base(serviceProvider, listener) + { + } + + public override uint Parse(XElement element) + { + var intValue = PrimitiveParserProvider.IntParser.Parse(element); + + var asUint = (uint)intValue; + if (intValue != asUint) + { + var location = XmlLocationInfo.FromElement(element); + + OnParseError(new XmlParseErrorEventArgs(location.XmlFile, element, XmlParseErrorKind.InvalidValue, + $"Expected unsigned integer but got '{intValue}' at {location}")); + } + + return asUint; + } + + protected override void OnParseError(XmlParseErrorEventArgs e) + { + Logger?.LogWarning(e.Message); + base.OnParseError(e); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PrimitiveParserProvider.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PrimitiveParserProvider.cs new file mode 100644 index 0000000..747c83a --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/PrimitiveParserProvider.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading; +using Microsoft.Extensions.DependencyInjection; +using PG.StarWarsGame.Files.XML.ErrorHandling; + +namespace PG.StarWarsGame.Files.XML.Parsers.Primitives; + +internal class PrimitiveParserProvider(IServiceProvider serviceProvider) : IPrimitiveParserProvider +{ + private readonly IPrimitiveXmlParserErrorListener _primitiveParserErrorListener = serviceProvider.GetRequiredService(); + + private PetroglyphXmlStringParser _stringParser = null!; + private PetroglyphXmlUnsignedIntegerParser _uintParser = null!; + private PetroglyphXmlLooseStringListParser _looseStringListParser = null!; + private PetroglyphXmlIntegerParser _intParser = null!; + private PetroglyphXmlFloatParser _floatParser = null!; + private PetroglyphXmlByteParser _byteParser = null!; + private PetroglyphXmlMax100ByteParser _max100ByteParser = null!; + private PetroglyphXmlBooleanParser _booleanParser = null!; + private CommaSeparatedStringKeyValueListParser _commaSeparatedStringKeyValueListParser = null!; + + public PetroglyphXmlStringParser StringParser => + LazyInitializer.EnsureInitialized(ref _stringParser, () => new PetroglyphXmlStringParser(serviceProvider, _primitiveParserErrorListener)); + + public PetroglyphXmlUnsignedIntegerParser UIntParser => + LazyInitializer.EnsureInitialized(ref _uintParser, () => new PetroglyphXmlUnsignedIntegerParser(serviceProvider, _primitiveParserErrorListener)); + + public PetroglyphXmlLooseStringListParser LooseStringListParser => + LazyInitializer.EnsureInitialized(ref _looseStringListParser, () => new PetroglyphXmlLooseStringListParser(serviceProvider, _primitiveParserErrorListener)); + + public PetroglyphXmlIntegerParser IntParser => + LazyInitializer.EnsureInitialized(ref _intParser, () => new PetroglyphXmlIntegerParser(serviceProvider, _primitiveParserErrorListener)); + + public PetroglyphXmlFloatParser FloatParser => + LazyInitializer.EnsureInitialized(ref _floatParser, () => new PetroglyphXmlFloatParser(serviceProvider, _primitiveParserErrorListener)); + + public PetroglyphXmlByteParser ByteParser => + LazyInitializer.EnsureInitialized(ref _byteParser, () => new PetroglyphXmlByteParser(serviceProvider, _primitiveParserErrorListener)); + + public PetroglyphXmlMax100ByteParser Max100ByteParser => + LazyInitializer.EnsureInitialized(ref _max100ByteParser, () => new PetroglyphXmlMax100ByteParser(serviceProvider, _primitiveParserErrorListener)); + + public PetroglyphXmlBooleanParser BooleanParser => + LazyInitializer.EnsureInitialized(ref _booleanParser, () => new PetroglyphXmlBooleanParser(serviceProvider, _primitiveParserErrorListener)); + + public CommaSeparatedStringKeyValueListParser CommaSeparatedStringKeyValueListParser => + LazyInitializer.EnsureInitialized(ref _commaSeparatedStringKeyValueListParser, () => new CommaSeparatedStringKeyValueListParser(serviceProvider, _primitiveParserErrorListener)); +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/XmlFileContainerParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/XmlFileContainerParser.cs index 490ce83..20aeace 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/XmlFileContainerParser.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Primitives/XmlFileContainerParser.cs @@ -1,24 +1,32 @@ using System; -using System.Linq; +using System.Collections.Generic; using System.Xml.Linq; +using PG.Commons.Hashing; +using PG.StarWarsGame.Files.XML.ErrorHandling; namespace PG.StarWarsGame.Files.XML.Parsers.Primitives; -public class XmlFileContainerParser(IServiceProvider serviceProvider) : PetroglyphXmlFileParser(serviceProvider) +public class XmlFileContainerParser(IServiceProvider serviceProvider, IXmlParserErrorListener? listener = null) : + PetroglyphXmlFileParser(serviceProvider, listener) { - protected override IPetroglyphXmlElementParser? GetParser(string tag) + protected override bool LoadLineInfo => false; + + protected override void Parse(XElement element, IValueListDictionary parsedElements) { - if (tag == "File") - return PetroglyphXmlStringParser.Instance; - return null; + throw new NotSupportedException(); } public override XmlFileContainer Parse(XElement element) { - var xmlValues = ToKeyValuePairList(element); - - return xmlValues.TryGetValues("File", out var files) - ? new XmlFileContainer(files.OfType().ToList()) - : new XmlFileContainer([]); + var files = new List(); + foreach (var child in element.Elements()) + { + if (child.Name == "File") + { + var file = PrimitiveParserProvider.StringParser.Parse(child); + files.Add(file); + } + } + return new XmlFileContainer(files); } } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ValueListDictionary.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ValueListDictionary.cs index 19cd60a..73f8aab 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ValueListDictionary.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/ValueListDictionary.cs @@ -1,28 +1,100 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; +using AnakinRaW.CommonUtilities.Collections; namespace PG.StarWarsGame.Files.XML; + +public interface IReadOnlyValueListDictionary : IEnumerable> where TKey : notnull +{ + ICollection Values { get; } + + ICollection Keys { get; } + + int Count { get; } + + TKey this[int index] { get; } + + TValue GetValueAtIndex(int index); + + bool ContainsKey(TKey key); + + ReadOnlyFrugalList GetValues(TKey key); + + TValue GetLastValue(TKey key); + + TValue GetFirstValue(TKey key); + + bool TryGetFirstValue(TKey key, [NotNullWhen(true)] out TValue value); + + bool TryGetLastValue(TKey key, [NotNullWhen(true)] out TValue value); + + bool TryGetValues(TKey key, out ReadOnlyFrugalList values); +} + +public interface IValueListDictionary : IReadOnlyValueListDictionary where TKey : notnull +{ + bool Add(TKey key, TValue value); +} + // NOT THREAD-SAFE! -public class ValueListDictionary where TKey : notnull +public class ValueListDictionary : IValueListDictionary where TKey : notnull { - private readonly Dictionary _singleValueDictionary = new (); - private readonly Dictionary> _multiValueDictionary = new(); + private readonly List _insertionTrackingList = new(); + private readonly Dictionary _singleValueDictionary = new (); + private readonly Dictionary> _multiValueDictionary = new(); + + private readonly EqualityComparer _equalityComparer = EqualityComparer.Default; + + public int Count => _insertionTrackingList.Count; + + public TKey this[int index] => _insertionTrackingList[index]; + + public ICollection Keys => _singleValueDictionary.Keys.Concat(_multiValueDictionary.Keys).ToList(); + + public ICollection Values => this.Select(x => x.Value).ToList(); + + public TValue GetValueAtIndex(int index) + { + if (index < 0 || index >= Count) + throw new ArgumentOutOfRangeException(nameof(index)); + var key = this[index]; + if (_singleValueDictionary.TryGetValue(key, out var value)) + return value; + + if (index == 0) + return _multiValueDictionary[key].First(); + + if (index == Count - 1) + return _multiValueDictionary[key].Last(); + + var keyCount = 0; + foreach (var k in _insertionTrackingList.Take(index + 1)) + { + if (_equalityComparer.Equals(key, k)) + keyCount++; + } + + return _multiValueDictionary[key][keyCount - 1]; + } public bool ContainsKey(TKey key) { return _singleValueDictionary.ContainsKey(key) || _multiValueDictionary.ContainsKey(key); } - - public bool Add(TKey key, TValue? value) + + public bool Add(TKey key, TValue value) { - if (key == null) + if (key is null) throw new ArgumentNullException(nameof(key)); + _insertionTrackingList.Add(key); + if (!_singleValueDictionary.ContainsKey(key)) { if (!_multiValueDictionary.TryGetValue(key, out var list)) @@ -48,7 +120,7 @@ public bool Add(TKey key, TValue? value) return true; } - public TValue? GetLastValue(TKey key) + public TValue GetLastValue(TKey key) { if (_singleValueDictionary.TryGetValue(key, out var value)) return value; @@ -59,66 +131,153 @@ public bool Add(TKey key, TValue? value) throw new KeyNotFoundException($"The key '{key}' was not found."); } - public T? GetLastValue(TKey key) where T : TValue + public TValue GetFirstValue(TKey key) { - var value = GetLastValue(key); - if (value is null) - return default; - return (T)value; + if (_singleValueDictionary.TryGetValue(key, out var value)) + return value; + + if (_multiValueDictionary.TryGetValue(key, out var valueList)) + return valueList.First(); + + throw new KeyNotFoundException($"The key '{key}' was not found."); } + + public ReadOnlyFrugalList GetValues(TKey key) + { + if (TryGetValues(key, out var values)) + return values; + throw new KeyNotFoundException($"The key '{key}' was not found."); + + } - public IList GetValues(TKey key) + public bool TryGetFirstValue(TKey key, [NotNullWhen(true)] out TValue value) { - if (!TryGetValues(key, out var values)) - throw new KeyNotFoundException($"The key '{key}' was not found."); - return values; + if (_singleValueDictionary.TryGetValue(key, out value!)) + return true; + + if (_multiValueDictionary.TryGetValue(key, out var valueList)) + { + value = valueList.First()!; + return true; + } + + return false; } - public bool TryGetLastValue(TKey key, [NotNullWhen(true)] out TValue? value) + public bool TryGetLastValue(TKey key, [NotNullWhen(true)] out TValue value) { if (_singleValueDictionary.TryGetValue(key, out value!)) return true; if (_multiValueDictionary.TryGetValue(key, out var valueList)) { - value = valueList.Last()!; + value = valueList.Last()!; return true; } return false; } - public bool TryGetValues(TKey key, [NotNullWhen(true)] out IList? values) + public bool TryGetValues(TKey key, out ReadOnlyFrugalList values) { if (_singleValueDictionary.TryGetValue(key, out var value)) { - values = new List(1) - { - value - }; + values = new ReadOnlyFrugalList(value); return true; } if (_multiValueDictionary.TryGetValue(key, out var valueList)) { - values = valueList; + values = new ReadOnlyFrugalList(valueList); return true; } - values = null; + values = ReadOnlyFrugalList.Empty; return false; } - public IEnumerable AggregateValues(ISet keys, Predicate filter, bool multipleValuesPerKey = false) where T : TValue + public IEnumerator> GetEnumerator() + { + return new Enumerator(this); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public struct Enumerator : IEnumerator> + { + private Dictionary.Enumerator _singleEnumerator; + private Dictionary>.Enumerator _multiEnumerator; + private List.Enumerator _currentListEnumerator = default; + private bool _isMultiEnumeratorActive = false; + + internal Enumerator(ValueListDictionary valueListDictionary) + { + _singleEnumerator = valueListDictionary._singleValueDictionary.GetEnumerator(); + _multiEnumerator = valueListDictionary._multiValueDictionary.GetEnumerator(); + } + + public KeyValuePair Current => + _isMultiEnumeratorActive + ? new KeyValuePair(_multiEnumerator.Current.Key, _currentListEnumerator.Current) + : _singleEnumerator.Current; + + object IEnumerator.Current => Current; + + public bool MoveNext() + { + if (_singleEnumerator.MoveNext()) + return true; + + if (_isMultiEnumeratorActive) + { + if (_currentListEnumerator.MoveNext()) + return true; + _isMultiEnumeratorActive = false; + } + + if (_multiEnumerator.MoveNext()) + { + _currentListEnumerator = _multiEnumerator.Current.Value.GetEnumerator(); + _isMultiEnumeratorActive = true; + return _currentListEnumerator.MoveNext(); + } + + return false; + } + + public void Reset() + { + throw new NotSupportedException(); + } + + public void Dispose() + { + _singleEnumerator.Dispose(); + _multiEnumerator.Dispose(); + } + } +} + +public static class ValueListDictionaryExtensions +{ + public static IEnumerable AggregateValues( + this IReadOnlyValueListDictionary valueListDictionary, + ISet keys, Predicate filter, + AggregateStrategy aggregateStrategy) + where TKey : notnull + where T : TValue { foreach (var key in keys) { - if (!ContainsKey(key)) + if (!valueListDictionary.ContainsKey(key)) continue; - if (multipleValuesPerKey) + if (aggregateStrategy == AggregateStrategy.MultipleValuesPerKey) { - foreach (var value in GetValues(key)) + foreach (var value in valueListDictionary.GetValues(key)) { if (value is not null) { @@ -126,12 +285,14 @@ public IEnumerable AggregateValues(ISet keys, Predicate filter, b if (filter(typedValue)) yield return typedValue; } - + } } else { - var value = GetLastValue(key); + var value = aggregateStrategy == AggregateStrategy.FirstValuePerKey + ? valueListDictionary.GetFirstValue(key) + : valueListDictionary.GetLastValue(key); if (value is not null) { var typedValue = (T)value; @@ -141,4 +302,11 @@ public IEnumerable AggregateValues(ISet keys, Predicate filter, b } } } + + public enum AggregateStrategy + { + FirstValuePerKey, + LastValuePerKey, + MultipleValuesPerKey, + } } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/XmlLocationInfo.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/XmlLocationInfo.cs index bcafd6f..e92cc63 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/XmlLocationInfo.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/XmlLocationInfo.cs @@ -7,11 +7,10 @@ public readonly struct XmlLocationInfo(string xmlFile, int? line) { public bool HasLocation => !string.IsNullOrEmpty(XmlFile) && Line is not null; - public string? XmlFile { get; } = xmlFile; + public string XmlFile { get; } = xmlFile; public int? Line { get; } = line; - public static XmlLocationInfo FromElement(XElement element) { if (element.Document is null) @@ -21,11 +20,10 @@ public static XmlLocationInfo FromElement(XElement element) return new XmlLocationInfo(element.Document.BaseUri, null); } - public override string ToString() { if (string.IsNullOrEmpty(XmlFile)) - return "No File information"; - return Line is null ? XmlFile! : $"{XmlFile} at line: {Line}"; + return "(n/a)"; + return Line is null ? XmlFile : $"{XmlFile} at line: {Line}"; } } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/XmlServiceContribution.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/XmlServiceContribution.cs new file mode 100644 index 0000000..6fb18d4 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/XmlServiceContribution.cs @@ -0,0 +1,18 @@ +// Copyright (c) Alamo Engine Tools and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using Microsoft.Extensions.DependencyInjection; +using PG.StarWarsGame.Files.XML.ErrorHandling; +using PG.StarWarsGame.Files.XML.Parsers.Primitives; + +namespace PG.StarWarsGame.Files.XML; + +public static class XmlServiceContribution +{ + public static void ContributeServices(IServiceCollection serviceCollection) + { + serviceCollection.AddSingleton(_ => new PrimitiveXmlParserErrorBroker()); + serviceCollection.AddSingleton(sp => sp.GetRequiredService()); + serviceCollection.AddSingleton(sp => new PrimitiveParserProvider(sp)); + } +} \ No newline at end of file