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